From 7a381ed4364a97b44718c33d879275e941ec24fa Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Fri, 8 May 2026 15:23:36 +0800 Subject: [PATCH 1/3] {"schema":"maestro/commit/1","summary":"Add capture frame export presets","authority":"manual"} --- .../CaptureFrameEffect.swift | 261 ++++++++++++++++++ .../RsnapNativeHostKit/NativeHostApp.swift | 68 ++++- .../NativeHostSettings.swift | 79 +++++- .../NativeHostSettingsView.swift | 100 +++++++ .../RsnapNativeHostKitProbe/main.swift | 111 ++++++++ 5 files changed, 613 insertions(+), 6 deletions(-) create mode 100644 native/macos-host/Sources/RsnapNativeHostKit/CaptureFrameEffect.swift diff --git a/native/macos-host/Sources/RsnapNativeHostKit/CaptureFrameEffect.swift b/native/macos-host/Sources/RsnapNativeHostKit/CaptureFrameEffect.swift new file mode 100644 index 00000000..6edd07c6 --- /dev/null +++ b/native/macos-host/Sources/RsnapNativeHostKit/CaptureFrameEffect.swift @@ -0,0 +1,261 @@ +import AppKit +import CoreGraphics +import Foundation +import ImageIO + +enum CaptureFrameSource: Equatable { + case dragRegion + case window + case fullScreen + case scrollCapture + case unknown +} + +package enum CaptureFrameEffectRenderer { + package static func render( + image: CGImage, + background: CaptureFrameBackgroundPreference, + screen: NSScreen? + ) -> CGImage? { + let imageSize = CGSize(width: image.width, height: image.height) + guard imageSize.width > 0, imageSize.height > 0 else { + return nil + } + let canvasSize = canvasSize(for: imageSize) + guard + let colorSpace = CGColorSpace(name: CGColorSpace.sRGB), + let context = CGContext( + data: nil, + width: Int(canvasSize.width.rounded()), + height: Int(canvasSize.height.rounded()), + bitsPerComponent: 8, + bytesPerRow: 0, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) + else { + return nil + } + + let canvasRect = CGRect(origin: .zero, size: canvasSize) + drawBackground(background, screen: screen, in: canvasRect, context: context) + drawFramedCapture(image, imageSize: imageSize, in: canvasRect, context: context) + return context.makeImage() + } + + package static func canvasSize(for imageSize: CGSize) -> CGSize { + let padding = padding(for: imageSize) + return CGSize( + width: ceil(imageSize.width + padding * 2), + height: ceil(imageSize.height + padding * 2) + ) + } + + package static func imageRect(for imageSize: CGSize) -> CGRect { + let padding = padding(for: imageSize) + return CGRect( + x: padding, + y: padding, + width: imageSize.width, + height: imageSize.height + ) + } + + private static func padding(for imageSize: CGSize) -> CGFloat { + let shortSide = min(imageSize.width, imageSize.height) + let longSide = max(imageSize.width, imageSize.height) + let visualPadding = shortSide * 0.115 + let maximumPadding = max(72, longSide * 0.18) + return min(max(visualPadding, 48), maximumPadding) + } + + private static func cornerRadius(for imageSize: CGSize) -> CGFloat { + min(32, max(10, min(imageSize.width, imageSize.height) * 0.035)) + } + + private static func drawBackground( + _ background: CaptureFrameBackgroundPreference, + screen: NSScreen?, + in rect: CGRect, + context: CGContext + ) { + if background == .systemWallpaper, + let wallpaper = systemWallpaperImage( + screen: screen, + targetPixelSize: Int(max(rect.width, rect.height).rounded(.up)) + ) + { + drawAspectFill(wallpaper, in: rect, context: context) + context.setFillColor(NSColor.black.withAlphaComponent(0.10).cgColor) + context.fill(rect) + return + } + + let colors = gradientColors(for: background) + guard + let gradient = CGGradient( + colorsSpace: CGColorSpace(name: CGColorSpace.sRGB), + colors: colors as CFArray, + locations: [0, 0.54, 1] + ) + else { + context.setFillColor(colors.first ?? NSColor.windowBackgroundColor.cgColor) + context.fill(rect) + return + } + context.drawLinearGradient( + gradient, + start: CGPoint(x: rect.minX, y: rect.maxY), + end: CGPoint(x: rect.maxX, y: rect.minY), + options: [.drawsBeforeStartLocation, .drawsAfterEndLocation] + ) + } + + private static func gradientColors(for background: CaptureFrameBackgroundPreference) + -> [CGColor] + { + switch background { + case .systemWallpaper, .aurora: + return [ + NSColor(calibratedRed: 0.10, green: 0.16, blue: 0.28, alpha: 1).cgColor, + NSColor(calibratedRed: 0.30, green: 0.47, blue: 0.71, alpha: 1).cgColor, + NSColor(calibratedRed: 0.95, green: 0.61, blue: 0.43, alpha: 1).cgColor, + ] + case .graphite: + return [ + NSColor(calibratedRed: 0.08, green: 0.09, blue: 0.11, alpha: 1).cgColor, + NSColor(calibratedRed: 0.24, green: 0.26, blue: 0.30, alpha: 1).cgColor, + NSColor(calibratedRed: 0.56, green: 0.59, blue: 0.64, alpha: 1).cgColor, + ] + case .linen: + return [ + NSColor(calibratedRed: 0.83, green: 0.87, blue: 0.82, alpha: 1).cgColor, + NSColor(calibratedRed: 0.58, green: 0.70, blue: 0.71, alpha: 1).cgColor, + NSColor(calibratedRed: 0.24, green: 0.36, blue: 0.47, alpha: 1).cgColor, + ] + } + } + + private static func drawFramedCapture( + _ image: CGImage, + imageSize: CGSize, + in canvasRect: CGRect, + context: CGContext + ) { + let imageRect = imageRect(for: imageSize) + let cornerRadius = cornerRadius(for: imageSize) + let capturePath = CGPath( + roundedRect: imageRect, + cornerWidth: cornerRadius, + cornerHeight: cornerRadius, + transform: nil + ) + + context.saveGState() + context.addPath(capturePath) + context.setShadow( + offset: .zero, + blur: max(48, min(canvasRect.width, canvasRect.height) * 0.065), + color: NSColor.black.withAlphaComponent(0.34).cgColor + ) + context.setFillColor(NSColor.black.withAlphaComponent(0.24).cgColor) + context.fillPath() + context.restoreGState() + + context.saveGState() + context.addPath(capturePath) + context.setShadow( + offset: CGSize(width: 0, height: -max(18, canvasRect.height * 0.026)), + blur: max(28, min(canvasRect.width, canvasRect.height) * 0.04), + color: NSColor.black.withAlphaComponent(0.40).cgColor + ) + context.setFillColor(NSColor.black.withAlphaComponent(0.28).cgColor) + context.fillPath() + context.restoreGState() + + context.saveGState() + context.addPath(capturePath) + context.clip() + context.interpolationQuality = .high + context.draw(image, in: imageRect) + context.restoreGState() + + context.saveGState() + context.addPath(capturePath) + context.setStrokeColor(NSColor.black.withAlphaComponent(0.22).cgColor) + context.setLineWidth(max(1, min(imageSize.width, imageSize.height) * 0.0015)) + context.strokePath() + context.restoreGState() + } + + private static func systemWallpaperImage( + screen: NSScreen?, + targetPixelSize: Int + ) -> CGImage? { + guard + let screen = screen ?? NSScreen.main, + let url = NSWorkspace.shared.desktopImageURL(for: screen) + else { + return nil + } + guard + let source = CGImageSourceCreateWithURL( + url as CFURL, + [kCGImageSourceShouldCache: false] as CFDictionary + ) + else { + return nil + } + let maxPixelSize = max(1, targetPixelSize) + let options = + [ + kCGImageSourceCreateThumbnailFromImageAlways: true, + kCGImageSourceCreateThumbnailWithTransform: true, + kCGImageSourceShouldCacheImmediately: true, + kCGImageSourceThumbnailMaxPixelSize: maxPixelSize, + ] as CFDictionary + return CGImageSourceCreateThumbnailAtIndex(source, 0, options) + } + + private static func drawAspectFill( + _ image: CGImage, + in destination: CGRect, + context: CGContext + ) { + let imageSize = CGSize(width: image.width, height: image.height) + let source = aspectFillCropRect(sourceSize: imageSize, destinationSize: destination.size) + let cropped = image.cropping(to: source.integral) ?? image + context.interpolationQuality = .high + context.draw(cropped, in: destination) + } + + private static func aspectFillCropRect( + sourceSize: CGSize, + destinationSize: CGSize + ) -> CGRect { + let sourceAspect = sourceSize.width / max(sourceSize.height, 1) + let destinationAspect = destinationSize.width / max(destinationSize.height, 1) + if sourceAspect > destinationAspect { + let width = sourceSize.height * destinationAspect + return CGRect( + x: (sourceSize.width - width) / 2, + y: 0, + width: width, + height: sourceSize.height + ) + } + let height = sourceSize.width / max(destinationAspect, .leastNonzeroMagnitude) + return CGRect( + x: 0, + y: (sourceSize.height - height) / 2, + width: sourceSize.width, + height: height + ) + } +} + +extension NativeHostSettings { + func shouldApplyCaptureFrameEffect(to source: CaptureFrameSource) -> Bool { + captureFrameEffectEnabled && captureFrameApplicability.includes(source) + } +} diff --git a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift index 5c17a693..b08cd485 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift @@ -2035,6 +2035,10 @@ final class CaptureSessionController: NSObject { chromeState.frozenSelectionSnapshot = selection chromeState.frozenSelectionEditable = editable chromeState.frozenSelectionInteraction = nil + chromeState.captureFrameSource = captureFrameSource( + for: selection, + editable: editable + ) chromeState.frozenDisplayFrame = frozenFrame.displayFrame chromeState.frozenDisplayImage = frozenFrame.image let hostOwnedFrozenScene = hostOwnedFrozenPresentationScene( @@ -2096,6 +2100,37 @@ final class CaptureSessionController: NSObject { ) } + private func captureFrameSource(for selection: CGRect, editable: Bool) -> CaptureFrameSource { + if editable { + return .dragRegion + } + if scene.highlightedWindow != nil { + return .window + } + if let activeMonitor = scene.activeMonitor, + Self.rectNearlyMatches(selection, activeMonitor.frame, tolerance: 2) + { + return .fullScreen + } + if NSScreen.screens.contains(where: { screen in + Self.rectNearlyMatches(selection, screen.frame, tolerance: 2) + }) { + return .fullScreen + } + return .unknown + } + + private static func rectNearlyMatches( + _ lhs: CGRect, + _ rhs: CGRect, + tolerance: CGFloat + ) -> Bool { + abs(lhs.minX - rhs.minX) <= tolerance + && abs(lhs.minY - rhs.minY) <= tolerance + && abs(lhs.width - rhs.width) <= tolerance + && abs(lhs.height - rhs.height) <= tolerance + } + private func hostOwnedFrozenToolbarItems(scrollEnabled: Bool) -> [ToolbarItem] { let allowTextInput = session?.configuration.allowTextInput @@ -2253,6 +2288,7 @@ final class CaptureSessionController: NSObject { chromeState.frozenSelectionEditable = false chromeState.frozenSelectionInteraction = nil chromeState.frozenSelectionSnapshot = selection + chromeState.captureFrameSource = .scrollCapture chromeState.frozenDisplayFrame = nil chromeState.frozenDisplayImage = nil chromeState.frozenBaseImage = baseImage @@ -2345,7 +2381,8 @@ final class CaptureSessionController: NSObject { } let copyStartedAt = ProcessInfo.processInfo.systemUptime let captureImageStartedAt = ProcessInfo.processInfo.systemUptime - guard let cgImage = try captureFrozenSelectionImage() else { + guard let cgImage = try captureFrozenSelectionImage(applyingCaptureFrameEffect: true) + else { NativeHostTelemetry.copyCaptureTiming( captureID: currentCaptureTelemetryID, totalMilliseconds: NativeHostTelemetry.milliseconds(since: copyStartedAt), @@ -2417,7 +2454,8 @@ final class CaptureSessionController: NSObject { guard let session else { return } - guard let cgImage = try captureFrozenSelectionImage() else { + guard let cgImage = try captureFrozenSelectionImage(applyingCaptureFrameEffect: true) + else { try sendHostStatusMessage("Could not capture the frozen selection.") return } @@ -2615,7 +2653,9 @@ final class CaptureSessionController: NSObject { return exportImage } - private func captureFrozenSelectionImage() throws -> CGImage? { + private func captureFrozenSelectionImage(applyingCaptureFrameEffect: Bool = false) throws + -> CGImage? + { let captureStartedAt = ProcessInfo.processInfo.systemUptime guard let selection = currentFrozenSelection() else { NativeHostTelemetry.frozenSelectionImageTiming( @@ -2681,7 +2721,11 @@ final class CaptureSessionController: NSObject { } let compositeStartedAt = ProcessInfo.processInfo.systemUptime - let result = compositeFrozenOverlay(on: baseImage, selection: selection) ?? baseImage + let composited = compositeFrozenOverlay(on: baseImage, selection: selection) ?? baseImage + let result = + applyingCaptureFrameEffect + ? applyCaptureFrameEffectIfNeeded(to: composited, selection: selection) + : composited let compositeMilliseconds = NativeHostTelemetry.milliseconds(since: compositeStartedAt) let imageSource: String if refreshedFromFrozenDisplay { @@ -2708,6 +2752,19 @@ final class CaptureSessionController: NSObject { return result } + private func applyCaptureFrameEffectIfNeeded(to image: CGImage, selection: CGRect) -> CGImage { + let settings = settingsStore.settings + guard settings.shouldApplyCaptureFrameEffect(to: chromeState.captureFrameSource) else { + return image + } + let selectionCenter = CGPoint(x: selection.midX, y: selection.midY) + return CaptureFrameEffectRenderer.render( + image: image, + background: settings.captureFrameBackground, + screen: screen(containing: selectionCenter) + ) ?? image + } + @discardableResult private func refreshFrozenBaseImageFromDisplay(for selection: CGRect) -> Bool { // Export must stay tied to the latched frozen display, not the live desktop. @@ -8369,6 +8426,7 @@ private struct CaptureChromeState { var frozenDisplayFrame: CGRect? var frozenDisplayImage: CGImage? var frozenBaseImage: CGImage? + var captureFrameSource: CaptureFrameSource = .unknown var scrollMinimapPreview: ScrollCaptureMinimapSnapshot? var frozenOverlay = FrozenOverlayState() var annotationStyle = FrozenAnnotationStyleState() @@ -8389,6 +8447,7 @@ private struct CaptureChromeState { frozenDisplayFrame = nil frozenDisplayImage = nil frozenBaseImage = nil + captureFrameSource = .unknown scrollMinimapPreview = nil frozenOverlay.reset() annotationStyle = FrozenAnnotationStyleState() @@ -8406,6 +8465,7 @@ private struct CaptureChromeState { frozenDisplayFrame = nil frozenDisplayImage = nil frozenBaseImage = nil + captureFrameSource = .unknown scrollMinimapPreview = nil frozenOverlay.reset() annotationStyle = FrozenAnnotationStyleState() diff --git a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettings.swift b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettings.swift index 2bbf1850..7d009fe0 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettings.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettings.swift @@ -24,6 +24,9 @@ final class NativeHostSettingsStore { static let hudTintBrightness = "hudTintBrightness" static let liquidGlassStyle = "liquidGlassStyle" static let loupeSampleSize = "loupeSampleSize" + static let captureFrameEffectEnabled = "captureFrameEffectEnabled" + static let captureFrameBackground = "captureFrameBackground" + static let captureFrameApplicability = "captureFrameApplicability" } private let defaults: UserDefaults @@ -73,7 +76,16 @@ final class NativeHostSettingsStore { ?? baseSettings.liquidGlassStyle, loupeSampleSize: LoupeSampleSizePreference( rawValue: defaults.string(forKey: DefaultsKey.loupeSampleSize) ?? "") - ?? baseSettings.loupeSampleSize + ?? baseSettings.loupeSampleSize, + captureFrameEffectEnabled: defaults.object( + forKey: DefaultsKey.captureFrameEffectEnabled) as? Bool + ?? baseSettings.captureFrameEffectEnabled, + captureFrameBackground: CaptureFrameBackgroundPreference( + rawValue: defaults.string(forKey: DefaultsKey.captureFrameBackground) ?? "") + ?? baseSettings.captureFrameBackground, + captureFrameApplicability: CaptureFrameApplicabilityPreference( + rawValue: defaults.string(forKey: DefaultsKey.captureFrameApplicability) ?? "") + ?? baseSettings.captureFrameApplicability ) self.settings = settings.sanitized() Self.persist(self.settings, into: defaults) @@ -115,6 +127,15 @@ final class NativeHostSettingsStore { defaults.set(settings.hudTintBrightness, forKey: DefaultsKey.hudTintBrightness) defaults.set(settings.liquidGlassStyle.rawValue, forKey: DefaultsKey.liquidGlassStyle) defaults.set(settings.loupeSampleSize.rawValue, forKey: DefaultsKey.loupeSampleSize) + defaults.set( + settings.captureFrameEffectEnabled, + forKey: DefaultsKey.captureFrameEffectEnabled) + defaults.set( + settings.captureFrameBackground.rawValue, + forKey: DefaultsKey.captureFrameBackground) + defaults.set( + settings.captureFrameApplicability.rawValue, + forKey: DefaultsKey.captureFrameApplicability) } } @@ -136,6 +157,9 @@ struct NativeHostSettings: Equatable { var hudTintBrightness: Double var liquidGlassStyle: LiquidGlassStylePreference var loupeSampleSize: LoupeSampleSizePreference + var captureFrameEffectEnabled: Bool + var captureFrameBackground: CaptureFrameBackgroundPreference + var captureFrameApplicability: CaptureFrameApplicabilityPreference static var defaults: NativeHostSettings { NativeHostSettings( @@ -156,7 +180,10 @@ struct NativeHostSettings: Equatable { hudTintSaturation: 0.72, hudTintBrightness: 0.95, liquidGlassStyle: .clear, - loupeSampleSize: .small + loupeSampleSize: .small, + captureFrameEffectEnabled: false, + captureFrameBackground: .systemWallpaper, + captureFrameApplicability: .window ) } @@ -381,6 +408,54 @@ enum LoupeSampleSizePreference: String, CaseIterable { } } +package enum CaptureFrameBackgroundPreference: String, CaseIterable { + case systemWallpaper = "system_wallpaper" + case aurora + case graphite + case linen + + var title: String { + switch self { + case .systemWallpaper: + return "Wallpaper" + case .aurora: + return "Aurora" + case .graphite: + return "Graphite" + case .linen: + return "Linen" + } + } +} + +enum CaptureFrameApplicabilityPreference: String, CaseIterable { + case dragRegion = "drag_region" + case window + case both + + var title: String { + switch self { + case .dragRegion: + return "Drag" + case .window: + return "Window" + case .both: + return "Both" + } + } + + func includes(_ source: CaptureFrameSource) -> Bool { + switch (self, source) { + case (.dragRegion, .dragRegion), (.window, .window), (.both, .dragRegion), + (.both, .window): + return true + case (.dragRegion, .window), (.window, .dragRegion), (_, .fullScreen), + (_, .scrollCapture), (_, .unknown): + return false + } + } +} + enum LiveChromeGlassMaterialSupport { static let isLiquidGlassBuildSupported: Bool = { #if compiler(>=6.2) diff --git a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettingsView.swift b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettingsView.swift index 4c483b40..c68337d7 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettingsView.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettingsView.swift @@ -510,6 +510,12 @@ private struct OutputInspector: View { value: settings.outputNaming.title, symbolName: "number" ) + InspectorMetric( + title: "Frame", + value: settings.captureFrameEffectEnabled + ? settings.captureFrameBackground.title : "Off", + symbolName: "photo.on.rectangle.angled" + ) } } } @@ -928,6 +934,54 @@ private struct OutputNamingPicker: View { } } +private struct CaptureFrameBackgroundPicker: View { + let selection: CaptureFrameBackgroundPreference + let isEnabled: Bool + let onSelect: (CaptureFrameBackgroundPreference) -> Void + + var body: some View { + Picker( + "", + selection: Binding( + get: { selection }, + set: { value in onSelect(value) } + ) + ) { + ForEach(CaptureFrameBackgroundPreference.allCases, id: \.rawValue) { background in + Text(background.title).tag(background) + } + } + .labelsHidden() + .pickerStyle(.menu) + .frame(width: SettingsControlLayout.controlColumnWidth) + .disabled(!isEnabled) + } +} + +private struct CaptureFrameApplicabilityPicker: View { + let selection: CaptureFrameApplicabilityPreference + let isEnabled: Bool + let onSelect: (CaptureFrameApplicabilityPreference) -> Void + + var body: some View { + HStack(spacing: 8) { + ForEach(CaptureFrameApplicabilityPreference.allCases, id: \.rawValue) { target in + ModernSegmentButton( + title: target.title, + isSelected: selection == target, + isEnabled: isEnabled + ) { + onSelect(target) + } + } + } + .padding(.horizontal, 1) + .frame(width: SettingsControlLayout.controlColumnWidth) + .segmentedGlassBackground() + .disabled(!isEnabled) + } +} + private struct ModernSegmentButton: View { let title: String let isSelected: Bool @@ -1953,6 +2007,52 @@ private struct OutputSettingsPanel: View { } } } + + VStack(spacing: 0) { + SettingsControlTile( + symbolName: "photo.on.rectangle.angled", + title: "Export frame", + subtitle: "Wallpaper canvas." + ) { + Toggle( + "", + isOn: Binding( + get: { model.settings.captureFrameEffectEnabled }, + set: { value in + model.update { $0.captureFrameEffectEnabled = value } + } + ) + ) + .labelsHidden() + .toggleStyle(SettingsToggleStyle()) + } + + SettingsControlTile( + symbolName: "rectangle.on.rectangle", + title: "Frame preset", + subtitle: "Background style." + ) { + CaptureFrameBackgroundPicker( + selection: model.settings.captureFrameBackground, + isEnabled: model.settings.captureFrameEffectEnabled + ) { value in + model.update { $0.captureFrameBackground = value } + } + } + + SettingsControlTile( + symbolName: "viewfinder", + title: "Apply to", + subtitle: "Fullscreen excluded." + ) { + CaptureFrameApplicabilityPicker( + selection: model.settings.captureFrameApplicability, + isEnabled: model.settings.captureFrameEffectEnabled + ) { value in + model.update { $0.captureFrameApplicability = value } + } + } + } } } diff --git a/native/macos-host/Sources/RsnapNativeHostKitProbe/main.swift b/native/macos-host/Sources/RsnapNativeHostKitProbe/main.swift index f463e23c..9d1f61a0 100644 --- a/native/macos-host/Sources/RsnapNativeHostKitProbe/main.swift +++ b/native/macos-host/Sources/RsnapNativeHostKitProbe/main.swift @@ -44,6 +44,7 @@ enum RsnapNativeHostKitProbe { assertScrimOverlappingRoundedExclusionStaysClear() assertScrimExclusionPreservesExistingPixels() assertRoundedExclusionMaskKeepsCornersFilled() + assertCaptureFrameEffectExpandsExportCanvas() let minimapExportSize = CGSize(width: 100, height: 200) guard let rightMinimap = scrollCaptureMinimapFrame( @@ -129,6 +130,116 @@ enum RsnapNativeHostKitProbe { } } + private static func assertCaptureFrameEffectExpandsExportCanvas() { + let imageSize = CGSize(width: 320, height: 180) + let canvasSize = CaptureFrameEffectRenderer.canvasSize(for: imageSize) + guard canvasSize.width > imageSize.width, canvasSize.height > imageSize.height else { + fatalError("capture frame canvas should add room for wallpaper and shadow") + } + let imageRect = CaptureFrameEffectRenderer.imageRect(for: imageSize) + guard imageRect.minX > 0, imageRect.minY > 0 else { + fatalError("capture frame image rect should be inset from the canvas edge") + } + guard let source = solidImage(width: 320, height: 180) else { + fatalError("could not build capture frame probe image") + } + guard + let rendered = CaptureFrameEffectRenderer.render( + image: source, + background: .aurora, + screen: nil + ) + else { + fatalError("capture frame renderer should produce an image for gradient presets") + } + guard rendered.width == Int(canvasSize.width), rendered.height == Int(canvasSize.height) + else { + fatalError("capture frame renderer size should match layout geometry") + } + guard let pixels = rgbaPixels(from: rendered) else { + fatalError("could not read capture frame rendered pixels") + } + let center = pixel( + in: pixels, + width: rendered.width, + height: rendered.height, + x: Int(imageRect.midX), + yFromBottom: Int(imageRect.midY) + ) + guard center.0 > 20, center.1 > 35, center.2 > 60, center.3 > 240 else { + fatalError("capture frame should draw the source image inside the framed rect") + } + let background = pixel( + in: pixels, + width: rendered.width, + height: rendered.height, + x: 8, + yFromBottom: 8 + ) + guard background.3 > 240 else { + fatalError("capture frame background should be opaque") + } + } + + private static func solidImage(width: Int, height: Int) -> CGImage? { + guard + let colorSpace = CGColorSpace(name: CGColorSpace.sRGB), + let context = CGContext( + data: nil, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) + else { + return nil + } + context.setFillColor(CGColor(red: 0.14, green: 0.22, blue: 0.32, alpha: 1)) + context.fill(CGRect(x: 0, y: 0, width: width, height: height)) + return context.makeImage() + } + + private static func rgbaPixels(from image: CGImage) -> [UInt8]? { + let width = image.width + let height = image.height + var pixels = [UInt8](repeating: 0, count: width * height * 4) + let rendered = pixels.withUnsafeMutableBytes { buffer -> Bool in + guard + let data = buffer.baseAddress, + let colorSpace = CGColorSpace(name: CGColorSpace.sRGB), + let context = CGContext( + data: data, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: width * 4, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) + else { + return false + } + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + return true + } + return rendered ? pixels : nil + } + + private static func pixel( + in data: [UInt8], + width: Int, + height: Int, + x: Int, + yFromBottom: Int + ) -> (UInt8, UInt8, UInt8, UInt8) { + let clampedX = max(0, min(width - 1, x)) + let y = rowIndex(fromBottom: yFromBottom, height: height) + let offset = (y * width + clampedX) * 4 + return (data[offset], data[offset + 1], data[offset + 2], data[offset + 3]) + } + private static func assertRectOverlayDrawsAtVisualTop( _ rect: CGRect, selection: CGRect, From 67b9932a6f7e63ffbbe8f3561a6524101ede046f Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Fri, 8 May 2026 18:09:24 +0800 Subject: [PATCH 2/3] {"schema":"maestro/commit/1","summary":"Use system window snapshots for capture frames","authority":"manual"} --- .../CaptureFrameEffect.swift | 130 ++++++++++++++---- .../RsnapNativeHostKit/NativeHostApp.swift | 76 +++++++++- .../RsnapNativeHostKitProbe/main.swift | 14 +- 3 files changed, 189 insertions(+), 31 deletions(-) diff --git a/native/macos-host/Sources/RsnapNativeHostKit/CaptureFrameEffect.swift b/native/macos-host/Sources/RsnapNativeHostKit/CaptureFrameEffect.swift index 6edd07c6..9e1a6f91 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/CaptureFrameEffect.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/CaptureFrameEffect.swift @@ -3,7 +3,7 @@ import CoreGraphics import Foundation import ImageIO -enum CaptureFrameSource: Equatable { +package enum CaptureFrameSource: Equatable { case dragRegion case window case fullScreen @@ -13,6 +13,45 @@ enum CaptureFrameSource: Equatable { package enum CaptureFrameEffectRenderer { package static func render( + image: CGImage, + background: CaptureFrameBackgroundPreference, + screen: NSScreen?, + source: CaptureFrameSource + ) -> CGImage? { + let imageSize = CGSize(width: image.width, height: image.height) + guard imageSize.width > 0, imageSize.height > 0 else { + return nil + } + let canvasSize = canvasSize(for: imageSize) + guard + let colorSpace = CGColorSpace(name: CGColorSpace.sRGB), + let context = CGContext( + data: nil, + width: Int(canvasSize.width.rounded()), + height: Int(canvasSize.height.rounded()), + bitsPerComponent: 8, + bytesPerRow: 0, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) + else { + return nil + } + + let canvasRect = CGRect(origin: .zero, size: canvasSize) + drawBackground(background, screen: screen, in: canvasRect, context: context) + drawFramedCapture( + image, + imageSize: imageSize, + in: canvasRect, + screen: screen, + source: source, + context: context + ) + return context.makeImage() + } + + package static func renderWindowSnapshot( image: CGImage, background: CaptureFrameBackgroundPreference, screen: NSScreen? @@ -39,7 +78,7 @@ package enum CaptureFrameEffectRenderer { let canvasRect = CGRect(origin: .zero, size: canvasSize) drawBackground(background, screen: screen, in: canvasRect, context: context) - drawFramedCapture(image, imageSize: imageSize, in: canvasRect, context: context) + drawFloatingWindowSnapshot(image, imageSize: imageSize, context: context) return context.makeImage() } @@ -69,8 +108,21 @@ package enum CaptureFrameEffectRenderer { return min(max(visualPadding, 48), maximumPadding) } - private static func cornerRadius(for imageSize: CGSize) -> CGFloat { - min(32, max(10, min(imageSize.width, imageSize.height) * 0.035)) + private static func cornerRadius( + for imageSize: CGSize, + screen: NSScreen?, + source: CaptureFrameSource + ) -> CGFloat { + let shortSide = min(imageSize.width, imageSize.height) + switch source { + case .window: + let scaleFactor = screen?.backingScaleFactor ?? 2 + return min(max(20 * scaleFactor, 24), shortSide * 0.055) + case .dragRegion: + return min(24, max(8, shortSide * 0.025)) + case .fullScreen, .scrollCapture, .unknown: + return min(28, max(8, shortSide * 0.025)) + } } private static func drawBackground( @@ -140,10 +192,12 @@ package enum CaptureFrameEffectRenderer { _ image: CGImage, imageSize: CGSize, in canvasRect: CGRect, + screen: NSScreen?, + source: CaptureFrameSource, context: CGContext ) { let imageRect = imageRect(for: imageSize) - let cornerRadius = cornerRadius(for: imageSize) + let cornerRadius = cornerRadius(for: imageSize, screen: screen, source: source) let capturePath = CGPath( roundedRect: imageRect, cornerWidth: cornerRadius, @@ -151,40 +205,64 @@ package enum CaptureFrameEffectRenderer { transform: nil ) - context.saveGState() - context.addPath(capturePath) - context.setShadow( + drawShadow( + path: capturePath, offset: .zero, - blur: max(48, min(canvasRect.width, canvasRect.height) * 0.065), - color: NSColor.black.withAlphaComponent(0.34).cgColor + blur: max(80, min(canvasRect.width, canvasRect.height) * 0.085), + alpha: 0.30, + context: context + ) + drawShadow( + path: capturePath, + offset: CGSize(width: 0, height: -max(22, canvasRect.height * 0.030)), + blur: max(46, min(canvasRect.width, canvasRect.height) * 0.050), + alpha: 0.36, + context: context + ) + drawShadow( + path: capturePath, + offset: CGSize(width: 0, height: -max(4, canvasRect.height * 0.006)), + blur: max(10, min(canvasRect.width, canvasRect.height) * 0.014), + alpha: 0.22, + context: context ) - context.setFillColor(NSColor.black.withAlphaComponent(0.24).cgColor) - context.fillPath() - context.restoreGState() context.saveGState() context.addPath(capturePath) - context.setShadow( - offset: CGSize(width: 0, height: -max(18, canvasRect.height * 0.026)), - blur: max(28, min(canvasRect.width, canvasRect.height) * 0.04), - color: NSColor.black.withAlphaComponent(0.40).cgColor - ) - context.setFillColor(NSColor.black.withAlphaComponent(0.28).cgColor) - context.fillPath() + context.clip() + context.interpolationQuality = .high + context.draw(image, in: imageRect) context.restoreGState() + } + private static func drawFloatingWindowSnapshot( + _ image: CGImage, + imageSize: CGSize, + context: CGContext + ) { + let imageRect = imageRect(for: imageSize) context.saveGState() - context.addPath(capturePath) - context.clip() context.interpolationQuality = .high context.draw(image, in: imageRect) context.restoreGState() + } + private static func drawShadow( + path: CGPath, + offset: CGSize, + blur: CGFloat, + alpha: CGFloat, + context: CGContext + ) { context.saveGState() - context.addPath(capturePath) - context.setStrokeColor(NSColor.black.withAlphaComponent(0.22).cgColor) - context.setLineWidth(max(1, min(imageSize.width, imageSize.height) * 0.0015)) - context.strokePath() + context.addPath(path) + context.setShadow( + offset: offset, + blur: blur, + color: NSColor.black.withAlphaComponent(alpha).cgColor + ) + context.setFillColor(NSColor.black.cgColor) + context.fillPath() context.restoreGState() } diff --git a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift index b08cd485..401a73ec 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift @@ -2035,10 +2035,13 @@ final class CaptureSessionController: NSObject { chromeState.frozenSelectionSnapshot = selection chromeState.frozenSelectionEditable = editable chromeState.frozenSelectionInteraction = nil - chromeState.captureFrameSource = captureFrameSource( + let frameSource = captureFrameSource( for: selection, editable: editable ) + chromeState.captureFrameSource = frameSource + chromeState.captureFrameWindowID = + frameSource == .window ? scene.highlightedWindow?.windowID : nil chromeState.frozenDisplayFrame = frozenFrame.displayFrame chromeState.frozenDisplayImage = frozenFrame.image let hostOwnedFrozenScene = hostOwnedFrozenPresentationScene( @@ -2289,6 +2292,7 @@ final class CaptureSessionController: NSObject { chromeState.frozenSelectionInteraction = nil chromeState.frozenSelectionSnapshot = selection chromeState.captureFrameSource = .scrollCapture + chromeState.captureFrameWindowID = nil chromeState.frozenDisplayFrame = nil chromeState.frozenDisplayImage = nil chromeState.frozenBaseImage = baseImage @@ -2724,7 +2728,11 @@ final class CaptureSessionController: NSObject { let composited = compositeFrozenOverlay(on: baseImage, selection: selection) ?? baseImage let result = applyingCaptureFrameEffect - ? applyCaptureFrameEffectIfNeeded(to: composited, selection: selection) + ? applyCaptureFrameEffectIfNeeded( + to: composited, + selection: selection, + hasOverlayEdits: hasOverlayEdits + ) : composited let compositeMilliseconds = NativeHostTelemetry.milliseconds(since: compositeStartedAt) let imageSource: String @@ -2752,19 +2760,76 @@ final class CaptureSessionController: NSObject { return result } - private func applyCaptureFrameEffectIfNeeded(to image: CGImage, selection: CGRect) -> CGImage { + private func applyCaptureFrameEffectIfNeeded( + to image: CGImage, + selection: CGRect, + hasOverlayEdits: Bool + ) -> CGImage { let settings = settingsStore.settings guard settings.shouldApplyCaptureFrameEffect(to: chromeState.captureFrameSource) else { return image } let selectionCenter = CGPoint(x: selection.midX, y: selection.midY) + let screen = screen(containing: selectionCenter) + if !hasOverlayEdits, + chromeState.captureFrameSource == .window, + let windowImage = captureFrameWindowImage() + { + return CaptureFrameEffectRenderer.renderWindowSnapshot( + image: windowImage, + background: settings.captureFrameBackground, + screen: screen + ) ?? image + } return CaptureFrameEffectRenderer.render( image: image, background: settings.captureFrameBackground, - screen: screen(containing: selectionCenter) + screen: screen, + source: chromeState.captureFrameSource ) ?? image } + private func captureFrameWindowImage() -> CGImage? { + guard let windowID = chromeState.captureFrameWindowID else { + return nil + } + guard let createImage = Self.captureFrameWindowListCreateImage else { + return nil + } + return createImage( + CGRect.null, + CGWindowListOption.optionIncludingWindow.rawValue, + windowID, + CGWindowImageOption.bestResolution.rawValue + )? + .takeRetainedValue() + } + + private typealias CaptureFrameWindowListCreateImage = + @convention(c) ( + CGRect, + UInt32, + CGWindowID, + UInt32 + ) -> Unmanaged? + + nonisolated private static let captureFrameWindowListCreateImage: + CaptureFrameWindowListCreateImage? = { + guard + let coreGraphics = dlopen( + "/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics", + RTLD_LAZY + ) + else { + return nil + } + guard let symbol = dlsym(coreGraphics, "CGWindowListCreateImage") else { + dlclose(coreGraphics) + return nil + } + return unsafeBitCast(symbol, to: CaptureFrameWindowListCreateImage.self) + }() + @discardableResult private func refreshFrozenBaseImageFromDisplay(for selection: CGRect) -> Bool { // Export must stay tied to the latched frozen display, not the live desktop. @@ -8427,6 +8492,7 @@ private struct CaptureChromeState { var frozenDisplayImage: CGImage? var frozenBaseImage: CGImage? var captureFrameSource: CaptureFrameSource = .unknown + var captureFrameWindowID: CGWindowID? var scrollMinimapPreview: ScrollCaptureMinimapSnapshot? var frozenOverlay = FrozenOverlayState() var annotationStyle = FrozenAnnotationStyleState() @@ -8448,6 +8514,7 @@ private struct CaptureChromeState { frozenDisplayImage = nil frozenBaseImage = nil captureFrameSource = .unknown + captureFrameWindowID = nil scrollMinimapPreview = nil frozenOverlay.reset() annotationStyle = FrozenAnnotationStyleState() @@ -8466,6 +8533,7 @@ private struct CaptureChromeState { frozenDisplayImage = nil frozenBaseImage = nil captureFrameSource = .unknown + captureFrameWindowID = nil scrollMinimapPreview = nil frozenOverlay.reset() annotationStyle = FrozenAnnotationStyleState() diff --git a/native/macos-host/Sources/RsnapNativeHostKitProbe/main.swift b/native/macos-host/Sources/RsnapNativeHostKitProbe/main.swift index 9d1f61a0..0fd5fc34 100644 --- a/native/macos-host/Sources/RsnapNativeHostKitProbe/main.swift +++ b/native/macos-host/Sources/RsnapNativeHostKitProbe/main.swift @@ -147,7 +147,8 @@ enum RsnapNativeHostKitProbe { let rendered = CaptureFrameEffectRenderer.render( image: source, background: .aurora, - screen: nil + screen: nil, + source: .window ) else { fatalError("capture frame renderer should produce an image for gradient presets") @@ -156,6 +157,17 @@ enum RsnapNativeHostKitProbe { else { fatalError("capture frame renderer size should match layout geometry") } + guard + let renderedWindowSnapshot = CaptureFrameEffectRenderer.renderWindowSnapshot( + image: source, + background: .aurora, + screen: nil + ), + renderedWindowSnapshot.width == Int(canvasSize.width), + renderedWindowSnapshot.height == Int(canvasSize.height) + else { + fatalError("window snapshot frame renderer should preserve layout geometry") + } guard let pixels = rgbaPixels(from: rendered) else { fatalError("could not read capture frame rendered pixels") } From 36b9b6adbc4bf21d122cebf5944147f754216b26 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Fri, 8 May 2026 18:36:52 +0800 Subject: [PATCH 3/3] {"schema":"maestro/commit/1","summary":"Polish capture frame settings and prepare v0.1.7","authority":"manual"} --- Cargo.lock | 8 +- Cargo.toml | 8 +- README.md | 14 ++- .../smoke-perf-validation-surface.md | 2 +- docs/runbook/performance-validation.md | 4 +- docs/runbook/scroll-capture-benchmarks.md | 2 +- docs/runbook/validate-release.md | 8 +- docs/spec/capture-session.md | 2 +- docs/spec/settings.md | 12 ++ .../NativeHostSettingsView.swift | 114 +++++++++++------- scripts/build_and_run.sh | 2 +- 11 files changed, 108 insertions(+), 68 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 6406c73f..f5da1d6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3363,7 +3363,7 @@ checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97" [[package]] name = "rsnap" -version = "0.1.6" +version = "0.1.7" dependencies = [ "color-eyre", "directories", @@ -3376,7 +3376,7 @@ dependencies = [ [[package]] name = "rsnap-capture-core" -version = "0.1.6" +version = "0.1.7" dependencies = [ "image", "serde", @@ -3384,7 +3384,7 @@ dependencies = [ [[package]] name = "rsnap-host-ffi" -version = "0.1.6" +version = "0.1.7" dependencies = [ "rsnap-capture-core", "rsnap-overlay", @@ -3392,7 +3392,7 @@ dependencies = [ [[package]] name = "rsnap-overlay" -version = "0.1.6" +version = "0.1.7" dependencies = [ "block2 0.6.2", "color-eyre", diff --git a/Cargo.toml b/Cargo.toml index f818adaf..287b3684 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ homepage = "https://hack.ink/rsnap" license = "GPL-3.0" readme = "README.md" repository = "https://github.com/hack-ink/rsnap" -version = "0.1.6" +version = "0.1.7" [workspace.dependencies] arboard = { version = "3.6" } @@ -52,9 +52,9 @@ wgpu = { version = "29.0" } winit = { version = "0.30", features = ["rwh_06"] } xcap = { version = "0.9" } -rsnap-capture-core = { version = "0.1.6", path = "packages/rsnap-capture-core" } -rsnap-host-ffi = { version = "0.1.6", path = "packages/rsnap-host-ffi" } -rsnap-overlay = { version = "0.1.6", path = "packages/rsnap-overlay" } +rsnap-capture-core = { version = "0.1.7", path = "packages/rsnap-capture-core" } +rsnap-host-ffi = { version = "0.1.7", path = "packages/rsnap-host-ffi" } +rsnap-overlay = { version = "0.1.7", path = "packages/rsnap-overlay" } [profile.final-release] inherits = "release" diff --git a/README.md b/README.md index 92ffb12f..0ab71f80 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ Prototype / in active development. - Menubar and Dock are not included in live window-outline targeting. - Windows support is planned (minimum Windows 10), but not implemented yet. - The scroll-capture engine, deterministic replay, and benchmark surfaces remain in the repository, - but the v0.1.6 native-host release does not expose scroll capture in the toolbar. + but the v0.1.7 native-host release does not expose scroll capture in the toolbar. ## Usage @@ -123,7 +123,7 @@ Rsnap currently relies on **Screen Recording** permission to capture other apps/ - ScreenCaptureKit live sampling on macOS requires macOS 12.3+ and Screen Recording permission. - Normal region/window/monitor capture does not require Accessibility or Input Monitoring. - The retained scroll-capture path uses Screen Recording-backed screenshots plus forwarded wheel - input, but the v0.1.6 native-host release does not expose scroll capture in the toolbar. + input, but the v0.1.7 native-host release does not expose scroll capture in the toolbar. - macOS may describe Screen Recording as `Screen & System Audio Recording` or as direct screen/audio access when Rsnap bypasses the system picker. - Settings -> Permissions shows Screen Recording as the only required permission. - Normal native capture depends on Screen Recording; if access is missing, Rsnap opens the Screen Recording page in System Settings and shows a floating drag-to-grant guide. @@ -150,13 +150,15 @@ Rsnap currently relies on **Screen Recording** permission to capture other apps/ - In Frozen mode, use Cmd+S (macOS) / Ctrl+S to save a PNG to disk and exit. - On macOS, use the frozen toolbar `Recognize Text` action to copy recognized text from the current frozen capture and exit. - Output is configured in the native `Settings…` window: - - `output directory` (default: Desktop) - - `filename prefix` (default: `Rsnap`, sanitized to `[A-Za-z0-9_-]`) - - `output naming` (`timestamp` or `sequence`) + - `Save Location` (default: Desktop) + - `Filename Prefix` (default: `Rsnap`, sanitized to `[A-Za-z0-9_-]`) + - `Naming` (`Timestamp` or `Sequence`) + - `Frame Preset` (`Off`, wallpaper, or gradient backgrounds) + - `Apply To` for drag-region and window captures; fullscreen captures are excluded ### Current scroll-capture status -Scroll capture is temporarily hidden in the v0.1.6 native-host release. The retained Rust +Scroll capture is temporarily hidden in the v0.1.7 native-host release. The retained Rust scroll-capture session, deterministic replay, and benchmark surfaces remain for validation and future re-enablement, but users should not expect a `Scroll Capture` toolbar item in this release. diff --git a/docs/reference/smoke-perf-validation-surface.md b/docs/reference/smoke-perf-validation-surface.md index dec18a5c..70c95945 100644 --- a/docs/reference/smoke-perf-validation-surface.md +++ b/docs/reference/smoke-perf-validation-surface.md @@ -17,7 +17,7 @@ Depends on: `docs/runbook/performance-validation.md`; `docs/spec/performance.md` Covers: The current layer map for smoke/perf entrypoints, deterministic replay/bench surfaces, overlay runtime integration tests, and scroll-capture session semantics tests. -Release exposure note: v0.1.6 hides user-facing scroll capture in the native host. The +Release exposure note: v0.1.7 hides user-facing scroll capture in the native host. The scroll-capture entries in this reference describe retained internal validation assets, not a visible toolbar feature in that release. diff --git a/docs/runbook/performance-validation.md b/docs/runbook/performance-validation.md index ea207072..08234e3b 100644 --- a/docs/runbook/performance-validation.md +++ b/docs/runbook/performance-validation.md @@ -17,9 +17,9 @@ Depends on: `docs/spec/performance.md` Outputs: A clear command choice for the regression class you are testing, plus a repeatable local baseline workflow for the committed Criterion benchmark targets. -Current release status: v0.1.6 hides user-facing scroll capture in the native host. The replay and +Current release status: v0.1.7 hides user-facing scroll capture in the native host. The replay and benchmark commands in this runbook still own retained internal scroll-capture engine validation and -future re-enablement work, but they are not evidence that the v0.1.6 toolbar exposes scroll +future re-enablement work, but they are not evidence that the v0.1.7 toolbar exposes scroll capture. ## Command selection diff --git a/docs/runbook/scroll-capture-benchmarks.md b/docs/runbook/scroll-capture-benchmarks.md index 4473f0d0..91b55af5 100644 --- a/docs/runbook/scroll-capture-benchmarks.md +++ b/docs/runbook/scroll-capture-benchmarks.md @@ -14,7 +14,7 @@ Depends on: `docs/spec/performance.md` Outputs: A repeatable local benchmark run, an optional saved Criterion baseline, and a clear understanding of what the synthetic fixture is intended to cover. -Current release status: v0.1.6 hides user-facing scroll capture in the native host. This runbook +Current release status: v0.1.7 hides user-facing scroll capture in the native host. This runbook still applies to the retained internal scroll-capture engine, replay, and future re-enablement work. diff --git a/docs/runbook/validate-release.md b/docs/runbook/validate-release.md index 0dba80c9..ac943ed4 100644 --- a/docs/runbook/validate-release.md +++ b/docs/runbook/validate-release.md @@ -29,7 +29,7 @@ manual first-run/user-flow validation. - Sparkle update signing is configured: `SUPublicEDKey` is checked into `scripts/build_and_run.sh`, and `SPARKLE_PRIVATE_ED_KEY` is available to the Release workflow for signing the published update archive. - - Apple notary credentials are optional for v0.1.6; when absent, the Release workflow still + - Apple notary credentials are optional for v0.1.7; when absent, the Release workflow still publishes a signed but unnotarized macOS zip. 3. Confirm local gates: - `cargo make checks` @@ -58,7 +58,7 @@ Validate these user-visible flows: fullscreen fallback. - Frozen toolbar tools: pointer, pen, arrow, text, mosaic, spotlight, undo, redo, auto-center, Recognize Text, copy, and save. -- Scroll capture is hidden in the v0.1.6 native-host release: the toolbar must not show a scroll +- Scroll capture is hidden in the v0.1.7 native-host release: the toolbar must not show a scroll capture item, and pressing `s` must not enter scroll capture. - Light and dark appearance; Classic Glass and Liquid Glass where the OS and current build support Liquid Glass. @@ -66,6 +66,8 @@ Validate these user-visible flows: titles. The Auto Update mode control must show `Off`, `Notify`, and `Install`; secondary text must use sentence case, must not look like download progress, and the release-configured build must not report that the Sparkle appcast is missing. +- Settings -> Output frame rows: `Frame Preset` must include `Off`, selecting `Off` must disable + `Apply To`, and selecting a background preset must re-enable `Apply To`. - Sparkle local update smoke: ```sh @@ -96,7 +98,7 @@ user-entered annotation text. 4. Treat notarization failure as a release blocker only when notary credentials are configured. 5. The Release workflow publishes the signed macOS zip and `appcast.xml` to the GitHub release. It notarizes and staples the app only when notary credentials are configured. It does not - publish crates.io packages or non-macOS desktop archives for v0.1.6. + publish crates.io packages or non-macOS desktop archives for v0.1.7. ## Published Artifact Check diff --git a/docs/spec/capture-session.md b/docs/spec/capture-session.md index 51018b2f..7286ea89 100644 --- a/docs/spec/capture-session.md +++ b/docs/spec/capture-session.md @@ -162,7 +162,7 @@ product level rather than binding itself to a particular window toolkit or shell ## Scroll capture -- The v0.1.6 native-host release does not expose scroll capture. The frozen toolbar MUST NOT show a +- The v0.1.7 native-host release does not expose scroll capture. The frozen toolbar MUST NOT show a scroll-capture item while the native-host scroll-capture gate is disabled, and plain `s` MUST NOT enter scroll capture in that state. - When scroll capture is re-enabled, it is available only from a dragged-region freeze on macOS. diff --git a/docs/spec/settings.md b/docs/spec/settings.md index 0dcf3360..b8cadc87 100644 --- a/docs/spec/settings.md +++ b/docs/spec/settings.md @@ -75,6 +75,8 @@ Defines: - Output directory: `~/Desktop`. - Output filename prefix: `Rsnap`. - Output naming: timestamp. +- Capture frame preset: Off. +- Capture frame apply-to: window. - Frozen toolbar placement: bottom. - Frozen resize handles: outward. - Live HUD hint keycap: enabled. @@ -88,6 +90,16 @@ Defines: - Loupe sample size: small. - Sparkle update mode: install in release builds. +## Output Settings + +- The Output section must expose save location, filename prefix, naming, frame preset, and frame + applicability controls. +- Frame Preset must be a single control with Off and the available background presets. Off disables + the capture frame effect without requiring a separate Export Frame toggle. +- Apply To must be disabled while Frame Preset is Off. +- Capture frame effects may apply to drag-region captures, window captures, or both. Fullscreen + captures are excluded from this setting. + ## Permission Settings - Settings must include a Permissions section. diff --git a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettingsView.swift b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettingsView.swift index c68337d7..4722f13e 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettingsView.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettingsView.swift @@ -513,7 +513,7 @@ private struct OutputInspector: View { InspectorMetric( title: "Frame", value: settings.captureFrameEffectEnabled - ? settings.captureFrameBackground.title : "Off", + ? settings.captureFrameBackground.title : CaptureFramePresetOption.off.title, symbolName: "photo.on.rectangle.angled" ) } @@ -934,10 +934,32 @@ private struct OutputNamingPicker: View { } } -private struct CaptureFrameBackgroundPicker: View { - let selection: CaptureFrameBackgroundPreference - let isEnabled: Bool - let onSelect: (CaptureFrameBackgroundPreference) -> Void +private enum CaptureFramePresetOption: Hashable, Identifiable { + case off + case background(CaptureFrameBackgroundPreference) + + var id: String { + switch self { + case .off: + return "off" + case .background(let background): + return background.rawValue + } + } + + var title: String { + switch self { + case .off: + return "Off" + case .background(let background): + return background.title + } + } +} + +private struct CaptureFramePresetPicker: View { + let selection: CaptureFramePresetOption + let onSelect: (CaptureFramePresetOption) -> Void var body: some View { Picker( @@ -947,14 +969,15 @@ private struct CaptureFrameBackgroundPicker: View { set: { value in onSelect(value) } ) ) { + Text(CaptureFramePresetOption.off.title).tag(CaptureFramePresetOption.off) ForEach(CaptureFrameBackgroundPreference.allCases, id: \.rawValue) { background in - Text(background.title).tag(background) + let option = CaptureFramePresetOption.background(background) + Text(option.title).tag(option) } } .labelsHidden() .pickerStyle(.menu) .frame(width: SettingsControlLayout.controlColumnWidth) - .disabled(!isEnabled) } } @@ -1097,7 +1120,7 @@ private struct AppearanceSettingsPanel: View { if model.settings.resolvedHudGlassMode == .liquidGlass { SettingsControlTile( symbolName: "circle.hexagongrid", - title: "Liquid style", + title: "Liquid Style", subtitle: "Material profile." ) { LiquidGlassStylePicker( @@ -1115,7 +1138,7 @@ private struct AppearanceSettingsPanel: View { if model.settings.resolvedHudGlassMode == .classicGlass { SettingsControlTile( symbolName: "slider.horizontal.3", - title: "Classic tuning", + title: "Classic Tuning", subtitle: "Opacity and blur." ) { SettingsCompactSliderStack( @@ -1138,7 +1161,7 @@ private struct AppearanceSettingsPanel: View { VStack(spacing: 0) { SettingsControlTile( symbolName: "eyedropper.halffull", - title: "Tint strength", + title: "Tint Strength", subtitle: "Accent weight." ) { SettingsTileSlider( @@ -1152,7 +1175,7 @@ private struct AppearanceSettingsPanel: View { SettingsControlTile( symbolName: "paintpalette", - title: "Tint color", + title: "Tint Color", subtitle: "HUD accent." ) { FlatColorSwatch( @@ -1467,7 +1490,7 @@ private struct CaptureSettingsPanel: View { VStack(spacing: 8) { SettingsHeroControlTile( symbolName: "keyboard", - title: "New capture shortcut", + title: "New Capture Shortcut", subtitle: "Current: \(shortcutPresentation.displayTitle)." ) { CaptureHotKeyField(model: model) @@ -1476,7 +1499,7 @@ private struct CaptureSettingsPanel: View { VStack(spacing: 0) { SettingsControlTile( symbolName: "rectangle.bottomthird.inset.filled", - title: "Frozen toolbar", + title: "Frozen Toolbar", subtitle: "Command bar." ) { ToolbarPlacementPicker(selection: model.settings.toolbarPlacement) { value in @@ -1486,7 +1509,7 @@ private struct CaptureSettingsPanel: View { SettingsControlTile( symbolName: "crop", - title: "Corner handles", + title: "Corner Handles", subtitle: "Resize direction." ) { FrozenResizeHandleOrientationPicker( @@ -1500,7 +1523,7 @@ private struct CaptureSettingsPanel: View { VStack(spacing: 0) { SettingsControlTile( symbolName: "plus.magnifyingglass", - title: "Loupe sample", + title: "Loupe Sample", subtitle: "Patch size." ) { LoupeSampleSizePicker(selection: model.settings.loupeSampleSize) { value in @@ -1510,7 +1533,7 @@ private struct CaptureSettingsPanel: View { SettingsControlTile( symbolName: "lightbulb", - title: "HUD hint", + title: "HUD Hint", subtitle: "Tab keycap." ) { Toggle( @@ -1605,7 +1628,7 @@ private struct PermissionsSettingsPanel: View { SettingsHeroControlTile( symbolName: "power", - title: "Open at Login", + title: "Open At Login", subtitle: model.launchAtLoginState.subtitle ) { LaunchAtLoginToggle(model: model) @@ -1961,7 +1984,7 @@ private struct OutputSettingsPanel: View { VStack(spacing: 8) { SettingsHeroControlTile( symbolName: "folder", - title: "Save location", + title: "Save Location", subtitle: abbreviatedPath(model.settings.outputDirectory) ) { Button(action: model.chooseOutputDirectory) { @@ -1975,7 +1998,7 @@ private struct OutputSettingsPanel: View { VStack(spacing: 0) { SettingsControlTile( symbolName: "textformat.abc", - title: "Filename prefix", + title: "Filename Prefix", subtitle: "Safe text." ) { TextField( @@ -2009,45 +2032,25 @@ private struct OutputSettingsPanel: View { } VStack(spacing: 0) { - SettingsControlTile( - symbolName: "photo.on.rectangle.angled", - title: "Export frame", - subtitle: "Wallpaper canvas." - ) { - Toggle( - "", - isOn: Binding( - get: { model.settings.captureFrameEffectEnabled }, - set: { value in - model.update { $0.captureFrameEffectEnabled = value } - } - ) - ) - .labelsHidden() - .toggleStyle(SettingsToggleStyle()) - } - SettingsControlTile( symbolName: "rectangle.on.rectangle", - title: "Frame preset", + title: "Frame Preset", subtitle: "Background style." ) { - CaptureFrameBackgroundPicker( - selection: model.settings.captureFrameBackground, - isEnabled: model.settings.captureFrameEffectEnabled - ) { value in - model.update { $0.captureFrameBackground = value } + CaptureFramePresetPicker(selection: captureFramePresetSelection) { + option in + updateCaptureFramePreset(option) } } SettingsControlTile( symbolName: "viewfinder", - title: "Apply to", + title: "Apply To", subtitle: "Fullscreen excluded." ) { CaptureFrameApplicabilityPicker( selection: model.settings.captureFrameApplicability, - isEnabled: model.settings.captureFrameEffectEnabled + isEnabled: captureFrameApplyToEnabled ) { value in model.update { $0.captureFrameApplicability = value } } @@ -2056,6 +2059,27 @@ private struct OutputSettingsPanel: View { } } + private var captureFramePresetSelection: CaptureFramePresetOption { + model.settings.captureFrameEffectEnabled + ? .background(model.settings.captureFrameBackground) : .off + } + + private var captureFrameApplyToEnabled: Bool { + model.settings.captureFrameEffectEnabled + } + + private func updateCaptureFramePreset(_ option: CaptureFramePresetOption) { + model.update { settings in + switch option { + case .off: + settings.captureFrameEffectEnabled = false + case .background(let background): + settings.captureFrameEffectEnabled = true + settings.captureFrameBackground = background + } + } + } + private func abbreviatedPath(_ url: URL) -> String { let path = url.path let home = FileManager.default.homeDirectoryForCurrentUser.path diff --git a/scripts/build_and_run.sh b/scripts/build_and_run.sh index 452d617e..97891685 100755 --- a/scripts/build_and_run.sh +++ b/scripts/build_and_run.sh @@ -65,7 +65,7 @@ APP_VERSION="${RSNAP_NATIVE_HOST_APP_VERSION:-}" if [[ -z "$APP_VERSION" ]]; then APP_VERSION="$(sed -n '/^\[workspace.package\]/,/^\[/s/^version *= *"\(.*\)"/\1/p' "$ROOT_DIR/Cargo.toml" | head -n 1)" fi -APP_VERSION="${APP_VERSION:-0.1.6}" + APP_VERSION="${APP_VERSION:-0.1.7}" require_liquid_glass_capable_swift_for_release() { [[ "$SWIFT_CONFIGURATION" == "release" ]] || return 0