Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,369 changes: 1,262 additions & 107 deletions Plugins/Screenshot/Resources/Localizable.xcstrings

Large diffs are not rendered by default.

206 changes: 158 additions & 48 deletions Plugins/Screenshot/Sources/AnnotationRenderer.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import AppKit
import CoreImage

enum Shape {
case rect(NSRect)
Expand All @@ -11,12 +10,13 @@ enum Shape {
case tag(Int, at: NSPoint)
case redaction(NSRect)
case mosaic(NSRect)
case blur(NSRect)
case spotlight(NSRect)
}

struct Stroke {
var color: NSColor
var width: CGFloat
var filled = false

/// Stroke presets also select text sizes, matching the annotation toolbar.
var fontSize: CGFloat {
Expand All @@ -32,9 +32,44 @@ struct Item {
let shape: Shape
let stroke: Stroke

var editableRect: NSRect? {
switch shape {
case .rect(let rect), .ellipse(let rect), .spotlight(let rect): return rect
default: return nil
}
}

func replacingEditableRect(_ rect: NSRect) -> Item? {
switch shape {
case .rect: return Item(shape: .rect(rect), stroke: stroke)
case .ellipse: return Item(shape: .ellipse(rect), stroke: stroke)
case .spotlight: return Item(shape: .spotlight(rect), stroke: stroke)
default: return nil
}
}

static func qrMask(_ rect: NSRect) -> Item {
Item(shape: .redaction(rect.insetBy(dx: -6, dy: -6)), stroke: Stroke(color: .black, width: 0))
}

var supportsFill: Bool {
switch shape {
case .rect, .ellipse: return true
default: return false
}
}

/// Opaque covers draw after source-sampling effects so nothing beneath them can show through.
var isMask: Bool {
if case .redaction = shape { return true }
return supportsFill && stroke.filled
}

/// Ellipses hit-test their outline path; other editable shapes use their bounds.
func contains(_ point: NSPoint) -> Bool {
if case .ellipse(let rect) = shape { return NSBezierPath(ovalIn: rect).contains(point) }
return editableRect?.contains(point) == true
}
}

/// Draw annotations and export the frozen image without depending on overlay interaction state.
Expand All @@ -44,8 +79,10 @@ final class AnnotationRenderer {
private let scale: CGFloat
private let bounds: NSRect
private let frozen: NSImage
private lazy var ciContext = CIContext()
private var blurCache: [String: CGImage] = [:]
private var mosaicCache: [String: CGImage] = [:]
/// One random seed per capture keeps preview and export identical while varying between captures.
private let mosaicSeed = UInt64.random(in: .min ... .max)
private static let mosaicBlock: CGFloat = 14

init(image: CGImage, scale: CGFloat, size: NSSize) {
cgImage = image
Expand All @@ -54,7 +91,7 @@ final class AnnotationRenderer {
frozen = NSImage(cgImage: image, size: size)
}

func clearCache() { blurCache.removeAll() }
func clearCache() { mosaicCache.removeAll() }

static func shadowMargin(for shadowSize: CGFloat) -> CGFloat {
shadowSize > 0 ? (shadowSize * 2 + shadowOffset(for: shadowSize)).rounded(.up) : 0
Expand All @@ -80,26 +117,28 @@ final class AnnotationRenderer {
}

func drawItems(_ items: [Item], draft: Item? = nil, selection: NSRect) {
for item in items {
if case .redaction = item.shape { continue }
for item in items where !item.isMask {
draw(item, cache: true, selection: selection)
}
// Draft geometry changes every frame, so it must not grow the blur cache.
if let draft { draw(draft, cache: false, selection: selection) }
// Blur and mosaic sample the frozen source; keep masks above those effects.
for item in items {
if case .redaction = item.shape { draw(item, cache: true, selection: selection) }
// Draft geometry changes every frame, so it must not grow the mosaic cache.
if let draft, !draft.isMask { draw(draft, cache: false, selection: selection) }
drawSpotlights(items + (draft.map { [$0] } ?? []), selection: selection)
// Mosaic samples the frozen source; keep masks above it.
for item in items where item.isMask {
draw(item, cache: true, selection: selection)
}
if let draft, draft.isMask { draw(draft, cache: false, selection: selection) }
}

private func draw(_ item: Item, cache: Bool, selection: NSRect) {
item.stroke.color.set()
let width = item.stroke.width
switch item.shape {
case .rect(let r):
stroke(NSBezierPath(rect: r), width: width)
drawClosedShape(NSBezierPath(rect: item.stroke.filled ? deviceAligned(r) : r),
style: item.stroke, selection: selection)
case .ellipse(let r):
stroke(NSBezierPath(ovalIn: r), width: width)
drawClosedShape(NSBezierPath(ovalIn: r), style: item.stroke, selection: selection)
case .line(let from, let to):
let path = NSBezierPath()
path.move(to: from)
Expand Down Expand Up @@ -144,56 +183,127 @@ final class AnnotationRenderer {
context.saveGState()
context.setBlendMode(.copy)
context.setFillColor(NSColor.black.cgColor)
context.fill(r.intersection(selection).intersection(bounds))
context.fill(deviceAligned(r).intersection(selection).intersection(bounds))
context.restoreGState()
case .mosaic(let r):
drawMosaic(in: r, selection: selection)
case .blur(let r):
drawBlur(in: r, cache: cache, selection: selection)
drawMosaic(in: r, cache: cache, selection: selection)
case .spotlight:
break // All spotlight regions share one dimming layer.
}
}

/// Expands a cover to whole device pixels. A half-pixel edge would blend the source beneath it.
private func deviceAligned(_ rect: NSRect) -> NSRect {
guard let cg = NSGraphicsContext.current?.cgContext else { return rect }
return cg.convertToUserSpace(cg.convertToDeviceSpace(rect).integral)
}

private func drawClosedShape(_ path: NSBezierPath, style: Stroke, selection: NSRect) {
guard style.filled else { stroke(path, width: style.width); return }
NSGraphicsContext.saveGraphicsState()
defer { NSGraphicsContext.restoreGraphicsState() }
NSBezierPath(rect: selection.intersection(bounds)).addClip()
style.color.withAlphaComponent(1).setFill()
path.fill()
}

private func drawSpotlights(_ items: [Item], selection: NSRect) {
let regions = items.compactMap { item -> NSRect? in
guard case .spotlight(let rect) = item.shape else { return nil }
let clipped = rect.intersection(selection).intersection(bounds)
return clipped.width >= 2 && clipped.height >= 2 ? clipped : nil
}
guard !regions.isEmpty else { return }
NSGraphicsContext.saveGraphicsState()
defer { NSGraphicsContext.restoreGraphicsState() }
// Intersect the outside of each region so overlapping highlights stay clear.
for rect in regions {
let mask = NSBezierPath(rect: selection.intersection(bounds))
let radius = min(8, min(rect.width, rect.height) / 2)
mask.append(NSBezierPath(roundedRect: rect, xRadius: radius, yRadius: radius))
mask.windingRule = .evenOdd
mask.addClip()
}
NSColor.black.withAlphaComponent(0.5).setFill()
NSBezierPath(rect: selection.intersection(bounds)).fill()
}

private func stroke(_ path: NSBezierPath, width: CGFloat) {
path.lineWidth = width
path.stroke()
}

private func drawMosaic(in rect: NSRect, selection: NSRect) {
guard let (sub, r) = crop(rect, selection: selection), let cg = NSGraphicsContext.current?.cgContext else { return }
let block: CGFloat = 10
let w = max(1, Int(r.width / block)), h = max(1, Int(r.height / block))
guard let tiny = CGContext(data: nil, width: w, height: h, bitsPerComponent: 8, bytesPerRow: 0,
space: CGColorSpaceCreateDeviceRGB(),
bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue)
else { return }
tiny.interpolationQuality = .high
tiny.draw(sub, in: CGRect(x: 0, y: 0, width: w, height: h))
guard let small = tiny.makeImage() else { return }

/// Pixelation keeps only a quantized, seeded-jitter average per block. Exact block averages
/// can be matched against rendered candidates (as Depix does), so blocks stay coarse and noisy.
private func drawMosaic(in rect: NSRect, cache: Bool, selection: NSRect) {
guard let cg = NSGraphicsContext.current?.cgContext,
let (sub, r) = crop(deviceAligned(rect), selection: selection) else { return }
let key = "\(r)"
let image: CGImage
if let hit = mosaicCache[key] {
image = hit
} else {
guard let made = mosaicImage(from: sub, size: r.size) else { return }
if cache { mosaicCache[key] = made }
image = made
}
cg.saveGState()
cg.interpolationQuality = .none
cg.draw(small, in: r)
cg.draw(image, in: r)
cg.restoreGState()
}

private func drawBlur(in rect: NSRect, cache: Bool, selection: NSRect) {
guard let (sub, r) = crop(rect, selection: selection), let cg = NSGraphicsContext.current?.cgContext else { return }
let key = "\(r)"
let out: CGImage
if let hit = blurCache[key] {
out = hit
} else {
let extent = CGRect(x: 0, y: 0, width: sub.width, height: sub.height)

// Extend edge pixels before blurring to avoid a dark border.
let blurred = CIImage(cgImage: sub).clampedToExtent()
.applyingGaussianBlur(sigma: 6 * scale)
.cropped(to: extent)
guard let made = ciContext.createCGImage(blurred, from: extent) else { return }
out = made
if cache { blurCache[key] = made }
private func mosaicImage(from source: CGImage, size: NSSize) -> CGImage? {
let cols = max(1, Int((size.width / Self.mosaicBlock).rounded()))
let rows = max(1, Int((size.height / Self.mosaicBlock).rounded()))
// Downsample to k×k samples per block, then average them: close to an area average
// without visiting every source pixel of a large region.
let k = 4, w = cols * k, h = rows * k
let space = CGColorSpaceCreateDeviceRGB()
let info = CGImageAlphaInfo.premultipliedLast.rawValue
var samples = [UInt8](repeating: 0, count: w * h * 4)
let drawn = samples.withUnsafeMutableBytes { buffer -> Bool in
guard let context = CGContext(data: buffer.baseAddress, width: w, height: h, bitsPerComponent: 8,
bytesPerRow: w * 4, space: space, bitmapInfo: info) else { return false }
context.interpolationQuality = .high
context.draw(source, in: CGRect(x: 0, y: 0, width: w, height: h))
return true
}
cg.draw(out, in: r)
guard drawn else { return nil }

// Alpha stays 255: a mosaic must never let the source show through.
var blocks = [UInt8](repeating: 255, count: cols * rows * 4)
for by in 0..<rows {
for bx in 0..<cols {
var sum = (0, 0, 0)
for y in by * k..<(by + 1) * k {
for x in bx * k..<(bx + 1) * k {
let i = (y * w + x) * 4
sum.0 += Int(samples[i]); sum.1 += Int(samples[i + 1]); sum.2 += Int(samples[i + 2])
}
}
let jitter = mosaicNoise(bx, by)
let o = (by * cols + bx) * 4
for (c, total) in [sum.0, sum.1, sum.2].enumerated() {
let value = total / (k * k) + jitter
blocks[o + c] = UInt8(clamping: value / 8 * 8 + 4)
}
}
}
return blocks.withUnsafeMutableBytes { buffer in
CGContext(data: buffer.baseAddress, width: cols, height: rows, bitsPerComponent: 8,
bytesPerRow: cols * 4, space: space, bitmapInfo: info)?.makeImage()
}
}

/// Deterministic ±12 luminance jitter from the capture seed and block position (SplitMix64).
private func mosaicNoise(_ x: Int, _ y: Int) -> Int {
var z = mosaicSeed &+ UInt64(truncatingIfNeeded: x) &* 0x9E37_79B9_7F4A_7C15
&+ UInt64(truncatingIfNeeded: y) &* 0xD1B5_4A32_D192_ED03
z = (z ^ (z >> 30)) &* 0xBF58_476D_1CE4_E5B9
z = (z ^ (z >> 27)) &* 0x94D0_49BB_1331_11EB
z ^= z >> 31
return Int(z % 25) - 12
}

/// Convert bottom-left view points to top-left frozen-image pixels.
Expand Down
42 changes: 40 additions & 2 deletions Plugins/Screenshot/Sources/CaptureController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,25 @@ final class CaptureController {
private func configure(_ overlay: OverlayWindow, display: CaptureDisplay) {
overlay.onComplete = { [weak self] png, mode in
guard let self, !finished else { return }
dismiss()
ScreenshotOutput.save(png, mode: mode, environment: environment)
guard mode == .ask else {
dismiss()
ScreenshotOutput.save(png, mode: mode, environment: environment)
return
}
// Keep the selection and annotations until the file is written; cancelling or a write
// failure returns to them.
suspend()
let chosen = ScreenshotOutput.saveAs(png, suggestedName: ScreenshotOutput.suggestedScreenshotName(environment),
environment: environment) { [weak self] written in
guard let self, !finished else { return }
if written {
ScreenshotOutput.copyToPasteboard(png)
dismiss()
} else {
resume()
}
}
if !chosen, !finished { resume() }
}
overlay.onCancel = { [weak self] in self?.dismiss() }
overlay.onRecord = { [weak self] rect in
Expand All @@ -114,6 +131,27 @@ final class CaptureController {
}
}

/// Hides the capture surfaces without discarding their session state.
private func suspend() {
pointerTracker?.stop()
for overlay in overlays { overlay.orderOut(nil) }
if cursorPushed {
NSCursor.pop()
cursorPushed = false
}
}

private func resume() {
NSCursor.crosshair.push()
cursorPushed = true
PluginPresentationSafety.prepareForWindowOrdering()
for overlay in overlays { overlay.orderFrontRegardless() }
let tracker = CapturePointerTracker(overlays: overlays)
pointerTracker = tracker
// Keyboard focus follows the pointer's display, as when capture first appears.
tracker.update()
}

func dismiss() {
guard !finished else { return }
finished = true
Expand Down
2 changes: 2 additions & 0 deletions Plugins/Screenshot/Sources/CapturePipeline.swift
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,8 @@ enum CapturePipeline {
config.shouldBeOpaque = true
config.pixelFormat = kCVPixelFormatType_32BGRA
config.scalesToFit = false
// The default also strips shadows and framing from menus and popovers in display captures.
config.ignoreShadowsSingleWindow = false
return config
}

Expand Down
8 changes: 8 additions & 0 deletions Plugins/Screenshot/Sources/CaptureStatusPanel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ final class CaptureStatusPanel: NSPanel {
var onCancel: (() -> Void)?
private let label = NSTextField(labelWithString: "00:00")
private let primary = NSButton()
private var indicator: NSImageView?

init(primaryTitle: String, cancelTitle: String? = nil, indicatorColor: NSColor? = nil) {
super.init(contentRect: NSRect(x: 0, y: 0, width: 10, height: 10),
Expand All @@ -23,6 +24,7 @@ final class CaptureStatusPanel: NSPanel {
var views: [NSView] = []
if let indicatorColor, let image = NSImage(systemSymbolName: "circle.fill", accessibilityDescription: nil) {
let indicator = NSImageView(image: image)
self.indicator = indicator
indicator.contentTintColor = indicatorColor
indicator.widthAnchor.constraint(equalToConstant: 10).isActive = true
indicator.heightAnchor.constraint(equalToConstant: 10).isActive = true
Expand Down Expand Up @@ -59,6 +61,12 @@ final class CaptureStatusPanel: NSPanel {
}
}

/// Reuses the visible panel for a follow-up phase, such as processing a finished recording.
func setPrimary(title: String, indicatorColor: NSColor? = nil) {
primary.title = title
if let indicatorColor { indicator?.contentTintColor = indicatorColor }
}

func show(near region: CGRect, displayID: CGDirectDisplayID) {
guard let screen = NSScreen.screens.first(where: {
($0.deviceDescription[NSDeviceDescriptionKey("NSScreenNumber")] as? NSNumber)?.uint32Value == displayID
Expand Down
Loading
Loading