From b64a6a95c5799b8ab3529080210314d5cfa57117 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=A7=82=E6=BE=9C=E8=AF=9D=E4=B8=8D=E5=A4=9A?= <52843059+xysamuel@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:56:54 +0800 Subject: [PATCH] feat(mosaic): support multiple mosaic styles (pixel, blur, frosted, solid) --- Sources/Snapok/BackgroundEditor.swift | 18 ++- Sources/Snapok/EditorCanvas.swift | 12 +- Sources/Snapok/HistoryStore.swift | 7 +- Sources/Snapok/MosaicStyle.swift | 124 +++++++++++++++ Sources/Snapok/SnapokApp.swift | 115 ++++++++++---- Tests/SnapokTests/MosaicStyleTests.swift | 192 +++++++++++++++++++++++ scripts/test-mosaic.sh | 14 ++ 7 files changed, 441 insertions(+), 41 deletions(-) create mode 100644 Sources/Snapok/MosaicStyle.swift create mode 100644 Tests/SnapokTests/MosaicStyleTests.swift create mode 100755 scripts/test-mosaic.sh diff --git a/Sources/Snapok/BackgroundEditor.swift b/Sources/Snapok/BackgroundEditor.swift index 47bab57..c1e320e 100644 --- a/Sources/Snapok/BackgroundEditor.swift +++ b/Sources/Snapok/BackgroundEditor.swift @@ -41,6 +41,7 @@ final class BackgroundEditorController: NSWindowController, NSWindowDelegate, NS private var toolButtons: [NSButton] = [] private let strokePicker = NSPopUpButton() private let stepStylePicker = NSPopUpButton() + private let mosaicStylePicker = NSPopUpButton() private let numberRect = NSButton(checkboxWithTitle: L("Number boxes", "画框自动编号"), target: nil, action: nil) private let textControls = AnnotationTextControls() private let strokeControls = NSStackView() @@ -311,7 +312,12 @@ final class BackgroundEditorController: NSWindowController, NSWindowDelegate, NS stepStylePicker.setAccessibilityLabel(L("Number style", "编号样式")) numberRect.target = self numberRect.action = #selector(changeNumberRect) - let numberOptions = NSStackView(views: [numberRect, stepStylePicker]) + mosaicStylePicker.addItems(withTitles: MosaicStyle.allCases.map(\.title)) + mosaicStylePicker.target = self + mosaicStylePicker.action = #selector(changeMosaicStyle) + mosaicStylePicker.setAccessibilityLabel(L("Mosaic style", "马赛克样式")) + mosaicStylePicker.toolTip = L("Box styles: drag out a rectangle · Brush: paint freely", "框选样式:拖出一个矩形 · 涂抹:按住随意涂") + let numberOptions = NSStackView(views: [mosaicStylePicker, numberRect, stepStylePicker]) numberOptions.spacing = 10 let options = NSStackView(views: [strokeControls, textControls, label(L("Color", "颜色"), size: 11), annotationColor]) options.spacing = 10 @@ -384,6 +390,12 @@ final class BackgroundEditorController: NSWindowController, NSWindowDelegate, NS preview.stepStyle = StepMarkerStyle.allCases[stepStylePicker.indexOfSelectedItem] preview.applyStyle() } + @objc private func changeMosaicStyle() { + let style = MosaicStyle.allCases[max(0, mosaicStylePicker.indexOfSelectedItem)] + preview.mosaicStyle = style + MosaicStyle.current = style + preview.applyStyle() + } @objc private func changeNumberRect() { preview.setNumberRect(numberRect.state == .on) } @@ -397,7 +409,9 @@ final class BackgroundEditorController: NSWindowController, NSWindowDelegate, NS stepStylePicker.isHidden = !preview.usesStepStyle numberRect.isHidden = !preview.usesRectStyle numberRect.state = preview.autoNumberRect ? .on : .off - toolbarHeight?.constant = preview.usesStepStyle || preview.usesRectStyle ? 154 : 126 + mosaicStylePicker.isHidden = !preview.usesMosaicStyle + mosaicStylePicker.selectItem(at: MosaicStyle.allCases.firstIndex(of: preview.mosaicStyle) ?? 0) + toolbarHeight?.constant = preview.usesStepStyle || preview.usesRectStyle || preview.usesMosaicStyle ? 154 : 126 stepStylePicker.selectItem(at: StepMarkerStyle.allCases.firstIndex(of: preview.stepStyle) ?? 0) textControls.update(preview.textStyle, pixelScale: preview.pixelScale) for (index, button) in toolButtons.enumerated() { diff --git a/Sources/Snapok/EditorCanvas.swift b/Sources/Snapok/EditorCanvas.swift index 8f4a3e7..ee2cdd3 100644 --- a/Sources/Snapok/EditorCanvas.swift +++ b/Sources/Snapok/EditorCanvas.swift @@ -17,6 +17,8 @@ final class BackgroundPreview: NSView, NSTextFieldDelegate { || selectedIndex.map { annotations[$0].kind == .step || annotations[$0].stepNumber != nil } == true } var selectedColor: NSColor = Style.colors[0] + var mosaicStyle = MosaicStyle.current + var usesMosaicStyle: Bool { selectedTool == .mosaic || selectedIndex.map { annotations[$0].kind == .mosaic } == true } var textStyle = AnnotationTextStyle() var usesTextStyle: Bool { selectedTool == .text || selectedIndex.map { annotations[$0].kind == .text } == true } var sizeLevel = 1 { didSet { if oldValue != sizeLevel { textStyle.pointSize = Style.lineWidth(for: .text, level: sizeLevel) } } } @@ -182,6 +184,7 @@ final class BackgroundPreview: NSView, NSTextFieldDelegate { record() annotations[index].color = selectedColor annotations[index].sizeLevel = sizeLevel + if annotations[index].kind == .mosaic { annotations[index].restyleMosaic(mosaicStyle) } if annotations[index].kind == .step || annotations[index].stepNumber != nil { annotations[index].stepStyle = stepStyle if annotations[index].kind == .step { StepMarkers.resize(&annotations[index]) } @@ -229,6 +232,7 @@ final class BackgroundPreview: NSView, NSTextFieldDelegate { stepStyle = annotations[hit].stepStyle ?? .filledCircle } if annotations[hit].kind == .text { textStyle = annotations[hit].effectiveTextStyle } + if annotations[hit].kind == .mosaic { mosaicStyle = annotations[hit].effectiveMosaicStyle } if annotations[hit].kind == .text, event.clickCount == 2 { editText(at: point, index: hit) } else { record(); lastPoint = point } @@ -246,6 +250,7 @@ final class BackgroundPreview: NSView, NSTextFieldDelegate { if tool == .text { editText(at: point, index: nil); return } start = point draft = Annotation(kind: tool, rect: CGRect(origin: point, size: .zero), points: [point, point], color: selectedColor, sizeLevel: sizeLevel) + if tool == .mosaic { draft?.mosaicStyle = mosaicStyle } changed() } @@ -256,18 +261,19 @@ final class BackgroundPreview: NSView, NSTextFieldDelegate { annotations[index] = annotations[index].offsetBy(dx: point.x - lastPoint.x, dy: point.y - lastPoint.y) self.lastPoint = point } else if let start, var draft { - if draft.kind == .pen || draft.kind == .mosaic { draft.points.append(point) } + let isStroke = draft.kind == .pen || (draft.kind == .mosaic && !draft.isBoxMosaic) + if isStroke { draft.points.append(point) } else { draft.points = [start, point] } let xs = draft.points.map(\.x), ys = draft.points.map(\.y) draft.rect = CGRect(x: xs.min()!, y: ys.min()!, width: xs.max()! - xs.min()!, height: ys.max()! - ys.min()!) - if draft.kind == .pen || draft.kind == .mosaic { draft.rect = draft.rect.insetBy(dx: -draft.lineWidth / 2, dy: -draft.lineWidth / 2) } + if isStroke { draft.rect = draft.rect.insetBy(dx: -draft.lineWidth / 2, dy: -draft.lineWidth / 2) } self.draft = draft } changed() } override func mouseUp(with event: NSEvent) { - if var draft, draft.rect.width + draft.rect.height >= 2 { + if var draft, draft.isBoxMosaic ? draft.rect.width >= 3 && draft.rect.height >= 3 : draft.rect.width + draft.rect.height >= 2 { if draft.kind == .rect, autoNumberRect { draft.stepNumber = String(StepMarkers.nextNumber(in: annotations)) draft.stepStyle = stepStyle diff --git a/Sources/Snapok/HistoryStore.swift b/Sources/Snapok/HistoryStore.swift index a750c53..5d962fb 100644 --- a/Sources/Snapok/HistoryStore.swift +++ b/Sources/Snapok/HistoryStore.swift @@ -77,7 +77,7 @@ enum Keychain { extension Annotation: Codable { private enum CodingKeys: String, CodingKey { - case kind, rect, points, text, color, sizeLevel, textStyle, stepStyle, stepNumber + case kind, rect, points, text, color, sizeLevel, textStyle, stepStyle, stepNumber, mosaicStyle } init(from decoder: Decoder) throws { @@ -92,7 +92,9 @@ extension Annotation: Codable { sizeLevel: try container.decode(Int.self, forKey: .sizeLevel), textStyle: try container.decodeIfPresent(AnnotationTextStyle.self, forKey: .textStyle), stepStyle: try container.decodeIfPresent(StepMarkerStyle.self, forKey: .stepStyle), - stepNumber: try container.decodeIfPresent(String.self, forKey: .stepNumber) + stepNumber: try container.decodeIfPresent(String.self, forKey: .stepNumber), + // Unknown styles (from a newer version) fall back to the brush rather than failing the whole item. + mosaicStyle: (try? container.decodeIfPresent(MosaicStyle.self, forKey: .mosaicStyle)) ?? nil ) } @@ -108,6 +110,7 @@ extension Annotation: Codable { try container.encodeIfPresent(textStyle, forKey: .textStyle) try container.encodeIfPresent(stepStyle, forKey: .stepStyle) try container.encodeIfPresent(stepNumber, forKey: .stepNumber) + try container.encodeIfPresent(mosaicStyle, forKey: .mosaicStyle) } } diff --git a/Sources/Snapok/MosaicStyle.swift b/Sources/Snapok/MosaicStyle.swift new file mode 100644 index 0000000..81cb653 --- /dev/null +++ b/Sources/Snapok/MosaicStyle.swift @@ -0,0 +1,124 @@ +import AppKit +import CoreImage + +/// How a mosaic annotation hides what is underneath. +/// `brush` follows the pointer like a marker; the other styles cover a dragged rectangle. +enum MosaicStyle: String, CaseIterable, Codable, Sendable { + case pixelBox, blurBox, frostedBox, solidBox, brush + + var title: String { + switch self { + case .pixelBox: return L("Box · Pixelate", "框选 · 像素马赛克") + case .blurBox: return L("Box · Blur", "框选 · 高斯模糊") + case .frostedBox: return L("Box · Frosted Glass", "框选 · 毛玻璃") + case .solidBox: return L("Box · Solid Color", "框选 · 色块遮挡") + case .brush: return L("Brush · Pixelate", "涂抹 · 像素马赛克") + } + } + + /// Box styles are drawn by dragging out a rectangle instead of painting a stroke. + var isBox: Bool { self != .brush } + + /// Pixel block edge, in image points, for each size level. + nonisolated static func blockSize(level: Int) -> CGFloat { + [8, 13, 20][min(max(level, 0), 2)] + } + + /// Gaussian blur radius (sigma), in image points, for each size level. + nonisolated static func blurRadius(_ style: MosaicStyle, level: Int) -> CGFloat { + let level = min(max(level, 0), 2) + return style == .frostedBox ? [10, 16, 24][level] : [5, 9, 15][level] + } + + /// The style picked last time, so the choice survives between captures. + static var current: MosaicStyle { + get { UserDefaults.standard.string(forKey: "editor.mosaicStyle").flatMap(MosaicStyle.init(rawValue:)) ?? .pixelBox } + set { UserDefaults.standard.set(newValue.rawValue, forKey: "editor.mosaicStyle") } + } +} + +extension Annotation { + /// Older annotations (and automatic redaction strokes) carry no style and stay brush strokes. + var effectiveMosaicStyle: MosaicStyle { mosaicStyle ?? .brush } + var isBoxMosaic: Bool { kind == .mosaic && effectiveMosaicStyle.isBox } + + /// Switches a selected mosaic to another style, keeping the area it covers. + /// A brush stroke becomes a box over its bounds; a box becomes a zigzag brush stroke over the box. + @MainActor + mutating func restyleMosaic(_ style: MosaicStyle) { + guard kind == .mosaic, style != effectiveMosaicStyle else { return } + if !style.isBox { + let stroke = SensitiveDetector.mosaic(covering: rect.insetBy(dx: 3, dy: 3)) + points = stroke.points + rect = stroke.rect + sizeLevel = stroke.sizeLevel + } else if !effectiveMosaicStyle.isBox { + points = [CGPoint(x: rect.minX, y: rect.minY), CGPoint(x: rect.maxX, y: rect.maxY)] + } + mosaicStyle = style + } +} + +/// Full-size filtered copies of the screenshot, shared by the capture overlay and the editor. +/// Filtering the whole image once keeps dragging smooth and makes boxes that touch blend seamlessly. +@MainActor +enum MosaicCache { + private static var sourceID: ObjectIdentifier? + private static var images: [String: NSImage] = [:] + private static let context = CIContext(options: [.cacheIntermediates: false]) + + /// The classic brush mosaic (fixed 10 pt blocks, unchanged from earlier versions). + static func pixelated(for source: NSImage) -> NSImage? { + filtered(source, key: "brush") { $0.pixelated(blockSize: 10) } + } + + /// The filtered image used by a mosaic annotation, or nil for solid boxes (they need no source). + static func image(for annotation: Annotation, source: NSImage) -> NSImage? { + let style = annotation.effectiveMosaicStyle + let level = min(max(annotation.sizeLevel, 0), Style.sizeLevels - 1) + switch style { + case .brush: + return pixelated(for: source) + case .pixelBox: + let block = MosaicStyle.blockSize(level: level) + return filtered(source, key: "pixel-\(level)") { $0.pixelated(blockSize: block) } + case .blurBox, .frostedBox: + let radius = MosaicStyle.blurRadius(style, level: level) + return filtered(source, key: "blur-\(radius)") { blurred($0, radius: radius) } + case .solidBox: + return nil + } + } + + static func clear() { + sourceID = nil + images = [:] + } + + private static func filtered(_ source: NSImage, key: String, make: (NSImage) -> NSImage?) -> NSImage? { + if sourceID != ObjectIdentifier(source) { + sourceID = ObjectIdentifier(source) + images = [:] + } + if let image = images[key] { return image } + let image = make(source) + images[key] = image + return image + } + + /// Blurs with clamped edges so the border of the screenshot does not fade to transparent. + static func blurred(_ source: NSImage, radius: CGFloat) -> NSImage? { + guard let cgImage = source.cgImage(forProposedRect: nil, context: nil, hints: nil), source.size.width > 0 else { return nil } + let scale = CGFloat(cgImage.width) / source.size.width + let input = CIImage(cgImage: cgImage) + let output = input.clampedToExtent().applyingGaussianBlur(sigma: Double(radius * scale)).cropped(to: input.extent) + guard let result = context.createCGImage(output, from: input.extent, format: .RGBA8, + colorSpace: CGColorSpace(name: CGColorSpace.sRGB)) else { return nil } + return NSImage(cgImage: result, size: source.size) + } + + /// Corner radius of a box mosaic: crisp for pixel blocks, softly rounded for the others. + static func cornerRadius(for annotation: Annotation) -> CGFloat { + annotation.effectiveMosaicStyle == .pixelBox ? 0 : min(6, min(annotation.rect.width, annotation.rect.height) / 4) + } +} diff --git a/Sources/Snapok/SnapokApp.swift b/Sources/Snapok/SnapokApp.swift index 5b4f838..1c927de 100644 --- a/Sources/Snapok/SnapokApp.swift +++ b/Sources/Snapok/SnapokApp.swift @@ -585,6 +585,8 @@ struct Annotation { var textStyle: AnnotationTextStyle? = nil var stepStyle: StepMarkerStyle? = nil var stepNumber: String? = nil + /// Only for `.mosaic`; nil means the classic brush stroke. + var mosaicStyle: MosaicStyle? = nil var lineWidth: CGFloat { Style.lineWidth(for: kind, level: sizeLevel) @@ -850,6 +852,10 @@ final class CaptureView: NSView, NSTextFieldDelegate { annotations.indices.contains($0) && (annotations[$0].kind == .step || annotations[$0].stepNumber != nil) } == true } + private var mosaicStyle = MosaicStyle.current + private var usesMosaicStyle: Bool { + selectedTool == .mosaic || selectedAnnotationIndex.map { annotations.indices.contains($0) && annotations[$0].kind == .mosaic } == true + } private var annotations: [Annotation] = [] private var draftAnnotation: Annotation? private var dragState: DragState = .idle @@ -1285,7 +1291,7 @@ final class CaptureView: NSView, NSTextFieldDelegate { } private var optionsWidth: CGFloat { - 12 + (usesTextStyle ? 288 : CGFloat(Style.sizeLevels) * 28) + (usesStepStyle ? 126 : 0) + (usesRectStyle ? 142 : 0) + 13 + CGFloat(Style.colors.count) * 26 + 12 + (usesTextStyle ? 288 : CGFloat(Style.sizeLevels) * 28) + (usesStepStyle ? 126 : 0) + (usesRectStyle ? 142 : 0) + (usesMosaicStyle ? 176 : 0) + 13 + CGFloat(Style.colors.count) * 26 } private func drawOptions(in panel: CGRect) { @@ -1304,6 +1310,18 @@ final class CaptureView: NSView, NSTextFieldDelegate { toolbarButtons.append((.numberRect, rect)) cursorX += 142 } + if usesMosaicStyle { + let rect = CGRect(x: cursorX, y: panel.minY + 3, width: 170, height: 26) + NSAppearance(named: .aqua)?.performAsCurrentDrawingAppearance { + let cell = NSPopUpButtonCell(textCell: mosaicStyle.title, pullsDown: false) + cell.addItem(withTitle: mosaicStyle.title) + cell.bezelStyle = .rounded + cell.font = .systemFont(ofSize: 13) + cell.draw(withFrame: rect, in: self) + } + toolbarButtons.append((.mosaicAppearance, rect)) + cursorX += 176 + } if usesStepStyle { let rect = CGRect(x: cursorX, y: panel.minY + 3, width: 120, height: 26) NSAppearance(named: .aqua)?.performAsCurrentDrawingAppearance { @@ -1465,6 +1483,17 @@ final class CaptureView: NSView, NSTextFieldDelegate { switch action { case .fontFamily: showTextStyleMenu(action) + case .mosaicAppearance: + guard let rect = toolbarButtons.first(where: { $0.action == action })?.rect else { return } + let menu = NSMenu() + for style in MosaicStyle.allCases { + let item = NSMenuItem(title: style.title, action: #selector(pickMosaicStyle(_:)), keyEquivalent: "") + item.target = self + item.representedObject = style.rawValue + item.state = mosaicStyle == style ? .on : .off + menu.addItem(item) + } + menu.popUp(positioning: nil, at: CGPoint(x: rect.minX, y: rect.minY), in: self) case .stepAppearance: guard let rect = toolbarButtons.first(where: { $0.action == action })?.rect else { return } let menu = NSMenu() @@ -1543,6 +1572,14 @@ final class CaptureView: NSView, NSTextFieldDelegate { needsDisplay = true } + @objc private func pickMosaicStyle(_ item: NSMenuItem) { + guard let value = item.representedObject as? String, let style = MosaicStyle(rawValue: value) else { return } + mosaicStyle = style + MosaicStyle.current = style + applyStyleToSelection() + needsDisplay = true + } + @objc private func pickStepStyle(_ item: NSMenuItem) { guard let value = item.representedObject as? String, let style = StepMarkerStyle(rawValue: value) else { return } stepStyle = style @@ -1638,6 +1675,7 @@ final class CaptureView: NSView, NSTextFieldDelegate { stepStyle = annotations[index].stepStyle ?? .filledCircle } if annotations[index].kind == .text { textStyle = annotations[index].effectiveTextStyle } + if annotations[index].kind == .mosaic { mosaicStyle = annotations[index].effectiveMosaicStyle } dragState = .movingAnnotation(index: index, lastPoint: point) needsDisplay = true return @@ -1941,7 +1979,10 @@ final class CaptureView: NSView, NSTextFieldDelegate { private func makeAnnotation(_ tool: ToolKind, from start: CGPoint, to end: CGPoint) -> Annotation { var annotation = Annotation(kind: tool, rect: .zero, points: [start, end], color: selectedColor, sizeLevel: sizeLevel) + if tool == .mosaic { annotation.mosaicStyle = mosaicStyle } switch tool { + case .mosaic where mosaicStyle.isBox: + annotation.rect = normalizedRect(from: start, to: end) case .pen, .mosaic: annotation.points = start == end ? [start] : [start, end] annotation.rect = boundingRect(for: annotation.points).insetBy(dx: -annotation.lineWidth / 2, dy: -annotation.lineWidth / 2) @@ -1954,6 +1995,8 @@ final class CaptureView: NSView, NSTextFieldDelegate { private func updateDraft(to point: CGPoint) { guard var draft = draftAnnotation else { return } switch draft.kind { + case .mosaic where draft.isBoxMosaic: + draft = makeAnnotation(.mosaic, from: draft.points.first ?? point, to: point) case .pen, .mosaic: draft.points.append(point) draft.rect = boundingRect(for: draft.points).insetBy(dx: -draft.lineWidth / 2, dy: -draft.lineWidth / 2) @@ -1968,6 +2011,8 @@ final class CaptureView: NSView, NSTextFieldDelegate { guard var draft = draftAnnotation else { return } let isUsable: Bool switch draft.kind { + case .mosaic where draft.isBoxMosaic: + isUsable = draft.rect.width >= 3 && draft.rect.height >= 3 case .pen, .mosaic: isUsable = draft.points.count >= 2 case .arrow: @@ -2097,6 +2142,7 @@ final class CaptureView: NSView, NSTextFieldDelegate { guard let index = selectedAnnotationIndex, annotations.indices.contains(index) else { return } annotations[index].color = selectedColor annotations[index].sizeLevel = sizeLevel + if annotations[index].kind == .mosaic { annotations[index].restyleMosaic(mosaicStyle) } if annotations[index].kind == .step || annotations[index].stepNumber != nil { annotations[index].stepStyle = stepStyle if annotations[index].kind == .step { StepMarkers.resize(&annotations[index]) } @@ -2236,6 +2282,7 @@ enum ToolbarAction: Equatable { case fontFamily case textBold case stepAppearance + case mosaicAppearance case numberRect case scroll case editImage @@ -2259,7 +2306,7 @@ enum ToolbarAction: Equatable { case .save: return "square.and.arrow.down" case .cancel: return "xmark" case .done: return "checkmark" - case .size, .color, .fontFamily, .textBold, .stepAppearance, .numberRect: return nil + case .size, .color, .fontFamily, .textBold, .stepAppearance, .mosaicAppearance, .numberRect: return nil } } @@ -2268,6 +2315,7 @@ enum ToolbarAction: Equatable { case .fontFamily: return L("Font", "字体") case .textBold: return L("Bold", "加粗") case .stepAppearance: return L("Number style", "编号样式") + case .mosaicAppearance: return L("Mosaic style", "马赛克样式") case .numberRect: return L("Automatically number rectangles", "矩形标注自动编号") case .select: return L("Select / Move (V)", "选择 / 移动(V)") case .tool(let tool): return "\(tool.title) (\(tool.shortcut))" @@ -2446,44 +2494,43 @@ enum AnnotationRenderer { } private static func drawMosaic(_ annotation: Annotation, in context: CGContext, sourceImage: NSImage, origin: CGPoint, localImage: Bool = false, desktopBounds: CGRect? = nil) { - if localImage { - guard let pixelated = MosaicCache.pixelated(for: sourceImage) else { return } + let style = annotation.effectiveMosaicStyle + let rect = annotation.rect.standardized + guard rect.width > 0, rect.height > 0 else { return } + + // Clip to the brush stroke, or to the (slightly rounded) dragged box. + if style.isBox { + let radius = MosaicCache.cornerRadius(for: annotation) + context.addPath(CGPath(roundedRect: rect, cornerWidth: radius, cornerHeight: radius, transform: nil)) + } else { context.addPath(smoothPath(annotation.points)) context.replacePathWithStrokedPath() - context.clip() - NSGraphicsContext.current?.imageInterpolation = .none - pixelated.draw(in: CGRect(origin: .zero, size: sourceImage.size)) - return } - guard let pixelated = MosaicCache.pixelated(for: sourceImage), - let source = ScreenCapture.crop(image: pixelated, to: annotation.rect.offsetBy(dx: origin.x, dy: origin.y), desktopBounds: desktopBounds) else { - return - } - context.addPath(smoothPath(annotation.points)) - context.replacePathWithStrokedPath() context.clip() - NSGraphicsContext.current?.imageInterpolation = .none - source.draw(in: annotation.rect) - } -} -@MainActor -enum MosaicCache { - private static var sourceID: ObjectIdentifier? - private static var image: NSImage? - - static func pixelated(for source: NSImage) -> NSImage? { - if sourceID == ObjectIdentifier(source), let image { - return image + if style == .solidBox { + context.setFillColor(annotation.color.cgColor) + context.fill(rect) + return + } + guard let filtered = MosaicCache.image(for: annotation, source: sourceImage) else { return } + NSGraphicsContext.current?.imageInterpolation = style == .blurBox || style == .frostedBox ? .high : .none + if localImage { + filtered.draw(in: CGRect(origin: .zero, size: sourceImage.size)) + } else { + guard let crop = ScreenCapture.crop(image: filtered, to: rect.offsetBy(dx: origin.x, dy: origin.y), desktopBounds: desktopBounds) else { return } + crop.draw(in: rect) + } + if style == .frostedBox { + // A milky veil plus a hairline edge reads as frosted glass rather than a plain blur. + context.setFillColor(NSColor(white: 1, alpha: 0.38).cgColor) + context.fill(rect) + let radius = MosaicCache.cornerRadius(for: annotation) + context.addPath(CGPath(roundedRect: rect.insetBy(dx: 0.5, dy: 0.5), cornerWidth: radius, cornerHeight: radius, transform: nil)) + context.setStrokeColor(NSColor(white: 1, alpha: 0.7).cgColor) + context.setLineWidth(1) + context.strokePath() } - sourceID = ObjectIdentifier(source) - image = source.pixelated(blockSize: 10) - return image - } - - static func clear() { - sourceID = nil - image = nil } } diff --git a/Tests/SnapokTests/MosaicStyleTests.swift b/Tests/SnapokTests/MosaicStyleTests.swift new file mode 100644 index 0000000..17b2237 --- /dev/null +++ b/Tests/SnapokTests/MosaicStyleTests.swift @@ -0,0 +1,192 @@ +import AppKit +import Carbon +extension Bundle { static var module: Bundle { .main } } + +/// Box mosaics (pixelate / blur / frosted / solid) and the classic brush, rendered on a striped image. +@main @MainActor +struct MosaicStyleTests { + static let size = CGSize(width: 200, height: 120) // points; rendered at 2x like a Retina capture + static let box = CGRect(x: 40, y: 30, width: 80, height: 50) + + static func main() throws { + _ = NSApplication.shared + NSApp.setActivationPolicy(.prohibited) + UserDefaults.standard.removeObject(forKey: "editor.mosaicStyle") + let source = stripes() + let plain = render(source, []) + + // 1. Each box style changes only the inside of the box, and every style looks different. + var insides: [MosaicStyle: [UInt8]] = [:] + for style in MosaicStyle.allCases where style.isBox { + var mark = Annotation(kind: .mosaic, rect: box, points: [box.origin, CGPoint(x: box.maxX, y: box.maxY)], + color: NSColor(srgbRed: 0.2, green: 0.4, blue: 0.9, alpha: 1), sizeLevel: 1) + mark.mosaicStyle = style + let out = render(source, [mark]) + precondition(region(out, box.insetBy(dx: -4, dy: -4), outside: true) == region(plain, box.insetBy(dx: -4, dy: -4), outside: true), + "\(style) must not touch pixels outside the box") + let inside = region(out, box.insetBy(dx: 7, dy: 7), outside: false) + precondition(inside != region(plain, box.insetBy(dx: 7, dy: 7), outside: false), "\(style) must hide the box contents") + insides[style] = inside + } + precondition(Set(insides.values).count == 4, "Pixelate, blur, frosted and solid must render differently") + + // 2. Solid fills with the annotation color; pixel blocks are flat; frosted is lighter than plain blur. + let solid = insides[.solidBox]! + precondition(stride(from: 0, to: solid.count, by: 4).allSatisfy { abs(Int(solid[$0 + 2]) - 230) <= 10 }, + "Solid boxes must use the chosen color") + precondition(mean(insides[.frostedBox]!) > mean(insides[.blurBox]!) + 20, "Frosted glass must add a milky veil") + precondition(contrast(insides[.blurBox]!) < contrast(region(plain, box.insetBy(dx: 7, dy: 7), outside: false)) / 3, + "Blur must wash out the stripes") + let pixel = render(source, [{ var a = Annotation(kind: .mosaic, rect: box, points: [], color: .black, sizeLevel: 2); a.mosaicStyle = .pixelBox; return a }()]) + let block = Int(MosaicStyle.blockSize(level: 2) * 2) + precondition(pixelAt(pixel, 82, 82) == pixelAt(pixel, 83, 83), "Neighbouring pixels in one block must match") + precondition(block == 40) + // Larger levels give coarser blocks. + precondition(MosaicStyle.blockSize(level: 0) < MosaicStyle.blockSize(level: 2)) + precondition(MosaicStyle.blurRadius(.blurBox, level: 0) < MosaicStyle.blurRadius(.blurBox, level: 2)) + + // 3. Brush (and annotations without a style, e.g. from older history) still paints only along the stroke. + let brush = Annotation(kind: .mosaic, rect: CGRect(x: 20, y: 50, width: 160, height: 24), + points: [CGPoint(x: 32, y: 62), CGPoint(x: 168, y: 62)], color: .black, sizeLevel: 1) + precondition(brush.effectiveMosaicStyle == .brush && !brush.isBoxMosaic) + let brushed = render(source, [brush]) + precondition(region(brushed, CGRect(x: 0, y: 0, width: 200, height: 40), outside: false) + == region(plain, CGRect(x: 0, y: 0, width: 200, height: 40), outside: false), "Brush must stay on its stroke") + precondition(region(brushed, CGRect(x: 60, y: 58, width: 80, height: 8), outside: false) + != region(plain, CGRect(x: 60, y: 58, width: 80, height: 8), outside: false)) + + // 4. Style survives history save/load; old items without the key decode as brush. + var saved = Annotation(kind: .mosaic, rect: box, points: [], color: .black, sizeLevel: 0) + saved.mosaicStyle = .frostedBox + let decoded = try JSONDecoder().decode(Annotation.self, from: JSONEncoder().encode(saved)) + precondition(decoded.mosaicStyle == .frostedBox && decoded.rect == box) + let legacy = #"{"kind":"mosaic","rect":[[1,2],[3,4]],"points":[],"color":[0,0,0,1],"sizeLevel":1}"# + let legacyItem = try JSONDecoder().decode(Annotation.self, from: Data(legacy.utf8)) + precondition(legacyItem.effectiveMosaicStyle == .brush) + let future = #"{"kind":"mosaic","rect":[[1,2],[3,4]],"points":[],"color":[0,0,0,1],"sizeLevel":1,"mosaicStyle":"hologram"}"# + let futureItem = try JSONDecoder().decode(Annotation.self, from: Data(future.utf8)) + precondition(futureItem.effectiveMosaicStyle == .brush, + "An unknown style must not break loading the history item") + + // 5. Switching a selected mosaic between box and brush keeps the covered area. + var restyled = saved + restyled.restyleMosaic(.brush) + precondition(restyled.effectiveMosaicStyle == .brush && restyled.points.count >= 2 + && restyled.rect.insetBy(dx: -1, dy: -1).contains(box), "Box → brush must still cover the box") + restyled.restyleMosaic(.solidBox) + precondition(restyled.isBoxMosaic && restyled.rect.contains(box)) + + // 6. In the editor, dragging with a box style makes one rectangle (not a stroke) and is undoable. + let editor = BackgroundPreview(image: source) + let window = NSWindow(contentRect: CGRect(x: 0, y: 0, width: 600, height: 420), styleMask: [.borderless], backing: .buffered, defer: false) + editor.frame = window.contentView!.bounds + window.contentView = editor + func event(_ type: NSEvent.EventType, _ point: CGPoint) -> NSEvent { + NSEvent.mouseEvent(with: type, location: point, modifierFlags: [], timestamp: 0, + windowNumber: window.windowNumber, context: nil, eventNumber: 1, clickCount: 1, pressure: 1)! + } + precondition(editor.mosaicStyle == .pixelBox, "New installs default to the box pixel mosaic") + editor.chooseTool(.mosaic) + editor.mosaicStyle = .blurBox + editor.mouseDown(with: event(.leftMouseDown, CGPoint(x: 250, y: 180))) + for x in stride(from: 260, through: 340, by: 20) { editor.mouseDragged(with: event(.leftMouseDragged, CGPoint(x: x, y: 180 + (x - 250) / 2))) } + editor.mouseUp(with: event(.leftMouseUp, CGPoint(x: 340, y: 225))) + precondition(editor.annotations.count == 1, "One drag must make one mosaic") + let dragged = editor.annotations[0] + let a = editor.imagePoint(from: CGPoint(x: 250, y: 180)), b = editor.imagePoint(from: CGPoint(x: 340, y: 225)) + precondition(dragged.mosaicStyle == .blurBox && dragged.points.count == 2, "Box mosaics keep only their two corners") + precondition(abs(dragged.rect.minX - min(a.x, b.x)) < 0.5 && abs(dragged.rect.maxY - max(a.y, b.y)) < 0.5, + "The box must match the dragged rectangle exactly (no brush padding)") + // A tiny click must not leave a sliver behind. + editor.mouseDown(with: event(.leftMouseDown, CGPoint(x: 400, y: 300))) + editor.mouseUp(with: event(.leftMouseUp, CGPoint(x: 401, y: 300))) + precondition(editor.annotations.count == 1) + // Brush style in the editor still paints a stroke. + editor.mosaicStyle = .brush + editor.mouseDown(with: event(.leftMouseDown, CGPoint(x: 200, y: 160))) + for x in stride(from: 210, through: 260, by: 10) { editor.mouseDragged(with: event(.leftMouseDragged, CGPoint(x: x, y: 160))) } + editor.mouseUp(with: event(.leftMouseUp, CGPoint(x: 260, y: 160))) + precondition(editor.annotations.count == 2 && editor.annotations[1].points.count > 2 && !editor.annotations[1].isBoxMosaic) + editor.undoEdit() + precondition(editor.annotations.count == 1, "Undo must remove the last mosaic") + + if CommandLine.arguments.count > 1 { + var row: [Annotation] = [] + for (index, style) in MosaicStyle.allCases.filter(\.isBox).enumerated() { + var mark = Annotation(kind: .mosaic, rect: CGRect(x: 6 + CGFloat(index) * 48, y: 30, width: 42, height: 60), points: [], + color: NSColor(white: 0.15, alpha: 1), sizeLevel: 1) + mark.mosaicStyle = style + row.append(mark) + } + try bitmap(source, row).representation(using: .png, properties: [:])!.write(to: URL(fileURLWithPath: CommandLine.arguments[1])) + } + print("Passed mosaic checks: box styles stay inside the box, look distinct, solid color, frosted veil, blur, brush stroke, history coding, restyle, editor drag/undo") + } + + /// Black and white vertical stripes with colored text, so every filter has visible detail to remove. + static func stripes() -> NSImage { + let rep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: Int(size.width * 2), pixelsHigh: Int(size.height * 2), bitsPerSample: 8, + samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)! + rep.size = size + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep) + for x in stride(from: 0, to: Int(size.width), by: 4) { + (x % 8 == 0 ? NSColor.black : NSColor.white).setFill() + CGRect(x: CGFloat(x), y: 0, width: 4, height: size.height).fill() + } + ("13800138000" as NSString).draw(at: CGPoint(x: 48, y: 50), withAttributes: [.font: NSFont.boldSystemFont(ofSize: 14), .foregroundColor: NSColor.systemRed]) + NSGraphicsContext.restoreGraphicsState() + let image = NSImage(size: size) + image.addRepresentation(rep) + return image + } + + static func bitmap(_ source: NSImage, _ annotations: [Annotation]) -> NSBitmapImageRep { + let rep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: Int(size.width * 2), pixelsHigh: Int(size.height * 2), bitsPerSample: 8, + samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)! + rep.size = size + NSGraphicsContext.saveGraphicsState() + let context = NSGraphicsContext(bitmapImageRep: rep)! + NSGraphicsContext.current = context + source.draw(in: CGRect(origin: .zero, size: size)) + for annotation in annotations { + AnnotationRenderer.draw(annotation, in: context.cgContext, sourceImage: source, origin: .zero, localImage: true) + } + NSGraphicsContext.restoreGraphicsState() + return rep + } + + static func render(_ source: NSImage, _ annotations: [Annotation]) -> NSBitmapImageRep { bitmap(source, annotations) } + + /// Raw RGBA bytes inside (or outside) `rect` (points, bottom-left origin). + static func region(_ rep: NSBitmapImageRep, _ rect: CGRect, outside: Bool) -> [UInt8] { + var bytes: [UInt8] = [] + let data = rep.bitmapData! + for y in 0.. [UInt8] { + let i = y * rep.bytesPerRow + x * 4 + return (0..<4).map { rep.bitmapData![i + $0] } + } + + static func mean(_ bytes: [UInt8]) -> Double { + let rgb = bytes.enumerated().filter { $0.offset % 4 != 3 }.map { Double($0.element) } + return rgb.reduce(0, +) / Double(max(1, rgb.count)) + } + + /// Standard deviation of brightness: high for crisp stripes, low after blurring. + static func contrast(_ bytes: [UInt8]) -> Double { + let lum = stride(from: 0, to: bytes.count, by: 4).map { (Double(bytes[$0]) + Double(bytes[$0 + 1]) + Double(bytes[$0 + 2])) / 3 } + let avg = lum.reduce(0, +) / Double(max(1, lum.count)) + return (lum.map { ($0 - avg) * ($0 - avg) }.reduce(0, +) / Double(max(1, lum.count))).squareRoot() + } +} diff --git a/scripts/test-mosaic.sh b/scripts/test-mosaic.sh new file mode 100755 index 0000000..014057b --- /dev/null +++ b/scripts/test-mosaic.sh @@ -0,0 +1,14 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TEST_DIR="$(mktemp -d)" +trap 'rm -rf "$TEST_DIR"' EXIT +# Compile the app classes without its entry point; the test owns the AppKit application. +sed '/^@main$/d' "$ROOT_DIR/Sources/Snapok/SnapokApp.swift" > "$TEST_DIR/AppSource.swift" +SOURCES=() +for file in "$ROOT_DIR"/Sources/Snapok/*.swift; do + [[ "$(basename "$file")" == SnapokApp.swift ]] || SOURCES+=("$file") +done +swiftc -swift-version 6 -target "$(uname -m)-apple-macos14.0" -parse-as-library \ + "${SOURCES[@]}" "$TEST_DIR/AppSource.swift" "$ROOT_DIR/Tests/SnapokTests/MosaicStyleTests.swift" -o "$TEST_DIR/mosaic-tests" +SNAPOK_HISTORY_DIR="$TEST_DIR/history" "$TEST_DIR/mosaic-tests" "$@"