diff --git a/docs/spec/settings.md b/docs/spec/settings.md index e382a77c..993e835f 100644 --- a/docs/spec/settings.md +++ b/docs/spec/settings.md @@ -98,6 +98,14 @@ Defines: 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. +- Frame Preset must render as a compact horizontal swatch selector. Off is represented by an empty + slash swatch, and background presets are represented by small background thumbnails without + visible option labels. Preset growth must preserve swatch size instead of shrinking cards to fit; + overflow must remain mouse-accessible through lightweight step controls, including click-and-hold + repeated movement for long preset lists. +- Wallpaper swatches must not synchronously decode full wallpaper files in Swift. Swift may discover + the current wallpaper path and present pixels, but bounded wallpaper thumbnail decoding and caching + are Rust-owned. - 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. diff --git a/native/macos-host/Sources/RsnapNativeHostKit/CaptureFrameEffect.swift b/native/macos-host/Sources/RsnapNativeHostKit/CaptureFrameEffect.swift index 99fb2ac4..df9d5edf 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/CaptureFrameEffect.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/CaptureFrameEffect.swift @@ -48,6 +48,23 @@ package enum CaptureFrameEffectRenderer { captureFramePlan(for: imageSize, screen: nil, source: .unknown)?.imageRect ?? .zero } + package static func backgroundPlan( + for background: CaptureFrameBackgroundPreference + ) -> CaptureFrameBackgroundPlan? { + try? RsnapCaptureFramePlanner.backgroundPlan(for: background.planKind) + } + + package static func systemWallpaperPath(screen: NSScreen?) -> String? { + guard + let screen = screen ?? NSScreen.main, + let url = NSWorkspace.shared.desktopImageURL(for: screen) + else { + return nil + } + + return url.standardizedFileURL.path + } + private static func renderWithRust( image: CGImage, background: CaptureFrameBackgroundPreference, @@ -87,14 +104,7 @@ package enum CaptureFrameEffectRenderer { guard background == .systemWallpaper else { return nil } - guard - let screen = screen ?? NSScreen.main, - let url = NSWorkspace.shared.desktopImageURL(for: screen) - else { - return nil - } - - return url.standardizedFileURL.path + return systemWallpaperPath(screen: screen) } private static func captureFramePlan( diff --git a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettingsView.swift b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettingsView.swift index c009b6d3..a563732f 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettingsView.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettingsView.swift @@ -186,6 +186,10 @@ private enum SettingsControlLayout { static let sliderTrackWidth: CGFloat = 136 static let compactSliderLabelWidth: CGFloat = 44 static let compactSliderTrackWidth: CGFloat = 88 + static let framePresetSelectorHeight: CGFloat = 30 + static let framePresetSwatchWidth: CGFloat = 42 + static let framePresetSwatchHeight: CGFloat = 24 + static let framePresetSwatchSpacing: CGFloat = 6 } private struct SettingsRail: View { @@ -955,29 +959,523 @@ private enum CaptureFramePresetOption: Hashable, Identifiable { return background.title } } + + static var allOptions: [CaptureFramePresetOption] { + [.off] + CaptureFrameBackgroundPreference.allCases.map { .background($0) } + } } -private struct CaptureFramePresetPicker: View { +private struct CaptureFramePresetSelector: View { let selection: CaptureFramePresetOption let onSelect: (CaptureFramePresetOption) -> Void + @State private var leadingIndex = 0 var body: some View { - Picker( - "", - selection: Binding( - get: { selection }, - set: { value in onSelect(value) } + let options = CaptureFramePresetOption.allOptions + + ZStack { + HStack(spacing: SettingsControlLayout.framePresetSwatchSpacing) { + ForEach(options) { option in + CaptureFramePresetSwatchButton(option: option, isSelected: option == selection) + { + onSelect(option) + } + } + } + .padding(.horizontal, 1) + .padding(.vertical, 2) + .fixedSize(horizontal: true, vertical: false) + .offset(x: -scrollOffset(for: options.count)) + .animation(.easeOut(duration: 0.16), value: leadingIndex) + .frame( + width: contentWidth(for: options.count), + height: SettingsControlLayout.framePresetSelectorHeight, + alignment: .leading ) - ) { - Text(CaptureFramePresetOption.off.title).tag(CaptureFramePresetOption.off) - ForEach(CaptureFrameBackgroundPreference.allCases, id: \.rawValue) { background in - let option = CaptureFramePresetOption.background(background) - Text(option.title).tag(option) + .frame( + width: SettingsControlLayout.controlColumnWidth, + height: SettingsControlLayout.framePresetSelectorHeight, + alignment: .leading + ) + .clipped() + .mask { + selectorMask(optionCount: options.count) + } + + HStack { + if canStepBackward { + stepButton(systemName: "chevron.left", label: "Previous frame preset") { + shiftLeadingIndex(by: -1, optionCount: options.count) + } + } + Spacer(minLength: 0) + if canStepForward(optionCount: options.count) { + stepButton(systemName: "chevron.right", label: "Next frame preset") { + shiftLeadingIndex(by: 1, optionCount: options.count) + } + } } } - .labelsHidden() - .pickerStyle(.menu) - .frame(width: SettingsControlLayout.controlColumnWidth) + .frame( + width: SettingsControlLayout.controlColumnWidth, + height: SettingsControlLayout.framePresetSelectorHeight + ) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 8).onEnded { value in + if value.translation.width < -14 { + shiftLeadingIndex(by: 1, optionCount: options.count) + } else if value.translation.width > 14 { + shiftLeadingIndex(by: -1, optionCount: options.count) + } + } + ) + .onAppear { + revealSelection(options: options) + } + .onChange(of: selection.id) { _, _ in + revealSelection(options: options) + } + .accessibilityElement(children: .contain) + } + + private var canStepBackward: Bool { + leadingIndex > 0 + } + + private func canStepForward(optionCount: Int) -> Bool { + leadingIndex < maxLeadingIndex(for: optionCount) + } + + @ViewBuilder + private func stepButton(systemName: String, label: String, action: @escaping () -> Void) + -> some View + { + RepeatingFramePresetStepButton( + systemName: systemName, + label: label, + action: action + ) + } + + @ViewBuilder + private func selectorMask(optionCount: Int) -> some View { + HStack(spacing: 0) { + if canStepBackward { + LinearGradient( + colors: [.clear, .black], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: 18) + } + Rectangle().fill(.black) + if canStepForward(optionCount: optionCount) { + LinearGradient( + colors: [.black, .clear], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: 18) + } + } + } + + private func revealSelection(options: [CaptureFramePresetOption]) { + guard let selectedIndex = options.firstIndex(of: selection) else { + return + } + let visibleCount = visibleOptionCount + let upperVisibleIndex = leadingIndex + visibleCount - 1 + let updatedIndex = + if selectedIndex < leadingIndex { + selectedIndex + } else if selectedIndex > upperVisibleIndex { + selectedIndex - visibleCount + 1 + } else { + leadingIndex + } + leadingIndex = clampedLeadingIndex(updatedIndex, optionCount: options.count) + } + + private func shiftLeadingIndex(by delta: Int, optionCount: Int) { + leadingIndex = clampedLeadingIndex(leadingIndex + delta, optionCount: optionCount) + } + + private var visibleOptionCount: Int { + let itemAdvance = + SettingsControlLayout.framePresetSwatchWidth + + SettingsControlLayout.framePresetSwatchSpacing + return max( + Int( + floor( + (SettingsControlLayout.controlColumnWidth + + SettingsControlLayout.framePresetSwatchSpacing) + / itemAdvance) + ), + 1 + ) + } + + private func maxLeadingIndex(for optionCount: Int) -> Int { + max(optionCount - visibleOptionCount, 0) + } + + private func clampedLeadingIndex(_ index: Int, optionCount: Int) -> Int { + min(max(index, 0), maxLeadingIndex(for: optionCount)) + } + + private func scrollOffset(for optionCount: Int) -> CGFloat { + let itemAdvance = + SettingsControlLayout.framePresetSwatchWidth + + SettingsControlLayout.framePresetSwatchSpacing + let requestedOffset = + CGFloat(clampedLeadingIndex(leadingIndex, optionCount: optionCount)) + * itemAdvance + return min(requestedOffset, maxContentOffset(for: optionCount)) + } + + private func contentWidth(for optionCount: Int) -> CGFloat { + guard optionCount > 0 else { + return 0 + } + return CGFloat(optionCount) * SettingsControlLayout.framePresetSwatchWidth + + CGFloat(optionCount - 1) * SettingsControlLayout.framePresetSwatchSpacing + 2 + } + + private func maxContentOffset(for optionCount: Int) -> CGFloat { + max(contentWidth(for: optionCount) - SettingsControlLayout.controlColumnWidth, 0) + } +} + +private struct RepeatingFramePresetStepButton: View { + let systemName: String + let label: String + let action: () -> Void + @State private var isPressed = false + @State private var repeatCount = 0 + @State private var repeatWorkItem: DispatchWorkItem? + + var body: some View { + Image(systemName: systemName) + .symbolRenderingMode(.hierarchical) + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(Color.primary.opacity(isPressed ? 0.90 : 0.74)) + .frame(width: 18, height: 22) + .background(.thinMaterial, in: Capsule()) + .overlay { + Capsule().stroke(Color.primary.opacity(isPressed ? 0.18 : 0.10), lineWidth: 1) + } + .scaleEffect(isPressed ? 0.96 : 1) + .contentShape(Capsule()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + beginPress() + } + .onEnded { _ in + endPress() + } + ) + .animation(.easeOut(duration: 0.10), value: isPressed) + .help(label) + .accessibilityLabel(label) + .accessibilityAddTraits(.isButton) + .accessibilityAction { + action() + } + .onDisappear(perform: endPress) + } + + private func beginPress() { + guard isPressed == false else { + return + } + + isPressed = true + repeatCount = 0 + action() + scheduleRepeat(after: 0.34) + } + + private func endPress() { + isPressed = false + repeatCount = 0 + repeatWorkItem?.cancel() + repeatWorkItem = nil + } + + private func scheduleRepeat(after delay: TimeInterval) { + repeatWorkItem?.cancel() + let workItem = DispatchWorkItem { + guard isPressed else { + return + } + + action() + repeatCount += 1 + scheduleRepeat(after: repeatInterval) + } + repeatWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: workItem) + } + + private var repeatInterval: TimeInterval { + switch repeatCount { + case 0..<4: + return 0.15 + case 4..<10: + return 0.10 + default: + return 0.065 + } + } +} + +private struct CaptureFramePresetSwatchButton: View { + let option: CaptureFramePresetOption + let isSelected: Bool + let onSelect: () -> Void + @State private var isHovered = false + + var body: some View { + Button(action: onSelect) { + CaptureFramePresetSwatch(option: option, isSelected: isSelected) + .scaleEffect(isHovered ? 1.04 : 1) + } + .buttonStyle(.plain) + .help(option.title) + .accessibilityLabel(option.title) + .accessibilityValue(isSelected ? "Selected" : "") + .animation(.easeOut(duration: 0.12), value: isHovered) + .onHover { hovering in + isHovered = hovering + } + } +} + +private struct CaptureFramePresetSwatch: View { + let option: CaptureFramePresetOption + let isSelected: Bool + @Environment(\.colorScheme) private var colorScheme + @State private var wallpaperImage: NSImage? + @State private var wallpaperThumbnailRequestID: String? + + var body: some View { + ZStack { + swatchFill + offOverlay + wallpaperFallbackOverlay + selectionBadge + } + .frame( + width: SettingsControlLayout.framePresetSwatchWidth, + height: SettingsControlLayout.framePresetSwatchHeight + ) + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(borderColor, lineWidth: isSelected ? 1.6 : 1) + } + .shadow(color: shadowColor, radius: isSelected ? 3 : 0, x: 0, y: 1) + .onAppear(perform: refreshWallpaperThumbnail) + } + + @ViewBuilder + private var swatchFill: some View { + switch option { + case .off: + LinearGradient( + colors: [ + Color.primary.opacity(colorScheme == .light ? 0.035 : 0.080), + Color.primary.opacity(colorScheme == .light ? 0.070 : 0.135), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + case .background(let background): + if background == .systemWallpaper, let wallpaperImage { + Image(nsImage: wallpaperImage) + .resizable() + .interpolation(.high) + .scaledToFill() + } else { + backgroundGradient(for: background) + } + } + } + + @ViewBuilder + private var offOverlay: some View { + if case .off = option { + Image(systemName: "slash") + .symbolRenderingMode(.hierarchical) + .font(.system(size: 13, weight: .bold)) + .foregroundStyle(Color.secondary.opacity(colorScheme == .light ? 0.64 : 0.78)) + } + } + + @ViewBuilder + private var wallpaperFallbackOverlay: some View { + if case .background(.systemWallpaper) = option, wallpaperImage == nil { + Image(systemName: "photo") + .symbolRenderingMode(.hierarchical) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Color.white.opacity(0.72)) + .shadow(color: Color.black.opacity(0.22), radius: 2, x: 0, y: 1) + } + } + + @ViewBuilder + private var selectionBadge: some View { + if isSelected { + VStack { + Spacer() + HStack { + Spacer() + Circle() + .fill(Color.accentColor) + .frame(width: 11, height: 11) + .overlay { + Image(systemName: "checkmark") + .font(.system(size: 6.5, weight: .black)) + .foregroundStyle(Color.white) + } + .padding(3) + } + } + } + } + + private var borderColor: Color { + if isSelected { + return Color.accentColor.opacity(colorScheme == .light ? 0.90 : 0.95) + } + return colorScheme == .light ? Color.black.opacity(0.12) : Color.white.opacity(0.18) + } + + private var shadowColor: Color { + Color.accentColor.opacity(colorScheme == .light ? 0.15 : 0.20) + } + + private func backgroundGradient( + for background: CaptureFrameBackgroundPreference + ) -> LinearGradient { + let plan = CaptureFrameEffectRenderer.backgroundPlan(for: background) + let colorStops: [CaptureFrameColorStop] = plan?.colorStops ?? fallbackColorStops + let locations: [CGFloat] = plan?.locations ?? fallbackLocations + var gradientStops: [Gradient.Stop] = [] + + for index in colorStops.indices { + let colorStop = colorStops[index] + let location = locations.indices.contains(index) ? locations[index] : CGFloat(index) + let color = Color( + red: Double(colorStop.red), + green: Double(colorStop.green), + blue: Double(colorStop.blue), + opacity: Double(colorStop.alpha) + ) + gradientStops.append( + Gradient.Stop(color: color, location: location.clamped(to: 0...1))) + } + + return LinearGradient( + gradient: Gradient(stops: gradientStops), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + + private var fallbackColorStops: [CaptureFrameColorStop] { + [ + CaptureFrameColorStop(red: 0.10, green: 0.16, blue: 0.28, alpha: 1), + CaptureFrameColorStop(red: 0.30, green: 0.47, blue: 0.71, alpha: 1), + CaptureFrameColorStop(red: 0.95, green: 0.61, blue: 0.43, alpha: 1), + ] + } + + private var fallbackLocations: [CGFloat] { + [0, 0.54, 1] + } + + private func refreshWallpaperThumbnail() { + guard case .background(.systemWallpaper) = option else { + wallpaperImage = nil + wallpaperThumbnailRequestID = nil + return + } + guard + let wallpaperPath = CaptureFrameEffectRenderer.systemWallpaperPath( + screen: NSScreen.main) + else { + wallpaperImage = nil + wallpaperThumbnailRequestID = nil + return + } + + let targetPixelSize = max( + Int( + (SettingsControlLayout.framePresetSwatchWidth + * (NSScreen.main?.backingScaleFactor ?? 2)) + .rounded(.up) + ), + 1 + ) + let requestID = "\(wallpaperPath)#\(targetPixelSize)" + guard wallpaperThumbnailRequestID != requestID else { + return + } + + wallpaperThumbnailRequestID = requestID + wallpaperImage = nil + DispatchQueue.global(qos: .utility).async { + let snapshot = try? RsnapWallpaperThumbnailDecoder.pngThumbnail( + path: wallpaperPath, + targetPixelSize: targetPixelSize + ) + DispatchQueue.main.async { + guard wallpaperThumbnailRequestID == requestID else { + return + } + wallpaperImage = snapshot.flatMap(Self.image) + } + } + } + + private static func image(from snapshot: RGBARegionSnapshot) -> NSImage? { + let expectedByteCount = snapshot.width * snapshot.height * 4 + guard + snapshot.width > 0, + snapshot.height > 0, + snapshot.rgba.count == expectedByteCount, + let colorSpace = CGColorSpace(name: CGColorSpace.sRGB), + let provider = CGDataProvider(data: snapshot.rgba as CFData) + else { + return nil + } + + guard + let cgImage = CGImage( + width: snapshot.width, + height: snapshot.height, + bitsPerComponent: 8, + bitsPerPixel: 32, + bytesPerRow: snapshot.width * 4, + space: colorSpace, + bitmapInfo: CGBitmapInfo(rawValue: CGImageAlphaInfo.last.rawValue), + provider: provider, + decode: nil, + shouldInterpolate: true, + intent: .defaultIntent + ) + else { + return nil + } + + return NSImage( + cgImage: cgImage, + size: NSSize(width: snapshot.width, height: snapshot.height) + ) } } @@ -2037,7 +2535,7 @@ private struct OutputSettingsPanel: View { title: "Frame Preset", subtitle: "Background style." ) { - CaptureFramePresetPicker(selection: captureFramePresetSelection) { + CaptureFramePresetSelector(selection: captureFramePresetSelection) { option in updateCaptureFramePreset(option) } diff --git a/packages/rsnap-host-ffi/src/lib.rs b/packages/rsnap-host-ffi/src/lib.rs index f1c09722..1f22541d 100644 --- a/packages/rsnap-host-ffi/src/lib.rs +++ b/packages/rsnap-host-ffi/src/lib.rs @@ -1908,7 +1908,7 @@ pub unsafe extern "C" fn rsnap_capture_frame_wallpaper_request_plan( RsnapStatus::Ok } -/// Decodes a PNG wallpaper thumbnail with Rust's streaming low-memory path. +/// Decodes a PNG wallpaper thumbnail through Rust's streaming low-memory cached path. /// /// Non-PNG paths and decode failures return `Empty` so native hosts can skip wallpaper drawing. /// @@ -1939,7 +1939,7 @@ pub unsafe extern "C" fn rsnap_capture_frame_wallpaper_png_thumbnail( return RsnapStatus::InvalidInput; }; let Ok(Some(thumbnail)) = - rsnap_capture_core::capture_frame_wallpaper_png_thumbnail(path, target_pixel_size) + rsnap_capture_core::capture_frame_wallpaper_png_thumbnail_cached(path, target_pixel_size) else { return RsnapStatus::Empty; };