diff --git a/.github/workflows/language.yml b/.github/workflows/language.yml index b820ec59..d5bfb82c 100644 --- a/.github/workflows/language.yml +++ b/.github/workflows/language.yml @@ -82,7 +82,7 @@ jobs: - name: Run Rust clippy run: cargo make check-rust - - name: Run tests + - name: Run Rust tests run: cargo make test-rust swift-check: @@ -112,9 +112,20 @@ jobs: - name: Run Swift format check run: cargo make fmt-swift-check + - name: Run Swift style check + uses: hack-ink/vibe-style@bfb4d2d2f5e4b5e5ce8de4ed1d708b3a2f0e61fe # v0.2.1 + with: + language: swift + workspace: true + args: --all-features + version: v0.2.1 + - name: Run SwiftLint and strict build run: cargo make check-swift + - name: Run Swift tests + run: cargo make test-swift + toml-check: name: TOML check runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index f5da1d6f..195619db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1214,6 +1214,18 @@ dependencies = [ "once_cell", ] +[[package]] +name = "fast_image_resize" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12dd43e5011e8d8411a3215a0d57a2ec5c68282fb90eb5d7221fab0113442174" +dependencies = [ + "cfg-if", + "document-features", + "num-traits", + "thiserror 2.0.18", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -3378,7 +3390,10 @@ dependencies = [ name = "rsnap-capture-core" version = "0.1.7" dependencies = [ + "color-eyre", + "fast_image_resize", "image", + "png", "serde", ] @@ -3432,6 +3447,16 @@ dependencies = [ "xcap", ] +[[package]] +name = "rsnap-perf" +version = "0.1.7" +dependencies = [ + "color-eyre", + "image", + "rsnap-capture-core", + "rsnap-overlay", +] + [[package]] name = "rustc-demangle" version = "0.1.27" diff --git a/Cargo.toml b/Cargo.toml index 287b3684..8ae7b910 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ egui = { version = "0.34" } egui-phosphor = { version = "0.12", features = ["fill"] } egui-wgpu = { version = "0.34" } egui-winit = { version = "0.34" } +fast_image_resize = { version = "6.0" } font8x8 = { version = "0.3" } fontdb = { version = "0.23" } fontdue = { version = "0.9" } @@ -38,6 +39,7 @@ objc2-core-video = { version = "0.3" } objc2-foundation = { version = "0.3" } objc2-screen-capture-kit = { version = "0.3" } objc2-vision = { version = "0.3" } +png = { version = "0.18" } pollster = { version = "0.4" } raw-window-handle = { version = "0.6" } serde = { version = "1.0", features = ["derive"] } diff --git a/Makefile.toml b/Makefile.toml index 6d40d55a..99582a13 100644 --- a/Makefile.toml +++ b/Makefile.toml @@ -1,13 +1,15 @@ # Rsnap workspace tasks. # Check -# | task | type | purpose | -# | ---------------- | --------- | ------- | -# | check | composite | full read-only verification | -# | check-rust | command | Rust clippy, no cargo check | -# | check-swift | composite | SwiftLint and strict build | -# | check-swiftlint | command | SwiftLint plugin, read-only | -# | check-vstyle | command | Rust vibe-style curate | +# | task | type | cwd | +# | ------------------ | --------- | --- | +# | check | composite | | +# | check-rust | command | | +# | check-swift | composite | | +# | check-swiftlint | command | | +# | check-vstyle | composite | | +# | check-vstyle-rust | command | | +# | check-vstyle-swift | command | | [tasks.check] clear = true @@ -15,8 +17,8 @@ workspace = false dependencies = [ "fmt-check", "check-rust", - "check-vstyle", "check-swift", + "check-vstyle", "test", ] @@ -73,6 +75,13 @@ swift package \ [tasks.check-vstyle] workspace = false +dependencies = [ + "check-vstyle-rust", + "check-vstyle-swift", +] + +[tasks.check-vstyle-rust] +workspace = false command = "cargo" args = [ "vstyle", @@ -83,14 +92,106 @@ args = [ "--all-features", ] +[tasks.check-vstyle-swift] +workspace = false +command = "cargo" +args = [ + "vstyle", + "curate", + "--language", + "swift", + "--workspace", + "--all-features", +] + +# Format +# | task | type | cwd | +# | --------------- | --------- | --- | +# | fmt | composite | | +# | fmt-check | composite | | +# | fmt-rust | command | | +# | fmt-rust-check | extend | | +# | fmt-swift | command | | +# | fmt-swift-check | command | | +# | fmt-toml | command | | +# | fmt-toml-check | extend | | + +[tasks.fmt] +workspace = false +dependencies = [ + "fmt-rust", + "fmt-swift", + "fmt-toml", +] + +[tasks.fmt-check] +workspace = false +dependencies = [ + "fmt-rust-check", + "fmt-swift-check", + "fmt-toml-check", +] + +[tasks.fmt-rust] +workspace = false +script = "cargo +nightly fmt --all" + +[tasks.fmt-rust-check] +extend = "fmt-rust" +script = "cargo +nightly fmt --all -- --check" + +[tasks.fmt-swift] +workspace = false +command = "swift" +args = [ + "format", + "format", + "--in-place", + "--recursive", + "--parallel", + "native/macos-host/Sources", + "scripts/smoke/lib/live-hud-mouse-path.swift", + "scripts/smoke/lib/mask-probe-capture.swift", + "scripts/smoke/lib/visual-background-window.swift", +] + +[tasks.fmt-swift-check] +workspace = false +command = "swift" +args = [ + "format", + "lint", + "--recursive", + "--strict", + "--parallel", + "native/macos-host/Sources", + "scripts/smoke/lib/live-hud-mouse-path.swift", + "scripts/smoke/lib/mask-probe-capture.swift", + "scripts/smoke/lib/visual-background-window.swift", +] + +[tasks.fmt-toml] +workspace = false +command = "taplo" +args = [ + "fmt", +] + +[tasks.fmt-toml-check] +extend = "fmt-toml" +args = [ + "fmt", + "--check", +] + # Lint -# | task | type | purpose | -# | ------------- | --------- | ------- | -# | lint | composite | apply safe fixes | -# | lint-rust | command | cargo clippy --fix | -# | lint-swift | composite | Swift format and SwiftLint fixes | -# | lint-swiftlint| command | SwiftLint plugin fixes | -# | lint-vstyle | command | Rust vibe-style tune | +# | task | type | cwd | +# | -------------- | --------- | --- | +# | lint | composite | | +# | lint-rust | command | | +# | lint-swift | composite | | +# | lint-swiftlint | command | | +# | lint-vstyle | composite | | [tasks.lint] workspace = false @@ -101,7 +202,8 @@ dependencies = [ ] [tasks.lint-rust] -extend = "check-rust" +workspace = false +command = "cargo" args = [ "clippy", "--fix", @@ -133,8 +235,6 @@ workspace = false dependencies = [ "fmt-swift", "lint-swiftlint", - "check-swiftlint", - "build-swift-strict", ] [tasks.lint-swiftlint] @@ -156,6 +256,13 @@ swift package \ [tasks.lint-vstyle] workspace = false +dependencies = [ + "lint-vstyle-rust", + "lint-vstyle-swift", +] + +[tasks.lint-vstyle-rust] +workspace = false command = "cargo" args = [ "vstyle", @@ -167,22 +274,37 @@ args = [ "--strict", ] +[tasks.lint-vstyle-swift] +workspace = false +command = "cargo" +args = [ + "vstyle", + "tune", + "--language", + "swift", + "--workspace", + "--all-features", + "--strict", +] # Test -# | task | type | purpose | -# | ---------------------------- | --------- | ------- | -# | test | composite | default Rust suite | -# | test-rust | command | workspace nextest | -# | test-host-reset | composite | reset boundary probes | -# | test-rust-host-reset | command | Rust core/FFI tests | -# | test-host-ffi-header | command | exported C header smoke | -# | test-macos-native-host | command | Swift bridge probes | -# | test-macos-native-host-stage | command | staged app bundle smoke | +# | task | type | cwd | +# | ---------------------------- | --------- | --- | +# | test | composite | | +# | test-rust | command | | +# | test-swift | composite | | +# | test-host-reset | composite | | +# | test-rust-host-reset | command | | +# | test-host-ffi-header | command | | +# | test-macos-native-host | command | | +# | test-macos-native-host-stage | command | | [tasks.test] +clear = true workspace = false dependencies = [ "test-rust", + "test-swift", ] [tasks.test-rust] @@ -196,7 +318,11 @@ args = [ "--all-features", ] -# Host/core reset tests. +[tasks.test-swift] +workspace = false +dependencies = [ + "test-macos-native-host", +] [tasks.test-host-reset] workspace = false @@ -228,8 +354,6 @@ args = [ "packages/rsnap-host-ffi/tests/header_smoke.c", ] -# Native host probes. - [tasks.test-macos-native-host] workspace = false dependencies = [ @@ -278,10 +402,10 @@ fi ''' # Build -# | task | type | purpose | -# | ------------------------ | ------- | ------- | -# | build-host-ffi-staticlib | command | Rust static library for Swift | -# | build-swift-strict | command | Swift strict-concurrency build | +# | task | type | cwd | +# | ------------------------ | ------- | --- | +# | build-host-ffi-staticlib | command | | +# | build-swift-strict | command | | [tasks.build-host-ffi-staticlib] workspace = false @@ -305,88 +429,6 @@ swift build --package-path native/macos-host \ -Xswiftc -strict-concurrency=complete ''' - -# Format -# | task | type | cwd | -# | -------------- | --------- | --- | -# | fmt | composite | | -# | fmt-check | composite | | -# | fmt-rust | command | | -# | fmt-rust-check | extend | | -# | fmt-swift | command | | -# | fmt-swift-check| command | | -# | fmt-toml | command | | -# | fmt-toml-check | extend | | - -[tasks.fmt] -workspace = false -dependencies = [ - "fmt-rust", - "fmt-swift", - "fmt-toml", -] - -[tasks.fmt-check] -workspace = false -dependencies = [ - "fmt-rust-check", - "fmt-swift-check", - "fmt-toml-check", -] - -[tasks.fmt-rust] -workspace = false -script = "cargo +nightly fmt --all" - -[tasks.fmt-rust-check] -extend = "fmt-rust" -script = "cargo +nightly fmt --all -- --check" - -[tasks.fmt-toml] -workspace = false -command = "taplo" -args = [ - "fmt", -] - -[tasks.fmt-toml-check] -extend = "fmt-toml" -args = [ - "fmt", - "--check", -] - -[tasks.fmt-swift] -workspace = false -command = "swift" -args = [ - "format", - "format", - "--in-place", - "--recursive", - "--parallel", - "native/macos-host/Sources", - "scripts/smoke/lib/live-hud-mouse-path.swift", - "scripts/smoke/lib/mask-probe-capture.swift", - "scripts/smoke/lib/visual-background-window.swift", -] - -[tasks.fmt-swift-check] -workspace = false -command = "swift" -args = [ - "format", - "lint", - "--recursive", - "--strict", - "--parallel", - "native/macos-host/Sources", - "scripts/smoke/lib/live-hud-mouse-path.swift", - "scripts/smoke/lib/mask-probe-capture.swift", - "scripts/smoke/lib/visual-background-window.swift", -] - - # Meta # | task | type | cwd | # | ------ | --------- | --- | diff --git a/README.md b/README.md index 0ab71f80..81c479f7 100644 --- a/README.md +++ b/README.md @@ -55,8 +55,9 @@ Prototype / in active development. slices that have not yet moved into `rsnap-capture-core` / the native host. - The active reset target is no longer a pure-Rust UI stack. New boundary crates now live in: - `packages/rsnap-capture-core/` for platform-neutral session semantics and host/core protocol - models - - `packages/rsnap-host-ffi/` for the thin C ABI that a future native macOS host will call + models, plus Rust-owned export, capture-frame rendering, wallpaper thumbnail, minimap, + selection-transform, and image-analysis algorithms + - `packages/rsnap-host-ffi/` for the thin C ABI that the native macOS host calls through `packages/rsnap-host-ffi/include/rsnap_host_ffi.h` - Current version support remains **macOS only**. Windows and Linux stay out of scope for this version beyond protocol and abstraction design. @@ -185,6 +186,10 @@ Native-host local loop: - The live native-host path is now driven by `primary interaction -> scene.liveSelectionPreview -> requestFreezeSnapshot`, so the host no longer keeps its own pending freeze-selection shadow state. +- The native host keeps OS-facing ownership. It captures or discovers platform resources, passes + source pixels and resource paths through `RsnapHostBridge`, displays returned images, and performs + clipboard, save-panel, OCR, and update side effects. Rust owns the final-byte and image-planning + algorithms exposed through `rsnap-host-ffi`. Smoke/perf entrypoints: @@ -219,8 +224,8 @@ The tracked workspace currently keeps: - `native/macos-host/` as the new AppKit-first macOS host shell and local run target - `apps/rsnap/` as the thin launcher/bootstrap crate for the native host bundle - `packages/rsnap-overlay/` as the large transitional overlay/runtime container -- `packages/rsnap-capture-core/` as the new durable product-semantics layer -- `packages/rsnap-host-ffi/` as the new thin C ABI bridge for future native hosts +- `packages/rsnap-capture-core/` as the durable product-semantics and image-algorithm layer +- `packages/rsnap-host-ffi/` as the thin C ABI bridge used by the native macOS host Generated or local-only directories such as `target/`, `.worktrees/`, and `.workspaces/` are not part of the tracked repository structure. For the authoritative layout and ownership map, read diff --git a/apps/rsnap-perf/Cargo.toml b/apps/rsnap-perf/Cargo.toml new file mode 100644 index 00000000..dde57134 --- /dev/null +++ b/apps/rsnap-perf/Cargo.toml @@ -0,0 +1,20 @@ +[package] +authors.workspace = true +description = "Local deterministic performance checks for Rsnap Rust core paths" +edition.workspace = true +homepage.workspace = true +license.workspace = true +name = "rsnap-perf" +readme.workspace = true +repository.workspace = true +version.workspace = true + +[[bin]] +name = "rsnap-perf" +path = "src/main.rs" + +[dependencies] +color-eyre = { workspace = true } +image = { workspace = true } +rsnap-capture-core = { workspace = true } +rsnap-overlay = { workspace = true } diff --git a/apps/rsnap-perf/src/main.rs b/apps/rsnap-perf/src/main.rs new file mode 100644 index 00000000..239f7780 --- /dev/null +++ b/apps/rsnap-perf/src/main.rs @@ -0,0 +1,1086 @@ +#![allow(missing_docs)] + +use std::env; +use std::path::PathBuf; +use std::process; +use std::{ + fs, hint, + path::Path, + time::{Duration, Instant}, +}; + +use color_eyre::eyre::{self, Result}; +use image::{Rgba, RgbaImage}; + +use rsnap_capture_core::{ + self, BgraFrameView, CaptureFrameBackgroundKind, CaptureFrameRenderImageRef, + CaptureFrameRenderKind, CaptureFrameSourceKind, DisplayPointRect, + FrozenSelectionTransformInput, FrozenSelectionTransformKind, RectPoints, ScrollMinimapInput, + ToolbarItemKind, +}; +use rsnap_overlay::bench_support::{ + ScrollCaptureBenchHarness, ScrollCaptureBenchScenario, ScrollCaptureFingerprintMetrics, + ScrollCaptureOverlapMetrics, ScrollCaptureSessionMetrics, +}; +use rsnap_overlay::frozen_edit::{ + FrozenOverlayEditColor, FrozenOverlayEditPoint, FrozenOverlayEditRect, + FrozenOverlayEditSession, FrozenOverlayEditSpotlightStyle, FrozenOverlayEditStrokeStyle, + FrozenOverlayEditStyle, FrozenOverlayEditTextStyle, +}; +use rsnap_overlay::frozen_export::{ + self, FrozenOverlayExportArrow, FrozenOverlayExportElement, FrozenOverlayExportMosaic, + FrozenOverlayExportPen, FrozenOverlayExportPoint, FrozenOverlayExportSpotlight, + FrozenOverlayExportSpotlightStyle, FrozenOverlayExportStrokeStyle, FrozenOverlayExportText, + FrozenOverlayExportTextStyle, +}; + +struct PerfCaseResult { + name: String, + iterations: u32, + elapsed: Duration, + budget: Duration, + checksum: u64, +} +impl PerfCaseResult { + fn print(&self) { + println!( + "[perf] {} iterations={} elapsed={} budget={} checksum={:#018x}", + self.name, + self.iterations, + format_duration(self.elapsed), + format_duration(self.budget), + self.checksum + ); + } + + fn require_budget(&self) -> Result<()> { + eyre::ensure!( + self.elapsed <= self.budget, + "performance case {} exceeded budget: elapsed={} budget={}", + self.name, + format_duration(self.elapsed), + format_duration(self.budget) + ); + + Ok(()) + } +} + +fn main() -> Result<()> { + color_eyre::install()?; + + let mut results = Vec::new(); + + run_export_cases(&mut results)?; + run_scroll_capture_cases(&mut results)?; + + for result in &results { + result.print(); + result.require_budget()?; + } + + println!("[perf] deterministic local performance sweep passed"); + + Ok(()) +} + +fn run_export_cases(results: &mut Vec) -> Result<()> { + let image = build_export_fixture(1_440, 900); + let auto_center_image = + build_auto_center_fixture(1_440, 900, RectPoints::new(420, 240, 360, 220)); + let bgra_bytes_per_row = 640 * 4 + 16; + let bgra_frame = build_bgra_fixture(640, 480, bgra_bytes_per_row); + + verify_export_round_trip(&image)?; + verify_crop_exactness(&image)?; + verify_mosaic_patch()?; + verify_frozen_overlay_export(&image)?; + verify_bgra_frame_sampling(&bgra_frame, bgra_bytes_per_row)?; + verify_capture_frame_plan()?; + verify_scroll_minimap_plan()?; + verify_frozen_selection_transform()?; + verify_frozen_overlay_edit_session()?; + verify_auto_center_content_bounds(&auto_center_image)?; + + let wallpaper_fixture = write_wallpaper_fixture_png()?; + + verify_wallpaper_png_thumbnail(&wallpaper_fixture)?; + run_core_export_perf_cases(results, &image)?; + run_bgra_frame_perf_case(results, &bgra_frame, bgra_bytes_per_row)?; + run_capture_frame_perf_case(results)?; + run_scroll_minimap_perf_case(results)?; + run_frozen_selection_transform_perf_case(results)?; + run_frozen_overlay_edit_perf_case(results)?; + run_auto_center_perf_case(results, &auto_center_image)?; + run_wallpaper_thumbnail_perf_case(results, &wallpaper_fixture)?; + + let _ = fs::remove_file(wallpaper_fixture); + + Ok(()) +} + +fn run_core_export_perf_cases(results: &mut Vec, image: &RgbaImage) -> Result<()> { + results.push(time_case( + "export_png_lossless_fast_1440x900", + 4, + Duration::from_millis(900), + || { + let png = rsnap_capture_core::encode_png_lossless_fast(image)?; + + Ok(checksum_bytes(&png)) + }, + )?); + results.push(time_case("crop_rgba_960x540", 200, Duration::from_millis(900), || { + let crop = rsnap_capture_core::crop_rgba_image(image, RectPoints::new(240, 160, 960, 540)) + .ok_or_else(|| eyre::eyre!("export crop performance fixture is invalid"))?; + + Ok(checksum_bytes(crop.as_raw())) + })?); + results.push(time_case( + "frozen_mosaic_light_privacy_patch_960x540", + 1_000, + Duration::from_millis(120), + || { + let patch = rsnap_capture_core::frozen_mosaic_light_privacy_patch( + 1_440, + 900, + DisplayPointRect::new(240.5, 160.25, 960.0, 540.0), + ) + .ok_or_else(|| eyre::eyre!("mosaic patch performance fixture is invalid"))?; + + Ok(checksum_bytes(patch.as_raw())) + }, + )?); + results.push(time_case( + "frozen_overlay_export_rgba_1440x900", + 10, + Duration::from_millis(900), + || { + let rendered = frozen_export::render_frozen_overlay_export_rgba( + image.width(), + image.height(), + image.as_raw(), + DisplayPointRect::new(0.0, 0.0, 1_440.0, 900.0), + &frozen_overlay_export_fixture(), + )?; + + Ok(checksum_bytes(rendered.as_raw())) + }, + )?); + + Ok(()) +} + +fn run_auto_center_perf_case( + results: &mut Vec, + auto_center_image: &RgbaImage, +) -> Result<()> { + results.push(time_case( + "auto_center_content_bounds_rgba_1440x900", + 50, + Duration::from_millis(900), + || { + let bounds = rsnap_capture_core::detect_auto_center_content_bounds_rgba( + auto_center_image.width(), + auto_center_image.height(), + auto_center_image.as_raw(), + ) + .map_err(|error| eyre::eyre!("auto-center performance fixture is invalid: {error:?}"))? + .ok_or_else(|| eyre::eyre!("auto-center performance fixture did not detect content"))?; + let shift_x = rsnap_capture_core::auto_center_margin_balance_shift_points( + f64::from(bounds.x), + f64::from(bounds.width), + f64::from(auto_center_image.width()), + 720.0, + ); + let shift_y = rsnap_capture_core::auto_center_margin_balance_shift_points( + f64::from(bounds.y), + f64::from(bounds.height), + f64::from(auto_center_image.height()), + 450.0, + ); + + Ok(checksum_f64s(&[ + f64::from(bounds.x), + f64::from(bounds.y), + f64::from(bounds.width), + f64::from(bounds.height), + shift_x, + shift_y, + ])) + }, + )?); + + Ok(()) +} + +fn run_wallpaper_thumbnail_perf_case( + results: &mut Vec, + wallpaper_fixture: &Path, +) -> Result<()> { + results.push(time_case( + "wallpaper_png_thumbnail_stream_lanczos_512x288_to_128", + 20, + Duration::from_millis(500), + || { + let thumbnail = + rsnap_capture_core::capture_frame_wallpaper_png_thumbnail(wallpaper_fixture, 128)? + .ok_or_else(|| { + eyre::eyre!("wallpaper thumbnail performance fixture is invalid") + })?; + + Ok(checksum_bytes(thumbnail.as_raw())) + }, + )?); + + Ok(()) +} + +fn run_scroll_minimap_perf_case(results: &mut Vec) -> Result<()> { + results.push(time_case( + "scroll_minimap_plan_100x200", + 10_000, + Duration::from_millis(60), + || { + let plan = rsnap_capture_core::scroll_minimap_plan(scroll_minimap_fixture()) + .ok_or_else(|| eyre::eyre!("scroll minimap plan performance fixture is invalid"))?; + + Ok(checksum_f64s(&[ + plan.frame.x, + plan.frame.y, + plan.frame.width, + plan.frame.height, + plan.image_frame.x, + plan.image_frame.y, + plan.image_frame.width, + plan.image_frame.height, + plan.viewport_frame.map_or(0.0, |rect| rect.y), + plan.viewport_frame.map_or(0.0, |rect| rect.height), + ])) + }, + )?); + + Ok(()) +} + +fn run_capture_frame_perf_case(results: &mut Vec) -> Result<()> { + let source_image = build_export_fixture(1_440, 900); + let source = CaptureFrameRenderImageRef::new( + source_image.width(), + source_image.height(), + source_image.as_raw(), + )?; + + results.push(time_case( + "capture_frame_plan_and_background_1440x900", + 10_000, + Duration::from_millis(60), + || { + let plan = rsnap_capture_core::capture_frame_plan( + 1_440, + 900, + 2.0, + CaptureFrameSourceKind::Window, + ) + .ok_or_else(|| eyre::eyre!("capture frame plan performance fixture is invalid"))?; + let crop = rsnap_capture_core::capture_frame_aspect_fill_crop_rect( + 2_400, + 1_600, + plan.canvas_width, + plan.canvas_height, + ) + .ok_or_else(|| { + eyre::eyre!("capture frame aspect-fill performance fixture is invalid") + })?; + let background = rsnap_capture_core::capture_frame_background_plan( + CaptureFrameBackgroundKind::SystemWallpaper, + ); + let wallpaper_request = rsnap_capture_core::capture_frame_wallpaper_request_plan( + CaptureFrameBackgroundKind::SystemWallpaper, + plan.canvas_width, + plan.canvas_height, + ) + .ok_or_else(|| { + eyre::eyre!("capture frame wallpaper request performance fixture is invalid") + })?; + + Ok(checksum_f64s(&[ + plan.canvas_width, + plan.canvas_height, + plan.image_rect.x, + plan.corner_radius, + plan.shadows[0].blur, + plan.shadows[1].offset_y, + crop.x, + crop.y, + crop.width, + crop.height, + background.colors[0].red, + background.colors[1].green, + background.locations[1], + background.wallpaper_overlay_alpha, + f64::from(wallpaper_request.target_pixel_size), + wallpaper_request.overlay_alpha, + ])) + }, + )?); + results.push(time_case( + "capture_frame_render_rgba_1440x900", + 4, + Duration::from_millis(1_200), + || { + let rendered = rsnap_capture_core::render_capture_frame_effect( + source, + CaptureFrameBackgroundKind::Aurora, + 2.0, + CaptureFrameSourceKind::Window, + CaptureFrameRenderKind::FramedCapture, + None, + )? + .ok_or_else(|| eyre::eyre!("capture frame render performance fixture is invalid"))?; + + Ok(checksum_bytes(rendered.as_raw())) + }, + )?); + + Ok(()) +} + +fn run_bgra_frame_perf_case( + results: &mut Vec, + bgra_frame: &[u8], + bgra_bytes_per_row: usize, +) -> Result<()> { + results.push(time_case( + "bgra_loupe_patch_rgba_64x64", + 4_000, + Duration::from_millis(120), + || { + let patch = rsnap_capture_core::loupe_patch_rgba_from_bgra_frame( + BgraFrameView { + width: 640, + height: 480, + bytes_per_row: bgra_bytes_per_row, + bytes: bgra_frame, + }, + DisplayPointRect::new(0.0, 0.0, 640.0, 480.0), + 24.0, + 470.0, + 64, + ) + .ok_or_else(|| eyre::eyre!("BGRA loupe patch performance fixture is invalid"))?; + + Ok(checksum_bytes(patch.as_raw())) + }, + )?); + + Ok(()) +} + +fn run_frozen_selection_transform_perf_case(results: &mut Vec) -> Result<()> { + results.push(time_case( + "frozen_selection_transform_rect", + 10_000, + Duration::from_millis(60), + || { + let rect = + rsnap_capture_core::frozen_selection_transform_rect(selection_transform_fixture()) + .ok_or_else(|| { + eyre::eyre!("selection transform performance fixture is invalid") + })?; + + Ok(checksum_f64s(&[rect.x, rect.y, rect.width, rect.height])) + }, + )?); + + Ok(()) +} + +fn run_frozen_overlay_edit_perf_case(results: &mut Vec) -> Result<()> { + results.push(time_case( + "frozen_overlay_edit_session_lifecycle", + 2_000, + Duration::from_millis(120), + || Ok(run_frozen_overlay_edit_lifecycle()), + )?); + + Ok(()) +} + +fn run_scroll_capture_cases(results: &mut Vec) -> Result<()> { + for scenario in ScrollCaptureBenchScenario::ALL { + let harness = ScrollCaptureBenchHarness::new(scenario); + let name = scenario.as_str(); + + verify_scroll_fingerprint(scenario, harness.run_fingerprint())?; + + results.push(time_case( + format!("scroll_capture_fingerprint_{name}"), + 500, + Duration::from_millis(250), + || { + let metrics = harness.run_fingerprint(); + + Ok(u64::from(metrics.checksum).wrapping_add(metrics.byte_len as u64)) + }, + )?); + + verify_scroll_overlap(scenario, harness.run_overlap_match())?; + + results.push(time_case( + format!("scroll_capture_overlap_match_{name}"), + 120, + Duration::from_millis(900), + || { + let metrics = harness.run_overlap_match(); + + Ok(scroll_overlap_checksum(metrics)) + }, + )?); + + verify_scroll_session(scenario, harness.run_session_commit())?; + + results.push(time_case( + format!("scroll_capture_session_commit_{name}"), + 80, + Duration::from_millis(1_800), + || { + let metrics = harness.run_session_commit(); + + Ok(scroll_session_checksum(metrics)) + }, + )?); + } + + Ok(()) +} + +fn verify_export_round_trip(image: &RgbaImage) -> Result<()> { + let png = rsnap_capture_core::encode_png_lossless_fast(image)?; + let decoded = image::load_from_memory(&png) + .map_err(|error| eyre::eyre!("failed to decode lossless PNG fixture: {error}"))? + .into_rgba8(); + + eyre::ensure!( + decoded.dimensions() == image.dimensions(), + "lossless PNG round trip changed dimensions" + ); + eyre::ensure!(decoded.as_raw() == image.as_raw(), "lossless PNG round trip changed pixels"); + + Ok(()) +} + +fn verify_crop_exactness(image: &RgbaImage) -> Result<()> { + let rect = RectPoints::new(240, 160, 960, 540); + let crop = rsnap_capture_core::crop_rgba_image(image, rect) + .ok_or_else(|| eyre::eyre!("export crop fixture is invalid"))?; + + eyre::ensure!(crop.dimensions() == (rect.width, rect.height), "crop changed dimensions"); + eyre::ensure!(crop.get_pixel(0, 0) == image.get_pixel(rect.x, rect.y), "crop origin mismatch"); + eyre::ensure!( + crop.get_pixel(rect.width - 1, rect.height - 1) + == image.get_pixel(rect.x + rect.width - 1, rect.y + rect.height - 1), + "crop tail pixel mismatch" + ); + + Ok(()) +} + +fn verify_mosaic_patch() -> Result<()> { + let patch = rsnap_capture_core::frozen_mosaic_light_privacy_patch( + 100, + 80, + DisplayPointRect::new(4.2, 9.1, 28.4, 21.0), + ) + .ok_or_else(|| eyre::eyre!("mosaic patch fixture is invalid"))?; + + eyre::ensure!(patch.dimensions() == (3, 3), "mosaic patch dimensions changed"); + eyre::ensure!( + patch.as_raw()[..12] == [211, 211, 211, 255, 205, 205, 205, 255, 202, 201, 199, 255], + "mosaic patch seeded color bytes changed" + ); + + Ok(()) +} + +fn verify_frozen_overlay_export(image: &RgbaImage) -> Result<()> { + let rendered = frozen_export::render_frozen_overlay_export_rgba( + image.width(), + image.height(), + image.as_raw(), + DisplayPointRect::new(0.0, 0.0, 1_440.0, 900.0), + &frozen_overlay_export_fixture(), + )?; + + eyre::ensure!(rendered.dimensions() == image.dimensions(), "frozen overlay dimensions changed"); + eyre::ensure!(rendered.as_raw() != image.as_raw(), "frozen overlay did not change pixels"); + + Ok(()) +} + +fn verify_bgra_frame_sampling(bgra: &[u8], bytes_per_row: usize) -> Result<()> { + let frame = BgraFrameView { width: 640, height: 480, bytes_per_row, bytes: bgra }; + let rgb = rsnap_capture_core::sample_rgb_from_bgra_frame( + frame, + DisplayPointRect::new(0.0, 0.0, 640.0, 480.0), + 17.2, + 479.5, + ) + .ok_or_else(|| eyre::eyre!("BGRA RGB fixture is invalid"))?; + + eyre::ensure!(rgb.r == 27 && rgb.g == 37 && rgb.b == 47, "BGRA RGB sample changed"); + + let patch = rsnap_capture_core::loupe_patch_rgba_from_bgra_frame( + frame, + DisplayPointRect::new(0.0, 0.0, 640.0, 480.0), + 0.0, + 479.0, + 3, + ) + .ok_or_else(|| eyre::eyre!("BGRA loupe fixture is invalid"))?; + + eyre::ensure!(patch.dimensions() == (3, 3), "BGRA loupe dimensions changed"); + eyre::ensure!( + patch.as_raw()[..8] == [10, 20, 30, 200, 10, 20, 30, 200], + "BGRA loupe bytes changed" + ); + + Ok(()) +} + +fn verify_capture_frame_plan() -> Result<()> { + let plan = + rsnap_capture_core::capture_frame_plan(320, 180, 2.0, CaptureFrameSourceKind::Window) + .ok_or_else(|| eyre::eyre!("capture frame plan fixture is invalid"))?; + + eyre::ensure!(plan.canvas_width == 416.0, "capture frame canvas width changed"); + eyre::ensure!(plan.canvas_height == 276.0, "capture frame canvas height changed"); + eyre::ensure!( + plan.image_rect == DisplayPointRect::new(48.0, 48.0, 320.0, 180.0), + "capture frame image rect changed" + ); + eyre::ensure!(plan.corner_radius == 9.9, "capture frame corner radius changed"); + + let crop = + rsnap_capture_core::capture_frame_aspect_fill_crop_rect(1_600, 900, 1_000.0, 1_000.0) + .ok_or_else(|| eyre::eyre!("capture frame aspect-fill fixture is invalid"))?; + + eyre::ensure!( + crop == DisplayPointRect::new(350.0, 0.0, 900.0, 900.0), + "capture frame aspect-fill crop changed" + ); + + let background = rsnap_capture_core::capture_frame_background_plan( + CaptureFrameBackgroundKind::SystemWallpaper, + ); + + eyre::ensure!(background.prefers_wallpaper, "capture frame wallpaper flag changed"); + eyre::ensure!( + background.wallpaper_overlay_alpha == 0.10, + "capture frame wallpaper overlay changed" + ); + eyre::ensure!( + background.colors[2].red == 0.95 && background.locations == [0.0, 0.54, 1.0], + "capture frame background gradient changed" + ); + + let wallpaper_request = rsnap_capture_core::capture_frame_wallpaper_request_plan( + CaptureFrameBackgroundKind::SystemWallpaper, + 1_535.2, + 996.0, + ) + .ok_or_else(|| eyre::eyre!("capture frame wallpaper request fixture is invalid"))?; + + eyre::ensure!( + wallpaper_request.target_pixel_size == 1_536, + "capture frame wallpaper target changed" + ); + eyre::ensure!( + wallpaper_request.overlay_alpha == 0.10, + "capture frame wallpaper overlay changed" + ); + + let source_rgba = vec![255; 4 * 2 * 4]; + let source = CaptureFrameRenderImageRef::new(4, 2, &source_rgba)?; + let rendered = rsnap_capture_core::render_capture_frame_effect( + source, + CaptureFrameBackgroundKind::Aurora, + 2.0, + CaptureFrameSourceKind::DragRegion, + CaptureFrameRenderKind::WindowSnapshot, + None, + )? + .ok_or_else(|| eyre::eyre!("capture frame render fixture is invalid"))?; + + eyre::ensure!(rendered.width() == 100, "capture frame render width changed"); + eyre::ensure!(rendered.height() == 98, "capture frame render height changed"); + eyre::ensure!( + rendered.as_raw()[((48 * 100 + 48) * 4)..((48 * 100 + 49) * 4)] == [255, 255, 255, 255], + "capture frame render source pixels changed" + ); + + Ok(()) +} + +fn verify_scroll_minimap_plan() -> Result<()> { + let plan = rsnap_capture_core::scroll_minimap_plan(scroll_minimap_fixture()) + .ok_or_else(|| eyre::eyre!("scroll minimap plan fixture is invalid"))?; + + eyre::ensure!( + plan.frame == DisplayPointRect::new(210.0, 54.0, 96.0, 192.0), + "scroll minimap frame changed" + ); + eyre::ensure!( + plan.image_frame == DisplayPointRect::new(213.0, 57.0, 90.0, 186.0), + "scroll minimap image frame changed" + ); + eyre::ensure!( + plan.viewport_frame == Some(DisplayPointRect::new(213.0, 131.4, 90.0, 93.0)), + "scroll minimap viewport frame changed" + ); + + Ok(()) +} + +fn verify_frozen_selection_transform() -> Result<()> { + let selection = DisplayPointRect::new(100.0, 80.0, 240.0, 160.0); + let hit = + rsnap_capture_core::frozen_selection_transform_hit_test(102.0, 238.0, selection, 12.0, 4.0) + .ok_or_else(|| eyre::eyre!("selection transform hit fixture is invalid"))?; + + eyre::ensure!(hit == FrozenSelectionTransformKind::ResizeTopLeft, "selection hit changed"); + + let rect = rsnap_capture_core::frozen_selection_transform_rect(selection_transform_fixture()) + .ok_or_else(|| eyre::eyre!("selection transform fixture is invalid"))?; + + eyre::ensure!( + rect == DisplayPointRect::new(100.0, 228.0, 12.0, 12.0), + "selection transform rect changed" + ); + + Ok(()) +} + +fn verify_frozen_overlay_edit_session() -> Result<()> { + let checksum = run_frozen_overlay_edit_lifecycle(); + + eyre::ensure!(checksum != 0, "frozen overlay edit lifecycle checksum is empty"); + + Ok(()) +} + +fn verify_auto_center_content_bounds(image: &RgbaImage) -> Result<()> { + let bounds = rsnap_capture_core::detect_auto_center_content_bounds_rgba( + image.width(), + image.height(), + image.as_raw(), + ) + .map_err(|error| eyre::eyre!("auto-center fixture is invalid: {error:?}"))? + .ok_or_else(|| eyre::eyre!("auto-center fixture did not detect content"))?; + + eyre::ensure!(bounds == RectPoints::new(420, 240, 360, 220), "auto-center bounds changed"); + eyre::ensure!( + rsnap_capture_core::auto_center_margin_balance_shift_points(420.0, 360.0, 1_440.0, 720.0) + == -60.0, + "auto-center horizontal shift changed" + ); + eyre::ensure!( + rsnap_capture_core::auto_center_margin_balance_shift_points(240.0, 220.0, 900.0, 450.0) + == -50.0, + "auto-center vertical shift changed" + ); + + Ok(()) +} + +fn verify_wallpaper_png_thumbnail(path: &Path) -> Result<()> { + let thumbnail = rsnap_capture_core::capture_frame_wallpaper_png_thumbnail(path, 128)? + .ok_or_else(|| eyre::eyre!("wallpaper thumbnail fixture did not decode"))?; + + eyre::ensure!(thumbnail.width() <= 128, "wallpaper thumbnail width exceeded target"); + eyre::ensure!(thumbnail.height() <= 128, "wallpaper thumbnail height exceeded target"); + eyre::ensure!( + thumbnail.as_raw().len() == thumbnail.width() as usize * thumbnail.height() as usize * 4, + "wallpaper thumbnail byte length changed" + ); + + Ok(()) +} + +fn verify_scroll_fingerprint( + scenario: ScrollCaptureBenchScenario, + metrics: ScrollCaptureFingerprintMetrics, +) -> Result<()> { + eyre::ensure!(metrics.byte_len == 768, "scroll fingerprint byte length changed"); + eyre::ensure!(metrics.checksum != 0, "scroll fingerprint checksum is empty"); + eyre::ensure!( + metrics.checksum == expected_scroll_fingerprint_checksum(scenario), + "scroll fingerprint checksum changed for {}: expected={} actual={}", + scenario.as_str(), + expected_scroll_fingerprint_checksum(scenario), + metrics.checksum + ); + + Ok(()) +} + +fn verify_scroll_overlap( + scenario: ScrollCaptureBenchScenario, + metrics: ScrollCaptureOverlapMetrics, +) -> Result<()> { + eyre::ensure!(metrics.matched, "scroll overlap did not match for {}", scenario.as_str()); + eyre::ensure!( + metrics.motion_rows == expected_scroll_motion_rows(scenario), + "scroll overlap motion changed for {}", + scenario.as_str() + ); + eyre::ensure!( + metrics.overlap_rows == expected_scroll_overlap_rows(scenario), + "scroll overlap rows changed for {}", + scenario.as_str() + ); + eyre::ensure!( + metrics.mean_abs_diff_x100 == 0, + "scroll overlap fixture should be exact for {}", + scenario.as_str() + ); + + Ok(()) +} + +fn verify_scroll_session( + scenario: ScrollCaptureBenchScenario, + metrics: ScrollCaptureSessionMetrics, +) -> Result<()> { + eyre::ensure!(metrics.committed, "scroll session did not commit for {}", scenario.as_str()); + eyre::ensure!( + metrics.growth_rows == expected_scroll_motion_rows(scenario), + "scroll session growth changed for {}", + scenario.as_str() + ); + eyre::ensure!( + metrics.export_height == expected_scroll_export_height(scenario), + "scroll session export height changed for {}", + scenario.as_str() + ); + eyre::ensure!( + metrics.preview_height == expected_scroll_preview_height(scenario), + "scroll session preview height changed for {}", + scenario.as_str() + ); + + Ok(()) +} + +fn time_case( + name: impl Into, + iterations: u32, + budget: Duration, + mut run_once: impl FnMut() -> Result, +) -> Result { + let started_at = Instant::now(); + let mut checksum = 0_u64; + + for _ in 0..iterations { + checksum = checksum.wrapping_add(hint::black_box(run_once()?)); + } + + Ok(PerfCaseResult { + name: name.into(), + iterations, + elapsed: started_at.elapsed(), + budget, + checksum, + }) +} + +fn build_export_fixture(width: u32, height: u32) -> RgbaImage { + RgbaImage::from_fn(width, height, |x, y| { + let diagonal = x.wrapping_add(y); + let r = pattern_byte(x.wrapping_mul(13).wrapping_add(y.wrapping_mul(7))); + let g = pattern_byte(x.wrapping_mul(3).wrapping_add(y.wrapping_mul(17))); + let b = pattern_byte(diagonal.wrapping_mul(11).wrapping_add((x / 5) * 19)); + let a = if (x / 32 + y / 32).is_multiple_of(7) { 220 } else { 255 }; + + Rgba([r, g, b, a]) + }) +} + +fn frozen_overlay_export_fixture() -> Vec { + vec![ + FrozenOverlayExportElement::Mosaic(FrozenOverlayExportMosaic { + rect: DisplayPointRect::new(180.0, 160.0, 320.0, 180.0), + }), + FrozenOverlayExportElement::Spotlight(FrozenOverlayExportSpotlight { + rect: DisplayPointRect::new(760.0, 180.0, 360.0, 240.0), + style: FrozenOverlayExportSpotlightStyle { + border_width_points: 1.5, + border_rgba: [255, 255, 255, 255], + }, + }), + FrozenOverlayExportElement::Pen(FrozenOverlayExportPen { + points: vec![ + FrozenOverlayExportPoint::new(120.0, 120.0), + FrozenOverlayExportPoint::new(360.0, 260.0), + FrozenOverlayExportPoint::new(520.0, 220.0), + ], + style: FrozenOverlayExportStrokeStyle { + stroke_width_points: 3.0, + rgba: [102, 178, 255, 255], + }, + }), + FrozenOverlayExportElement::Arrow(FrozenOverlayExportArrow { + start: FrozenOverlayExportPoint::new(520.0, 650.0), + end: FrozenOverlayExportPoint::new(980.0, 520.0), + style: FrozenOverlayExportStrokeStyle { + stroke_width_points: 4.0, + rgba: [255, 107, 107, 255], + }, + }), + FrozenOverlayExportElement::Text(FrozenOverlayExportText { + anchor: FrozenOverlayExportPoint::new(120.0, 720.0), + text: "Rsnap".to_owned(), + style: FrozenOverlayExportTextStyle { + font_size_points: 20.0, + rgba: [255, 255, 255, 255], + }, + }), + ] +} + +fn write_wallpaper_fixture_png() -> Result { + let image = build_export_fixture(512, 288); + let png = rsnap_capture_core::encode_png_lossless_fast(&image)?; + let path = env::temp_dir().join(format!("rsnap-perf-wallpaper-fixture-{}.png", process::id())); + + fs::write(&path, png).map_err(|error| { + eyre::eyre!("failed to write wallpaper performance fixture {}: {error}", path.display()) + })?; + + Ok(path) +} + +fn build_auto_center_fixture(width: u32, height: u32, content: RectPoints) -> RgbaImage { + RgbaImage::from_fn(width, height, |x, y| { + if x >= content.x + && x < content.x + content.width + && y >= content.y + && y < content.y + content.height + { + return Rgba([24, 32, 40, 255]); + } + + Rgba([180, 180, 180, 255]) + }) +} + +fn build_bgra_fixture(width: u32, height: u32, bytes_per_row: usize) -> Vec { + let mut bytes = vec![0xEE; bytes_per_row * height as usize]; + + for y in 0..height { + for x in 0..width { + let offset = y as usize * bytes_per_row + x as usize * 4; + + bytes[offset] = pattern_byte(30 + y * 15 + x); + bytes[offset + 1] = pattern_byte(20 + y * 10 + x); + bytes[offset + 2] = pattern_byte(10 + y * 5 + x); + bytes[offset + 3] = 200 + pattern_byte((x + y) % 55); + } + } + + bytes +} + +fn scroll_minimap_fixture() -> ScrollMinimapInput { + ScrollMinimapInput { + selection: DisplayPointRect::new(100.0, 100.0, 100.0, 100.0), + export_width: 100.0, + export_height: 200.0, + bounds: DisplayPointRect::new(0.0, 0.0, 500.0, 500.0), + preferred_width: 96.0, + minimum_width: 44.0, + gap: 10.0, + margin: 10.0, + image_inset: 3.0, + viewport_top_pixels: 20.0, + viewport_height_pixels: 100.0, + } +} + +fn selection_transform_fixture() -> FrozenSelectionTransformInput { + FrozenSelectionTransformInput { + kind: FrozenSelectionTransformKind::ResizeBottomRight, + initial_selection: DisplayPointRect::new(100.0, 80.0, 240.0, 160.0), + monitor_frame: DisplayPointRect::new(0.0, 0.0, 500.0, 400.0), + initial_pointer_x: 340.0, + initial_pointer_y: 80.0, + point_x: 50.0, + point_y: 300.0, + minimum_size: 12.0, + } +} + +fn frozen_overlay_edit_selection() -> FrozenOverlayEditRect { + FrozenOverlayEditRect::new(10.0, 20.0, 420.0, 260.0) +} + +fn frozen_overlay_edit_style() -> FrozenOverlayEditStyle { + FrozenOverlayEditStyle { + stroke: FrozenOverlayEditStrokeStyle { + stroke_width_points: 3.0, + color: FrozenOverlayEditColor::Blue, + }, + spotlight: FrozenOverlayEditSpotlightStyle { + border_width_points: 1.5, + border_color: FrozenOverlayEditColor::White, + }, + text: FrozenOverlayEditTextStyle { + font_size_points: 16.0, + color: FrozenOverlayEditColor::White, + }, + } +} + +fn run_frozen_overlay_edit_lifecycle() -> u64 { + let selection = frozen_overlay_edit_selection(); + let style = frozen_overlay_edit_style(); + let mut session = FrozenOverlayEditSession::default(); + let mut checksum = bool_bit(session.begin( + ToolbarItemKind::Pen, + FrozenOverlayEditPoint::new(20.0, 30.0), + selection, + style, + )); + + for offset in 1..=12 { + checksum = checksum.wrapping_add(bool_bit(session.update( + FrozenOverlayEditPoint::new(20.0 + f64::from(offset * 4), 30.0 + f64::from(offset * 3)), + selection, + ))); + } + + checksum = checksum.wrapping_add(bool_bit(session.finish(selection))); + checksum = checksum.wrapping_add(bool_bit(session.begin( + ToolbarItemKind::Mosaic, + FrozenOverlayEditPoint::new(90.0, 80.0), + selection, + style, + ))); + checksum = checksum.wrapping_add(bool_bit( + session.update(FrozenOverlayEditPoint::new(180.0, 150.0), selection), + )); + checksum = checksum.wrapping_add(bool_bit(session.finish(selection))); + checksum = checksum.wrapping_add(bool_bit(session.begin( + ToolbarItemKind::Text, + FrozenOverlayEditPoint::new(210.0, 110.0), + selection, + style, + ))); + checksum = checksum.wrapping_add(bool_bit(session.append_text("Rsnap"))); + checksum = checksum.wrapping_add(bool_bit(session.commit_text_edit(style.text))); + checksum = checksum.wrapping_add(bool_bit( + session.contains_movable_annotation(FrozenOverlayEditPoint::new(212.0, 112.0)), + )); + checksum = checksum.wrapping_add(bool_bit(session.begin( + ToolbarItemKind::Pointer, + FrozenOverlayEditPoint::new(212.0, 112.0), + selection, + style, + ))); + checksum = checksum.wrapping_add(bool_bit( + session.update(FrozenOverlayEditPoint::new(260.0, 160.0), selection), + )); + + let moving_snapshot = session.snapshot(); + + checksum = checksum + .wrapping_add(bool_bit(moving_snapshot.is_moving_movable_annotation)) + .wrapping_add(moving_snapshot.elements.len() as u64) + .wrapping_add(bool_bit(moving_snapshot.preview_text.is_some()) << 8); + checksum = checksum.wrapping_add(bool_bit(session.finish(selection))); + checksum = checksum.wrapping_add(bool_bit(session.undo()) << 16); + checksum = checksum.wrapping_add(bool_bit(session.redo()) << 24); + + let snapshot = session.snapshot(); + + checksum + .wrapping_add(snapshot.elements.len() as u64) + .wrapping_add(bool_bit(snapshot.can_undo) << 32) + .wrapping_add(bool_bit(snapshot.can_redo) << 40) +} + +fn pattern_byte(value: u32) -> u8 { + let reduced = value % 251; + + reduced.to_le_bytes()[0] +} + +fn checksum_bytes(bytes: &[u8]) -> u64 { + bytes.iter().fold(0xcbf2_9ce4_8422_2325_u64, |acc, byte| { + acc.wrapping_mul(0x0000_0001_0000_01b3).wrapping_add(u64::from(*byte) + 1) + }) +} + +fn checksum_f64s(values: &[f64]) -> u64 { + values.iter().fold(0xcbf2_9ce4_8422_2325_u64, |acc, value| { + acc.wrapping_mul(0x0000_0001_0000_01b3).wrapping_add(value.to_bits()) + }) +} + +fn scroll_overlap_checksum(metrics: ScrollCaptureOverlapMetrics) -> u64 { + bool_bit(metrics.matched) + .wrapping_add(u64::from(metrics.motion_rows) << 8) + .wrapping_add(u64::from(metrics.overlap_rows) << 24) + .wrapping_add(u64::from(metrics.mean_abs_diff_x100) << 40) +} + +fn scroll_session_checksum(metrics: ScrollCaptureSessionMetrics) -> u64 { + bool_bit(metrics.committed) + .wrapping_add(u64::from(metrics.growth_rows) << 8) + .wrapping_add(u64::from(metrics.export_height) << 24) + .wrapping_add(u64::from(metrics.preview_height) << 40) +} + +fn bool_bit(value: bool) -> u64 { + u64::from(u8::from(value)) +} + +fn expected_scroll_fingerprint_checksum(scenario: ScrollCaptureBenchScenario) -> u32 { + match scenario { + ScrollCaptureBenchScenario::Baseline => 1_186_711_576, + ScrollCaptureBenchScenario::Wide => 996_223_489, + } +} + +fn expected_scroll_motion_rows(scenario: ScrollCaptureBenchScenario) -> u32 { + match scenario { + ScrollCaptureBenchScenario::Baseline => 12, + ScrollCaptureBenchScenario::Wide => 20, + } +} + +fn expected_scroll_overlap_rows(scenario: ScrollCaptureBenchScenario) -> u32 { + match scenario { + ScrollCaptureBenchScenario::Baseline => 116, + ScrollCaptureBenchScenario::Wide => 140, + } +} + +fn expected_scroll_export_height(scenario: ScrollCaptureBenchScenario) -> u32 { + match scenario { + ScrollCaptureBenchScenario::Baseline => 140, + ScrollCaptureBenchScenario::Wide => 180, + } +} + +fn expected_scroll_preview_height(scenario: ScrollCaptureBenchScenario) -> u32 { + expected_scroll_export_height(scenario) +} + +fn format_duration(duration: Duration) -> String { + let micros = duration.as_micros(); + let millis = micros / 1_000; + let fractional = micros % 1_000; + + format!("{millis}.{fractional:03}ms") +} diff --git a/docs/reference/host-core-reset.md b/docs/reference/host-core-reset.md index 7fbe742d..b5674fcf 100644 --- a/docs/reference/host-core-reset.md +++ b/docs/reference/host-core-reset.md @@ -25,9 +25,12 @@ The active reset target is: In practical terms: - native hosts own capture-window lifecycle, focus/activation, cursor, IME, permissions, and - native capture capabilities -- Rust owns capture-session state, geometry, annotations, export composition, scroll stitching, - replay, and deterministic product logic + native capture capabilities. On macOS, Swift also discovers OS-only resources such as the current + wallpaper path, converts captured images into bridgeable buffers, presents returned pixels, and + performs host-side effects such as clipboard, save-panel, OCR, and update UI. +- Rust owns capture-session state, geometry, annotations, export composition, capture-frame + planning/rendering, wallpaper thumbnail decoding/caching, scroll stitching, minimap planning, + selection transforms, auto-centering, replay, and deterministic product logic. - host and core communicate through an explicit protocol instead of sharing ownership of OS-facing behavior @@ -42,12 +45,15 @@ Today: now centers on session/replay surfaces while remaining macOS host adapters stay behind explicit host modules - `packages/rsnap-capture-core/` is now the checked-in landing zone for portable geometry, - semantic scene models, and the first durable host/core protocol types -- `packages/rsnap-host-ffi/` is now the checked-in thin C ABI bridge for future native hosts and - ships the first checked-in header at `packages/rsnap-host-ffi/include/rsnap_host_ffi.h` + semantic scene models, host/core protocol types, export/crop/PNG encoding, capture-frame + planning/rendering, wallpaper thumbnail decode/cache, minimap planning, mosaic generation, + frozen-selection transforms, auto-centering, and live-sample pixel helpers +- `packages/rsnap-host-ffi/` is now the checked-in thin C ABI bridge for the native macOS host and + ships the checked-in header at `packages/rsnap-host-ffi/include/rsnap_host_ffi.h` - `native/macos-host/` is now the visible app shell and owns clipboard, save, and deferred OCR - publication for the reset lane, while the Rust core continues to prepare authoritative semantic - host-effect requests + publication for the reset lane. It calls `RsnapHostBridge` for Rust-owned session, export, + capture-frame, wallpaper thumbnail, minimap, selection-transform, auto-center, and sampling + algorithms rather than keeping parallel Swift implementations. During the reset, treat these as implementation containers rather than the final architecture story. @@ -81,6 +87,9 @@ Current reset posture for the boundary slice: - durable geometry and scene protocol types now belong in `rsnap-capture-core` - native-host ABI entry points now belong in `rsnap-host-ffi` +- final-byte and performance-sensitive image algorithms should move behind Rust ABI entry points as + reusable cross-platform core work, while Swift stays limited to OS acquisition, presentation, and + host-side effects - targeted reset-slice validation now lives at `cargo make test-host-reset` - `apps/rsnap/` and `rsnap-overlay/` should treat those crates as the migration target instead of inventing parallel durable protocol types inside legacy containers diff --git a/docs/reference/workspace-layout.md b/docs/reference/workspace-layout.md index cbaed50b..598daf3c 100644 --- a/docs/reference/workspace-layout.md +++ b/docs/reference/workspace-layout.md @@ -32,9 +32,9 @@ For the active target architecture and migration direction, read: | --- | --- | | `native/macos-host/` | SwiftPM AppKit-first macOS host shell: menu bar entry, full-screen capture windows, in-window HUD/toolbar, and native bridging into `rsnap-host-ffi` | | `apps/rsnap/` | Thin launcher/bootstrap crate: startup logging, build metadata, stable-bundle resolution, and `cargo run -p rsnap` handoff into the staged native macOS host | -| `packages/rsnap-overlay/` | Rust-core session/rendering crate: capture-session logic, overlay rendering, capture backend integration, worker runtime, and scroll-capture stitching/replay semantics, with any remaining macOS host adapters quarantined behind explicit host modules | -| `packages/rsnap-capture-core/` | New durable product-semantics crate: shared geometry, semantic scene model, host/core protocol enums, and the first reset-native session core | -| `packages/rsnap-host-ffi/` | New thin C ABI bridge crate for future native hosts that call the Rust product core | +| `packages/rsnap-overlay/` | Transitional Rust runtime and implementation reservoir: legacy overlay runtime, retained scroll-capture logic, frozen edit/export logic, and macOS adapters that have not yet moved into `rsnap-capture-core` | +| `packages/rsnap-capture-core/` | Durable Rust product-semantics and image-algorithm crate: shared geometry, semantic scene model, host/core protocol enums, reset-native session core, export/crop/PNG encoding, capture-frame rendering, wallpaper thumbnail, minimap, mosaic, selection transform, auto-center, and live-sample helpers | +| `packages/rsnap-host-ffi/` | Thin C ABI bridge crate used by the native macOS host to call the Rust product core and retained Rust transition modules | | `docs/` | Agent-facing repository docs split into `spec`, `runbook`, `reference`, and `decisions` | | `assets/` | Shared app-icon source plus generated bundle/runtime assets | | `scripts/` | Packaging helpers plus structured smoke/perf entrypoints under `scripts/smoke/` and `scripts/perf/` | @@ -67,18 +67,15 @@ Key paths: ### `packages/rsnap-overlay/` Treat `packages/rsnap-overlay/` as the current transitional container for most capture-session and -overlay behavior. +legacy overlay behavior. Today it owns: -- capture-session lifecycle -- overlay, HUD, loupe, and toolbar rendering -- frozen-mode behavior and output flow -- text annotation semantics, text model, edit intent, caret and selection semantics, and rendered - text state -- capture backend abstraction and worker coordination -- macOS live frame streaming and OCR support -- scroll-capture session logic, replay support, and benchmarks +- legacy capture-session lifecycle and overlay runtime paths not yet used by the native-host reset +- retained scroll-capture session logic, replay support, and benchmarks +- frozen edit/export logic that is still Rust-owned but has not yet moved into `rsnap-capture-core` +- text annotation rendering helpers reused by Rust export paths +- macOS capture and live-frame adapter code that remains quarantined behind explicit host modules Important: @@ -108,6 +105,14 @@ It owns: - semantic scene snapshots - explicit host/core protocol enums and structs - the first reset-native reference session core +- RGBA/BGRA pixel helpers used by host bridge code +- export crop mapping and lossless PNG encoding +- capture-frame layout, background planning, shadowing, wallpaper thumbnail decode/cache, and final + RGBA composition +- scroll minimap planning +- mosaic patch generation +- frozen selection hit-testing and transform geometry +- auto-center content-bound detection and margin-balance rules This crate must stay free of: @@ -125,7 +130,9 @@ It owns: - opaque session handles for foreign hosts - FFI-safe config, event, report, scene, and request types - exported `extern "C"` functions that forward into `rsnap-capture-core` -- the checked-in C header consumed by future native hosts: +- exported `extern "C"` functions that bridge retained Rust transition modules while they migrate + toward `rsnap-capture-core` +- the checked-in C header consumed by the native macOS host: `packages/rsnap-host-ffi/include/rsnap_host_ffi.h` It does not own product behavior beyond ABI adaptation. @@ -139,6 +146,9 @@ It owns: - the SwiftPM-built `.app` host shell - the AppKit window/view tree used for live and frozen capture UI - native cursor, focus, event routing, menu bar entry, and host-side effects +- OS-only resource discovery such as the current wallpaper path +- conversion between AppKit/CoreGraphics image objects and bridgeable RGBA buffers +- presentation of Rust-rendered images and models in native windows - the checked-in bridge probe used by `cargo make test-host-reset` It depends on: @@ -146,8 +156,9 @@ It depends on: - `packages/rsnap-host-ffi/` for the C ABI contract - `packages/rsnap-capture-core/` indirectly through that ABI -It must not grow a second product-semantic model. Scene state and host requests still come from the -Rust core. +It must not grow a second product-semantic model or duplicate Rust-owned image algorithms. Scene +state, host requests, export bytes, capture-frame renders, minimap plans, selection transforms, +auto-center decisions, and similar deterministic outputs come from the Rust side. ## Documentation placement diff --git a/docs/spec/platform-host-boundary.md b/docs/spec/platform-host-boundary.md index d4483cc7..bc9442ba 100644 --- a/docs/spec/platform-host-boundary.md +++ b/docs/spec/platform-host-boundary.md @@ -41,7 +41,9 @@ The native platform host owns: - global hotkey registration - permissions and permission recovery flows - native screenshot / live-stream / window-list / OCR capability acquisition +- OS resource discovery that has no portable API, such as the current desktop wallpaper path - clipboard, save-panel, notification, sound, and similar host-side effects +- presenting rendered pixels returned by the core inside native windows and controls ## Rust core ownership @@ -53,6 +55,9 @@ The Rust core owns: - annotation state, undo/redo, and export composition rules - display-authority versus export-authority semantics - scroll-capture overlap proof, stitching, and fail-closed rules +- final-byte image algorithms: crop mapping, lossless PNG export encoding, capture-frame planning + and compositing, wallpaper thumbnail decoding/caching, mosaic patch generation, minimap planning, + selection transforms, auto-centering analysis, and live-sample pixel extraction - deterministic replay, test fixtures, and product-level validation logic - cross-platform product data models and behavior contracts @@ -65,6 +70,8 @@ Host to core messages include: - user-intent events such as pointer, keyboard, and IME events - capability results such as live-frame delivery, freeze snapshot delivery, and window snapshot updates +- source pixels and narrow OS resource references, such as a wallpaper file path, when Rust owns the + portable planning, decode, cache, resize, composition, or export algorithm - lifecycle and environment signals such as permission changes or host teardown Core to host messages include: @@ -72,6 +79,8 @@ Core to host messages include: - host commands such as show/hide/update capture UI - capability requests such as start/stop live capture or request freeze snapshots - side-effect requests such as copy, save, OCR, or other host-owned effects +- rendered pixels, PNG bytes, geometry plans, hit-test results, and other deterministic outputs from + Rust-owned product algorithms The boundary must avoid leaking platform-native event types or platform-native window handles into the Rust product model except through narrow adapter types owned by the host layer. @@ -87,6 +96,8 @@ The following are out of bounds for new architecture work: - allowing Rust session code to directly own top-level platform window focus or activation policy - coupling product correctness to a legacy fallback-heavy lifecycle when that concern belongs in a host capability adapter +- reimplementing Rust-owned image planning, export, geometry, or analysis algorithms in Swift after + an FFI entrypoint exists for that responsibility ## Portability rule diff --git a/native/macos-host/Sources/RsnapHostBridge/HostFFI.swift b/native/macos-host/Sources/RsnapHostBridge/HostFFI.swift index c2b14334..3a764bfd 100644 --- a/native/macos-host/Sources/RsnapHostBridge/HostFFI.swift +++ b/native/macos-host/Sources/RsnapHostBridge/HostFFI.swift @@ -70,6 +70,1561 @@ public struct RGBARegionSnapshot: Equatable, Sendable { } } +public enum FrozenOverlayExportColor: UInt32, Equatable { + case white = 0 + case yellow = 1 + case green = 2 + case blue = 3 + case red = 4 + case black = 5 + + fileprivate var ffiColor: RsnapFrozenAnnotationColor { + switch self { + case .white: + RSNAP_FROZEN_ANNOTATION_COLOR_WHITE + case .yellow: + RSNAP_FROZEN_ANNOTATION_COLOR_YELLOW + case .green: + RSNAP_FROZEN_ANNOTATION_COLOR_GREEN + case .blue: + RSNAP_FROZEN_ANNOTATION_COLOR_BLUE + case .red: + RSNAP_FROZEN_ANNOTATION_COLOR_RED + case .black: + RSNAP_FROZEN_ANNOTATION_COLOR_BLACK + } + } +} + +public struct FrozenOverlayExportStrokeStyle: Equatable { + public var strokeWidthPoints: CGFloat + public var color: FrozenOverlayExportColor + + public init(strokeWidthPoints: CGFloat, color: FrozenOverlayExportColor) { + self.strokeWidthPoints = strokeWidthPoints + self.color = color + } +} + +public struct FrozenOverlayExportSpotlightStyle: Equatable { + public var borderWidthPoints: CGFloat + public var borderColor: FrozenOverlayExportColor + + public init(borderWidthPoints: CGFloat, borderColor: FrozenOverlayExportColor) { + self.borderWidthPoints = borderWidthPoints + self.borderColor = borderColor + } +} + +public struct FrozenOverlayExportTextStyle: Equatable { + public var fontSizePoints: CGFloat + public var color: FrozenOverlayExportColor + + public init(fontSizePoints: CGFloat, color: FrozenOverlayExportColor) { + self.fontSizePoints = fontSizePoints + self.color = color + } +} + +public enum FrozenOverlayExportElement: Equatable { + case pen(points: [CGPoint], style: FrozenOverlayExportStrokeStyle) + case arrow(start: CGPoint, end: CGPoint, style: FrozenOverlayExportStrokeStyle) + case mosaic(rect: CGRect) + case spotlight(rect: CGRect, style: FrozenOverlayExportSpotlightStyle) + case text(anchor: CGPoint, text: String, style: FrozenOverlayExportTextStyle) +} + +public struct FrozenOverlayEditStyle: Equatable { + public var strokeWidthPoints: CGFloat + public var strokeColor: FrozenOverlayExportColor + public var spotlightBorderWidthPoints: CGFloat + public var spotlightColor: FrozenOverlayExportColor + public var textFontSizePoints: CGFloat + public var textColor: FrozenOverlayExportColor + + public init( + strokeWidthPoints: CGFloat, + strokeColor: FrozenOverlayExportColor, + spotlightBorderWidthPoints: CGFloat, + spotlightColor: FrozenOverlayExportColor, + textFontSizePoints: CGFloat, + textColor: FrozenOverlayExportColor + ) { + self.strokeWidthPoints = strokeWidthPoints + self.strokeColor = strokeColor + self.spotlightBorderWidthPoints = spotlightBorderWidthPoints + self.spotlightColor = spotlightColor + self.textFontSizePoints = textFontSizePoints + self.textColor = textColor + } +} + +public struct FrozenOverlayActiveTextEdit: Equatable { + public var anchor: CGPoint + public var text: String + + public init(anchor: CGPoint, text: String) { + self.anchor = anchor + self.text = text + } +} + +public struct FrozenOverlayEditSnapshot: Equatable { + public var canUndo: Bool + public var canRedo: Bool + public var keepsFrozenSelectionFixed: Bool + public var isMovingMovableAnnotation: Bool + public var hasActiveInteraction: Bool + public var elements: [FrozenOverlayExportElement] + public var previewPen: FrozenOverlayExportElement? + public var previewArrow: FrozenOverlayExportElement? + public var previewMosaic: FrozenOverlayExportElement? + public var previewSpotlight: FrozenOverlayExportElement? + public var previewText: FrozenOverlayExportElement? + public var activeTextEdit: FrozenOverlayActiveTextEdit? + + public init( + canUndo: Bool, + canRedo: Bool, + keepsFrozenSelectionFixed: Bool, + isMovingMovableAnnotation: Bool, + hasActiveInteraction: Bool, + elements: [FrozenOverlayExportElement], + previewPen: FrozenOverlayExportElement?, + previewArrow: FrozenOverlayExportElement?, + previewMosaic: FrozenOverlayExportElement?, + previewSpotlight: FrozenOverlayExportElement?, + previewText: FrozenOverlayExportElement?, + activeTextEdit: FrozenOverlayActiveTextEdit? + ) { + self.canUndo = canUndo + self.canRedo = canRedo + self.keepsFrozenSelectionFixed = keepsFrozenSelectionFixed + self.isMovingMovableAnnotation = isMovingMovableAnnotation + self.hasActiveInteraction = hasActiveInteraction + self.elements = elements + self.previewPen = previewPen + self.previewArrow = previewArrow + self.previewMosaic = previewMosaic + self.previewSpotlight = previewSpotlight + self.previewText = previewText + self.activeTextEdit = activeTextEdit + } +} + +private final class FrozenOverlayExportFFIStorage { + var elements: [RsnapFrozenOverlayExportElement] = [] + private var pointBuffers: [UnsafeMutableBufferPointer] = [] + private var textBuffers: [UnsafeMutableBufferPointer] = [] + + init(_ elements: [FrozenOverlayExportElement]) { + self.elements = elements.map { element in + switch element { + case .pen(let points, let style): + return encodePen(points: points, style: style) + case .arrow(let start, let end, let style): + return encodeArrow(start: start, end: end, style: style) + case .mosaic(let rect): + return encodeMosaic(rect: rect) + case .spotlight(let rect, let style): + return encodeSpotlight(rect: rect, style: style) + case .text(let anchor, let text, let style): + return encodeText(anchor: anchor, text: text, style: style) + } + } + } + + deinit { + for buffer in pointBuffers { + buffer.baseAddress?.deinitialize(count: buffer.count) + buffer.baseAddress?.deallocate() + } + for buffer in textBuffers { + buffer.baseAddress?.deinitialize(count: buffer.count) + buffer.baseAddress?.deallocate() + } + } + + private func encodePen( + points: [CGPoint], + style: FrozenOverlayExportStrokeStyle + ) -> RsnapFrozenOverlayExportElement { + let buffer = allocatePoints(points) + return element( + kind: RSNAP_FROZEN_OVERLAY_EXPORT_ELEMENT_PEN, + points: buffer.baseAddress, + pointsLen: buffer.count, + strokeWidthPoints: style.strokeWidthPoints, + color: style.color + ) + } + + private func encodeArrow( + start: CGPoint, + end: CGPoint, + style: FrozenOverlayExportStrokeStyle + ) -> RsnapFrozenOverlayExportElement { + element( + kind: RSNAP_FROZEN_OVERLAY_EXPORT_ELEMENT_ARROW, + start: Self.encode(point: start), + end: Self.encode(point: end), + strokeWidthPoints: style.strokeWidthPoints, + color: style.color + ) + } + + private func encodeMosaic(rect: CGRect) -> RsnapFrozenOverlayExportElement { + element(kind: RSNAP_FROZEN_OVERLAY_EXPORT_ELEMENT_MOSAIC, rect: Self.encode(rect: rect)) + } + + private func encodeSpotlight( + rect: CGRect, + style: FrozenOverlayExportSpotlightStyle + ) -> RsnapFrozenOverlayExportElement { + element( + kind: RSNAP_FROZEN_OVERLAY_EXPORT_ELEMENT_SPOTLIGHT, + rect: Self.encode(rect: rect), + borderWidthPoints: style.borderWidthPoints, + color: style.borderColor + ) + } + + private func encodeText( + anchor: CGPoint, + text: String, + style: FrozenOverlayExportTextStyle + ) -> RsnapFrozenOverlayExportElement { + let buffer = allocateText(text) + return element( + kind: RSNAP_FROZEN_OVERLAY_EXPORT_ELEMENT_TEXT, + start: Self.encode(point: anchor), + text: buffer.baseAddress, + fontSizePoints: style.fontSizePoints, + color: style.color + ) + } + + private func allocatePoints(_ points: [CGPoint]) -> UnsafeMutableBufferPointer + { + guard points.isEmpty == false else { + return UnsafeMutableBufferPointer(start: nil, count: 0) + } + let encoded = points.map(Self.encode(point:)) + let pointer = UnsafeMutablePointer.allocate(capacity: encoded.count) + pointer.initialize(from: encoded, count: encoded.count) + let buffer = UnsafeMutableBufferPointer(start: pointer, count: encoded.count) + pointBuffers.append(buffer) + return buffer + } + + private func allocateText(_ text: String) -> UnsafeMutableBufferPointer { + let encoded = Array(text.utf8CString) + let pointer = UnsafeMutablePointer.allocate(capacity: encoded.count) + pointer.initialize(from: encoded, count: encoded.count) + let buffer = UnsafeMutableBufferPointer(start: pointer, count: encoded.count) + textBuffers.append(buffer) + return buffer + } + + private func element( + kind: RsnapFrozenOverlayExportElementKind, + rect: RsnapFloatRect = RsnapFloatRect(), + start: RsnapFloatPoint = RsnapFloatPoint(), + end: RsnapFloatPoint = RsnapFloatPoint(), + points: UnsafePointer? = nil, + pointsLen: Int = 0, + text: UnsafePointer? = nil, + strokeWidthPoints: CGFloat = 0, + borderWidthPoints: CGFloat = 0, + fontSizePoints: CGFloat = 0, + color: FrozenOverlayExportColor = .blue + ) -> RsnapFrozenOverlayExportElement { + RsnapFrozenOverlayExportElement( + kind: kind, + rect: rect, + start: start, + end: end, + points: points, + points_len: pointsLen, + text: text, + stroke_width_points: Double(strokeWidthPoints), + border_width_points: Double(borderWidthPoints), + font_size_points: Double(fontSizePoints), + color: color.ffiColor + ) + } + + private static func encode(point: CGPoint) -> RsnapFloatPoint { + RsnapFloatPoint(x: Double(point.x), y: Double(point.y)) + } + + private static func encode(rect: CGRect) -> RsnapFloatRect { + RsnapFloatRect( + x: Double(rect.origin.x), + y: Double(rect.origin.y), + width: Double(rect.width), + height: Double(rect.height) + ) + } +} + +public final class RsnapFrozenOverlayEditSession { + private let handle: OpaquePointer + + public init() throws { + guard let handle = rsnap_frozen_overlay_edit_session_create() else { + throw HostBridgeError.sessionCreationFailed + } + self.handle = handle + } + + deinit { + rsnap_frozen_overlay_edit_session_destroy(handle) + } + + public func reset() throws { + try Self.requireOk( + rsnap_frozen_overlay_edit_session_reset(handle), + context: "resetting frozen overlay edit session" + ) + } + + public func begin( + tool: ToolbarItemKind, + at point: CGPoint, + selection: CGRect, + style: FrozenOverlayEditStyle + ) throws -> Bool { + try boolResult { outChanged in + rsnap_frozen_overlay_edit_session_begin( + handle, + tool.ffiKind, + Self.encode(point: point), + Self.encode(rect: selection), + Self.encode(style: style), + outChanged + ) + } + } + + public func update(to point: CGPoint, selection: CGRect) throws -> Bool { + try boolResult { outChanged in + rsnap_frozen_overlay_edit_session_update( + handle, + Self.encode(point: point), + Self.encode(rect: selection), + outChanged + ) + } + } + + public func finish(selection: CGRect) throws -> Bool { + try boolResult { outChanged in + rsnap_frozen_overlay_edit_session_finish( + handle, + Self.encode(rect: selection), + outChanged + ) + } + } + + public func appendText(_ text: String) throws -> Bool { + try text.withCString { textPointer in + try boolResult { outChanged in + rsnap_frozen_overlay_edit_session_append_text(handle, textPointer, outChanged) + } + } + } + + public func backspaceText() throws -> Bool { + try boolResult { outChanged in + rsnap_frozen_overlay_edit_session_backspace_text(handle, outChanged) + } + } + + public func commitText(style: FrozenOverlayEditStyle) throws -> Bool { + try boolResult { outChanged in + rsnap_frozen_overlay_edit_session_commit_text( + handle, + Self.encode(style: style), + outChanged + ) + } + } + + public func cancelText() throws { + try Self.requireOk( + rsnap_frozen_overlay_edit_session_cancel_text(handle), + context: "canceling frozen overlay text edit" + ) + } + + public func undo() throws -> Bool { + try boolResult { outChanged in + rsnap_frozen_overlay_edit_session_undo(handle, outChanged) + } + } + + public func redo() throws -> Bool { + try boolResult { outChanged in + rsnap_frozen_overlay_edit_session_redo(handle, outChanged) + } + } + + public func containsMovableAnnotation(at point: CGPoint) throws -> Bool { + try boolResult { outContains in + rsnap_frozen_overlay_edit_session_contains_movable_annotation( + handle, + Self.encode(point: point), + outContains + ) + } + } + + public func snapshot() throws -> FrozenOverlayEditSnapshot { + var rawSnapshot = RsnapFrozenOverlayEditSnapshot() + try Self.requireOk( + rsnap_frozen_overlay_edit_session_copy_snapshot(handle, &rawSnapshot), + context: "copying frozen overlay edit snapshot" + ) + defer { + rsnap_frozen_overlay_edit_snapshot_release(&rawSnapshot) + } + + return FrozenOverlayEditSnapshot( + canUndo: rawSnapshot.can_undo != 0, + canRedo: rawSnapshot.can_redo != 0, + keepsFrozenSelectionFixed: rawSnapshot.keeps_frozen_selection_fixed != 0, + isMovingMovableAnnotation: rawSnapshot.is_moving_movable_annotation != 0, + hasActiveInteraction: rawSnapshot.has_active_interaction != 0, + elements: Self.decodeElements(rawSnapshot.elements, count: rawSnapshot.elements_len), + previewPen: rawSnapshot.has_preview_pen == 0 + ? nil : Self.decode(element: rawSnapshot.preview_pen), + previewArrow: rawSnapshot.has_preview_arrow == 0 + ? nil : Self.decode(element: rawSnapshot.preview_arrow), + previewMosaic: rawSnapshot.has_preview_mosaic == 0 + ? nil : Self.decode(element: rawSnapshot.preview_mosaic), + previewSpotlight: rawSnapshot.has_preview_spotlight == 0 + ? nil : Self.decode(element: rawSnapshot.preview_spotlight), + previewText: rawSnapshot.has_preview_text == 0 + ? nil : Self.decode(element: rawSnapshot.preview_text), + activeTextEdit: rawSnapshot.has_active_text_edit == 0 + ? nil : Self.decode(activeTextEdit: rawSnapshot.active_text_edit) + ) + } + + private func boolResult(_ body: (UnsafeMutablePointer) -> RsnapStatus) throws -> Bool { + var changed: UInt8 = 0 + try Self.requireOk(body(&changed), context: "running frozen overlay edit operation") + return changed != 0 + } + + private static func requireOk(_ status: RsnapStatus, context: String) throws { + let code = rsnap_status_code(status) + if code != 0 { + throw HostBridgeError.ffiStatus(context: context, code: code) + } + } + + private static func decodeElements( + _ elements: UnsafeMutablePointer?, + count: Int + ) -> [FrozenOverlayExportElement] { + guard let elements, count > 0 else { + return [] + } + return UnsafeBufferPointer(start: elements, count: count).compactMap(decode(element:)) + } + + private static func decode(element: RsnapFrozenOverlayExportElement) + -> FrozenOverlayExportElement? + { + switch element.kind { + case RSNAP_FROZEN_OVERLAY_EXPORT_ELEMENT_PEN: + return .pen( + points: decodePoints(element.points, count: element.points_len), + style: FrozenOverlayExportStrokeStyle( + strokeWidthPoints: element.stroke_width_points, + color: decode(color: element.color) + ) + ) + case RSNAP_FROZEN_OVERLAY_EXPORT_ELEMENT_ARROW: + return .arrow( + start: decode(point: element.start), + end: decode(point: element.end), + style: FrozenOverlayExportStrokeStyle( + strokeWidthPoints: element.stroke_width_points, + color: decode(color: element.color) + ) + ) + case RSNAP_FROZEN_OVERLAY_EXPORT_ELEMENT_MOSAIC: + return .mosaic(rect: decode(rect: element.rect)) + case RSNAP_FROZEN_OVERLAY_EXPORT_ELEMENT_SPOTLIGHT: + return .spotlight( + rect: decode(rect: element.rect), + style: FrozenOverlayExportSpotlightStyle( + borderWidthPoints: element.border_width_points, + borderColor: decode(color: element.color) + ) + ) + case RSNAP_FROZEN_OVERLAY_EXPORT_ELEMENT_TEXT: + return .text( + anchor: decode(point: element.start), + text: decode(text: element.text), + style: FrozenOverlayExportTextStyle( + fontSizePoints: element.font_size_points, + color: decode(color: element.color) + ) + ) + default: + return nil + } + } + + private static func decode(activeTextEdit element: RsnapFrozenOverlayExportElement) + -> FrozenOverlayActiveTextEdit + { + FrozenOverlayActiveTextEdit( + anchor: decode(point: element.start), + text: decode(text: element.text) + ) + } + + private static func decodePoints( + _ points: UnsafePointer?, + count: Int + ) -> [CGPoint] { + guard let points, count > 0 else { + return [] + } + return UnsafeBufferPointer(start: points, count: count).map(decode(point:)) + } + + private static func decode(text: UnsafePointer?) -> String { + guard let text else { + return "" + } + return String(cString: text) + } + + private static func encode(style: FrozenOverlayEditStyle) -> RsnapFrozenOverlayEditStyle { + RsnapFrozenOverlayEditStyle( + stroke_width_points: Double(style.strokeWidthPoints), + stroke_color: style.strokeColor.ffiColor, + spotlight_border_width_points: Double(style.spotlightBorderWidthPoints), + spotlight_color: style.spotlightColor.ffiColor, + text_font_size_points: Double(style.textFontSizePoints), + text_color: style.textColor.ffiColor + ) + } + + private static func encode(point: CGPoint) -> RsnapFloatPoint { + RsnapFloatPoint(x: Double(point.x), y: Double(point.y)) + } + + private static func decode(point: RsnapFloatPoint) -> CGPoint { + CGPoint(x: point.x, y: point.y) + } + + private static func encode(rect: CGRect) -> RsnapFloatRect { + RsnapFloatRect( + x: Double(rect.origin.x), + y: Double(rect.origin.y), + width: Double(rect.width), + height: Double(rect.height) + ) + } + + private static func decode(rect: RsnapFloatRect) -> CGRect { + CGRect(x: rect.x, y: rect.y, width: rect.width, height: rect.height) + } + + private static func decode(color: RsnapFrozenAnnotationColor) -> FrozenOverlayExportColor { + switch color { + case RSNAP_FROZEN_ANNOTATION_COLOR_WHITE: + .white + case RSNAP_FROZEN_ANNOTATION_COLOR_YELLOW: + .yellow + case RSNAP_FROZEN_ANNOTATION_COLOR_GREEN: + .green + case RSNAP_FROZEN_ANNOTATION_COLOR_BLUE: + .blue + case RSNAP_FROZEN_ANNOTATION_COLOR_RED: + .red + case RSNAP_FROZEN_ANNOTATION_COLOR_BLACK: + .black + default: + .blue + } + } +} + +public enum CaptureFrameSourceKind: UInt32, Equatable, Sendable { + case dragRegion = 0 + case window = 1 + case fullScreen = 2 + case scrollCapture = 3 + case unknown = 4 + + fileprivate var ffiKind: RsnapCaptureFrameSourceKind { + switch self { + case .dragRegion: + RSNAP_CAPTURE_FRAME_SOURCE_DRAG_REGION + case .window: + RSNAP_CAPTURE_FRAME_SOURCE_WINDOW + case .fullScreen: + RSNAP_CAPTURE_FRAME_SOURCE_FULL_SCREEN + case .scrollCapture: + RSNAP_CAPTURE_FRAME_SOURCE_SCROLL_CAPTURE + case .unknown: + RSNAP_CAPTURE_FRAME_SOURCE_UNKNOWN + } + } +} + +public enum CaptureFrameBackgroundKind: UInt32, Equatable, Sendable { + case systemWallpaper = 0 + case aurora = 1 + case graphite = 2 + case linen = 3 + + fileprivate var ffiKind: RsnapCaptureFrameBackgroundKind { + switch self { + case .systemWallpaper: + RSNAP_CAPTURE_FRAME_BACKGROUND_SYSTEM_WALLPAPER + case .aurora: + RSNAP_CAPTURE_FRAME_BACKGROUND_AURORA + case .graphite: + RSNAP_CAPTURE_FRAME_BACKGROUND_GRAPHITE + case .linen: + RSNAP_CAPTURE_FRAME_BACKGROUND_LINEN + } + } +} + +public enum CaptureFrameRenderKind: UInt32, Equatable, Sendable { + case framedCapture = 0 + case windowSnapshot = 1 + + fileprivate var ffiKind: RsnapCaptureFrameRenderKind { + switch self { + case .framedCapture: + RSNAP_CAPTURE_FRAME_RENDER_FRAMED_CAPTURE + case .windowSnapshot: + RSNAP_CAPTURE_FRAME_RENDER_WINDOW_SNAPSHOT + } + } +} + +public struct CaptureFrameColorStop: Equatable, Sendable { + public var red: CGFloat + public var green: CGFloat + public var blue: CGFloat + public var alpha: CGFloat + + public init(red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) { + self.red = red + self.green = green + self.blue = blue + self.alpha = alpha + } +} + +public struct CaptureFrameBackgroundPlan: Equatable, Sendable { + public var colorStops: [CaptureFrameColorStop] + public var locations: [CGFloat] + public var prefersWallpaper: Bool + public var wallpaperOverlayAlpha: CGFloat + + public init( + colorStops: [CaptureFrameColorStop], + locations: [CGFloat], + prefersWallpaper: Bool, + wallpaperOverlayAlpha: CGFloat + ) { + self.colorStops = colorStops + self.locations = locations + self.prefersWallpaper = prefersWallpaper + self.wallpaperOverlayAlpha = wallpaperOverlayAlpha + } +} + +public struct CaptureFrameWallpaperRequest: Equatable, Sendable { + public var targetPixelSize: Int + public var overlayAlpha: CGFloat + + public init(targetPixelSize: Int, overlayAlpha: CGFloat) { + self.targetPixelSize = targetPixelSize + self.overlayAlpha = overlayAlpha + } +} + +public struct CaptureFrameShadowPlan: Equatable, Sendable { + public var offset: CGSize + public var blur: CGFloat + public var alpha: CGFloat +} + +public struct CaptureFrameLayoutPlan: Equatable, Sendable { + public var canvasSize: CGSize + public var imageRect: CGRect + public var cornerRadius: CGFloat + public var shadows: [CaptureFrameShadowPlan] +} + +public struct ScrollMinimapLayoutPlan: Equatable, Sendable { + public var frame: CGRect + public var imageFrame: CGRect + public var viewportFrame: CGRect? +} + +public enum FrozenSelectionTransformKind: UInt32, Equatable, Sendable { + case move = 0 + case resizeLeft = 1 + case resizeRight = 2 + case resizeTop = 3 + case resizeBottom = 4 + case resizeTopLeft = 5 + case resizeTopRight = 6 + case resizeBottomLeft = 7 + case resizeBottomRight = 8 + + fileprivate var ffiKind: RsnapFrozenSelectionTransformKind { + switch self { + case .move: + RSNAP_FROZEN_SELECTION_TRANSFORM_MOVE + case .resizeLeft: + RSNAP_FROZEN_SELECTION_TRANSFORM_RESIZE_LEFT + case .resizeRight: + RSNAP_FROZEN_SELECTION_TRANSFORM_RESIZE_RIGHT + case .resizeTop: + RSNAP_FROZEN_SELECTION_TRANSFORM_RESIZE_TOP + case .resizeBottom: + RSNAP_FROZEN_SELECTION_TRANSFORM_RESIZE_BOTTOM + case .resizeTopLeft: + RSNAP_FROZEN_SELECTION_TRANSFORM_RESIZE_TOP_LEFT + case .resizeTopRight: + RSNAP_FROZEN_SELECTION_TRANSFORM_RESIZE_TOP_RIGHT + case .resizeBottomLeft: + RSNAP_FROZEN_SELECTION_TRANSFORM_RESIZE_BOTTOM_LEFT + case .resizeBottomRight: + RSNAP_FROZEN_SELECTION_TRANSFORM_RESIZE_BOTTOM_RIGHT + } + } +} + +public enum RsnapCaptureFramePlanner { + public static func plan( + imageWidth: Int, + imageHeight: Int, + screenScaleFactor: CGFloat, + source: CaptureFrameSourceKind + ) throws -> CaptureFrameLayoutPlan? { + var outPlan = RsnapCaptureFramePlan() + let status = rsnap_capture_frame_plan( + UInt32(max(imageWidth, 0)), + UInt32(max(imageHeight, 0)), + Double(screenScaleFactor), + source.ffiKind, + &outPlan + ) + let code = rsnap_status_code(status) + if code == RSNAP_STATUS_INVALID_INPUT.rawValue { + return nil + } + try requireOk(status, context: "resolving capture frame layout plan") + + return CaptureFrameLayoutPlan( + canvasSize: CGSize(width: outPlan.canvas_width, height: outPlan.canvas_height), + imageRect: decode(rect: outPlan.image_rect), + cornerRadius: CGFloat(outPlan.corner_radius), + shadows: [ + decode(shadow: outPlan.shadows.0), + decode(shadow: outPlan.shadows.1), + decode(shadow: outPlan.shadows.2), + ] + ) + } + + public static func aspectFillCropRect( + sourceWidth: Int, + sourceHeight: Int, + destinationSize: CGSize + ) throws -> CGRect? { + var outRect = RsnapFloatRect() + let status = rsnap_capture_frame_aspect_fill_crop_rect( + UInt32(max(sourceWidth, 0)), + UInt32(max(sourceHeight, 0)), + Double(destinationSize.width), + Double(destinationSize.height), + &outRect + ) + let code = rsnap_status_code(status) + if code == RSNAP_STATUS_INVALID_INPUT.rawValue { + return nil + } + try requireOk(status, context: "resolving capture frame aspect-fill crop") + + return decode(rect: outRect) + } + + public static func backgroundPlan( + for background: CaptureFrameBackgroundKind + ) throws -> CaptureFrameBackgroundPlan { + var outPlan = RsnapCaptureFrameBackgroundPlan() + let status = rsnap_capture_frame_background_plan(background.ffiKind, &outPlan) + try requireOk(status, context: "resolving capture frame background plan") + + return CaptureFrameBackgroundPlan( + colorStops: [ + decode(color: outPlan.colors.0), + decode(color: outPlan.colors.1), + decode(color: outPlan.colors.2), + ], + locations: [ + CGFloat(outPlan.locations.0), + CGFloat(outPlan.locations.1), + CGFloat(outPlan.locations.2), + ], + prefersWallpaper: outPlan.prefers_wallpaper != 0, + wallpaperOverlayAlpha: CGFloat(outPlan.wallpaper_overlay_alpha) + ) + } + + public static func wallpaperRequestPlan( + for background: CaptureFrameBackgroundKind, + destinationSize: CGSize + ) throws -> CaptureFrameWallpaperRequest? { + var outRequest = RsnapCaptureFrameWallpaperRequest() + let status = rsnap_capture_frame_wallpaper_request_plan( + background.ffiKind, + Double(destinationSize.width), + Double(destinationSize.height), + &outRequest + ) + let code = rsnap_status_code(status) + if code == RSNAP_STATUS_EMPTY.rawValue { + return nil + } + try requireOk(status, context: "resolving capture frame wallpaper request") + + return CaptureFrameWallpaperRequest( + targetPixelSize: Int(outRequest.target_pixel_size), + overlayAlpha: CGFloat(outRequest.overlay_alpha) + ) + } + + private static func requireOk(_ status: RsnapStatus, context: String) throws { + let code = rsnap_status_code(status) + if code != 0 { + throw HostBridgeError.ffiStatus(context: context, code: code) + } + } + + private static func decode(rect: RsnapFloatRect) -> CGRect { + CGRect(x: rect.x, y: rect.y, width: rect.width, height: rect.height) + } + + private static func decode(color: RsnapCaptureFrameColorStop) -> CaptureFrameColorStop { + CaptureFrameColorStop( + red: CGFloat(color.red), + green: CGFloat(color.green), + blue: CGFloat(color.blue), + alpha: CGFloat(color.alpha) + ) + } + + private static func decode(shadow: RsnapCaptureFrameShadow) -> CaptureFrameShadowPlan { + CaptureFrameShadowPlan( + offset: CGSize(width: shadow.offset_x, height: shadow.offset_y), + blur: CGFloat(shadow.blur), + alpha: CGFloat(shadow.alpha) + ) + } +} + +public enum RsnapCaptureFrameRenderer { + public static func render( + source: RGBARegionSnapshot, + background: CaptureFrameBackgroundKind, + screenScaleFactor: CGFloat, + sourceKind: CaptureFrameSourceKind, + renderKind: CaptureFrameRenderKind, + wallpaperPath: String? + ) throws -> RGBARegionSnapshot? { + var outRegion = RsnapOwnedRgbaRegion() + let status = source.rgba.withUnsafeBytes { buffer -> RsnapStatus in + guard let baseAddress = buffer.bindMemory(to: UInt8.self).baseAddress else { + return RSNAP_STATUS_INVALID_INPUT + } + + if let wallpaperPath { + return wallpaperPath.withCString { wallpaperPathPointer in + rsnap_capture_frame_render_rgba( + UInt32(max(source.width, 0)), + UInt32(max(source.height, 0)), + baseAddress, + source.rgba.count, + Double(screenScaleFactor), + sourceKind.ffiKind, + background.ffiKind, + renderKind.ffiKind, + wallpaperPathPointer, + &outRegion + ) + } + } + + return rsnap_capture_frame_render_rgba( + UInt32(max(source.width, 0)), + UInt32(max(source.height, 0)), + baseAddress, + source.rgba.count, + Double(screenScaleFactor), + sourceKind.ffiKind, + background.ffiKind, + renderKind.ffiKind, + nil, + &outRegion + ) + } + let code = rsnap_status_code(status) + if code == RSNAP_STATUS_INVALID_INPUT.rawValue { + return nil + } + try requireOk(status, context: "rendering capture frame") + + return rgbaSnapshot(from: outRegion) + } + + private static func requireOk(_ status: RsnapStatus, context: String) throws { + let code = rsnap_status_code(status) + if code != 0 { + throw HostBridgeError.ffiStatus(context: context, code: code) + } + } + + private static func rgbaSnapshot(from outRegion: RsnapOwnedRgbaRegion) -> RGBARegionSnapshot? { + guard outRegion.len > 0, let rgba = outRegion.rgba else { + return nil + } + + let ownedRegion = UnsafeMutablePointer.allocate(capacity: 1) + ownedRegion.initialize(to: outRegion) + let data = Data( + bytesNoCopy: rgba, + count: outRegion.len, + deallocator: .custom { _, _ in + rsnap_owned_rgba_region_release(ownedRegion) + ownedRegion.deinitialize(count: 1) + ownedRegion.deallocate() + } + ) + return RGBARegionSnapshot( + width: Int(outRegion.width), + height: Int(outRegion.height), + rgba: data + ) + } +} + +public enum RsnapWallpaperThumbnailDecoder { + public static func pngThumbnail( + path: String, + targetPixelSize: Int + ) throws -> RGBARegionSnapshot? { + let clampedTarget = min(max(targetPixelSize, 0), Int(UInt32.max)) + if clampedTarget == 0 { + return nil + } + + var outRegion = RsnapOwnedRgbaRegion() + let status = path.withCString { pathPointer in + rsnap_capture_frame_wallpaper_png_thumbnail( + pathPointer, + UInt32(clampedTarget), + &outRegion + ) + } + let code = rsnap_status_code(status) + if code == RSNAP_STATUS_EMPTY.rawValue { + return nil + } + try requireOk(status, context: "decoding PNG wallpaper thumbnail") + + return rgbaSnapshot(from: outRegion) + } + + private static func requireOk(_ status: RsnapStatus, context: String) throws { + let code = rsnap_status_code(status) + if code != 0 { + throw HostBridgeError.ffiStatus(context: context, code: code) + } + } + + private static func rgbaSnapshot(from outRegion: RsnapOwnedRgbaRegion) -> RGBARegionSnapshot? { + guard outRegion.len > 0, let rgba = outRegion.rgba else { + return nil + } + + let ownedRegion = UnsafeMutablePointer.allocate(capacity: 1) + ownedRegion.initialize(to: outRegion) + let data = Data( + bytesNoCopy: rgba, + count: outRegion.len, + deallocator: .custom { _, _ in + rsnap_owned_rgba_region_release(ownedRegion) + ownedRegion.deinitialize(count: 1) + ownedRegion.deallocate() + } + ) + return RGBARegionSnapshot( + width: Int(outRegion.width), + height: Int(outRegion.height), + rgba: data + ) + } +} + +public enum RsnapScrollMinimapPlanner { + public static func plan( + selection: CGRect, + exportSize: CGSize, + bounds: CGRect, + preferredWidth: CGFloat, + minimumWidth: CGFloat, + gap: CGFloat, + margin: CGFloat, + imageInset: CGFloat, + viewportTopPixels: CGFloat, + viewportHeightPixels: CGFloat + ) throws -> ScrollMinimapLayoutPlan? { + var outPlan = RsnapScrollMinimapPlan() + let status = rsnap_scroll_minimap_plan( + encode(rect: selection), + Double(exportSize.width), + Double(exportSize.height), + encode(rect: bounds), + Double(preferredWidth), + Double(minimumWidth), + Double(gap), + Double(margin), + Double(imageInset), + Double(viewportTopPixels), + Double(viewportHeightPixels), + &outPlan + ) + let code = rsnap_status_code(status) + if code == RSNAP_STATUS_EMPTY.rawValue { + return nil + } + try requireOk(status, context: "resolving scroll minimap layout plan") + let viewportFrame = + outPlan.has_viewport_frame != 0 ? decode(rect: outPlan.viewport_frame) : nil + + return ScrollMinimapLayoutPlan( + frame: decode(rect: outPlan.frame), + imageFrame: decode(rect: outPlan.image_frame), + viewportFrame: viewportFrame + ) + } + + private static func requireOk(_ status: RsnapStatus, context: String) throws { + let code = rsnap_status_code(status) + if code != 0 { + throw HostBridgeError.ffiStatus(context: context, code: code) + } + } + + private static func encode(rect: CGRect) -> RsnapFloatRect { + RsnapFloatRect( + x: Double(rect.minX), + y: Double(rect.minY), + width: Double(rect.width), + height: Double(rect.height) + ) + } + + private static func decode(rect: RsnapFloatRect) -> CGRect { + CGRect(x: rect.x, y: rect.y, width: rect.width, height: rect.height) + } +} + +public enum RsnapFrozenSelectionTransformPlanner { + public static func hitTest( + point: CGPoint, + selection: CGRect, + handleRadius: CGFloat, + edgeTolerance: CGFloat + ) throws -> FrozenSelectionTransformKind? { + var outKind = RSNAP_FROZEN_SELECTION_TRANSFORM_MOVE + let status = rsnap_frozen_selection_transform_hit_test( + Double(point.x), + Double(point.y), + encode(rect: selection), + Double(handleRadius), + Double(edgeTolerance), + &outKind + ) + let code = rsnap_status_code(status) + if code == RSNAP_STATUS_EMPTY.rawValue { + return nil + } + try requireOk(status, context: "hit-testing frozen selection transform") + + return decode(kind: outKind) + } + + public static func transformedRect( + kind: FrozenSelectionTransformKind, + initialSelection: CGRect, + monitorFrame: CGRect, + initialPointer: CGPoint, + point: CGPoint, + minimumSize: CGFloat + ) throws -> CGRect? { + var outRect = RsnapFloatRect() + let status = rsnap_frozen_selection_transform_rect( + kind.ffiKind, + encode(rect: initialSelection), + encode(rect: monitorFrame), + Double(initialPointer.x), + Double(initialPointer.y), + Double(point.x), + Double(point.y), + Double(minimumSize), + &outRect + ) + let code = rsnap_status_code(status) + if code == RSNAP_STATUS_EMPTY.rawValue { + return nil + } + try requireOk(status, context: "resolving frozen selection transform") + + return decode(rect: outRect) + } + + private static func requireOk(_ status: RsnapStatus, context: String) throws { + let code = rsnap_status_code(status) + if code != 0 { + throw HostBridgeError.ffiStatus(context: context, code: code) + } + } + + private static func encode(rect: CGRect) -> RsnapFloatRect { + RsnapFloatRect( + x: Double(rect.minX), + y: Double(rect.minY), + width: Double(rect.width), + height: Double(rect.height) + ) + } + + private static func decode(rect: RsnapFloatRect) -> CGRect { + CGRect(x: rect.x, y: rect.y, width: rect.width, height: rect.height) + } + + private static func decode(kind: RsnapFrozenSelectionTransformKind) + -> FrozenSelectionTransformKind + { + return switch kind { + case RSNAP_FROZEN_SELECTION_TRANSFORM_MOVE: + .move + case RSNAP_FROZEN_SELECTION_TRANSFORM_RESIZE_LEFT: + .resizeLeft + case RSNAP_FROZEN_SELECTION_TRANSFORM_RESIZE_RIGHT: + .resizeRight + case RSNAP_FROZEN_SELECTION_TRANSFORM_RESIZE_TOP: + .resizeTop + case RSNAP_FROZEN_SELECTION_TRANSFORM_RESIZE_BOTTOM: + .resizeBottom + case RSNAP_FROZEN_SELECTION_TRANSFORM_RESIZE_TOP_LEFT: + .resizeTopLeft + case RSNAP_FROZEN_SELECTION_TRANSFORM_RESIZE_TOP_RIGHT: + .resizeTopRight + case RSNAP_FROZEN_SELECTION_TRANSFORM_RESIZE_BOTTOM_LEFT: + .resizeBottomLeft + case RSNAP_FROZEN_SELECTION_TRANSFORM_RESIZE_BOTTOM_RIGHT: + .resizeBottomRight + default: + .move + } + } +} + +public enum RsnapAutoCenterPlanner { + public static func contentBounds(in image: RGBARegionSnapshot) throws -> CGRect? { + var outRect = RsnapPixelRect() + let status = image.rgba.withUnsafeBytes { buffer -> RsnapStatus in + guard let baseAddress = buffer.bindMemory(to: UInt8.self).baseAddress else { + return RSNAP_STATUS_INVALID_INPUT + } + return rsnap_auto_center_content_bounds_rgba( + UInt32(max(image.width, 0)), + UInt32(max(image.height, 0)), + baseAddress, + image.rgba.count, + &outRect + ) + } + let code = rsnap_status_code(status) + if code == RSNAP_STATUS_EMPTY.rawValue { + return nil + } + try requireOk(status, context: "detecting auto-center content bounds") + + return decode(pixelRect: outRect) + } + + public static func marginBalanceShiftPoints( + contentOriginPixels: CGFloat, + contentSizePixels: CGFloat, + cropSizePixels: CGFloat, + captureSizePoints: CGFloat + ) -> CGFloat { + CGFloat( + rsnap_auto_center_margin_balance_shift_points( + Double(contentOriginPixels), + Double(contentSizePixels), + Double(cropSizePixels), + Double(captureSizePoints) + ) + ) + } + + private static func requireOk(_ status: RsnapStatus, context: String) throws { + let code = rsnap_status_code(status) + if code != 0 { + throw HostBridgeError.ffiStatus(context: context, code: code) + } + } + + private static func decode(pixelRect: RsnapPixelRect) -> CGRect { + CGRect( + x: Int(pixelRect.x), + y: Int(pixelRect.y), + width: Int(pixelRect.width), + height: Int(pixelRect.height) + ) + } +} + +public enum RsnapBgraFrameSampler { + public static func rgbSample( + width: Int, + height: Int, + bytesPerRow: Int, + baseAddress: UnsafeRawPointer, + byteCount: Int, + displayFrame: CGRect, + point: CGPoint + ) throws -> RGBSample? { + var outRGB = RsnapRgb() + let status = rsnap_bgra_frame_sample_rgb( + UInt32(max(width, 0)), + UInt32(max(height, 0)), + max(bytesPerRow, 0), + baseAddress.assumingMemoryBound(to: UInt8.self), + max(byteCount, 0), + encode(rect: displayFrame), + Double(point.x), + Double(point.y), + &outRGB + ) + let code = rsnap_status_code(status) + if code == RSNAP_STATUS_EMPTY.rawValue { + return nil + } + try requireOk(status, context: "sampling BGRA frame RGB") + + return RGBSample(r: outRGB.r, g: outRGB.g, b: outRGB.b) + } + + public static func loupePatch( + width: Int, + height: Int, + bytesPerRow: Int, + baseAddress: UnsafeRawPointer, + byteCount: Int, + displayFrame: CGRect, + point: CGPoint, + sidePixels: Int + ) throws -> RGBARegionSnapshot? { + var outRegion = RsnapOwnedRgbaRegion() + let status = rsnap_bgra_frame_loupe_patch_rgba( + UInt32(max(width, 0)), + UInt32(max(height, 0)), + max(bytesPerRow, 0), + baseAddress.assumingMemoryBound(to: UInt8.self), + max(byteCount, 0), + encode(rect: displayFrame), + Double(point.x), + Double(point.y), + UInt32(max(sidePixels, 0)), + &outRegion + ) + let code = rsnap_status_code(status) + if code == RSNAP_STATUS_EMPTY.rawValue { + return nil + } + try requireOk(status, context: "sampling BGRA frame loupe patch") + + return rgbaSnapshot(from: outRegion) + } + + private static func requireOk(_ status: RsnapStatus, context: String) throws { + let code = rsnap_status_code(status) + if code != 0 { + throw HostBridgeError.ffiStatus(context: context, code: code) + } + } + + private static func encode(rect: CGRect) -> RsnapFloatRect { + RsnapFloatRect( + x: Double(rect.origin.x), + y: Double(rect.origin.y), + width: Double(rect.width), + height: Double(rect.height) + ) + } + + private static func rgbaSnapshot(from outRegion: RsnapOwnedRgbaRegion) -> RGBARegionSnapshot? { + guard outRegion.len > 0, let rgba = outRegion.rgba else { + return nil + } + + let ownedRegion = UnsafeMutablePointer.allocate(capacity: 1) + ownedRegion.initialize(to: outRegion) + let data = Data( + bytesNoCopy: rgba, + count: outRegion.len, + deallocator: .custom { _, _ in + rsnap_owned_rgba_region_release(ownedRegion) + ownedRegion.deinitialize(count: 1) + ownedRegion.deallocate() + } + ) + return RGBARegionSnapshot( + width: Int(outRegion.width), + height: Int(outRegion.height), + rgba: data + ) + } +} + +public enum RsnapExportEncoder { + public static func pngData(from image: RGBARegionSnapshot) throws -> Data { + var outPNG = RsnapOwnedBytes() + let status = image.rgba.withUnsafeBytes { buffer -> RsnapStatus in + guard let baseAddress = buffer.bindMemory(to: UInt8.self).baseAddress else { + return RSNAP_STATUS_INVALID_INPUT + } + return rsnap_export_rgba_to_png( + UInt32(max(image.width, 0)), + UInt32(max(image.height, 0)), + baseAddress, + image.rgba.count, + &outPNG + ) + } + try requireOk(status, context: "encoding export PNG") + + return try data(from: outPNG, context: "taking encoded export PNG") + } + + public static func pngData(from image: RGBARegionSnapshot, crop: CGRect) throws -> Data { + let cropRect = try encode(crop: crop) + var outPNG = RsnapOwnedBytes() + let status = image.rgba.withUnsafeBytes { buffer -> RsnapStatus in + guard let baseAddress = buffer.bindMemory(to: UInt8.self).baseAddress else { + return RSNAP_STATUS_INVALID_INPUT + } + return rsnap_export_rgba_crop_to_png( + UInt32(max(image.width, 0)), + UInt32(max(image.height, 0)), + baseAddress, + image.rgba.count, + cropRect, + &outPNG + ) + } + try requireOk(status, context: "encoding cropped export PNG") + + return try data(from: outPNG, context: "taking encoded cropped export PNG") + } + + public static func frozenDisplayCropRect( + imageWidth: Int, + imageHeight: Int, + displayFrame: CGRect, + selection: CGRect + ) throws -> CGRect? { + var outRect = RsnapPixelRect() + let status = rsnap_frozen_display_crop_rect( + UInt32(max(imageWidth, 0)), + UInt32(max(imageHeight, 0)), + encode(rect: displayFrame), + encode(rect: selection), + &outRect + ) + let code = rsnap_status_code(status) + if code == RSNAP_STATUS_EMPTY.rawValue { + return nil + } + try requireOk(status, context: "resolving frozen display export crop") + + return decode(pixelRect: outRect) + } + + public static func frozenMosaicLightPrivacyPatch( + imageWidth: Int, + imageHeight: Int, + sourceRect: CGRect + ) throws -> RGBARegionSnapshot? { + var outRegion = RsnapOwnedRgbaRegion() + let status = rsnap_frozen_mosaic_light_privacy_patch_rgba( + UInt32(max(imageWidth, 0)), + UInt32(max(imageHeight, 0)), + encode(rect: sourceRect), + &outRegion + ) + let code = rsnap_status_code(status) + if code == RSNAP_STATUS_EMPTY.rawValue { + return nil + } + try requireOk(status, context: "rendering frozen mosaic privacy patch") + + return rgbaSnapshot(from: outRegion) + } + + public static func frozenOverlayExportImage( + from image: RGBARegionSnapshot, + selection: CGRect, + elements: [FrozenOverlayExportElement] + ) throws -> RGBARegionSnapshot { + let storage = FrozenOverlayExportFFIStorage(elements) + var outRegion = RsnapOwnedRgbaRegion() + let status = image.rgba.withUnsafeBytes { buffer -> RsnapStatus in + guard let baseAddress = buffer.bindMemory(to: UInt8.self).baseAddress else { + return RSNAP_STATUS_INVALID_INPUT + } + return storage.elements.withUnsafeBufferPointer { elementBuffer in + rsnap_frozen_overlay_export_render_rgba( + UInt32(max(image.width, 0)), + UInt32(max(image.height, 0)), + baseAddress, + image.rgba.count, + encode(rect: selection), + elementBuffer.baseAddress, + elementBuffer.count, + &outRegion + ) + } + } + try requireOk(status, context: "rendering frozen overlay export image") + guard let snapshot = rgbaSnapshot(from: outRegion) else { + throw HostBridgeError.ffiStatus( + context: "taking frozen overlay export image", + code: RSNAP_STATUS_EMPTY.rawValue) + } + + return snapshot + } + + private static func requireOk(_ status: RsnapStatus, context: String) throws { + let code = rsnap_status_code(status) + if code != 0 { + throw HostBridgeError.ffiStatus(context: context, code: code) + } + } + + private static func encode(crop: CGRect) throws -> RsnapPixelRect { + let x = crop.origin.x.rounded() + let y = crop.origin.y.rounded() + let width = crop.width.rounded() + let height = crop.height.rounded() + let maxValue = CGFloat(UInt32.max) + + guard + x >= 0, + y >= 0, + width >= 0, + height >= 0, + x <= maxValue, + y <= maxValue, + width <= maxValue, + height <= maxValue + else { + throw HostBridgeError.ffiStatus( + context: "encoding export crop rectangle", + code: RSNAP_STATUS_INVALID_INPUT.rawValue) + } + + return RsnapPixelRect( + x: UInt32(x), + y: UInt32(y), + width: UInt32(width), + height: UInt32(height) + ) + } + + private static func encode(rect: CGRect) -> RsnapFloatRect { + RsnapFloatRect( + x: Double(rect.origin.x), + y: Double(rect.origin.y), + width: Double(rect.width), + height: Double(rect.height) + ) + } + + private static func decode(pixelRect: RsnapPixelRect) -> CGRect { + CGRect( + x: Int(pixelRect.x), + y: Int(pixelRect.y), + width: Int(pixelRect.width), + height: Int(pixelRect.height) + ) + } + + private static func data(from outPNG: RsnapOwnedBytes, context: String) throws -> Data { + guard outPNG.len > 0, let bytes = outPNG.bytes else { + throw HostBridgeError.ffiStatus(context: context, code: RSNAP_STATUS_EMPTY.rawValue) + } + + let ownedBytes = UnsafeMutablePointer.allocate(capacity: 1) + ownedBytes.initialize(to: outPNG) + return Data( + bytesNoCopy: bytes, + count: outPNG.len, + deallocator: .custom { _, _ in + rsnap_owned_bytes_release(ownedBytes) + ownedBytes.deinitialize(count: 1) + ownedBytes.deallocate() + } + ) + } + + private static func rgbaSnapshot(from outRegion: RsnapOwnedRgbaRegion) -> RGBARegionSnapshot? { + guard outRegion.len > 0, let rgba = outRegion.rgba else { + return nil + } + + let ownedRegion = UnsafeMutablePointer.allocate(capacity: 1) + ownedRegion.initialize(to: outRegion) + let data = Data( + bytesNoCopy: rgba, + count: outRegion.len, + deallocator: .custom { _, _ in + rsnap_owned_rgba_region_release(ownedRegion) + ownedRegion.deinitialize(count: 1) + ownedRegion.deallocate() + } + ) + return RGBARegionSnapshot( + width: Int(outRegion.width), height: Int(outRegion.height), rgba: data) + } +} + public enum ScrollObserveOutcome: UInt32, Equatable, Sendable { case noChange = 0 case previewUpdated = 1 @@ -166,6 +1721,37 @@ public enum ToolbarItemKind: UInt32, Equatable, Sendable { return false } } + + fileprivate var ffiKind: RsnapToolbarItemKind { + switch self { + case .pointer: + RSNAP_TOOLBAR_ITEM_POINTER + case .pen: + RSNAP_TOOLBAR_ITEM_PEN + case .arrow: + RSNAP_TOOLBAR_ITEM_ARROW + case .text: + RSNAP_TOOLBAR_ITEM_TEXT + case .mosaic: + RSNAP_TOOLBAR_ITEM_MOSAIC + case .spotlight: + RSNAP_TOOLBAR_ITEM_SPOTLIGHT + case .undo: + RSNAP_TOOLBAR_ITEM_UNDO + case .redo: + RSNAP_TOOLBAR_ITEM_REDO + case .autoCenter: + RSNAP_TOOLBAR_ITEM_AUTO_CENTER + case .scroll: + RSNAP_TOOLBAR_ITEM_SCROLL + case .ocr: + RSNAP_TOOLBAR_ITEM_OCR + case .copy: + RSNAP_TOOLBAR_ITEM_COPY + case .save: + RSNAP_TOOLBAR_ITEM_SAVE + } + } } public struct ToolbarItem: Equatable, Sendable { diff --git a/native/macos-host/Sources/RsnapHostBridgeProbe/main.swift b/native/macos-host/Sources/RsnapHostBridgeProbe/main.swift index fa56804a..cc51f00e 100644 --- a/native/macos-host/Sources/RsnapHostBridgeProbe/main.swift +++ b/native/macos-host/Sources/RsnapHostBridgeProbe/main.swift @@ -6,6 +6,27 @@ import RsnapHostBridge enum RsnapHostBridgeProbe { static func main() throws { let session = try RsnapHostSession() + try verifyLiveDragFreezeLifecycle(session) + try verifyClickWindowFreezeLifecycle(session) + try verifyFullscreenFreezeLifecycle(session) + try verifyLiveWindowClearing(session) + try verifyScrollExportPlanning() + try verifyBgraFrameSampler() + try verifyCaptureFramePlanning() + try verifyWallpaperRendering() + try verifyMinimapAndTransformPlanning() + try verifyFrozenOverlayExport() + try verifyFrozenOverlayEditSession() + + print("rsnap-host-bridge probe ok") + } + + private static func verifyLiveDragFreezeLifecycle(_ session: RsnapHostSession) throws { + try verifyLiveDragSelection(session) + try verifyFrozenToolbarInteractions(session) + } + + private static func verifyLiveDragSelection(_ session: RsnapHostSession) throws { try session.enterLive() let liveRequests = try session.drainRequests() @@ -19,7 +40,7 @@ enum RsnapHostBridgeProbe { rgb: RGBSample(r: 1, g: 2, b: 3), activeMonitor: MonitorSnapshot( id: 9, - frame: CGRect(x: 0, y: 0, width: 1440, height: 900), + frame: CGRect(x: 0, y: 0, width: 1_440, height: 900), scaleFactorX1000: 2_000 ), highlightedWindow: WindowSnapshot( @@ -33,7 +54,7 @@ enum RsnapHostBridgeProbe { point: CGPoint(x: 120, y: 180), activeMonitor: MonitorSnapshot( id: 9, - frame: CGRect(x: 0, y: 0, width: 1440, height: 900), + frame: CGRect(x: 0, y: 0, width: 1_440, height: 900), scaleFactorX1000: 2_000 ), highlightedWindow: WindowSnapshot( @@ -47,7 +68,7 @@ enum RsnapHostBridgeProbe { point: CGPoint(x: 260, y: 320), activeMonitor: MonitorSnapshot( id: 9, - frame: CGRect(x: 0, y: 0, width: 1440, height: 900), + frame: CGRect(x: 0, y: 0, width: 1_440, height: 900), scaleFactorX1000: 2_000 ), highlightedWindow: WindowSnapshot( @@ -65,7 +86,7 @@ enum RsnapHostBridgeProbe { point: CGPoint(x: 260, y: 320), activeMonitor: MonitorSnapshot( id: 9, - frame: CGRect(x: 0, y: 0, width: 1440, height: 900), + frame: CGRect(x: 0, y: 0, width: 1_440, height: 900), scaleFactorX1000: 2_000 ), highlightedWindow: WindowSnapshot( @@ -104,12 +125,15 @@ enum RsnapHostBridgeProbe { scene.statusMessage == nil, scene.toolbarItems.contains(where: { $0.kind == .pointer && $0.selected }), scene.toolbarItems.contains(where: { $0.kind == .ocr && $0.enabled }), - !scene.toolbarItems.contains(where: { $0.kind == .scroll }), + scene.toolbarItems.contains(where: { $0.kind == .scroll }) == false, scene.toolbarItems.contains(where: { $0.kind == .copy && $0.enabled }), scene.toolbarItems.contains(where: { $0.kind == .save && $0.enabled }) else { fatalError("unexpected frozen scene: \(scene)") } + } + + private static func verifyFrozenToolbarInteractions(_ session: RsnapHostSession) throws { try session.send(event: .toolbarItemInvoked(.scroll)) guard try session.takeNextRequest() == nil else { fatalError("scroll toolbar invocation should stay disabled") @@ -123,7 +147,7 @@ enum RsnapHostBridgeProbe { highlightedWindow: nil ) ) - scene = try session.currentScene() + var scene = try session.currentScene() guard scene.cursorIntent == .resizeEast else { fatalError("unexpected frozen resize cursor: \(scene)") } @@ -141,7 +165,7 @@ enum RsnapHostBridgeProbe { guard scene.cursorIntent == .text, scene.toolbarItems.contains(where: { $0.kind == .text && $0.selected }), - !scene.toolbarItems.contains(where: { $0.kind == .pointer && $0.selected }) + scene.toolbarItems.contains(where: { $0.kind == .pointer && $0.selected }) == false else { fatalError("unexpected text-tool scene: \(scene)") } @@ -174,7 +198,9 @@ enum RsnapHostBridgeProbe { guard scene.statusMessage == "Host-only status" else { fatalError("unexpected host status message: \(String(describing: scene.statusMessage))") } + } + private static func verifyClickWindowFreezeLifecycle(_ session: RsnapHostSession) throws { try session.enterLive() _ = try session.takeNextRequest() let clickSelection = CGRect(x: 300, y: 220, width: 360, height: 260) @@ -184,7 +210,7 @@ enum RsnapHostBridgeProbe { point: CGPoint(x: 420, y: 340), activeMonitor: MonitorSnapshot( id: 9, - frame: CGRect(x: 0, y: 0, width: 1440, height: 900), + frame: CGRect(x: 0, y: 0, width: 1_440, height: 900), scaleFactorX1000: 2_000 ), highlightedWindow: clickWindow @@ -195,7 +221,7 @@ enum RsnapHostBridgeProbe { point: CGPoint(x: 420, y: 340), activeMonitor: MonitorSnapshot( id: 9, - frame: CGRect(x: 0, y: 0, width: 1440, height: 900), + frame: CGRect(x: 0, y: 0, width: 1_440, height: 900), scaleFactorX1000: 2_000 ), highlightedWindow: clickWindow @@ -218,16 +244,18 @@ enum RsnapHostBridgeProbe { highlightedWindow: nil ) ) - scene = try session.currentScene() + let scene = try session.currentScene() guard scene.mode == .frozen, scene.cursorIntent == .default else { fatalError("unexpected click-window frozen cursor: \(scene)") } + } + private static func verifyFullscreenFreezeLifecycle(_ session: RsnapHostSession) throws { try session.enterLive() _ = try session.takeNextRequest() let fullscreenMonitor = MonitorSnapshot( id: 10, - frame: CGRect(x: 0, y: 0, width: 1440, height: 900), + frame: CGRect(x: 0, y: 0, width: 1_440, height: 900), scaleFactorX1000: 2_000 ) try session.send( @@ -262,11 +290,13 @@ enum RsnapHostBridgeProbe { highlightedWindow: nil ) ) - scene = try session.currentScene() + let scene = try session.currentScene() guard scene.mode == .frozen, scene.cursorIntent == .default else { fatalError("unexpected fullscreen frozen cursor: \(scene)") } + } + private static func verifyLiveWindowClearing(_ session: RsnapHostSession) throws { try session.enterLive() _ = try session.takeNextRequest() try session.send( @@ -275,32 +305,32 @@ enum RsnapHostBridgeProbe { rgb: nil, activeMonitor: MonitorSnapshot( id: 11, - frame: CGRect(x: 1440, y: 0, width: 1728, height: 1117), + frame: CGRect(x: 1_440, y: 0, width: 1_728, height: 1_117), scaleFactorX1000: 2_000 ), highlightedWindow: WindowSnapshot( windowID: 77, - frame: CGRect(x: 1500, y: 100, width: 500, height: 400) + frame: CGRect(x: 1_500, y: 100, width: 500, height: 400) ) ) ) - scene = try session.currentScene() + var scene = try session.currentScene() guard scene.mode == .live, scene.activeMonitor?.id == 11, - scene.activeMonitor?.frame == CGRect(x: 1440, y: 0, width: 1728, height: 1117), + scene.activeMonitor?.frame == CGRect(x: 1_440, y: 0, width: 1_728, height: 1_117), scene.highlightedWindow?.windowID == 77, - scene.highlightedWindow?.frame == CGRect(x: 1500, y: 100, width: 500, height: 400) + scene.highlightedWindow?.frame == CGRect(x: 1_500, y: 100, width: 500, height: 400) else { fatalError("unexpected live monitor/window scene: \(scene)") } try session.send( event: .pointerMoved( - point: CGPoint(x: 2600, y: 800), + point: CGPoint(x: 2_600, y: 800), rgb: nil, activeMonitor: MonitorSnapshot( id: 11, - frame: CGRect(x: 1440, y: 0, width: 1728, height: 1117), + frame: CGRect(x: 1_440, y: 0, width: 1_728, height: 1_117), scaleFactorX1000: 2_000 ), highlightedWindow: nil @@ -310,7 +340,9 @@ enum RsnapHostBridgeProbe { guard scene.highlightedWindow == nil else { fatalError("stale live highlighted window was not cleared: \(scene)") } + } + private static func verifyScrollExportPlanning() throws { let baseScrollFrame = makeScrollFrame(width: 16, height: 96, topRow: 0) let movedScrollFrame = makeScrollFrame(width: 16, height: 96, topRow: 24) let scrollSession = try RsnapScrollCaptureSession( @@ -330,8 +362,391 @@ enum RsnapHostBridgeProbe { guard let scrollExport = try scrollSession.exportImage(), scrollExport.height == 120 else { fatalError("unexpected scroll export image") } + let png = try RsnapExportEncoder.pngData(from: scrollExport) + guard let fullPNGDimensions = pngDimensions(png), fullPNGDimensions == (16, 120) else { + fatalError("unexpected PNG export dimensions") + } + let croppedPNG = try RsnapExportEncoder.pngData( + from: scrollExport, + crop: CGRect(x: 1, y: 2, width: 4, height: 8) + ) + guard let croppedPNGDimensions = pngDimensions(croppedPNG), + croppedPNGDimensions == (4, 8) + else { + fatalError("unexpected cropped PNG export dimensions") + } + let frozenDisplayCrop = try RsnapExportEncoder.frozenDisplayCropRect( + imageWidth: 2_880, + imageHeight: 1_800, + displayFrame: CGRect(x: 0, y: 0, width: 1_440, height: 900), + selection: CGRect(x: 100, y: 200, width: 300, height: 150) + ) + guard frozenDisplayCrop == CGRect(x: 200, y: 1_100, width: 600, height: 300) else { + fatalError("unexpected frozen display crop rect") + } + let emptyFrozenDisplayCrop = try RsnapExportEncoder.frozenDisplayCropRect( + imageWidth: 200, + imageHeight: 200, + displayFrame: CGRect(x: 0, y: 0, width: 100, height: 100), + selection: CGRect(x: 120, y: 10, width: 10, height: 20) + ) + guard emptyFrozenDisplayCrop == nil else { + fatalError("unexpected out-of-bounds frozen display crop rect") + } + guard + let mosaicPatch = try RsnapExportEncoder.frozenMosaicLightPrivacyPatch( + imageWidth: 100, + imageHeight: 80, + sourceRect: CGRect(x: 4.2, y: 9.1, width: 28.4, height: 21.0) + ), + mosaicPatch.width == 3, + mosaicPatch.height == 3, + Array(mosaicPatch.rgba.prefix(12)) == [ + 211, 211, 211, 255, 205, 205, 205, 255, 202, 201, 199, 255, + ] + else { + fatalError("unexpected frozen mosaic privacy patch") + } + } - print("rsnap-host-bridge probe ok") + private static func verifyBgraFrameSampler() throws { + let bgraFrame = makeBgraFrame(width: 4, height: 3) + try bgraFrame.withUnsafeBytes { buffer in + guard let baseAddress = buffer.baseAddress else { + fatalError("missing BGRA fixture storage") + } + let rgb = try RsnapBgraFrameSampler.rgbSample( + width: 4, + height: 3, + bytesPerRow: 16, + baseAddress: baseAddress, + byteCount: bgraFrame.count, + displayFrame: CGRect(x: 0, y: 0, width: 4, height: 3), + point: CGPoint(x: 1, y: 2.5) + ) + guard rgb == RGBSample(r: 11, g: 21, b: 31) else { + fatalError("unexpected BGRA RGB sample") + } + guard + let patch = try RsnapBgraFrameSampler.loupePatch( + width: 4, + height: 3, + bytesPerRow: 16, + baseAddress: baseAddress, + byteCount: bgraFrame.count, + displayFrame: CGRect(x: 0, y: 0, width: 4, height: 3), + point: CGPoint(x: 0, y: 2), + sidePixels: 3 + ), + patch.width == 3, + patch.height == 3, + Array(patch.rgba.prefix(8)) == [10, 20, 30, 200, 10, 20, 30, 200] + else { + fatalError("unexpected BGRA loupe patch") + } + } + } + + private static func verifyCaptureFramePlanning() throws { + guard + let framePlan = try RsnapCaptureFramePlanner.plan( + imageWidth: 320, + imageHeight: 180, + screenScaleFactor: 2, + source: .window + ), + framePlan.canvasSize == CGSize(width: 416, height: 276), + framePlan.imageRect == CGRect(x: 48, y: 48, width: 320, height: 180), + framePlan.cornerRadius == 9.9, + framePlan.shadows.count == 3, + framePlan.shadows[0].blur == 80, + framePlan.shadows[1].offset.height == -22 + else { + fatalError("unexpected capture frame layout plan") + } + guard + try RsnapCaptureFramePlanner.aspectFillCropRect( + sourceWidth: 1_600, + sourceHeight: 900, + destinationSize: CGSize(width: 1_000, height: 1_000) + ) == CGRect(x: 350, y: 0, width: 900, height: 900) + else { + fatalError("unexpected capture frame aspect-fill crop rect") + } + let backgroundPlan = try RsnapCaptureFramePlanner.backgroundPlan(for: .systemWallpaper) + guard + backgroundPlan.prefersWallpaper, + backgroundPlan.wallpaperOverlayAlpha == 0.10, + backgroundPlan.locations == [0, 0.54, 1], + backgroundPlan.colorStops.count == 3, + backgroundPlan.colorStops[0] + == CaptureFrameColorStop(red: 0.10, green: 0.16, blue: 0.28, alpha: 1), + backgroundPlan.colorStops[2] + == CaptureFrameColorStop(red: 0.95, green: 0.61, blue: 0.43, alpha: 1) + else { + fatalError("unexpected capture frame background plan") + } + guard + try RsnapCaptureFramePlanner.wallpaperRequestPlan( + for: .systemWallpaper, + destinationSize: CGSize(width: 1_535.2, height: 996) + ) == CaptureFrameWallpaperRequest(targetPixelSize: 1_536, overlayAlpha: 0.10), + try RsnapCaptureFramePlanner.wallpaperRequestPlan( + for: .aurora, + destinationSize: CGSize(width: 1_536, height: 996) + ) == nil + else { + fatalError("unexpected capture frame wallpaper request") + } + } + + private static func verifyWallpaperRendering() throws { + let wallpaperFilename = + "rsnap-bridge-wallpaper-thumb-\(ProcessInfo.processInfo.processIdentifier).png" + let wallpaperPath = FileManager.default.temporaryDirectory + .appendingPathComponent(wallpaperFilename) + let wallpaperPNG = try RsnapExportEncoder.pngData( + from: RGBARegionSnapshot( + width: 4, + height: 2, + rgba: Data([ + 255, 0, 0, 255, 0, 255, 0, 255, + 0, 0, 255, 255, 255, 255, 255, 255, + 255, 255, 0, 255, 0, 255, 255, 255, + 255, 0, 255, 255, 20, 30, 40, 255, + ]) + ) + ) + try wallpaperPNG.write(to: wallpaperPath) + defer { + try? FileManager.default.removeItem(at: wallpaperPath) + } + guard + let wallpaperThumbnail = try RsnapWallpaperThumbnailDecoder.pngThumbnail( + path: wallpaperPath.path, + targetPixelSize: 64 + ), + wallpaperThumbnail.width <= 64, + wallpaperThumbnail.height <= 64, + wallpaperThumbnail.rgba.count + == wallpaperThumbnail.width * wallpaperThumbnail.height * 4, + try RsnapWallpaperThumbnailDecoder.pngThumbnail( + path: "/tmp/rsnap-missing-wallpaper.png", + targetPixelSize: 64 + ) == nil + else { + fatalError("unexpected PNG wallpaper thumbnail decode") + } + guard + let renderedFrame = try RsnapCaptureFrameRenderer.render( + source: RGBARegionSnapshot( + width: 4, height: 2, rgba: Data(repeating: 255, count: 32)), + background: .aurora, + screenScaleFactor: 2, + sourceKind: .dragRegion, + renderKind: .windowSnapshot, + wallpaperPath: nil + ), + renderedFrame.width == 100, + renderedFrame.height == 98, + renderedFrame.rgba.count == 100 * 98 * 4, + Array(renderedFrame.rgba[((48 * 100 + 48) * 4)..<((48 * 100 + 49) * 4)]) + == [255, 255, 255, 255] + else { + fatalError("unexpected capture frame render") + } + } + + private static func verifyMinimapAndTransformPlanning() throws { + guard + let minimapPlan = try RsnapScrollMinimapPlanner.plan( + selection: CGRect(x: 100, y: 100, width: 100, height: 100), + exportSize: CGSize(width: 100, height: 200), + bounds: CGRect(x: 0, y: 0, width: 500, height: 500), + preferredWidth: 96, + minimumWidth: 44, + gap: 10, + margin: 10, + imageInset: 3, + viewportTopPixels: 20, + viewportHeightPixels: 100 + ), + minimapPlan.frame == CGRect(x: 210, y: 54, width: 96, height: 192), + minimapPlan.imageFrame == CGRect(x: 213, y: 57, width: 90, height: 186), + minimapPlan.viewportFrame == CGRect(x: 213, y: 131.4, width: 90, height: 93) + else { + fatalError("unexpected scroll minimap layout plan") + } + guard + try RsnapFrozenSelectionTransformPlanner.hitTest( + point: CGPoint(x: 102, y: 238), + selection: CGRect(x: 100, y: 80, width: 240, height: 160), + handleRadius: 12, + edgeTolerance: 4 + ) == .resizeTopLeft, + try RsnapFrozenSelectionTransformPlanner.transformedRect( + kind: .resizeBottomRight, + initialSelection: CGRect(x: 100, y: 80, width: 240, height: 160), + monitorFrame: CGRect(x: 0, y: 0, width: 500, height: 400), + initialPointer: CGPoint(x: 340, y: 80), + point: CGPoint(x: 50, y: 300), + minimumSize: 12 + ) == CGRect(x: 100, y: 228, width: 12, height: 12) + else { + fatalError("unexpected frozen selection transform plan") + } + let autoCenterFrame = makeAutoCenterFrame( + width: 100, + height: 80, + content: CGRect(x: 30, y: 20, width: 24, height: 18) + ) + guard + try RsnapAutoCenterPlanner.contentBounds(in: autoCenterFrame) + == CGRect(x: 30, y: 20, width: 24, height: 18), + RsnapAutoCenterPlanner.marginBalanceShiftPoints( + contentOriginPixels: 30, + contentSizePixels: 24, + cropSizePixels: 100, + captureSizePoints: 50 + ) == -4 + else { + fatalError("unexpected auto-center plan") + } + } + + private static func verifyFrozenOverlayExport() throws { + let overlayBase = makeAutoCenterFrame( + width: 64, + height: 40, + content: CGRect(x: 0, y: 0, width: 64, height: 40) + ) + let overlayExport = try RsnapExportEncoder.frozenOverlayExportImage( + from: overlayBase, + selection: CGRect(x: 0, y: 0, width: 64, height: 40), + elements: [ + .mosaic(rect: CGRect(x: 4, y: 4, width: 16, height: 10)), + .spotlight( + rect: CGRect(x: 24, y: 4, width: 16, height: 10), + style: FrozenOverlayExportSpotlightStyle( + borderWidthPoints: 1, + borderColor: .white + ) + ), + .pen( + points: [CGPoint(x: 2, y: 2), CGPoint(x: 24, y: 18)], + style: FrozenOverlayExportStrokeStyle(strokeWidthPoints: 2, color: .blue) + ), + .arrow( + start: CGPoint(x: 10, y: 30), + end: CGPoint(x: 48, y: 22), + style: FrozenOverlayExportStrokeStyle(strokeWidthPoints: 3, color: .red) + ), + .text( + anchor: CGPoint(x: 6, y: 24), + text: "Hi", + style: FrozenOverlayExportTextStyle(fontSizePoints: 12, color: .white) + ), + ] + ) + guard + overlayExport.width == overlayBase.width, + overlayExport.height == overlayBase.height, + overlayExport.rgba.count == overlayBase.rgba.count, + overlayExport.rgba != overlayBase.rgba + else { + fatalError("unexpected frozen overlay export render") + } + } + + private static func verifyFrozenOverlayEditSession() throws { + let editSession = try RsnapFrozenOverlayEditSession() + let selection = CGRect(x: 10, y: 20, width: 220, height: 120) + let style = FrozenOverlayEditStyle( + strokeWidthPoints: 3, + strokeColor: .blue, + spotlightBorderWidthPoints: 1.5, + spotlightColor: .white, + textFontSizePoints: 16, + textColor: .white + ) + + guard + try editSession.begin( + tool: .text, + at: CGPoint(x: 40, y: 50), + selection: selection, + style: style + ), + try editSession.appendText("Hello"), + try editSession.commitText(style: style) + else { + fatalError("unexpected frozen edit text lifecycle result") + } + var snapshot = try editSession.snapshot() + guard + snapshot.canUndo, + snapshot.canRedo == false, + snapshot.keepsFrozenSelectionFixed, + snapshot.activeTextEdit == nil, + snapshot.elements.count == 1, + try editSession.containsMovableAnnotation(at: CGPoint(x: 42, y: 52)) + else { + fatalError("unexpected committed frozen edit snapshot: \(snapshot)") + } + guard + try editSession.begin( + tool: .pointer, + at: CGPoint(x: 42, y: 52), + selection: selection, + style: style + ), + try editSession.update(to: CGPoint(x: 70, y: 82), selection: selection) + else { + fatalError("unexpected frozen edit move lifecycle result") + } + snapshot = try editSession.snapshot() + guard + snapshot.isMovingMovableAnnotation, + snapshot.elements.isEmpty, + snapshot.previewText != nil + else { + fatalError("unexpected active frozen edit move snapshot: \(snapshot)") + } + guard try editSession.finish(selection: selection) else { + fatalError("frozen edit move did not finish") + } + snapshot = try editSession.snapshot() + guard + snapshot.elements.count == 1, + try editSession.undo(), + try editSession.snapshot().canRedo, + try editSession.redo(), + try editSession.snapshot().elements.count == 1 + else { + fatalError("unexpected frozen edit undo/redo state") + } + } + + private static func pngDimensions(_ data: Data) -> (Int, Int)? { + let bytes = [UInt8](data) + guard + bytes.count >= 24, + bytes[0..<8].elementsEqual([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]) + else { + return nil + } + let width = + Int(UInt32(bytes[16]) << 24) + | Int(UInt32(bytes[17]) << 16) + | Int(UInt32(bytes[18]) << 8) + | Int(UInt32(bytes[19])) + let height = + Int(UInt32(bytes[20]) << 24) + | Int(UInt32(bytes[21]) << 16) + | Int(UInt32(bytes[22]) << 8) + | Int(UInt32(bytes[23])) + + return (width, height) } private static func makeScrollFrame( @@ -352,4 +767,41 @@ enum RsnapHostBridgeProbe { } return RGBARegionSnapshot(width: width, height: height, rgba: rgba) } + + private static func makeAutoCenterFrame( + width: Int, + height: Int, + content: CGRect + ) -> RGBARegionSnapshot { + var rgba = Data(repeating: 180, count: width * height * 4) + for index in stride(from: 3, to: rgba.count, by: 4) { + rgba[index] = 255 + } + let xRange = Int(content.minX).. Data { + var bgra = Data(repeating: 0xEE, count: width * height * 4) + for y in 0.. 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, + renderWithRust( + image: image, + background: background, screen: screen, source: source, - context: context + renderKind: .framedCapture ) - return context.makeImage() } package static func renderWindowSnapshot( @@ -56,279 +32,176 @@ package enum CaptureFrameEffectRenderer { 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) - drawFloatingWindowSnapshot(image, imageSize: imageSize, context: context) - return context.makeImage() + renderWithRust( + image: image, + background: background, + screen: screen, + source: .window, + renderKind: .windowSnapshot + ) } 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) - ) + captureFramePlan(for: imageSize, screen: nil, source: .unknown)?.canvasSize ?? .zero } 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 - ) + captureFramePlan(for: imageSize, screen: nil, source: .unknown)?.imageRect ?? .zero } - 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, - 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( - _ background: CaptureFrameBackgroundPreference, + private static func renderWithRust( + image: CGImage, + 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 + source: CaptureFrameSource, + renderKind: CaptureFrameRenderKind + ) -> CGImage? { + guard let sourceSnapshot = rgbaSnapshot(from: image) else { + return nil } - - let colors = gradientColors(for: background) guard - let gradient = CGGradient( - colorsSpace: CGColorSpace(name: CGColorSpace.sRGB), - colors: colors as CFArray, - locations: [0, 0.54, 1] + let rendered = try? RsnapCaptureFrameRenderer.render( + source: sourceSnapshot, + background: background.planKind, + screenScaleFactor: screen?.backingScaleFactor ?? 2, + sourceKind: source.planKind, + renderKind: renderKind, + wallpaperPath: systemWallpaperPath(for: background, screen: screen) ) else { - context.setFillColor(colors.first ?? NSColor.windowBackgroundColor.cgColor) - context.fill(rect) - return + return nil } - 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, - ] - } + return cgImage(from: rendered) } - private static func drawFramedCapture( - _ image: CGImage, - imageSize: CGSize, - in canvasRect: CGRect, - screen: NSScreen?, - source: CaptureFrameSource, - context: CGContext - ) { - let imageRect = imageRect(for: imageSize) - let cornerRadius = cornerRadius(for: imageSize, screen: screen, source: source) - let capturePath = CGPath( - roundedRect: imageRect, - cornerWidth: cornerRadius, - cornerHeight: cornerRadius, - transform: nil - ) + private static func rgbaSnapshot(from image: CGImage) -> RGBARegionSnapshot? { + let width = image.width + let height = image.height + guard + width > 0, + height > 0, + let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) + else { + return nil + } - drawShadow( - path: capturePath, - offset: .zero, - 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 - ) + let bytesPerRow = width * 4 + var rgba = Data(count: bytesPerRow * height) + let didDraw = rgba.withUnsafeMutableBytes { buffer -> Bool in + guard + let baseAddress = buffer.baseAddress, + let context = CGContext( + data: baseAddress, + width: width, + height: height, + bitsPerComponent: 8, + bytesPerRow: bytesPerRow, + space: colorSpace, + bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue + ) + else { + return false + } + + context.interpolationQuality = .high + context.draw(image, in: CGRect(x: 0, y: 0, width: width, height: height)) + return true + } + guard didDraw else { + return nil + } - context.saveGState() - context.addPath(capturePath) - context.clip() - context.interpolationQuality = .high - context.draw(image, in: imageRect) - context.restoreGState() + return RGBARegionSnapshot(width: width, height: height, rgba: rgba) } - private static func drawFloatingWindowSnapshot( - _ image: CGImage, - imageSize: CGSize, - context: CGContext - ) { - let imageRect = imageRect(for: imageSize) - context.saveGState() - context.interpolationQuality = .high - context.draw(image, in: imageRect) - context.restoreGState() - } + private static func cgImage(from snapshot: RGBARegionSnapshot) -> CGImage? { + 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 + } - private static func drawShadow( - path: CGPath, - offset: CGSize, - blur: CGFloat, - alpha: CGFloat, - context: CGContext - ) { - context.saveGState() - context.addPath(path) - context.setShadow( - offset: offset, - blur: blur, - color: NSColor.black.withAlphaComponent(alpha).cgColor + return 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 ) - context.setFillColor(NSColor.black.cgColor) - context.fillPath() - 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 { + private static func systemWallpaperPath( + for background: CaptureFrameBackgroundPreference, + screen: NSScreen? + ) -> String? { + guard background == .systemWallpaper else { return nil } guard - let source = CGImageSourceCreateWithURL( - url as CFURL, - [kCGImageSourceShouldCache: false] as CFDictionary - ) + let screen = screen ?? NSScreen.main, + let url = NSWorkspace.shared.desktopImageURL(for: screen) 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) + + return url.standardizedFileURL.path + } + + private static func captureFramePlan( + for imageSize: CGSize, + screen: NSScreen?, + source: CaptureFrameSource + ) -> CaptureFrameLayoutPlan? { + try? RsnapCaptureFramePlanner.plan( + imageWidth: Int(max(imageSize.width.rounded(), 0)), + imageHeight: Int(max(imageSize.height.rounded(), 0)), + screenScaleFactor: screen?.backingScaleFactor ?? 2, + source: source.planKind + ) } +} - 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) +extension CaptureFrameSource { + fileprivate var planKind: CaptureFrameSourceKind { + switch self { + case .dragRegion: + return .dragRegion + case .window: + return .window + case .fullScreen: + return .fullScreen + case .scrollCapture: + return .scrollCapture + case .unknown: + return .unknown + } } +} - 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 - ) +extension CaptureFrameBackgroundPreference { + fileprivate var planKind: CaptureFrameBackgroundKind { + switch self { + case .systemWallpaper: + return .systemWallpaper + case .aurora: + return .aurora + case .graphite: + return .graphite + case .linen: + return .linen } - let height = sourceSize.width / max(destinationAspect, .leastNonzeroMagnitude) - return CGRect( - x: 0, - y: (sourceSize.height - height) / 2, - width: sourceSize.width, - height: height - ) } } diff --git a/native/macos-host/Sources/RsnapNativeHostKit/FrozenFrameAuthority.swift b/native/macos-host/Sources/RsnapNativeHostKit/FrozenFrameAuthority.swift index 7d4b744b..6979855c 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/FrozenFrameAuthority.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/FrozenFrameAuthority.swift @@ -151,7 +151,7 @@ final class FrozenFrameAuthority: @unchecked Sendable { guard let content else { return nil } - guard !content.displays.isEmpty else { + guard content.displays.isEmpty == false else { return nil } guard let displayIDs else { @@ -256,7 +256,7 @@ final class FrozenFrameAuthority: @unchecked Sendable { ) { let setupStartedAt = ProcessInfo.processInfo.systemUptime let targets = screens.compactMap(Self.displayTarget(for:)) - guard !targets.isEmpty else { + guard targets.isEmpty == false else { stop() return } @@ -300,7 +300,9 @@ final class FrozenFrameAuthority: @unchecked Sendable { ) return } - if unchanged, streamsCoverTargets || setupInProgressForTargets, !rebuildContentFilter { + if unchanged, streamsCoverTargets || setupInProgressForTargets, + rebuildContentFilter == false + { updateTelemetryContextLocked( captureID: captureID, source: source, @@ -315,7 +317,7 @@ final class FrozenFrameAuthority: @unchecked Sendable { let requestGeneration = generation activeDisplayIDs = targetIDs setupDisplayIDs = targetIDs - if !rebuildContentFilter { + if rebuildContentFilter == false { selfCaptureFilterRequired = false selfCaptureUnsafeAfterUptime = nil } @@ -356,191 +358,254 @@ final class FrozenFrameAuthority: @unchecked Sendable { retryUntilUptime: TimeInterval, requestID: UInt64 ) { - if let content = Self.cachedShareableContent(covering: targetIDs) { - let preparedFilters = Self.contentFilters( - for: targets, - in: content, + if configureStreamsFromCachedShareableContent( + targets: targets, + targetIDs: targetIDs, + selfCaptureExceptionWindowIDs: selfCaptureExceptionWindowIDs, + includedCurrentProcessWindowIDs: includedCurrentProcessWindowIDs, + captureID: captureID, + source: source, + startedAtUptime: startedAtUptime, + requestID: requestID + ) { + return + } + + SCShareableContent.getExcludingDesktopWindows(false, onScreenWindowsOnly: false) { + [weak self] content, error in + self?.handleShareableContentLookup( + content, + error: error, + targets: targets, + targetIDs: targetIDs, selfCaptureExceptionWindowIDs: selfCaptureExceptionWindowIDs, - includedCurrentProcessWindowIDs: includedCurrentProcessWindowIDs + includedCurrentProcessWindowIDs: includedCurrentProcessWindowIDs, + captureID: captureID, + source: source, + startedAtUptime: startedAtUptime, + retryUntilUptime: retryUntilUptime, + requestID: requestID ) - if Self.filtersAreComplete(preparedFilters, for: targets) { - NativeHostTelemetry.frozenAuthorityContentLookupTiming( - captureID: captureID, - source: source, - totalMilliseconds: NativeHostTelemetry.milliseconds(since: startedAtUptime), - success: true, - displayCount: content.displays.count, - windowCount: content.windows.count - ) - stateLock.lock() - guard setupRequestID == requestID, activeDisplayIDs == targetIDs else { - stateLock.unlock() - return - } - generation &+= 1 - let requestGeneration = generation - setupDisplayIDs = targetIDs - latestFrames = latestFrames.filter { targetIDs.contains($0.key) } - updateTelemetryContextLocked( - captureID: captureID, - source: source, - startedAtUptime: startedAtUptime, - targetIDs: targetIDs - ) - let staleStreams = streams.values - streams.removeAll() - stateLock.unlock() - - for staleStream in staleStreams { - staleStream.stop() - } + } + } - configureStreams( - targets: targets, - preparedFilters: preparedFilters, - generation: requestGeneration, - captureID: captureID, - source: source - ) - return - } + private func configureStreamsFromCachedShareableContent( + targets: [DisplayTarget], + targetIDs: Set, + selfCaptureExceptionWindowIDs: Set, + includedCurrentProcessWindowIDs: Set, + captureID: UInt64, + source: String, + startedAtUptime: TimeInterval, + requestID: UInt64 + ) -> Bool { + guard let content = Self.cachedShareableContent(covering: targetIDs) else { + return false } - SCShareableContent.getExcludingDesktopWindows(false, onScreenWindowsOnly: false) { - [weak self] content, error in - guard let self else { - return - } - guard let content else { - NativeHostTelemetry.frozenAuthorityWarning( - "frozen_authority.content_lookup_failed", - captureID: captureID, - source: source, - displayID: 0, - error: String(describing: error) - ) - NativeHostTelemetry.frozenAuthorityContentLookupTiming( - captureID: captureID, - source: source, - totalMilliseconds: NativeHostTelemetry.milliseconds(since: startedAtUptime), - success: false, - displayCount: targets.count, - windowCount: 0 - ) - self.finishSetup(targetIDs: targetIDs) - return - } + let preparedFilters = Self.contentFilters( + for: targets, + in: content, + selfCaptureExceptionWindowIDs: selfCaptureExceptionWindowIDs, + includedCurrentProcessWindowIDs: includedCurrentProcessWindowIDs + ) + guard Self.filtersAreComplete(preparedFilters, for: targets) else { + return false + } + NativeHostTelemetry.frozenAuthorityContentLookupTiming( + captureID: captureID, + source: source, + totalMilliseconds: NativeHostTelemetry.milliseconds(since: startedAtUptime), + success: true, + displayCount: content.displays.count, + windowCount: content.windows.count + ) + replaceStreamsFromPreparedFilters( + targets: targets, + targetIDs: targetIDs, + preparedFilters: preparedFilters, + captureID: captureID, + source: source, + startedAtUptime: startedAtUptime, + requestID: requestID + ) + return true + } - let contentCoversTargets = Self.shareableContent(content, covers: targetIDs) + private func handleShareableContentLookup( + _ content: SCShareableContent?, + error: Error?, + targets: [DisplayTarget], + targetIDs: Set, + selfCaptureExceptionWindowIDs: Set, + includedCurrentProcessWindowIDs: Set, + captureID: UInt64, + source: String, + startedAtUptime: TimeInterval, + retryUntilUptime: TimeInterval, + requestID: UInt64 + ) { + guard let content else { + NativeHostTelemetry.frozenAuthorityWarning( + "frozen_authority.content_lookup_failed", + captureID: captureID, + source: source, + displayID: 0, + error: String(describing: error) + ) NativeHostTelemetry.frozenAuthorityContentLookupTiming( captureID: captureID, source: source, totalMilliseconds: NativeHostTelemetry.milliseconds(since: startedAtUptime), - success: contentCoversTargets, - displayCount: content.displays.count, - windowCount: content.windows.count + success: false, + displayCount: targets.count, + windowCount: 0 ) - guard self.isCurrentSetupRequest(requestID, targetIDs: targetIDs) else { - return - } - guard contentCoversTargets else { - NativeHostTelemetry.frozenAuthorityWarning( - "frozen_authority.content_lookup_invalid", + finishSetup(targetIDs: targetIDs) + return + } + + let contentCoversTargets = Self.shareableContent(content, covers: targetIDs) + NativeHostTelemetry.frozenAuthorityContentLookupTiming( + captureID: captureID, + source: source, + totalMilliseconds: NativeHostTelemetry.milliseconds(since: startedAtUptime), + success: contentCoversTargets, + displayCount: content.displays.count, + windowCount: content.windows.count + ) + guard isCurrentSetupRequest(requestID, targetIDs: targetIDs) else { + return + } + guard contentCoversTargets else { + NativeHostTelemetry.frozenAuthorityWarning( + "frozen_authority.content_lookup_invalid", + captureID: captureID, + source: source, + displayID: 0, + error: Self.shareableContentDisplayDetail(content, requiredDisplayIDs: targetIDs) + ) + if ProcessInfo.processInfo.systemUptime < retryUntilUptime { + retryRebuildStreamsFromShareableContent( + targets: targets, + targetIDs: targetIDs, + selfCaptureExceptionWindowIDs: selfCaptureExceptionWindowIDs, + includedCurrentProcessWindowIDs: includedCurrentProcessWindowIDs, captureID: captureID, source: source, - displayID: 0, - error: Self.shareableContentDisplayDetail( - content, - requiredDisplayIDs: targetIDs - ) + startedAtUptime: startedAtUptime, + retryUntilUptime: retryUntilUptime, + requestID: requestID ) - if ProcessInfo.processInfo.systemUptime < retryUntilUptime { - DispatchQueue.global(qos: .userInteractive).asyncAfter( - deadline: .now() + Self.selfCaptureFilterRetryInterval - ) { [weak self] in - self?.rebuildStreamsFromShareableContent( - targets: targets, - targetIDs: targetIDs, - selfCaptureExceptionWindowIDs: selfCaptureExceptionWindowIDs, - includedCurrentProcessWindowIDs: includedCurrentProcessWindowIDs, - captureID: captureID, - source: source, - startedAtUptime: startedAtUptime, - retryUntilUptime: retryUntilUptime, - requestID: requestID - ) - } - return - } - self.finishSetup(targetIDs: targetIDs) return } - Self.shareableContentCache.store(content) - let preparedFilters = Self.contentFilters( - for: targets, - in: content, - selfCaptureExceptionWindowIDs: selfCaptureExceptionWindowIDs, - includedCurrentProcessWindowIDs: includedCurrentProcessWindowIDs - ) - guard Self.filtersAreComplete(preparedFilters, for: targets) else { - if ProcessInfo.processInfo.systemUptime < retryUntilUptime { - DispatchQueue.global(qos: .userInteractive).asyncAfter( - deadline: .now() + Self.selfCaptureFilterRetryInterval - ) { [weak self] in - self?.rebuildStreamsFromShareableContent( - targets: targets, - targetIDs: targetIDs, - selfCaptureExceptionWindowIDs: selfCaptureExceptionWindowIDs, - includedCurrentProcessWindowIDs: includedCurrentProcessWindowIDs, - captureID: captureID, - source: source, - startedAtUptime: startedAtUptime, - retryUntilUptime: retryUntilUptime, - requestID: requestID - ) - } - return - } - self.logIncompleteFilters( - preparedFilters, + finishSetup(targetIDs: targetIDs) + return + } + Self.shareableContentCache.store(content) + let preparedFilters = Self.contentFilters( + for: targets, + in: content, + selfCaptureExceptionWindowIDs: selfCaptureExceptionWindowIDs, + includedCurrentProcessWindowIDs: includedCurrentProcessWindowIDs + ) + guard Self.filtersAreComplete(preparedFilters, for: targets) else { + if ProcessInfo.processInfo.systemUptime < retryUntilUptime { + retryRebuildStreamsFromShareableContent( targets: targets, + targetIDs: targetIDs, + selfCaptureExceptionWindowIDs: selfCaptureExceptionWindowIDs, + includedCurrentProcessWindowIDs: includedCurrentProcessWindowIDs, captureID: captureID, - source: source + source: source, + startedAtUptime: startedAtUptime, + retryUntilUptime: retryUntilUptime, + requestID: requestID ) - self.finishSetup(targetIDs: targetIDs) return } + logIncompleteFilters( + preparedFilters, targets: targets, captureID: captureID, source: source) + finishSetup(targetIDs: targetIDs) + return + } + replaceStreamsFromPreparedFilters( + targets: targets, + targetIDs: targetIDs, + preparedFilters: preparedFilters, + captureID: captureID, + source: source, + startedAtUptime: startedAtUptime, + requestID: requestID + ) + } - self.stateLock.lock() - guard self.setupRequestID == requestID, self.activeDisplayIDs == targetIDs else { - self.stateLock.unlock() - return - } - self.generation &+= 1 - let requestGeneration = self.generation - self.setupDisplayIDs = targetIDs - self.latestFrames = self.latestFrames.filter { targetIDs.contains($0.key) } - self.updateTelemetryContextLocked( + private func retryRebuildStreamsFromShareableContent( + targets: [DisplayTarget], + targetIDs: Set, + selfCaptureExceptionWindowIDs: Set, + includedCurrentProcessWindowIDs: Set, + captureID: UInt64, + source: String, + startedAtUptime: TimeInterval, + retryUntilUptime: TimeInterval, + requestID: UInt64 + ) { + DispatchQueue.global(qos: .userInteractive).asyncAfter( + deadline: .now() + Self.selfCaptureFilterRetryInterval + ) { [weak self] in + self?.rebuildStreamsFromShareableContent( + targets: targets, + targetIDs: targetIDs, + selfCaptureExceptionWindowIDs: selfCaptureExceptionWindowIDs, + includedCurrentProcessWindowIDs: includedCurrentProcessWindowIDs, captureID: captureID, source: source, startedAtUptime: startedAtUptime, - targetIDs: targetIDs + retryUntilUptime: retryUntilUptime, + requestID: requestID ) - let staleStreams = self.streams.values - self.streams.removeAll() - self.stateLock.unlock() + } + } - for staleStream in staleStreams { - staleStream.stop() - } + private func replaceStreamsFromPreparedFilters( + targets: [DisplayTarget], + targetIDs: Set, + preparedFilters: [CGDirectDisplayID: PreparedContentFilter], + captureID: UInt64, + source: String, + startedAtUptime: TimeInterval, + requestID: UInt64 + ) { + stateLock.lock() + guard setupRequestID == requestID, activeDisplayIDs == targetIDs else { + stateLock.unlock() + return + } + generation &+= 1 + let requestGeneration = generation + setupDisplayIDs = targetIDs + latestFrames = latestFrames.filter { targetIDs.contains($0.key) } + updateTelemetryContextLocked( + captureID: captureID, + source: source, + startedAtUptime: startedAtUptime, + targetIDs: targetIDs + ) + let staleStreams = streams.values + streams.removeAll() + stateLock.unlock() - self.configureStreams( - targets: targets, - preparedFilters: preparedFilters, - generation: requestGeneration, - captureID: captureID, - source: source - ) + for staleStream in staleStreams { + staleStream.stop() } + + configureStreams( + targets: targets, + preparedFilters: preparedFilters, + generation: requestGeneration, + captureID: captureID, + source: source + ) } private func configureStreamsFromShareableContent( @@ -721,7 +786,7 @@ final class FrozenFrameAuthority: @unchecked Sendable { else { return true } - return !eligibleRecord.selfCaptureFilterComplete + return eligibleRecord.selfCaptureFilterComplete == false } func hasSelfCaptureCompleteFrame(containing point: CGPoint) -> Bool { @@ -902,7 +967,7 @@ final class FrozenFrameAuthority: @unchecked Sendable { } private func snapshotEligibleRecordLocked(_ record: FrameRecord) -> FrameRecord? { - if !isSelfCaptureSafeLocked(record) { + if isSelfCaptureSafeLocked(record) == false { return nil } return record @@ -1024,7 +1089,7 @@ final class FrozenFrameAuthority: @unchecked Sendable { ) continue } - guard !preparedFilter.selfCaptureFilterComplete else { + guard preparedFilter.selfCaptureFilterComplete == false else { continue } NativeHostTelemetry.frozenAuthorityWarning( @@ -1082,7 +1147,7 @@ final class FrozenFrameAuthority: @unchecked Sendable { } let currentPID = getpid() let excludedApplications = content.applications.filter { $0.processID == currentPID } - if !excludedApplications.isEmpty { + if excludedApplications.isEmpty == false { let includedWindows = content.windows.filter { includedCurrentProcessWindowIDs.contains($0.windowID) } @@ -1137,7 +1202,7 @@ final class FrozenFrameAuthority: @unchecked Sendable { if generation == requestGeneration, activeDisplayIDs.contains(frame.displayID), isSelfCaptureSafeLocked(frame) { - if !firstFrameLoggedDisplayIDs.contains(frame.displayID) { + if firstFrameLoggedDisplayIDs.contains(frame.displayID) == false { firstFrameLoggedDisplayIDs.insert(frame.displayID) let startedAt = firstFrameStartUptimes[frame.displayID] ?? telemetryContext.startedAtUptime @@ -1344,19 +1409,12 @@ final class FrozenFrameAuthority: @unchecked Sendable { point: CGPoint, displayFrame: CGRect ) -> RGBSample? { - guard displayFrame.width > 0, displayFrame.height > 0, displayFrame.contains(point) else { - return nil - } let width = CVPixelBufferGetWidth(pixelBuffer) let height = CVPixelBufferGetHeight(pixelBuffer) let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer) guard width > 0, height > 0, bytesPerRow >= width * 4 else { return nil } - let xRatio = (point.x - displayFrame.minX) / displayFrame.width - let yRatio = (displayFrame.maxY - point.y) / displayFrame.height - let x = min(max(Int((xRatio * CGFloat(width)).rounded(.down)), 0), width - 1) - let y = min(max(Int((yRatio * CGFloat(height)).rounded(.down)), 0), height - 1) guard CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly) == kCVReturnSuccess else { return nil } @@ -1366,9 +1424,15 @@ final class FrozenFrameAuthority: @unchecked Sendable { guard let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) else { return nil } - let bytes = baseAddress.assumingMemoryBound(to: UInt8.self) - let offset = y * bytesPerRow + x * 4 - return RGBSample(r: bytes[offset + 2], g: bytes[offset + 1], b: bytes[offset]) + return try? RsnapBgraFrameSampler.rgbSample( + width: width, + height: height, + bytesPerRow: bytesPerRow, + baseAddress: baseAddress, + byteCount: bytesPerRow * height, + displayFrame: displayFrame, + point: point + ) } private static func loupePatch( @@ -1377,9 +1441,6 @@ final class FrozenFrameAuthority: @unchecked Sendable { displayFrame: CGRect, sidePixels: Int ) -> CGImage? { - guard displayFrame.width > 0, displayFrame.height > 0, displayFrame.contains(point) else { - return nil - } let width = CVPixelBufferGetWidth(pixelBuffer) let height = CVPixelBufferGetHeight(pixelBuffer) let bytesPerRow = CVPixelBufferGetBytesPerRow(pixelBuffer) @@ -1387,10 +1448,6 @@ final class FrozenFrameAuthority: @unchecked Sendable { guard width > 0, height > 0, bytesPerRow >= width * 4 else { return nil } - let xRatio = (point.x - displayFrame.minX) / displayFrame.width - let yRatio = (displayFrame.maxY - point.y) / displayFrame.height - let centerX = min(max(Int((xRatio * CGFloat(width)).rounded(.down)), 0), width - 1) - let centerY = min(max(Int((yRatio * CGFloat(height)).rounded(.down)), 0), height - 1) guard CVPixelBufferLockBaseAddress(pixelBuffer, .readOnly) == kCVReturnSuccess else { return nil } @@ -1400,39 +1457,35 @@ final class FrozenFrameAuthority: @unchecked Sendable { guard let baseAddress = CVPixelBufferGetBaseAddress(pixelBuffer) else { return nil } - let sourceBytes = baseAddress.assumingMemoryBound(to: UInt8.self) - let outputBytesPerPixel = 4 - let outputBytesPerRow = side * outputBytesPerPixel - let half = side / 2 - var rgba = [UInt8](repeating: 0, count: outputBytesPerRow * side) - for outputY in 0.. CGImage? { + private static func rgbaImage(width: Int, height: Int, rgba: Data) -> CGImage? { guard width > 0, height > 0 else { return nil } let bytesPerRow = width * 4 let expectedByteCount = bytesPerRow * height - guard rgba.count >= expectedByteCount else { + guard rgba.count == expectedByteCount else { return nil } - let data = Data(rgba.prefix(expectedByteCount)) let bitmapInfo = CGBitmapInfo(rawValue: CGImageAlphaInfo.last.rawValue) guard - let provider = CGDataProvider(data: data as CFData), + let provider = CGDataProvider(data: rgba as CFData), let image = CGImage( width: width, height: height, diff --git a/native/macos-host/Sources/RsnapNativeHostKit/GlobalHotKeyCenter.swift b/native/macos-host/Sources/RsnapNativeHostKit/GlobalHotKeyCenter.swift index 5163dd8e..466abf92 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/GlobalHotKeyCenter.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/GlobalHotKeyCenter.swift @@ -184,7 +184,7 @@ final class GlobalHotKeyCenter { private static func parseCaptureHotKey(_ raw: String) -> HotKeyDefinition? { let tokens = NativeHostSettings.captureHotKeyTokens(from: raw) - guard !tokens.isEmpty else { + guard tokens.isEmpty == false else { return nil } diff --git a/native/macos-host/Sources/RsnapNativeHostKit/LiveChromeWindows.swift b/native/macos-host/Sources/RsnapNativeHostKit/LiveChromeWindows.swift index c593c77b..298f68ec 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/LiveChromeWindows.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/LiveChromeWindows.swift @@ -114,7 +114,7 @@ enum PhosphorToolbarIcons { } private static func ensureRegistered() { - guard !didAttemptRegisterFonts else { + guard didAttemptRegisterFonts == false else { return } didAttemptRegisterFonts = true @@ -433,7 +433,7 @@ private final class LiveChromeBackdropWindow: NSWindow { } renderView.update(theme: theme, settings: settings) - if !isPresented { + if isPresented == false { orderFrontRegardless() isPresented = true } diff --git a/native/macos-host/Sources/RsnapNativeHostKit/LiveFrameStream.swift b/native/macos-host/Sources/RsnapNativeHostKit/LiveFrameStream.swift index c429c987..c98b3550 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/LiveFrameStream.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/LiveFrameStream.swift @@ -273,7 +273,7 @@ final class LiveFrameStreamBroker { let captureID: UInt64 let totalMilliseconds: Double stateLock.lock() - guard !didEmitFirstRgbSample, activeTelemetryCaptureID != 0 else { + guard didEmitFirstRgbSample == false, activeTelemetryCaptureID != 0 else { stateLock.unlock() return } @@ -371,7 +371,7 @@ final class LiveFrameStreamBroker { id: displayID, appKitFrame: appKitFrame, quartzFrame: appKitRectToQuartz(appKitFrame, mainDisplayHeight: mainDisplayHeight), - scaleFactorX1000: UInt32(max((screen.backingScaleFactor * 1000).rounded(), 1000)) + scaleFactorX1000: UInt32(max((screen.backingScaleFactor * 1_000).rounded(), 1_000)) ) } diff --git a/native/macos-host/Sources/RsnapNativeHostKit/LiveOverlayRenderer.swift b/native/macos-host/Sources/RsnapNativeHostKit/LiveOverlayRenderer.swift index 022f9464..4922ac5b 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/LiveOverlayRenderer.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/LiveOverlayRenderer.swift @@ -92,7 +92,7 @@ final class WindowSnapshotFeed { for info in candidateWindows { let isOnScreen = (info[kCGWindowIsOnscreen as String] as? NSNumber)?.boolValue ?? false let ownerPID = (info[kCGWindowOwnerPID as String] as? NSNumber)?.int32Value ?? -1 - if !isOnScreen { + if isOnScreen == false { continue } let alpha = (info[kCGWindowAlpha as String] as? NSNumber)?.doubleValue ?? 1 @@ -401,7 +401,7 @@ final class ChromeSampleFeed: @unchecked Sendable { private func enqueueRefresh() { stateLock.lock() - guard !refreshQueued else { + guard refreshQueued == false else { stateLock.unlock() return } @@ -676,7 +676,7 @@ final class ChromeSampleFeed: @unchecked Sendable { shouldNotifyImmediately = false sampleSidePixels = sidePixels sampleIncludesLoupePatch = includeLoupePatch - } else if !whiteStreamRunHasProbed, + } else if whiteStreamRunHasProbed == false, now - lastBackgroundProbeUptime >= Self.backgroundProbeMinimumInterval { guard pointIdleDuration >= Self.backgroundProbeIdleDelay else { @@ -908,7 +908,7 @@ final class LiveFrameClockDriver: @unchecked Sendable { stateLock.lock() let alreadyRunning = timer != nil && currentTargetFramesPerSecond == sanitizedTarget stateLock.unlock() - guard !alreadyRunning else { + guard alreadyRunning == false else { return } @@ -1033,7 +1033,7 @@ private final class SelectionFlowBandLayer: CALayer { } func hide() { - guard !isHidden || !focusRect.isNull else { + guard isHidden == false || focusRect.isNull == false else { return } isHidden = true @@ -1116,7 +1116,7 @@ private final class SelectionFlowBandLayer: CALayer { private func installFlowAnimation(restartsAnimation: Bool) { let hasAnimations = linePass.gradientLayer.animation(forKey: Self.flowAnimationKey) != nil - if !restartsAnimation, hasAnimations { + if restartsAnimation == false, hasAnimations { return } removeFlowAnimation() @@ -1327,7 +1327,7 @@ private final class LiveScrimLayer: CAShapeLayer { bounds: CGRect, roundedExclusions: [OverlayMaskGeometry.RoundedExclusion] ) { - guard !roundedExclusions.isEmpty else { + guard roundedExclusions.isEmpty == false else { mask = nil return } @@ -1415,7 +1415,7 @@ final class LiveOverlayRenderer { static let scrim: CGFloat = 10 static let selectionChrome: CGFloat = 30 static let selectionSize: CGFloat = 40 - static let hudChrome: CGFloat = 1000 + static let hudChrome: CGFloat = 1_000 } private var snapshotProvider: (() -> LivePreviewSnapshot?)? @@ -1702,15 +1702,7 @@ final class LiveOverlayRenderer { private func renderFocus(_ snapshot: LivePreviewSnapshot) { let focusRect = snapshot.dragSelectionLocal ?? snapshot.hoverSelectionLocal guard let focusRect else { - scrimLayer.isHidden = true - for scrimLayer in [topScrimLayer, leftScrimLayer, rightScrimLayer, bottomScrimLayer] { - scrimLayer.isHidden = true - } - hoverGlowLayer.isHidden = true - hoverFlowLayer.hide() - dragBorderOutlineLayer.isHidden = true - dragBorderLayer.isHidden = true - selectionSizeLayer.isHidden = true + hideFocusLayers() return } @@ -1718,9 +1710,7 @@ final class LiveOverlayRenderer { let scrimColor = NSColor(calibratedWhite: 0, alpha: scrimAlpha).cgColor let bounds = snapshot.bounds let chromeExclusions = liveChromeRoundedExclusions(for: snapshot) - for legacyScrimLayer in [topScrimLayer, leftScrimLayer, rightScrimLayer, bottomScrimLayer] { - legacyScrimLayer.isHidden = true - } + hideLegacyScrimLayers() updateScrimLayer( bounds: bounds, focusRect: focusRect, @@ -1729,107 +1719,146 @@ final class LiveOverlayRenderer { ) if snapshot.frozenPending { - hoverGlowLayer.isHidden = true - hoverFlowLayer.hide() - dragBorderOutlineLayer.isHidden = false - dragBorderLayer.isHidden = false - selectionSizeLayer.isHidden = true - let pixelsPerPoint = hostView?.window?.screen?.backingScaleFactor ?? 1 - let borderOutset = CaptureChrome.dashedBorderOutset( - strokeWidth: CaptureChrome.frozenDashedBorderWidth, - pixelsPerPoint: pixelsPerPoint - ) - let borderRect = focusRect.insetBy(dx: -borderOutset, dy: -borderOutset) - let layerFrame = dashedBorderLayerFrame( - for: borderRect, - lineWidth: CaptureChrome.frozenDashedBorderWidth + 0.75 - ) - let localBorderRect = borderRect.offsetBy( - dx: -layerFrame.minX, - dy: -layerFrame.minY - ) - let frozenPath = CaptureChrome.dashedBorderPath(for: localBorderRect) - for layer in [dragBorderOutlineLayer, dragBorderLayer] { - layer.frame = layerFrame - layer.masksToBounds = true - } - dragBorderOutlineLayer.path = frozenPath - dragBorderOutlineLayer.strokeColor = - NSColor(calibratedRed: 229 / 255, green: 247 / 255, blue: 1, alpha: 116 / 255) - .cgColor - dragBorderOutlineLayer.lineWidth = CaptureChrome.frozenDashedBorderWidth + 0.75 - dragBorderOutlineLayer.lineCap = .butt - dragBorderOutlineLayer.lineJoin = .miter - dragBorderLayer.path = frozenPath - dragBorderLayer.strokeColor = - NSColor(calibratedRed: 167 / 255, green: 223 / 255, blue: 1, alpha: 248 / 255) - .cgColor - dragBorderLayer.lineWidth = CaptureChrome.frozenDashedBorderWidth - dragBorderLayer.lineCap = .butt - dragBorderLayer.lineJoin = .miter + renderFrozenPendingFocus(focusRect) return } if let dragSelection = snapshot.dragSelectionLocal { - hoverGlowLayer.isHidden = true - hoverFlowLayer.hide() - dragBorderOutlineLayer.isHidden = false - dragBorderLayer.isHidden = false - let pixelsPerPoint = hostView?.window?.screen?.backingScaleFactor ?? 1 - let borderOutset = CaptureChrome.dashedBorderOutset( - strokeWidth: CaptureChrome.liveDashedBorderWidth, - pixelsPerPoint: pixelsPerPoint - ) - let borderRect = dragSelection.insetBy(dx: -borderOutset, dy: -borderOutset) - let layerFrame = dashedBorderLayerFrame( - for: borderRect, - lineWidth: CaptureChrome.liveDashedBorderWidth + 0.75 - ) - let localBorderRect = borderRect.offsetBy( - dx: -layerFrame.minX, - dy: -layerFrame.minY - ) - let dragPath = CaptureChrome.dashedBorderPath(for: localBorderRect) - for layer in [dragBorderOutlineLayer, dragBorderLayer] { - layer.frame = layerFrame - layer.masksToBounds = true - } - dragBorderOutlineLayer.path = dragPath - dragBorderOutlineLayer.strokeColor = - NSColor(calibratedRed: 229 / 255, green: 247 / 255, blue: 1, alpha: 116 / 255) - .cgColor - dragBorderOutlineLayer.lineWidth = CaptureChrome.liveDashedBorderWidth + 0.75 - dragBorderOutlineLayer.lineCap = .butt - dragBorderOutlineLayer.lineJoin = .miter - dragBorderLayer.path = dragPath - dragBorderLayer.strokeColor = - NSColor(calibratedRed: 167 / 255, green: 223 / 255, blue: 1, alpha: 0.96).cgColor - dragBorderLayer.lineWidth = CaptureChrome.liveDashedBorderWidth - dragBorderLayer.lineCap = .butt - dragBorderLayer.lineJoin = .miter - if let selectionSizeText = snapshot.selectionSizeText { - let font = LiveOverlayTypography.font - let textSize = selectionSizeText.size(using: font) - let frame = CaptureChrome.selectionSizeBadgeFrame( - for: dragSelection, - textSize: textSize, - in: bounds - ) - applyText( - selectionSizeLayer, - text: selectionSizeText, - font: font, - color: NSColor.white.withAlphaComponent(0.98), - frame: frame, - alignment: .left - ) - selectionSizeLayer.isHidden = false - } else { - selectionSizeLayer.isHidden = true - } + renderDragSelectionFocus(dragSelection, snapshot: snapshot, bounds: bounds) return } + renderHoverFocus(focusRect, snapshot: snapshot, chromeExclusions: chromeExclusions) + } + + private func hideFocusLayers() { + scrimLayer.isHidden = true + hideLegacyScrimLayers() + hoverGlowLayer.isHidden = true + hoverFlowLayer.hide() + dragBorderOutlineLayer.isHidden = true + dragBorderLayer.isHidden = true + selectionSizeLayer.isHidden = true + } + + private func hideLegacyScrimLayers() { + for scrimLayer in [topScrimLayer, leftScrimLayer, rightScrimLayer, bottomScrimLayer] { + scrimLayer.isHidden = true + } + } + + private func renderFrozenPendingFocus(_ focusRect: CGRect) { + hoverGlowLayer.isHidden = true + hoverFlowLayer.hide() + dragBorderOutlineLayer.isHidden = false + dragBorderLayer.isHidden = false + selectionSizeLayer.isHidden = true + let pixelsPerPoint = hostView?.window?.screen?.backingScaleFactor ?? 1 + let borderOutset = CaptureChrome.dashedBorderOutset( + strokeWidth: CaptureChrome.frozenDashedBorderWidth, + pixelsPerPoint: pixelsPerPoint + ) + let borderRect = focusRect.insetBy(dx: -borderOutset, dy: -borderOutset) + let layerFrame = dashedBorderLayerFrame( + for: borderRect, + lineWidth: CaptureChrome.frozenDashedBorderWidth + 0.75 + ) + let localBorderRect = borderRect.offsetBy(dx: -layerFrame.minX, dy: -layerFrame.minY) + let frozenPath = CaptureChrome.dashedBorderPath(for: localBorderRect) + for layer in [dragBorderOutlineLayer, dragBorderLayer] { + layer.frame = layerFrame + layer.masksToBounds = true + } + dragBorderOutlineLayer.path = frozenPath + dragBorderOutlineLayer.strokeColor = + NSColor(calibratedRed: 229 / 255, green: 247 / 255, blue: 1, alpha: 116 / 255) + .cgColor + dragBorderOutlineLayer.lineWidth = CaptureChrome.frozenDashedBorderWidth + 0.75 + dragBorderOutlineLayer.lineCap = .butt + dragBorderOutlineLayer.lineJoin = .miter + dragBorderLayer.path = frozenPath + dragBorderLayer.strokeColor = + NSColor(calibratedRed: 167 / 255, green: 223 / 255, blue: 1, alpha: 248 / 255) + .cgColor + dragBorderLayer.lineWidth = CaptureChrome.frozenDashedBorderWidth + dragBorderLayer.lineCap = .butt + dragBorderLayer.lineJoin = .miter + } + + private func renderDragSelectionFocus( + _ dragSelection: CGRect, + snapshot: LivePreviewSnapshot, + bounds: CGRect + ) { + hoverGlowLayer.isHidden = true + hoverFlowLayer.hide() + dragBorderOutlineLayer.isHidden = false + dragBorderLayer.isHidden = false + let pixelsPerPoint = hostView?.window?.screen?.backingScaleFactor ?? 1 + let borderOutset = CaptureChrome.dashedBorderOutset( + strokeWidth: CaptureChrome.liveDashedBorderWidth, + pixelsPerPoint: pixelsPerPoint + ) + let borderRect = dragSelection.insetBy(dx: -borderOutset, dy: -borderOutset) + let layerFrame = dashedBorderLayerFrame( + for: borderRect, + lineWidth: CaptureChrome.liveDashedBorderWidth + 0.75 + ) + let localBorderRect = borderRect.offsetBy(dx: -layerFrame.minX, dy: -layerFrame.minY) + let dragPath = CaptureChrome.dashedBorderPath(for: localBorderRect) + for layer in [dragBorderOutlineLayer, dragBorderLayer] { + layer.frame = layerFrame + layer.masksToBounds = true + } + dragBorderOutlineLayer.path = dragPath + dragBorderOutlineLayer.strokeColor = + NSColor(calibratedRed: 229 / 255, green: 247 / 255, blue: 1, alpha: 116 / 255) + .cgColor + dragBorderOutlineLayer.lineWidth = CaptureChrome.liveDashedBorderWidth + 0.75 + dragBorderOutlineLayer.lineCap = .butt + dragBorderOutlineLayer.lineJoin = .miter + dragBorderLayer.path = dragPath + dragBorderLayer.strokeColor = + NSColor(calibratedRed: 167 / 255, green: 223 / 255, blue: 1, alpha: 0.96).cgColor + dragBorderLayer.lineWidth = CaptureChrome.liveDashedBorderWidth + dragBorderLayer.lineCap = .butt + dragBorderLayer.lineJoin = .miter + renderSelectionSizeBadge( + snapshot.selectionSizeText, selection: dragSelection, bounds: bounds) + } + + private func renderSelectionSizeBadge( + _ selectionSizeText: String?, + selection: CGRect, + bounds: CGRect + ) { + guard let selectionSizeText else { + selectionSizeLayer.isHidden = true + return + } + let font = LiveOverlayTypography.font + let textSize = selectionSizeText.size(using: font) + let frame = CaptureChrome.selectionSizeBadgeFrame( + for: selection, + textSize: textSize, + in: bounds + ) + applyText( + selectionSizeLayer, + text: selectionSizeText, + font: font, + color: NSColor.white.withAlphaComponent(0.98), + frame: frame, + alignment: .left + ) + selectionSizeLayer.isHidden = false + } + + private func renderHoverFocus( + _ focusRect: CGRect, + snapshot: LivePreviewSnapshot, + chromeExclusions: [OverlayMaskGeometry.RoundedExclusion] + ) { dragBorderOutlineLayer.isHidden = true dragBorderLayer.isHidden = true selectionSizeLayer.isHidden = true @@ -1887,8 +1916,8 @@ final class LiveOverlayRenderer { ) -> [OverlayMaskGeometry.RoundedExclusion] { roundedExclusions.compactMap { exclusion in let visibleRect = exclusion.rect.intersection(bounds) - guard !visibleRect.isNull, visibleRect.width > 0, visibleRect.height > 0, - !focusRect.contains(visibleRect) + guard visibleRect.isNull == false, visibleRect.width > 0, visibleRect.height > 0, + focusRect.contains(visibleRect) == false else { return nil } @@ -1918,7 +1947,7 @@ final class LiveOverlayRenderer { private func updateLiveScrimExclusions( excluding exclusions: [OverlayMaskGeometry.RoundedExclusion] ) { - guard !scrimLayer.isHidden, let focusRect = lastRenderedFocusRect else { + guard scrimLayer.isHidden == false, let focusRect = lastRenderedFocusRect else { return } updateScrimLayer( @@ -1932,7 +1961,7 @@ final class LiveOverlayRenderer { private func updateLiveFlowExclusions( excluding exclusions: [OverlayMaskGeometry.RoundedExclusion] ) { - guard !hoverFlowLayer.isHidden else { + guard hoverFlowLayer.isHidden == false else { return } hoverFlowLayer.updateRoundedExclusions(exclusions) @@ -2222,7 +2251,7 @@ final class LiveOverlayRenderer { hudHexLayer.isHidden = true hudHexRollLayer.isHidden = false hudHexRollLayer.frame = frame - guard !hudHexPendingRollActive else { + guard hudHexPendingRollActive == false else { return } @@ -2485,7 +2514,7 @@ final class LiveOverlayRenderer { ) -> HudHexPendingRollColumnState { var digits = Self.pendingHexRollSequence(index: index) let scrollsUp = Self.pendingHexRollColumnScrollsUp(index: index) - if !scrollsUp { + if scrollsUp == false { digits.reverse() } let contentText = digits.map(String.init).joined(separator: "\n") @@ -2529,7 +2558,7 @@ final class LiveOverlayRenderer { private func currentPendingHudHexDigits(lineHeight: CGFloat) -> [Character?] { hudHexPendingRollColumns.map { column in - guard !column.digits.isEmpty else { + guard column.digits.isEmpty == false else { return nil } let presentationLayer = column.contentLayer.presentation() ?? column.contentLayer diff --git a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift index 401a73ec..9dcbd5a1 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostApp.swift @@ -118,7 +118,7 @@ private struct NativeHostFeedbackSound { } sound.stop() sound.currentTime = 0 - if !sound.play() { + if sound.play() == false { NativeHostTelemetry.lifecycleWarning(playFailedEvent) } } @@ -158,184 +158,66 @@ private enum OcrCompletionSound { } } -private let frozenMosaicBlockSizePixels: CGFloat = 10.0 - -package func frozenExportOverlayPoint( - _ point: CGPoint, - selection: CGRect, - imageSize: CGSize -) -> CGPoint { - let scaleX = imageSize.width / max(selection.width, 1) - let scaleY = imageSize.height / max(selection.height, 1) - return CGPoint( - x: (point.x - selection.minX) * scaleX, - y: (point.y - selection.minY) * scaleY - ) -} - -package func frozenExportOverlayRect( - _ rect: CGRect, - selection: CGRect, - imageSize: CGSize -) -> CGRect { - let scaleX = imageSize.width / max(selection.width, 1) - let scaleY = imageSize.height / max(selection.height, 1) - return CGRect( - x: (rect.minX - selection.minX) * scaleX, - y: (rect.minY - selection.minY) * scaleY, - width: rect.width * scaleX, - height: rect.height * scaleY - ) -} - -package func frozenExportSourceImageRect( - _ rect: CGRect, - selection: CGRect, - imageSize: CGSize -) -> CGRect { - let scaleX = imageSize.width / max(selection.width, 1) - let scaleY = imageSize.height / max(selection.height, 1) - return CGRect( - x: (rect.minX - selection.minX) * scaleX, - y: (selection.maxY - rect.maxY) * scaleY, - width: rect.width * scaleX, - height: rect.height * scaleY - ) -} - -package func scrollCaptureMinimapFrame( +package func scrollCaptureMinimapPlan( for selection: CGRect, exportSize: CGSize, in bounds: CGRect, preferredWidth: CGFloat, minimumWidth: CGFloat, gap: CGFloat, - margin: CGFloat -) -> CGRect? { - guard exportSize.width > 0, exportSize.height > 0, bounds.width > margin * 2, - bounds.height > margin * 2 - else { - return nil - } - - let rightSpace = bounds.maxX - selection.maxX - gap - margin - let leftSpace = selection.minX - bounds.minX - gap - margin - let useRight: Bool - let sideSpace: CGFloat - if rightSpace >= minimumWidth { - useRight = true - sideSpace = rightSpace - } else if leftSpace >= minimumWidth { - useRight = false - sideSpace = leftSpace - } else { - useRight = rightSpace >= leftSpace - sideSpace = max(rightSpace, leftSpace) - } - - let maxHeight = bounds.height - margin * 2 - let aspectHeightPerWidth = exportSize.height / exportSize.width - let heightLimitedWidth = maxHeight / max(aspectHeightPerWidth, .leastNonzeroMagnitude) - let width = min(preferredWidth, sideSpace, heightLimitedWidth) - guard width >= min(minimumWidth, preferredWidth) * 0.55 else { - return nil - } - - let height = width * aspectHeightPerWidth - let maxY = max(margin, bounds.maxY - margin - height) - let y = (selection.midY - height / 2).clamped(to: margin...maxY) - let x = useRight ? selection.maxX + gap : selection.minX - gap - width - return CGRect(x: x, y: y, width: width, height: height) + margin: CGFloat, + imageInset: CGFloat, + viewportTopPixels: CGFloat, + viewportHeightPixels: CGFloat +) -> ScrollMinimapLayoutPlan? { + try? RsnapScrollMinimapPlanner.plan( + selection: selection, + exportSize: exportSize, + bounds: bounds, + preferredWidth: preferredWidth, + minimumWidth: minimumWidth, + gap: gap, + margin: margin, + imageInset: imageInset, + viewportTopPixels: viewportTopPixels, + viewportHeightPixels: viewportHeightPixels + ) } private func makeFrozenMosaicPatch(from image: CGImage, sourceRect: CGRect) -> CGImage? { - let imageRect = CGRect(x: 0, y: 0, width: image.width, height: image.height) - let cropRect = sourceRect.integral.intersection(imageRect) guard - !cropRect.isNull, - cropRect.width >= 1, - cropRect.height >= 1 + let patch = try? RsnapExportEncoder.frozenMosaicLightPrivacyPatch( + imageWidth: image.width, + imageHeight: image.height, + sourceRect: sourceRect + ) else { return nil } - let pixelWidth = max(1, Int(ceil(cropRect.width / frozenMosaicBlockSizePixels))) - let pixelHeight = max(1, Int(ceil(cropRect.height / frozenMosaicBlockSizePixels))) - let bytesPerRow = pixelWidth * 4 - let seedX = Int(floor(cropRect.minX / frozenMosaicBlockSizePixels)) - let seedY = Int(floor(cropRect.minY / frozenMosaicBlockSizePixels)) + let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB() + let bitmapInfo = + CGBitmapInfo.byteOrder32Big.rawValue | CGImageAlphaInfo.premultipliedLast.rawValue guard - let colorSpace = CGColorSpace(name: CGColorSpace.sRGB), - let context = CGContext( - data: nil, - width: pixelWidth, - height: pixelHeight, + let provider = CGDataProvider(data: patch.rgba as CFData), + let patchImage = CGImage( + width: patch.width, + height: patch.height, bitsPerComponent: 8, - bytesPerRow: bytesPerRow, + bitsPerPixel: 32, + bytesPerRow: patch.width * 4, space: colorSpace, - bitmapInfo: CGBitmapInfo.byteOrder32Big - .union(CGBitmapInfo(rawValue: CGImageAlphaInfo.premultipliedLast.rawValue)) - .rawValue + bitmapInfo: CGBitmapInfo(rawValue: bitmapInfo), + provider: provider, + decode: nil, + shouldInterpolate: false, + intent: .defaultIntent ) else { return nil } - if let rawData = context.data { - let pixels = rawData.assumingMemoryBound(to: UInt8.self) - for y in 0.. (red: UInt8, green: UInt8, blue: UInt8) { - let hash = frozenMosaicHash(x: x, y: y, width: width, height: height) - let groupHash = frozenMosaicHash(x: x / 2, y: y / 2, width: width, height: height) - let base: CGFloat = 0.74 + CGFloat(Int(groupHash & 3)) * 0.035 - let variation = (CGFloat(Int((hash >> 8) & 3)) - 1.5) * 0.012 - let warmth = CGFloat(Int((groupHash >> 3) & 1)) * 0.012 - return ( - frozenMosaicByte(base + variation + warmth), - frozenMosaicByte(base + variation + warmth * 0.5), - frozenMosaicByte(base + variation) - ) -} - -private func frozenMosaicHash(x: Int, y: Int, width: Int, height: Int) -> UInt32 { - var hash = - UInt32(truncatingIfNeeded: x) &* 0x45d9_f3b - ^ UInt32(truncatingIfNeeded: y) &* 0x119d_e1f3 - ^ UInt32(truncatingIfNeeded: width) &* 0x27d4_eb2d - ^ UInt32(truncatingIfNeeded: height) &* 0x1656_67b1 - hash ^= hash >> 16 - hash &*= 0x7feb_352d - hash ^= hash >> 15 - hash &*= 0x846c_a68b - hash ^= hash >> 16 - return hash -} - -private func frozenMosaicByte(_ value: CGFloat) -> UInt8 { - UInt8((min(max(value, 0), 1) * 255).rounded()) + return patchImage } @MainActor @@ -369,7 +251,7 @@ public final class NativeHostApplicationController: NSObject, NSApplicationDeleg }) public func finishLaunching() { - guard !didBootstrap else { + guard didBootstrap == false else { return } didBootstrap = true @@ -515,12 +397,12 @@ public final class NativeHostApplicationController: NSObject, NSApplicationDeleg source: String, oncePerLaunch: Bool = false ) -> Bool { - guard !NativePermissions.screenRecordingGranted else { + guard NativePermissions.screenRecordingGranted == false else { permissionRecoveryWindowController.close() return false } if oncePerLaunch { - guard !didPresentLaunchPermissionOnboarding else { + guard didPresentLaunchPermissionOnboarding == false else { return true } didPresentLaunchPermissionOnboarding = true @@ -906,123 +788,10 @@ final class CaptureSessionController: NSObject { return } do { - let startPoint = NSEvent.mouseLocation - let desktopFrame = CaptureOverlayController.desktopFrame - frozenFrameLatchToken = nil - // The Rust live sampler treats these IDs as current-process windows to - // include through the app-level exclusion. Overlay windows must stay out - // of this list so color sampling sees the desktop under the capture UI. - pendingLiveFrameStreamRelease?.cancel() - pendingLiveFrameStreamRelease = nil - liveFrameStream.updateSelfCaptureExceptionWindowIDs(capturableOwnWindowIDs) - let warmStartedAt = ProcessInfo.processInfo.systemUptime - let initialSample = warmLiveSamplingIfPossible( - at: startPoint, - source: "start_capture", + try startCaptureSession( captureID: captureID, - includedCurrentProcessWindowIDs: capturableOwnWindowIDs - ) - let initialRgbSample = - initialSample?.rgbSample - ?? frozenFrameAuthority.rgbSample(containing: startPoint) - let warmMilliseconds = NativeHostTelemetry.milliseconds(since: warmStartedAt) - liveFrameStream.start( - for: NSScreen.screens, - prewarmPoint: startPoint, - captureID: captureID - ) - let windowSnapshotStartedAt = ProcessInfo.processInfo.systemUptime - let initialWindowSnapshots = WindowSnapshotFeed.snapshots(desktopFrame: desktopFrame) - let windowSnapshotMilliseconds = - NativeHostTelemetry.milliseconds(since: windowSnapshotStartedAt) - let initialHighlightedWindow = WindowSnapshotFeed.window( - at: startPoint, in: initialWindowSnapshots) - chromeState.rgbSample = initialRgbSample - let sessionSetupStartedAt = ProcessInfo.processInfo.systemUptime - let session = try RsnapHostSession(configuration: settingsStore.sessionConfiguration) - self.session = session - - try session.enterLive() - try session.send( - event: .pointerMoved( - point: startPoint, - rgb: initialRgbSample, - activeMonitor: activeMonitor(at: startPoint), - highlightedWindow: initialHighlightedWindow - ) - ) - let initialScene = try session.currentScene() - self.scene = initialScene - let sessionSetupMilliseconds = - NativeHostTelemetry.milliseconds(since: sessionSetupStartedAt) - - let overlayController = CaptureOverlayController( - controller: self, - liveFrameStream: liveFrameStream, - frameRgbSampler: { [frozenFrameAuthority] point in - frozenFrameAuthority.liveRgbSample(containing: point) - }, - framePatchSampler: { [frozenFrameAuthority] point, sidePixels in - frozenFrameAuthority.loupePatch(containing: point, sidePixels: sidePixels) - } - ) - self.overlayController = overlayController - let overlayShowStartedAt = ProcessInfo.processInfo.systemUptime - overlayController.show( - initialScene: initialScene, - chrome: chromeState, - settings: settingsStore.settings, - focusPoint: startPoint, - initialWindowSnapshots: initialWindowSnapshots, - prepareCaptureStreams: { [weak self, weak overlayController] in - guard let self, let overlayController else { - return - } - let selfCaptureExceptionWindowIDs = - overlayController.selfCaptureExceptionWindowIDs - self.liveFrameStream.start( - for: NSScreen.screens, - prewarmPoint: startPoint, - captureID: captureID - ) - if self.frozenFrameAuthority.hasSelfCaptureCompleteFrame( - containing: startPoint) - { - NativeHostTelemetry.captureEvent( - "capture.self_capture_rebuild_skipped", - captureID: captureID, - detail: "start_capture_complete_filter" - ) - } else { - _ = self.warmLiveSamplingIfPossible( - at: startPoint, - source: "capture_overlay_preflight", - captureID: captureID, - excludeSelfFromFrozenAuthority: true, - selfCaptureExceptionWindowIDs: selfCaptureExceptionWindowIDs, - includedCurrentProcessWindowIDs: capturableOwnWindowIDs - ) - } - } - ) - overlayController.prepareCaptureStreamsNow(trigger: "overlay_show") - let overlayShowMilliseconds = - NativeHostTelemetry.milliseconds(since: overlayShowStartedAt) - (NSApp.delegate as? NativeHostApplicationController)?.window = - overlayController.primaryWindow - sceneDidChange?(initialScene) - - captureStateDidChange?() - NativeHostTelemetry.captureStartTiming( - captureID: captureID, - totalMilliseconds: NativeHostTelemetry.milliseconds(since: captureStartedAt), - warmMilliseconds: warmMilliseconds, - windowSnapshotMilliseconds: windowSnapshotMilliseconds, - sessionSetupMilliseconds: sessionSetupMilliseconds, - overlayShowMilliseconds: overlayShowMilliseconds, - initialSampleReady: initialRgbSample != nil, - screenCount: NSScreen.screens.count, - windowCount: initialWindowSnapshots.count + captureStartedAt: captureStartedAt, + capturableOwnWindowIDs: capturableOwnWindowIDs ) } catch { NativeHostTelemetry.captureWarning( @@ -1040,8 +809,133 @@ final class CaptureSessionController: NSObject { } } + private func startCaptureSession( + captureID: UInt64, + captureStartedAt: TimeInterval, + capturableOwnWindowIDs: Set + ) throws { + let startPoint = NSEvent.mouseLocation + let desktopFrame = CaptureOverlayController.desktopFrame + frozenFrameLatchToken = nil + // The Rust live sampler treats these IDs as current-process windows to + // include through the app-level exclusion. Overlay windows must stay out + // of this list so color sampling sees the desktop under the capture UI. + pendingLiveFrameStreamRelease?.cancel() + pendingLiveFrameStreamRelease = nil + liveFrameStream.updateSelfCaptureExceptionWindowIDs(capturableOwnWindowIDs) + let warmStartedAt = ProcessInfo.processInfo.systemUptime + let initialSample = warmLiveSamplingIfPossible( + at: startPoint, + source: "start_capture", + captureID: captureID, + includedCurrentProcessWindowIDs: capturableOwnWindowIDs + ) + let initialRgbSample = + initialSample?.rgbSample + ?? frozenFrameAuthority.rgbSample(containing: startPoint) + let warmMilliseconds = NativeHostTelemetry.milliseconds(since: warmStartedAt) + liveFrameStream.start( + for: NSScreen.screens, + prewarmPoint: startPoint, + captureID: captureID + ) + let windowSnapshotStartedAt = ProcessInfo.processInfo.systemUptime + let initialWindowSnapshots = WindowSnapshotFeed.snapshots(desktopFrame: desktopFrame) + let windowSnapshotMilliseconds = + NativeHostTelemetry.milliseconds(since: windowSnapshotStartedAt) + let initialHighlightedWindow = WindowSnapshotFeed.window( + at: startPoint, in: initialWindowSnapshots) + chromeState.rgbSample = initialRgbSample + let sessionSetupStartedAt = ProcessInfo.processInfo.systemUptime + let session = try RsnapHostSession(configuration: settingsStore.sessionConfiguration) + self.session = session + + try session.enterLive() + try session.send( + event: .pointerMoved( + point: startPoint, + rgb: initialRgbSample, + activeMonitor: activeMonitor(at: startPoint), + highlightedWindow: initialHighlightedWindow + ) + ) + let initialScene = try session.currentScene() + self.scene = initialScene + let sessionSetupMilliseconds = + NativeHostTelemetry.milliseconds(since: sessionSetupStartedAt) + + let overlayController = CaptureOverlayController( + controller: self, + liveFrameStream: liveFrameStream, + frameRgbSampler: { [frozenFrameAuthority] point in + frozenFrameAuthority.liveRgbSample(containing: point) + }, + framePatchSampler: { [frozenFrameAuthority] point, sidePixels in + frozenFrameAuthority.loupePatch(containing: point, sidePixels: sidePixels) + } + ) + self.overlayController = overlayController + let overlayShowStartedAt = ProcessInfo.processInfo.systemUptime + overlayController.show( + initialScene: initialScene, + chrome: chromeState, + settings: settingsStore.settings, + focusPoint: startPoint, + initialWindowSnapshots: initialWindowSnapshots, + prepareCaptureStreams: { [weak self, weak overlayController] in + guard let self, let overlayController else { + return + } + let selfCaptureExceptionWindowIDs = + overlayController.selfCaptureExceptionWindowIDs + self.liveFrameStream.start( + for: NSScreen.screens, + prewarmPoint: startPoint, + captureID: captureID + ) + if self.frozenFrameAuthority.hasSelfCaptureCompleteFrame( + containing: startPoint) + { + NativeHostTelemetry.captureEvent( + "capture.self_capture_rebuild_skipped", + captureID: captureID, + detail: "start_capture_complete_filter" + ) + } else { + _ = self.warmLiveSamplingIfPossible( + at: startPoint, + source: "capture_overlay_preflight", + captureID: captureID, + excludeSelfFromFrozenAuthority: true, + selfCaptureExceptionWindowIDs: selfCaptureExceptionWindowIDs, + includedCurrentProcessWindowIDs: capturableOwnWindowIDs + ) + } + } + ) + overlayController.prepareCaptureStreamsNow(trigger: "overlay_show") + let overlayShowMilliseconds = + NativeHostTelemetry.milliseconds(since: overlayShowStartedAt) + (NSApp.delegate as? NativeHostApplicationController)?.window = + overlayController.primaryWindow + sceneDidChange?(initialScene) + + captureStateDidChange?() + NativeHostTelemetry.captureStartTiming( + captureID: captureID, + totalMilliseconds: NativeHostTelemetry.milliseconds(since: captureStartedAt), + warmMilliseconds: warmMilliseconds, + windowSnapshotMilliseconds: windowSnapshotMilliseconds, + sessionSetupMilliseconds: sessionSetupMilliseconds, + overlayShowMilliseconds: overlayShowMilliseconds, + initialSampleReady: initialRgbSample != nil, + screenCount: NSScreen.screens.count, + windowCount: initialWindowSnapshots.count + ) + } + private func ensureCapturePermissions() -> Bool { - guard !NativePermissions.screenRecordingGranted else { + guard NativePermissions.screenRecordingGranted == false else { return true } return NativePermissions.requestScreenRecording() @@ -1399,7 +1293,13 @@ final class CaptureSessionController: NSObject { else { return false } - guard let kind = FrozenSelectionTransformKind.hitTest(at: point, selection: selection) + guard + let kind = try? RsnapFrozenSelectionTransformPlanner.hitTest( + point: point, + selection: selection, + handleRadius: 12, + edgeTolerance: 4 + ) else { return false } @@ -1480,78 +1380,14 @@ final class CaptureSessionController: NSObject { interaction: FrozenSelectionInteractionState, point: CGPoint ) -> CGRect? { - let minSize = CaptureChrome.frozenSelectionMinimumSize - let selection = interaction.initialSelection - let monitor = interaction.monitorFrame - let deltaX = point.x - interaction.initialPointer.x - let deltaY = point.y - interaction.initialPointer.y - - switch interaction.kind { - case .move: - return Self.clampedSelectionRect( - width: selection.width, - height: selection.height, - x: selection.minX + deltaX, - y: selection.minY + deltaY, - monitorFrame: monitor - ) - case .resizeLeft: - let newMinX = (selection.minX + deltaX).clamped( - to: monitor.minX...(selection.maxX - minSize)) - return CGRect( - x: newMinX, y: selection.minY, width: selection.maxX - newMinX, - height: selection.height) - case .resizeRight: - let newMaxX = (selection.maxX + deltaX).clamped( - to: (selection.minX + minSize)...monitor.maxX) - return CGRect( - x: selection.minX, y: selection.minY, width: newMaxX - selection.minX, - height: selection.height) - case .resizeTop: - let newMaxY = (selection.maxY + deltaY).clamped( - to: (selection.minY + minSize)...monitor.maxY) - return CGRect( - x: selection.minX, y: selection.minY, width: selection.width, - height: newMaxY - selection.minY) - case .resizeBottom: - let newMinY = (selection.minY + deltaY).clamped( - to: monitor.minY...(selection.maxY - minSize)) - return CGRect( - x: selection.minX, y: newMinY, width: selection.width, - height: selection.maxY - newMinY) - case .resizeTopLeft: - let newMinX = (selection.minX + deltaX).clamped( - to: monitor.minX...(selection.maxX - minSize)) - let newMaxY = (selection.maxY + deltaY).clamped( - to: (selection.minY + minSize)...monitor.maxY) - return CGRect( - x: newMinX, y: selection.minY, width: selection.maxX - newMinX, - height: newMaxY - selection.minY) - case .resizeTopRight: - let newMaxX = (selection.maxX + deltaX).clamped( - to: (selection.minX + minSize)...monitor.maxX) - let newMaxY = (selection.maxY + deltaY).clamped( - to: (selection.minY + minSize)...monitor.maxY) - return CGRect( - x: selection.minX, y: selection.minY, width: newMaxX - selection.minX, - height: newMaxY - selection.minY) - case .resizeBottomLeft: - let newMinX = (selection.minX + deltaX).clamped( - to: monitor.minX...(selection.maxX - minSize)) - let newMinY = (selection.minY + deltaY).clamped( - to: monitor.minY...(selection.maxY - minSize)) - return CGRect( - x: newMinX, y: newMinY, width: selection.maxX - newMinX, - height: selection.maxY - newMinY) - case .resizeBottomRight: - let newMaxX = (selection.maxX + deltaX).clamped( - to: (selection.minX + minSize)...monitor.maxX) - let newMinY = (selection.minY + deltaY).clamped( - to: monitor.minY...(selection.maxY - minSize)) - return CGRect( - x: selection.minX, y: newMinY, width: newMaxX - selection.minX, - height: selection.maxY - newMinY) - } + try? RsnapFrozenSelectionTransformPlanner.transformedRect( + kind: interaction.kind, + initialSelection: interaction.initialSelection, + monitorFrame: interaction.monitorFrame, + initialPointer: interaction.initialPointer, + point: point, + minimumSize: CaptureChrome.frozenSelectionMinimumSize + ) } func performFrozenUndo() { @@ -1689,7 +1525,9 @@ final class CaptureSessionController: NSObject { } let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - guard !flags.contains(.command), !flags.contains(.control), !flags.contains(.option) else { + guard flags.contains(.command) == false, flags.contains(.control) == false, + flags.contains(.option) == false + else { return false } guard let characters = event.characters else { @@ -1760,7 +1598,7 @@ final class CaptureSessionController: NSObject { guard scene.mode == .live else { return } - guard !chromeState.hostLocalFrozenSelecting else { + guard chromeState.hostLocalFrozenSelecting == false else { return } chromeState.beginHostLocalFrozenSelecting() @@ -1772,7 +1610,7 @@ final class CaptureSessionController: NSObject { } var pendingRequests = try session.drainRequests() - while !pendingRequests.isEmpty { + while pendingRequests.isEmpty == false { for request in pendingRequests { try handle(request: request) } @@ -1787,7 +1625,7 @@ final class CaptureSessionController: NSObject { chromeState.resetLiveChrome() } if scene.mode != .frozen { - if !chromeState.hostLocalFrozenSelecting { + if chromeState.hostLocalFrozenSelecting == false { chromeState.resetFrozenChrome() } } else if previousMode != .frozen @@ -1844,7 +1682,7 @@ final class CaptureSessionController: NSObject { case .requestScreenRecordingPermission: let granted = NativePermissions.requestScreenRecording() try session?.send(report: .permissionChanged(.screenRecording, granted: granted)) - if !granted { + if granted == false { try sendHostStatusMessage("Screen recording permission is required.") } } @@ -2406,16 +2244,36 @@ final class CaptureSessionController: NSObject { let captureImageMilliseconds = NativeHostTelemetry.milliseconds(since: captureImageStartedAt) + let makeImageStartedAt = ProcessInfo.processInfo.systemUptime + guard let pngData = try Self.losslessPNGData(from: cgImage) else { + let makeImageMilliseconds = NativeHostTelemetry.milliseconds(since: makeImageStartedAt) + NativeHostTelemetry.copyCaptureTiming( + captureID: currentCaptureTelemetryID, + totalMilliseconds: NativeHostTelemetry.milliseconds(since: copyStartedAt), + captureImageMilliseconds: captureImageMilliseconds, + clearPasteboardMilliseconds: 0, + makeImageMilliseconds: makeImageMilliseconds, + writePasteboardMilliseconds: 0, + success: false, + failureStage: "encode_image", + width: cgImage.width, + height: cgImage.height + ) + try sendHostStatusMessage("Could not encode the captured image.") + return + } + let makeImageMilliseconds = NativeHostTelemetry.milliseconds(since: makeImageStartedAt) + let pasteboard = NSPasteboard.general let clearPasteboardStartedAt = ProcessInfo.processInfo.systemUptime pasteboard.clearContents() let clearPasteboardMilliseconds = NativeHostTelemetry.milliseconds(since: clearPasteboardStartedAt) - let makeImageStartedAt = ProcessInfo.processInfo.systemUptime - let image = NSImage(cgImage: cgImage, size: .zero) - let makeImageMilliseconds = NativeHostTelemetry.milliseconds(since: makeImageStartedAt) let writePasteboardStartedAt = ProcessInfo.processInfo.systemUptime - let didWritePasteboard = pasteboard.writeObjects([image]) + let pasteboardItem = NSPasteboardItem() + let didWritePasteboard = + pasteboardItem.setData(pngData, forType: .png) + && pasteboard.writeObjects([pasteboardItem]) let writePasteboardMilliseconds = NativeHostTelemetry.milliseconds(since: writePasteboardStartedAt) guard didWritePasteboard else { @@ -2463,8 +2321,7 @@ final class CaptureSessionController: NSObject { try sendHostStatusMessage("Could not capture the frozen selection.") return } - let bitmap = NSBitmapImageRep(cgImage: cgImage) - guard let pngData = bitmap.representation(using: .png, properties: [:]) else { + guard let pngData = try Self.losslessPNGData(from: cgImage) else { try sendHostStatusMessage("Could not encode the captured image.") return } @@ -2479,20 +2336,106 @@ final class CaptureSessionController: NSObject { completedHostEffect = .saveCapture } + private struct RecognizeTextRun { + let captureID: UInt64 + let startedAt: TimeInterval + let recognitionLevel: String + let usesLanguageCorrection: Bool + let automaticallyDetectsLanguage: Bool + } + + private struct RecognizeTextResult { + let observations: [VNRecognizedTextObservation] + let recognizedLines: [String] + let text: String + let processingMilliseconds: Double + } + + private struct RecognizeTextPasteboardTiming { + let clearMilliseconds: Double + let writeMilliseconds: Double + } + private func performRecognizeText() throws { guard let session else { return } - let captureID = currentCaptureTelemetryID - let recognizeStartedAt = ProcessInfo.processInfo.systemUptime + let run = RecognizeTextRun( + captureID: currentCaptureTelemetryID, + startedAt: ProcessInfo.processInfo.systemUptime, + recognitionLevel: "accurate", + usesLanguageCorrection: true, + automaticallyDetectsLanguage: true + ) let captureImageStartedAt = ProcessInfo.processInfo.systemUptime - let recognitionLevel = "accurate" - let usesLanguageCorrection = true - let automaticallyDetectsLanguage = true + guard + let cgImage = try recognizeTextCaptureImage( + run: run, + captureImageStartedAt: captureImageStartedAt + ) + else { + return + } + let captureImageMilliseconds = + NativeHostTelemetry.milliseconds(since: captureImageStartedAt) + let request = recognizeTextRequest(run: run) + let visionRequestMilliseconds = try performRecognizeTextRequest( + request, + cgImage: cgImage, + run: run, + captureImageMilliseconds: captureImageMilliseconds + ) + let result = recognizeTextResult(from: request) + guard + let pasteboardTiming = try writeRecognizedTextIfNeeded( + result.text, + run: run, + cgImage: cgImage, + captureImageMilliseconds: captureImageMilliseconds, + visionRequestMilliseconds: visionRequestMilliseconds, + result: result + ) + else { + return + } + + recordRecognizeTextTiming( + run: run, + captureImageMilliseconds: captureImageMilliseconds, + visionRequestMilliseconds: visionRequestMilliseconds, + resultProcessingMilliseconds: result.processingMilliseconds, + clearPasteboardMilliseconds: pasteboardTiming.clearMilliseconds, + writePasteboardMilliseconds: pasteboardTiming.writeMilliseconds, + success: true, + outcome: result.text.isEmpty ? "no_text" : "text_ready", + failureStage: "none", + width: cgImage.width, + height: cgImage.height, + observationCount: result.observations.count, + recognizedLines: result.recognizedLines.count, + recognizedCharacters: result.text.count + ) + + if result.text.isEmpty == false { + ocrCompletionSound.play() + } + + try session.send(report: .hostEffectCompleted(.recognizeText)) + let message = + result.text.isEmpty + ? "No text was recognized." + : "Recognized text copied to clipboard." + try session.send(report: .statusMessage(message)) + completedHostEffect = .recognizeText + } + + private func recognizeTextCaptureImage( + run: RecognizeTextRun, + captureImageStartedAt: TimeInterval + ) throws -> CGImage? { guard let cgImage = try captureFrozenSelectionImage() else { - NativeHostTelemetry.recognizeTextTiming( - captureID: captureID, - totalMilliseconds: NativeHostTelemetry.milliseconds(since: recognizeStartedAt), + recordRecognizeTextTiming( + run: run, captureImageMilliseconds: NativeHostTelemetry.milliseconds( since: captureImageStartedAt), visionRequestMilliseconds: 0, @@ -2506,32 +2449,38 @@ final class CaptureSessionController: NSObject { height: 0, observationCount: 0, recognizedLines: 0, - recognizedCharacters: 0, - recognitionLevel: recognitionLevel, - languageCorrection: usesLanguageCorrection, - automaticLanguageDetection: automaticallyDetectsLanguage + recognizedCharacters: 0 ) try sendHostStatusMessage("Could not capture the frozen selection.") - return + return nil } - let captureImageMilliseconds = - NativeHostTelemetry.milliseconds(since: captureImageStartedAt) + return cgImage + } + private func recognizeTextRequest(run: RecognizeTextRun) -> VNRecognizeTextRequest { let request = VNRecognizeTextRequest() request.recognitionLevel = .accurate - request.usesLanguageCorrection = usesLanguageCorrection - request.automaticallyDetectsLanguage = automaticallyDetectsLanguage + request.usesLanguageCorrection = run.usesLanguageCorrection + request.automaticallyDetectsLanguage = run.automaticallyDetectsLanguage + return request + } + + private func performRecognizeTextRequest( + _ request: VNRecognizeTextRequest, + cgImage: CGImage, + run: RecognizeTextRun, + captureImageMilliseconds: Double + ) throws -> Double { let handler = VNImageRequestHandler(cgImage: cgImage) let visionStartedAt = ProcessInfo.processInfo.systemUptime do { try handler.perform([request]) } catch { - NativeHostTelemetry.recognizeTextTiming( - captureID: captureID, - totalMilliseconds: NativeHostTelemetry.milliseconds(since: recognizeStartedAt), + let visionRequestMilliseconds = NativeHostTelemetry.milliseconds(since: visionStartedAt) + recordRecognizeTextTiming( + run: run, captureImageMilliseconds: captureImageMilliseconds, - visionRequestMilliseconds: NativeHostTelemetry.milliseconds( - since: visionStartedAt), + visionRequestMilliseconds: visionRequestMilliseconds, resultProcessingMilliseconds: 0, clearPasteboardMilliseconds: 0, writePasteboardMilliseconds: 0, @@ -2542,103 +2491,114 @@ final class CaptureSessionController: NSObject { height: cgImage.height, observationCount: 0, recognizedLines: 0, - recognizedCharacters: 0, - recognitionLevel: recognitionLevel, - languageCorrection: usesLanguageCorrection, - automaticLanguageDetection: automaticallyDetectsLanguage + recognizedCharacters: 0 ) throw error } - let visionRequestMilliseconds = NativeHostTelemetry.milliseconds(since: visionStartedAt) + return NativeHostTelemetry.milliseconds(since: visionStartedAt) + } + private func recognizeTextResult(from request: VNRecognizeTextRequest) -> RecognizeTextResult { let resultProcessingStartedAt = ProcessInfo.processInfo.systemUptime let observations = request.results ?? [] - let recognizedLines = - observations - .compactMap { observation -> String? in - guard let line = observation.topCandidates(1).first?.string, - !line.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - else { - return nil - } - return line - } - let text = recognizedLines.joined(separator: "\n") - let resultProcessingMilliseconds = - NativeHostTelemetry.milliseconds(since: resultProcessingStartedAt) - - var clearPasteboardMilliseconds = 0.0 - var writePasteboardMilliseconds = 0.0 - if !text.isEmpty { - let pasteboard = NSPasteboard.general - let clearPasteboardStartedAt = ProcessInfo.processInfo.systemUptime - pasteboard.clearContents() - clearPasteboardMilliseconds = - NativeHostTelemetry.milliseconds(since: clearPasteboardStartedAt) - let writePasteboardStartedAt = ProcessInfo.processInfo.systemUptime - guard pasteboard.setString(text, forType: .string) else { - writePasteboardMilliseconds = - NativeHostTelemetry.milliseconds(since: writePasteboardStartedAt) - NativeHostTelemetry.recognizeTextTiming( - captureID: captureID, - totalMilliseconds: NativeHostTelemetry.milliseconds( - since: recognizeStartedAt), - captureImageMilliseconds: captureImageMilliseconds, - visionRequestMilliseconds: visionRequestMilliseconds, - resultProcessingMilliseconds: resultProcessingMilliseconds, - clearPasteboardMilliseconds: clearPasteboardMilliseconds, - writePasteboardMilliseconds: writePasteboardMilliseconds, - success: false, - outcome: "recognize_error", - failureStage: "pasteboard_write", - width: cgImage.width, - height: cgImage.height, - observationCount: observations.count, - recognizedLines: recognizedLines.count, - recognizedCharacters: text.count, - recognitionLevel: recognitionLevel, - languageCorrection: usesLanguageCorrection, - automaticLanguageDetection: automaticallyDetectsLanguage - ) - try sendHostStatusMessage("Could not copy recognized text.") - return + let recognizedLines = observations.compactMap { observation -> String? in + guard let line = observation.topCandidates(1).first?.string, + line.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false + else { + return nil } - writePasteboardMilliseconds = - NativeHostTelemetry.milliseconds(since: writePasteboardStartedAt) + return line } + return RecognizeTextResult( + observations: observations, + recognizedLines: recognizedLines, + text: recognizedLines.joined(separator: "\n"), + processingMilliseconds: NativeHostTelemetry.milliseconds( + since: resultProcessingStartedAt) + ) + } + private func writeRecognizedTextIfNeeded( + _ text: String, + run: RecognizeTextRun, + cgImage: CGImage, + captureImageMilliseconds: Double, + visionRequestMilliseconds: Double, + result: RecognizeTextResult + ) throws -> RecognizeTextPasteboardTiming? { + guard text.isEmpty == false else { + return RecognizeTextPasteboardTiming(clearMilliseconds: 0, writeMilliseconds: 0) + } + let pasteboard = NSPasteboard.general + let clearPasteboardStartedAt = ProcessInfo.processInfo.systemUptime + pasteboard.clearContents() + let clearPasteboardMilliseconds = + NativeHostTelemetry.milliseconds(since: clearPasteboardStartedAt) + let writePasteboardStartedAt = ProcessInfo.processInfo.systemUptime + guard pasteboard.setString(text, forType: .string) else { + let writePasteboardMilliseconds = + NativeHostTelemetry.milliseconds(since: writePasteboardStartedAt) + recordRecognizeTextTiming( + run: run, + captureImageMilliseconds: captureImageMilliseconds, + visionRequestMilliseconds: visionRequestMilliseconds, + resultProcessingMilliseconds: result.processingMilliseconds, + clearPasteboardMilliseconds: clearPasteboardMilliseconds, + writePasteboardMilliseconds: writePasteboardMilliseconds, + success: false, + outcome: "recognize_error", + failureStage: "pasteboard_write", + width: cgImage.width, + height: cgImage.height, + observationCount: result.observations.count, + recognizedLines: result.recognizedLines.count, + recognizedCharacters: text.count + ) + try sendHostStatusMessage("Could not copy recognized text.") + return nil + } + return RecognizeTextPasteboardTiming( + clearMilliseconds: clearPasteboardMilliseconds, + writeMilliseconds: NativeHostTelemetry.milliseconds(since: writePasteboardStartedAt) + ) + } + + private func recordRecognizeTextTiming( + run: RecognizeTextRun, + captureImageMilliseconds: Double, + visionRequestMilliseconds: Double, + resultProcessingMilliseconds: Double, + clearPasteboardMilliseconds: Double, + writePasteboardMilliseconds: Double, + success: Bool, + outcome: String, + failureStage: String, + width: Int, + height: Int, + observationCount: Int, + recognizedLines: Int, + recognizedCharacters: Int + ) { NativeHostTelemetry.recognizeTextTiming( - captureID: captureID, - totalMilliseconds: NativeHostTelemetry.milliseconds(since: recognizeStartedAt), + captureID: run.captureID, + totalMilliseconds: NativeHostTelemetry.milliseconds(since: run.startedAt), captureImageMilliseconds: captureImageMilliseconds, visionRequestMilliseconds: visionRequestMilliseconds, resultProcessingMilliseconds: resultProcessingMilliseconds, clearPasteboardMilliseconds: clearPasteboardMilliseconds, writePasteboardMilliseconds: writePasteboardMilliseconds, - success: true, - outcome: text.isEmpty ? "no_text" : "text_ready", - failureStage: "none", - width: cgImage.width, - height: cgImage.height, - observationCount: observations.count, - recognizedLines: recognizedLines.count, - recognizedCharacters: text.count, - recognitionLevel: recognitionLevel, - languageCorrection: usesLanguageCorrection, - automaticLanguageDetection: automaticallyDetectsLanguage + success: success, + outcome: outcome, + failureStage: failureStage, + width: width, + height: height, + observationCount: observationCount, + recognizedLines: recognizedLines, + recognizedCharacters: recognizedCharacters, + recognitionLevel: run.recognitionLevel, + languageCorrection: run.usesLanguageCorrection, + automaticLanguageDetection: run.automaticallyDetectsLanguage ) - - if !text.isEmpty { - ocrCompletionSound.play() - } - - try session.send(report: .hostEffectCompleted(.recognizeText)) - let message = - text.isEmpty - ? "No text was recognized." - : "Recognized text copied to clipboard." - try session.send(report: .statusMessage(message)) - completedHostEffect = .recognizeText } private func activeScrollCaptureExportImage() throws -> CGImage? { @@ -2697,7 +2657,7 @@ final class CaptureSessionController: NSObject { let hadBaseImageBefore = chromeState.frozenBaseImage != nil let hadFrozenDisplayImageBefore = chromeState.frozenDisplayImage != nil let hasOverlayEdits = - chromeState.frozenOverlay.canUndo || chromeState.frozenOverlay.activeInteraction != nil + chromeState.frozenOverlay.canUndo || chromeState.frozenOverlay.hasActiveInteraction let ensureStartedAt = ProcessInfo.processInfo.systemUptime ensureFrozenBaseImageFromDisplayIfNeeded(for: selection) let ensureMilliseconds = NativeHostTelemetry.milliseconds(since: ensureStartedAt) @@ -2725,7 +2685,7 @@ final class CaptureSessionController: NSObject { } let compositeStartedAt = ProcessInfo.processInfo.systemUptime - let composited = compositeFrozenOverlay(on: baseImage, selection: selection) ?? baseImage + let composited = try compositeFrozenOverlay(on: baseImage, selection: selection) let result = applyingCaptureFrameEffect ? applyCaptureFrameEffectIfNeeded( @@ -2771,7 +2731,7 @@ final class CaptureSessionController: NSObject { } let selectionCenter = CGPoint(x: selection.midX, y: selection.midY) let screen = screen(containing: selectionCenter) - if !hasOverlayEdits, + if hasOverlayEdits == false, chromeState.captureFrameSource == .window, let windowImage = captureFrameWindowImage() { @@ -2866,15 +2826,14 @@ final class CaptureSessionController: NSObject { displayFrame: CGRect, selection: CGRect ) -> CGImage? { - let cropRect = CGRect( - x: ((selection.minX - displayFrame.minX) / max(displayFrame.width, 1)) - * CGFloat(image.width), - y: ((displayFrame.maxY - selection.maxY) / max(displayFrame.height, 1)) - * CGFloat(image.height), - width: (selection.width / max(displayFrame.width, 1)) * CGFloat(image.width), - height: (selection.height / max(displayFrame.height, 1)) * CGFloat(image.height) - ).integral.intersection(CGRect(x: 0, y: 0, width: image.width, height: image.height)) - guard cropRect.width > 0, cropRect.height > 0 else { + guard + let cropRect = try? RsnapExportEncoder.frozenDisplayCropRect( + imageWidth: image.width, + imageHeight: image.height, + displayFrame: displayFrame, + selection: selection + ) + else { return nil } return image.cropping(to: cropRect) @@ -2919,6 +2878,14 @@ final class CaptureSessionController: NSObject { return RGBARegionSnapshot(width: width, height: height, rgba: rgba) } + private static func losslessPNGData(from image: CGImage) throws -> Data? { + guard let snapshot = rgbaSnapshot(from: image) else { + return nil + } + + return try RsnapExportEncoder.pngData(from: snapshot) + } + private static func cgImage(from snapshot: RGBARegionSnapshot) -> CGImage? { guard snapshot.width > 0, snapshot.height > 0 else { return nil @@ -3032,134 +2999,27 @@ final class CaptureSessionController: NSObject { scene.statusMessage = message } - private func compositeFrozenOverlay(on image: CGImage, selection: CGRect) -> CGImage? { - guard - chromeState.frozenOverlay.canUndo || chromeState.frozenOverlay.activeInteraction != nil - else { + private func compositeFrozenOverlay(on image: CGImage, selection: CGRect) throws -> CGImage { + let elements = chromeState.frozenOverlay.exportElements + guard elements.isEmpty == false else { return image } - let width = image.width - let height = image.height guard - let colorSpace = image.colorSpace ?? CGColorSpace(name: CGColorSpace.sRGB), - let context = CGContext( - data: nil, - width: width, - height: height, - bitsPerComponent: 8, - bytesPerRow: 0, - space: colorSpace, - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue - ) + let snapshot = Self.rgbaSnapshot(from: image), + let rendered = Self.cgImage( + from: try RsnapExportEncoder.frozenOverlayExportImage( + from: snapshot, + selection: selection, + elements: elements + )) else { - return image + throw HostBridgeError.ffiStatus( + context: "converting frozen overlay export image", + code: 4) } - let imageRect = CGRect(x: 0, y: 0, width: width, height: height) - let imageSize = CGSize(width: CGFloat(width), height: CGFloat(height)) - let scaleX = imageSize.width / max(selection.width, 1) - let scaleY = imageSize.height / max(selection.height, 1) - context.draw(image, in: imageRect) - - func mapPoint(_ point: CGPoint) -> CGPoint { - frozenExportOverlayPoint( - point, - selection: selection, - imageSize: imageSize - ) - } - func mapRect(_ rect: CGRect) -> CGRect { - frozenExportOverlayRect( - rect, - selection: selection, - imageSize: imageSize - ) - } - func sourceImageRect(_ rect: CGRect) -> CGRect { - frozenExportSourceImageRect( - rect, - selection: selection, - imageSize: imageSize - ) - } - - let mosaicRects = chromeState.frozenOverlay.mosaicRects.map { - (source: sourceImageRect($0), destination: mapRect($0)) - } - if !mosaicRects.isEmpty { - context.saveGState() - context.interpolationQuality = .high - for rect in mosaicRects { - if let mosaicPatch = makeFrozenMosaicPatch(from: image, sourceRect: rect.source) { - context.draw(mosaicPatch, in: rect.destination.integral.intersection(imageRect)) - } - } - context.restoreGState() - } - - let spotlightAnnotations = chromeState.frozenOverlay.spotlightAnnotations.map { - (rect: mapRect($0.rect), style: $0.style) - } - let averageScale = (scaleX + scaleY) / 2 - if !spotlightAnnotations.isEmpty { - context.saveGState() - context.setFillColor(NSColor.black.withAlphaComponent(0.32).cgColor) - context.fill(imageRect) - for annotation in spotlightAnnotations { - context.saveGState() - context.clip(to: annotation.rect) - context.draw(image, in: imageRect) - context.restoreGState() - } - context.restoreGState() - - for annotation in spotlightAnnotations { - drawFrozenSpotlightBorder( - for: annotation.rect, - style: annotation.style, - scale: averageScale, - alpha: 0.96, - in: context - ) - } - } - - for stroke in chromeState.frozenOverlay.penStrokes { - guard let first = stroke.points.first else { - continue - } - context.setStrokeColor(stroke.style.color.nsColor(alpha: 0.96).cgColor) - context.setLineWidth(stroke.style.strokeWidthPoints * averageScale) - context.setLineCap(.round) - context.setLineJoin(.round) - context.beginPath() - context.move(to: mapPoint(first)) - for point in stroke.points.dropFirst() { - context.addLine(to: mapPoint(point)) - } - context.strokePath() - } - for annotation in chromeState.frozenOverlay.arrowAnnotations { - drawFrozenArrow( - from: mapPoint(annotation.start), - to: mapPoint(annotation.end), - style: annotation.style, - scale: averageScale, - in: context - ) - } - for annotation in chromeState.frozenOverlay.textAnnotations { - drawExportText( - annotation.text, - at: mapPoint(annotation.anchor), - style: annotation.style, - scale: averageScale, - in: context - ) - } - - return context.makeImage() + return rendered } private func drawExportText( @@ -3169,7 +3029,7 @@ final class CaptureSessionController: NSObject { scale: CGFloat, in context: CGContext ) { - guard !text.isEmpty else { + guard text.isEmpty == false else { return } @@ -3329,199 +3189,19 @@ final class CaptureSessionController: NSObject { cropSizePx: CGFloat, captureSizePoints: CGFloat ) -> CGFloat { - guard cropSizePx > 0, captureSizePoints > 0 else { - return 0 - } - let leadingMarginPx = contentOriginPx - let trailingMarginPx = cropSizePx - (contentOriginPx + contentSizePx) - let deltaPx = (leadingMarginPx - trailingMarginPx) * 0.5 - return (deltaPx * captureSizePoints / cropSizePx).rounded() - } - - private static func detectAutoCenterContentBounds(in image: CGImage) -> CGRect? { - let bitmap = NSBitmapImageRep(cgImage: image) - let width = bitmap.pixelsWide - let height = bitmap.pixelsHigh - guard width >= 2, height >= 2 else { - return nil - } - guard - bitmap.bitsPerSample == 8, - !bitmap.isPlanar, - bitmap.samplesPerPixel >= 3, - !bitmap.bitmapFormat.contains(.floatingPointSamples), - let bitmapData = bitmap.bitmapData - else { - return nil - } - - let edgeStrip = max(1, min(24, Int((CGFloat(min(width, height)) * 0.08).rounded()))) - guard - let topMean = regionRGBMean( - bitmapData, bitmap: bitmap, x0: 0, x1: width, y0: 0, y1: edgeStrip), - let bottomMean = regionRGBMean( - bitmapData, bitmap: bitmap, x0: 0, x1: width, y0: height - edgeStrip, y1: height), - let leftMean = regionRGBMean( - bitmapData, bitmap: bitmap, x0: 0, x1: edgeStrip, y0: 0, y1: height), - let rightMean = regionRGBMean( - bitmapData, bitmap: bitmap, x0: width - edgeStrip, x1: width, y0: 0, y1: height) - else { - return nil - } - - let threshold = max( - 24, - min( - 96, - Int( - round( - max( - regionRGBMeanDistance( - bitmapData, bitmap: bitmap, x0: 0, x1: width, y0: 0, y1: edgeStrip, - mean: topMean), - regionRGBMeanDistance( - bitmapData, bitmap: bitmap, x0: 0, x1: width, - y0: height - edgeStrip, y1: height, mean: bottomMean), - regionRGBMeanDistance( - bitmapData, bitmap: bitmap, x0: 0, x1: edgeStrip, y0: 0, y1: height, - mean: leftMean), - regionRGBMeanDistance( - bitmapData, bitmap: bitmap, x0: width - edgeStrip, x1: width, y0: 0, - y1: height, mean: rightMean) - ) * 3 - ) - ) - ) - ) - let minSalientPerRow = max(1, width / 64) - let minSalientPerColumn = max(1, height / 64) - var rowCounts = Array(repeating: 0, count: height) - var columnCounts = Array(repeating: 0, count: width) - - for y in 0..= CGFloat(threshold) else { - continue - } - rowCounts[y] += 1 - columnCounts[x] += 1 - } - } - - guard - let top = rowCounts.firstIndex(where: { $0 >= minSalientPerRow }), - let bottom = rowCounts.lastIndex(where: { $0 >= minSalientPerRow }), - let left = columnCounts.firstIndex(where: { $0 >= minSalientPerColumn }), - let right = columnCounts.lastIndex(where: { $0 >= minSalientPerColumn }), - left <= right, - top <= bottom - else { - return nil - } - - let bounds = CGRect( - x: left, - y: top, - width: right - left + 1, - height: bottom - top + 1 + RsnapAutoCenterPlanner.marginBalanceShiftPoints( + contentOriginPixels: contentOriginPx, + contentSizePixels: contentSizePx, + cropSizePixels: cropSizePx, + captureSizePoints: captureSizePoints ) - let fillsCropWidth = bounds.width * 100 >= CGFloat(width) * 92 - let fillsCropHeight = bounds.height * 100 >= CGFloat(height) * 92 - return (fillsCropWidth && fillsCropHeight) ? nil : bounds } - private static func regionRGBMean( - _ bitmapData: UnsafeMutablePointer, - bitmap: NSBitmapImageRep, - x0: Int, - x1: Int, - y0: Int, - y1: Int - ) -> [CGFloat]? { - guard x0 < x1, y0 < y1 else { - return nil - } - var rTotal: CGFloat = 0 - var gTotal: CGFloat = 0 - var bTotal: CGFloat = 0 - var count: CGFloat = 0 - for y in y0.. 0 else { + private static func detectAutoCenterContentBounds(in image: CGImage) -> CGRect? { + guard let snapshot = rgbaSnapshot(from: image) else { return nil } - return [rTotal / count, gTotal / count, bTotal / count] - } - - private static func regionRGBMeanDistance( - _ bitmapData: UnsafeMutablePointer, - bitmap: NSBitmapImageRep, - x0: Int, - x1: Int, - y0: Int, - y1: Int, - mean: [CGFloat] - ) -> CGFloat { - guard x0 < x1, y0 < y1 else { - return 0 - } - var total: CGFloat = 0 - var count: CGFloat = 0 - for y in y0.., - bitmap: NSBitmapImageRep, - x: Int, - y: Int - ) -> (r: CGFloat, g: CGFloat, b: CGFloat) { - let bytesPerPixel = max(3, bitmap.bitsPerPixel / 8) - let offset = y * bitmap.bytesPerRow + x * bytesPerPixel - if bitmap.bitmapFormat.contains(.alphaFirst), bytesPerPixel >= 4 { - return ( - r: CGFloat(bitmapData[offset + 1]), - g: CGFloat(bitmapData[offset + 2]), - b: CGFloat(bitmapData[offset + 3]) - ) - } - return ( - r: CGFloat(bitmapData[offset]), - g: CGFloat(bitmapData[offset + 1]), - b: CGFloat(bitmapData[offset + 2]) - ) - } - - private static func rgbDistanceToMean( - _ rgb: (r: CGFloat, g: CGFloat, b: CGFloat), - mean: [CGFloat] - ) -> CGFloat { - abs(rgb.r - mean[0]).rounded() - + abs(rgb.g - mean[1]).rounded() - + abs(rgb.b - mean[2]).rounded() + return try? RsnapAutoCenterPlanner.contentBounds(in: snapshot) } } @@ -3775,7 +3455,7 @@ final class CaptureOverlayController { windowSnapshotFeed.stop() chromeSampleFeed.stop() liveChromeBackdrops.hideAll() - guard !windows.isEmpty else { + guard windows.isEmpty == false else { focusedWindowNumber = nil collapsedForFrozen = false return @@ -3991,7 +3671,7 @@ final class CaptureOverlayController { width: sampleSide, height: sampleSide ).intersection(source.screenFrame) - guard !sampleRect.isNull, sampleRect.width > 0, sampleRect.height > 0 else { + guard sampleRect.isNull == false, sampleRect.width > 0, sampleRect.height > 0 else { return nil } guard @@ -4016,7 +3696,7 @@ final class CaptureOverlayController { width: sampleSide, height: sampleSide ).intersection(source.screenFrame) - guard !sampleRect.isNull, sampleRect.width > 0, sampleRect.height > 0 else { + guard sampleRect.isNull == false, sampleRect.width > 0, sampleRect.height > 0 else { return nil } guard @@ -4050,7 +3730,7 @@ final class CaptureOverlayController { source: LiveColorSampleSource ) -> CGImage? { let displayRect = appKitRectToQuartz(rect, desktopFrame: source.desktopFrame) - guard !displayRect.isNull, displayRect.width > 0, displayRect.height > 0 else { + guard displayRect.isNull == false, displayRect.width > 0, displayRect.height > 0 else { return nil } return displayCreateImageForRect?(source.displayID, displayRect)? @@ -4224,11 +3904,11 @@ final class CaptureOverlayController { } private func prepareFrozenPresentation(for selection: CGRect) { - guard !collapsedForFrozen else { + guard collapsedForFrozen == false else { return } collapsedForFrozen = true - guard collapsedForFrozen, !windows.isEmpty else { + guard collapsedForFrozen, windows.isEmpty == false else { return } windowSnapshotFeed.stop() @@ -4996,7 +4676,7 @@ final class CaptureHostView: NSView { if recoverReleasedLivePrimaryInteractionIfNeeded(at: point) { return } - if !liveDragExceededThreshold, + if liveDragExceededThreshold == false, liveDragDistance(from: point) >= Self.liveDragIntentThreshold { liveDragExceededThreshold = true @@ -5137,10 +4817,10 @@ final class CaptureHostView: NSView { private func plainFrozenShortcutAvailable(_ event: NSEvent) -> Bool { let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) - return !flags.contains(.command) - && !flags.contains(.control) - && !flags.contains(.option) - && !flags.contains(.shift) + return flags.contains(.command) == false + && flags.contains(.control) == false + && flags.contains(.option) == false + && flags.contains(.shift) == false } private static let annotationStyleWheelDeadZone: CGFloat = 0.05 @@ -5271,14 +4951,17 @@ final class CaptureHostView: NSView { return } guard - let frame = scrollCaptureMinimapFrame( + let minimapPlan = scrollCaptureMinimapPlan( for: selection, exportSize: preview.exportSizePixels, in: bounds, preferredWidth: CaptureChrome.scrollMinimapPreferredWidth, minimumWidth: CaptureChrome.scrollMinimapMinimumWidth, gap: CaptureChrome.scrollMinimapGap, - margin: CaptureChrome.scrollMinimapScreenMargin + margin: CaptureChrome.scrollMinimapScreenMargin, + imageInset: CaptureChrome.scrollMinimapImageInset, + viewportTopPixels: preview.viewportTopYPixels, + viewportHeightPixels: preview.viewportHeightPixels ) else { return @@ -5286,10 +4969,8 @@ final class CaptureHostView: NSView { let theme = chromeTheme() let palette = CaptureChrome.palette(for: theme, settings: settings) - let imageFrame = frame.insetBy( - dx: CaptureChrome.scrollMinimapImageInset, - dy: CaptureChrome.scrollMinimapImageInset - ) + let frame = minimapPlan.frame + let imageFrame = minimapPlan.imageFrame let backgroundPath = NSBezierPath( roundedRect: frame, xRadius: CaptureChrome.scrollMinimapCornerRadius, @@ -5317,10 +4998,7 @@ final class CaptureHostView: NSView { context.draw(preview.image, in: imageFrame) context.restoreGState() - if let viewportFrame = scrollCaptureMinimapViewportFrame( - for: preview, - in: imageFrame - ) { + if let viewportFrame = minimapPlan.viewportFrame { context.setFillColor(NSColor.white.withAlphaComponent(0.13).cgColor) context.fill(viewportFrame) context.setStrokeColor(NSColor.white.withAlphaComponent(0.88).cgColor) @@ -5333,27 +5011,6 @@ final class CaptureHostView: NSView { backgroundPath.stroke() } - private func scrollCaptureMinimapViewportFrame( - for preview: ScrollCaptureMinimapSnapshot, - in frame: CGRect - ) -> CGRect? { - let exportHeight = max(preview.exportSizePixels.height, 1) - let viewportHeight = preview.viewportHeightPixels.clamped(to: 1...exportHeight) - let maxTop = max(exportHeight - viewportHeight, 0) - let viewportTop = preview.viewportTopYPixels.clamped(to: 0...maxTop) - let markerHeight = max(2, frame.height * viewportHeight / exportHeight) - let markerY = - frame.maxY - frame.height * (viewportTop + viewportHeight) / exportHeight - let marker = CGRect( - x: frame.minX, - y: markerY, - width: frame.width, - height: markerHeight - ) - let clippedMarker = marker.intersection(frame) - return clippedMarker.isNull ? nil : clippedMarker - } - private func localFrozenDisplayFrame() -> CGRect? { localRect(from: chrome.frozenDisplayFrame) } @@ -5550,7 +5207,7 @@ final class CaptureHostView: NSView { guard scene.mode == .live, liveDragStartGlobal != nil, - !livePrimaryCompletionInFlight + livePrimaryCompletionInFlight == false else { return } @@ -5592,7 +5249,7 @@ final class CaptureHostView: NSView { else { return } - if !isPrimaryMouseButtonPressed() { + if isPrimaryMouseButtonPressed() == false { let point = NSEvent.mouseLocation logLivePrimaryInputEvent("capture.live_primary_release_watchdog", point: point) completeLivePrimaryInteractionFromSystemMouseUp(at: point, source: "watchdog") @@ -5668,7 +5325,7 @@ final class CaptureHostView: NSView { } private func emitLiveChromeInputSummary(reason: String) { - guard !didEmitLiveChromeInputSummary else { + guard didEmitLiveChromeInputSummary == false else { return } let observedMouseEvents = max( @@ -5765,7 +5422,7 @@ final class CaptureHostView: NSView { { return .openHand } - if !chrome.frozenSelectionTransformAllowed { + if chrome.frozenSelectionTransformAllowed == false { return .arrow } if let pointer = currentGlobalMousePoint(), @@ -5834,7 +5491,13 @@ final class CaptureHostView: NSView { } private func editableFrozenCursorIntent(at point: CGPoint, selection: CGRect) -> CursorIntent? { - guard let kind = FrozenSelectionTransformKind.hitTest(at: point, selection: selection) + guard + let kind = try? RsnapFrozenSelectionTransformPlanner.hitTest( + point: point, + selection: selection, + handleRadius: 12, + edgeTolerance: 4 + ) else { return nil } @@ -6085,7 +5748,7 @@ final class CaptureHostView: NSView { let mosaicRects = chrome.frozenOverlay.mosaicRects.compactMap(localRect(from:)) let previewRect = chrome.frozenOverlay.previewMosaicRect.flatMap(localRect(from:)) let allRects = mosaicRects + (previewRect.map { [$0] } ?? []) - guard !allRects.isEmpty, let baseImage = chrome.frozenBaseImage else { + guard allRects.isEmpty == false, let baseImage = chrome.frozenBaseImage else { return } let imageSize = CGSize(width: CGFloat(baseImage.width), height: CGFloat(baseImage.height)) @@ -6125,7 +5788,7 @@ final class CaptureHostView: NSView { } } let allAnnotations = spotlightAnnotations + (previewAnnotation.map { [$0] } ?? []) - guard !allAnnotations.isEmpty else { + guard allAnnotations.isEmpty == false else { return } @@ -6153,7 +5816,7 @@ final class CaptureHostView: NSView { let allStrokes = chrome.frozenOverlay.penStrokes + (chrome.frozenOverlay.previewPenStroke.map { [$0] } ?? []) - guard !allStrokes.isEmpty else { + guard allStrokes.isEmpty == false else { return } @@ -6183,7 +5846,7 @@ final class CaptureHostView: NSView { let arrows = chrome.frozenOverlay.arrowAnnotations + (chrome.frozenOverlay.previewArrow.map { [$0] } ?? []) - guard !arrows.isEmpty else { + guard arrows.isEmpty == false else { return } @@ -6238,7 +5901,7 @@ final class CaptureHostView: NSView { scale: CGFloat, in context: CGContext ) { - guard !text.isEmpty else { + guard text.isEmpty == false else { return } @@ -6262,7 +5925,7 @@ final class CaptureHostView: NSView { private func toolbarLayout(for selection: CGRect) -> FrozenToolbarLayout? { let items = visibleToolbarItems() - guard !items.isEmpty else { + guard items.isEmpty == false else { return nil } @@ -6440,7 +6103,7 @@ final class CaptureHostView: NSView { return nil } let visibleSelection = selection.intersection(bounds) - if !visibleSelection.isNull, toolbarFrame.intersects(visibleSelection) { + if visibleSelection.isNull == false, toolbarFrame.intersects(visibleSelection) { return nil } return CGPath( @@ -6887,7 +6550,7 @@ final class CaptureHostView: NSView { return nil } - if !livePrimaryCompletionInFlight { + if livePrimaryCompletionInFlight == false { let polledPoint = currentGlobalMousePoint() ?? NSEvent.mouseLocation if let currentPreview = livePointerPreviewGlobal { if hypot(currentPreview.x - polledPoint.x, currentPreview.y - polledPoint.y) @@ -6979,7 +6642,8 @@ final class CaptureHostView: NSView { } let previousPreview = liveHighlightedWindowPreview refreshLiveHighlightedWindowPreview(at: globalPoint) - return !Self.windowSnapshotsEquivalent(previousPreview, liveHighlightedWindowPreview) + return Self.windowSnapshotsEquivalent(previousPreview, liveHighlightedWindowPreview) + == false } private static func windowSnapshotsEquivalent(_ lhs: WindowSnapshot?, _ rhs: WindowSnapshot?) @@ -7121,7 +6785,7 @@ final class CaptureHostView: NSView { let maxY = max(bounds.height - size.height - 6, minY) var x = alignTrailing ? (referenceFrame.maxX - size.width) : referenceFrame.minX - if !alignTrailing, x + size.width > bounds.width - 6 { + if alignTrailing == false, x + size.width > bounds.width - 6 { x = referenceFrame.maxX - size.width } x = x.clamped(to: minX...maxX) @@ -7877,7 +7541,7 @@ final class CaptureHostView: NSView { } frozenToolbarLiquidGlassVisible = true frozenToolbarLiquidGlassContentDrawn = true - if !wasVisible { + if wasVisible == false { needsDisplay = true } } @@ -8183,6 +7847,105 @@ private struct FrozenTextStyle: Equatable { } } +extension FrozenAnnotationColor { + fileprivate var exportColor: FrozenOverlayExportColor { + switch self { + case .white: + .white + case .yellow: + .yellow + case .green: + .green + case .blue: + .blue + case .red: + .red + case .black: + .black + } + } +} + +extension FrozenBrushStyle { + fileprivate var exportStrokeStyle: FrozenOverlayExportStrokeStyle { + FrozenOverlayExportStrokeStyle( + strokeWidthPoints: strokeWidthPoints, + color: color.exportColor + ) + } +} + +extension FrozenSpotlightStyle { + fileprivate var exportSpotlightStyle: FrozenOverlayExportSpotlightStyle { + FrozenOverlayExportSpotlightStyle( + borderWidthPoints: borderWidthPoints, + borderColor: borderColor.exportColor + ) + } +} + +extension FrozenTextStyle { + fileprivate var exportTextStyle: FrozenOverlayExportTextStyle { + FrozenOverlayExportTextStyle( + fontSizePoints: fontSizePoints, + color: color.exportColor + ) + } +} + +extension FrozenOverlayExportColor { + fileprivate var annotationColor: FrozenAnnotationColor { + switch self { + case .white: + .white + case .yellow: + .yellow + case .green: + .green + case .blue: + .blue + case .red: + .red + case .black: + .black + } + } +} + +extension FrozenOverlayExportStrokeStyle { + fileprivate var frozenBrushStyle: FrozenBrushStyle { + FrozenBrushStyle(strokeWidthPoints: strokeWidthPoints, color: color.annotationColor) + } +} + +extension FrozenOverlayExportSpotlightStyle { + fileprivate var frozenSpotlightStyle: FrozenSpotlightStyle { + FrozenSpotlightStyle( + borderWidthPoints: borderWidthPoints, + borderColor: borderColor.annotationColor + ) + } +} + +extension FrozenOverlayExportTextStyle { + fileprivate var frozenTextStyle: FrozenTextStyle { + FrozenTextStyle(fontSizePoints: fontSizePoints, color: color.annotationColor) + } +} + +extension FrozenAnnotationStyleState { + fileprivate var editStyle: FrozenOverlayEditStyle { + FrozenOverlayEditStyle( + strokeWidthPoints: brushStyle.strokeWidthPoints, + strokeColor: brushStyle.color.exportColor, + spotlightBorderWidthPoints: spotlightStyle.borderWidthPoints, + spotlightColor: spotlightStyle.borderColor.exportColor, + textFontSizePoints: textStyle.fontSizePoints, + textColor: textStyle.color.exportColor + ) + } +} + private enum FrozenAnnotationStyleAction: Equatable { case decreaseSize case increaseSize @@ -8419,61 +8182,6 @@ private func drawFrozenArrow( context.restoreGState() } -private enum FrozenSelectionTransformKind { - case move - case resizeLeft - case resizeRight - case resizeTop - case resizeBottom - case resizeTopLeft - case resizeTopRight - case resizeBottomLeft - case resizeBottomRight -} - -extension FrozenSelectionTransformKind { - fileprivate static func hitTest( - at point: CGPoint, - selection: CGRect - ) -> FrozenSelectionTransformKind? { - let handleRadius = CGFloat(12) - let edgeTolerance = CGFloat(4) - let left = selection.minX - let right = selection.maxX - let top = selection.maxY - let bottom = selection.minY - - if abs(point.x - left) <= handleRadius, abs(point.y - top) <= handleRadius { - return .resizeTopLeft - } - if abs(point.x - right) <= handleRadius, abs(point.y - top) <= handleRadius { - return .resizeTopRight - } - if abs(point.x - left) <= handleRadius, abs(point.y - bottom) <= handleRadius { - return .resizeBottomLeft - } - if abs(point.x - right) <= handleRadius, abs(point.y - bottom) <= handleRadius { - return .resizeBottomRight - } - if point.y >= bottom, point.y <= top, abs(point.x - left) <= edgeTolerance { - return .resizeLeft - } - if point.y >= bottom, point.y <= top, abs(point.x - right) <= edgeTolerance { - return .resizeRight - } - if point.x >= left, point.x <= right, abs(point.y - top) <= edgeTolerance { - return .resizeTop - } - if point.x >= left, point.x <= right, abs(point.y - bottom) <= edgeTolerance { - return .resizeBottom - } - if selection.contains(point) { - return .move - } - return nil - } -} - private struct FrozenSelectionInteractionState { let kind: FrozenSelectionTransformKind let initialPointer: CGPoint @@ -8553,491 +8261,214 @@ private struct ScrollCaptureMinimapSnapshot { let viewportHeightPixels: CGFloat } -private struct FrozenOverlayState { - enum Edit { - case pen(FrozenBrushStroke) - case arrow(FrozenArrowAnnotation) - case mosaic(CGRect) - case spotlight(FrozenSpotlightAnnotation) - case text(FrozenTextAnnotation) - } - - enum ActiveInteraction { - case pen(points: [CGPoint], style: FrozenBrushStyle) - case arrow(start: CGPoint, current: CGPoint, style: FrozenBrushStyle) - case mosaic(anchor: CGPoint, current: CGPoint) - case mosaicMove(index: Int, currentRect: CGRect, dragOffset: CGSize) - case textMove(index: Int, currentAnnotation: FrozenTextAnnotation, dragOffset: CGSize) - case spotlight(anchor: CGPoint, current: CGPoint, style: FrozenSpotlightStyle) - } - - private enum MoveTarget { - case mosaic(index: Int, rect: CGRect) - case text(index: Int, annotation: FrozenTextAnnotation) - } - - var edits: [Edit] = [] - var redoEdits: [Edit] = [] - var activeInteraction: ActiveInteraction? - var activeTextEdit: FrozenTextEditState? +private final class FrozenOverlayState { + private let session: RsnapFrozenOverlayEditSession + private var snapshot: FrozenOverlayEditSnapshot - var canUndo: Bool { !edits.isEmpty } - var canRedo: Bool { !redoEdits.isEmpty } - var keepsFrozenSelectionFixed: Bool { - !edits.isEmpty || !redoEdits.isEmpty || activeInteraction != nil || activeTextEdit != nil - } - var isMovingMovableAnnotation: Bool { - switch activeInteraction { - case .mosaicMove?, .textMove?: - return true - case nil, .pen?, .arrow?, .mosaic?, .spotlight?: - return false + init() { + do { + let session = try RsnapFrozenOverlayEditSession() + self.session = session + self.snapshot = try session.snapshot() + } catch { + fatalError("Failed to create Rust frozen overlay edit session: \(error)") } } - mutating func reset() { - edits.removeAll() - redoEdits.removeAll() - activeInteraction = nil - activeTextEdit = nil + var canUndo: Bool { snapshot.canUndo } + var canRedo: Bool { snapshot.canRedo } + var keepsFrozenSelectionFixed: Bool { snapshot.keepsFrozenSelectionFixed } + var isMovingMovableAnnotation: Bool { snapshot.isMovingMovableAnnotation } + var hasActiveInteraction: Bool { snapshot.hasActiveInteraction } + var activeTextEdit: FrozenTextEditState? { + snapshot.activeTextEdit.map { FrozenTextEditState(anchor: $0.anchor, text: $0.text) } } + var exportElements: [FrozenOverlayExportElement] { snapshot.elements } - mutating func begin( - tool: ToolbarItemKind, - at point: CGPoint, - selection: CGRect, - style: FrozenAnnotationStyleState - ) -> Bool { - guard selection.contains(point) else { - return false - } - - switch tool { - case .pen: - activeInteraction = .pen(points: [point], style: style.brushStyle) - case .arrow: - activeInteraction = .arrow(start: point, current: point, style: style.brushStyle) - case .mosaic: - activeInteraction = .mosaic(anchor: point, current: point) - case .pointer: - guard let target = Self.moveTarget(in: edits, at: point) else { - return false - } - switch target { - case .mosaic(let index, let rect): - activeInteraction = .mosaicMove( - index: index, - currentRect: rect, - dragOffset: CGSize(width: point.x - rect.minX, height: point.y - rect.minY) - ) - case .text(let index, let annotation): - activeInteraction = .textMove( - index: index, - currentAnnotation: annotation, - dragOffset: CGSize( - width: point.x - annotation.anchor.x, - height: point.y - annotation.anchor.y - ) - ) - } - case .spotlight: - activeInteraction = .spotlight( - anchor: point, - current: point, - style: style.spotlightStyle - ) - case .text: - let _ = commitTextEdit(style: style.textStyle) - activeTextEdit = FrozenTextEditState(anchor: selection.clamp(point), text: "") - return true - default: - return false - } - - return true + var penStrokes: [FrozenBrushStroke] { + snapshot.elements.compactMap(Self.penStroke(from:)) } - mutating func update(to point: CGPoint, selection: CGRect) -> Bool { - guard let activeInteraction else { - return false - } - - switch activeInteraction { - case .pen(var points, let style): - let clamped = selection.clamp(point) - if let lastPoint = points.last, - hypot(lastPoint.x - clamped.x, lastPoint.y - clamped.y) < 1.5 - { - return false - } - points.append(clamped) - self.activeInteraction = .pen(points: points, style: style) - case .arrow(let start, _, let style): - self.activeInteraction = .arrow( - start: start, current: selection.clamp(point), style: style) - case .mosaic(let anchor, _): - self.activeInteraction = .mosaic(anchor: anchor, current: selection.clamp(point)) - case .mosaicMove(let index, let currentRect, let dragOffset): - self.activeInteraction = .mosaicMove( - index: index, - currentRect: Self.movedMosaicRect( - rect: currentRect, - dragOffset: dragOffset, - point: point, - selection: selection - ), - dragOffset: dragOffset - ) - case .textMove(let index, let currentAnnotation, let dragOffset): - self.activeInteraction = .textMove( - index: index, - currentAnnotation: Self.movedTextAnnotation( - currentAnnotation, - dragOffset: dragOffset, - point: point, - selection: selection - ), - dragOffset: dragOffset - ) - case .spotlight(let anchor, _, let style): - self.activeInteraction = .spotlight( - anchor: anchor, - current: selection.clamp(point), - style: style - ) - } - - return true + var arrowAnnotations: [FrozenArrowAnnotation] { + snapshot.elements.compactMap(Self.arrowAnnotation(from:)) } - mutating func finish(selection: CGRect) -> Bool { - guard let activeInteraction else { - return false - } - defer { self.activeInteraction = nil } - - var changed = true - switch activeInteraction { - case .pen(let points, let style): - guard points.count >= 2 else { - return false - } - edits.append(.pen(FrozenBrushStroke(points: points, style: style))) - case .arrow(let start, let current, let style): - guard hypot(start.x - current.x, start.y - current.y) >= 6 else { - return false - } - edits.append(.arrow(FrozenArrowAnnotation(start: start, end: current, style: style))) - case .mosaic(let anchor, let current): - let rect = selection.normalizedRect(anchor: anchor, current: current) - guard rect.width >= 6, rect.height >= 6 else { - return false - } - edits.append(.mosaic(rect)) - case .mosaicMove(let index, let currentRect, _): - guard edits.indices.contains(index), case .mosaic(let oldRect) = edits[index] else { - return false - } - if oldRect == currentRect { - changed = false - } else { - edits[index] = .mosaic(currentRect) - } - case .textMove(let index, let currentAnnotation, _): - guard edits.indices.contains(index), - case .text(let oldAnnotation) = edits[index] - else { - return false - } - if oldAnnotation == currentAnnotation { - changed = false - } else { - edits[index] = .text(currentAnnotation) - } - case .spotlight(let anchor, let current, let style): - let rect = selection.normalizedRect(anchor: anchor, current: current) - guard rect.width >= 6, rect.height >= 6 else { - return false - } - edits.append(.spotlight(FrozenSpotlightAnnotation(rect: rect, style: style))) - } - - if changed { - redoEdits.removeAll() - } - return true + var mosaicRects: [CGRect] { + snapshot.elements.compactMap(Self.mosaicRect(from:)) } - private static func moveTarget(in edits: [Edit], at point: CGPoint) -> MoveTarget? { - for index in edits.indices.reversed() { - switch edits[index] { - case .mosaic(let rect) where rect.contains(point): - return .mosaic(index: index, rect: rect) - case .text(let annotation) where textHitBounds(for: annotation).contains(point): - return .text(index: index, annotation: annotation) - case .pen, .arrow, .mosaic, .spotlight, .text: - continue - } - } - return nil + var spotlightAnnotations: [FrozenSpotlightAnnotation] { + snapshot.elements.compactMap(Self.spotlightAnnotation(from:)) } - private static func mosaicMoveTarget( - in edits: [Edit], - at point: CGPoint - ) -> (index: Int, rect: CGRect)? { - for index in edits.indices.reversed() { - if case .mosaic(let rect) = edits[index], rect.contains(point) { - return (index, rect) - } - } - return nil + var textAnnotations: [FrozenTextAnnotation] { + snapshot.elements.compactMap(Self.textAnnotation(from:)) } - func containsMovableAnnotation(at point: CGPoint) -> Bool { - Self.moveTarget(in: edits, at: point) != nil + var previewPenStroke: FrozenBrushStroke? { + snapshot.previewPen.flatMap(Self.penStroke(from:)) } - private static func movedMosaicRect( - rect: CGRect, - dragOffset: CGSize, - point: CGPoint, - selection: CGRect - ) -> CGRect { - let maxMinX = max(selection.minX, selection.maxX - rect.width) - let maxMinY = max(selection.minY, selection.maxY - rect.height) - return CGRect( - x: min(max(point.x - dragOffset.width, selection.minX), maxMinX), - y: min(max(point.y - dragOffset.height, selection.minY), maxMinY), - width: rect.width, - height: rect.height - ) + var previewArrow: FrozenArrowAnnotation? { + snapshot.previewArrow.flatMap(Self.arrowAnnotation(from:)) } - private static func movedTextAnnotation( - _ annotation: FrozenTextAnnotation, - dragOffset: CGSize, - point: CGPoint, - selection: CGRect - ) -> FrozenTextAnnotation { - let size = textBounds(for: annotation).size - let maxAnchorX = max(selection.minX, selection.maxX - size.width) - let maxAnchorY = max(selection.minY, selection.maxY - size.height) - let anchor = CGPoint( - x: min(max(point.x - dragOffset.width, selection.minX), maxAnchorX), - y: min(max(point.y - dragOffset.height, selection.minY), maxAnchorY) - ) - return FrozenTextAnnotation(anchor: anchor, text: annotation.text, style: annotation.style) + var previewMosaicRect: CGRect? { + snapshot.previewMosaic.flatMap(Self.mosaicRect(from:)) } - private static func textHitBounds(for annotation: FrozenTextAnnotation) -> CGRect { - textBounds(for: annotation).insetBy(dx: -4, dy: -4) + var previewTextAnnotation: FrozenTextAnnotation? { + snapshot.previewText.flatMap(Self.textAnnotation(from:)) } - private static func textBounds(for annotation: FrozenTextAnnotation) -> CGRect { - let font = NSFont.systemFont( - ofSize: max(1, annotation.style.fontSizePoints), weight: .medium) - let attributed = NSAttributedString( - string: annotation.text, - attributes: [.font: font] - ) - let size = attributed.boundingRect( - with: CGSize( - width: CGFloat.greatestFiniteMagnitude, - height: CGFloat.greatestFiniteMagnitude - ), - options: [.usesLineFragmentOrigin, .usesFontLeading] - ).size - return CGRect( - x: annotation.anchor.x, - y: annotation.anchor.y, - width: max(1, ceil(size.width)), - height: max(ceil(font.ascender - font.descender + font.leading), ceil(size.height)) - ) + var previewSpotlightAnnotation: FrozenSpotlightAnnotation? { + snapshot.previewSpotlight.flatMap(Self.spotlightAnnotation(from:)) } - mutating func appendText(_ text: String) -> Bool { - guard var activeTextEdit else { - return false - } - let sanitized = text.replacingOccurrences(of: "\r", with: "") - guard !sanitized.isEmpty else { - return false + func reset() { + do { + try session.reset() + try refreshSnapshot() + } catch { + fatalError("Failed to reset Rust frozen overlay state: \(error)") } - activeTextEdit.text.append(sanitized) - self.activeTextEdit = activeTextEdit - return true } - mutating func backspaceText() -> Bool { - guard var activeTextEdit else { - return false - } - guard activeTextEdit.text.popLast() != nil else { - return false + func begin( + tool: ToolbarItemKind, + at point: CGPoint, + selection: CGRect, + style: FrozenAnnotationStyleState + ) -> Bool { + performRefreshingWhenChanged { + try session.begin(tool: tool, at: point, selection: selection, style: style.editStyle) } - self.activeTextEdit = activeTextEdit - return true } - mutating func commitTextEdit(style: FrozenTextStyle) -> Bool { - guard let activeTextEdit else { - return false - } - self.activeTextEdit = nil - let trimmed = activeTextEdit.text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - return false + func update(to point: CGPoint, selection: CGRect) -> Bool { + performRefreshingWhenChanged { + try session.update(to: point, selection: selection) } - edits.append( - .text( - FrozenTextAnnotation( - anchor: activeTextEdit.anchor, - text: activeTextEdit.text, - style: style - ))) - redoEdits.removeAll() - return true } - mutating func cancelTextEdit() { - activeTextEdit = nil + func finish(selection: CGRect) -> Bool { + performRefreshingAlways { + try session.finish(selection: selection) + } } - mutating func undo() -> Bool { - activeTextEdit = nil - guard let edit = edits.popLast() else { - return false + func appendText(_ text: String) -> Bool { + performRefreshingWhenChanged { + try session.appendText(text) } - redoEdits.append(edit) - return true } - mutating func redo() -> Bool { - activeTextEdit = nil - guard let edit = redoEdits.popLast() else { - return false + func backspaceText() -> Bool { + performRefreshingWhenChanged { + try session.backspaceText() } - edits.append(edit) - return true } - var penStrokes: [FrozenBrushStroke] { - edits.compactMap { - if case .pen(let stroke) = $0 { - return stroke - } - return nil + func commitTextEdit(style: FrozenTextStyle) -> Bool { + performRefreshingAlways { + try session.commitText( + style: FrozenOverlayEditStyle( + strokeWidthPoints: 3, + strokeColor: .blue, + spotlightBorderWidthPoints: 0, + spotlightColor: .blue, + textFontSizePoints: style.fontSizePoints, + textColor: style.color.exportColor + ) + ) } } - var arrowAnnotations: [FrozenArrowAnnotation] { - edits.compactMap { - if case .arrow(let annotation) = $0 { - return annotation - } - return nil + func undo() -> Bool { + performRefreshingAlways { + try session.undo() } } - var mosaicRects: [CGRect] { - let movingIndex = movingMosaicEditIndex - return edits.indices.compactMap { index in - if index == movingIndex { - return nil - } - if case .mosaic(let rect) = edits[index] { - return rect - } - return nil + func redo() -> Bool { + performRefreshingAlways { + try session.redo() } } - var spotlightAnnotations: [FrozenSpotlightAnnotation] { - edits.compactMap { - if case .spotlight(let annotation) = $0 { - return annotation - } - return nil + func containsMovableAnnotation(at point: CGPoint) -> Bool { + do { + return try session.containsMovableAnnotation(at: point) + } catch { + fatalError("Failed to hit-test Rust frozen overlay annotation: \(error)") } } - var textAnnotations: [FrozenTextAnnotation] { - let movingIndex = movingTextEditIndex - return edits.indices.compactMap { index in - if index == movingIndex { - return nil - } - if case .text(let annotation) = edits[index] { - return annotation + private func performRefreshingWhenChanged(_ operation: () throws -> Bool) -> Bool { + do { + let changed = try operation() + if changed { + try refreshSnapshot() } - return nil + return changed + } catch { + fatalError("Failed to update Rust frozen overlay state: \(error)") } } - var previewPenStroke: FrozenBrushStroke? { - if case .pen(let points, let style)? = activeInteraction { - return FrozenBrushStroke(points: points, style: style) + private func performRefreshingAlways(_ operation: () throws -> Bool) -> Bool { + do { + let changed = try operation() + try refreshSnapshot() + return changed + } catch { + fatalError("Failed to update Rust frozen overlay state: \(error)") } - return nil } - var previewArrow: FrozenArrowAnnotation? { - if case .arrow(let start, let current, let style)? = activeInteraction { - return FrozenArrowAnnotation(start: start, end: current, style: style) - } - return nil + private func refreshSnapshot() throws { + snapshot = try session.snapshot() } - var movingMosaicEditIndex: Int? { - if case .mosaicMove(let index, _, _)? = activeInteraction { - return index + private static func penStroke(from element: FrozenOverlayExportElement) -> FrozenBrushStroke? { + guard case .pen(let points, let style) = element else { + return nil } - return nil + return FrozenBrushStroke(points: points, style: style.frozenBrushStyle) } - var movingTextEditIndex: Int? { - if case .textMove(let index, _, _)? = activeInteraction { - return index + private static func arrowAnnotation(from element: FrozenOverlayExportElement) + -> FrozenArrowAnnotation? + { + guard case .arrow(let start, let end, let style) = element else { + return nil } - return nil + return FrozenArrowAnnotation(start: start, end: end, style: style.frozenBrushStyle) } - var previewMosaicRect: CGRect? { - if case .mosaic(let anchor, let current)? = activeInteraction { - return CGRect( - x: min(anchor.x, current.x), - y: min(anchor.y, current.y), - width: abs(current.x - anchor.x), - height: abs(current.y - anchor.y) - ) - } - if case .mosaicMove(_, let currentRect, _)? = activeInteraction { - return currentRect + private static func mosaicRect(from element: FrozenOverlayExportElement) -> CGRect? { + guard case .mosaic(let rect) = element else { + return nil } - return nil + return rect } - var previewTextAnnotation: FrozenTextAnnotation? { - if case .textMove(_, let annotation, _)? = activeInteraction { - return annotation + private static func spotlightAnnotation(from element: FrozenOverlayExportElement) + -> FrozenSpotlightAnnotation? + { + guard case .spotlight(let rect, let style) = element else { + return nil } - return nil + return FrozenSpotlightAnnotation(rect: rect, style: style.frozenSpotlightStyle) } - var previewSpotlightAnnotation: FrozenSpotlightAnnotation? { - if case .spotlight(let anchor, let current, let style)? = activeInteraction { - return FrozenSpotlightAnnotation( - rect: CGRect( - x: min(anchor.x, current.x), - y: min(anchor.y, current.y), - width: abs(current.x - anchor.x), - height: abs(current.y - anchor.y) - ), - style: style - ) + private static func textAnnotation(from element: FrozenOverlayExportElement) + -> FrozenTextAnnotation? + { + guard case .text(let anchor, let text, let style) = element else { + return nil } - return nil + return FrozenTextAnnotation(anchor: anchor, text: text, style: style.frozenTextStyle) } } diff --git a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostPanels.swift b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostPanels.swift index 8fe54d3e..d07cabca 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostPanels.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostPanels.swift @@ -141,7 +141,7 @@ enum NativePermissions { static func requestScreenRecording() -> Bool { let granted = screenRecordingGranted || CGRequestScreenCaptureAccess() - if !granted { + if granted == false { openScreenRecordingSettings() } return granted diff --git a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettings.swift b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettings.swift index 7d009fe0..00df51b6 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettings.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettings.swift @@ -173,10 +173,10 @@ struct NativeHostSettings: Equatable { showAltHintKeycap: true, hudGlassEnabled: true, hudGlassMode: .liquidGlass, - hudOpacity: 0.4999747693194925, - hudBlur: 0.5032628676470589, + hudOpacity: 0.499_974_769_319_492_5, + hudBlur: 0.503_262_867_647_058_9, hudTint: 1.0, - hudTintHue: 0.6074879184861536, + hudTintHue: 0.607_487_918_486_153_6, hudTintSaturation: 0.72, hudTintBrightness: 0.95, liquidGlassStyle: .clear, @@ -220,7 +220,7 @@ struct NativeHostSettings: Equatable { private static func sanitizeCaptureHotkey(_ raw: String) -> String { let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { + guard trimmed.isEmpty == false else { return defaults.captureHotkey } return captureHotKeyPresentation(for: trimmed).displayTitle @@ -238,7 +238,7 @@ struct NativeHostSettings: Equatable { private static func parseCaptureHotKeyPresentation(_ raw: String) -> CaptureHotKeyPresentation? { let tokens = captureHotKeyTokens(from: raw) - guard !tokens.isEmpty else { + guard tokens.isEmpty == false else { return nil } diff --git a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettingsView.swift b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettingsView.swift index 4722f13e..c009b6d3 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettingsView.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSettingsView.swift @@ -895,10 +895,10 @@ private struct SoftwareUpdateModePicker: View { for mode: NativeHostSoftwareUpdater.Mode, isEnabled: Bool ) -> String { - if !snapshot.isConfigured { + if snapshot.isConfigured == false { return "Sparkle appcast not configured." } - if mode == .install, !isEnabled { + if mode == .install, isEnabled == false { return "Automatic install is unavailable." } switch mode { @@ -1047,14 +1047,14 @@ private struct ModernSegmentButton: View { } private var hoverBackground: Color { - if isHovered && !isSelected && isEnabled { + if isHovered && isSelected == false && isEnabled { return colorScheme == .light ? Color.black.opacity(0.035) : Color.white.opacity(0.050) } return .clear } private var textColor: Color { - if !isEnabled { + if isEnabled == false { return Color.secondary.opacity(0.54) } if isSelected { @@ -1583,7 +1583,7 @@ private struct CaptureHotKeyField: View { } } .onChange(of: model.settings.captureHotkey) { _, _ in - if !isFocused { + if isFocused == false { syncDraft() } } @@ -1973,7 +1973,7 @@ private struct AboutLinkTile: View { guard let subtitle else { return false } - return !subtitle.isEmpty + return subtitle.isEmpty == false } } diff --git a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSoftwareUpdater.swift b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSoftwareUpdater.swift index 08ee728f..98218ca0 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSoftwareUpdater.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostSoftwareUpdater.swift @@ -30,7 +30,7 @@ final class NativeHostSoftwareUpdater { let lastCheckSummary: String var modeSubtitle: String { - if !isConfigured { + if isConfigured == false { return "Sparkle appcast not configured." } switch mode { diff --git a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostTelemetry.swift b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostTelemetry.swift index 2f0879bb..804894a5 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/NativeHostTelemetry.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/NativeHostTelemetry.swift @@ -445,7 +445,7 @@ enum NativeHostTelemetry { } private func percentile(_ sorted: [Double], _ percentile: Double) -> Double { - guard !sorted.isEmpty else { + guard sorted.isEmpty == false else { return 0 } let fraction = min(max(percentile, 0), 1) diff --git a/native/macos-host/Sources/RsnapNativeHostKit/PermissionAppDragSource.swift b/native/macos-host/Sources/RsnapNativeHostKit/PermissionAppDragSource.swift index dcfbb7ec..e562f409 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/PermissionAppDragSource.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/PermissionAppDragSource.swift @@ -58,7 +58,7 @@ final class PermissionAppDragSourceView: NSView, NSDraggingSource { } override func mouseDragged(with event: NSEvent) { - guard !dragStarted else { + guard dragStarted == false else { return } dragStarted = true diff --git a/native/macos-host/Sources/RsnapNativeHostKit/PermissionRecoveryGuideWindow.swift b/native/macos-host/Sources/RsnapNativeHostKit/PermissionRecoveryGuideWindow.swift index d16b380c..fdf79d9b 100644 --- a/native/macos-host/Sources/RsnapNativeHostKit/PermissionRecoveryGuideWindow.swift +++ b/native/macos-host/Sources/RsnapNativeHostKit/PermissionRecoveryGuideWindow.swift @@ -303,7 +303,7 @@ final class PermissionRecoveryGuideWindowController: NSWindowController { private static func intersectionArea(_ lhs: CGRect, _ rhs: CGRect) -> CGFloat { let intersection = lhs.intersection(rhs) - guard !intersection.isNull, !intersection.isInfinite else { + guard intersection.isNull == false, intersection.isInfinite == false else { return 0 } return max(0, intersection.width) * max(0, intersection.height) @@ -336,7 +336,7 @@ private struct PermissionRecoveryGuideView: View { .padding(.horizontal, 11) .frame(width: 318, height: 50) .onAppear { - guard !reduceMotion else { + guard reduceMotion == false else { return } withAnimation(.easeInOut(duration: 0.78).repeatForever(autoreverses: true)) { @@ -437,7 +437,7 @@ private struct PermissionGuideArrow: View { } private func dotOpacity(index: Int) -> Double { - guard !reduceMotion else { + guard reduceMotion == false else { return 0.34 } let activeIndex = pulse ? 2 : 0 @@ -445,7 +445,7 @@ private struct PermissionGuideArrow: View { } private var arrowOffset: CGFloat { - guard !reduceMotion else { + guard reduceMotion == false else { return 0 } switch symbolName { diff --git a/native/macos-host/Sources/RsnapNativeHostKitProbe/main.swift b/native/macos-host/Sources/RsnapNativeHostKitProbe/main.swift index 0fd5fc34..7a883fa2 100644 --- a/native/macos-host/Sources/RsnapNativeHostKitProbe/main.swift +++ b/native/macos-host/Sources/RsnapNativeHostKitProbe/main.swift @@ -4,42 +4,6 @@ import RsnapNativeHostKit @main enum RsnapNativeHostKitProbe { static func main() { - let selection = CGRect(x: 100, y: 200, width: 50, height: 100) - let imageSize = CGSize(width: 100, height: 200) - let rectNearTop = CGRect(x: 110, y: 270, width: 20, height: 20) - - assertRectEqual( - frozenExportOverlayRect( - rectNearTop, - selection: selection, - imageSize: imageSize - ), - CGRect(x: 20, y: 140, width: 40, height: 40), - "rect overlay export must stay in bottom-left drawing coordinates" - ) - assertRectEqual( - frozenExportSourceImageRect( - rectNearTop, - selection: selection, - imageSize: imageSize - ), - CGRect(x: 20, y: 20, width: 40, height: 40), - "source image rect must stay in top-down CGImage coordinates" - ) - assertPointEqual( - frozenExportOverlayPoint( - CGPoint(x: 125, y: 280), - selection: selection, - imageSize: imageSize - ), - CGPoint(x: 50, y: 160), - "point annotation export must match bottom-left drawing coordinates" - ) - assertRectOverlayDrawsAtVisualTop( - rectNearTop, - selection: selection, - imageSize: imageSize - ) assertScrimRoundedExclusionKeepsCornersMasked() assertScrimOverlappingRoundedExclusionStaysClear() assertScrimExclusionPreservesExistingPixels() @@ -47,38 +11,54 @@ enum RsnapNativeHostKitProbe { assertCaptureFrameEffectExpandsExportCanvas() let minimapExportSize = CGSize(width: 100, height: 200) guard - let rightMinimap = scrollCaptureMinimapFrame( + let rightMinimap = scrollCaptureMinimapPlan( for: CGRect(x: 100, y: 100, width: 100, height: 100), exportSize: minimapExportSize, in: CGRect(x: 0, y: 0, width: 500, height: 500), preferredWidth: 96, minimumWidth: 44, gap: 10, - margin: 10 + margin: 10, + imageInset: 3, + viewportTopPixels: 20, + viewportHeightPixels: 100 ) else { - fatalError("expected right-side scroll minimap frame") + fatalError("expected right-side scroll minimap plan") } assertRectEqual( - rightMinimap, + rightMinimap.frame, CGRect(x: 210, y: 54, width: 96, height: 192), "scroll minimap should prefer the right side when space is available" ) + assertRectEqual( + rightMinimap.imageFrame, + CGRect(x: 213, y: 57, width: 90, height: 186), + "scroll minimap image frame should be planned by Rust" + ) + assertRectEqual( + rightMinimap.viewportFrame ?? .null, + CGRect(x: 213, y: 131.4, width: 90, height: 93), + "scroll minimap viewport frame should be planned by Rust" + ) guard - let leftMinimap = scrollCaptureMinimapFrame( + let leftMinimap = scrollCaptureMinimapPlan( for: CGRect(x: 130, y: 100, width: 100, height: 100), exportSize: minimapExportSize, in: CGRect(x: 0, y: 0, width: 250, height: 500), preferredWidth: 96, minimumWidth: 44, gap: 10, - margin: 10 + margin: 10, + imageInset: 3, + viewportTopPixels: 20, + viewportHeightPixels: 100 ) else { - fatalError("expected left-side scroll minimap frame") + fatalError("expected left-side scroll minimap plan") } assertRectEqual( - leftMinimap, + leftMinimap.frame, CGRect(x: 24, y: 54, width: 96, height: 192), "scroll minimap should fall back to the left when the right side is constrained" ) @@ -95,13 +75,6 @@ enum RsnapNativeHostKitProbe { } } - private static func assertPointEqual(_ actual: CGPoint, _ expected: CGPoint, _ message: String) - { - guard nearlyEqual(actual.x, expected.x), nearlyEqual(actual.y, expected.y) else { - fatalError("\(message): expected \(expected), got \(actual)") - } - } - private static func nearlyEqual(_ actual: CGFloat, _ expected: CGFloat) -> Bool { abs(actual - expected) <= 0.000_1 } @@ -118,14 +91,14 @@ enum RsnapNativeHostKitProbe { } let missingBundle = LaunchAtLoginController.state(for: .notFound) - guard !missingBundle.isOn, missingBundle.isControlEnabled else { + guard missingBundle.isOn == false, missingBundle.isControlEnabled else { fatalError("missing app bundle should keep the login item toggle clickable") } let failed = LaunchAtLoginController.state( for: .notRegistered, errorMessage: "registration failed") - guard !failed.isOn, failed.subtitle.contains("failed") else { + guard failed.isOn == false, failed.subtitle.contains("failed") else { fatalError("failed login item update should keep current state and surface failure") } } @@ -252,51 +225,6 @@ enum RsnapNativeHostKitProbe { return (data[offset], data[offset + 1], data[offset + 2], data[offset + 3]) } - private static func assertRectOverlayDrawsAtVisualTop( - _ rect: CGRect, - selection: CGRect, - imageSize: CGSize - ) { - let width = Int(imageSize.width) - let height = Int(imageSize.height) - let byteCount = width * height * 4 - let data = UnsafeMutablePointer.allocate(capacity: byteCount) - data.initialize(repeating: 0, count: byteCount) - defer { - data.deinitialize(count: byteCount) - data.deallocate() - } - guard - 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 { - fatalError("could not create geometry probe context") - } - - context.setFillColor(CGColor(red: 1, green: 0, blue: 0, alpha: 1)) - context.fill( - frozenExportOverlayRect( - rect, - selection: selection, - imageSize: imageSize - )) - - guard redPixel(in: data, width: width, x: 25, yFromTop: 30) else { - fatalError("rect overlay export did not mark the visual top rows") - } - guard !redPixel(in: data, width: width, x: 25, yFromTop: 170) else { - fatalError("rect overlay export marked the mirrored bottom rows") - } - } - private static func assertScrimRoundedExclusionKeepsCornersMasked() { let width = 80 let height = 80 @@ -506,19 +434,6 @@ enum RsnapNativeHostKitProbe { } } - private static func redPixel( - in data: UnsafePointer, - width: Int, - x: Int, - yFromTop: Int - ) -> Bool { - let offset = (yFromTop * width + x) * 4 - return data[offset] > 200 - && data[offset + 1] < 80 - && data[offset + 2] < 20 - && data[offset + 3] > 200 - } - private static func opaquePixel( in data: UnsafePointer, width: Int, diff --git a/packages/rsnap-capture-core/Cargo.toml b/packages/rsnap-capture-core/Cargo.toml index 0ef81ad2..226a8916 100644 --- a/packages/rsnap-capture-core/Cargo.toml +++ b/packages/rsnap-capture-core/Cargo.toml @@ -13,5 +13,8 @@ version.workspace = true path = "src/lib.rs" [dependencies] -image = { workspace = true } -serde = { workspace = true } +color-eyre = { workspace = true } +fast_image_resize = { workspace = true } +image = { workspace = true } +png = { workspace = true } +serde = { workspace = true } diff --git a/packages/rsnap-capture-core/src/auto_center.rs b/packages/rsnap-capture-core/src/auto_center.rs new file mode 100644 index 00000000..d950ebb8 --- /dev/null +++ b/packages/rsnap-capture-core/src/auto_center.rs @@ -0,0 +1,316 @@ +//! Frozen auto-center content detection owned by the Rust product core. + +use crate::RectPoints; + +/// Error returned when an auto-center image payload is invalid. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AutoCenterImageError { + /// The image dimensions are too small or overflow platform sizes. + InvalidDimensions, + /// The RGBA payload does not contain exactly `width * height * 4` bytes. + InvalidRgbaLength, +} + +#[derive(Clone, Copy)] +struct EdgeMeans { + top: [f64; 3], + bottom: [f64; 3], + left: [f64; 3], + right: [f64; 3], +} + +/// Detects the salient content bounds used by frozen auto-center. +pub fn detect_auto_center_content_bounds_rgba( + width: u32, + height: u32, + rgba: &[u8], +) -> Result, AutoCenterImageError> { + let width = usize::try_from(width).map_err(|_error| AutoCenterImageError::InvalidDimensions)?; + let height = + usize::try_from(height).map_err(|_error| AutoCenterImageError::InvalidDimensions)?; + + if width < 2 || height < 2 { + return Ok(None); + } + + let expected_len = width + .checked_mul(height) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or(AutoCenterImageError::InvalidDimensions)?; + + if rgba.len() != expected_len { + return Err(AutoCenterImageError::InvalidRgbaLength); + } + + let edge_strip = edge_strip_pixels(width, height); + let edge_means = EdgeMeans { + top: region_rgb_mean(rgba, width, 0, width, 0, edge_strip), + bottom: region_rgb_mean(rgba, width, 0, width, height - edge_strip, height), + left: region_rgb_mean(rgba, width, 0, edge_strip, 0, height), + right: region_rgb_mean(rgba, width, width - edge_strip, width, 0, height), + }; + let threshold = salient_threshold(rgba, width, height, edge_strip, edge_means); + let min_salient_per_row = 1_usize.max(width / 64); + let min_salient_per_column = 1_usize.max(height / 64); + let Some(bounds) = salient_bounds( + rgba, + width, + height, + edge_means, + threshold, + min_salient_per_row, + min_salient_per_column, + ) else { + return Ok(None); + }; + let fills_crop_width = bounds.width as usize * 100 >= width * 92; + let fills_crop_height = bounds.height as usize * 100 >= height * 92; + + if fills_crop_width && fills_crop_height { + return Ok(None); + } + + Ok(Some(bounds)) +} + +/// Resolves the point shift that balances content margins inside a frozen crop. +#[must_use] +pub fn auto_center_margin_balance_shift_points( + content_origin_px: f64, + content_size_px: f64, + crop_size_px: f64, + capture_size_points: f64, +) -> f64 { + if crop_size_px <= 0.0 || capture_size_points <= 0.0 { + return 0.0; + } + + let leading_margin_px = content_origin_px; + let trailing_margin_px = crop_size_px - (content_origin_px + content_size_px); + let delta_px = (leading_margin_px - trailing_margin_px) * 0.5; + + (delta_px * capture_size_points / crop_size_px).round() +} + +fn edge_strip_pixels(width: usize, height: usize) -> usize { + let short_side = width.min(height) as f64; + + 1_usize.max(24_usize.min((short_side * 0.08).round() as usize)) +} + +fn salient_threshold( + rgba: &[u8], + width: usize, + height: usize, + edge_strip: usize, + means: EdgeMeans, +) -> f64 { + region_rgb_mean_distance(rgba, width, 0, width, 0, edge_strip, means.top) + .max(region_rgb_mean_distance( + rgba, + width, + 0, + width, + height - edge_strip, + height, + means.bottom, + )) + .max(region_rgb_mean_distance(rgba, width, 0, edge_strip, 0, height, means.left)) + .max(region_rgb_mean_distance( + rgba, + width, + width - edge_strip, + width, + 0, + height, + means.right, + )) + .mul_add(3.0, 0.0) + .round() + .clamp(24.0, 96.0) +} + +fn salient_bounds( + rgba: &[u8], + width: usize, + height: usize, + means: EdgeMeans, + threshold: f64, + min_salient_per_row: usize, + min_salient_per_column: usize, +) -> Option { + let mut row_counts = vec![0_usize; height]; + let mut column_counts = vec![0_usize; width]; + + for (y, row_count) in row_counts.iter_mut().enumerate() { + for (x, column_count) in column_counts.iter_mut().enumerate() { + let rgb = rgb_at(rgba, width, x, y); + let salient_distance = rgb_distance_to_mean(rgb, means.top) + .min(rgb_distance_to_mean(rgb, means.bottom)) + .min(rgb_distance_to_mean(rgb, means.left)) + .min(rgb_distance_to_mean(rgb, means.right)); + + if salient_distance < threshold { + continue; + } + + *row_count += 1; + *column_count += 1; + } + } + + let top = row_counts.iter().position(|count| *count >= min_salient_per_row)?; + let bottom = row_counts.iter().rposition(|count| *count >= min_salient_per_row)?; + let left = column_counts.iter().position(|count| *count >= min_salient_per_column)?; + let right = column_counts.iter().rposition(|count| *count >= min_salient_per_column)?; + + if left > right || top > bottom { + return None; + } + + Some(RectPoints::new( + left as u32, + top as u32, + (right - left + 1) as u32, + (bottom - top + 1) as u32, + )) +} + +fn region_rgb_mean( + rgba: &[u8], + width: usize, + x0: usize, + x1: usize, + y0: usize, + y1: usize, +) -> [f64; 3] { + let mut r_total = 0.0; + let mut g_total = 0.0; + let mut b_total = 0.0; + let mut count = 0.0; + + for y in y0..y1 { + for x in x0..x1 { + let rgb = rgb_at(rgba, width, x, y); + + r_total += rgb[0]; + g_total += rgb[1]; + b_total += rgb[2]; + count += 1.0; + } + } + + [r_total / count, g_total / count, b_total / count] +} + +fn region_rgb_mean_distance( + rgba: &[u8], + width: usize, + x0: usize, + x1: usize, + y0: usize, + y1: usize, + mean: [f64; 3], +) -> f64 { + let mut total = 0.0; + let mut count = 0.0; + + for y in y0..y1 { + for x in x0..x1 { + total += rgb_distance_to_mean(rgb_at(rgba, width, x, y), mean); + count += 1.0; + } + } + + if count == 0.0 { 0.0 } else { total / count } +} + +fn rgb_at(rgba: &[u8], width: usize, x: usize, y: usize) -> [f64; 3] { + let offset = (y * width + x) * 4; + + [f64::from(rgba[offset]), f64::from(rgba[offset + 1]), f64::from(rgba[offset + 2])] +} + +fn rgb_distance_to_mean(rgb: [f64; 3], mean: [f64; 3]) -> f64 { + (rgb[0] - mean[0]).abs().round() + + (rgb[1] - mean[1]).abs().round() + + (rgb[2] - mean[2]).abs().round() +} + +#[cfg(test)] +mod tests { + use crate::RectPoints; + use crate::auto_center::{self, AutoCenterImageError}; + + #[test] + fn detects_centered_content_bounds_from_rgba() { + let rgba = auto_center_fixture(100, 80, Some(RectPoints::new(30, 20, 24, 18))); + let bounds = auto_center::detect_auto_center_content_bounds_rgba(100, 80, &rgba) + .expect("valid RGBA fixture"); + + assert_eq!(bounds, Some(RectPoints::new(30, 20, 24, 18))); + } + + #[test] + fn returns_empty_for_uniform_or_full_frame_content() { + let uniform = auto_center_fixture(100, 80, None); + let full = auto_center_fixture(100, 80, Some(RectPoints::new(2, 2, 96, 76))); + + assert_eq!( + auto_center::detect_auto_center_content_bounds_rgba(100, 80, &uniform) + .expect("valid uniform fixture"), + None + ); + assert_eq!( + auto_center::detect_auto_center_content_bounds_rgba(100, 80, &full) + .expect("valid full fixture"), + None + ); + } + + #[test] + fn rejects_invalid_rgba_length() { + assert!(matches!( + auto_center::detect_auto_center_content_bounds_rgba(100, 80, &[0, 1, 2, 3]), + Err(AutoCenterImageError::InvalidRgbaLength) + )); + } + + #[test] + fn margin_balance_shift_matches_native_math() { + assert_eq!( + auto_center::auto_center_margin_balance_shift_points(30.0, 24.0, 100.0, 50.0), + -4.0 + ); + assert_eq!( + auto_center::auto_center_margin_balance_shift_points(0.0, 24.0, 100.0, 50.0), + -19.0 + ); + assert_eq!( + auto_center::auto_center_margin_balance_shift_points(30.0, 24.0, 0.0, 50.0), + 0.0 + ); + } + + fn auto_center_fixture(width: u32, height: u32, content: Option) -> Vec { + let mut rgba = vec![180_u8; (width * height * 4) as usize]; + + for pixel in rgba.chunks_exact_mut(4) { + pixel[3] = 255; + } + + if let Some(content) = content { + for y in content.y..content.y + content.height { + for x in content.x..content.x + content.width { + let offset = ((y * width + x) * 4) as usize; + + rgba[offset] = 24; + rgba[offset + 1] = 32; + rgba[offset + 2] = 40; + } + } + } + + rgba + } +} diff --git a/packages/rsnap-capture-core/src/bgra_frame.rs b/packages/rsnap-capture-core/src/bgra_frame.rs new file mode 100644 index 00000000..2cd9b8c6 --- /dev/null +++ b/packages/rsnap-capture-core/src/bgra_frame.rs @@ -0,0 +1,256 @@ +//! BGRA frame sampling primitives shared by native capture hosts. + +use image::{Rgba, RgbaImage}; + +use crate::{DisplayPointRect, Rgb}; + +/// Borrowed BGRA8 frame storage with row-stride metadata. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct BgraFrameView<'a> { + /// Frame width in pixels. + pub width: u32, + /// Frame height in pixels. + pub height: u32, + /// Source bytes per row, including any platform padding. + pub bytes_per_row: usize, + /// Packed BGRA8 source bytes. + pub bytes: &'a [u8], +} +impl BgraFrameView<'_> { + /// Returns true when the dimensions, stride, and byte storage can contain the frame. + #[must_use] + pub fn is_valid(self) -> bool { + frame_is_valid(self) + } +} + +/// Samples an RGB value from a BGRA frame using display-space coordinates. +#[must_use] +pub fn sample_rgb_from_bgra_frame( + frame: BgraFrameView<'_>, + display_frame: DisplayPointRect, + point_x: f64, + point_y: f64, +) -> Option { + let (x, y) = display_point_to_pixel(frame, display_frame, point_x, point_y)?; + let offset = pixel_offset(frame, x, y)?; + + Some(Rgb::new(frame.bytes[offset + 2], frame.bytes[offset + 1], frame.bytes[offset])) +} + +/// Builds a square RGBA loupe patch from a BGRA frame using display-space coordinates. +#[must_use] +pub fn loupe_patch_rgba_from_bgra_frame( + frame: BgraFrameView<'_>, + display_frame: DisplayPointRect, + point_x: f64, + point_y: f64, + side_pixels: u32, +) -> Option { + let (center_x, center_y) = display_point_to_pixel(frame, display_frame, point_x, point_y)?; + let side = side_pixels.max(1); + let half = i64::from(side / 2); + let max_x = i64::from(frame.width.checked_sub(1)?); + let max_y = i64::from(frame.height.checked_sub(1)?); + + Some(RgbaImage::from_fn(side, side, |output_x, output_y| { + let source_x = clamp_i64( + i64::try_from(center_x).unwrap_or(i64::MAX) - half + i64::from(output_x), + 0, + max_x, + ); + let source_y = clamp_i64( + i64::try_from(center_y).unwrap_or(i64::MAX) - half + i64::from(output_y), + 0, + max_y, + ); + let offset = pixel_offset(frame, source_x as usize, source_y as usize) + .expect("validated BGRA frame should contain every patch pixel"); + + Rgba([ + frame.bytes[offset + 2], + frame.bytes[offset + 1], + frame.bytes[offset], + frame.bytes[offset + 3], + ]) + })) +} + +fn display_point_to_pixel( + frame: BgraFrameView<'_>, + display_frame: DisplayPointRect, + point_x: f64, + point_y: f64, +) -> Option<(usize, usize)> { + if !frame_is_valid(frame) || !display_frame_is_valid(display_frame) { + return None; + } + + let display_max_x = display_frame.x + display_frame.width; + let display_max_y = display_frame.y + display_frame.height; + + if !(point_x.is_finite() + && point_y.is_finite() + && point_x >= display_frame.x + && point_x < display_max_x + && point_y >= display_frame.y + && point_y < display_max_y) + { + return None; + } + + let x_ratio = (point_x - display_frame.x) / display_frame.width; + let y_ratio = (display_max_y - point_y) / display_frame.height; + let x = ((x_ratio * f64::from(frame.width)).floor() as i64).clamp(0, i64::from(frame.width) - 1) + as usize; + let y = ((y_ratio * f64::from(frame.height)).floor() as i64) + .clamp(0, i64::from(frame.height) - 1) as usize; + + Some((x, y)) +} + +fn frame_is_valid(frame: BgraFrameView<'_>) -> bool { + if frame.width == 0 || frame.height == 0 { + return false; + } + + let width_bytes = usize::try_from(frame.width).ok().and_then(|width| width.checked_mul(4)); + let Some(width_bytes) = width_bytes else { + return false; + }; + + if frame.bytes_per_row < width_bytes { + return false; + } + + let required_len = usize::try_from(frame.height) + .ok() + .and_then(|height| height.checked_mul(frame.bytes_per_row)); + let Some(required_len) = required_len else { + return false; + }; + + frame.bytes.len() >= required_len +} + +fn display_frame_is_valid(rect: DisplayPointRect) -> bool { + rect.x.is_finite() + && rect.y.is_finite() + && rect.width.is_finite() + && rect.height.is_finite() + && rect.width > 0.0 + && rect.height > 0.0 +} + +fn pixel_offset(frame: BgraFrameView<'_>, x: usize, y: usize) -> Option { + if x >= usize::try_from(frame.width).ok()? || y >= usize::try_from(frame.height).ok()? { + return None; + } + + let row = y.checked_mul(frame.bytes_per_row)?; + let column = x.checked_mul(4)?; + let offset = row.checked_add(column)?; + + (offset + 3 < frame.bytes.len()).then_some(offset) +} + +const fn clamp_i64(value: i64, min: i64, max: i64) -> i64 { + if value < min { + min + } else if value > max { + max + } else { + value + } +} + +#[cfg(test)] +mod tests { + use crate::bgra_frame::{self, BgraFrameView}; + use crate::{DisplayPointRect, Rgb}; + + #[test] + fn samples_rgb_from_bgra_display_point() { + let bytes = bgra_fixture(4, 3, 20); + let rgb = bgra_frame::sample_rgb_from_bgra_frame( + BgraFrameView { width: 4, height: 3, bytes_per_row: 20, bytes: &bytes }, + DisplayPointRect::new(10.0, 20.0, 40.0, 30.0), + 25.0, + 45.0, + ); + + assert_eq!(rgb, Some(Rgb::new(11, 21, 31))); + } + + #[test] + fn samples_bottom_edge_like_native_mapping() { + let bytes = bgra_fixture(4, 3, 16); + let rgb = bgra_frame::sample_rgb_from_bgra_frame( + BgraFrameView { width: 4, height: 3, bytes_per_row: 16, bytes: &bytes }, + DisplayPointRect::new(0.0, 0.0, 4.0, 3.0), + 0.0, + 0.0, + ); + + assert_eq!(rgb, Some(Rgb::new(20, 40, 60))); + } + + #[test] + fn loupe_patch_clamps_edges_and_converts_bgra_to_rgba() { + let bytes = bgra_fixture(4, 3, 16); + let patch = bgra_frame::loupe_patch_rgba_from_bgra_frame( + BgraFrameView { width: 4, height: 3, bytes_per_row: 16, bytes: &bytes }, + DisplayPointRect::new(0.0, 0.0, 4.0, 3.0), + 0.0, + 2.0, + 3, + ) + .expect("valid patch"); + + assert_eq!(patch.dimensions(), (3, 3)); + assert_eq!(patch.get_pixel(0, 0).0, [10, 20, 30, 200]); + assert_eq!(patch.get_pixel(2, 2).0, [21, 41, 61, 203]); + } + + #[test] + fn rejects_invalid_frame_inputs() { + let bytes = bgra_fixture(4, 3, 16); + + assert_eq!( + bgra_frame::sample_rgb_from_bgra_frame( + BgraFrameView { width: 4, height: 3, bytes_per_row: 12, bytes: &bytes }, + DisplayPointRect::new(0.0, 0.0, 4.0, 3.0), + 1.0, + 1.0, + ), + None + ); + assert_eq!( + bgra_frame::loupe_patch_rgba_from_bgra_frame( + BgraFrameView { width: 4, height: 3, bytes_per_row: 16, bytes: &bytes[..12] }, + DisplayPointRect::new(0.0, 0.0, 4.0, 3.0), + 1.0, + 1.0, + 3, + ), + None + ); + } + + fn bgra_fixture(width: u32, height: u32, bytes_per_row: usize) -> Vec { + let mut bytes = vec![0xEE; bytes_per_row * height as usize]; + + for y in 0..height { + for x in 0..width { + let offset = y as usize * bytes_per_row + x as usize * 4; + + bytes[offset] = 30 + y as u8 * 15 + x as u8; + bytes[offset + 1] = 20 + y as u8 * 10 + x as u8; + bytes[offset + 2] = 10 + y as u8 * 5 + x as u8; + bytes[offset + 3] = 200 + y as u8 + x as u8; + } + } + + bytes + } +} diff --git a/packages/rsnap-capture-core/src/capture_frame.rs b/packages/rsnap-capture-core/src/capture_frame.rs new file mode 100644 index 00000000..f04f4810 --- /dev/null +++ b/packages/rsnap-capture-core/src/capture_frame.rs @@ -0,0 +1,1085 @@ +//! Capture-frame layout and rendering owned by the Rust product core. + +use color_eyre::eyre::{self, Result, WrapErr}; +use fast_image_resize::images::{Image, ImageRef}; +use fast_image_resize::{FilterType, PixelType, ResizeAlg, ResizeOptions, Resizer}; + +use crate::{DisplayPointRect, RgbaExportImage}; + +/// Product source kind used to tune capture-frame styling. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CaptureFrameSourceKind { + /// User-dragged region capture. + DragRegion, + /// Single-window capture. + Window, + /// Full-screen capture. + FullScreen, + /// Scroll-capture export. + ScrollCapture, + /// Unknown or future capture source. + Unknown, +} + +/// Capture-frame background preset chosen by the user. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CaptureFrameBackgroundKind { + /// Prefer the current system wallpaper with a subtle dark overlay, falling back to Aurora. + SystemWallpaper, + /// Blue-to-warm product gradient. + Aurora, + /// Neutral graphite gradient. + Graphite, + /// Light linen gradient. + Linen, +} + +/// Capture-frame render mode. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CaptureFrameRenderKind { + /// Draw the capture as a framed object with shadows and rounded clipping. + FramedCapture, + /// Draw the capture as a floating window snapshot without additional clipping. + WindowSnapshot, +} + +/// Borrowed RGBA image consumed by the capture-frame renderer. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CaptureFrameRenderImageRef<'a> { + width: u32, + height: u32, + rgba: &'a [u8], +} +impl<'a> CaptureFrameRenderImageRef<'a> { + /// Creates a borrowed RGBA image after validating dimensions and byte count. + pub fn new(width: u32, height: u32, rgba: &'a [u8]) -> Result { + let expected = expected_rgba_len(width, height)?; + + if rgba.len() != expected { + return Err(eyre::eyre!( + "capture-frame RGBA byte length mismatch: expected {expected}, got {}", + rgba.len() + )); + } + + Ok(Self { width, height, rgba }) + } + + /// Borrows an owned product-core export image. + #[must_use] + pub fn from_export(image: &'a RgbaExportImage) -> Self { + Self { width: image.width(), height: image.height(), rgba: image.as_raw() } + } + + /// Returns image width in pixels. + #[must_use] + pub const fn width(self) -> u32 { + self.width + } + + /// Returns image height in pixels. + #[must_use] + pub const fn height(self) -> u32 { + self.height + } + + /// Returns raw row-major RGBA bytes. + #[must_use] + pub const fn rgba(self) -> &'a [u8] { + self.rgba + } +} + +/// One sRGB capture-frame background color stop. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CaptureFrameColorStop { + /// Red component in sRGB space. + pub red: f64, + /// Green component in sRGB space. + pub green: f64, + /// Blue component in sRGB space. + pub blue: f64, + /// Alpha component. + pub alpha: f64, +} +impl CaptureFrameColorStop { + /// Creates an sRGB color stop. + #[must_use] + pub const fn new(red: f64, green: f64, blue: f64, alpha: f64) -> Self { + Self { red, green, blue, alpha } + } +} + +/// Capture-frame background plan consumed by native hosts. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CaptureFrameBackgroundPlan { + /// Ordered sRGB gradient color stops. + pub colors: [CaptureFrameColorStop; 3], + /// Gradient locations matching `colors`. + pub locations: [f64; 3], + /// Whether the host should first try drawing the system wallpaper. + pub prefers_wallpaper: bool, + /// Overlay alpha applied when wallpaper drawing succeeds. + pub wallpaper_overlay_alpha: f64, +} + +/// Platform wallpaper thumbnail request planned by the Rust product core. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CaptureFrameWallpaperRequest { + /// Maximum thumbnail dimension requested from the platform image pipeline. + pub target_pixel_size: u32, + /// Overlay alpha applied after drawing the wallpaper thumbnail. + pub overlay_alpha: f64, +} + +/// One capture-frame shadow pass. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CaptureFrameShadow { + /// Horizontal shadow offset in output pixels. + pub offset_x: f64, + /// Vertical shadow offset in output pixels. + pub offset_y: f64, + /// Shadow blur radius in output pixels. + pub blur: f64, + /// Shadow alpha. + pub alpha: f64, +} +impl CaptureFrameShadow { + /// Creates a shadow pass. + #[must_use] + pub const fn new(offset_x: f64, offset_y: f64, blur: f64, alpha: f64) -> Self { + Self { offset_x, offset_y, blur, alpha } + } +} + +/// Capture-frame plan consumed by native hosts for final drawing. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CaptureFramePlan { + /// Canvas width in output pixels. + pub canvas_width: f64, + /// Canvas height in output pixels. + pub canvas_height: f64, + /// Image placement inside the canvas. + pub image_rect: DisplayPointRect, + /// Rounded capture corner radius. + pub corner_radius: f64, + /// Ordered shadow passes behind the framed capture. + pub shadows: [CaptureFrameShadow; 3], +} + +/// Resolves capture-frame layout, rounded-corner, and shadow parameters. +#[must_use] +pub fn capture_frame_plan( + image_width: u32, + image_height: u32, + screen_scale_factor: f64, + source: CaptureFrameSourceKind, +) -> Option { + if image_width == 0 || image_height == 0 { + return None; + } + + let image_width = f64::from(image_width); + let image_height = f64::from(image_height); + let padding = capture_frame_padding(image_width, image_height); + let canvas_width = (image_width + padding * 2.0).ceil(); + let canvas_height = (image_height + padding * 2.0).ceil(); + let image_rect = DisplayPointRect::new(padding, padding, image_width, image_height); + let corner_radius = + capture_frame_corner_radius(image_width, image_height, screen_scale_factor, source); + + Some(CaptureFramePlan { + canvas_width, + canvas_height, + image_rect, + corner_radius, + shadows: capture_frame_shadows(canvas_width, canvas_height), + }) +} + +/// Resolves the source crop rect for aspect-fill drawing. +#[must_use] +pub fn capture_frame_aspect_fill_crop_rect( + source_width: u32, + source_height: u32, + destination_width: f64, + destination_height: f64, +) -> Option { + if source_width == 0 + || source_height == 0 + || !destination_width.is_finite() + || !destination_height.is_finite() + || destination_width <= 0.0 + || destination_height <= 0.0 + { + return None; + } + + let source_width = f64::from(source_width); + let source_height = f64::from(source_height); + let source_aspect = source_width / source_height.max(1.0); + let destination_aspect = destination_width / destination_height.max(1.0); + + if source_aspect > destination_aspect { + let width = source_height * destination_aspect; + + return Some(DisplayPointRect::new( + (source_width - width) / 2.0, + 0.0, + width, + source_height, + )); + } + + let height = source_width / destination_aspect.max(f64::MIN_POSITIVE); + + Some(DisplayPointRect::new(0.0, (source_height - height) / 2.0, source_width, height)) +} + +/// Resolves capture-frame background colors and wallpaper fallback behavior. +#[must_use] +pub fn capture_frame_background_plan( + kind: CaptureFrameBackgroundKind, +) -> CaptureFrameBackgroundPlan { + const LOCATIONS: [f64; 3] = [0.0, 0.54, 1.0]; + const AURORA: [CaptureFrameColorStop; 3] = [ + CaptureFrameColorStop::new(0.10, 0.16, 0.28, 1.0), + CaptureFrameColorStop::new(0.30, 0.47, 0.71, 1.0), + CaptureFrameColorStop::new(0.95, 0.61, 0.43, 1.0), + ]; + const GRAPHITE: [CaptureFrameColorStop; 3] = [ + CaptureFrameColorStop::new(0.08, 0.09, 0.11, 1.0), + CaptureFrameColorStop::new(0.24, 0.26, 0.30, 1.0), + CaptureFrameColorStop::new(0.56, 0.59, 0.64, 1.0), + ]; + const LINEN: [CaptureFrameColorStop; 3] = [ + CaptureFrameColorStop::new(0.83, 0.87, 0.82, 1.0), + CaptureFrameColorStop::new(0.58, 0.70, 0.71, 1.0), + CaptureFrameColorStop::new(0.24, 0.36, 0.47, 1.0), + ]; + + match kind { + CaptureFrameBackgroundKind::SystemWallpaper => CaptureFrameBackgroundPlan { + colors: AURORA, + locations: LOCATIONS, + prefers_wallpaper: true, + wallpaper_overlay_alpha: 0.10, + }, + CaptureFrameBackgroundKind::Aurora => CaptureFrameBackgroundPlan { + colors: AURORA, + locations: LOCATIONS, + prefers_wallpaper: false, + wallpaper_overlay_alpha: 0.0, + }, + CaptureFrameBackgroundKind::Graphite => CaptureFrameBackgroundPlan { + colors: GRAPHITE, + locations: LOCATIONS, + prefers_wallpaper: false, + wallpaper_overlay_alpha: 0.0, + }, + CaptureFrameBackgroundKind::Linen => CaptureFrameBackgroundPlan { + colors: LINEN, + locations: LOCATIONS, + prefers_wallpaper: false, + wallpaper_overlay_alpha: 0.0, + }, + } +} + +/// Resolves whether a platform wallpaper thumbnail should be requested for a destination. +#[must_use] +pub fn capture_frame_wallpaper_request_plan( + kind: CaptureFrameBackgroundKind, + destination_width: f64, + destination_height: f64, +) -> Option { + if !destination_width.is_finite() + || !destination_height.is_finite() + || destination_width <= 0.0 + || destination_height <= 0.0 + { + return None; + } + + let background = capture_frame_background_plan(kind); + + if !background.prefers_wallpaper { + return None; + } + + let target_pixel_size = + destination_width.max(destination_height).ceil().clamp(1.0, f64::from(u32::MAX)) as u32; + + Some(CaptureFrameWallpaperRequest { + target_pixel_size, + overlay_alpha: background.wallpaper_overlay_alpha, + }) +} + +/// Renders the complete capture-frame effect into a new RGBA export image. +/// +/// Platform hosts provide raw capture pixels and, when available, a pre-decoded wallpaper +/// thumbnail. Rust owns the durable product behavior: canvas geometry, background selection, +/// wallpaper aspect-fill, overlay, shadows, rounded clipping, and final RGBA composition. +pub fn render_capture_frame_effect( + source: CaptureFrameRenderImageRef<'_>, + background_kind: CaptureFrameBackgroundKind, + screen_scale_factor: f64, + source_kind: CaptureFrameSourceKind, + render_kind: CaptureFrameRenderKind, + wallpaper: Option>, +) -> Result> { + let Some(plan) = + capture_frame_plan(source.width(), source.height(), screen_scale_factor, source_kind) + else { + return Ok(None); + }; + let canvas_width = finite_canvas_dimension(plan.canvas_width)?; + let canvas_height = finite_canvas_dimension(plan.canvas_height)?; + let canvas_len = expected_rgba_len(canvas_width, canvas_height)?; + let mut canvas = vec![0_u8; canvas_len]; + + draw_capture_frame_background( + &mut canvas, + canvas_width, + canvas_height, + background_kind, + wallpaper, + )?; + + match render_kind { + CaptureFrameRenderKind::FramedCapture => { + for shadow in plan.shadows { + draw_soft_rounded_shadow( + &mut canvas, + canvas_width, + canvas_height, + plan.image_rect, + plan.corner_radius, + shadow, + ); + } + + draw_capture_source( + &mut canvas, + canvas_width, + canvas_height, + source, + plan.image_rect, + Some(plan.corner_radius), + )?; + }, + CaptureFrameRenderKind::WindowSnapshot => { + draw_capture_source( + &mut canvas, + canvas_width, + canvas_height, + source, + plan.image_rect, + None, + )?; + }, + } + + RgbaExportImage::from_raw(canvas_width, canvas_height, canvas).map(Some) +} + +fn capture_frame_padding(image_width: f64, image_height: f64) -> f64 { + let short_side = image_width.min(image_height); + let long_side = image_width.max(image_height); + let visual_padding = short_side * 0.115; + let maximum_padding = 72.0_f64.max(long_side * 0.18); + + visual_padding.clamp(48.0, maximum_padding) +} + +fn capture_frame_corner_radius( + image_width: f64, + image_height: f64, + screen_scale_factor: f64, + source: CaptureFrameSourceKind, +) -> f64 { + let short_side = image_width.min(image_height); + + match source { + CaptureFrameSourceKind::Window => { + let scale_factor = if screen_scale_factor.is_finite() && screen_scale_factor > 0.0 { + screen_scale_factor + } else { + 2.0 + }; + + (20.0 * scale_factor).max(24.0).min(short_side * 0.055) + }, + CaptureFrameSourceKind::DragRegion => 24.0_f64.min(8.0_f64.max(short_side * 0.025)), + CaptureFrameSourceKind::FullScreen + | CaptureFrameSourceKind::ScrollCapture + | CaptureFrameSourceKind::Unknown => 28.0_f64.min(8.0_f64.max(short_side * 0.025)), + } +} + +fn capture_frame_shadows(canvas_width: f64, canvas_height: f64) -> [CaptureFrameShadow; 3] { + let short_side = canvas_width.min(canvas_height); + + [ + CaptureFrameShadow::new(0.0, 0.0, 80.0_f64.max(short_side * 0.085), 0.30), + CaptureFrameShadow::new( + 0.0, + -22.0_f64.max(canvas_height * 0.030), + 46.0_f64.max(short_side * 0.050), + 0.36, + ), + CaptureFrameShadow::new( + 0.0, + -4.0_f64.max(canvas_height * 0.006), + 10.0_f64.max(short_side * 0.014), + 0.22, + ), + ] +} + +fn finite_canvas_dimension(value: f64) -> Result { + if !value.is_finite() || value <= 0.0 || value > f64::from(u32::MAX) { + return Err(eyre::eyre!("capture-frame canvas dimension is invalid: {value}")); + } + + Ok(value.ceil() as u32) +} + +fn draw_capture_frame_background( + canvas: &mut [u8], + canvas_width: u32, + canvas_height: u32, + background_kind: CaptureFrameBackgroundKind, + wallpaper: Option>, +) -> Result<()> { + let background = capture_frame_background_plan(background_kind); + + if background.prefers_wallpaper + && let Some(wallpaper) = wallpaper + { + draw_wallpaper_background( + canvas, + canvas_width, + canvas_height, + wallpaper, + background.wallpaper_overlay_alpha, + )?; + + return Ok(()); + } + + draw_gradient_background(canvas, canvas_width, canvas_height, background); + + Ok(()) +} + +fn draw_gradient_background( + canvas: &mut [u8], + canvas_width: u32, + canvas_height: u32, + background: CaptureFrameBackgroundPlan, +) { + let width = usize::try_from(canvas_width).unwrap_or(0); + let height = usize::try_from(canvas_height).unwrap_or(0); + + if width == 0 || height == 0 { + return; + } + + let dx = f64::from(canvas_width); + let dy = -f64::from(canvas_height); + let length_squared = (dx * dx + dy * dy).max(f64::MIN_POSITIVE); + + for y in 0..height { + for x in 0..width { + let px = x as f64 + 0.5; + let py = y as f64 + 0.5; + let projection = (px * dx + (py - f64::from(canvas_height)) * dy) / length_squared; + let color = gradient_color_at(background, projection.clamp(0.0, 1.0)); + let index = (y * width + x) * 4; + + canvas[index] = color[0]; + canvas[index + 1] = color[1]; + canvas[index + 2] = color[2]; + canvas[index + 3] = color[3]; + } + } +} + +fn gradient_color_at(background: CaptureFrameBackgroundPlan, location: f64) -> [u8; 4] { + let segment = if location <= background.locations[1] { 0 } else { 1 }; + let start_location = background.locations[segment]; + let end_location = background.locations[segment + 1]; + let span = (end_location - start_location).max(f64::MIN_POSITIVE); + let t = ((location - start_location) / span).clamp(0.0, 1.0); + let start = background.colors[segment]; + let end = background.colors[segment + 1]; + + [ + unit_to_u8(lerp(start.red, end.red, t)), + unit_to_u8(lerp(start.green, end.green, t)), + unit_to_u8(lerp(start.blue, end.blue, t)), + unit_to_u8(lerp(start.alpha, end.alpha, t)), + ] +} + +fn draw_wallpaper_background( + canvas: &mut [u8], + canvas_width: u32, + canvas_height: u32, + wallpaper: CaptureFrameRenderImageRef<'_>, + overlay_alpha: f64, +) -> Result<()> { + let crop = capture_frame_aspect_fill_crop_rect( + wallpaper.width(), + wallpaper.height(), + f64::from(canvas_width), + f64::from(canvas_height), + ) + .ok_or_else(|| eyre::eyre!("capture-frame wallpaper crop is invalid"))?; + let (crop_x, crop_y, crop_width, crop_height) = + integral_crop_rect(crop, wallpaper.width(), wallpaper.height()) + .ok_or_else(|| eyre::eyre!("capture-frame wallpaper crop is empty"))?; + let cropped = crop_rgba_to_vec(wallpaper, crop_x, crop_y, crop_width, crop_height)?; + let fitted = resize_rgba_exact(crop_width, crop_height, &cropped, canvas_width, canvas_height) + .wrap_err("failed to resize capture-frame wallpaper background")?; + + canvas.copy_from_slice(&fitted); + + apply_black_overlay(canvas, overlay_alpha); + + Ok(()) +} + +fn apply_black_overlay(canvas: &mut [u8], alpha: f64) { + let alpha = alpha.clamp(0.0, 1.0) as f32; + + if alpha <= 0.0 { + return; + } + + for pixel in canvas.chunks_exact_mut(4) { + blend_black_alpha(pixel, alpha); + } +} + +fn draw_capture_source( + canvas: &mut [u8], + canvas_width: u32, + canvas_height: u32, + source: CaptureFrameRenderImageRef<'_>, + destination: DisplayPointRect, + clip_radius: Option, +) -> Result<()> { + let Some((destination_x, destination_y, destination_width, destination_height)) = + destination_pixel_rect(destination, canvas_width, canvas_height) + else { + return Ok(()); + }; + let resized_source; + let source_rgba = + if source.width() == destination_width && source.height() == destination_height { + source.rgba() + } else { + resized_source = resize_rgba_exact( + source.width(), + source.height(), + source.rgba(), + destination_width, + destination_height, + ) + .wrap_err("failed to resize capture source into capture frame")?; + + &resized_source + }; + let canvas_width_usize = + usize::try_from(canvas_width).wrap_err("failed to convert canvas width")?; + let destination_width_usize = + usize::try_from(destination_width).wrap_err("failed to convert destination width")?; + let destination_height_usize = + usize::try_from(destination_height).wrap_err("failed to convert destination height")?; + let destination_x_usize = + usize::try_from(destination_x).wrap_err("failed to convert destination x")?; + let destination_y_usize = + usize::try_from(destination_y).wrap_err("failed to convert destination y")?; + + for y in 0..destination_height_usize { + for x in 0..destination_width_usize { + let source_index = (y * destination_width_usize + x) * 4; + let canvas_x = destination_x_usize + x; + let canvas_y = destination_y_usize + y; + let canvas_index = (canvas_y * canvas_width_usize + canvas_x) * 4; + let coverage = clip_radius.map_or(1.0, |radius| { + rounded_rect_coverage( + canvas_x as f64 + 0.5, + canvas_y as f64 + 0.5, + destination, + radius, + ) + }); + + if coverage > 0.0 { + let source_alpha = (f32::from(source_rgba[source_index + 3]) / 255.0) * coverage; + + blend_rgba_pixel( + &mut canvas[canvas_index..canvas_index + 4], + &source_rgba[source_index..source_index + 4], + source_alpha, + ); + } + } + } + + Ok(()) +} + +fn draw_soft_rounded_shadow( + canvas: &mut [u8], + canvas_width: u32, + canvas_height: u32, + image_rect: DisplayPointRect, + corner_radius: f64, + shadow: CaptureFrameShadow, +) { + if !shadow.blur.is_finite() + || !shadow.alpha.is_finite() + || shadow.blur <= 0.0 + || shadow.alpha <= 0.0 + { + return; + } + + let shadow_rect = DisplayPointRect::new( + image_rect.x + shadow.offset_x, + image_rect.y + shadow.offset_y, + image_rect.width, + image_rect.height, + ); + let blur = shadow.blur.max(1.0); + let influence = blur * 2.0 + 2.0; + let min_x = ((shadow_rect.x - influence).floor().max(0.0)) as u32; + let min_y = ((shadow_rect.y - influence).floor().max(0.0)) as u32; + let max_x = ((shadow_rect.x + shadow_rect.width + influence) + .ceil() + .min(f64::from(canvas_width))) as u32; + let max_y = ((shadow_rect.y + shadow_rect.height + influence) + .ceil() + .min(f64::from(canvas_height))) as u32; + let canvas_width_usize = canvas_width as usize; + + for y in min_y..max_y { + for x in min_x..max_x { + let distance = rounded_rect_signed_distance( + f64::from(x) + 0.5, + f64::from(y) + 0.5, + shadow_rect, + corner_radius, + ) + .max(0.0); + let softness = (1.0 - distance / (blur * 1.6)).clamp(0.0, 1.0); + + if softness <= 0.0 { + continue; + } + + let eased = softness * softness * (3.0 - 2.0 * softness); + let alpha = (shadow.alpha * eased).clamp(0.0, 1.0) as f32; + let index = ((y as usize) * canvas_width_usize + (x as usize)) * 4; + + blend_black_alpha(&mut canvas[index..index + 4], alpha); + } + } +} + +fn destination_pixel_rect( + rect: DisplayPointRect, + canvas_width: u32, + canvas_height: u32, +) -> Option<(u32, u32, u32, u32)> { + if !rect.x.is_finite() + || !rect.y.is_finite() + || !rect.width.is_finite() + || !rect.height.is_finite() + || rect.width <= 0.0 + || rect.height <= 0.0 + { + return None; + } + + let x = rect.x.round().max(0.0).min(f64::from(canvas_width)) as u32; + let y = rect.y.round().max(0.0).min(f64::from(canvas_height)) as u32; + let width = rect.width.round().max(1.0) as u32; + let height = rect.height.round().max(1.0) as u32; + let width = width.min(canvas_width.checked_sub(x)?); + let height = height.min(canvas_height.checked_sub(y)?); + + (width > 0 && height > 0).then_some((x, y, width, height)) +} + +fn integral_crop_rect( + rect: DisplayPointRect, + image_width: u32, + image_height: u32, +) -> Option<(u32, u32, u32, u32)> { + if !rect.x.is_finite() + || !rect.y.is_finite() + || !rect.width.is_finite() + || !rect.height.is_finite() + || rect.width <= 0.0 + || rect.height <= 0.0 + { + return None; + } + + let x = rect.x.floor().max(0.0).min(f64::from(image_width)) as u32; + let y = rect.y.floor().max(0.0).min(f64::from(image_height)) as u32; + let right = (rect.x + rect.width).ceil().max(0.0).min(f64::from(image_width)) as u32; + let bottom = (rect.y + rect.height).ceil().max(0.0).min(f64::from(image_height)) as u32; + let width = right.checked_sub(x)?; + let height = bottom.checked_sub(y)?; + + (width > 0 && height > 0).then_some((x, y, width, height)) +} + +fn crop_rgba_to_vec( + image: CaptureFrameRenderImageRef<'_>, + x: u32, + y: u32, + width: u32, + height: u32, +) -> Result> { + let source_width = usize::try_from(image.width()).wrap_err("failed to convert source width")?; + let x = usize::try_from(x).wrap_err("failed to convert crop x")?; + let y = usize::try_from(y).wrap_err("failed to convert crop y")?; + let width = usize::try_from(width).wrap_err("failed to convert crop width")?; + let height = usize::try_from(height).wrap_err("failed to convert crop height")?; + let mut cropped = vec![0_u8; width * height * 4]; + + for row in 0..height { + let source_start = ((y + row) * source_width + x) * 4; + let source_end = source_start + width * 4; + let destination_start = row * width * 4; + + cropped[destination_start..destination_start + width * 4] + .copy_from_slice(&image.rgba()[source_start..source_end]); + } + + Ok(cropped) +} + +fn resize_rgba_exact( + source_width: u32, + source_height: u32, + source_rgba: &[u8], + destination_width: u32, + destination_height: u32, +) -> Result> { + let expected = expected_rgba_len(source_width, source_height)?; + + if source_rgba.len() != expected { + return Err(eyre::eyre!( + "capture-frame resize byte length mismatch: expected {expected}, got {}", + source_rgba.len() + )); + } + if source_width == destination_width && source_height == destination_height { + return Ok(source_rgba.to_vec()); + } + + let source_ref = ImageRef::new(source_width, source_height, source_rgba, PixelType::U8x4) + .wrap_err("failed to prepare capture-frame source image")?; + let options = ResizeOptions::new().resize_alg(ResizeAlg::Convolution(FilterType::Lanczos3)); + let mut destination_image = Image::new(destination_width, destination_height, PixelType::U8x4); + let mut resizer = Resizer::new(); + + resizer + .resize(&source_ref, &mut destination_image, &options) + .wrap_err("failed to Lanczos-resize capture-frame image")?; + + Ok(destination_image.into_vec()) +} + +fn rounded_rect_coverage(px: f64, py: f64, rect: DisplayPointRect, radius: f64) -> f32 { + (0.5 - rounded_rect_signed_distance(px, py, rect, radius)).clamp(0.0, 1.0) as f32 +} + +fn rounded_rect_signed_distance(px: f64, py: f64, rect: DisplayPointRect, radius: f64) -> f64 { + let half_width = rect.width * 0.5; + let half_height = rect.height * 0.5; + let radius = radius.max(0.0).min(half_width).min(half_height); + let center_x = rect.x + half_width; + let center_y = rect.y + half_height; + let qx = (px - center_x).abs() - (half_width - radius); + let qy = (py - center_y).abs() - (half_height - radius); + let outside_x = qx.max(0.0); + let outside_y = qy.max(0.0); + let outside = (outside_x * outside_x + outside_y * outside_y).sqrt(); + let inside = qx.max(qy).min(0.0); + + outside + inside - radius +} + +fn blend_rgba_pixel(destination: &mut [u8], source: &[u8], alpha: f32) { + let alpha = alpha.clamp(0.0, 1.0); + + if alpha <= 0.0 { + return; + } + + let inverse = 1.0 - alpha; + + destination[0] = blend_channel(source[0], destination[0], alpha, inverse); + destination[1] = blend_channel(source[1], destination[1], alpha, inverse); + destination[2] = blend_channel(source[2], destination[2], alpha, inverse); + destination[3] = 255; +} + +fn blend_black_alpha(destination: &mut [u8], alpha: f32) { + let inverse = 1.0 - alpha.clamp(0.0, 1.0); + + destination[0] = (f32::from(destination[0]) * inverse).round().clamp(0.0, 255.0) as u8; + destination[1] = (f32::from(destination[1]) * inverse).round().clamp(0.0, 255.0) as u8; + destination[2] = (f32::from(destination[2]) * inverse).round().clamp(0.0, 255.0) as u8; + destination[3] = 255; +} + +fn blend_channel(source: u8, destination: u8, alpha: f32, inverse: f32) -> u8 { + (f32::from(source) * alpha + f32::from(destination) * inverse).round().clamp(0.0, 255.0) as u8 +} + +fn lerp(start: f64, end: f64, t: f64) -> f64 { + start + (end - start) * t +} + +fn unit_to_u8(value: f64) -> u8 { + (value.clamp(0.0, 1.0) * 255.0).round() as u8 +} + +fn expected_rgba_len(width: u32, height: u32) -> Result { + if width == 0 || height == 0 { + return Err(eyre::eyre!( + "capture-frame RGBA dimensions must be non-zero: width={width}, height={height}" + )); + } + + (width as usize) + .checked_mul(height as usize) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or_else(|| eyre::eyre!("capture-frame RGBA byte length overflow")) +} + +#[cfg(test)] +mod tests { + use crate::DisplayPointRect; + use crate::capture_frame::{ + self, CaptureFrameBackgroundKind, CaptureFrameColorStop, CaptureFrameRenderImageRef, + CaptureFrameRenderKind, CaptureFrameShadow, CaptureFrameSourceKind, + }; + + #[test] + fn capture_frame_plan_matches_native_window_geometry() { + let plan = capture_frame::capture_frame_plan(320, 180, 2.0, CaptureFrameSourceKind::Window) + .expect("valid plan"); + + assert_eq!(plan.canvas_width, 416.0); + assert_eq!(plan.canvas_height, 276.0); + assert_eq!(plan.image_rect, DisplayPointRect::new(48.0, 48.0, 320.0, 180.0)); + assert_eq!(plan.corner_radius, 9.9); + assert_eq!( + plan.shadows, + [ + CaptureFrameShadow::new(0.0, 0.0, 80.0, 0.30), + CaptureFrameShadow::new(0.0, -22.0, 46.0, 0.36), + CaptureFrameShadow::new(0.0, -4.0, 10.0, 0.22), + ] + ); + } + + #[test] + fn capture_frame_plan_scales_large_shadow_geometry() { + let plan = + capture_frame::capture_frame_plan(1_440, 900, 2.0, CaptureFrameSourceKind::DragRegion) + .expect("valid plan"); + + assert_eq!(plan.canvas_width, 1_647.0); + assert_eq!(plan.canvas_height, 1_107.0); + assert_eq!(plan.image_rect, DisplayPointRect::new(103.5, 103.5, 1_440.0, 900.0)); + assert_eq!(plan.corner_radius, 22.5); + + assert_shadow_near(plan.shadows[0], CaptureFrameShadow::new(0.0, 0.0, 94.095, 0.30)); + assert_shadow_near(plan.shadows[1], CaptureFrameShadow::new(0.0, -33.21, 55.35, 0.36)); + assert_shadow_near(plan.shadows[2], CaptureFrameShadow::new(0.0, -6.642, 15.498, 0.22)); + } + + #[test] + fn capture_frame_plan_rejects_empty_input() { + assert!( + capture_frame::capture_frame_plan(0, 180, 2.0, CaptureFrameSourceKind::Window) + .is_none() + ); + assert!( + capture_frame::capture_frame_plan(320, 0, 2.0, CaptureFrameSourceKind::Window) + .is_none() + ); + } + + #[test] + fn capture_frame_aspect_fill_crop_matches_native_wide_source() { + let rect = capture_frame::capture_frame_aspect_fill_crop_rect(1_600, 900, 1_000.0, 1_000.0) + .expect("valid crop rect"); + + assert_eq!(rect, DisplayPointRect::new(350.0, 0.0, 900.0, 900.0)); + } + + #[test] + fn capture_frame_aspect_fill_crop_matches_native_tall_source() { + let rect = capture_frame::capture_frame_aspect_fill_crop_rect(800, 1_200, 1_600.0, 900.0) + .expect("valid crop rect"); + + assert_eq!(rect, DisplayPointRect::new(0.0, 375.0, 800.0, 450.0)); + } + + #[test] + fn capture_frame_background_plan_matches_native_wallpaper_fallback() { + let plan = capture_frame::capture_frame_background_plan( + CaptureFrameBackgroundKind::SystemWallpaper, + ); + + assert!(plan.prefers_wallpaper); + assert_eq!(plan.wallpaper_overlay_alpha, 0.10); + assert_eq!(plan.locations, [0.0, 0.54, 1.0]); + assert_eq!( + plan.colors, + [ + CaptureFrameColorStop::new(0.10, 0.16, 0.28, 1.0), + CaptureFrameColorStop::new(0.30, 0.47, 0.71, 1.0), + CaptureFrameColorStop::new(0.95, 0.61, 0.43, 1.0), + ] + ); + } + + #[test] + fn capture_frame_background_plan_matches_native_linen_gradient() { + let plan = capture_frame::capture_frame_background_plan(CaptureFrameBackgroundKind::Linen); + + assert!(!plan.prefers_wallpaper); + assert_eq!(plan.wallpaper_overlay_alpha, 0.0); + assert_eq!(plan.locations, [0.0, 0.54, 1.0]); + assert_eq!( + plan.colors, + [ + CaptureFrameColorStop::new(0.83, 0.87, 0.82, 1.0), + CaptureFrameColorStop::new(0.58, 0.70, 0.71, 1.0), + CaptureFrameColorStop::new(0.24, 0.36, 0.47, 1.0), + ] + ); + } + + #[test] + fn capture_frame_wallpaper_request_plan_matches_native_thumbnail_policy() { + let request = capture_frame::capture_frame_wallpaper_request_plan( + CaptureFrameBackgroundKind::SystemWallpaper, + 1_535.2, + 996.0, + ) + .expect("wallpaper request"); + + assert_eq!(request.target_pixel_size, 1_536); + assert_eq!(request.overlay_alpha, 0.10); + } + + #[test] + fn capture_frame_wallpaper_request_plan_skips_non_wallpaper_backgrounds() { + assert_eq!( + capture_frame::capture_frame_wallpaper_request_plan( + CaptureFrameBackgroundKind::Aurora, + 1_536.0, + 996.0 + ), + None + ); + } + + #[test] + fn capture_frame_wallpaper_request_plan_rejects_empty_destination() { + assert_eq!( + capture_frame::capture_frame_wallpaper_request_plan( + CaptureFrameBackgroundKind::SystemWallpaper, + 0.0, + 996.0 + ), + None + ); + } + + #[test] + fn capture_frame_renderer_expands_canvas_and_draws_source_pixels() { + let source_rgba = vec![ + 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 0, 255, + 0, 255, 255, 255, 255, 0, 255, 255, 20, 30, 40, 255, + ]; + let source = CaptureFrameRenderImageRef::new(4, 2, &source_rgba) + .expect("source fixture should be valid"); + let rendered = capture_frame::render_capture_frame_effect( + source, + CaptureFrameBackgroundKind::Aurora, + 2.0, + CaptureFrameSourceKind::DragRegion, + CaptureFrameRenderKind::WindowSnapshot, + None, + ) + .expect("render should succeed") + .expect("render should produce an image"); + + assert_eq!(rendered.width(), 100); + assert_eq!(rendered.height(), 98); + + let first_source_pixel = ((48 * rendered.width() as usize) + 48) * 4; + + assert_eq!( + &rendered.as_raw()[first_source_pixel..first_source_pixel + 4], + &[255, 0, 0, 255] + ); + } + + #[test] + fn capture_frame_renderer_uses_wallpaper_thumbnail_when_available() { + let source_rgba = vec![255; 2 * 2 * 4]; + let wallpaper_rgba = [64, 128, 255, 255].repeat(8 * 8); + let source = CaptureFrameRenderImageRef::new(2, 2, &source_rgba) + .expect("source fixture should be valid"); + let wallpaper = CaptureFrameRenderImageRef::new(8, 8, &wallpaper_rgba) + .expect("wallpaper fixture should be valid"); + let rendered = capture_frame::render_capture_frame_effect( + source, + CaptureFrameBackgroundKind::SystemWallpaper, + 2.0, + CaptureFrameSourceKind::Window, + CaptureFrameRenderKind::WindowSnapshot, + Some(wallpaper), + ) + .expect("render should succeed") + .expect("render should produce an image"); + + assert_eq!(&rendered.as_raw()[0..4], &[58, 115, 230, 255]); + } + + #[test] + fn capture_frame_renderer_rejects_invalid_source_bytes() { + let error = CaptureFrameRenderImageRef::new(2, 2, &[0; 15]) + .expect_err("invalid source length should fail") + .to_string(); + + assert!(error.contains("byte length mismatch")); + } + + fn assert_shadow_near(actual: CaptureFrameShadow, expected: CaptureFrameShadow) { + const TOLERANCE: f64 = 0.000_001; + + assert!((actual.offset_x - expected.offset_x).abs() <= TOLERANCE); + assert!((actual.offset_y - expected.offset_y).abs() <= TOLERANCE); + assert!((actual.blur - expected.blur).abs() <= TOLERANCE); + assert!((actual.alpha - expected.alpha).abs() <= TOLERANCE); + } +} diff --git a/packages/rsnap-capture-core/src/export.rs b/packages/rsnap-capture-core/src/export.rs new file mode 100644 index 00000000..be73dbec --- /dev/null +++ b/packages/rsnap-capture-core/src/export.rs @@ -0,0 +1,382 @@ +//! Lossless export-image primitives owned by the Rust product core. + +use color_eyre::eyre::{self, Result, WrapErr}; +use image::codecs::png::{CompressionType, FilterType, PngEncoder}; +use image::{ExtendedColorType, ImageEncoder, RgbaImage, imageops}; + +use crate::RectPoints; + +/// Rectangle in display point space used for export geometry decisions. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct DisplayPointRect { + /// Left coordinate in display points. + pub x: f64, + /// Top coordinate in display points. + pub y: f64, + /// Rectangle width in display points. + pub width: f64, + /// Rectangle height in display points. + pub height: f64, +} +impl DisplayPointRect { + /// Creates a display-space rectangle. + #[must_use] + pub const fn new(x: f64, y: f64, width: f64, height: f64) -> Self { + Self { x, y, width, height } + } + + fn max_y(self) -> f64 { + self.y + self.height + } + + fn is_valid(self) -> bool { + self.x.is_finite() + && self.y.is_finite() + && self.width.is_finite() + && self.height.is_finite() + && self.width > 0.0 + && self.height > 0.0 + } +} + +/// RGBA export image prepared by the product core. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RgbaExportImage { + image: RgbaImage, +} +impl RgbaExportImage { + /// Wraps an existing RGBA image as a product-core export image. + #[must_use] + pub fn from_image(image: RgbaImage) -> Self { + Self { image } + } + + /// Creates a product-core export image from raw RGBA bytes. + /// + /// The byte payload must be exactly `width * height * 4` bytes and dimensions + /// must be non-zero. + pub fn from_raw(width: u32, height: u32, rgba: Vec) -> Result { + let expected = expected_rgba_len(width, height)?; + let actual = rgba.len(); + + if actual != expected { + return Err(eyre::eyre!( + "RGBA export image byte length mismatch: expected {expected}, got {actual}" + )); + } + + let image = RgbaImage::from_raw(width, height, rgba) + .ok_or_else(|| eyre::eyre!("failed to create RGBA export image from raw bytes"))?; + + Ok(Self { image }) + } + + /// Returns the image width in pixels. + #[must_use] + pub fn width(&self) -> u32 { + self.image.width() + } + + /// Returns the image height in pixels. + #[must_use] + pub fn height(&self) -> u32 { + self.image.height() + } + + /// Returns the underlying RGBA byte buffer. + #[must_use] + pub fn as_raw(&self) -> &[u8] { + self.image.as_raw() + } + + /// Returns the underlying image. + #[must_use] + pub fn as_image(&self) -> &RgbaImage { + &self.image + } + + /// Consumes the wrapper and returns the underlying image. + #[must_use] + pub fn into_image(self) -> RgbaImage { + self.image + } + + /// Returns a pixel-exact crop of this export image. + #[must_use] + pub fn crop(&self, rect: RectPoints) -> Option { + crop_rgba_image(&self.image, rect).map(Self::from_image) + } + + /// Encodes this export image as a lossless PNG with the fast export settings. + pub fn to_png_bytes(&self) -> Result> { + encode_png_lossless_fast(&self.image) + } +} + +/// Returns a pixel-exact crop when the requested rectangle lies inside the image. +#[must_use] +pub fn crop_rgba_image(image: &RgbaImage, rect: RectPoints) -> Option { + if rect.is_empty() { + return None; + } + + let max_x = rect.x.checked_add(rect.width)?; + let max_y = rect.y.checked_add(rect.height)?; + + if max_x > image.width() || max_y > image.height() { + return None; + } + + Some(imageops::crop_imm(image, rect.x, rect.y, rect.width, rect.height).to_image()) +} + +/// Crops from a frozen export image, or clones the full export when no crop is requested. +#[must_use] +pub fn crop_export_image( + export_image: &RgbaImage, + crop_rect: Option, +) -> Option { + crop_rect.map_or_else( + || Some(export_image.clone()), + |crop_rect| crop_rgba_image(export_image, crop_rect), + ) +} + +/// Resolves a frozen display selection into an image-local pixel crop rectangle. +/// +/// `display_frame` and `selection` use the same global display point coordinate +/// space. The returned rectangle mirrors CoreGraphics' integral crop semantics: +/// fractional source rectangles are expanded to the smallest containing integer +/// pixel rectangle and then clipped to the display image bounds. +#[must_use] +pub fn frozen_display_crop_rect( + image_width: u32, + image_height: u32, + display_frame: DisplayPointRect, + selection: DisplayPointRect, +) -> Option { + if image_width == 0 || image_height == 0 || !display_frame.is_valid() || !selection.is_valid() { + return None; + } + + let image_width_f64 = f64::from(image_width); + let image_height_f64 = f64::from(image_height); + let left = ((selection.x - display_frame.x) / display_frame.width) * image_width_f64; + let top = + ((display_frame.max_y() - selection.max_y()) / display_frame.height) * image_height_f64; + let width = (selection.width / display_frame.width) * image_width_f64; + let height = (selection.height / display_frame.height) * image_height_f64; + + integral_image_intersection(left, top, width, height, image_width, image_height) +} + +/// Encodes an RGBA export image as lossless PNG using the fast capture-output profile. +/// +/// The encoder uses PNG's uncompressed mode and disables filtering. That keeps +/// the image byte-exact after decoding while avoiding expensive deflate work on +/// the capture hot path. +pub fn encode_png_lossless_fast(image: &RgbaImage) -> Result> { + let raw_len = image.as_raw().len(); + let mut bytes = Vec::new(); + + if raw_len >= 16 * 1_024 * 1_024 { + let extra = (image.height() as usize).saturating_add(1_024); + let _ = bytes.try_reserve_exact(raw_len.saturating_add(extra)); + } + + let encoder = PngEncoder::new_with_quality( + &mut bytes, + CompressionType::Uncompressed, + FilterType::NoFilter, + ); + + encoder + .write_image(image.as_raw(), image.width(), image.height(), ExtendedColorType::Rgba8) + .wrap_err("failed to encode screenshot as PNG")?; + + Ok(bytes) +} + +fn integral_image_intersection( + left: f64, + top: f64, + width: f64, + height: f64, + image_width: u32, + image_height: u32, +) -> Option { + let right = left + width; + let bottom = top + height; + + if !left.is_finite() + || !top.is_finite() + || !right.is_finite() + || !bottom.is_finite() + || width <= 0.0 + || height <= 0.0 + { + return None; + } + + let clipped_left = left.floor().max(0.0); + let clipped_top = top.floor().max(0.0); + let clipped_right = right.ceil().min(f64::from(image_width)); + let clipped_bottom = bottom.ceil().min(f64::from(image_height)); + + if clipped_left >= clipped_right || clipped_top >= clipped_bottom { + return None; + } + + let x = integral_f64_to_u32(clipped_left)?; + let y = integral_f64_to_u32(clipped_top)?; + let max_x = integral_f64_to_u32(clipped_right)?; + let max_y = integral_f64_to_u32(clipped_bottom)?; + let rect = RectPoints::new(x, y, max_x.checked_sub(x)?, max_y.checked_sub(y)?); + + if rect.is_empty() { + return None; + } + + Some(rect) +} + +fn integral_f64_to_u32(value: f64) -> Option { + if !value.is_finite() || value < 0.0 || value > f64::from(u32::MAX) { + return None; + } + + Some(value as u32) +} + +fn expected_rgba_len(width: u32, height: u32) -> Result { + if width == 0 || height == 0 { + return Err(eyre::eyre!( + "RGBA export image dimensions must be non-zero: width={width}, height={height}" + )); + } + + let width = usize::try_from(width).wrap_err("failed to convert RGBA image width")?; + let height = usize::try_from(height).wrap_err("failed to convert RGBA image height")?; + + width.checked_mul(height).and_then(|pixel_count| pixel_count.checked_mul(4)).ok_or_else(|| { + eyre::eyre!("RGBA export image byte length overflow: width={width}, height={height}") + }) +} + +#[cfg(test)] +mod tests { + use image::{Rgba, RgbaImage}; + + use crate::{DisplayPointRect, RectPoints, RgbaExportImage}; + + #[test] + fn raw_export_image_validates_byte_length() { + let error = RgbaExportImage::from_raw(2, 2, vec![0; 15]) + .expect_err("invalid RGBA length should fail") + .to_string(); + + assert!(error.contains("byte length mismatch")); + } + + #[test] + fn raw_export_image_rejects_empty_dimensions() { + let error = RgbaExportImage::from_raw(0, 2, Vec::new()) + .expect_err("empty dimensions should fail") + .to_string(); + + assert!(error.contains("dimensions must be non-zero")); + } + + #[test] + fn crop_rgba_image_copies_exact_pixels() { + let image = RgbaImage::from_fn(4, 4, |x, y| Rgba([x as u8, y as u8, (x + y) as u8, 255])); + let crop = crate::crop_rgba_image(&image, RectPoints::new(1, 1, 2, 2)).expect("valid crop"); + + assert_eq!(crop.dimensions(), (2, 2)); + assert_eq!(crop.get_pixel(0, 0), image.get_pixel(1, 1)); + assert_eq!(crop.get_pixel(1, 1), image.get_pixel(2, 2)); + } + + #[test] + fn crop_rgba_image_rejects_out_of_bounds_rect() { + let image = RgbaImage::new(4, 4); + + assert!(crate::crop_rgba_image(&image, RectPoints::new(3, 3, 2, 2)).is_none()); + } + + #[test] + fn crop_export_image_clones_full_image_without_rect() { + let image = RgbaImage::from_pixel(2, 2, Rgba([1, 2, 3, 255])); + + assert_eq!(crate::crop_export_image(&image, None), Some(image)); + } + + #[test] + fn frozen_display_crop_rect_maps_global_selection_to_image_pixels() { + let crop = crate::frozen_display_crop_rect( + 2_880, + 1_800, + DisplayPointRect::new(0.0, 0.0, 1_440.0, 900.0), + DisplayPointRect::new(100.0, 200.0, 300.0, 150.0), + ); + + assert_eq!(crop, Some(RectPoints::new(200, 1_100, 600, 300))); + } + + #[test] + fn frozen_display_crop_rect_integral_expands_and_clips() { + let crop = crate::frozen_display_crop_rect( + 200, + 200, + DisplayPointRect::new(0.0, 0.0, 100.0, 100.0), + DisplayPointRect::new(-1.2, 10.25, 12.5, 20.25), + ); + + assert_eq!(crop, Some(RectPoints::new(0, 139, 23, 41))); + } + + #[test] + fn frozen_display_crop_rect_rejects_empty_or_outside_selection() { + let display_frame = DisplayPointRect::new(0.0, 0.0, 100.0, 100.0); + + assert_eq!( + crate::frozen_display_crop_rect( + 200, + 200, + display_frame, + DisplayPointRect::new(10.0, 10.0, 0.0, 20.0) + ), + None + ); + assert_eq!( + crate::frozen_display_crop_rect( + 200, + 200, + display_frame, + DisplayPointRect::new(120.0, 10.0, 10.0, 20.0) + ), + None + ); + } + + #[test] + fn encode_png_lossless_fast_writes_png_payload() { + let image = RgbaImage::from_pixel(2, 2, Rgba([1, 2, 3, 255])); + let png = crate::encode_png_lossless_fast(&image).expect("PNG encode should succeed"); + + assert!(png.starts_with(b"\x89PNG\r\n\x1a\n")); + } + + #[test] + fn export_image_wrapper_crops_and_encodes() { + let image = RgbaExportImage::from_image(RgbaImage::from_fn(4, 4, |x, y| { + Rgba([x as u8, y as u8, 0, 255]) + })); + let crop = image.crop(RectPoints::new(1, 1, 2, 2)).expect("valid crop"); + let png = crop.to_png_bytes().expect("PNG encode should succeed"); + + assert_eq!(crop.width(), 2); + assert_eq!(crop.height(), 2); + assert!(png.starts_with(b"\x89PNG\r\n\x1a\n")); + } +} diff --git a/packages/rsnap-capture-core/src/lib.rs b/packages/rsnap-capture-core/src/lib.rs index a22e670a..2b2ec9ca 100644 --- a/packages/rsnap-capture-core/src/lib.rs +++ b/packages/rsnap-capture-core/src/lib.rs @@ -4,20 +4,51 @@ //! the native-host reset. It intentionally contains no window toolkit, AppKit, or //! `winit` ownership. +pub mod auto_center; +pub mod bgra_frame; +pub mod capture_frame; +pub mod export; pub mod geometry; +pub mod minimap; +pub mod mosaic; pub mod protocol; +pub mod selection_transform; pub mod session; +pub mod wallpaper; pub use self::{ + auto_center::{ + AutoCenterImageError, auto_center_margin_balance_shift_points, + detect_auto_center_content_bounds_rgba, + }, + bgra_frame::{BgraFrameView, loupe_patch_rgba_from_bgra_frame, sample_rgb_from_bgra_frame}, + capture_frame::{ + CaptureFrameBackgroundKind, CaptureFrameBackgroundPlan, CaptureFrameColorStop, + CaptureFramePlan, CaptureFrameRenderImageRef, CaptureFrameRenderKind, CaptureFrameShadow, + CaptureFrameSourceKind, CaptureFrameWallpaperRequest, capture_frame_aspect_fill_crop_rect, + capture_frame_background_plan, capture_frame_plan, capture_frame_wallpaper_request_plan, + render_capture_frame_effect, + }, + export::{DisplayPointRect, frozen_display_crop_rect}, + export::{RgbaExportImage, crop_export_image, crop_rgba_image, encode_png_lossless_fast}, geometry::{ GlobalPoint, GlobalRect, MonitorRect, MonitorRectPoints, RectPoints, Rgb, WindowHit, WindowRect, }, + minimap::{ScrollMinimapInput, ScrollMinimapPlan, scroll_minimap_plan}, + mosaic::frozen_mosaic_light_privacy_patch, protocol::{ CaptureMode, CursorIntent, DeferredTextRecognitionOutcome, DeferredTextRecognitionOutcomeKind, DeferredTextRecognitionRequest, HostEffectKind, HostEvent, HostReport, HostRequest, HudModel, OutputNaming, PermissionKind, PlatformTag, PreparedHostEffectRequest, SceneModel, SessionConfig, ToolbarItemKind, ToolbarItemModel, }, + selection_transform::{ + FrozenSelectionTransformInput, FrozenSelectionTransformKind, + frozen_selection_transform_hit_test, frozen_selection_transform_rect, + }, session::CaptureSessionCore, + wallpaper::{ + capture_frame_wallpaper_png_thumbnail, capture_frame_wallpaper_png_thumbnail_cached, + }, }; diff --git a/packages/rsnap-capture-core/src/minimap.rs b/packages/rsnap-capture-core/src/minimap.rs new file mode 100644 index 00000000..4a98e74b --- /dev/null +++ b/packages/rsnap-capture-core/src/minimap.rs @@ -0,0 +1,237 @@ +//! Scroll-capture minimap layout planning owned by the Rust product core. + +use crate::DisplayPointRect; + +/// Inputs used to resolve a scroll-capture minimap layout. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScrollMinimapInput { + /// Frozen selection rect in the host view coordinate space. + pub selection: DisplayPointRect, + /// Stitched export width in pixels. + pub export_width: f64, + /// Stitched export height in pixels. + pub export_height: f64, + /// Host view bounds. + pub bounds: DisplayPointRect, + /// Preferred minimap width. + pub preferred_width: f64, + /// Minimum useful minimap width. + pub minimum_width: f64, + /// Gap between the frozen selection and the minimap. + pub gap: f64, + /// Outer margin inside the host view bounds. + pub margin: f64, + /// Inset applied to the preview image inside the minimap frame. + pub image_inset: f64, + /// Current viewport top in stitched export pixels. + pub viewport_top_pixels: f64, + /// Current viewport height in stitched export pixels. + pub viewport_height_pixels: f64, +} + +/// Planned scroll-capture minimap rectangles. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ScrollMinimapPlan { + /// Outer minimap frame. + pub frame: DisplayPointRect, + /// Preview image frame inside `frame`. + pub image_frame: DisplayPointRect, + /// Viewport marker inside `image_frame`, when visible. + pub viewport_frame: Option, +} + +/// Resolves the scroll-capture minimap frame, image frame, and viewport marker. +#[must_use] +pub fn scroll_minimap_plan(input: ScrollMinimapInput) -> Option { + if !minimap_input_is_valid(input) { + return None; + } + + let right_space = max_x(input.bounds) - max_x(input.selection) - input.gap - input.margin; + let left_space = input.selection.x - input.bounds.x - input.gap - input.margin; + let (use_right, side_space) = if right_space >= input.minimum_width { + (true, right_space) + } else if left_space >= input.minimum_width { + (false, left_space) + } else { + (right_space >= left_space, right_space.max(left_space)) + }; + let max_height = input.bounds.height - input.margin * 2.0; + let aspect_height_per_width = input.export_height / input.export_width; + let height_limited_width = max_height / aspect_height_per_width.max(f64::MIN_POSITIVE); + let width = input.preferred_width.min(side_space).min(height_limited_width); + + if width < input.minimum_width.min(input.preferred_width) * 0.55 { + return None; + } + + let height = width * aspect_height_per_width; + let max_y = input.margin.max(max_y(input.bounds) - input.margin - height); + let y = (mid_y(input.selection) - height / 2.0).clamp(input.margin, max_y); + let x = if use_right { + max_x(input.selection) + input.gap + } else { + input.selection.x - input.gap - width + }; + let frame = DisplayPointRect::new(x, y, width, height); + let image_frame = inset_rect(frame, input.image_inset); + + if !rect_is_valid(image_frame) { + return None; + } + + let viewport_frame = scroll_minimap_viewport_frame( + image_frame, + input.export_height, + input.viewport_top_pixels, + input.viewport_height_pixels, + ); + + Some(ScrollMinimapPlan { frame, image_frame, viewport_frame }) +} + +fn minimap_input_is_valid(input: ScrollMinimapInput) -> bool { + rect_is_valid(input.selection) + && rect_is_valid(input.bounds) + && input.export_width.is_finite() + && input.export_width > 0.0 + && input.export_height.is_finite() + && input.export_height > 0.0 + && finite_positive(input.preferred_width) + && finite_positive(input.minimum_width) + && finite_nonnegative(input.gap) + && finite_nonnegative(input.margin) + && finite_nonnegative(input.image_inset) + && input.viewport_top_pixels.is_finite() + && input.viewport_height_pixels.is_finite() + && input.bounds.width > input.margin * 2.0 + && input.bounds.height > input.margin * 2.0 +} + +fn scroll_minimap_viewport_frame( + frame: DisplayPointRect, + export_height: f64, + viewport_top_pixels: f64, + viewport_height_pixels: f64, +) -> Option { + if !rect_is_valid(frame) { + return None; + } + + let export_height = export_height.max(1.0); + let viewport_height = viewport_height_pixels.clamp(1.0, export_height); + let max_top = (export_height - viewport_height).max(0.0); + let viewport_top = viewport_top_pixels.clamp(0.0, max_top); + let marker_height = 2.0_f64.max(frame.height * viewport_height / export_height); + let marker_y = max_y(frame) - frame.height * (viewport_top + viewport_height) / export_height; + let marker = DisplayPointRect::new(frame.x, marker_y, frame.width, marker_height); + + intersect_rect(marker, frame) +} + +fn rect_is_valid(rect: DisplayPointRect) -> bool { + rect.x.is_finite() + && rect.y.is_finite() + && rect.width.is_finite() + && rect.height.is_finite() + && rect.width > 0.0 + && rect.height > 0.0 +} + +fn finite_nonnegative(value: f64) -> bool { + value.is_finite() && value >= 0.0 +} + +fn finite_positive(value: f64) -> bool { + value.is_finite() && value > 0.0 +} + +fn inset_rect(rect: DisplayPointRect, inset: f64) -> DisplayPointRect { + DisplayPointRect::new( + rect.x + inset, + rect.y + inset, + rect.width - inset * 2.0, + rect.height - inset * 2.0, + ) +} + +fn intersect_rect(a: DisplayPointRect, b: DisplayPointRect) -> Option { + let min_x = a.x.max(b.x); + let min_y = a.y.max(b.y); + let max_x = max_x(a).min(max_x(b)); + let max_y = max_y(a).min(max_y(b)); + let width = max_x - min_x; + let height = max_y - min_y; + + if width <= 0.0 || height <= 0.0 { + return None; + } + + Some(DisplayPointRect::new(min_x, min_y, width, height)) +} + +fn max_x(rect: DisplayPointRect) -> f64 { + rect.x + rect.width +} + +fn max_y(rect: DisplayPointRect) -> f64 { + rect.y + rect.height +} + +fn mid_y(rect: DisplayPointRect) -> f64 { + rect.y + rect.height / 2.0 +} + +#[cfg(test)] +mod tests { + use crate::DisplayPointRect; + use crate::minimap::{self, ScrollMinimapInput}; + + #[test] + fn scroll_minimap_prefers_right_side_when_space_exists() { + let plan = minimap::scroll_minimap_plan(test_input(DisplayPointRect::new( + 100.0, 100.0, 100.0, 100.0, + ))) + .expect("right-side minimap plan"); + + assert_eq!(plan.frame, DisplayPointRect::new(210.0, 54.0, 96.0, 192.0)); + assert_eq!(plan.image_frame, DisplayPointRect::new(213.0, 57.0, 90.0, 186.0)); + assert_eq!(plan.viewport_frame, Some(DisplayPointRect::new(213.0, 131.4, 90.0, 93.0))); + } + + #[test] + fn scroll_minimap_falls_back_to_left_when_right_side_is_tight() { + let mut input = test_input(DisplayPointRect::new(130.0, 100.0, 100.0, 100.0)); + + input.bounds = DisplayPointRect::new(0.0, 0.0, 250.0, 500.0); + + let plan = minimap::scroll_minimap_plan(input).expect("left-side minimap plan"); + + assert_eq!(plan.frame, DisplayPointRect::new(24.0, 54.0, 96.0, 192.0)); + } + + #[test] + fn scroll_minimap_rejects_tiny_available_space() { + let mut input = test_input(DisplayPointRect::new(100.0, 100.0, 100.0, 100.0)); + + input.bounds = DisplayPointRect::new(0.0, 0.0, 230.0, 60.0); + + assert_eq!(minimap::scroll_minimap_plan(input), None); + } + + fn test_input(selection: DisplayPointRect) -> ScrollMinimapInput { + ScrollMinimapInput { + selection, + export_width: 100.0, + export_height: 200.0, + bounds: DisplayPointRect::new(0.0, 0.0, 500.0, 500.0), + preferred_width: 96.0, + minimum_width: 44.0, + gap: 10.0, + margin: 10.0, + image_inset: 3.0, + viewport_top_pixels: 20.0, + viewport_height_pixels: 100.0, + } + } +} diff --git a/packages/rsnap-capture-core/src/mosaic.rs b/packages/rsnap-capture-core/src/mosaic.rs new file mode 100644 index 00000000..394cca5b --- /dev/null +++ b/packages/rsnap-capture-core/src/mosaic.rs @@ -0,0 +1,175 @@ +//! Frozen mosaic privacy-patch rendering owned by the Rust product core. + +use image::{Rgba, RgbaImage}; + +use crate::export::DisplayPointRect; + +const FROZEN_MOSAIC_BLOCK_SIZE_PIXELS: f64 = 10.0; + +/// Builds the light privacy mosaic patch used by native frozen-overlay rendering. +#[must_use] +pub fn frozen_mosaic_light_privacy_patch( + image_width: u32, + image_height: u32, + source_rect: DisplayPointRect, +) -> Option { + let crop_rect = integral_image_intersection(source_rect, image_width, image_height)?; + let patch_width = mosaic_patch_axis(crop_rect.width)?; + let patch_height = mosaic_patch_axis(crop_rect.height)?; + let seed_x = (f64::from(crop_rect.x) / FROZEN_MOSAIC_BLOCK_SIZE_PIXELS).floor() as u32; + let seed_y = (f64::from(crop_rect.y) / FROZEN_MOSAIC_BLOCK_SIZE_PIXELS).floor() as u32; + + Some(RgbaImage::from_fn(patch_width, patch_height, |x, y| { + frozen_mosaic_light_privacy_color( + x.saturating_add(seed_x), + y.saturating_add(seed_y), + patch_width, + patch_height, + ) + })) +} + +fn integral_image_intersection( + rect: DisplayPointRect, + image_width: u32, + image_height: u32, +) -> Option { + if image_width == 0 + || image_height == 0 + || !rect.x.is_finite() + || !rect.y.is_finite() + || !rect.width.is_finite() + || !rect.height.is_finite() + || rect.width <= 0.0 + || rect.height <= 0.0 + { + return None; + } + + let right = rect.x + rect.width; + let bottom = rect.y + rect.height; + + if !right.is_finite() || !bottom.is_finite() { + return None; + } + + let clipped_left = rect.x.floor().max(0.0); + let clipped_top = rect.y.floor().max(0.0); + let clipped_right = right.ceil().min(f64::from(image_width)); + let clipped_bottom = bottom.ceil().min(f64::from(image_height)); + + if clipped_left >= clipped_right || clipped_top >= clipped_bottom { + return None; + } + + let x = integral_f64_to_u32(clipped_left)?; + let y = integral_f64_to_u32(clipped_top)?; + let max_x = integral_f64_to_u32(clipped_right)?; + let max_y = integral_f64_to_u32(clipped_bottom)?; + + Some(crate::RectPoints::new(x, y, max_x.checked_sub(x)?, max_y.checked_sub(y)?)) +} + +fn integral_f64_to_u32(value: f64) -> Option { + if !value.is_finite() || value < 0.0 || value > f64::from(u32::MAX) { + return None; + } + + Some(value as u32) +} + +fn mosaic_patch_axis(crop_axis: u32) -> Option { + if crop_axis == 0 { + return None; + } + + Some(((f64::from(crop_axis) / FROZEN_MOSAIC_BLOCK_SIZE_PIXELS).ceil() as u32).max(1)) +} + +fn frozen_mosaic_light_privacy_color(x: u32, y: u32, width: u32, height: u32) -> Rgba { + let hash = frozen_mosaic_hash(x, y, width, height); + let group_hash = frozen_mosaic_hash(x / 2, y / 2, width, height); + let base = 0.74 + f64::from(group_hash & 3) * 0.035; + let variation = (f64::from((hash >> 8) & 3) - 1.5) * 0.012; + let warmth = f64::from((group_hash >> 3) & 1) * 0.012; + + Rgba([ + frozen_mosaic_byte(base + variation + warmth), + frozen_mosaic_byte(base + variation + warmth * 0.5), + frozen_mosaic_byte(base + variation), + 255, + ]) +} + +fn frozen_mosaic_hash(x: u32, y: u32, width: u32, height: u32) -> u32 { + let mut hash = x.wrapping_mul(0x045d_9f3b) + ^ y.wrapping_mul(0x119d_e1f3) + ^ width.wrapping_mul(0x27d4_eb2d) + ^ height.wrapping_mul(0x1656_67b1); + + hash ^= hash >> 16; + hash = hash.wrapping_mul(0x7feb_352d); + hash ^= hash >> 15; + hash = hash.wrapping_mul(0x846c_a68b); + hash ^= hash >> 16; + + hash +} + +fn frozen_mosaic_byte(value: f64) -> u8 { + (value.clamp(0.0, 1.0) * 255.0).round() as u8 +} + +#[cfg(test)] +mod tests { + use crate::mosaic::{self, DisplayPointRect}; + + #[test] + fn mosaic_light_privacy_patch_matches_native_dimensions_and_seeded_colors() { + let patch = mosaic::frozen_mosaic_light_privacy_patch( + 100, + 80, + DisplayPointRect::new(4.2, 9.1, 28.4, 21.0), + ) + .expect("valid patch"); + + assert_eq!(patch.dimensions(), (3, 3)); + assert_eq!(patch.get_pixel(0, 0).0, [211, 211, 211, 255]); + assert_eq!(patch.get_pixel(1, 0).0, [205, 205, 205, 255]); + assert_eq!(patch.get_pixel(2, 0).0, [202, 201, 199, 255]); + assert_eq!(patch.get_pixel(0, 2).0, [220, 220, 220, 255]); + } + + #[test] + fn mosaic_light_privacy_patch_clips_to_image_bounds() { + let patch = mosaic::frozen_mosaic_light_privacy_patch( + 32, + 24, + DisplayPointRect::new(25.5, 18.2, 20.0, 20.0), + ) + .expect("clipped patch"); + + assert_eq!(patch.dimensions(), (1, 1)); + assert_eq!(patch.get_pixel(0, 0).0[3], 255); + } + + #[test] + fn mosaic_light_privacy_patch_rejects_empty_or_outside_rects() { + assert!( + mosaic::frozen_mosaic_light_privacy_patch( + 100, + 80, + DisplayPointRect::new(10.0, 10.0, 0.0, 20.0) + ) + .is_none() + ); + assert!( + mosaic::frozen_mosaic_light_privacy_patch( + 100, + 80, + DisplayPointRect::new(120.0, 10.0, 10.0, 20.0) + ) + .is_none() + ); + } +} diff --git a/packages/rsnap-capture-core/src/protocol.rs b/packages/rsnap-capture-core/src/protocol.rs index 6d9edc18..db892333 100644 --- a/packages/rsnap-capture-core/src/protocol.rs +++ b/packages/rsnap-capture-core/src/protocol.rs @@ -2,10 +2,11 @@ use std::path::PathBuf; -use image::{RgbaImage, imageops}; +use image::RgbaImage; use serde::{Deserialize, Serialize}; use crate::RectPoints; +use crate::export; use crate::geometry::{GlobalPoint, GlobalRect, MonitorRect, Rgb, WindowRect}; /// Supported platform families for the host/core boundary. @@ -301,7 +302,7 @@ impl DeferredTextRecognitionImageSource { match self { Self::Prepared { image } => Some(image.clone()), Self::FrozenCrop { export_image, crop_rect } => { - export_image_from_frozen_crop(export_image, *crop_rect) + export::crop_export_image(export_image, *crop_rect) }, } } @@ -310,7 +311,7 @@ impl DeferredTextRecognitionImageSource { match self { Self::Prepared { image } => Some(image), Self::FrozenCrop { export_image, crop_rect } => { - export_image_from_frozen_crop(&export_image, crop_rect) + export::crop_export_image(&export_image, crop_rect) }, } } @@ -472,31 +473,6 @@ pub struct DeferredTextRecognitionOutcome { pub recognized_text: Option, } -fn export_image_from_frozen_crop( - export_image: &RgbaImage, - crop_rect: Option, -) -> Option { - match crop_rect { - Some(crop_rect) => { - if crop_rect.width == 0 || crop_rect.height == 0 { - return None; - } - - Some( - imageops::crop_imm( - export_image, - crop_rect.x, - crop_rect.y, - crop_rect.width, - crop_rect.height, - ) - .to_image(), - ) - }, - None => Some(export_image.clone()), - } -} - #[cfg(test)] mod tests { use image::RgbaImage; diff --git a/packages/rsnap-capture-core/src/selection_transform.rs b/packages/rsnap-capture-core/src/selection_transform.rs new file mode 100644 index 00000000..7bda7ebf --- /dev/null +++ b/packages/rsnap-capture-core/src/selection_transform.rs @@ -0,0 +1,354 @@ +//! Frozen selection hit-testing and transform geometry. + +use crate::DisplayPointRect; + +/// Frozen selection transform operation selected by pointer hit-testing. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum FrozenSelectionTransformKind { + /// Move the whole selection rectangle. + Move, + /// Resize the left edge. + ResizeLeft, + /// Resize the right edge. + ResizeRight, + /// Resize the top edge. + ResizeTop, + /// Resize the bottom edge. + ResizeBottom, + /// Resize the top-left corner. + ResizeTopLeft, + /// Resize the top-right corner. + ResizeTopRight, + /// Resize the bottom-left corner. + ResizeBottomLeft, + /// Resize the bottom-right corner. + ResizeBottomRight, +} + +/// Input payload for resolving a frozen selection transform. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FrozenSelectionTransformInput { + /// The active transform operation. + pub kind: FrozenSelectionTransformKind, + /// The selection rect when the interaction started. + pub initial_selection: DisplayPointRect, + /// The monitor bounds that constrain the selection. + pub monitor_frame: DisplayPointRect, + /// Pointer x-position when the interaction started. + pub initial_pointer_x: f64, + /// Pointer y-position when the interaction started. + pub initial_pointer_y: f64, + /// Current pointer x-position. + pub point_x: f64, + /// Current pointer y-position. + pub point_y: f64, + /// Minimum allowed selection width and height. + pub minimum_size: f64, +} + +/// Hit-tests a pointer against a frozen selection transform target. +#[must_use] +pub fn frozen_selection_transform_hit_test( + point_x: f64, + point_y: f64, + selection: DisplayPointRect, + handle_radius: f64, + edge_tolerance: f64, +) -> Option { + if !rect_is_valid(selection) + || !point_is_finite(point_x, point_y) + || !handle_radius.is_finite() + || !edge_tolerance.is_finite() + { + return None; + } + + let handle_radius = handle_radius.max(0.0); + let edge_tolerance = edge_tolerance.max(0.0); + let left = selection.x; + let right = max_x(selection); + let top = max_y(selection); + let bottom = selection.y; + + if (point_x - left).abs() <= handle_radius && (point_y - top).abs() <= handle_radius { + return Some(FrozenSelectionTransformKind::ResizeTopLeft); + } + if (point_x - right).abs() <= handle_radius && (point_y - top).abs() <= handle_radius { + return Some(FrozenSelectionTransformKind::ResizeTopRight); + } + if (point_x - left).abs() <= handle_radius && (point_y - bottom).abs() <= handle_radius { + return Some(FrozenSelectionTransformKind::ResizeBottomLeft); + } + if (point_x - right).abs() <= handle_radius && (point_y - bottom).abs() <= handle_radius { + return Some(FrozenSelectionTransformKind::ResizeBottomRight); + } + if point_y >= bottom && point_y <= top && (point_x - left).abs() <= edge_tolerance { + return Some(FrozenSelectionTransformKind::ResizeLeft); + } + if point_y >= bottom && point_y <= top && (point_x - right).abs() <= edge_tolerance { + return Some(FrozenSelectionTransformKind::ResizeRight); + } + if point_x >= left && point_x <= right && (point_y - top).abs() <= edge_tolerance { + return Some(FrozenSelectionTransformKind::ResizeTop); + } + if point_x >= left && point_x <= right && (point_y - bottom).abs() <= edge_tolerance { + return Some(FrozenSelectionTransformKind::ResizeBottom); + } + if point_x >= left && point_x < right && point_y >= bottom && point_y < top { + return Some(FrozenSelectionTransformKind::Move); + } + + None +} + +/// Resolves the transformed frozen selection constrained to the monitor bounds. +#[must_use] +pub fn frozen_selection_transform_rect( + input: FrozenSelectionTransformInput, +) -> Option { + if !rect_is_valid(input.initial_selection) + || !rect_is_valid(input.monitor_frame) + || !point_is_finite(input.initial_pointer_x, input.initial_pointer_y) + || !point_is_finite(input.point_x, input.point_y) + || !input.minimum_size.is_finite() + || input.minimum_size <= 0.0 + { + return None; + } + + let selection = input.initial_selection; + let monitor = input.monitor_frame; + let min_size = input.minimum_size; + let delta_x = input.point_x - input.initial_pointer_x; + let delta_y = input.point_y - input.initial_pointer_y; + + match input.kind { + FrozenSelectionTransformKind::Move => clamped_rect( + selection.width, + selection.height, + selection.x + delta_x, + selection.y + delta_y, + monitor, + ), + FrozenSelectionTransformKind::ResizeLeft => { + let new_min_x = clamp(selection.x + delta_x, monitor.x, max_x(selection) - min_size); + + Some(DisplayPointRect::new( + new_min_x, + selection.y, + max_x(selection) - new_min_x, + selection.height, + )) + }, + FrozenSelectionTransformKind::ResizeRight => { + let new_max_x = + clamp(max_x(selection) + delta_x, selection.x + min_size, max_x(monitor)); + + Some(DisplayPointRect::new( + selection.x, + selection.y, + new_max_x - selection.x, + selection.height, + )) + }, + FrozenSelectionTransformKind::ResizeTop => { + let new_max_y = + clamp(max_y(selection) + delta_y, selection.y + min_size, max_y(monitor)); + + Some(DisplayPointRect::new( + selection.x, + selection.y, + selection.width, + new_max_y - selection.y, + )) + }, + FrozenSelectionTransformKind::ResizeBottom => { + let new_min_y = clamp(selection.y + delta_y, monitor.y, max_y(selection) - min_size); + + Some(DisplayPointRect::new( + selection.x, + new_min_y, + selection.width, + max_y(selection) - new_min_y, + )) + }, + FrozenSelectionTransformKind::ResizeTopLeft => { + let new_min_x = clamp(selection.x + delta_x, monitor.x, max_x(selection) - min_size); + let new_max_y = + clamp(max_y(selection) + delta_y, selection.y + min_size, max_y(monitor)); + + Some(DisplayPointRect::new( + new_min_x, + selection.y, + max_x(selection) - new_min_x, + new_max_y - selection.y, + )) + }, + FrozenSelectionTransformKind::ResizeTopRight => { + let new_max_x = + clamp(max_x(selection) + delta_x, selection.x + min_size, max_x(monitor)); + let new_max_y = + clamp(max_y(selection) + delta_y, selection.y + min_size, max_y(monitor)); + + Some(DisplayPointRect::new( + selection.x, + selection.y, + new_max_x - selection.x, + new_max_y - selection.y, + )) + }, + FrozenSelectionTransformKind::ResizeBottomLeft => { + let new_min_x = clamp(selection.x + delta_x, monitor.x, max_x(selection) - min_size); + let new_min_y = clamp(selection.y + delta_y, monitor.y, max_y(selection) - min_size); + + Some(DisplayPointRect::new( + new_min_x, + new_min_y, + max_x(selection) - new_min_x, + max_y(selection) - new_min_y, + )) + }, + FrozenSelectionTransformKind::ResizeBottomRight => { + let new_max_x = + clamp(max_x(selection) + delta_x, selection.x + min_size, max_x(monitor)); + let new_min_y = clamp(selection.y + delta_y, monitor.y, max_y(selection) - min_size); + + Some(DisplayPointRect::new( + selection.x, + new_min_y, + new_max_x - selection.x, + max_y(selection) - new_min_y, + )) + }, + } +} + +fn clamped_rect( + width: f64, + height: f64, + x: f64, + y: f64, + monitor: DisplayPointRect, +) -> Option { + if width <= 0.0 || height <= 0.0 || !point_is_finite(x, y) { + return None; + } + + let max_rect_x = monitor.x.max(max_x(monitor) - width); + let max_rect_y = monitor.y.max(max_y(monitor) - height); + + Some(DisplayPointRect::new( + clamp(x, monitor.x, max_rect_x), + clamp(y, monitor.y, max_rect_y), + width, + height, + )) +} + +fn rect_is_valid(rect: DisplayPointRect) -> bool { + rect.x.is_finite() + && rect.y.is_finite() + && rect.width.is_finite() + && rect.height.is_finite() + && rect.width > 0.0 + && rect.height > 0.0 +} + +fn point_is_finite(x: f64, y: f64) -> bool { + x.is_finite() && y.is_finite() +} + +fn max_x(rect: DisplayPointRect) -> f64 { + rect.x + rect.width +} + +fn max_y(rect: DisplayPointRect) -> f64 { + rect.y + rect.height +} + +fn clamp(value: f64, min: f64, max: f64) -> f64 { + value.clamp(min.min(max), min.max(max)) +} + +#[cfg(test)] +mod tests { + use crate::DisplayPointRect; + use crate::selection_transform::{ + self, FrozenSelectionTransformInput, FrozenSelectionTransformKind, + }; + + #[test] + fn hit_test_prefers_corner_handles_before_edges() { + let selection = DisplayPointRect::new(100.0, 80.0, 240.0, 160.0); + + assert_eq!( + selection_transform::frozen_selection_transform_hit_test( + 102.0, 238.0, selection, 12.0, 4.0 + ), + Some(FrozenSelectionTransformKind::ResizeTopLeft) + ); + assert_eq!( + selection_transform::frozen_selection_transform_hit_test( + 220.0, 240.0, selection, 12.0, 4.0 + ), + Some(FrozenSelectionTransformKind::ResizeTop) + ); + assert_eq!( + selection_transform::frozen_selection_transform_hit_test( + 180.0, 120.0, selection, 12.0, 4.0 + ), + Some(FrozenSelectionTransformKind::Move) + ); + } + + #[test] + fn transform_move_clamps_to_monitor() { + let rect = + selection_transform::frozen_selection_transform_rect(FrozenSelectionTransformInput { + kind: FrozenSelectionTransformKind::Move, + initial_selection: DisplayPointRect::new(100.0, 80.0, 240.0, 160.0), + monitor_frame: DisplayPointRect::new(0.0, 0.0, 500.0, 400.0), + initial_pointer_x: 150.0, + initial_pointer_y: 120.0, + point_x: -100.0, + point_y: 500.0, + minimum_size: 1.0, + }); + + assert_eq!(rect, Some(DisplayPointRect::new(0.0, 240.0, 240.0, 160.0))); + } + + #[test] + fn transform_resize_bottom_right_preserves_minimum_size() { + let rect = + selection_transform::frozen_selection_transform_rect(FrozenSelectionTransformInput { + kind: FrozenSelectionTransformKind::ResizeBottomRight, + initial_selection: DisplayPointRect::new(100.0, 80.0, 240.0, 160.0), + monitor_frame: DisplayPointRect::new(0.0, 0.0, 500.0, 400.0), + initial_pointer_x: 340.0, + initial_pointer_y: 80.0, + point_x: 50.0, + point_y: 300.0, + minimum_size: 12.0, + }); + + assert_eq!(rect, Some(DisplayPointRect::new(100.0, 228.0, 12.0, 12.0))); + } + + #[test] + fn rejects_invalid_transform_input() { + let rect = + selection_transform::frozen_selection_transform_rect(FrozenSelectionTransformInput { + kind: FrozenSelectionTransformKind::ResizeRight, + initial_selection: DisplayPointRect::new(100.0, 80.0, 240.0, 160.0), + monitor_frame: DisplayPointRect::new(0.0, 0.0, 500.0, 400.0), + initial_pointer_x: 340.0, + initial_pointer_y: 80.0, + point_x: f64::NAN, + point_y: 80.0, + minimum_size: 12.0, + }); + + assert_eq!(rect, None); + } +} diff --git a/packages/rsnap-capture-core/src/wallpaper.rs b/packages/rsnap-capture-core/src/wallpaper.rs new file mode 100644 index 00000000..719b09bc --- /dev/null +++ b/packages/rsnap-capture-core/src/wallpaper.rs @@ -0,0 +1,551 @@ +//! Platform-neutral wallpaper thumbnail decoding for large PNG backgrounds. + +use std::fs::File; +use std::io::BufReader; +use std::path::{Path, PathBuf}; +use std::sync::{Mutex, OnceLock}; +use std::time::UNIX_EPOCH; + +use color_eyre::eyre::{self, Result, WrapErr}; +use fast_image_resize::images::{Image, ImageRef}; +use fast_image_resize::{FilterType, PixelType, ResizeAlg, ResizeOptions, Resizer}; +use png::{BitDepth, ColorType, Decoder, Transformations}; + +use crate::RgbaExportImage; + +const MAX_LANCZOS_INTERMEDIATE_PIXEL_SIZE: u32 = 6_000; +const WALLPAPER_THUMBNAIL_CACHE_CAPACITY: usize = 4; + +static WALLPAPER_THUMBNAIL_CACHE: OnceLock> = OnceLock::new(); + +#[derive(Clone, Copy)] +enum PngRowLayout { + Rgba, + GrayscaleAlpha, +} +impl PngRowLayout { + fn from_output((color_type, bit_depth): (ColorType, BitDepth)) -> Result { + if bit_depth != BitDepth::Eight { + return Err(eyre::eyre!( + "unsupported PNG bit depth after transformations: {bit_depth:?}" + )); + } + + match color_type { + ColorType::Rgba => Ok(Self::Rgba), + ColorType::GrayscaleAlpha => Ok(Self::GrayscaleAlpha), + other => { + Err(eyre::eyre!("unsupported PNG color type after transformations: {other:?}")) + }, + } + } + + fn expected_len(self, width: u32) -> Result { + (width as usize) + .checked_mul(self.bytes_per_pixel()) + .ok_or_else(|| eyre::eyre!("PNG row length overflow")) + } + + fn bytes_per_pixel(self) -> usize { + match self { + Self::Rgba => 4, + Self::GrayscaleAlpha => 2, + } + } + + fn add_weighted_rgba( + self, + row: &[u8], + source_x: usize, + weight: f32, + accumulator: &mut [f32; 4], + ) { + let pixel_index = source_x * self.bytes_per_pixel(); + + match self { + Self::Rgba => { + accumulator[0] += f32::from(row[pixel_index]) * weight; + accumulator[1] += f32::from(row[pixel_index + 1]) * weight; + accumulator[2] += f32::from(row[pixel_index + 2]) * weight; + accumulator[3] += f32::from(row[pixel_index + 3]) * weight; + }, + Self::GrayscaleAlpha => { + let luma = f32::from(row[pixel_index]) * weight; + + accumulator[0] += luma; + accumulator[1] += luma; + accumulator[2] += luma; + accumulator[3] += f32::from(row[pixel_index + 1]) * weight; + }, + } + } +} + +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +struct WallpaperThumbnailCacheKey { + path: PathBuf, + target_pixel_size: u32, + file_size: Option, + modified_nanos: Option, +} +impl WallpaperThumbnailCacheKey { + fn from_path(path: &Path, target_pixel_size: u32) -> Self { + let metadata = path.metadata().ok(); + let modified_nanos = metadata + .as_ref() + .and_then(|metadata| metadata.modified().ok()) + .and_then(|modified| modified.duration_since(UNIX_EPOCH).ok()) + .map(|duration| duration.as_nanos()); + + Self { + path: path.to_path_buf(), + target_pixel_size, + file_size: metadata.map(|metadata| metadata.len()), + modified_nanos, + } + } +} + +#[derive(Debug)] +struct WallpaperThumbnailCache { + capacity: usize, + images: Vec<(WallpaperThumbnailCacheKey, RgbaExportImage)>, +} +impl WallpaperThumbnailCache { + fn new(capacity: usize) -> Self { + Self { capacity: capacity.max(1), images: Vec::new() } + } + + fn image(&mut self, key: &WallpaperThumbnailCacheKey) -> Option { + let index = self.images.iter().position(|(candidate, _)| candidate == key)?; + let (key, image) = self.images.remove(index); + let cloned = image.clone(); + + self.images.push((key, image)); + + Some(cloned) + } + + fn store(&mut self, key: WallpaperThumbnailCacheKey, image: RgbaExportImage) { + if let Some(index) = self.images.iter().position(|(candidate, _)| *candidate == key) { + self.images.remove(index); + } + + self.images.push((key, image)); + + while self.images.len() > self.capacity { + self.images.remove(0); + } + } +} + +/// Decodes a PNG wallpaper and downsamples it into a bounded RGBA thumbnail. +/// +/// The implementation streams source rows instead of materializing the full decoded image. That +/// keeps huge wallpapers from allocating hundreds of megabytes just to draw a background preview. +pub fn capture_frame_wallpaper_png_thumbnail( + path: impl AsRef, + target_pixel_size: u32, +) -> Result> { + let path = path.as_ref(); + + if target_pixel_size == 0 || !is_png_path(path) { + return Ok(None); + } + + let intermediate_target = intermediate_target_pixel_size(target_pixel_size); + let thumbnail = + decode_png_streaming_area_thumbnail(path, intermediate_target).wrap_err_with(|| { + format!("failed to decode PNG wallpaper thumbnail: {}", path.display()) + })?; + let thumbnail = resize_rgba_lanczos_to_fit(thumbnail, target_pixel_size)?; + + Ok(Some(thumbnail)) +} + +/// Decodes a PNG wallpaper thumbnail through the shared Rust cache. +/// +/// Cache invalidation is based on path, target size, file size, and modification time. +pub fn capture_frame_wallpaper_png_thumbnail_cached( + path: impl AsRef, + target_pixel_size: u32, +) -> Result> { + let path = path.as_ref(); + + if target_pixel_size == 0 || !is_png_path(path) { + return Ok(None); + } + + let key = WallpaperThumbnailCacheKey::from_path(path, target_pixel_size); + let cache = WALLPAPER_THUMBNAIL_CACHE.get_or_init(|| { + Mutex::new(WallpaperThumbnailCache::new(WALLPAPER_THUMBNAIL_CACHE_CAPACITY)) + }); + + if let Some(image) = cache + .lock() + .map_err(|_| eyre::eyre!("wallpaper thumbnail cache lock was poisoned"))? + .image(&key) + { + return Ok(Some(image)); + } + + let thumbnail = capture_frame_wallpaper_png_thumbnail(path, target_pixel_size)?; + + if let Some(image) = thumbnail.as_ref() { + cache + .lock() + .map_err(|_| eyre::eyre!("wallpaper thumbnail cache lock was poisoned"))? + .store(key, image.clone()); + } + + Ok(thumbnail) +} + +fn decode_png_streaming_area_thumbnail( + path: &Path, + target_pixel_size: u32, +) -> Result { + let file = File::open(path).wrap_err_with(|| format!("failed to open {}", path.display()))?; + let mut decoder = Decoder::new(BufReader::new(file)); + + decoder.set_transformations(Transformations::ALPHA | Transformations::STRIP_16); + + let mut reader = decoder.read_info().wrap_err("failed to read PNG metadata")?; + let source_width = reader.info().width; + let source_height = reader.info().height; + let row_layout = PngRowLayout::from_output(reader.output_color_type())?; + let Some((destination_width, destination_height)) = + fit_inside(source_width, source_height, target_pixel_size) + else { + return Err(eyre::eyre!("PNG wallpaper has invalid dimensions")); + }; + let expected_row_len = row_layout.expected_len(source_width)?; + let x_contributions = destination_axis_contributions( + source_width as usize, + destination_width as usize, + source_width as f32 / destination_width as f32, + ); + let y_contributions = source_axis_contributions( + source_height as usize, + destination_height as usize, + source_height as f32 / destination_height as f32, + ); + let area = (source_width as f32 / destination_width as f32) + * (source_height as f32 / destination_height as f32); + let accumulator_len = expected_rgba_len(destination_width, destination_height)?; + let mut accumulator = vec![0.0_f32; accumulator_len]; + let destination_width_usize = destination_width as usize; + let mut source_y = 0_usize; + + while let Some(row) = reader.next_row().wrap_err("failed to decode PNG row")? { + let row = row.data(); + + if row.len() != expected_row_len { + return Err(eyre::eyre!( + "unsupported PNG row layout: expected {expected_row_len} RGBA bytes, got {}", + row.len() + )); + } + + let y_weights = &y_contributions[source_y]; + + for (destination_x, x_weights) in x_contributions.iter().enumerate() { + let mut horizontal = [0.0_f32; 4]; + + for &(source_x, x_weight) in x_weights { + row_layout.add_weighted_rgba(row, source_x, x_weight, &mut horizontal); + } + for &(destination_y, y_weight) in y_weights { + let accumulator_index = + (destination_y * destination_width_usize + destination_x) * 4; + + accumulator[accumulator_index] += horizontal[0] * y_weight; + accumulator[accumulator_index + 1] += horizontal[1] * y_weight; + accumulator[accumulator_index + 2] += horizontal[2] * y_weight; + accumulator[accumulator_index + 3] += horizontal[3] * y_weight; + } + } + + source_y += 1; + } + + if source_y != source_height as usize { + return Err(eyre::eyre!("decoded {source_y} PNG rows, expected {source_height}")); + } + + let mut rgba = vec![0_u8; accumulator.len()]; + + for (index, value) in accumulator.into_iter().enumerate() { + rgba[index] = (value / area).round().clamp(0.0, 255.0) as u8; + } + + RgbaExportImage::from_raw(destination_width, destination_height, rgba) +} + +fn resize_rgba_lanczos_to_fit( + image: RgbaExportImage, + target_pixel_size: u32, +) -> Result { + let Some((destination_width, destination_height)) = + fit_inside(image.width(), image.height(), target_pixel_size) + else { + return Err(eyre::eyre!("PNG wallpaper thumbnail has invalid dimensions")); + }; + + if destination_width == image.width() && destination_height == image.height() { + return Ok(image); + } + + let source_ref = ImageRef::new(image.width(), image.height(), image.as_raw(), PixelType::U8x4) + .wrap_err("failed to prepare source wallpaper thumbnail for Lanczos resize")?; + let options = ResizeOptions::new().resize_alg(ResizeAlg::Convolution(FilterType::Lanczos3)); + let mut destination_image = Image::new(destination_width, destination_height, PixelType::U8x4); + let mut resizer = Resizer::new(); + + resizer + .resize(&source_ref, &mut destination_image, &options) + .wrap_err("failed to Lanczos-resize wallpaper thumbnail")?; + + RgbaExportImage::from_raw(destination_width, destination_height, destination_image.into_vec()) +} + +fn is_png_path(path: &Path) -> bool { + path.extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("png")) +} + +fn fit_inside(width: u32, height: u32, target_pixel_size: u32) -> Option<(u32, u32)> { + if width == 0 || height == 0 || target_pixel_size == 0 { + return None; + } + + let max_side = width.max(height); + + if max_side <= target_pixel_size { + return Some((width, height)); + } + + let scale = f64::from(target_pixel_size) / f64::from(max_side); + + Some(( + (f64::from(width) * scale).round().max(1.0) as u32, + (f64::from(height) * scale).round().max(1.0) as u32, + )) +} + +fn destination_axis_contributions( + source_len: usize, + destination_len: usize, + scale: f32, +) -> Vec> { + (0..destination_len) + .map(|destination_index| { + let destination_start = destination_index as f32 * scale; + let destination_end = destination_start + scale; + let source_start = destination_start.floor().max(0.0) as usize; + let source_end = (destination_end.ceil() as usize).min(source_len); + + (source_start..source_end) + .filter_map(|source_index| { + let source_start = source_index as f32; + let source_end = source_start + 1.0; + let overlap = + source_end.min(destination_end) - source_start.max(destination_start); + + (overlap > 0.0).then_some((source_index, overlap)) + }) + .collect() + }) + .collect() +} + +fn source_axis_contributions( + source_len: usize, + destination_len: usize, + scale: f32, +) -> Vec> { + (0..source_len) + .map(|source_index| { + let source_start = source_index as f32; + let source_end = source_start + 1.0; + let destination_start = + ((source_start / scale).floor().max(0.0) as usize).min(destination_len); + let destination_end = ((source_end / scale).ceil() as usize).min(destination_len); + + (destination_start..destination_end) + .filter_map(|destination_index| { + let destination_source_start = destination_index as f32 * scale; + let destination_source_end = destination_source_start + scale; + let overlap = source_end.min(destination_source_end) + - source_start.max(destination_source_start); + + (overlap > 0.0).then_some((destination_index, overlap)) + }) + .collect() + }) + .collect() +} + +fn expected_rgba_len(width: u32, height: u32) -> Result { + (width as usize) + .checked_mul(height as usize) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or_else(|| eyre::eyre!("RGBA image dimensions overflow")) +} + +fn intermediate_target_pixel_size(target_pixel_size: u32) -> u32 { + target_pixel_size + .saturating_mul(2) + .min(MAX_LANCZOS_INTERMEDIATE_PIXEL_SIZE) + .max(target_pixel_size) +} + +#[cfg(test)] +mod tests { + use std::env; + use std::fs; + use std::process; + + use image::codecs::png::PngEncoder; + use image::{ExtendedColorType, ImageEncoder, RgbaImage}; + + use crate::wallpaper::{self, Path}; + + #[test] + fn png_thumbnail_downsamples_without_full_frame_buffer() { + let path = env::temp_dir().join(format!( + "rsnap-wallpaper-thumb-{}-{}.png", + process::id(), + "downsample" + )); + + write_test_png(&path, 4, 2); + + let thumbnail = wallpaper::capture_frame_wallpaper_png_thumbnail(&path, 2) + .expect("test PNG should decode") + .expect("PNG thumbnail should be produced"); + let _ = fs::remove_file(path); + + assert_eq!(thumbnail.width(), 2); + assert_eq!(thumbnail.height(), 1); + assert_eq!(thumbnail.as_raw().len(), 8); + } + + #[test] + fn png_thumbnail_skips_non_png_paths() { + let thumbnail = + wallpaper::capture_frame_wallpaper_png_thumbnail("/tmp/not-a-wallpaper.jpg", 128) + .expect("non-PNG extension should not be an error"); + + assert!(thumbnail.is_none()); + } + + #[test] + fn png_thumbnail_decodes_rgb_and_grayscale_inputs() { + let rgb_path = + env::temp_dir().join(format!("rsnap-wallpaper-thumb-{}-{}.png", process::id(), "rgb")); + let grayscale_path = env::temp_dir().join(format!( + "rsnap-wallpaper-thumb-{}-{}.png", + process::id(), + "grayscale" + )); + + write_rgb_test_png(&rgb_path, 3, 2); + write_grayscale_test_png(&grayscale_path, 3, 2); + + let rgb_thumbnail = wallpaper::capture_frame_wallpaper_png_thumbnail(&rgb_path, 3) + .expect("RGB test PNG should decode") + .expect("RGB PNG thumbnail should be produced"); + let grayscale_thumbnail = + wallpaper::capture_frame_wallpaper_png_thumbnail(&grayscale_path, 3) + .expect("grayscale test PNG should decode") + .expect("grayscale PNG thumbnail should be produced"); + let _ = fs::remove_file(rgb_path); + let _ = fs::remove_file(grayscale_path); + + assert_eq!(rgb_thumbnail.as_raw().len(), 24); + assert_eq!(grayscale_thumbnail.as_raw().len(), 24); + } + + #[test] + fn intermediate_target_keeps_common_exports_oversampled_without_unbounded_growth() { + assert_eq!(wallpaper::intermediate_target_pixel_size(1_536), 3_072); + assert_eq!(wallpaper::intermediate_target_pixel_size(3_000), 6_000); + assert_eq!(wallpaper::intermediate_target_pixel_size(6_000), 6_000); + } + + #[test] + fn png_thumbnail_cached_reuses_valid_cached_thumbnail() { + let path = env::temp_dir().join(format!( + "rsnap-wallpaper-thumb-{}-{}.png", + process::id(), + "cache" + )); + + write_test_png(&path, 8, 4); + + let first = wallpaper::capture_frame_wallpaper_png_thumbnail_cached(&path, 4) + .expect("test PNG should decode") + .expect("cached PNG thumbnail should be produced"); + let second = wallpaper::capture_frame_wallpaper_png_thumbnail_cached(&path, 4) + .expect("test PNG should decode through cache") + .expect("cached PNG thumbnail should be produced"); + let _ = fs::remove_file(path); + + assert_eq!(first, second); + } + + fn write_test_png(path: &Path, width: u32, height: u32) { + let mut image = RgbaImage::new(width, height); + + for y in 0..height { + for x in 0..width { + image.put_pixel( + x, + y, + image::Rgba([(x * 40) as u8, (y * 80) as u8, ((x + y) * 24) as u8, 255]), + ); + } + } + + let mut bytes = Vec::new(); + + PngEncoder::new(&mut bytes) + .write_image(image.as_raw(), width, height, ExtendedColorType::Rgba8) + .expect("test PNG should encode"); + fs::write(path, bytes).expect("test PNG should be written"); + } + + fn write_rgb_test_png(path: &Path, width: u32, height: u32) { + let mut bytes = Vec::new(); + let mut rgb = Vec::new(); + + for y in 0..height { + for x in 0..width { + rgb.extend_from_slice(&[(x * 40) as u8, (y * 80) as u8, ((x + y) * 24) as u8]); + } + } + + PngEncoder::new(&mut bytes) + .write_image(&rgb, width, height, ExtendedColorType::Rgb8) + .expect("test RGB PNG should encode"); + fs::write(path, bytes).expect("test RGB PNG should be written"); + } + + fn write_grayscale_test_png(path: &Path, width: u32, height: u32) { + let mut bytes = Vec::new(); + let mut grayscale = Vec::new(); + + for y in 0..height { + for x in 0..width { + grayscale.push(((x * 40) + (y * 20)) as u8); + } + } + + PngEncoder::new(&mut bytes) + .write_image(&grayscale, width, height, ExtendedColorType::L8) + .expect("test grayscale PNG should encode"); + fs::write(path, bytes).expect("test grayscale PNG should be written"); + } +} diff --git a/packages/rsnap-host-ffi/include/rsnap_host_ffi.h b/packages/rsnap-host-ffi/include/rsnap_host_ffi.h index 79e065c0..441bc5ca 100644 --- a/packages/rsnap-host-ffi/include/rsnap_host_ffi.h +++ b/packages/rsnap-host-ffi/include/rsnap_host_ffi.h @@ -8,7 +8,7 @@ extern "C" { #endif -#define RSNAP_HOST_FFI_ABI_VERSION 18u +#define RSNAP_HOST_FFI_ABI_VERSION 32u #define RSNAP_TOOLBAR_ITEM_CAPACITY 16u #define RSNAP_STATUS_MESSAGE_CAPACITY 256u #define RSNAP_LIVE_SAMPLE_PATCH_CAPACITY 4096u @@ -16,6 +16,7 @@ extern "C" { typedef struct RsnapSessionHandle RsnapSessionHandle; typedef struct RsnapLiveSamplerHandle RsnapLiveSamplerHandle; typedef struct RsnapScrollSessionHandle RsnapScrollSessionHandle; +typedef struct RsnapFrozenOverlayEditSessionHandle RsnapFrozenOverlayEditSessionHandle; typedef enum RsnapStatus { RSNAP_STATUS_OK = 0, @@ -242,6 +243,166 @@ typedef struct RsnapOwnedRgbaRegion { uint8_t *rgba; } RsnapOwnedRgbaRegion; +typedef struct RsnapOwnedBytes { + size_t len; + size_t capacity; + uint8_t *bytes; +} RsnapOwnedBytes; + +typedef struct RsnapPixelRect { + uint32_t x; + uint32_t y; + uint32_t width; + uint32_t height; +} RsnapPixelRect; + +typedef struct RsnapFloatRect { + double x; + double y; + double width; + double height; +} RsnapFloatRect; + +typedef struct RsnapFloatPoint { + double x; + double y; +} RsnapFloatPoint; + +typedef enum RsnapFrozenAnnotationColor { + RSNAP_FROZEN_ANNOTATION_COLOR_WHITE = 0, + RSNAP_FROZEN_ANNOTATION_COLOR_YELLOW = 1, + RSNAP_FROZEN_ANNOTATION_COLOR_GREEN = 2, + RSNAP_FROZEN_ANNOTATION_COLOR_BLUE = 3, + RSNAP_FROZEN_ANNOTATION_COLOR_RED = 4, + RSNAP_FROZEN_ANNOTATION_COLOR_BLACK = 5, +} RsnapFrozenAnnotationColor; + +typedef enum RsnapFrozenOverlayExportElementKind { + RSNAP_FROZEN_OVERLAY_EXPORT_ELEMENT_PEN = 0, + RSNAP_FROZEN_OVERLAY_EXPORT_ELEMENT_ARROW = 1, + RSNAP_FROZEN_OVERLAY_EXPORT_ELEMENT_MOSAIC = 2, + RSNAP_FROZEN_OVERLAY_EXPORT_ELEMENT_SPOTLIGHT = 3, + RSNAP_FROZEN_OVERLAY_EXPORT_ELEMENT_TEXT = 4, +} RsnapFrozenOverlayExportElementKind; + +typedef struct RsnapFrozenOverlayExportElement { + enum RsnapFrozenOverlayExportElementKind kind; + struct RsnapFloatRect rect; + struct RsnapFloatPoint start; + struct RsnapFloatPoint end; + const struct RsnapFloatPoint *points; + size_t points_len; + const char *text; + double stroke_width_points; + double border_width_points; + double font_size_points; + enum RsnapFrozenAnnotationColor color; +} RsnapFrozenOverlayExportElement; + +typedef struct RsnapFrozenOverlayEditStyle { + double stroke_width_points; + enum RsnapFrozenAnnotationColor stroke_color; + double spotlight_border_width_points; + enum RsnapFrozenAnnotationColor spotlight_color; + double text_font_size_points; + enum RsnapFrozenAnnotationColor text_color; +} RsnapFrozenOverlayEditStyle; + +typedef struct RsnapFrozenOverlayEditSnapshot { + uint8_t can_undo; + uint8_t can_redo; + uint8_t keeps_frozen_selection_fixed; + uint8_t is_moving_movable_annotation; + uint8_t has_active_interaction; + struct RsnapFrozenOverlayExportElement *elements; + size_t elements_len; + uint8_t has_preview_pen; + struct RsnapFrozenOverlayExportElement preview_pen; + uint8_t has_preview_arrow; + struct RsnapFrozenOverlayExportElement preview_arrow; + uint8_t has_preview_mosaic; + struct RsnapFrozenOverlayExportElement preview_mosaic; + uint8_t has_preview_spotlight; + struct RsnapFrozenOverlayExportElement preview_spotlight; + uint8_t has_preview_text; + struct RsnapFrozenOverlayExportElement preview_text; + uint8_t has_active_text_edit; + struct RsnapFrozenOverlayExportElement active_text_edit; +} RsnapFrozenOverlayEditSnapshot; + +typedef enum RsnapCaptureFrameSourceKind { + RSNAP_CAPTURE_FRAME_SOURCE_DRAG_REGION = 0, + RSNAP_CAPTURE_FRAME_SOURCE_WINDOW = 1, + RSNAP_CAPTURE_FRAME_SOURCE_FULL_SCREEN = 2, + RSNAP_CAPTURE_FRAME_SOURCE_SCROLL_CAPTURE = 3, + RSNAP_CAPTURE_FRAME_SOURCE_UNKNOWN = 4, +} RsnapCaptureFrameSourceKind; + +typedef enum RsnapCaptureFrameBackgroundKind { + RSNAP_CAPTURE_FRAME_BACKGROUND_SYSTEM_WALLPAPER = 0, + RSNAP_CAPTURE_FRAME_BACKGROUND_AURORA = 1, + RSNAP_CAPTURE_FRAME_BACKGROUND_GRAPHITE = 2, + RSNAP_CAPTURE_FRAME_BACKGROUND_LINEN = 3, +} RsnapCaptureFrameBackgroundKind; + +typedef enum RsnapCaptureFrameRenderKind { + RSNAP_CAPTURE_FRAME_RENDER_FRAMED_CAPTURE = 0, + RSNAP_CAPTURE_FRAME_RENDER_WINDOW_SNAPSHOT = 1, +} RsnapCaptureFrameRenderKind; + +typedef struct RsnapCaptureFrameColorStop { + double red; + double green; + double blue; + double alpha; +} RsnapCaptureFrameColorStop; + +typedef struct RsnapCaptureFrameBackgroundPlan { + struct RsnapCaptureFrameColorStop colors[3]; + double locations[3]; + uint8_t prefers_wallpaper; + double wallpaper_overlay_alpha; +} RsnapCaptureFrameBackgroundPlan; + +typedef struct RsnapCaptureFrameShadow { + double offset_x; + double offset_y; + double blur; + double alpha; +} RsnapCaptureFrameShadow; + +typedef struct RsnapCaptureFramePlan { + double canvas_width; + double canvas_height; + struct RsnapFloatRect image_rect; + double corner_radius; + struct RsnapCaptureFrameShadow shadows[3]; +} RsnapCaptureFramePlan; + +typedef struct RsnapCaptureFrameWallpaperRequest { + uint32_t target_pixel_size; + double overlay_alpha; +} RsnapCaptureFrameWallpaperRequest; + +typedef struct RsnapScrollMinimapPlan { + struct RsnapFloatRect frame; + struct RsnapFloatRect image_frame; + uint8_t has_viewport_frame; + struct RsnapFloatRect viewport_frame; +} RsnapScrollMinimapPlan; + +typedef enum RsnapFrozenSelectionTransformKind { + RSNAP_FROZEN_SELECTION_TRANSFORM_MOVE = 0, + RSNAP_FROZEN_SELECTION_TRANSFORM_RESIZE_LEFT = 1, + RSNAP_FROZEN_SELECTION_TRANSFORM_RESIZE_RIGHT = 2, + RSNAP_FROZEN_SELECTION_TRANSFORM_RESIZE_TOP = 3, + RSNAP_FROZEN_SELECTION_TRANSFORM_RESIZE_BOTTOM = 4, + RSNAP_FROZEN_SELECTION_TRANSFORM_RESIZE_TOP_LEFT = 5, + RSNAP_FROZEN_SELECTION_TRANSFORM_RESIZE_TOP_RIGHT = 6, + RSNAP_FROZEN_SELECTION_TRANSFORM_RESIZE_BOTTOM_LEFT = 7, + RSNAP_FROZEN_SELECTION_TRANSFORM_RESIZE_BOTTOM_RIGHT = 8, +} RsnapFrozenSelectionTransformKind; + typedef enum RsnapScrollObserveOutcomeKind { RSNAP_SCROLL_OBSERVE_NO_CHANGE = 0, RSNAP_SCROLL_OBSERVE_PREVIEW_UPDATED = 1, @@ -266,6 +427,7 @@ RsnapScrollSessionHandle *rsnap_scroll_session_create( size_t rgba_len, uint32_t preview_width_px ); +RsnapFrozenOverlayEditSessionHandle *rsnap_frozen_overlay_edit_session_create(void); RsnapLiveSamplerHandle *rsnap_live_sampler_create(void); RsnapLiveSamplerHandle *rsnap_live_sampler_create_with_self_capture_exception_window_ids( const uint32_t *window_ids, @@ -280,7 +442,67 @@ enum RsnapStatus rsnap_live_sampler_reset( ); void rsnap_session_destroy(RsnapSessionHandle *handle); void rsnap_scroll_session_destroy(RsnapScrollSessionHandle *handle); +void rsnap_frozen_overlay_edit_session_destroy(RsnapFrozenOverlayEditSessionHandle *handle); void rsnap_live_sampler_destroy(RsnapLiveSamplerHandle *handle); +enum RsnapStatus rsnap_frozen_overlay_edit_session_reset( + RsnapFrozenOverlayEditSessionHandle *handle +); +enum RsnapStatus rsnap_frozen_overlay_edit_session_begin( + RsnapFrozenOverlayEditSessionHandle *handle, + enum RsnapToolbarItemKind tool, + struct RsnapFloatPoint point, + struct RsnapFloatRect selection, + struct RsnapFrozenOverlayEditStyle style, + uint8_t *out_changed +); +enum RsnapStatus rsnap_frozen_overlay_edit_session_update( + RsnapFrozenOverlayEditSessionHandle *handle, + struct RsnapFloatPoint point, + struct RsnapFloatRect selection, + uint8_t *out_changed +); +enum RsnapStatus rsnap_frozen_overlay_edit_session_finish( + RsnapFrozenOverlayEditSessionHandle *handle, + struct RsnapFloatRect selection, + uint8_t *out_changed +); +enum RsnapStatus rsnap_frozen_overlay_edit_session_append_text( + RsnapFrozenOverlayEditSessionHandle *handle, + const char *text, + uint8_t *out_changed +); +enum RsnapStatus rsnap_frozen_overlay_edit_session_backspace_text( + RsnapFrozenOverlayEditSessionHandle *handle, + uint8_t *out_changed +); +enum RsnapStatus rsnap_frozen_overlay_edit_session_commit_text( + RsnapFrozenOverlayEditSessionHandle *handle, + struct RsnapFrozenOverlayEditStyle style, + uint8_t *out_changed +); +enum RsnapStatus rsnap_frozen_overlay_edit_session_cancel_text( + RsnapFrozenOverlayEditSessionHandle *handle +); +enum RsnapStatus rsnap_frozen_overlay_edit_session_undo( + RsnapFrozenOverlayEditSessionHandle *handle, + uint8_t *out_changed +); +enum RsnapStatus rsnap_frozen_overlay_edit_session_redo( + RsnapFrozenOverlayEditSessionHandle *handle, + uint8_t *out_changed +); +enum RsnapStatus rsnap_frozen_overlay_edit_session_contains_movable_annotation( + const RsnapFrozenOverlayEditSessionHandle *handle, + struct RsnapFloatPoint point, + uint8_t *out_contains +); +enum RsnapStatus rsnap_frozen_overlay_edit_session_copy_snapshot( + const RsnapFrozenOverlayEditSessionHandle *handle, + struct RsnapFrozenOverlayEditSnapshot *out_snapshot +); +void rsnap_frozen_overlay_edit_snapshot_release( + struct RsnapFrozenOverlayEditSnapshot *snapshot +); enum RsnapStatus rsnap_scroll_session_observe_downward_frame( RsnapScrollSessionHandle *handle, uint32_t width, @@ -297,6 +519,154 @@ enum RsnapStatus rsnap_scroll_session_undo_last_append( RsnapScrollSessionHandle *handle, struct RsnapScrollObserveResult *out_result ); +enum RsnapStatus rsnap_export_rgba_to_png( + uint32_t width, + uint32_t height, + const uint8_t *rgba, + size_t rgba_len, + struct RsnapOwnedBytes *out_png +); +enum RsnapStatus rsnap_export_rgba_crop_to_png( + uint32_t width, + uint32_t height, + const uint8_t *rgba, + size_t rgba_len, + struct RsnapPixelRect crop_rect, + struct RsnapOwnedBytes *out_png +); +enum RsnapStatus rsnap_frozen_overlay_export_render_rgba( + uint32_t width, + uint32_t height, + const uint8_t *rgba, + size_t rgba_len, + struct RsnapFloatRect selection, + const struct RsnapFrozenOverlayExportElement *elements, + size_t elements_len, + struct RsnapOwnedRgbaRegion *out_region +); +enum RsnapStatus rsnap_frozen_display_crop_rect( + uint32_t image_width, + uint32_t image_height, + struct RsnapFloatRect display_frame, + struct RsnapFloatRect selection, + struct RsnapPixelRect *out_rect +); +enum RsnapStatus rsnap_frozen_mosaic_light_privacy_patch_rgba( + uint32_t image_width, + uint32_t image_height, + struct RsnapFloatRect source_rect, + struct RsnapOwnedRgbaRegion *out_region +); +enum RsnapStatus rsnap_bgra_frame_sample_rgb( + uint32_t width, + uint32_t height, + size_t bytes_per_row, + const uint8_t *bgra, + size_t bgra_len, + struct RsnapFloatRect display_frame, + double point_x, + double point_y, + struct RsnapRgb *out_rgb +); +enum RsnapStatus rsnap_bgra_frame_loupe_patch_rgba( + uint32_t width, + uint32_t height, + size_t bytes_per_row, + const uint8_t *bgra, + size_t bgra_len, + struct RsnapFloatRect display_frame, + double point_x, + double point_y, + uint32_t side_pixels, + struct RsnapOwnedRgbaRegion *out_region +); +enum RsnapStatus rsnap_capture_frame_plan( + uint32_t image_width, + uint32_t image_height, + double screen_scale_factor, + enum RsnapCaptureFrameSourceKind source_kind, + struct RsnapCaptureFramePlan *out_plan +); +enum RsnapStatus rsnap_capture_frame_aspect_fill_crop_rect( + uint32_t source_width, + uint32_t source_height, + double destination_width, + double destination_height, + struct RsnapFloatRect *out_rect +); +enum RsnapStatus rsnap_capture_frame_background_plan( + enum RsnapCaptureFrameBackgroundKind background_kind, + struct RsnapCaptureFrameBackgroundPlan *out_plan +); +enum RsnapStatus rsnap_capture_frame_wallpaper_request_plan( + enum RsnapCaptureFrameBackgroundKind background_kind, + double destination_width, + double destination_height, + struct RsnapCaptureFrameWallpaperRequest *out_request +); +enum RsnapStatus rsnap_capture_frame_wallpaper_png_thumbnail( + const char *path, + uint32_t target_pixel_size, + struct RsnapOwnedRgbaRegion *out_region +); +enum RsnapStatus rsnap_capture_frame_render_rgba( + uint32_t source_width, + uint32_t source_height, + const uint8_t *source_rgba, + size_t source_rgba_len, + double screen_scale_factor, + enum RsnapCaptureFrameSourceKind source_kind, + enum RsnapCaptureFrameBackgroundKind background_kind, + enum RsnapCaptureFrameRenderKind render_kind, + const char *wallpaper_path, + struct RsnapOwnedRgbaRegion *out_region +); +enum RsnapStatus rsnap_scroll_minimap_plan( + struct RsnapFloatRect selection, + double export_width, + double export_height, + struct RsnapFloatRect bounds, + double preferred_width, + double minimum_width, + double gap, + double margin, + double image_inset, + double viewport_top_pixels, + double viewport_height_pixels, + struct RsnapScrollMinimapPlan *out_plan +); +enum RsnapStatus rsnap_frozen_selection_transform_hit_test( + double point_x, + double point_y, + struct RsnapFloatRect selection, + double handle_radius, + double edge_tolerance, + enum RsnapFrozenSelectionTransformKind *out_kind +); +enum RsnapStatus rsnap_frozen_selection_transform_rect( + enum RsnapFrozenSelectionTransformKind kind, + struct RsnapFloatRect initial_selection, + struct RsnapFloatRect monitor_frame, + double initial_pointer_x, + double initial_pointer_y, + double point_x, + double point_y, + double minimum_size, + struct RsnapFloatRect *out_rect +); +enum RsnapStatus rsnap_auto_center_content_bounds_rgba( + uint32_t width, + uint32_t height, + const uint8_t *rgba, + size_t rgba_len, + struct RsnapPixelRect *out_rect +); +double rsnap_auto_center_margin_balance_shift_points( + double content_origin_px, + double content_size_px, + double crop_size_px, + double capture_size_points +); enum RsnapStatus rsnap_session_enter_live(RsnapSessionHandle *handle); enum RsnapStatus rsnap_session_handle_host_event( RsnapSessionHandle *handle, @@ -345,6 +715,7 @@ enum RsnapStatus rsnap_live_sampler_take_latest_monitor_rgba( struct RsnapOwnedRgbaRegion *out_region ); void rsnap_owned_rgba_region_release(struct RsnapOwnedRgbaRegion *region); +void rsnap_owned_bytes_release(struct RsnapOwnedBytes *bytes); #ifdef __cplusplus } diff --git a/packages/rsnap-host-ffi/src/lib.rs b/packages/rsnap-host-ffi/src/lib.rs index f230e568..f1c09722 100644 --- a/packages/rsnap-host-ffi/src/lib.rs +++ b/packages/rsnap-host-ffi/src/lib.rs @@ -4,7 +4,9 @@ //! new host/core direction with an opaque session handle, FFI-safe config/event //! structs, and copy-out scene/request snapshots. +use std::ffi::{CStr, CString}; use std::mem; +use std::os::raw::c_char; use std::ptr::{self, NonNull}; use std::slice; @@ -13,10 +15,28 @@ use rsnap_overlay as _; use rsnap_capture_core::SceneModel; use rsnap_capture_core::{ - self, CaptureMode, CaptureSessionCore, CursorIntent, GlobalRect, HostEffectKind, HostEvent, - HostReport, HostRequest, PermissionKind, PlatformTag, Rgb, SessionConfig, ToolbarItemKind, + self, AutoCenterImageError, BgraFrameView, CaptureFrameBackgroundKind, + CaptureFrameBackgroundPlan, CaptureFrameColorStop, CaptureFramePlan, + CaptureFrameRenderImageRef, CaptureFrameRenderKind, CaptureFrameShadow, CaptureFrameSourceKind, + CaptureFrameWallpaperRequest, CaptureMode, CaptureSessionCore, CursorIntent, DisplayPointRect, + FrozenSelectionTransformInput, FrozenSelectionTransformKind, GlobalRect, HostEffectKind, + HostEvent, HostReport, HostRequest, PermissionKind, PlatformTag, RectPoints, Rgb, + RgbaExportImage, ScrollMinimapInput, ScrollMinimapPlan, SessionConfig, ToolbarItemKind, ToolbarItemModel, WindowRect, }; +use rsnap_overlay::frozen_edit::{ + FrozenOverlayEditArrow, FrozenOverlayEditColor, FrozenOverlayEditElement, + FrozenOverlayEditMosaic, FrozenOverlayEditPen, FrozenOverlayEditPoint, FrozenOverlayEditRect, + FrozenOverlayEditSession, FrozenOverlayEditSnapshot, FrozenOverlayEditSpotlight, + FrozenOverlayEditSpotlightStyle, FrozenOverlayEditStrokeStyle, FrozenOverlayEditStyle, + FrozenOverlayEditText, FrozenOverlayEditTextStyle, FrozenOverlayTextEdit, +}; +use rsnap_overlay::frozen_export::{ + self, FrozenOverlayExportArrow, FrozenOverlayExportElement, FrozenOverlayExportMosaic, + FrozenOverlayExportPen, FrozenOverlayExportPoint, FrozenOverlayExportSpotlight, + FrozenOverlayExportSpotlightStyle, FrozenOverlayExportStrokeStyle, FrozenOverlayExportText, + FrozenOverlayExportTextStyle, +}; #[cfg(target_os = "macos")] use rsnap_overlay::host_live_sampling_macos::HostMacLiveSampler; use rsnap_overlay::scroll_stitching::{ @@ -24,7 +44,7 @@ use rsnap_overlay::scroll_stitching::{ }; /// ABI version exported by the thin C host bridge. -pub const RSNAP_HOST_FFI_ABI_VERSION: u32 = 18; +pub const RSNAP_HOST_FFI_ABI_VERSION: u32 = 32; const RSNAP_TOOLBAR_ITEM_CAPACITY: usize = 16; const RSNAP_STATUS_MESSAGE_CAPACITY: usize = 256; @@ -40,6 +60,11 @@ pub struct RsnapScrollSessionHandle { session: ScrollStitchSession, } +/// Opaque frozen-overlay edit handle owned by the native host through the C ABI. +pub struct RsnapFrozenOverlayEditSessionHandle { + session: FrozenOverlayEditSession, +} + #[cfg(target_os = "macos")] /// Opaque live-sampler handle owned by the native host through the C ABI. pub struct RsnapLiveSamplerHandle { @@ -146,6 +171,368 @@ impl Default for RsnapOwnedRgbaRegion { } } +/// FFI-safe owned byte buffer retained by Rust until explicitly freed. +#[repr(C)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct RsnapOwnedBytes { + /// Byte count in `bytes`. + pub len: usize, + /// Reserved buffer capacity in bytes. + pub capacity: usize, + /// Owned byte buffer. + pub bytes: *mut u8, +} +impl Default for RsnapOwnedBytes { + fn default() -> Self { + Self { len: 0, capacity: 0, bytes: ptr::null_mut() } + } +} + +/// FFI-safe pixel-space rectangle. +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub struct RsnapPixelRect { + /// Left coordinate in pixels. + pub x: u32, + /// Top coordinate in pixels. + pub y: u32, + /// Rectangle width in pixels. + pub width: u32, + /// Rectangle height in pixels. + pub height: u32, +} + +/// FFI-safe display-space rectangle. +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct RsnapFloatRect { + /// Left coordinate in display points. + pub x: f64, + /// Top coordinate in display points. + pub y: f64, + /// Rectangle width in display points. + pub width: f64, + /// Rectangle height in display points. + pub height: f64, +} + +/// FFI-safe display-space point. +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct RsnapFloatPoint { + /// X coordinate in display points. + pub x: f64, + /// Y coordinate in display points. + pub y: f64, +} + +/// FFI-safe frozen annotation color discriminator. +#[repr(C)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RsnapFrozenAnnotationColor { + /// White annotation color. + White = 0, + /// Yellow annotation color. + Yellow = 1, + /// Green annotation color. + Green = 2, + /// Blue annotation color. + Blue = 3, + /// Red annotation color. + Red = 4, + /// Black annotation color. + Black = 5, +} + +/// FFI-safe frozen-overlay export element discriminator. +#[repr(C)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RsnapFrozenOverlayExportElementKind { + /// Pen stroke annotation. + Pen = 0, + /// Arrow annotation. + Arrow = 1, + /// Mosaic privacy rectangle. + Mosaic = 2, + /// Spotlight annotation. + Spotlight = 3, + /// Text annotation. + Text = 4, +} + +/// FFI-safe frozen-overlay export element. +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RsnapFrozenOverlayExportElement { + /// Element kind. + pub kind: RsnapFrozenOverlayExportElementKind, + /// Rectangle payload for mosaic or spotlight annotations. + pub rect: RsnapFloatRect, + /// Start point, arrow tail, or text anchor. + pub start: RsnapFloatPoint, + /// Arrow tip. + pub end: RsnapFloatPoint, + /// Optional point buffer for pen strokes. + pub points: *const RsnapFloatPoint, + /// Number of points in `points`. + pub points_len: usize, + /// Optional null-terminated UTF-8 text payload. + pub text: *const c_char, + /// Stroke width in points for pen and arrow annotations. + pub stroke_width_points: f64, + /// Border width in points for spotlight annotations. + pub border_width_points: f64, + /// Font size in points for text annotations. + pub font_size_points: f64, + /// Annotation color. + pub color: RsnapFrozenAnnotationColor, +} + +/// FFI-safe frozen-overlay edit style payload. +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RsnapFrozenOverlayEditStyle { + /// Stroke width in points for pen and arrow annotations. + pub stroke_width_points: f64, + /// Stroke color for pen and arrow annotations. + pub stroke_color: RsnapFrozenAnnotationColor, + /// Border width in points for spotlight annotations. + pub spotlight_border_width_points: f64, + /// Border color for spotlight annotations. + pub spotlight_color: RsnapFrozenAnnotationColor, + /// Font size in points for text annotations. + pub text_font_size_points: f64, + /// Text color. + pub text_color: RsnapFrozenAnnotationColor, +} +impl Default for RsnapFrozenOverlayEditStyle { + fn default() -> Self { + Self { + stroke_width_points: 3.0, + stroke_color: RsnapFrozenAnnotationColor::Blue, + spotlight_border_width_points: 0.0, + spotlight_color: RsnapFrozenAnnotationColor::Blue, + text_font_size_points: 16.0, + text_color: RsnapFrozenAnnotationColor::Blue, + } + } +} + +/// FFI-safe owned frozen-overlay edit snapshot. +#[repr(C)] +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RsnapFrozenOverlayEditSnapshot { + /// Non-zero when undo is available. + pub can_undo: u8, + /// Non-zero when redo is available. + pub can_redo: u8, + /// Non-zero when selection transforms should be locked out. + pub keeps_frozen_selection_fixed: u8, + /// Non-zero when a movable annotation is being dragged. + pub is_moving_movable_annotation: u8, + /// Non-zero when any pointer interaction is active. + pub has_active_interaction: u8, + /// Owned visible committed elements. + pub elements: *mut RsnapFrozenOverlayExportElement, + /// Number of visible committed elements. + pub elements_len: usize, + /// Non-zero when `preview_pen` is present. + pub has_preview_pen: u8, + /// Active pen preview. + pub preview_pen: RsnapFrozenOverlayExportElement, + /// Non-zero when `preview_arrow` is present. + pub has_preview_arrow: u8, + /// Active arrow preview. + pub preview_arrow: RsnapFrozenOverlayExportElement, + /// Non-zero when `preview_mosaic` is present. + pub has_preview_mosaic: u8, + /// Active mosaic preview. + pub preview_mosaic: RsnapFrozenOverlayExportElement, + /// Non-zero when `preview_spotlight` is present. + pub has_preview_spotlight: u8, + /// Active spotlight preview. + pub preview_spotlight: RsnapFrozenOverlayExportElement, + /// Non-zero when `preview_text` is present. + pub has_preview_text: u8, + /// Active moved text preview. + pub preview_text: RsnapFrozenOverlayExportElement, + /// Non-zero when `active_text_edit` is present. + pub has_active_text_edit: u8, + /// Active text edit payload. + pub active_text_edit: RsnapFrozenOverlayExportElement, +} +impl Default for RsnapFrozenOverlayEditSnapshot { + fn default() -> Self { + Self { + can_undo: 0, + can_redo: 0, + keeps_frozen_selection_fixed: 0, + is_moving_movable_annotation: 0, + has_active_interaction: 0, + elements: ptr::null_mut(), + elements_len: 0, + has_preview_pen: 0, + preview_pen: frozen_overlay_empty_element(), + has_preview_arrow: 0, + preview_arrow: frozen_overlay_empty_element(), + has_preview_mosaic: 0, + preview_mosaic: frozen_overlay_empty_element(), + has_preview_spotlight: 0, + preview_spotlight: frozen_overlay_empty_element(), + has_preview_text: 0, + preview_text: frozen_overlay_empty_element(), + has_active_text_edit: 0, + active_text_edit: frozen_overlay_empty_element(), + } + } +} + +/// FFI-safe capture-frame source discriminator. +#[repr(C)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RsnapCaptureFrameSourceKind { + /// User-dragged region capture. + DragRegion = 0, + /// Single-window capture. + Window = 1, + /// Full-screen capture. + FullScreen = 2, + /// Scroll-capture export. + ScrollCapture = 3, + /// Unknown or future capture source. + Unknown = 4, +} + +/// FFI-safe capture-frame background discriminator. +#[repr(C)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RsnapCaptureFrameBackgroundKind { + /// Prefer system wallpaper with gradient fallback. + SystemWallpaper = 0, + /// Blue-to-warm product gradient. + Aurora = 1, + /// Neutral graphite gradient. + Graphite = 2, + /// Light linen gradient. + Linen = 3, +} + +/// FFI-safe capture-frame render mode. +#[repr(C)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RsnapCaptureFrameRenderKind { + /// Draw shadows and rounded clipping around the capture. + FramedCapture = 0, + /// Draw a floating full-window snapshot without added clipping. + WindowSnapshot = 1, +} + +/// FFI-safe sRGB capture-frame color stop. +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct RsnapCaptureFrameColorStop { + /// Red component in sRGB space. + pub red: f64, + /// Green component in sRGB space. + pub green: f64, + /// Blue component in sRGB space. + pub blue: f64, + /// Alpha component. + pub alpha: f64, +} + +/// FFI-safe capture-frame background drawing plan. +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct RsnapCaptureFrameBackgroundPlan { + /// Ordered sRGB gradient color stops. + pub colors: [RsnapCaptureFrameColorStop; 3], + /// Gradient locations matching `colors`. + pub locations: [f64; 3], + /// Non-zero when the host should first try drawing system wallpaper. + pub prefers_wallpaper: u8, + /// Overlay alpha applied when wallpaper drawing succeeds. + pub wallpaper_overlay_alpha: f64, +} + +/// FFI-safe capture-frame shadow pass. +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct RsnapCaptureFrameShadow { + /// Horizontal shadow offset in output pixels. + pub offset_x: f64, + /// Vertical shadow offset in output pixels. + pub offset_y: f64, + /// Shadow blur radius in output pixels. + pub blur: f64, + /// Shadow alpha. + pub alpha: f64, +} + +/// FFI-safe capture-frame drawing plan. +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct RsnapCaptureFramePlan { + /// Canvas width in output pixels. + pub canvas_width: f64, + /// Canvas height in output pixels. + pub canvas_height: f64, + /// Image placement inside the canvas. + pub image_rect: RsnapFloatRect, + /// Rounded capture corner radius. + pub corner_radius: f64, + /// Ordered shadow passes behind the framed capture. + pub shadows: [RsnapCaptureFrameShadow; 3], +} + +/// FFI-safe platform wallpaper thumbnail request. +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct RsnapCaptureFrameWallpaperRequest { + /// Maximum thumbnail dimension requested from the platform image pipeline. + pub target_pixel_size: u32, + /// Overlay alpha applied after drawing the wallpaper thumbnail. + pub overlay_alpha: f64, +} + +/// FFI-safe scroll-capture minimap layout plan. +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct RsnapScrollMinimapPlan { + /// Outer minimap frame. + pub frame: RsnapFloatRect, + /// Preview image frame inside `frame`. + pub image_frame: RsnapFloatRect, + /// Non-zero when `viewport_frame` contains a visible marker. + pub has_viewport_frame: u8, + /// Viewport marker frame inside `image_frame`. + pub viewport_frame: RsnapFloatRect, +} + +/// FFI-safe frozen selection transform discriminator. +#[repr(C)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum RsnapFrozenSelectionTransformKind { + /// Move the whole selection rectangle. + Move = 0, + /// Resize the left edge. + ResizeLeft = 1, + /// Resize the right edge. + ResizeRight = 2, + /// Resize the top edge. + ResizeTop = 3, + /// Resize the bottom edge. + ResizeBottom = 4, + /// Resize the top-left corner. + ResizeTopLeft = 5, + /// Resize the top-right corner. + ResizeTopRight = 6, + /// Resize the bottom-left corner. + ResizeBottomLeft = 7, + /// Resize the bottom-right corner. + ResizeBottomRight = 8, +} + /// FFI-safe scroll-capture observation discriminator. #[repr(C)] #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -645,204 +1032,1280 @@ pub unsafe extern "C" fn rsnap_scroll_session_destroy(handle: *mut RsnapScrollSe } } -/// Observes one discrete viewport screenshot for downward scroll-capture stitching. +/// Creates a Rust-owned frozen-overlay edit session. +/// +/// The returned pointer must be released by calling +/// `rsnap_frozen_overlay_edit_session_destroy`. +#[unsafe(no_mangle)] +pub extern "C" fn rsnap_frozen_overlay_edit_session_create() +-> *mut RsnapFrozenOverlayEditSessionHandle { + Box::into_raw(Box::new(RsnapFrozenOverlayEditSessionHandle { + session: FrozenOverlayEditSession::default(), + })) +} + +/// Destroys a frozen-overlay edit session. /// /// # Safety /// -/// `handle` must be a valid pointer returned by `rsnap_scroll_session_create`. -/// `rgba` must point to `rgba_len` readable bytes containing `width * height * 4` -/// row-major RGBA data, and `out_result` must be writable. +/// The pointer must either be null or a pointer returned by +/// `rsnap_frozen_overlay_edit_session_create` that has not already been destroyed. #[unsafe(no_mangle)] -pub unsafe extern "C" fn rsnap_scroll_session_observe_downward_frame( - handle: *mut RsnapScrollSessionHandle, - width: u32, - height: u32, - rgba: *const u8, - rgba_len: usize, - out_result: *mut RsnapScrollObserveResult, -) -> RsnapStatus { - let Some(handle) = (unsafe { scroll_session_handle_mut(handle) }) else { - return RsnapStatus::NullHandle; - }; - - if out_result.is_null() { - return RsnapStatus::NullOutput; +pub unsafe extern "C" fn rsnap_frozen_overlay_edit_session_destroy( + handle: *mut RsnapFrozenOverlayEditSessionHandle, +) { + if let Some(handle) = NonNull::new(handle) { + unsafe { + drop(Box::from_raw(handle.as_ptr())); + } } +} - let Some(bytes) = (unsafe { rgba_bytes(rgba, rgba_len) }) else { - return RsnapStatus::InvalidInput; - }; - let outcome = match handle.session.observe_worker_pairwise_rgba(width, height, bytes) { - Ok(outcome) => outcome, - Err(_err) => return RsnapStatus::InvalidInput, +/// Resets a frozen-overlay edit session. +/// +/// # Safety +/// +/// `handle` must be null or a valid pointer returned by +/// `rsnap_frozen_overlay_edit_session_create`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rsnap_frozen_overlay_edit_session_reset( + handle: *mut RsnapFrozenOverlayEditSessionHandle, +) -> RsnapStatus { + let Some(handle) = (unsafe { frozen_edit_handle_mut(handle) }) else { + return RsnapStatus::NullHandle; }; - let export = handle.session.export_image(); - unsafe { - ptr::write(out_result, encode_scroll_observe_result(outcome, &export, &handle.session)); - } + handle.session.reset(); RsnapStatus::Ok } -/// Copies the current committed scroll-capture export into a Rust-owned RGBA buffer. +/// Starts a Rust-owned frozen-overlay interaction. /// /// # Safety /// -/// `handle` must be a valid pointer returned by `rsnap_scroll_session_create`, and -/// `out_region` must be writable. The returned buffer must be released with -/// `rsnap_owned_rgba_region_release`. +/// `handle` must be a valid frozen-overlay edit handle and `out_changed` must be writable. #[unsafe(no_mangle)] -pub unsafe extern "C" fn rsnap_scroll_session_take_export_rgba( - handle: *mut RsnapScrollSessionHandle, - out_region: *mut RsnapOwnedRgbaRegion, +pub unsafe extern "C" fn rsnap_frozen_overlay_edit_session_begin( + handle: *mut RsnapFrozenOverlayEditSessionHandle, + tool: RsnapToolbarItemKind, + point: RsnapFloatPoint, + selection: RsnapFloatRect, + style: RsnapFrozenOverlayEditStyle, + out_changed: *mut u8, ) -> RsnapStatus { - let Some(handle) = (unsafe { scroll_session_handle_mut(handle) }) else { + let Some(handle) = (unsafe { frozen_edit_handle_mut(handle) }) else { return RsnapStatus::NullHandle; }; - - if out_region.is_null() { + let Some(out_changed) = (unsafe { out_changed.as_mut() }) else { return RsnapStatus::NullOutput; - } - - let export = handle.session.export_image(); + }; + let Some(point) = decode_frozen_edit_point(point) else { + return RsnapStatus::InvalidInput; + }; + let Some(selection) = decode_frozen_edit_rect(selection) else { + return RsnapStatus::InvalidInput; + }; + let Some(style) = decode_frozen_overlay_edit_style(style) else { + return RsnapStatus::InvalidInput; + }; - unsafe { - ptr::write(out_region, owned_region_from_scroll_image(export)); - } + *out_changed = u8::from(handle.session.begin( + decode_toolbar_item_kind(tool as u32), + point, + selection, + style, + )); RsnapStatus::Ok } -/// Reverts the most recent committed scroll-capture append when possible. +/// Updates a Rust-owned frozen-overlay interaction. /// /// # Safety /// -/// `handle` must be a valid pointer returned by `rsnap_scroll_session_create`, and -/// `out_result` must be writable. +/// `handle` must be a valid frozen-overlay edit handle and `out_changed` must be writable. #[unsafe(no_mangle)] -pub unsafe extern "C" fn rsnap_scroll_session_undo_last_append( - handle: *mut RsnapScrollSessionHandle, - out_result: *mut RsnapScrollObserveResult, +pub unsafe extern "C" fn rsnap_frozen_overlay_edit_session_update( + handle: *mut RsnapFrozenOverlayEditSessionHandle, + point: RsnapFloatPoint, + selection: RsnapFloatRect, + out_changed: *mut u8, ) -> RsnapStatus { - let Some(handle) = (unsafe { scroll_session_handle_mut(handle) }) else { + let Some(handle) = (unsafe { frozen_edit_handle_mut(handle) }) else { return RsnapStatus::NullHandle; }; - - if out_result.is_null() { + let Some(out_changed) = (unsafe { out_changed.as_mut() }) else { return RsnapStatus::NullOutput; - } - - let did_undo = handle.session.undo_last_append(); - let export = handle.session.export_image(); - let kind = if did_undo { - ScrollStitchObserveOutcome::PreviewUpdated - } else { - ScrollStitchObserveOutcome::NoChange + }; + let Some(point) = decode_frozen_edit_point(point) else { + return RsnapStatus::InvalidInput; + }; + let Some(selection) = decode_frozen_edit_rect(selection) else { + return RsnapStatus::InvalidInput; }; - unsafe { - ptr::write(out_result, encode_scroll_observe_result(kind, &export, &handle.session)); - } + *out_changed = u8::from(handle.session.update(point, selection)); RsnapStatus::Ok } -/// Returns the current C ABI version for the native host bridge. -#[unsafe(no_mangle)] -pub extern "C" fn rsnap_host_ffi_abi_version() -> u32 { - RSNAP_HOST_FFI_ABI_VERSION -} - -/// Creates a new opaque live-sampler handle for the native host. +/// Finishes a Rust-owned frozen-overlay interaction. /// /// # Safety /// -/// The returned pointer must be released by calling `rsnap_live_sampler_destroy`. -#[cfg(target_os = "macos")] +/// `handle` must be a valid frozen-overlay edit handle and `out_changed` must be writable. #[unsafe(no_mangle)] -pub unsafe extern "C" fn rsnap_live_sampler_create() -> *mut RsnapLiveSamplerHandle { - Box::into_raw(Box::new(RsnapLiveSamplerHandle { sampler: HostMacLiveSampler::new() })) +pub unsafe extern "C" fn rsnap_frozen_overlay_edit_session_finish( + handle: *mut RsnapFrozenOverlayEditSessionHandle, + selection: RsnapFloatRect, + out_changed: *mut u8, +) -> RsnapStatus { + let Some(handle) = (unsafe { frozen_edit_handle_mut(handle) }) else { + return RsnapStatus::NullHandle; + }; + let Some(out_changed) = (unsafe { out_changed.as_mut() }) else { + return RsnapStatus::NullOutput; + }; + let Some(selection) = decode_frozen_edit_rect(selection) else { + return RsnapStatus::InvalidInput; + }; + + *out_changed = u8::from(handle.session.finish(selection)); + + RsnapStatus::Ok } -/// Creates a live sampler that keeps selected current-process windows capturable. +/// Appends UTF-8 text to the active frozen text edit. /// /// # Safety /// -/// `window_ids` must point to `window_id_count` valid `u32` values, or be null when -/// `window_id_count` is zero. The returned pointer must be released by calling -/// `rsnap_live_sampler_destroy`. -#[cfg(target_os = "macos")] +/// `handle` must be a valid frozen-overlay edit handle, `text` must point to a valid +/// null-terminated UTF-8 string, and `out_changed` must be writable. #[unsafe(no_mangle)] -pub unsafe extern "C" fn rsnap_live_sampler_create_with_self_capture_exception_window_ids( - window_ids: *const u32, - window_id_count: usize, -) -> *mut RsnapLiveSamplerHandle { - if window_id_count > 0 && window_ids.is_null() { - return ptr::null_mut(); +pub unsafe extern "C" fn rsnap_frozen_overlay_edit_session_append_text( + handle: *mut RsnapFrozenOverlayEditSessionHandle, + text: *const c_char, + out_changed: *mut u8, +) -> RsnapStatus { + let Some(handle) = (unsafe { frozen_edit_handle_mut(handle) }) else { + return RsnapStatus::NullHandle; + }; + let Some(out_changed) = (unsafe { out_changed.as_mut() }) else { + return RsnapStatus::NullOutput; + }; + + if text.is_null() { + return RsnapStatus::InvalidInput; } - let exception_window_ids = if window_id_count == 0 { - Vec::new() - } else { - unsafe { slice::from_raw_parts(window_ids, window_id_count) }.to_vec() + let Ok(text) = (unsafe { CStr::from_ptr(text) }).to_str() else { + return RsnapStatus::InvalidInput; }; - Box::into_raw(Box::new(RsnapLiveSamplerHandle { - sampler: HostMacLiveSampler::with_self_capture_exception_window_ids(exception_window_ids), - })) + *out_changed = u8::from(handle.session.append_text(text)); + + RsnapStatus::Ok } -/// Starts warming the live sampler for the requested monitor without blocking on the -/// first captured frame. +/// Deletes one scalar from the active frozen text edit. /// /// # Safety /// -/// `handle` must be a valid pointer returned by `rsnap_live_sampler_create`. -#[cfg(target_os = "macos")] +/// `handle` must be a valid frozen-overlay edit handle and `out_changed` must be writable. #[unsafe(no_mangle)] -pub unsafe extern "C" fn rsnap_live_sampler_prime_monitor( - handle: *mut RsnapLiveSamplerHandle, - monitor: RsnapMonitorRect, +pub unsafe extern "C" fn rsnap_frozen_overlay_edit_session_backspace_text( + handle: *mut RsnapFrozenOverlayEditSessionHandle, + out_changed: *mut u8, ) -> RsnapStatus { - let Some(handle) = (unsafe { live_sampler_handle_mut(handle) }) else { + let Some(handle) = (unsafe { frozen_edit_handle_mut(handle) }) else { return RsnapStatus::NullHandle; }; + let Some(out_changed) = (unsafe { out_changed.as_mut() }) else { + return RsnapStatus::NullOutput; + }; - handle.sampler.prime_monitor(decode_overlay_monitor(monitor)); + *out_changed = u8::from(handle.session.backspace_text()); RsnapStatus::Ok } -/// Stops any active ScreenCaptureKit stream while retaining the live-sampler worker. +/// Commits the active frozen text edit. /// /// # Safety /// -/// `handle` must be a valid pointer returned by `rsnap_live_sampler_create`. -#[cfg(target_os = "macos")] +/// `handle` must be a valid frozen-overlay edit handle and `out_changed` must be writable. #[unsafe(no_mangle)] -pub unsafe extern "C" fn rsnap_live_sampler_reset( - handle: *mut RsnapLiveSamplerHandle, +pub unsafe extern "C" fn rsnap_frozen_overlay_edit_session_commit_text( + handle: *mut RsnapFrozenOverlayEditSessionHandle, + style: RsnapFrozenOverlayEditStyle, + out_changed: *mut u8, ) -> RsnapStatus { - let Some(handle) = (unsafe { live_sampler_handle_mut(handle) }) else { + let Some(handle) = (unsafe { frozen_edit_handle_mut(handle) }) else { return RsnapStatus::NullHandle; }; + let Some(out_changed) = (unsafe { out_changed.as_mut() }) else { + return RsnapStatus::NullOutput; + }; + let Some(style) = decode_frozen_overlay_edit_style(style) else { + return RsnapStatus::InvalidInput; + }; - handle.sampler.reset(); + *out_changed = u8::from(handle.session.commit_text_edit(style.text)); RsnapStatus::Ok } -/// Destroys an opaque session handle. +/// Cancels the active frozen text edit. /// /// # Safety /// -/// The pointer must either be null or a pointer returned by `rsnap_session_create` that -/// has not already been destroyed. +/// `handle` must be null or a valid frozen-overlay edit handle. #[unsafe(no_mangle)] -pub unsafe extern "C" fn rsnap_session_destroy(handle: *mut RsnapSessionHandle) { - if let Some(handle) = NonNull::new(handle) { - unsafe { +pub unsafe extern "C" fn rsnap_frozen_overlay_edit_session_cancel_text( + handle: *mut RsnapFrozenOverlayEditSessionHandle, +) -> RsnapStatus { + let Some(handle) = (unsafe { frozen_edit_handle_mut(handle) }) else { + return RsnapStatus::NullHandle; + }; + + handle.session.cancel_text_edit(); + + RsnapStatus::Ok +} + +/// Undoes the latest frozen-overlay edit. +/// +/// # Safety +/// +/// `handle` must be a valid frozen-overlay edit handle and `out_changed` must be writable. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rsnap_frozen_overlay_edit_session_undo( + handle: *mut RsnapFrozenOverlayEditSessionHandle, + out_changed: *mut u8, +) -> RsnapStatus { + let Some(handle) = (unsafe { frozen_edit_handle_mut(handle) }) else { + return RsnapStatus::NullHandle; + }; + let Some(out_changed) = (unsafe { out_changed.as_mut() }) else { + return RsnapStatus::NullOutput; + }; + + *out_changed = u8::from(handle.session.undo()); + + RsnapStatus::Ok +} + +/// Redoes the latest frozen-overlay edit. +/// +/// # Safety +/// +/// `handle` must be a valid frozen-overlay edit handle and `out_changed` must be writable. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rsnap_frozen_overlay_edit_session_redo( + handle: *mut RsnapFrozenOverlayEditSessionHandle, + out_changed: *mut u8, +) -> RsnapStatus { + let Some(handle) = (unsafe { frozen_edit_handle_mut(handle) }) else { + return RsnapStatus::NullHandle; + }; + let Some(out_changed) = (unsafe { out_changed.as_mut() }) else { + return RsnapStatus::NullOutput; + }; + + *out_changed = u8::from(handle.session.redo()); + + RsnapStatus::Ok +} + +/// Tests whether a movable frozen-overlay annotation is under a point. +/// +/// # Safety +/// +/// `handle` must be a valid frozen-overlay edit handle and `out_contains` must be writable. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rsnap_frozen_overlay_edit_session_contains_movable_annotation( + handle: *const RsnapFrozenOverlayEditSessionHandle, + point: RsnapFloatPoint, + out_contains: *mut u8, +) -> RsnapStatus { + let Some(handle) = (unsafe { frozen_edit_handle_ref(handle) }) else { + return RsnapStatus::NullHandle; + }; + let Some(out_contains) = (unsafe { out_contains.as_mut() }) else { + return RsnapStatus::NullOutput; + }; + let Some(point) = decode_frozen_edit_point(point) else { + return RsnapStatus::InvalidInput; + }; + + *out_contains = u8::from(handle.session.contains_movable_annotation(point)); + + RsnapStatus::Ok +} + +/// Copies an owned frozen-overlay edit snapshot for native-host rendering. +/// +/// # Safety +/// +/// `handle` must be a valid frozen-overlay edit handle and `out_snapshot` must be writable. +/// Release a successful snapshot with `rsnap_frozen_overlay_edit_snapshot_release`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rsnap_frozen_overlay_edit_session_copy_snapshot( + handle: *const RsnapFrozenOverlayEditSessionHandle, + out_snapshot: *mut RsnapFrozenOverlayEditSnapshot, +) -> RsnapStatus { + let Some(handle) = (unsafe { frozen_edit_handle_ref(handle) }) else { + return RsnapStatus::NullHandle; + }; + + if out_snapshot.is_null() { + return RsnapStatus::NullOutput; + } + + let Some(snapshot) = encode_frozen_overlay_edit_snapshot(handle.session.snapshot()) else { + return RsnapStatus::InvalidInput; + }; + + unsafe { + ptr::write(out_snapshot, snapshot); + } + + RsnapStatus::Ok +} + +/// Releases an owned frozen-overlay edit snapshot. +/// +/// # Safety +/// +/// `snapshot` must point to a snapshot returned by +/// `rsnap_frozen_overlay_edit_session_copy_snapshot`, or be null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rsnap_frozen_overlay_edit_snapshot_release( + snapshot: *mut RsnapFrozenOverlayEditSnapshot, +) { + let Some(snapshot) = (unsafe { snapshot.as_mut() }) else { + return; + }; + + unsafe { + release_frozen_overlay_snapshot_elements(snapshot.elements, snapshot.elements_len); + release_frozen_overlay_snapshot_element(&mut snapshot.preview_pen); + release_frozen_overlay_snapshot_element(&mut snapshot.preview_arrow); + release_frozen_overlay_snapshot_element(&mut snapshot.preview_mosaic); + release_frozen_overlay_snapshot_element(&mut snapshot.preview_spotlight); + release_frozen_overlay_snapshot_element(&mut snapshot.preview_text); + release_frozen_overlay_snapshot_element(&mut snapshot.active_text_edit); + } + + *snapshot = RsnapFrozenOverlayEditSnapshot::default(); +} + +/// Observes one discrete viewport screenshot for downward scroll-capture stitching. +/// +/// # Safety +/// +/// `handle` must be a valid pointer returned by `rsnap_scroll_session_create`. +/// `rgba` must point to `rgba_len` readable bytes containing `width * height * 4` +/// row-major RGBA data, and `out_result` must be writable. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rsnap_scroll_session_observe_downward_frame( + handle: *mut RsnapScrollSessionHandle, + width: u32, + height: u32, + rgba: *const u8, + rgba_len: usize, + out_result: *mut RsnapScrollObserveResult, +) -> RsnapStatus { + let Some(handle) = (unsafe { scroll_session_handle_mut(handle) }) else { + return RsnapStatus::NullHandle; + }; + + if out_result.is_null() { + return RsnapStatus::NullOutput; + } + + let Some(bytes) = (unsafe { rgba_bytes(rgba, rgba_len) }) else { + return RsnapStatus::InvalidInput; + }; + let outcome = match handle.session.observe_worker_pairwise_rgba(width, height, bytes) { + Ok(outcome) => outcome, + Err(_err) => return RsnapStatus::InvalidInput, + }; + let export = handle.session.export_image(); + + unsafe { + ptr::write(out_result, encode_scroll_observe_result(outcome, &export, &handle.session)); + } + + RsnapStatus::Ok +} + +/// Copies the current committed scroll-capture export into a Rust-owned RGBA buffer. +/// +/// # Safety +/// +/// `handle` must be a valid pointer returned by `rsnap_scroll_session_create`, and +/// `out_region` must be writable. The returned buffer must be released with +/// `rsnap_owned_rgba_region_release`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rsnap_scroll_session_take_export_rgba( + handle: *mut RsnapScrollSessionHandle, + out_region: *mut RsnapOwnedRgbaRegion, +) -> RsnapStatus { + let Some(handle) = (unsafe { scroll_session_handle_mut(handle) }) else { + return RsnapStatus::NullHandle; + }; + + if out_region.is_null() { + return RsnapStatus::NullOutput; + } + + let export = handle.session.export_image(); + + unsafe { + ptr::write(out_region, owned_region_from_scroll_image(export)); + } + + RsnapStatus::Ok +} + +/// Reverts the most recent committed scroll-capture append when possible. +/// +/// # Safety +/// +/// `handle` must be a valid pointer returned by `rsnap_scroll_session_create`, and +/// `out_result` must be writable. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rsnap_scroll_session_undo_last_append( + handle: *mut RsnapScrollSessionHandle, + out_result: *mut RsnapScrollObserveResult, +) -> RsnapStatus { + let Some(handle) = (unsafe { scroll_session_handle_mut(handle) }) else { + return RsnapStatus::NullHandle; + }; + + if out_result.is_null() { + return RsnapStatus::NullOutput; + } + + let did_undo = handle.session.undo_last_append(); + let export = handle.session.export_image(); + let kind = if did_undo { + ScrollStitchObserveOutcome::PreviewUpdated + } else { + ScrollStitchObserveOutcome::NoChange + }; + + unsafe { + ptr::write(out_result, encode_scroll_observe_result(kind, &export, &handle.session)); + } + + RsnapStatus::Ok +} + +/// Encodes a full RGBA export image as lossless PNG through the Rust product core. +/// +/// # Safety +/// +/// `rgba` must point to `rgba_len` readable bytes containing `width * height * 4` +/// row-major RGBA data, and `out_png` must be writable. The returned buffer must +/// be released with `rsnap_owned_bytes_release`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rsnap_export_rgba_to_png( + width: u32, + height: u32, + rgba: *const u8, + rgba_len: usize, + out_png: *mut RsnapOwnedBytes, +) -> RsnapStatus { + if out_png.is_null() { + return RsnapStatus::NullOutput; + } + + let Some(bytes) = (unsafe { rgba_bytes(rgba, rgba_len) }) else { + return RsnapStatus::InvalidInput; + }; + let Ok(image) = RgbaExportImage::from_raw(width, height, bytes.to_vec()) else { + return RsnapStatus::InvalidInput; + }; + let Ok(png) = image.to_png_bytes() else { + return RsnapStatus::InvalidInput; + }; + + unsafe { + ptr::write(out_png, owned_bytes_from_vec(png)); + } + + RsnapStatus::Ok +} + +/// Encodes a pixel-space RGBA export crop as lossless PNG through the Rust product core. +/// +/// # Safety +/// +/// `rgba` must point to `rgba_len` readable bytes containing `width * height * 4` +/// row-major RGBA data, and `out_png` must be writable. The returned buffer must +/// be released with `rsnap_owned_bytes_release`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rsnap_export_rgba_crop_to_png( + width: u32, + height: u32, + rgba: *const u8, + rgba_len: usize, + crop_rect: RsnapPixelRect, + out_png: *mut RsnapOwnedBytes, +) -> RsnapStatus { + if out_png.is_null() { + return RsnapStatus::NullOutput; + } + + let Some(bytes) = (unsafe { rgba_bytes(rgba, rgba_len) }) else { + return RsnapStatus::InvalidInput; + }; + let Ok(image) = RgbaExportImage::from_raw(width, height, bytes.to_vec()) else { + return RsnapStatus::InvalidInput; + }; + let Some(cropped) = image.crop(decode_pixel_rect(crop_rect)) else { + return RsnapStatus::InvalidInput; + }; + let Ok(png) = cropped.to_png_bytes() else { + return RsnapStatus::InvalidInput; + }; + + unsafe { + ptr::write(out_png, owned_bytes_from_vec(png)); + } + + RsnapStatus::Ok +} + +/// Composites frozen-overlay annotations into a full RGBA export image through Rust. +/// +/// # Safety +/// +/// `rgba` must point to `rgba_len` readable bytes containing `width * height * 4` +/// row-major RGBA data. `elements` must either be null with `elements_len == 0`, or point +/// to `elements_len` readable element records whose nested point and text pointers stay +/// valid for the duration of the call. The returned buffer must be released with +/// `rsnap_owned_rgba_region_release`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rsnap_frozen_overlay_export_render_rgba( + width: u32, + height: u32, + rgba: *const u8, + rgba_len: usize, + selection: RsnapFloatRect, + elements: *const RsnapFrozenOverlayExportElement, + elements_len: usize, + out_region: *mut RsnapOwnedRgbaRegion, +) -> RsnapStatus { + if out_region.is_null() { + return RsnapStatus::NullOutput; + } + + let Some(bytes) = (unsafe { rgba_bytes(rgba, rgba_len) }) else { + return RsnapStatus::InvalidInput; + }; + let Some(elements) = (unsafe { decode_frozen_overlay_export_elements(elements, elements_len) }) + else { + return RsnapStatus::InvalidInput; + }; + let Ok(image) = frozen_export::render_frozen_overlay_export_rgba( + width, + height, + bytes, + decode_float_rect(selection), + &elements, + ) else { + return RsnapStatus::InvalidInput; + }; + + unsafe { + ptr::write(out_region, owned_region_from_raw_rgba(width, height, image.into_raw())); + } + + RsnapStatus::Ok +} + +/// Resolves a frozen display selection into an image-local pixel crop rectangle. +/// +/// # Safety +/// +/// `out_rect` must be writable when non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rsnap_frozen_display_crop_rect( + image_width: u32, + image_height: u32, + display_frame: RsnapFloatRect, + selection: RsnapFloatRect, + out_rect: *mut RsnapPixelRect, +) -> RsnapStatus { + if out_rect.is_null() { + return RsnapStatus::NullOutput; + } + + let Some(crop_rect) = rsnap_capture_core::frozen_display_crop_rect( + image_width, + image_height, + decode_float_rect(display_frame), + decode_float_rect(selection), + ) else { + return RsnapStatus::Empty; + }; + + unsafe { + ptr::write(out_rect, encode_pixel_rect(crop_rect)); + } + + RsnapStatus::Ok +} + +/// Builds a light privacy mosaic patch as row-major RGBA bytes. +/// +/// # Safety +/// +/// `out_region` must be writable. The returned buffer must be released with +/// `rsnap_owned_rgba_region_release`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rsnap_frozen_mosaic_light_privacy_patch_rgba( + image_width: u32, + image_height: u32, + source_rect: RsnapFloatRect, + out_region: *mut RsnapOwnedRgbaRegion, +) -> RsnapStatus { + if out_region.is_null() { + return RsnapStatus::NullOutput; + } + + let Some(patch) = rsnap_capture_core::frozen_mosaic_light_privacy_patch( + image_width, + image_height, + decode_float_rect(source_rect), + ) else { + return RsnapStatus::Empty; + }; + let (width, height) = patch.dimensions(); + + unsafe { + ptr::write(out_region, owned_region_from_raw_rgba(width, height, patch.into_raw())); + } + + RsnapStatus::Ok +} + +/// Samples an RGB value from a borrowed BGRA frame. +/// +/// # Safety +/// +/// `bgra` must point to `bgra_len` readable bytes while this function runs, and +/// `out_rgb` must be writable when non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rsnap_bgra_frame_sample_rgb( + width: u32, + height: u32, + bytes_per_row: usize, + bgra: *const u8, + bgra_len: usize, + display_frame: RsnapFloatRect, + point_x: f64, + point_y: f64, + out_rgb: *mut RsnapRgb, +) -> RsnapStatus { + if out_rgb.is_null() { + return RsnapStatus::NullOutput; + } + + let Some(frame) = (unsafe { decode_bgra_frame(width, height, bytes_per_row, bgra, bgra_len) }) + else { + return RsnapStatus::InvalidInput; + }; + let Some(rgb) = rsnap_capture_core::sample_rgb_from_bgra_frame( + frame, + decode_float_rect(display_frame), + point_x, + point_y, + ) else { + return RsnapStatus::Empty; + }; + + unsafe { + ptr::write(out_rgb, RsnapRgb { r: rgb.r, g: rgb.g, b: rgb.b }); + } + + RsnapStatus::Ok +} + +/// Builds a square RGBA loupe patch from a borrowed BGRA frame. +/// +/// # Safety +/// +/// `bgra` must point to `bgra_len` readable bytes while this function runs. `out_region` +/// must be writable, and the returned buffer must be released with +/// `rsnap_owned_rgba_region_release`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rsnap_bgra_frame_loupe_patch_rgba( + width: u32, + height: u32, + bytes_per_row: usize, + bgra: *const u8, + bgra_len: usize, + display_frame: RsnapFloatRect, + point_x: f64, + point_y: f64, + side_pixels: u32, + out_region: *mut RsnapOwnedRgbaRegion, +) -> RsnapStatus { + if out_region.is_null() { + return RsnapStatus::NullOutput; + } + + let Some(frame) = (unsafe { decode_bgra_frame(width, height, bytes_per_row, bgra, bgra_len) }) + else { + return RsnapStatus::InvalidInput; + }; + let Some(patch) = rsnap_capture_core::loupe_patch_rgba_from_bgra_frame( + frame, + decode_float_rect(display_frame), + point_x, + point_y, + side_pixels, + ) else { + unsafe { + ptr::write(out_region, RsnapOwnedRgbaRegion::default()); + } + + return RsnapStatus::Empty; + }; + let (width, height) = patch.dimensions(); + + unsafe { + ptr::write(out_region, owned_region_from_raw_rgba(width, height, patch.into_raw())); + } + + RsnapStatus::Ok +} + +/// Resolves capture-frame layout and shadow parameters. +/// +/// # Safety +/// +/// `out_plan` must be writable when non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rsnap_capture_frame_plan( + image_width: u32, + image_height: u32, + screen_scale_factor: f64, + source_kind: RsnapCaptureFrameSourceKind, + out_plan: *mut RsnapCaptureFramePlan, +) -> RsnapStatus { + if out_plan.is_null() { + return RsnapStatus::NullOutput; + } + + let Some(plan) = rsnap_capture_core::capture_frame_plan( + image_width, + image_height, + screen_scale_factor, + decode_capture_frame_source_kind(source_kind), + ) else { + return RsnapStatus::InvalidInput; + }; + + unsafe { + ptr::write(out_plan, encode_capture_frame_plan(plan)); + } + + RsnapStatus::Ok +} + +/// Resolves the source crop rect for aspect-fill drawing. +/// +/// # Safety +/// +/// `out_rect` must be writable when non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rsnap_capture_frame_aspect_fill_crop_rect( + source_width: u32, + source_height: u32, + destination_width: f64, + destination_height: f64, + out_rect: *mut RsnapFloatRect, +) -> RsnapStatus { + if out_rect.is_null() { + return RsnapStatus::NullOutput; + } + + let Some(rect) = rsnap_capture_core::capture_frame_aspect_fill_crop_rect( + source_width, + source_height, + destination_width, + destination_height, + ) else { + return RsnapStatus::InvalidInput; + }; + + unsafe { + ptr::write(out_rect, encode_float_rect(rect)); + } + + RsnapStatus::Ok +} + +/// Resolves capture-frame background colors and wallpaper fallback behavior. +/// +/// # Safety +/// +/// `out_plan` must be writable when non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rsnap_capture_frame_background_plan( + background_kind: RsnapCaptureFrameBackgroundKind, + out_plan: *mut RsnapCaptureFrameBackgroundPlan, +) -> RsnapStatus { + if out_plan.is_null() { + return RsnapStatus::NullOutput; + } + + let plan = rsnap_capture_core::capture_frame_background_plan( + decode_capture_frame_background_kind(background_kind), + ); + + unsafe { + ptr::write(out_plan, encode_capture_frame_background_plan(plan)); + } + + RsnapStatus::Ok +} + +/// Resolves a platform wallpaper thumbnail request for a capture-frame destination. +/// +/// # Safety +/// +/// `out_request` must be writable when non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rsnap_capture_frame_wallpaper_request_plan( + background_kind: RsnapCaptureFrameBackgroundKind, + destination_width: f64, + destination_height: f64, + out_request: *mut RsnapCaptureFrameWallpaperRequest, +) -> RsnapStatus { + if out_request.is_null() { + return RsnapStatus::NullOutput; + } + + let Some(request) = rsnap_capture_core::capture_frame_wallpaper_request_plan( + decode_capture_frame_background_kind(background_kind), + destination_width, + destination_height, + ) else { + return RsnapStatus::Empty; + }; + + unsafe { + ptr::write(out_request, encode_capture_frame_wallpaper_request(request)); + } + + RsnapStatus::Ok +} + +/// Decodes a PNG wallpaper thumbnail with Rust's streaming low-memory path. +/// +/// Non-PNG paths and decode failures return `Empty` so native hosts can skip wallpaper drawing. +/// +/// # Safety +/// +/// `path` must be a valid null-terminated UTF-8 string, and `out_region` must be a valid writable +/// pointer. When `Ok` is returned, the caller must release the returned buffer with +/// `rsnap_owned_rgba_region_release`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rsnap_capture_frame_wallpaper_png_thumbnail( + path: *const c_char, + target_pixel_size: u32, + out_region: *mut RsnapOwnedRgbaRegion, +) -> RsnapStatus { + if out_region.is_null() { + return RsnapStatus::NullOutput; + } + + unsafe { + ptr::write(out_region, RsnapOwnedRgbaRegion::default()); + } + + if path.is_null() || target_pixel_size == 0 { + return RsnapStatus::InvalidInput; + } + + let Ok(path) = (unsafe { CStr::from_ptr(path) }).to_str() else { + return RsnapStatus::InvalidInput; + }; + let Ok(Some(thumbnail)) = + rsnap_capture_core::capture_frame_wallpaper_png_thumbnail(path, target_pixel_size) + else { + return RsnapStatus::Empty; + }; + let image = thumbnail.into_image(); + let out = owned_region_from_raw_rgba(image.width(), image.height(), image.into_raw()); + + unsafe { + ptr::write(out_region, out); + } + + RsnapStatus::Ok +} + +/// Renders the complete capture-frame effect as Rust-owned RGBA bytes. +/// +/// Swift/native hosts only pass source pixels and an optional platform wallpaper path. Rust owns +/// wallpaper thumbnail planning/cache/decode, background drawing, shadows, clipping, and final +/// composition. +/// +/// # Safety +/// +/// `source_rgba` must point to `source_rgba_len` readable bytes containing +/// `source_width * source_height * 4` row-major RGBA data. `wallpaper_path` may be null or a valid +/// null-terminated UTF-8 string. `out_region` must be writable, and the returned buffer must be +/// released with `rsnap_owned_rgba_region_release`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rsnap_capture_frame_render_rgba( + source_width: u32, + source_height: u32, + source_rgba: *const u8, + source_rgba_len: usize, + screen_scale_factor: f64, + source_kind: RsnapCaptureFrameSourceKind, + background_kind: RsnapCaptureFrameBackgroundKind, + render_kind: RsnapCaptureFrameRenderKind, + wallpaper_path: *const c_char, + out_region: *mut RsnapOwnedRgbaRegion, +) -> RsnapStatus { + if out_region.is_null() { + return RsnapStatus::NullOutput; + } + + unsafe { + ptr::write(out_region, RsnapOwnedRgbaRegion::default()); + } + + let Some(source_bytes) = (unsafe { rgba_bytes(source_rgba, source_rgba_len) }) else { + return RsnapStatus::InvalidInput; + }; + let Ok(source) = CaptureFrameRenderImageRef::new(source_width, source_height, source_bytes) + else { + return RsnapStatus::InvalidInput; + }; + let background_kind = decode_capture_frame_background_kind(background_kind); + let source_kind = decode_capture_frame_source_kind(source_kind); + let render_kind = decode_capture_frame_render_kind(render_kind); + let wallpaper = match unsafe { + capture_frame_wallpaper_for_render( + source, + screen_scale_factor, + source_kind, + background_kind, + wallpaper_path, + ) + } { + Ok(wallpaper) => wallpaper, + Err(_err) => return RsnapStatus::InvalidInput, + }; + let wallpaper_ref = wallpaper.as_ref().map(CaptureFrameRenderImageRef::from_export); + let Ok(Some(rendered)) = rsnap_capture_core::render_capture_frame_effect( + source, + background_kind, + screen_scale_factor, + source_kind, + render_kind, + wallpaper_ref, + ) else { + return RsnapStatus::InvalidInput; + }; + let image = rendered.into_image(); + + unsafe { + ptr::write( + out_region, + owned_region_from_raw_rgba(image.width(), image.height(), image.into_raw()), + ); + } + + RsnapStatus::Ok +} + +/// Resolves scroll-capture minimap layout and viewport marker geometry. +/// +/// # Safety +/// +/// `out_plan` must be writable when non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rsnap_scroll_minimap_plan( + selection: RsnapFloatRect, + export_width: f64, + export_height: f64, + bounds: RsnapFloatRect, + preferred_width: f64, + minimum_width: f64, + gap: f64, + margin: f64, + image_inset: f64, + viewport_top_pixels: f64, + viewport_height_pixels: f64, + out_plan: *mut RsnapScrollMinimapPlan, +) -> RsnapStatus { + if out_plan.is_null() { + return RsnapStatus::NullOutput; + } + + let input = ScrollMinimapInput { + selection: decode_float_rect(selection), + export_width, + export_height, + bounds: decode_float_rect(bounds), + preferred_width, + minimum_width, + gap, + margin, + image_inset, + viewport_top_pixels, + viewport_height_pixels, + }; + let Some(plan) = rsnap_capture_core::scroll_minimap_plan(input) else { + return RsnapStatus::Empty; + }; + + unsafe { + ptr::write(out_plan, encode_scroll_minimap_plan(plan)); + } + + RsnapStatus::Ok +} + +/// Hit-tests a pointer against frozen selection transform handles. +/// +/// # Safety +/// +/// `out_kind` must be writable when non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rsnap_frozen_selection_transform_hit_test( + point_x: f64, + point_y: f64, + selection: RsnapFloatRect, + handle_radius: f64, + edge_tolerance: f64, + out_kind: *mut RsnapFrozenSelectionTransformKind, +) -> RsnapStatus { + if out_kind.is_null() { + return RsnapStatus::NullOutput; + } + + let Some(kind) = rsnap_capture_core::frozen_selection_transform_hit_test( + point_x, + point_y, + decode_float_rect(selection), + handle_radius, + edge_tolerance, + ) else { + return RsnapStatus::Empty; + }; + + unsafe { + ptr::write(out_kind, encode_frozen_selection_transform_kind(kind)); + } + + RsnapStatus::Ok +} + +/// Resolves a frozen selection transform rectangle. +/// +/// # Safety +/// +/// `out_rect` must be writable when non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rsnap_frozen_selection_transform_rect( + kind: RsnapFrozenSelectionTransformKind, + initial_selection: RsnapFloatRect, + monitor_frame: RsnapFloatRect, + initial_pointer_x: f64, + initial_pointer_y: f64, + point_x: f64, + point_y: f64, + minimum_size: f64, + out_rect: *mut RsnapFloatRect, +) -> RsnapStatus { + if out_rect.is_null() { + return RsnapStatus::NullOutput; + } + + let input = FrozenSelectionTransformInput { + kind: decode_frozen_selection_transform_kind(kind), + initial_selection: decode_float_rect(initial_selection), + monitor_frame: decode_float_rect(monitor_frame), + initial_pointer_x, + initial_pointer_y, + point_x, + point_y, + minimum_size, + }; + let Some(rect) = rsnap_capture_core::frozen_selection_transform_rect(input) else { + return RsnapStatus::Empty; + }; + + unsafe { + ptr::write(out_rect, encode_float_rect(rect)); + } + + RsnapStatus::Ok +} + +/// Detects salient content bounds for frozen auto-center from row-major RGBA. +/// +/// # Safety +/// +/// `rgba` must point to `rgba_len` readable bytes containing `width * height * 4` +/// row-major RGBA data, and `out_rect` must be writable when non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rsnap_auto_center_content_bounds_rgba( + width: u32, + height: u32, + rgba: *const u8, + rgba_len: usize, + out_rect: *mut RsnapPixelRect, +) -> RsnapStatus { + if out_rect.is_null() { + return RsnapStatus::NullOutput; + } + + let Some(bytes) = (unsafe { rgba_bytes(rgba, rgba_len) }) else { + return RsnapStatus::InvalidInput; + }; + let bounds = + match rsnap_capture_core::detect_auto_center_content_bounds_rgba(width, height, bytes) { + Ok(Some(bounds)) => bounds, + Ok(None) => return RsnapStatus::Empty, + Err( + AutoCenterImageError::InvalidDimensions | AutoCenterImageError::InvalidRgbaLength, + ) => { + return RsnapStatus::InvalidInput; + }, + }; + + unsafe { + ptr::write(out_rect, encode_pixel_rect(bounds)); + } + + RsnapStatus::Ok +} + +/// Resolves the point shift that balances content margins inside a frozen crop. +#[unsafe(no_mangle)] +pub extern "C" fn rsnap_auto_center_margin_balance_shift_points( + content_origin_px: f64, + content_size_px: f64, + crop_size_px: f64, + capture_size_points: f64, +) -> f64 { + rsnap_capture_core::auto_center_margin_balance_shift_points( + content_origin_px, + content_size_px, + crop_size_px, + capture_size_points, + ) +} + +/// Returns the current C ABI version for the native host bridge. +#[unsafe(no_mangle)] +pub extern "C" fn rsnap_host_ffi_abi_version() -> u32 { + RSNAP_HOST_FFI_ABI_VERSION +} + +/// Creates a new opaque live-sampler handle for the native host. +/// +/// # Safety +/// +/// The returned pointer must be released by calling `rsnap_live_sampler_destroy`. +#[cfg(target_os = "macos")] +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rsnap_live_sampler_create() -> *mut RsnapLiveSamplerHandle { + Box::into_raw(Box::new(RsnapLiveSamplerHandle { sampler: HostMacLiveSampler::new() })) +} + +/// Creates a live sampler that keeps selected current-process windows capturable. +/// +/// # Safety +/// +/// `window_ids` must point to `window_id_count` valid `u32` values, or be null when +/// `window_id_count` is zero. The returned pointer must be released by calling +/// `rsnap_live_sampler_destroy`. +#[cfg(target_os = "macos")] +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rsnap_live_sampler_create_with_self_capture_exception_window_ids( + window_ids: *const u32, + window_id_count: usize, +) -> *mut RsnapLiveSamplerHandle { + if window_id_count > 0 && window_ids.is_null() { + return ptr::null_mut(); + } + + let exception_window_ids = if window_id_count == 0 { + Vec::new() + } else { + unsafe { slice::from_raw_parts(window_ids, window_id_count) }.to_vec() + }; + + Box::into_raw(Box::new(RsnapLiveSamplerHandle { + sampler: HostMacLiveSampler::with_self_capture_exception_window_ids(exception_window_ids), + })) +} + +/// Starts warming the live sampler for the requested monitor without blocking on the +/// first captured frame. +/// +/// # Safety +/// +/// `handle` must be a valid pointer returned by `rsnap_live_sampler_create`. +#[cfg(target_os = "macos")] +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rsnap_live_sampler_prime_monitor( + handle: *mut RsnapLiveSamplerHandle, + monitor: RsnapMonitorRect, +) -> RsnapStatus { + let Some(handle) = (unsafe { live_sampler_handle_mut(handle) }) else { + return RsnapStatus::NullHandle; + }; + + handle.sampler.prime_monitor(decode_overlay_monitor(monitor)); + + RsnapStatus::Ok +} + +/// Stops any active ScreenCaptureKit stream while retaining the live-sampler worker. +/// +/// # Safety +/// +/// `handle` must be a valid pointer returned by `rsnap_live_sampler_create`. +#[cfg(target_os = "macos")] +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rsnap_live_sampler_reset( + handle: *mut RsnapLiveSamplerHandle, +) -> RsnapStatus { + let Some(handle) = (unsafe { live_sampler_handle_mut(handle) }) else { + return RsnapStatus::NullHandle; + }; + + handle.sampler.reset(); + + RsnapStatus::Ok +} + +/// Destroys an opaque session handle. +/// +/// # Safety +/// +/// The pointer must either be null or a pointer returned by `rsnap_session_create` that +/// has not already been destroyed. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rsnap_session_destroy(handle: *mut RsnapSessionHandle) { + if let Some(handle) = NonNull::new(handle) { + unsafe { drop(Box::from_raw(handle.as_ptr())); } } @@ -1283,6 +2746,25 @@ pub unsafe extern "C" fn rsnap_owned_rgba_region_release(region: *mut RsnapOwned *region = RsnapOwnedRgbaRegion::default(); } +/// Releases a byte buffer previously returned by an export function. +/// +/// # Safety +/// +/// `bytes` must point to a struct returned by a `*_to_png` function that has not already +/// been released, or be null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn rsnap_owned_bytes_release(bytes: *mut RsnapOwnedBytes) { + let Some(bytes) = (unsafe { bytes.as_mut() }) else { + return; + }; + + if !bytes.bytes.is_null() && bytes.capacity > 0 { + let _ = unsafe { Vec::from_raw_parts(bytes.bytes, bytes.len, bytes.capacity) }; + } + + *bytes = RsnapOwnedBytes::default(); +} + unsafe fn handle_mut<'a>(handle: *mut RsnapSessionHandle) -> Option<&'a mut RsnapSessionHandle> { unsafe { handle.as_mut() } } @@ -1297,6 +2779,18 @@ unsafe fn scroll_session_handle_mut<'a>( unsafe { handle.as_mut() } } +unsafe fn frozen_edit_handle_mut<'a>( + handle: *mut RsnapFrozenOverlayEditSessionHandle, +) -> Option<&'a mut RsnapFrozenOverlayEditSessionHandle> { + unsafe { handle.as_mut() } +} + +unsafe fn frozen_edit_handle_ref<'a>( + handle: *const RsnapFrozenOverlayEditSessionHandle, +) -> Option<&'a RsnapFrozenOverlayEditSessionHandle> { + unsafe { handle.as_ref() } +} + #[cfg(target_os = "macos")] unsafe fn live_sampler_handle_mut<'a>( handle: *mut RsnapLiveSamplerHandle, @@ -1312,6 +2806,645 @@ unsafe fn rgba_bytes<'a>(rgba: *const u8, rgba_len: usize) -> Option<&'a [u8]> { Some(unsafe { slice::from_raw_parts(rgba, rgba_len) }) } +fn decode_pixel_rect(rect: RsnapPixelRect) -> RectPoints { + RectPoints::new(rect.x, rect.y, rect.width, rect.height) +} + +fn decode_float_rect(rect: RsnapFloatRect) -> DisplayPointRect { + DisplayPointRect::new(rect.x, rect.y, rect.width, rect.height) +} + +unsafe fn decode_frozen_overlay_export_elements( + elements: *const RsnapFrozenOverlayExportElement, + elements_len: usize, +) -> Option> { + if elements_len == 0 { + return Some(Vec::new()); + } + if elements.is_null() { + return None; + } + + let elements = unsafe { slice::from_raw_parts(elements, elements_len) }; + + elements + .iter() + .map(|element| unsafe { decode_frozen_overlay_export_element(element) }) + .collect() +} + +unsafe fn decode_frozen_overlay_export_element( + element: &RsnapFrozenOverlayExportElement, +) -> Option { + let color = decode_frozen_annotation_color(element.color); + + match element.kind { + RsnapFrozenOverlayExportElementKind::Pen => { + Some(FrozenOverlayExportElement::Pen(FrozenOverlayExportPen { + points: unsafe { + decode_frozen_overlay_points(element.points, element.points_len) + }?, + style: FrozenOverlayExportStrokeStyle { + stroke_width_points: decode_f32(element.stroke_width_points)?, + rgba: color, + }, + })) + }, + RsnapFrozenOverlayExportElementKind::Arrow => { + Some(FrozenOverlayExportElement::Arrow(FrozenOverlayExportArrow { + start: decode_frozen_overlay_point(element.start)?, + end: decode_frozen_overlay_point(element.end)?, + style: FrozenOverlayExportStrokeStyle { + stroke_width_points: decode_f32(element.stroke_width_points)?, + rgba: color, + }, + })) + }, + RsnapFrozenOverlayExportElementKind::Mosaic => { + Some(FrozenOverlayExportElement::Mosaic(FrozenOverlayExportMosaic { + rect: decode_float_rect(element.rect), + })) + }, + RsnapFrozenOverlayExportElementKind::Spotlight => { + Some(FrozenOverlayExportElement::Spotlight(FrozenOverlayExportSpotlight { + rect: decode_float_rect(element.rect), + style: FrozenOverlayExportSpotlightStyle { + border_width_points: decode_f32(element.border_width_points)?, + border_rgba: color, + }, + })) + }, + RsnapFrozenOverlayExportElementKind::Text => { + Some(FrozenOverlayExportElement::Text(FrozenOverlayExportText { + anchor: decode_frozen_overlay_point(element.start)?, + text: unsafe { decode_optional_utf8(element.text) }?, + style: FrozenOverlayExportTextStyle { + font_size_points: decode_f32(element.font_size_points)?, + rgba: color, + }, + })) + }, + } +} + +unsafe fn decode_frozen_overlay_points( + points: *const RsnapFloatPoint, + points_len: usize, +) -> Option> { + if points_len == 0 { + return Some(Vec::new()); + } + if points.is_null() { + return None; + } + + unsafe { slice::from_raw_parts(points, points_len) } + .iter() + .map(|point| decode_frozen_overlay_point(*point)) + .collect() +} + +fn decode_frozen_overlay_point(point: RsnapFloatPoint) -> Option { + if point.x.is_finite() && point.y.is_finite() { + Some(FrozenOverlayExportPoint::new(point.x, point.y)) + } else { + None + } +} + +unsafe fn decode_optional_utf8(text: *const c_char) -> Option { + if text.is_null() { + return Some(String::new()); + } + + unsafe { CStr::from_ptr(text) }.to_str().ok().map(ToOwned::to_owned) +} + +fn decode_f32(value: f64) -> Option { + (value.is_finite() && value >= f64::from(f32::MIN) && value <= f64::from(f32::MAX)) + .then_some(value as f32) +} + +fn decode_frozen_annotation_color(color: RsnapFrozenAnnotationColor) -> [u8; 4] { + match color { + RsnapFrozenAnnotationColor::White => [255, 255, 255, 255], + RsnapFrozenAnnotationColor::Yellow => [255, 219, 77, 255], + RsnapFrozenAnnotationColor::Green => [92, 214, 149, 255], + RsnapFrozenAnnotationColor::Blue => [102, 178, 255, 255], + RsnapFrozenAnnotationColor::Red => [255, 107, 107, 255], + RsnapFrozenAnnotationColor::Black => [24, 24, 24, 255], + } +} + +fn frozen_overlay_empty_element() -> RsnapFrozenOverlayExportElement { + RsnapFrozenOverlayExportElement { + kind: RsnapFrozenOverlayExportElementKind::Mosaic, + rect: RsnapFloatRect::default(), + start: RsnapFloatPoint::default(), + end: RsnapFloatPoint::default(), + points: ptr::null(), + points_len: 0, + text: ptr::null(), + stroke_width_points: 0.0, + border_width_points: 0.0, + font_size_points: 0.0, + color: RsnapFrozenAnnotationColor::Blue, + } +} + +fn decode_frozen_overlay_edit_style( + style: RsnapFrozenOverlayEditStyle, +) -> Option { + Some(FrozenOverlayEditStyle { + stroke: FrozenOverlayEditStrokeStyle { + stroke_width_points: finite_nonnegative(style.stroke_width_points)?, + color: decode_frozen_edit_color(style.stroke_color), + }, + spotlight: FrozenOverlayEditSpotlightStyle { + border_width_points: finite_nonnegative(style.spotlight_border_width_points)?, + border_color: decode_frozen_edit_color(style.spotlight_color), + }, + text: FrozenOverlayEditTextStyle { + font_size_points: finite_positive(style.text_font_size_points)?, + color: decode_frozen_edit_color(style.text_color), + }, + }) +} + +fn decode_frozen_edit_color(color: RsnapFrozenAnnotationColor) -> FrozenOverlayEditColor { + match color { + RsnapFrozenAnnotationColor::White => FrozenOverlayEditColor::White, + RsnapFrozenAnnotationColor::Yellow => FrozenOverlayEditColor::Yellow, + RsnapFrozenAnnotationColor::Green => FrozenOverlayEditColor::Green, + RsnapFrozenAnnotationColor::Blue => FrozenOverlayEditColor::Blue, + RsnapFrozenAnnotationColor::Red => FrozenOverlayEditColor::Red, + RsnapFrozenAnnotationColor::Black => FrozenOverlayEditColor::Black, + } +} + +fn encode_frozen_edit_color(color: FrozenOverlayEditColor) -> RsnapFrozenAnnotationColor { + match color { + FrozenOverlayEditColor::White => RsnapFrozenAnnotationColor::White, + FrozenOverlayEditColor::Yellow => RsnapFrozenAnnotationColor::Yellow, + FrozenOverlayEditColor::Green => RsnapFrozenAnnotationColor::Green, + FrozenOverlayEditColor::Blue => RsnapFrozenAnnotationColor::Blue, + FrozenOverlayEditColor::Red => RsnapFrozenAnnotationColor::Red, + FrozenOverlayEditColor::Black => RsnapFrozenAnnotationColor::Black, + } +} + +fn decode_frozen_edit_point(point: RsnapFloatPoint) -> Option { + (point.x.is_finite() && point.y.is_finite()) + .then_some(FrozenOverlayEditPoint { x: point.x, y: point.y }) +} + +fn decode_frozen_edit_rect(rect: RsnapFloatRect) -> Option { + let rect = FrozenOverlayEditRect::new(rect.x, rect.y, rect.width, rect.height); + + rect.is_valid().then_some(rect) +} + +fn finite_nonnegative(value: f64) -> Option { + (value.is_finite() && value >= 0.0).then_some(value) +} + +fn finite_positive(value: f64) -> Option { + (value.is_finite() && value > 0.0).then_some(value) +} + +fn encode_frozen_overlay_edit_snapshot( + snapshot: FrozenOverlayEditSnapshot, +) -> Option { + let mut elements = snapshot + .elements + .iter() + .map(encode_frozen_overlay_edit_element) + .collect::>>()?; + let elements_len = elements.len(); + let elements_ptr = if elements.is_empty() { + ptr::null_mut() + } else { + let ptr = elements.as_mut_ptr(); + + mem::forget(elements); + + ptr + }; + + Some(RsnapFrozenOverlayEditSnapshot { + can_undo: u8::from(snapshot.can_undo), + can_redo: u8::from(snapshot.can_redo), + keeps_frozen_selection_fixed: u8::from(snapshot.keeps_frozen_selection_fixed), + is_moving_movable_annotation: u8::from(snapshot.is_moving_movable_annotation), + has_active_interaction: u8::from(snapshot.has_active_interaction), + elements: elements_ptr, + elements_len, + has_preview_pen: u8::from(snapshot.preview_pen.is_some()), + preview_pen: encode_optional_frozen_overlay_edit_pen(snapshot.preview_pen.as_ref())?, + has_preview_arrow: u8::from(snapshot.preview_arrow.is_some()), + preview_arrow: snapshot + .preview_arrow + .map(encode_frozen_overlay_edit_arrow) + .unwrap_or_else(frozen_overlay_empty_element), + has_preview_mosaic: u8::from(snapshot.preview_mosaic.is_some()), + preview_mosaic: snapshot + .preview_mosaic + .map(encode_frozen_overlay_edit_mosaic) + .unwrap_or_else(frozen_overlay_empty_element), + has_preview_spotlight: u8::from(snapshot.preview_spotlight.is_some()), + preview_spotlight: snapshot + .preview_spotlight + .map(encode_frozen_overlay_edit_spotlight) + .unwrap_or_else(frozen_overlay_empty_element), + has_preview_text: u8::from(snapshot.preview_text.is_some()), + preview_text: encode_optional_frozen_overlay_edit_text(snapshot.preview_text.as_ref())?, + has_active_text_edit: u8::from(snapshot.active_text_edit.is_some()), + active_text_edit: encode_optional_frozen_overlay_active_text_edit( + snapshot.active_text_edit.as_ref(), + )?, + }) +} + +fn encode_frozen_overlay_edit_element( + element: &FrozenOverlayEditElement, +) -> Option { + match element { + FrozenOverlayEditElement::Pen(annotation) => encode_frozen_overlay_edit_pen(annotation), + FrozenOverlayEditElement::Arrow(annotation) => { + Some(encode_frozen_overlay_edit_arrow(*annotation)) + }, + FrozenOverlayEditElement::Mosaic(annotation) => { + Some(encode_frozen_overlay_edit_mosaic(*annotation)) + }, + FrozenOverlayEditElement::Spotlight(annotation) => { + Some(encode_frozen_overlay_edit_spotlight(*annotation)) + }, + FrozenOverlayEditElement::Text(annotation) => encode_frozen_overlay_edit_text(annotation), + } +} + +fn encode_frozen_overlay_edit_pen( + annotation: &FrozenOverlayEditPen, +) -> Option { + let (points, points_len) = owned_frozen_overlay_points(&annotation.points); + + Some(RsnapFrozenOverlayExportElement { + kind: RsnapFrozenOverlayExportElementKind::Pen, + points, + points_len, + stroke_width_points: annotation.style.stroke_width_points, + color: encode_frozen_edit_color(annotation.style.color), + ..frozen_overlay_empty_element() + }) +} + +fn encode_optional_frozen_overlay_edit_pen( + annotation: Option<&FrozenOverlayEditPen>, +) -> Option { + annotation.map_or_else(|| Some(frozen_overlay_empty_element()), encode_frozen_overlay_edit_pen) +} + +fn encode_frozen_overlay_edit_arrow( + annotation: FrozenOverlayEditArrow, +) -> RsnapFrozenOverlayExportElement { + RsnapFrozenOverlayExportElement { + kind: RsnapFrozenOverlayExportElementKind::Arrow, + start: encode_frozen_edit_point(annotation.start), + end: encode_frozen_edit_point(annotation.end), + stroke_width_points: annotation.style.stroke_width_points, + color: encode_frozen_edit_color(annotation.style.color), + ..frozen_overlay_empty_element() + } +} + +fn encode_frozen_overlay_edit_mosaic( + annotation: FrozenOverlayEditMosaic, +) -> RsnapFrozenOverlayExportElement { + RsnapFrozenOverlayExportElement { + kind: RsnapFrozenOverlayExportElementKind::Mosaic, + rect: encode_frozen_edit_rect(annotation.rect), + ..frozen_overlay_empty_element() + } +} + +fn encode_frozen_overlay_edit_spotlight( + annotation: FrozenOverlayEditSpotlight, +) -> RsnapFrozenOverlayExportElement { + RsnapFrozenOverlayExportElement { + kind: RsnapFrozenOverlayExportElementKind::Spotlight, + rect: encode_frozen_edit_rect(annotation.rect), + border_width_points: annotation.style.border_width_points, + color: encode_frozen_edit_color(annotation.style.border_color), + ..frozen_overlay_empty_element() + } +} + +fn encode_frozen_overlay_edit_text( + annotation: &FrozenOverlayEditText, +) -> Option { + let text = owned_c_string(&annotation.text)?; + + Some(RsnapFrozenOverlayExportElement { + kind: RsnapFrozenOverlayExportElementKind::Text, + start: encode_frozen_edit_point(annotation.anchor), + text, + font_size_points: annotation.style.font_size_points, + color: encode_frozen_edit_color(annotation.style.color), + ..frozen_overlay_empty_element() + }) +} + +fn encode_optional_frozen_overlay_edit_text( + annotation: Option<&FrozenOverlayEditText>, +) -> Option { + annotation.map_or_else(|| Some(frozen_overlay_empty_element()), encode_frozen_overlay_edit_text) +} + +fn encode_frozen_overlay_active_text_edit( + edit: &FrozenOverlayTextEdit, +) -> Option { + let text = owned_c_string(&edit.text)?; + + Some(RsnapFrozenOverlayExportElement { + kind: RsnapFrozenOverlayExportElementKind::Text, + start: encode_frozen_edit_point(edit.anchor), + text, + ..frozen_overlay_empty_element() + }) +} + +fn encode_optional_frozen_overlay_active_text_edit( + edit: Option<&FrozenOverlayTextEdit>, +) -> Option { + edit.map_or_else( + || Some(frozen_overlay_empty_element()), + encode_frozen_overlay_active_text_edit, + ) +} + +fn owned_frozen_overlay_points( + points: &[FrozenOverlayEditPoint], +) -> (*const RsnapFloatPoint, usize) { + if points.is_empty() { + return (ptr::null(), 0); + } + + let mut owned: Vec<_> = points.iter().copied().map(encode_frozen_edit_point).collect(); + let ptr = owned.as_mut_ptr(); + let len = owned.len(); + + mem::forget(owned); + + (ptr, len) +} + +fn owned_c_string(text: &str) -> Option<*const c_char> { + CString::new(text).ok().map(|text| text.into_raw() as *const c_char) +} + +fn encode_frozen_edit_point(point: FrozenOverlayEditPoint) -> RsnapFloatPoint { + RsnapFloatPoint { x: point.x, y: point.y } +} + +fn encode_frozen_edit_rect(rect: FrozenOverlayEditRect) -> RsnapFloatRect { + RsnapFloatRect { x: rect.x, y: rect.y, width: rect.width, height: rect.height } +} + +unsafe fn release_frozen_overlay_snapshot_elements( + elements: *mut RsnapFrozenOverlayExportElement, + elements_len: usize, +) { + if elements.is_null() || elements_len == 0 { + return; + } + + let mut elements = unsafe { Vec::from_raw_parts(elements, elements_len, elements_len) }; + + for element in &mut elements { + unsafe { + release_frozen_overlay_snapshot_element(element); + } + } +} + +unsafe fn release_frozen_overlay_snapshot_element(element: &mut RsnapFrozenOverlayExportElement) { + if !element.points.is_null() && element.points_len > 0 { + let _ = unsafe { + Vec::from_raw_parts( + element.points as *mut RsnapFloatPoint, + element.points_len, + element.points_len, + ) + }; + } + if !element.text.is_null() { + let _ = unsafe { CString::from_raw(element.text as *mut c_char) }; + } + + *element = frozen_overlay_empty_element(); +} + +unsafe fn decode_bgra_frame<'a>( + width: u32, + height: u32, + bytes_per_row: usize, + bgra: *const u8, + bgra_len: usize, +) -> Option> { + if bgra.is_null() { + return None; + } + + let bytes = unsafe { slice::from_raw_parts(bgra, bgra_len) }; + let frame = BgraFrameView { width, height, bytes_per_row, bytes }; + + frame.is_valid().then_some(frame) +} + +fn encode_float_rect(rect: DisplayPointRect) -> RsnapFloatRect { + RsnapFloatRect { x: rect.x, y: rect.y, width: rect.width, height: rect.height } +} + +fn encode_pixel_rect(rect: RectPoints) -> RsnapPixelRect { + RsnapPixelRect { x: rect.x, y: rect.y, width: rect.width, height: rect.height } +} + +fn decode_capture_frame_source_kind(kind: RsnapCaptureFrameSourceKind) -> CaptureFrameSourceKind { + match kind { + RsnapCaptureFrameSourceKind::DragRegion => CaptureFrameSourceKind::DragRegion, + RsnapCaptureFrameSourceKind::Window => CaptureFrameSourceKind::Window, + RsnapCaptureFrameSourceKind::FullScreen => CaptureFrameSourceKind::FullScreen, + RsnapCaptureFrameSourceKind::ScrollCapture => CaptureFrameSourceKind::ScrollCapture, + RsnapCaptureFrameSourceKind::Unknown => CaptureFrameSourceKind::Unknown, + } +} + +fn decode_capture_frame_background_kind( + kind: RsnapCaptureFrameBackgroundKind, +) -> CaptureFrameBackgroundKind { + match kind { + RsnapCaptureFrameBackgroundKind::SystemWallpaper => { + CaptureFrameBackgroundKind::SystemWallpaper + }, + RsnapCaptureFrameBackgroundKind::Aurora => CaptureFrameBackgroundKind::Aurora, + RsnapCaptureFrameBackgroundKind::Graphite => CaptureFrameBackgroundKind::Graphite, + RsnapCaptureFrameBackgroundKind::Linen => CaptureFrameBackgroundKind::Linen, + } +} + +fn decode_capture_frame_render_kind(kind: RsnapCaptureFrameRenderKind) -> CaptureFrameRenderKind { + match kind { + RsnapCaptureFrameRenderKind::FramedCapture => CaptureFrameRenderKind::FramedCapture, + RsnapCaptureFrameRenderKind::WindowSnapshot => CaptureFrameRenderKind::WindowSnapshot, + } +} + +unsafe fn capture_frame_wallpaper_for_render( + source: CaptureFrameRenderImageRef<'_>, + screen_scale_factor: f64, + source_kind: CaptureFrameSourceKind, + background_kind: CaptureFrameBackgroundKind, + wallpaper_path: *const c_char, +) -> Result, ()> { + if wallpaper_path.is_null() { + return Ok(None); + } + + let Some(plan) = rsnap_capture_core::capture_frame_plan( + source.width(), + source.height(), + screen_scale_factor, + source_kind, + ) else { + return Ok(None); + }; + let Some(request) = rsnap_capture_core::capture_frame_wallpaper_request_plan( + background_kind, + plan.canvas_width, + plan.canvas_height, + ) else { + return Ok(None); + }; + let path = unsafe { CStr::from_ptr(wallpaper_path) }.to_str().map_err(|_| ())?; + + match rsnap_capture_core::capture_frame_wallpaper_png_thumbnail_cached( + path, + request.target_pixel_size, + ) { + Ok(thumbnail) => Ok(thumbnail), + Err(_err) => Ok(None), + } +} + +fn encode_capture_frame_plan(plan: CaptureFramePlan) -> RsnapCaptureFramePlan { + RsnapCaptureFramePlan { + canvas_width: plan.canvas_width, + canvas_height: plan.canvas_height, + image_rect: encode_float_rect(plan.image_rect), + corner_radius: plan.corner_radius, + shadows: plan.shadows.map(encode_capture_frame_shadow), + } +} + +fn decode_frozen_selection_transform_kind( + kind: RsnapFrozenSelectionTransformKind, +) -> FrozenSelectionTransformKind { + match kind { + RsnapFrozenSelectionTransformKind::Move => FrozenSelectionTransformKind::Move, + RsnapFrozenSelectionTransformKind::ResizeLeft => FrozenSelectionTransformKind::ResizeLeft, + RsnapFrozenSelectionTransformKind::ResizeRight => FrozenSelectionTransformKind::ResizeRight, + RsnapFrozenSelectionTransformKind::ResizeTop => FrozenSelectionTransformKind::ResizeTop, + RsnapFrozenSelectionTransformKind::ResizeBottom => { + FrozenSelectionTransformKind::ResizeBottom + }, + RsnapFrozenSelectionTransformKind::ResizeTopLeft => { + FrozenSelectionTransformKind::ResizeTopLeft + }, + RsnapFrozenSelectionTransformKind::ResizeTopRight => { + FrozenSelectionTransformKind::ResizeTopRight + }, + RsnapFrozenSelectionTransformKind::ResizeBottomLeft => { + FrozenSelectionTransformKind::ResizeBottomLeft + }, + RsnapFrozenSelectionTransformKind::ResizeBottomRight => { + FrozenSelectionTransformKind::ResizeBottomRight + }, + } +} + +fn encode_frozen_selection_transform_kind( + kind: FrozenSelectionTransformKind, +) -> RsnapFrozenSelectionTransformKind { + match kind { + FrozenSelectionTransformKind::Move => RsnapFrozenSelectionTransformKind::Move, + FrozenSelectionTransformKind::ResizeLeft => RsnapFrozenSelectionTransformKind::ResizeLeft, + FrozenSelectionTransformKind::ResizeRight => RsnapFrozenSelectionTransformKind::ResizeRight, + FrozenSelectionTransformKind::ResizeTop => RsnapFrozenSelectionTransformKind::ResizeTop, + FrozenSelectionTransformKind::ResizeBottom => { + RsnapFrozenSelectionTransformKind::ResizeBottom + }, + FrozenSelectionTransformKind::ResizeTopLeft => { + RsnapFrozenSelectionTransformKind::ResizeTopLeft + }, + FrozenSelectionTransformKind::ResizeTopRight => { + RsnapFrozenSelectionTransformKind::ResizeTopRight + }, + FrozenSelectionTransformKind::ResizeBottomLeft => { + RsnapFrozenSelectionTransformKind::ResizeBottomLeft + }, + FrozenSelectionTransformKind::ResizeBottomRight => { + RsnapFrozenSelectionTransformKind::ResizeBottomRight + }, + } +} + +fn encode_capture_frame_background_plan( + plan: CaptureFrameBackgroundPlan, +) -> RsnapCaptureFrameBackgroundPlan { + RsnapCaptureFrameBackgroundPlan { + colors: plan.colors.map(encode_capture_frame_color_stop), + locations: plan.locations, + prefers_wallpaper: u8::from(plan.prefers_wallpaper), + wallpaper_overlay_alpha: plan.wallpaper_overlay_alpha, + } +} + +fn encode_capture_frame_color_stop(color: CaptureFrameColorStop) -> RsnapCaptureFrameColorStop { + RsnapCaptureFrameColorStop { + red: color.red, + green: color.green, + blue: color.blue, + alpha: color.alpha, + } +} + +fn encode_capture_frame_shadow(shadow: CaptureFrameShadow) -> RsnapCaptureFrameShadow { + RsnapCaptureFrameShadow { + offset_x: shadow.offset_x, + offset_y: shadow.offset_y, + blur: shadow.blur, + alpha: shadow.alpha, + } +} + +fn encode_capture_frame_wallpaper_request( + request: CaptureFrameWallpaperRequest, +) -> RsnapCaptureFrameWallpaperRequest { + RsnapCaptureFrameWallpaperRequest { + target_pixel_size: request.target_pixel_size, + overlay_alpha: request.overlay_alpha, + } +} + +fn encode_scroll_minimap_plan(plan: ScrollMinimapPlan) -> RsnapScrollMinimapPlan { + RsnapScrollMinimapPlan { + frame: encode_float_rect(plan.frame), + image_frame: encode_float_rect(plan.image_frame), + has_viewport_frame: u8::from(plan.viewport_frame.is_some()), + viewport_frame: plan.viewport_frame.map_or_else(RsnapFloatRect::default, encode_float_rect), + } +} + fn decode_session_config(config: RsnapSessionConfig) -> SessionConfig { SessionConfig { platform: match config.platform { @@ -1619,16 +3752,28 @@ fn encode_scroll_observe_result( } fn owned_region_from_scroll_image(image: ScrollStitchImage) -> RsnapOwnedRgbaRegion { - let mut rgba = image.rgba; + owned_region_from_raw_rgba(image.width, image.height, image.rgba) +} + +fn owned_region_from_raw_rgba(width: u32, height: u32, mut rgba: Vec) -> RsnapOwnedRgbaRegion { let out = RsnapOwnedRgbaRegion { - width: image.width, - height: image.height, + width, + height, len: rgba.len(), capacity: rgba.capacity(), rgba: rgba.as_mut_ptr(), }; - mem::forget(rgba); + mem::forget(rgba); + + out +} + +fn owned_bytes_from_vec(mut bytes: Vec) -> RsnapOwnedBytes { + let out = + RsnapOwnedBytes { len: bytes.len(), capacity: bytes.capacity(), bytes: bytes.as_mut_ptr() }; + + mem::forget(bytes); out } @@ -1744,17 +3889,28 @@ fn encode_window(window: WindowRect) -> RsnapWindowRect { #[cfg(test)] mod tests { + use std::env; + use std::ffi::CString; + use std::fs; + use std::process; use std::ptr; + use std::slice; use crate::{ - RSNAP_HOST_FFI_ABI_VERSION, RSNAP_STATUS_MESSAGE_CAPACITY, RsnapCursorIntent, - RsnapHostEvent, RsnapHostEventKind, RsnapHostReport, RsnapHostReportKind, - RsnapHostRequestKind, RsnapHostRequestValue, RsnapMonitorRect, RsnapPlatformTag, - RsnapPoint, RsnapRect, RsnapRgb, RsnapSceneKind, RsnapSceneModel, RsnapSessionConfig, - RsnapSessionHandle, RsnapStatus, RsnapWindowRect, + RSNAP_HOST_FFI_ABI_VERSION, RSNAP_STATUS_MESSAGE_CAPACITY, RsnapCaptureFrameBackgroundKind, + RsnapCaptureFrameBackgroundPlan, RsnapCaptureFrameColorStop, RsnapCaptureFramePlan, + RsnapCaptureFrameRenderKind, RsnapCaptureFrameSourceKind, + RsnapCaptureFrameWallpaperRequest, RsnapCursorIntent, RsnapFloatPoint, RsnapFloatRect, + RsnapFrozenAnnotationColor, RsnapFrozenOverlayEditSnapshot, RsnapFrozenOverlayEditStyle, + RsnapFrozenOverlayExportElement, RsnapFrozenOverlayExportElementKind, + RsnapFrozenSelectionTransformKind, RsnapHostEvent, RsnapHostEventKind, RsnapHostReport, + RsnapHostReportKind, RsnapHostRequestKind, RsnapHostRequestValue, RsnapMonitorRect, + RsnapOwnedBytes, RsnapOwnedRgbaRegion, RsnapPixelRect, RsnapPlatformTag, RsnapPoint, + RsnapRect, RsnapRgb, RsnapSceneKind, RsnapSceneModel, RsnapScrollMinimapPlan, + RsnapSessionConfig, RsnapSessionHandle, RsnapStatus, RsnapToolbarItemKind, RsnapWindowRect, }; #[cfg(target_os = "macos")] - use crate::{RsnapOwnedRgbaRegion, RsnapScrollObserveOutcomeKind, RsnapScrollObserveResult}; + use crate::{RsnapScrollObserveOutcomeKind, RsnapScrollObserveResult}; fn default_config() -> RsnapSessionConfig { RsnapSessionConfig { @@ -1764,7 +3920,6 @@ mod tests { } } - #[cfg(target_os = "macos")] fn scroll_frame(width: u32, height: u32, top_row: u32) -> Vec { let mut rgba = Vec::with_capacity((width * height * 4) as usize); @@ -1782,6 +3937,15 @@ mod tests { rgba } + fn png_dimensions(png: &[u8]) -> (u32, u32) { + assert!(png.starts_with(b"\x89PNG\r\n\x1a\n")); + + let width = u32::from_be_bytes(png[16..20].try_into().expect("PNG width bytes")); + let height = u32::from_be_bytes(png[20..24].try_into().expect("PNG height bytes")); + + (width, height) + } + #[test] fn ffi_session_enters_live_and_emits_request() { let handle = unsafe { crate::rsnap_session_create(default_config()) }; @@ -1908,6 +4072,678 @@ mod tests { unsafe { crate::rsnap_session_destroy(handle) }; } + #[test] + fn ffi_export_rgba_to_png_returns_owned_png() { + let rgba = scroll_frame(4, 4, 0); + let mut png = RsnapOwnedBytes::default(); + + assert_eq!( + unsafe { crate::rsnap_export_rgba_to_png(4, 4, rgba.as_ptr(), rgba.len(), &mut png) }, + RsnapStatus::Ok + ); + assert!(png.len > 0); + assert_eq!(png_dimensions(unsafe { slice::from_raw_parts(png.bytes, png.len) }), (4, 4)); + + unsafe { + crate::rsnap_owned_bytes_release(&mut png); + } + + assert!(png.bytes.is_null()); + assert_eq!(png.len, 0); + assert_eq!(png.capacity, 0); + } + + #[test] + fn ffi_export_rgba_crop_to_png_crops_dimensions() { + let rgba = scroll_frame(4, 4, 0); + let crop = RsnapPixelRect { x: 1, y: 0, width: 2, height: 3 }; + let mut png = RsnapOwnedBytes::default(); + + assert_eq!( + unsafe { + crate::rsnap_export_rgba_crop_to_png( + 4, + 4, + rgba.as_ptr(), + rgba.len(), + crop, + &mut png, + ) + }, + RsnapStatus::Ok + ); + assert_eq!(png_dimensions(unsafe { slice::from_raw_parts(png.bytes, png.len) }), (2, 3)); + + unsafe { + crate::rsnap_owned_bytes_release(&mut png); + } + } + + #[test] + fn ffi_export_rgba_crop_to_png_rejects_out_of_bounds_crop() { + let rgba = scroll_frame(4, 4, 0); + let crop = RsnapPixelRect { x: 3, y: 3, width: 2, height: 2 }; + let mut png = RsnapOwnedBytes::default(); + + assert_eq!( + unsafe { + crate::rsnap_export_rgba_crop_to_png( + 4, + 4, + rgba.as_ptr(), + rgba.len(), + crop, + &mut png, + ) + }, + RsnapStatus::InvalidInput + ); + assert!(png.bytes.is_null()); + } + + #[test] + fn ffi_frozen_display_crop_rect_returns_core_pixel_rect() { + let mut out_rect = RsnapPixelRect::default(); + let status = unsafe { + crate::rsnap_frozen_display_crop_rect( + 2_880, + 1_800, + RsnapFloatRect { x: 0.0, y: 0.0, width: 1_440.0, height: 900.0 }, + RsnapFloatRect { x: 100.0, y: 200.0, width: 300.0, height: 150.0 }, + &mut out_rect, + ) + }; + + assert_eq!(status, RsnapStatus::Ok); + assert_eq!(out_rect, RsnapPixelRect { x: 200, y: 1_100, width: 600, height: 300 }); + } + + #[test] + fn ffi_frozen_display_crop_rect_returns_empty_for_outside_selection() { + let mut out_rect = RsnapPixelRect::default(); + let status = unsafe { + crate::rsnap_frozen_display_crop_rect( + 200, + 200, + RsnapFloatRect { x: 0.0, y: 0.0, width: 100.0, height: 100.0 }, + RsnapFloatRect { x: 120.0, y: 10.0, width: 10.0, height: 20.0 }, + &mut out_rect, + ) + }; + + assert_eq!(status, RsnapStatus::Empty); + } + + #[test] + fn ffi_frozen_mosaic_light_privacy_patch_returns_rgba_region() { + let mut patch = RsnapOwnedRgbaRegion::default(); + let status = unsafe { + crate::rsnap_frozen_mosaic_light_privacy_patch_rgba( + 100, + 80, + RsnapFloatRect { x: 4.2, y: 9.1, width: 28.4, height: 21.0 }, + &mut patch, + ) + }; + + assert_eq!(status, RsnapStatus::Ok); + assert_eq!(patch.width, 3); + assert_eq!(patch.height, 3); + assert_eq!(patch.len, 36); + + let bytes = unsafe { slice::from_raw_parts(patch.rgba, patch.len) }; + + assert_eq!(&bytes[..12], &[211, 211, 211, 255, 205, 205, 205, 255, 202, 201, 199, 255]); + + unsafe { + crate::rsnap_owned_rgba_region_release(&mut patch); + } + } + + #[test] + fn ffi_frozen_mosaic_light_privacy_patch_returns_empty_for_outside_rect() { + let mut patch = RsnapOwnedRgbaRegion::default(); + let status = unsafe { + crate::rsnap_frozen_mosaic_light_privacy_patch_rgba( + 100, + 80, + RsnapFloatRect { x: 120.0, y: 10.0, width: 10.0, height: 20.0 }, + &mut patch, + ) + }; + + assert_eq!(status, RsnapStatus::Empty); + } + + #[test] + fn ffi_frozen_overlay_export_render_rgba_returns_composited_region() { + let mut rgba = vec![180_u8; 64 * 40 * 4]; + + for alpha in (3..rgba.len()).step_by(4) { + rgba[alpha] = 255; + } + + let points = [RsnapFloatPoint { x: 2.0, y: 2.0 }, RsnapFloatPoint { x: 24.0, y: 18.0 }]; + let text = CString::new("Hi").expect("text has no interior nul"); + let elements = [ + RsnapFrozenOverlayExportElement { + kind: RsnapFrozenOverlayExportElementKind::Mosaic, + rect: RsnapFloatRect { x: 4.0, y: 4.0, width: 16.0, height: 10.0 }, + start: RsnapFloatPoint::default(), + end: RsnapFloatPoint::default(), + points: ptr::null(), + points_len: 0, + text: ptr::null(), + stroke_width_points: 0.0, + border_width_points: 0.0, + font_size_points: 0.0, + color: RsnapFrozenAnnotationColor::Blue, + }, + RsnapFrozenOverlayExportElement { + kind: RsnapFrozenOverlayExportElementKind::Pen, + rect: RsnapFloatRect::default(), + start: RsnapFloatPoint::default(), + end: RsnapFloatPoint::default(), + points: points.as_ptr(), + points_len: points.len(), + text: ptr::null(), + stroke_width_points: 2.0, + border_width_points: 0.0, + font_size_points: 0.0, + color: RsnapFrozenAnnotationColor::Blue, + }, + RsnapFrozenOverlayExportElement { + kind: RsnapFrozenOverlayExportElementKind::Text, + rect: RsnapFloatRect::default(), + start: RsnapFloatPoint { x: 6.0, y: 24.0 }, + end: RsnapFloatPoint::default(), + points: ptr::null(), + points_len: 0, + text: text.as_ptr(), + stroke_width_points: 0.0, + border_width_points: 0.0, + font_size_points: 12.0, + color: RsnapFrozenAnnotationColor::White, + }, + ]; + let mut out = RsnapOwnedRgbaRegion::default(); + let status = unsafe { + crate::rsnap_frozen_overlay_export_render_rgba( + 64, + 40, + rgba.as_ptr(), + rgba.len(), + RsnapFloatRect { x: 0.0, y: 0.0, width: 64.0, height: 40.0 }, + elements.as_ptr(), + elements.len(), + &mut out, + ) + }; + + assert_eq!(status, RsnapStatus::Ok); + assert_eq!(out.width, 64); + assert_eq!(out.height, 40); + assert_eq!(out.len, rgba.len()); + + let bytes = unsafe { slice::from_raw_parts(out.rgba, out.len) }; + + assert_ne!(bytes, rgba.as_slice()); + + unsafe { + crate::rsnap_owned_rgba_region_release(&mut out); + } + } + + #[test] + fn ffi_frozen_overlay_edit_session_copies_rust_owned_snapshot() { + let handle = crate::rsnap_frozen_overlay_edit_session_create(); + + assert!(!handle.is_null()); + + let style = RsnapFrozenOverlayEditStyle { + stroke_width_points: 3.0, + stroke_color: RsnapFrozenAnnotationColor::Blue, + spotlight_border_width_points: 0.0, + spotlight_color: RsnapFrozenAnnotationColor::Blue, + text_font_size_points: 16.0, + text_color: RsnapFrozenAnnotationColor::White, + }; + let selection = RsnapFloatRect { x: 0.0, y: 0.0, width: 200.0, height: 120.0 }; + let mut changed = 0; + let status = unsafe { + crate::rsnap_frozen_overlay_edit_session_begin( + handle, + RsnapToolbarItemKind::Text, + RsnapFloatPoint { x: 12.0, y: 18.0 }, + selection, + style, + &mut changed, + ) + }; + + assert_eq!(status, RsnapStatus::Ok); + assert_eq!(changed, 1); + + let text = CString::new("Hello").expect("text has no interior nul"); + let status = unsafe { + crate::rsnap_frozen_overlay_edit_session_append_text( + handle, + text.as_ptr(), + &mut changed, + ) + }; + + assert_eq!(status, RsnapStatus::Ok); + assert_eq!(changed, 1); + + let status = unsafe { + crate::rsnap_frozen_overlay_edit_session_commit_text(handle, style, &mut changed) + }; + + assert_eq!(status, RsnapStatus::Ok); + assert_eq!(changed, 1); + + let mut snapshot = RsnapFrozenOverlayEditSnapshot::default(); + let status = unsafe { + crate::rsnap_frozen_overlay_edit_session_copy_snapshot(handle, &mut snapshot) + }; + + assert_eq!(status, RsnapStatus::Ok); + assert_eq!(snapshot.can_undo, 1); + assert_eq!(snapshot.elements_len, 1); + + let elements = unsafe { slice::from_raw_parts(snapshot.elements, snapshot.elements_len) }; + + assert_eq!(elements[0].kind, RsnapFrozenOverlayExportElementKind::Text); + assert_eq!(unsafe { std::ffi::CStr::from_ptr(elements[0].text) }.to_str(), Ok("Hello")); + + unsafe { + crate::rsnap_frozen_overlay_edit_snapshot_release(&mut snapshot); + crate::rsnap_frozen_overlay_edit_session_destroy(handle); + } + } + + #[test] + fn ffi_bgra_frame_sample_rgb_returns_core_sample() { + let bgra = bgra_frame(4, 3, 16); + let mut rgb = RsnapRgb::default(); + let status = unsafe { + crate::rsnap_bgra_frame_sample_rgb( + 4, + 3, + 16, + bgra.as_ptr(), + bgra.len(), + RsnapFloatRect { x: 0.0, y: 0.0, width: 4.0, height: 3.0 }, + 1.0, + 2.5, + &mut rgb, + ) + }; + + assert_eq!(status, RsnapStatus::Ok); + assert_eq!(rgb, RsnapRgb { r: 11, g: 21, b: 31 }); + } + + #[test] + fn ffi_bgra_frame_loupe_patch_returns_rgba_region() { + let bgra = bgra_frame(4, 3, 16); + let mut patch = RsnapOwnedRgbaRegion::default(); + let status = unsafe { + crate::rsnap_bgra_frame_loupe_patch_rgba( + 4, + 3, + 16, + bgra.as_ptr(), + bgra.len(), + RsnapFloatRect { x: 0.0, y: 0.0, width: 4.0, height: 3.0 }, + 0.0, + 2.0, + 3, + &mut patch, + ) + }; + + assert_eq!(status, RsnapStatus::Ok); + assert_eq!(patch.width, 3); + assert_eq!(patch.height, 3); + assert_eq!(patch.len, 36); + + let bytes = unsafe { slice::from_raw_parts(patch.rgba, patch.len) }; + + assert_eq!(&bytes[..8], &[10, 20, 30, 200, 10, 20, 30, 200]); + + unsafe { + crate::rsnap_owned_rgba_region_release(&mut patch); + } + } + + #[test] + fn ffi_bgra_frame_loupe_patch_rejects_invalid_storage() { + let bgra = bgra_frame(4, 3, 16); + let mut patch = RsnapOwnedRgbaRegion::default(); + let status = unsafe { + crate::rsnap_bgra_frame_loupe_patch_rgba( + 4, + 3, + 12, + bgra.as_ptr(), + bgra.len(), + RsnapFloatRect { x: 0.0, y: 0.0, width: 4.0, height: 3.0 }, + 0.0, + 2.0, + 3, + &mut patch, + ) + }; + + assert_eq!(status, RsnapStatus::InvalidInput); + assert!(patch.rgba.is_null()); + } + + #[test] + fn ffi_capture_frame_plan_returns_core_geometry() { + let mut plan = RsnapCaptureFramePlan::default(); + let status = unsafe { + crate::rsnap_capture_frame_plan( + 320, + 180, + 2.0, + RsnapCaptureFrameSourceKind::Window, + &mut plan, + ) + }; + + assert_eq!(status, RsnapStatus::Ok); + assert_eq!(plan.canvas_width, 416.0); + assert_eq!(plan.canvas_height, 276.0); + assert_eq!( + plan.image_rect, + RsnapFloatRect { x: 48.0, y: 48.0, width: 320.0, height: 180.0 } + ); + assert_eq!(plan.corner_radius, 9.9); + assert_eq!(plan.shadows[0].blur, 80.0); + assert_eq!(plan.shadows[1].offset_y, -22.0); + } + + #[test] + fn ffi_capture_frame_aspect_fill_crop_rect_returns_core_rect() { + let mut rect = RsnapFloatRect::default(); + let status = unsafe { + crate::rsnap_capture_frame_aspect_fill_crop_rect( + 1_600, 900, 1_000.0, 1_000.0, &mut rect, + ) + }; + + assert_eq!(status, RsnapStatus::Ok); + assert_eq!(rect, RsnapFloatRect { x: 350.0, y: 0.0, width: 900.0, height: 900.0 }); + } + + #[test] + fn ffi_capture_frame_background_plan_returns_core_preset() { + let mut plan = RsnapCaptureFrameBackgroundPlan::default(); + let status = unsafe { + crate::rsnap_capture_frame_background_plan( + RsnapCaptureFrameBackgroundKind::Graphite, + &mut plan, + ) + }; + + assert_eq!(status, RsnapStatus::Ok); + assert_eq!(plan.prefers_wallpaper, 0); + assert_eq!(plan.wallpaper_overlay_alpha, 0.0); + assert_eq!(plan.locations, [0.0, 0.54, 1.0]); + assert_eq!( + plan.colors[0], + RsnapCaptureFrameColorStop { red: 0.08, green: 0.09, blue: 0.11, alpha: 1.0 } + ); + assert_eq!( + plan.colors[2], + RsnapCaptureFrameColorStop { red: 0.56, green: 0.59, blue: 0.64, alpha: 1.0 } + ); + } + + #[test] + fn ffi_capture_frame_wallpaper_request_returns_core_thumbnail_policy() { + let mut request = RsnapCaptureFrameWallpaperRequest::default(); + let status = unsafe { + crate::rsnap_capture_frame_wallpaper_request_plan( + RsnapCaptureFrameBackgroundKind::SystemWallpaper, + 1_535.2, + 996.0, + &mut request, + ) + }; + + assert_eq!(status, RsnapStatus::Ok); + assert_eq!(request.target_pixel_size, 1_536); + assert_eq!(request.overlay_alpha, 0.10); + } + + #[test] + fn ffi_capture_frame_wallpaper_request_returns_empty_for_gradient_background() { + let mut request = RsnapCaptureFrameWallpaperRequest::default(); + let status = unsafe { + crate::rsnap_capture_frame_wallpaper_request_plan( + RsnapCaptureFrameBackgroundKind::Aurora, + 1_536.0, + 996.0, + &mut request, + ) + }; + + assert_eq!(status, RsnapStatus::Empty); + } + + #[test] + fn ffi_capture_frame_wallpaper_png_thumbnail_returns_owned_region() { + let path_buf = + env::temp_dir().join(format!("rsnap-ffi-wallpaper-thumb-{}-rgba.png", process::id())); + let png = rsnap_capture_core::RgbaExportImage::from_raw( + 4, + 2, + vec![ + 255, 0, 0, 255, 0, 255, 0, 255, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 0, + 255, 0, 255, 255, 255, 255, 0, 255, 255, 20, 30, 40, 255, + ], + ) + .expect("test RGBA payload should create an image") + .to_png_bytes() + .expect("test RGBA image should encode as PNG"); + + fs::write(&path_buf, png).expect("test PNG should be written"); + + let path = CString::new(path_buf.to_string_lossy().as_bytes()) + .expect("test PNG path should not contain interior NUL bytes"); + let mut thumbnail = RsnapOwnedRgbaRegion::default(); + let status = unsafe { + crate::rsnap_capture_frame_wallpaper_png_thumbnail(path.as_ptr(), 64, &mut thumbnail) + }; + + assert_eq!(status, RsnapStatus::Ok); + assert!(thumbnail.width <= 64); + assert!(thumbnail.height <= 64); + assert_eq!(thumbnail.len, thumbnail.width as usize * thumbnail.height as usize * 4); + + unsafe { + crate::rsnap_owned_rgba_region_release(&mut thumbnail); + } + + let _ = fs::remove_file(path_buf); + } + + #[test] + fn ffi_capture_frame_render_rgba_returns_owned_composition() { + let rgba = [255; 4 * 2 * 4]; + let mut rendered = RsnapOwnedRgbaRegion::default(); + let status = unsafe { + crate::rsnap_capture_frame_render_rgba( + 4, + 2, + rgba.as_ptr(), + rgba.len(), + 2.0, + RsnapCaptureFrameSourceKind::DragRegion, + RsnapCaptureFrameBackgroundKind::Aurora, + RsnapCaptureFrameRenderKind::WindowSnapshot, + ptr::null(), + &mut rendered, + ) + }; + + assert_eq!(status, RsnapStatus::Ok); + assert_eq!(rendered.width, 100); + assert_eq!(rendered.height, 98); + assert_eq!(rendered.len, 100 * 98 * 4); + + let bytes = unsafe { slice::from_raw_parts(rendered.rgba, rendered.len) }; + let first_source_pixel = ((48 * rendered.width as usize) + 48) * 4; + + assert_eq!(&bytes[first_source_pixel..first_source_pixel + 4], &[255, 255, 255, 255]); + + unsafe { + crate::rsnap_owned_rgba_region_release(&mut rendered); + } + } + + #[test] + fn ffi_scroll_minimap_plan_returns_core_geometry() { + let mut plan = RsnapScrollMinimapPlan::default(); + let status = unsafe { + crate::rsnap_scroll_minimap_plan( + RsnapFloatRect { x: 100.0, y: 100.0, width: 100.0, height: 100.0 }, + 100.0, + 200.0, + RsnapFloatRect { x: 0.0, y: 0.0, width: 500.0, height: 500.0 }, + 96.0, + 44.0, + 10.0, + 10.0, + 3.0, + 20.0, + 100.0, + &mut plan, + ) + }; + + assert_eq!(status, RsnapStatus::Ok); + assert_eq!(plan.frame, RsnapFloatRect { x: 210.0, y: 54.0, width: 96.0, height: 192.0 }); + assert_eq!( + plan.image_frame, + RsnapFloatRect { x: 213.0, y: 57.0, width: 90.0, height: 186.0 } + ); + assert_eq!(plan.has_viewport_frame, 1); + assert_eq!( + plan.viewport_frame, + RsnapFloatRect { x: 213.0, y: 131.4, width: 90.0, height: 93.0 } + ); + } + + #[test] + fn ffi_scroll_minimap_plan_returns_empty_when_too_tight() { + let mut plan = RsnapScrollMinimapPlan::default(); + let status = unsafe { + crate::rsnap_scroll_minimap_plan( + RsnapFloatRect { x: 100.0, y: 100.0, width: 100.0, height: 100.0 }, + 100.0, + 200.0, + RsnapFloatRect { x: 0.0, y: 0.0, width: 230.0, height: 60.0 }, + 96.0, + 44.0, + 10.0, + 10.0, + 3.0, + 20.0, + 100.0, + &mut plan, + ) + }; + + assert_eq!(status, RsnapStatus::Empty); + } + + #[test] + fn ffi_frozen_selection_transform_hit_test_returns_core_kind() { + let mut kind = RsnapFrozenSelectionTransformKind::Move; + let status = unsafe { + crate::rsnap_frozen_selection_transform_hit_test( + 102.0, + 238.0, + RsnapFloatRect { x: 100.0, y: 80.0, width: 240.0, height: 160.0 }, + 12.0, + 4.0, + &mut kind, + ) + }; + + assert_eq!(status, RsnapStatus::Ok); + assert_eq!(kind, RsnapFrozenSelectionTransformKind::ResizeTopLeft); + } + + #[test] + fn ffi_frozen_selection_transform_rect_returns_core_rect() { + let mut rect = RsnapFloatRect::default(); + let status = unsafe { + crate::rsnap_frozen_selection_transform_rect( + RsnapFrozenSelectionTransformKind::ResizeBottomRight, + RsnapFloatRect { x: 100.0, y: 80.0, width: 240.0, height: 160.0 }, + RsnapFloatRect { x: 0.0, y: 0.0, width: 500.0, height: 400.0 }, + 340.0, + 80.0, + 50.0, + 300.0, + 12.0, + &mut rect, + ) + }; + + assert_eq!(status, RsnapStatus::Ok); + assert_eq!(rect, RsnapFloatRect { x: 100.0, y: 228.0, width: 12.0, height: 12.0 }); + } + + #[test] + fn ffi_auto_center_content_bounds_returns_core_rect() { + let rgba = auto_center_frame( + 100, + 80, + Some(RsnapPixelRect { x: 30, y: 20, width: 24, height: 18 }), + ); + let mut rect = RsnapPixelRect::default(); + let status = unsafe { + crate::rsnap_auto_center_content_bounds_rgba( + 100, + 80, + rgba.as_ptr(), + rgba.len(), + &mut rect, + ) + }; + + assert_eq!(status, RsnapStatus::Ok); + assert_eq!(rect, RsnapPixelRect { x: 30, y: 20, width: 24, height: 18 }); + assert_eq!( + crate::rsnap_auto_center_margin_balance_shift_points(30.0, 24.0, 100.0, 50.0), + -4.0 + ); + } + + #[test] + fn ffi_auto_center_content_bounds_returns_empty_for_uniform_image() { + let rgba = auto_center_frame(100, 80, None); + let mut rect = RsnapPixelRect::default(); + let status = unsafe { + crate::rsnap_auto_center_content_bounds_rgba( + 100, + 80, + rgba.as_ptr(), + rgba.len(), + &mut rect, + ) + }; + + assert_eq!(status, RsnapStatus::Empty); + } + #[cfg(target_os = "macos")] #[test] fn ffi_scroll_session_observes_downward_frame_and_exports() { @@ -2100,4 +4936,43 @@ mod tests { fn abi_version_matches_constant() { assert_eq!(crate::rsnap_host_ffi_abi_version(), RSNAP_HOST_FFI_ABI_VERSION); } + + fn auto_center_frame(width: u32, height: u32, content: Option) -> Vec { + let mut rgba = vec![180_u8; (width * height * 4) as usize]; + + for pixel in rgba.chunks_exact_mut(4) { + pixel[3] = 255; + } + + if let Some(content) = content { + for y in content.y..content.y + content.height { + for x in content.x..content.x + content.width { + let offset = ((y * width + x) * 4) as usize; + + rgba[offset] = 24; + rgba[offset + 1] = 32; + rgba[offset + 2] = 40; + } + } + } + + rgba + } + + fn bgra_frame(width: u32, height: u32, bytes_per_row: usize) -> Vec { + let mut bytes = vec![0xEE; bytes_per_row * height as usize]; + + for y in 0..height { + for x in 0..width { + let offset = y as usize * bytes_per_row + x as usize * 4; + + bytes[offset] = 30 + y as u8 * 15 + x as u8; + bytes[offset + 1] = 20 + y as u8 * 10 + x as u8; + bytes[offset + 2] = 10 + y as u8 * 5 + x as u8; + bytes[offset + 3] = 200 + y as u8 + x as u8; + } + } + + bytes + } } diff --git a/packages/rsnap-host-ffi/tests/header_smoke.c b/packages/rsnap-host-ffi/tests/header_smoke.c index c3673429..caade6ad 100644 --- a/packages/rsnap-host-ffi/tests/header_smoke.c +++ b/packages/rsnap-host-ffi/tests/header_smoke.c @@ -10,10 +10,48 @@ int main(void) { RsnapSceneModel scene = {0}; RsnapScrollObserveResult scroll_result = {0}; RsnapOwnedRgbaRegion scroll_export = {0}; + RsnapOwnedRgbaRegion mosaic_patch = {0}; + RsnapOwnedRgbaRegion bgra_patch = {0}; + RsnapOwnedBytes png_export = {0}; + RsnapRgb bgra_rgb = {0}; + RsnapPixelRect crop = {.x = 0, .y = 0, .width = 2, .height = 2}; + RsnapPixelRect display_crop = {0}; + RsnapCaptureFramePlan frame_plan = {0}; + RsnapCaptureFrameBackgroundPlan background_plan = {0}; + RsnapCaptureFrameWallpaperRequest wallpaper_request = {0}; + RsnapOwnedRgbaRegion wallpaper_thumbnail = {0}; + RsnapScrollMinimapPlan minimap_plan = {0}; + RsnapFrozenSelectionTransformKind transform_kind = RSNAP_FROZEN_SELECTION_TRANSFORM_MOVE; + RsnapFloatRect transformed_selection = {0}; + RsnapPixelRect auto_center_rect = {0}; + RsnapFloatRect aspect_crop = {0}; + RsnapFloatRect display_frame = {.x = 0.0, .y = 0.0, .width = 1440.0, .height = 900.0}; + RsnapFloatRect selection = {.x = 100.0, .y = 200.0, .width = 300.0, .height = 150.0}; + RsnapFloatRect mosaic_source = {.x = 4.2, .y = 9.1, .width = 28.4, .height = 21.0}; uint8_t rgba[4 * 4 * 4] = {0}; + uint8_t auto_center_rgba[4 * 4 * 4] = {0}; + uint8_t bgra[4 * 3 * 4] = {0}; RsnapSessionHandle *handle = rsnap_session_create(config); RsnapScrollSessionHandle *scroll_handle = rsnap_scroll_session_create(4, 4, rgba, sizeof(rgba), 4); + for (size_t index = 0; index < sizeof(auto_center_rgba); index += 4) { + auto_center_rgba[index] = 180; + auto_center_rgba[index + 1] = 180; + auto_center_rgba[index + 2] = 180; + auto_center_rgba[index + 3] = 255; + } + auto_center_rgba[(1 * 4 + 1) * 4] = 24; + auto_center_rgba[(1 * 4 + 1) * 4 + 1] = 32; + auto_center_rgba[(1 * 4 + 1) * 4 + 2] = 40; + for (uint32_t y = 0; y < 3; y++) { + for (uint32_t x = 0; x < 4; x++) { + size_t offset = (y * 4 + x) * 4; + bgra[offset] = (uint8_t)(30 + y * 15 + x); + bgra[offset + 1] = (uint8_t)(20 + y * 10 + x); + bgra[offset + 2] = (uint8_t)(10 + y * 5 + x); + bgra[offset + 3] = (uint8_t)(200 + y + x); + } + } if (handle == 0) { return 1; @@ -45,6 +83,233 @@ int main(void) { return 10; } rsnap_owned_rgba_region_release(&scroll_export); + if (rsnap_export_rgba_to_png(4, 4, rgba, sizeof(rgba), &png_export) != RSNAP_STATUS_OK) { + rsnap_scroll_session_destroy(scroll_handle); + rsnap_session_destroy(handle); + return 11; + } + rsnap_owned_bytes_release(&png_export); + if (rsnap_export_rgba_crop_to_png(4, 4, rgba, sizeof(rgba), crop, &png_export) != RSNAP_STATUS_OK) { + rsnap_scroll_session_destroy(scroll_handle); + rsnap_session_destroy(handle); + return 12; + } + rsnap_owned_bytes_release(&png_export); + if (rsnap_frozen_display_crop_rect(2880, 1800, display_frame, selection, &display_crop) != + RSNAP_STATUS_OK) { + rsnap_scroll_session_destroy(scroll_handle); + rsnap_session_destroy(handle); + return 13; + } + if (display_crop.x != 200 || display_crop.y != 1100 || display_crop.width != 600 || + display_crop.height != 300) { + rsnap_scroll_session_destroy(scroll_handle); + rsnap_session_destroy(handle); + return 14; + } + if (rsnap_frozen_mosaic_light_privacy_patch_rgba(100, 80, mosaic_source, &mosaic_patch) != + RSNAP_STATUS_OK) { + rsnap_scroll_session_destroy(scroll_handle); + rsnap_session_destroy(handle); + return 15; + } + if (mosaic_patch.width != 3 || mosaic_patch.height != 3 || mosaic_patch.len != 36) { + rsnap_owned_rgba_region_release(&mosaic_patch); + rsnap_scroll_session_destroy(scroll_handle); + rsnap_session_destroy(handle); + return 16; + } + rsnap_owned_rgba_region_release(&mosaic_patch); + if (rsnap_bgra_frame_sample_rgb( + 4, + 3, + 16, + bgra, + sizeof(bgra), + (RsnapFloatRect){.x = 0.0, .y = 0.0, .width = 4.0, .height = 3.0}, + 1.0, + 2.5, + &bgra_rgb + ) != RSNAP_STATUS_OK) { + rsnap_scroll_session_destroy(scroll_handle); + rsnap_session_destroy(handle); + return 29; + } + if (bgra_rgb.r != 11 || bgra_rgb.g != 21 || bgra_rgb.b != 31) { + rsnap_scroll_session_destroy(scroll_handle); + rsnap_session_destroy(handle); + return 30; + } + if (rsnap_bgra_frame_loupe_patch_rgba( + 4, + 3, + 16, + bgra, + sizeof(bgra), + (RsnapFloatRect){.x = 0.0, .y = 0.0, .width = 4.0, .height = 3.0}, + 0.0, + 2.0, + 3, + &bgra_patch + ) != RSNAP_STATUS_OK) { + rsnap_scroll_session_destroy(scroll_handle); + rsnap_session_destroy(handle); + return 31; + } + if (bgra_patch.width != 3 || bgra_patch.height != 3 || bgra_patch.len != 36) { + rsnap_owned_rgba_region_release(&bgra_patch); + rsnap_scroll_session_destroy(scroll_handle); + rsnap_session_destroy(handle); + return 32; + } + rsnap_owned_rgba_region_release(&bgra_patch); + if (rsnap_capture_frame_plan( + 320, + 180, + 2.0, + RSNAP_CAPTURE_FRAME_SOURCE_WINDOW, + &frame_plan + ) != RSNAP_STATUS_OK) { + rsnap_scroll_session_destroy(scroll_handle); + rsnap_session_destroy(handle); + return 17; + } + if (frame_plan.canvas_width != 416.0 || frame_plan.canvas_height != 276.0 || + frame_plan.image_rect.x != 48.0 || frame_plan.corner_radius != 9.9) { + rsnap_scroll_session_destroy(scroll_handle); + rsnap_session_destroy(handle); + return 18; + } + if (rsnap_capture_frame_aspect_fill_crop_rect(1600, 900, 1000.0, 1000.0, &aspect_crop) != + RSNAP_STATUS_OK) { + rsnap_scroll_session_destroy(scroll_handle); + rsnap_session_destroy(handle); + return 19; + } + if (aspect_crop.x != 350.0 || aspect_crop.y != 0.0 || aspect_crop.width != 900.0 || + aspect_crop.height != 900.0) { + rsnap_scroll_session_destroy(scroll_handle); + rsnap_session_destroy(handle); + return 20; + } + if (rsnap_capture_frame_background_plan( + RSNAP_CAPTURE_FRAME_BACKGROUND_SYSTEM_WALLPAPER, + &background_plan + ) != RSNAP_STATUS_OK) { + rsnap_scroll_session_destroy(scroll_handle); + rsnap_session_destroy(handle); + return 21; + } + if (background_plan.prefers_wallpaper != 1 || background_plan.wallpaper_overlay_alpha != 0.10 || + background_plan.locations[1] != 0.54 || background_plan.colors[2].red != 0.95) { + rsnap_scroll_session_destroy(scroll_handle); + rsnap_session_destroy(handle); + return 22; + } + if (rsnap_capture_frame_wallpaper_request_plan( + RSNAP_CAPTURE_FRAME_BACKGROUND_SYSTEM_WALLPAPER, + 1535.2, + 996.0, + &wallpaper_request + ) != RSNAP_STATUS_OK) { + rsnap_scroll_session_destroy(scroll_handle); + rsnap_session_destroy(handle); + return 27; + } + if (wallpaper_request.target_pixel_size != 1536 || + wallpaper_request.overlay_alpha != 0.10) { + rsnap_scroll_session_destroy(scroll_handle); + rsnap_session_destroy(handle); + return 28; + } + if (rsnap_capture_frame_wallpaper_png_thumbnail(0, 1536, &wallpaper_thumbnail) != + RSNAP_STATUS_INVALID_INPUT) { + rsnap_scroll_session_destroy(scroll_handle); + rsnap_session_destroy(handle); + return 47; + } + if (rsnap_scroll_minimap_plan( + (RsnapFloatRect){.x = 100.0, .y = 100.0, .width = 100.0, .height = 100.0}, + 100.0, + 200.0, + (RsnapFloatRect){.x = 0.0, .y = 0.0, .width = 500.0, .height = 500.0}, + 96.0, + 44.0, + 10.0, + 10.0, + 3.0, + 20.0, + 100.0, + &minimap_plan + ) != RSNAP_STATUS_OK) { + rsnap_scroll_session_destroy(scroll_handle); + rsnap_session_destroy(handle); + return 25; + } + if (minimap_plan.frame.x != 210.0 || minimap_plan.frame.y != 54.0 || + minimap_plan.frame.width != 96.0 || minimap_plan.frame.height != 192.0 || + minimap_plan.image_frame.x != 213.0 || minimap_plan.has_viewport_frame != 1 || + minimap_plan.viewport_frame.height != 93.0) { + rsnap_scroll_session_destroy(scroll_handle); + rsnap_session_destroy(handle); + return 26; + } + if (rsnap_frozen_selection_transform_hit_test( + 102.0, + 238.0, + (RsnapFloatRect){.x = 100.0, .y = 80.0, .width = 240.0, .height = 160.0}, + 12.0, + 4.0, + &transform_kind + ) != RSNAP_STATUS_OK) { + rsnap_scroll_session_destroy(scroll_handle); + rsnap_session_destroy(handle); + return 33; + } + if (transform_kind != RSNAP_FROZEN_SELECTION_TRANSFORM_RESIZE_TOP_LEFT) { + rsnap_scroll_session_destroy(scroll_handle); + rsnap_session_destroy(handle); + return 34; + } + if (rsnap_frozen_selection_transform_rect( + RSNAP_FROZEN_SELECTION_TRANSFORM_RESIZE_BOTTOM_RIGHT, + (RsnapFloatRect){.x = 100.0, .y = 80.0, .width = 240.0, .height = 160.0}, + (RsnapFloatRect){.x = 0.0, .y = 0.0, .width = 500.0, .height = 400.0}, + 340.0, + 80.0, + 50.0, + 300.0, + 12.0, + &transformed_selection + ) != RSNAP_STATUS_OK) { + rsnap_scroll_session_destroy(scroll_handle); + rsnap_session_destroy(handle); + return 35; + } + if (transformed_selection.x != 100.0 || transformed_selection.y != 228.0 || + transformed_selection.width != 12.0 || transformed_selection.height != 12.0) { + rsnap_scroll_session_destroy(scroll_handle); + rsnap_session_destroy(handle); + return 36; + } + if (rsnap_auto_center_content_bounds_rgba( + 4, + 4, + auto_center_rgba, + sizeof(auto_center_rgba), + &auto_center_rect + ) != RSNAP_STATUS_OK) { + rsnap_scroll_session_destroy(scroll_handle); + rsnap_session_destroy(handle); + return 23; + } + if (auto_center_rect.x != 1 || auto_center_rect.y != 1 || auto_center_rect.width != 1 || + auto_center_rect.height != 1 || + rsnap_auto_center_margin_balance_shift_points(1.0, 1.0, 4.0, 40.0) != -5.0) { + rsnap_scroll_session_destroy(scroll_handle); + rsnap_session_destroy(handle); + return 24; + } if (rsnap_session_enter_live(handle) != RSNAP_STATUS_OK) { rsnap_scroll_session_destroy(scroll_handle); rsnap_session_destroy(handle); diff --git a/packages/rsnap-overlay/src/backend.rs b/packages/rsnap-overlay/src/backend.rs index 305148bd..fb0bda6f 100644 --- a/packages/rsnap-overlay/src/backend.rs +++ b/packages/rsnap-overlay/src/backend.rs @@ -20,6 +20,7 @@ use std::time::{Duration, Instant}; #[cfg(target_os = "macos")] use block2::RcBlock; use color_eyre::eyre::{self, Result, WrapErr}; +use image::Rgba; use image::RgbaImage; use image::imageops; #[cfg(target_os = "macos")] @@ -30,6 +31,14 @@ use objc2_core_foundation::{CGPoint, CGRect, CGSize}; use objc2_core_graphics::{ CGDataProvider, CGImage, CGRectNull, CGWindowID, CGWindowImageOption, CGWindowListOption, }; +#[allow( + deprecated, + reason = "Legacy CG capture remains as the macOS fallback while ScreenCaptureKit owns the primary capture path." +)] +#[cfg(target_os = "macos")] +use objc2_core_graphics::{ + CGDisplayCreateImage, CGDisplayCreateImageForRect, CGWindowListCreateImage, +}; #[cfg(target_os = "macos")] use objc2_foundation::{NSError, NSOperatingSystemVersion, NSProcessInfo}; #[cfg(target_os = "macos")] @@ -380,7 +389,7 @@ impl XcapCaptureBackend { reason = "CoreGraphics monitor capture remains the verified macOS fallback until XY-74/XY-75 replace this path." )] fn capture_monitor_image(&mut self, monitor: MonitorRect) -> Result { - let cg_image = objc2_core_graphics::CGDisplayCreateImage(monitor.id) + let cg_image = CGDisplayCreateImage(monitor.id) .ok_or_else(|| eyre::eyre!("CGDisplayCreateImage returned null"))?; rgba_image_from_cg_image_for_display(cg_image.as_ref(), Some(monitor.id)) @@ -401,7 +410,7 @@ impl XcapCaptureBackend { let cg_rect: CGRect = unsafe { CGRectNull }; let image_option = CGWindowImageOption::BoundsIgnoreFraming | CGWindowImageOption::BestResolution; - let cg_image = objc2_core_graphics::CGWindowListCreateImage( + let cg_image = CGWindowListCreateImage( cg_rect, CGWindowListOption::OptionIncludingWindow, window_id as CGWindowID, @@ -1070,7 +1079,7 @@ fn capture_monitor_region_with_core_graphics( CGPoint::new(rect_px.x as f64, rect_px.y as f64), CGSize::new(rect_px.width.max(1) as f64, rect_px.height.max(1) as f64), ); - let image = objc2_core_graphics::CGDisplayCreateImageForRect(monitor.id, cg_rect) + let image = CGDisplayCreateImageForRect(monitor.id, cg_rect) .ok_or_else(|| eyre::eyre!("CGDisplayCreateImageForRect returned null"))?; let image = rgba_image_from_cg_image_for_display(image.as_ref(), Some(monitor.id)) .wrap_err("failed to decode CGDisplay rect capture")?; @@ -1079,7 +1088,7 @@ fn capture_monitor_region_with_core_graphics( return Ok(image); } - let full_image = objc2_core_graphics::CGDisplayCreateImage(monitor.id) + let full_image = CGDisplayCreateImage(monitor.id) .ok_or_else(|| eyre::eyre!("CGDisplayCreateImage returned null"))?; let full_image = rgba_image_from_cg_image_for_display(full_image.as_ref(), Some(monitor.id)) .wrap_err("failed to decode CGDisplay full-monitor capture")?; @@ -1115,7 +1124,7 @@ fn copy_rgba_patch( out.put_pixel(ox, oy, *pixel); } else { - out.put_pixel(ox, oy, image::Rgba([0, 0, 0, 0])); + out.put_pixel(ox, oy, Rgba([0, 0, 0, 0])); } } } diff --git a/packages/rsnap-overlay/src/frozen_edit.rs b/packages/rsnap-overlay/src/frozen_edit.rs new file mode 100644 index 00000000..f445d102 --- /dev/null +++ b/packages/rsnap-overlay/src/frozen_edit.rs @@ -0,0 +1,1228 @@ +//! Portable frozen-overlay edit state owned by Rust. + +use crate::frozen_export::{ + FrozenOverlayExportArrow, FrozenOverlayExportElement, FrozenOverlayExportMosaic, + FrozenOverlayExportPen, FrozenOverlayExportPoint, FrozenOverlayExportSpotlight, + FrozenOverlayExportSpotlightStyle, FrozenOverlayExportStrokeStyle, FrozenOverlayExportText, + FrozenOverlayExportTextStyle, +}; +use crate::text_rendering::{self, TextBounds}; +use rsnap_capture_core::{DisplayPointRect, ToolbarItemKind}; + +const PEN_SAMPLE_MIN_DISTANCE_POINTS: f64 = 1.5; +const ARROW_MIN_DISTANCE_POINTS: f64 = 6.0; +const RECT_MIN_SIZE_POINTS: f64 = 6.0; +const TEXT_HIT_PADDING_POINTS: f64 = 4.0; + +/// Frozen annotation color selected by the host UI. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum FrozenOverlayEditColor { + /// White annotation color. + White, + /// Yellow annotation color. + Yellow, + /// Green annotation color. + Green, + /// Blue annotation color. + #[default] + Blue, + /// Red annotation color. + Red, + /// Black annotation color. + Black, +} +impl FrozenOverlayEditColor { + /// Returns the non-premultiplied sRGBA export color. + #[must_use] + pub const fn export_rgba(self) -> [u8; 4] { + match self { + Self::White => [255, 255, 255, 255], + Self::Yellow => [255, 219, 77, 255], + Self::Green => [92, 214, 149, 255], + Self::Blue => [102, 178, 255, 255], + Self::Red => [255, 107, 107, 255], + Self::Black => [24, 24, 24, 255], + } + } +} + +/// Point-space coordinate used by frozen-overlay edit state. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct FrozenOverlayEditPoint { + /// X coordinate in frozen capture point-space. + pub x: f64, + /// Y coordinate in frozen capture point-space. + pub y: f64, +} +impl FrozenOverlayEditPoint { + /// Creates a frozen-overlay edit point. + #[must_use] + pub const fn new(x: f64, y: f64) -> Self { + Self { x, y } + } + + fn distance_to(self, other: Self) -> f64 { + (self.x - other.x).hypot(self.y - other.y) + } + + fn export_point(self) -> FrozenOverlayExportPoint { + FrozenOverlayExportPoint::new(self.x, self.y) + } +} + +/// Rect-space coordinate used by frozen-overlay edit state. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct FrozenOverlayEditRect { + /// Left coordinate in frozen capture point-space. + pub x: f64, + /// Top coordinate in frozen capture point-space. + pub y: f64, + /// Rectangle width in frozen capture points. + pub width: f64, + /// Rectangle height in frozen capture points. + pub height: f64, +} +impl FrozenOverlayEditRect { + /// Creates a frozen-overlay edit rectangle. + #[must_use] + pub const fn new(x: f64, y: f64, width: f64, height: f64) -> Self { + Self { x, y, width, height } + } + + /// Converts a shared display-point rectangle into edit geometry. + #[must_use] + pub const fn from_display_rect(rect: DisplayPointRect) -> Self { + Self::new(rect.x, rect.y, rect.width, rect.height) + } + + /// Converts edit geometry into a shared display-point rectangle. + #[must_use] + pub const fn display_rect(self) -> DisplayPointRect { + DisplayPointRect::new(self.x, self.y, self.width, self.height) + } + + /// Returns true when the rectangle has finite, positive dimensions. + #[must_use] + pub fn is_valid(self) -> bool { + self.x.is_finite() + && self.y.is_finite() + && self.width.is_finite() + && self.height.is_finite() + && self.width > 0.0 + && self.height > 0.0 + } + + /// Returns true when the point lies inside the rectangle bounds. + #[must_use] + pub fn contains(self, point: FrozenOverlayEditPoint) -> bool { + self.is_valid() + && point.x >= self.x + && point.y >= self.y + && point.x < self.max_x() + && point.y < self.max_y() + } + + fn max_x(self) -> f64 { + self.x + self.width + } + + fn max_y(self) -> f64 { + self.y + self.height + } + + fn inset(self, dx: f64, dy: f64) -> Self { + Self::new( + self.x + dx, + self.y + dy, + (self.width - dx * 2.0).max(0.0), + (self.height - dy * 2.0).max(0.0), + ) + } + + fn clamp_point(self, point: FrozenOverlayEditPoint) -> FrozenOverlayEditPoint { + FrozenOverlayEditPoint::new( + point.x.clamp(self.x, self.max_x()), + point.y.clamp(self.y, self.max_y()), + ) + } + + fn normalized_rect( + self, + anchor: FrozenOverlayEditPoint, + current: FrozenOverlayEditPoint, + ) -> Self { + let clamped_anchor = self.clamp_point(anchor); + let clamped_current = self.clamp_point(current); + + Self::new( + clamped_anchor.x.min(clamped_current.x), + clamped_anchor.y.min(clamped_current.y), + (clamped_current.x - clamped_anchor.x).abs(), + (clamped_current.y - clamped_anchor.y).abs(), + ) + } +} + +/// Stroke style used by frozen pen and arrow annotations. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FrozenOverlayEditStrokeStyle { + /// Stroke width in frozen capture points. + pub stroke_width_points: f64, + /// Selected annotation color. + pub color: FrozenOverlayEditColor, +} +impl FrozenOverlayEditStrokeStyle { + fn export_style(self) -> FrozenOverlayExportStrokeStyle { + FrozenOverlayExportStrokeStyle { + stroke_width_points: self.stroke_width_points as f32, + rgba: self.color.export_rgba(), + } + } +} + +impl Default for FrozenOverlayEditStrokeStyle { + fn default() -> Self { + Self { stroke_width_points: 3.0, color: FrozenOverlayEditColor::Blue } + } +} + +/// Spotlight style used by frozen spotlight annotations. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct FrozenOverlayEditSpotlightStyle { + /// Border width in frozen capture points. + pub border_width_points: f64, + /// Selected border color. + pub border_color: FrozenOverlayEditColor, +} +impl FrozenOverlayEditSpotlightStyle { + fn export_style(self) -> FrozenOverlayExportSpotlightStyle { + FrozenOverlayExportSpotlightStyle { + border_width_points: self.border_width_points as f32, + border_rgba: self.border_color.export_rgba(), + } + } +} + +/// Text style used by frozen text annotations. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FrozenOverlayEditTextStyle { + /// Font size in frozen capture points. + pub font_size_points: f64, + /// Selected text color. + pub color: FrozenOverlayEditColor, +} +impl FrozenOverlayEditTextStyle { + fn export_style(self) -> FrozenOverlayExportTextStyle { + FrozenOverlayExportTextStyle { + font_size_points: self.font_size_points as f32, + rgba: self.color.export_rgba(), + } + } +} + +impl Default for FrozenOverlayEditTextStyle { + fn default() -> Self { + Self { font_size_points: 16.0, color: FrozenOverlayEditColor::Blue } + } +} + +/// Full annotation style payload provided by the native host UI. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct FrozenOverlayEditStyle { + /// Current pen and arrow style. + pub stroke: FrozenOverlayEditStrokeStyle, + /// Current spotlight style. + pub spotlight: FrozenOverlayEditSpotlightStyle, + /// Current text style. + pub text: FrozenOverlayEditTextStyle, +} + +/// Frozen pen stroke annotation. +#[derive(Clone, Debug, PartialEq)] +pub struct FrozenOverlayEditPen { + /// Stroke points in frozen capture point-space. + pub points: Vec, + /// Stroke style. + pub style: FrozenOverlayEditStrokeStyle, +} +impl FrozenOverlayEditPen { + fn export_element(&self) -> FrozenOverlayExportElement { + FrozenOverlayExportElement::Pen(FrozenOverlayExportPen { + points: self.points.iter().map(|point| point.export_point()).collect(), + style: self.style.export_style(), + }) + } +} + +/// Frozen arrow annotation. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FrozenOverlayEditArrow { + /// Arrow tail in frozen capture point-space. + pub start: FrozenOverlayEditPoint, + /// Arrow tip in frozen capture point-space. + pub end: FrozenOverlayEditPoint, + /// Stroke style. + pub style: FrozenOverlayEditStrokeStyle, +} +impl FrozenOverlayEditArrow { + fn export_element(self) -> FrozenOverlayExportElement { + FrozenOverlayExportElement::Arrow(FrozenOverlayExportArrow { + start: self.start.export_point(), + end: self.end.export_point(), + style: self.style.export_style(), + }) + } +} + +/// Frozen mosaic privacy annotation. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FrozenOverlayEditMosaic { + /// Mosaic rectangle in frozen capture point-space. + pub rect: FrozenOverlayEditRect, +} +impl FrozenOverlayEditMosaic { + fn export_element(self) -> FrozenOverlayExportElement { + FrozenOverlayExportElement::Mosaic(FrozenOverlayExportMosaic { + rect: self.rect.display_rect(), + }) + } +} + +/// Frozen spotlight annotation. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FrozenOverlayEditSpotlight { + /// Spotlight rectangle in frozen capture point-space. + pub rect: FrozenOverlayEditRect, + /// Spotlight style. + pub style: FrozenOverlayEditSpotlightStyle, +} +impl FrozenOverlayEditSpotlight { + fn export_element(self) -> FrozenOverlayExportElement { + FrozenOverlayExportElement::Spotlight(FrozenOverlayExportSpotlight { + rect: self.rect.display_rect(), + style: self.style.export_style(), + }) + } +} + +/// Frozen text annotation. +#[derive(Clone, Debug, PartialEq)] +pub struct FrozenOverlayEditText { + /// Text anchor in frozen capture point-space. + pub anchor: FrozenOverlayEditPoint, + /// Text payload. + pub text: String, + /// Text style. + pub style: FrozenOverlayEditTextStyle, +} +impl FrozenOverlayEditText { + fn export_element(&self) -> FrozenOverlayExportElement { + FrozenOverlayExportElement::Text(FrozenOverlayExportText { + anchor: self.anchor.export_point(), + text: self.text.clone(), + style: self.style.export_style(), + }) + } +} + +/// One committed frozen-overlay edit. +#[derive(Clone, Debug, PartialEq)] +pub enum FrozenOverlayEditElement { + /// Pen stroke annotation. + Pen(FrozenOverlayEditPen), + /// Arrow annotation. + Arrow(FrozenOverlayEditArrow), + /// Mosaic privacy rectangle. + Mosaic(FrozenOverlayEditMosaic), + /// Spotlight annotation. + Spotlight(FrozenOverlayEditSpotlight), + /// Text annotation. + Text(FrozenOverlayEditText), +} +impl FrozenOverlayEditElement { + /// Converts the edit element into the export compositor payload. + #[must_use] + pub fn export_element(&self) -> FrozenOverlayExportElement { + match self { + Self::Pen(annotation) => annotation.export_element(), + Self::Arrow(annotation) => annotation.export_element(), + Self::Mosaic(annotation) => annotation.export_element(), + Self::Spotlight(annotation) => annotation.export_element(), + Self::Text(annotation) => annotation.export_element(), + } + } +} + +/// Active text edit payload. +#[derive(Clone, Debug, PartialEq)] +pub struct FrozenOverlayTextEdit { + /// Text anchor in frozen capture point-space. + pub anchor: FrozenOverlayEditPoint, + /// Uncommitted text payload. + pub text: String, +} + +/// Snapshot copied from the Rust-owned edit state for host rendering. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct FrozenOverlayEditSnapshot { + /// Whether undo is available. + pub can_undo: bool, + /// Whether redo is available. + pub can_redo: bool, + /// Whether selection transforms should be locked out by existing edits. + pub keeps_frozen_selection_fixed: bool, + /// Whether a movable annotation is currently being dragged. + pub is_moving_movable_annotation: bool, + /// Whether any pointer interaction is currently active. + pub has_active_interaction: bool, + /// Visible committed elements, excluding the target currently represented by a move preview. + pub elements: Vec, + /// Active pen preview. + pub preview_pen: Option, + /// Active arrow preview. + pub preview_arrow: Option, + /// Active mosaic or mosaic-move preview. + pub preview_mosaic: Option, + /// Active spotlight preview. + pub preview_spotlight: Option, + /// Active moved text preview. + pub preview_text: Option, + /// Active text edit state. + pub active_text_edit: Option, +} + +#[derive(Clone, Debug, PartialEq)] +enum ActiveFrozenOverlayEdit { + Pen { + points: Vec, + style: FrozenOverlayEditStrokeStyle, + }, + Arrow { + start: FrozenOverlayEditPoint, + current: FrozenOverlayEditPoint, + style: FrozenOverlayEditStrokeStyle, + }, + Mosaic { + anchor: FrozenOverlayEditPoint, + current: FrozenOverlayEditPoint, + }, + MosaicMove { + index: usize, + current_rect: FrozenOverlayEditRect, + drag_offset: FrozenOverlayEditPoint, + }, + TextMove { + index: usize, + current_annotation: FrozenOverlayEditText, + drag_offset: FrozenOverlayEditPoint, + }, + Spotlight { + anchor: FrozenOverlayEditPoint, + current: FrozenOverlayEditPoint, + style: FrozenOverlayEditSpotlightStyle, + }, +} + +#[derive(Clone, Debug, PartialEq)] +enum FrozenOverlayMoveTarget { + Mosaic { index: usize, rect: FrozenOverlayEditRect }, + Text { index: usize, annotation: FrozenOverlayEditText }, +} + +/// Rust-owned frozen-overlay edit state. +#[derive(Debug, Default)] +pub struct FrozenOverlayEditSession { + edits: Vec, + redo_edits: Vec, + active_interaction: Option, + active_text_edit: Option, +} +impl FrozenOverlayEditSession { + /// Clears all committed, redo, and active edit state. + pub fn reset(&mut self) { + self.edits.clear(); + self.redo_edits.clear(); + + self.active_interaction = None; + self.active_text_edit = None; + } + + /// Starts a frozen-overlay interaction for the selected toolbar tool. + pub fn begin( + &mut self, + tool: ToolbarItemKind, + point: FrozenOverlayEditPoint, + selection: FrozenOverlayEditRect, + style: FrozenOverlayEditStyle, + ) -> bool { + if !selection.contains(point) { + return false; + } + + match tool { + ToolbarItemKind::Pen => self.begin_pen(point, style.stroke), + ToolbarItemKind::Arrow => self.begin_arrow(point, style.stroke), + ToolbarItemKind::Mosaic => self.begin_mosaic(point), + ToolbarItemKind::Pointer => self.begin_move(point), + ToolbarItemKind::Spotlight => self.begin_spotlight(point, style.spotlight), + ToolbarItemKind::Text => { + let _ = self.commit_text_edit(style.text); + + self.active_text_edit = Some(FrozenOverlayTextEdit { + anchor: selection.clamp_point(point), + text: String::new(), + }); + + true + }, + ToolbarItemKind::Undo + | ToolbarItemKind::Redo + | ToolbarItemKind::AutoCenter + | ToolbarItemKind::Scroll + | ToolbarItemKind::Ocr + | ToolbarItemKind::Copy + | ToolbarItemKind::Save => false, + } + } + + /// Updates the active frozen-overlay pointer interaction. + pub fn update( + &mut self, + point: FrozenOverlayEditPoint, + selection: FrozenOverlayEditRect, + ) -> bool { + let Some(active) = self.active_interaction.take() else { + return false; + }; + let next = match active { + ActiveFrozenOverlayEdit::Pen { mut points, style } => { + let clamped = selection.clamp_point(point); + + if points.last().is_some_and(|last_point| { + last_point.distance_to(clamped) < PEN_SAMPLE_MIN_DISTANCE_POINTS + }) { + self.active_interaction = Some(ActiveFrozenOverlayEdit::Pen { points, style }); + + return false; + } + + points.push(clamped); + + ActiveFrozenOverlayEdit::Pen { points, style } + }, + ActiveFrozenOverlayEdit::Arrow { start, style, .. } => ActiveFrozenOverlayEdit::Arrow { + start, + current: selection.clamp_point(point), + style, + }, + ActiveFrozenOverlayEdit::Mosaic { anchor, .. } => { + ActiveFrozenOverlayEdit::Mosaic { anchor, current: selection.clamp_point(point) } + }, + ActiveFrozenOverlayEdit::MosaicMove { index, current_rect, drag_offset } => { + ActiveFrozenOverlayEdit::MosaicMove { + index, + current_rect: moved_rect(current_rect, drag_offset, point, selection), + drag_offset, + } + }, + ActiveFrozenOverlayEdit::TextMove { index, current_annotation, drag_offset } => { + ActiveFrozenOverlayEdit::TextMove { + index, + current_annotation: moved_text_annotation( + current_annotation, + drag_offset, + point, + selection, + ), + drag_offset, + } + }, + ActiveFrozenOverlayEdit::Spotlight { anchor, style, .. } => { + ActiveFrozenOverlayEdit::Spotlight { + anchor, + current: selection.clamp_point(point), + style, + } + }, + }; + + self.active_interaction = Some(next); + + true + } + + /// Finishes the active frozen-overlay pointer interaction. + pub fn finish(&mut self, selection: FrozenOverlayEditRect) -> bool { + let Some(active) = self.active_interaction.take() else { + return false; + }; + let mut changed = true; + let accepted = match active { + ActiveFrozenOverlayEdit::Pen { points, style } => self.finish_pen(points, style), + ActiveFrozenOverlayEdit::Arrow { start, current, style } => { + self.finish_arrow(start, current, style) + }, + ActiveFrozenOverlayEdit::Mosaic { anchor, current } => { + self.finish_mosaic(selection, anchor, current) + }, + ActiveFrozenOverlayEdit::MosaicMove { index, current_rect, .. } => { + let Some(moved) = self.finish_mosaic_move(index, current_rect) else { + return false; + }; + + changed = moved; + + true + }, + ActiveFrozenOverlayEdit::TextMove { index, current_annotation, .. } => { + let Some(moved) = self.finish_text_move(index, current_annotation) else { + return false; + }; + + changed = moved; + + true + }, + ActiveFrozenOverlayEdit::Spotlight { anchor, current, style } => { + self.finish_spotlight(selection, anchor, current, style) + }, + }; + + if accepted && changed { + self.redo_edits.clear(); + } + + accepted + } + + /// Appends text to the active text edit after stripping carriage returns. + pub fn append_text(&mut self, text: &str) -> bool { + let Some(active_text_edit) = self.active_text_edit.as_mut() else { + return false; + }; + let sanitized = text.replace('\r', ""); + + if sanitized.is_empty() { + return false; + } + + active_text_edit.text.push_str(&sanitized); + + true + } + + /// Deletes one Unicode scalar from the active text edit. + pub fn backspace_text(&mut self) -> bool { + self.active_text_edit + .as_mut() + .is_some_and(|active_text_edit| active_text_edit.text.pop().is_some()) + } + + /// Commits the active text edit using the provided style. + pub fn commit_text_edit(&mut self, style: FrozenOverlayEditTextStyle) -> bool { + let Some(active_text_edit) = self.active_text_edit.take() else { + return false; + }; + + if active_text_edit.text.trim().is_empty() { + return false; + } + + self.edits.push(FrozenOverlayEditElement::Text(FrozenOverlayEditText { + anchor: active_text_edit.anchor, + text: active_text_edit.text, + style, + })); + self.redo_edits.clear(); + + true + } + + /// Cancels the active text edit without committing it. + pub fn cancel_text_edit(&mut self) { + self.active_text_edit = None; + } + + /// Moves the last committed edit to the redo stack. + pub fn undo(&mut self) -> bool { + self.active_text_edit = None; + + let Some(edit) = self.edits.pop() else { + return false; + }; + + self.redo_edits.push(edit); + + true + } + + /// Restores the last redo edit. + pub fn redo(&mut self) -> bool { + self.active_text_edit = None; + + let Some(edit) = self.redo_edits.pop() else { + return false; + }; + + self.edits.push(edit); + + true + } + + /// Returns true if a movable annotation is under the provided point. + #[must_use] + pub fn contains_movable_annotation(&self, point: FrozenOverlayEditPoint) -> bool { + self.move_target(point).is_some() + } + + /// Copies the current edit state as host-renderable data. + #[must_use] + pub fn snapshot(&self) -> FrozenOverlayEditSnapshot { + let moving_mosaic = self.moving_mosaic_edit_index(); + let moving_text = self.moving_text_edit_index(); + + FrozenOverlayEditSnapshot { + can_undo: !self.edits.is_empty(), + can_redo: !self.redo_edits.is_empty(), + keeps_frozen_selection_fixed: self.keeps_frozen_selection_fixed(), + is_moving_movable_annotation: self.is_moving_movable_annotation(), + has_active_interaction: self.active_interaction.is_some(), + elements: self.visible_elements(moving_mosaic, moving_text), + preview_pen: self.preview_pen(), + preview_arrow: self.preview_arrow(), + preview_mosaic: self.preview_mosaic(), + preview_spotlight: self.preview_spotlight(), + preview_text: self.preview_text(), + active_text_edit: self.active_text_edit.clone(), + } + } + + fn begin_pen( + &mut self, + point: FrozenOverlayEditPoint, + style: FrozenOverlayEditStrokeStyle, + ) -> bool { + self.active_interaction = Some(ActiveFrozenOverlayEdit::Pen { points: vec![point], style }); + + true + } + + fn begin_arrow( + &mut self, + point: FrozenOverlayEditPoint, + style: FrozenOverlayEditStrokeStyle, + ) -> bool { + self.active_interaction = + Some(ActiveFrozenOverlayEdit::Arrow { start: point, current: point, style }); + + true + } + + fn begin_mosaic(&mut self, point: FrozenOverlayEditPoint) -> bool { + self.active_interaction = + Some(ActiveFrozenOverlayEdit::Mosaic { anchor: point, current: point }); + + true + } + + fn begin_spotlight( + &mut self, + point: FrozenOverlayEditPoint, + style: FrozenOverlayEditSpotlightStyle, + ) -> bool { + self.active_interaction = + Some(ActiveFrozenOverlayEdit::Spotlight { anchor: point, current: point, style }); + + true + } + + fn begin_move(&mut self, point: FrozenOverlayEditPoint) -> bool { + let Some(target) = self.move_target(point) else { + return false; + }; + + self.active_interaction = Some(match target { + FrozenOverlayMoveTarget::Mosaic { index, rect } => { + ActiveFrozenOverlayEdit::MosaicMove { + index, + current_rect: rect, + drag_offset: FrozenOverlayEditPoint::new(point.x - rect.x, point.y - rect.y), + } + }, + FrozenOverlayMoveTarget::Text { index, annotation } => { + ActiveFrozenOverlayEdit::TextMove { + index, + drag_offset: FrozenOverlayEditPoint::new( + point.x - annotation.anchor.x, + point.y - annotation.anchor.y, + ), + current_annotation: annotation, + } + }, + }); + + true + } + + fn finish_pen( + &mut self, + points: Vec, + style: FrozenOverlayEditStrokeStyle, + ) -> bool { + if points.len() < 2 { + return false; + } + + self.edits.push(FrozenOverlayEditElement::Pen(FrozenOverlayEditPen { points, style })); + + true + } + + fn finish_arrow( + &mut self, + start: FrozenOverlayEditPoint, + current: FrozenOverlayEditPoint, + style: FrozenOverlayEditStrokeStyle, + ) -> bool { + if start.distance_to(current) < ARROW_MIN_DISTANCE_POINTS { + return false; + } + + self.edits.push(FrozenOverlayEditElement::Arrow(FrozenOverlayEditArrow { + start, + end: current, + style, + })); + + true + } + + fn finish_mosaic( + &mut self, + selection: FrozenOverlayEditRect, + anchor: FrozenOverlayEditPoint, + current: FrozenOverlayEditPoint, + ) -> bool { + let rect = selection.normalized_rect(anchor, current); + + if rect.width < RECT_MIN_SIZE_POINTS || rect.height < RECT_MIN_SIZE_POINTS { + return false; + } + + self.edits.push(FrozenOverlayEditElement::Mosaic(FrozenOverlayEditMosaic { rect })); + + true + } + + fn finish_spotlight( + &mut self, + selection: FrozenOverlayEditRect, + anchor: FrozenOverlayEditPoint, + current: FrozenOverlayEditPoint, + style: FrozenOverlayEditSpotlightStyle, + ) -> bool { + let rect = selection.normalized_rect(anchor, current); + + if rect.width < RECT_MIN_SIZE_POINTS || rect.height < RECT_MIN_SIZE_POINTS { + return false; + } + + self.edits + .push(FrozenOverlayEditElement::Spotlight(FrozenOverlayEditSpotlight { rect, style })); + + true + } + + fn finish_mosaic_move( + &mut self, + index: usize, + current_rect: FrozenOverlayEditRect, + ) -> Option { + let Some(FrozenOverlayEditElement::Mosaic(annotation)) = self.edits.get_mut(index) else { + return None; + }; + + if annotation.rect == current_rect { + return Some(false); + } + + annotation.rect = current_rect; + + Some(true) + } + + fn finish_text_move( + &mut self, + index: usize, + current_annotation: FrozenOverlayEditText, + ) -> Option { + let Some(FrozenOverlayEditElement::Text(annotation)) = self.edits.get_mut(index) else { + return None; + }; + + if *annotation == current_annotation { + return Some(false); + } + + *annotation = current_annotation; + + Some(true) + } + + fn move_target(&self, point: FrozenOverlayEditPoint) -> Option { + for (index, edit) in self.edits.iter().enumerate().rev() { + match edit { + FrozenOverlayEditElement::Mosaic(annotation) if annotation.rect.contains(point) => { + return Some(FrozenOverlayMoveTarget::Mosaic { index, rect: annotation.rect }); + }, + FrozenOverlayEditElement::Text(annotation) + if text_hit_bounds(annotation).contains(point) => + { + return Some(FrozenOverlayMoveTarget::Text { + index, + annotation: annotation.clone(), + }); + }, + FrozenOverlayEditElement::Pen(_) + | FrozenOverlayEditElement::Arrow(_) + | FrozenOverlayEditElement::Mosaic(_) + | FrozenOverlayEditElement::Spotlight(_) + | FrozenOverlayEditElement::Text(_) => {}, + } + } + + None + } + + fn keeps_frozen_selection_fixed(&self) -> bool { + !self.edits.is_empty() + || !self.redo_edits.is_empty() + || self.active_interaction.is_some() + || self.active_text_edit.is_some() + } + + fn is_moving_movable_annotation(&self) -> bool { + matches!( + self.active_interaction, + Some( + ActiveFrozenOverlayEdit::MosaicMove { .. } + | ActiveFrozenOverlayEdit::TextMove { .. } + ) + ) + } + + fn moving_mosaic_edit_index(&self) -> Option { + match self.active_interaction { + Some(ActiveFrozenOverlayEdit::MosaicMove { index, .. }) => Some(index), + Some( + ActiveFrozenOverlayEdit::Pen { .. } + | ActiveFrozenOverlayEdit::Arrow { .. } + | ActiveFrozenOverlayEdit::Mosaic { .. } + | ActiveFrozenOverlayEdit::TextMove { .. } + | ActiveFrozenOverlayEdit::Spotlight { .. }, + ) + | None => None, + } + } + + fn moving_text_edit_index(&self) -> Option { + match self.active_interaction { + Some(ActiveFrozenOverlayEdit::TextMove { index, .. }) => Some(index), + Some( + ActiveFrozenOverlayEdit::Pen { .. } + | ActiveFrozenOverlayEdit::Arrow { .. } + | ActiveFrozenOverlayEdit::Mosaic { .. } + | ActiveFrozenOverlayEdit::MosaicMove { .. } + | ActiveFrozenOverlayEdit::Spotlight { .. }, + ) + | None => None, + } + } + + fn visible_elements( + &self, + moving_mosaic: Option, + moving_text: Option, + ) -> Vec { + self.edits + .iter() + .enumerate() + .filter_map(|(index, edit)| { + if Some(index) == moving_mosaic || Some(index) == moving_text { + None + } else { + Some(edit.clone()) + } + }) + .collect() + } + + fn preview_pen(&self) -> Option { + match self.active_interaction.as_ref()? { + ActiveFrozenOverlayEdit::Pen { points, style } => { + Some(FrozenOverlayEditPen { points: points.clone(), style: *style }) + }, + ActiveFrozenOverlayEdit::Arrow { .. } + | ActiveFrozenOverlayEdit::Mosaic { .. } + | ActiveFrozenOverlayEdit::MosaicMove { .. } + | ActiveFrozenOverlayEdit::TextMove { .. } + | ActiveFrozenOverlayEdit::Spotlight { .. } => None, + } + } + + fn preview_arrow(&self) -> Option { + match self.active_interaction.as_ref()? { + ActiveFrozenOverlayEdit::Arrow { start, current, style } => { + Some(FrozenOverlayEditArrow { start: *start, end: *current, style: *style }) + }, + ActiveFrozenOverlayEdit::Pen { .. } + | ActiveFrozenOverlayEdit::Mosaic { .. } + | ActiveFrozenOverlayEdit::MosaicMove { .. } + | ActiveFrozenOverlayEdit::TextMove { .. } + | ActiveFrozenOverlayEdit::Spotlight { .. } => None, + } + } + + fn preview_mosaic(&self) -> Option { + match self.active_interaction.as_ref()? { + ActiveFrozenOverlayEdit::Mosaic { anchor, current } => { + Some(FrozenOverlayEditMosaic { rect: normalized_rect(*anchor, *current) }) + }, + ActiveFrozenOverlayEdit::MosaicMove { current_rect, .. } => { + Some(FrozenOverlayEditMosaic { rect: *current_rect }) + }, + ActiveFrozenOverlayEdit::Pen { .. } + | ActiveFrozenOverlayEdit::Arrow { .. } + | ActiveFrozenOverlayEdit::TextMove { .. } + | ActiveFrozenOverlayEdit::Spotlight { .. } => None, + } + } + + fn preview_spotlight(&self) -> Option { + match self.active_interaction.as_ref()? { + ActiveFrozenOverlayEdit::Spotlight { anchor, current, style } => { + Some(FrozenOverlayEditSpotlight { + rect: normalized_rect(*anchor, *current), + style: *style, + }) + }, + ActiveFrozenOverlayEdit::Pen { .. } + | ActiveFrozenOverlayEdit::Arrow { .. } + | ActiveFrozenOverlayEdit::Mosaic { .. } + | ActiveFrozenOverlayEdit::MosaicMove { .. } + | ActiveFrozenOverlayEdit::TextMove { .. } => None, + } + } + + fn preview_text(&self) -> Option { + match self.active_interaction.as_ref()? { + ActiveFrozenOverlayEdit::TextMove { current_annotation, .. } => { + Some(current_annotation.clone()) + }, + ActiveFrozenOverlayEdit::Pen { .. } + | ActiveFrozenOverlayEdit::Arrow { .. } + | ActiveFrozenOverlayEdit::Mosaic { .. } + | ActiveFrozenOverlayEdit::MosaicMove { .. } + | ActiveFrozenOverlayEdit::Spotlight { .. } => None, + } + } +} + +fn normalized_rect( + anchor: FrozenOverlayEditPoint, + current: FrozenOverlayEditPoint, +) -> FrozenOverlayEditRect { + FrozenOverlayEditRect::new( + anchor.x.min(current.x), + anchor.y.min(current.y), + (current.x - anchor.x).abs(), + (current.y - anchor.y).abs(), + ) +} + +fn moved_rect( + rect: FrozenOverlayEditRect, + drag_offset: FrozenOverlayEditPoint, + point: FrozenOverlayEditPoint, + selection: FrozenOverlayEditRect, +) -> FrozenOverlayEditRect { + let max_min_x = selection.x.max(selection.max_x() - rect.width); + let max_min_y = selection.y.max(selection.max_y() - rect.height); + + FrozenOverlayEditRect::new( + (point.x - drag_offset.x).clamp(selection.x, max_min_x), + (point.y - drag_offset.y).clamp(selection.y, max_min_y), + rect.width, + rect.height, + ) +} + +fn moved_text_annotation( + annotation: FrozenOverlayEditText, + drag_offset: FrozenOverlayEditPoint, + point: FrozenOverlayEditPoint, + selection: FrozenOverlayEditRect, +) -> FrozenOverlayEditText { + let bounds = text_bounds(&annotation); + let max_anchor_x = selection.x.max(selection.max_x() - bounds.width); + let max_anchor_y = selection.y.max(selection.max_y() - bounds.height); + let anchor = FrozenOverlayEditPoint::new( + (point.x - drag_offset.x).clamp(selection.x, max_anchor_x), + (point.y - drag_offset.y).clamp(selection.y, max_anchor_y), + ); + + FrozenOverlayEditText { anchor, ..annotation } +} + +fn text_hit_bounds(annotation: &FrozenOverlayEditText) -> FrozenOverlayEditRect { + text_bounds(annotation).inset(-TEXT_HIT_PADDING_POINTS, -TEXT_HIT_PADDING_POINTS) +} + +fn text_bounds(annotation: &FrozenOverlayEditText) -> FrozenOverlayEditRect { + let font_size = annotation.style.font_size_points.max(1.0) as f32; + let bounds = + text_rendering::measure_text_bounds(&annotation.text, font_size).unwrap_or_else(|| { + let width = annotation.text.chars().count().max(1) as f32 * font_size * 0.6; + + TextBounds { width, height: font_size * 1.2 } + }); + + FrozenOverlayEditRect::new( + annotation.anchor.x, + annotation.anchor.y, + f64::from(bounds.width.ceil().max(1.0)), + f64::from(bounds.height.ceil().max(1.0)), + ) +} + +#[cfg(test)] +mod tests { + use crate::frozen_edit::{ + FrozenOverlayEditColor, FrozenOverlayEditElement, FrozenOverlayEditPoint, + FrozenOverlayEditRect, FrozenOverlayEditSession, FrozenOverlayEditStyle, + FrozenOverlayEditTextStyle, FrozenOverlayTextEdit, + }; + use rsnap_capture_core::ToolbarItemKind; + + fn selection() -> FrozenOverlayEditRect { + FrozenOverlayEditRect::new(10.0, 20.0, 400.0, 240.0) + } + + #[test] + fn pen_lifecycle_exports_visible_stroke() { + let style = FrozenOverlayEditStyle::default(); + let mut session = FrozenOverlayEditSession::default(); + + assert!(session.begin( + ToolbarItemKind::Pen, + FrozenOverlayEditPoint::new(20.0, 30.0), + selection(), + style, + )); + assert!(session.update(FrozenOverlayEditPoint::new(40.0, 50.0), selection())); + assert!(session.finish(selection())); + + let snapshot = session.snapshot(); + + assert!(snapshot.can_undo); + assert_eq!(snapshot.elements.len(), 1); + assert!(matches!(snapshot.elements[0], FrozenOverlayEditElement::Pen(_))); + } + + #[test] + fn undo_and_redo_move_committed_elements_between_stacks() { + let style = FrozenOverlayEditStyle::default(); + let mut session = FrozenOverlayEditSession::default(); + + assert!(session.begin( + ToolbarItemKind::Mosaic, + FrozenOverlayEditPoint::new(20.0, 30.0), + selection(), + style, + )); + assert!(session.update(FrozenOverlayEditPoint::new(80.0, 100.0), selection())); + assert!(session.finish(selection())); + assert!(session.undo()); + assert!(session.snapshot().can_redo); + assert!(session.snapshot().elements.is_empty()); + assert!(session.redo()); + assert_eq!(session.snapshot().elements.len(), 1); + } + + #[test] + fn mosaic_move_hides_original_while_preview_is_active() { + let style = FrozenOverlayEditStyle::default(); + let mut session = FrozenOverlayEditSession::default(); + + assert!(session.begin( + ToolbarItemKind::Mosaic, + FrozenOverlayEditPoint::new(20.0, 30.0), + selection(), + style, + )); + assert!(session.update(FrozenOverlayEditPoint::new(80.0, 100.0), selection())); + assert!(session.finish(selection())); + assert!(session.begin( + ToolbarItemKind::Pointer, + FrozenOverlayEditPoint::new(30.0, 40.0), + selection(), + style, + )); + + let snapshot = session.snapshot(); + + assert!(snapshot.is_moving_movable_annotation); + assert!(snapshot.elements.is_empty()); + assert!(snapshot.preview_mosaic.is_some()); + } + + #[test] + fn text_lifecycle_commits_and_trims_empty_edits() { + let style = FrozenOverlayEditStyle::default(); + let mut session = FrozenOverlayEditSession::default(); + + assert!(session.begin( + ToolbarItemKind::Text, + FrozenOverlayEditPoint::new(20.0, 30.0), + selection(), + style, + )); + assert!(session.append_text(" ")); + assert!(!session.commit_text_edit(FrozenOverlayEditTextStyle::default())); + assert!(session.begin( + ToolbarItemKind::Text, + FrozenOverlayEditPoint::new(30.0, 40.0), + selection(), + style, + )); + assert!(session.append_text("Hello")); + assert!(session.backspace_text()); + assert!(session.append_text("o")); + assert!(session.commit_text_edit(FrozenOverlayEditTextStyle { + color: FrozenOverlayEditColor::White, + ..FrozenOverlayEditTextStyle::default() + })); + assert_eq!(session.snapshot().active_text_edit, None); + assert!(matches!(session.snapshot().elements[0], FrozenOverlayEditElement::Text(_))); + } + + #[test] + fn snapshot_reports_active_text_edit() { + let mut session = FrozenOverlayEditSession::default(); + + assert!(session.begin( + ToolbarItemKind::Text, + FrozenOverlayEditPoint::new(20.0, 30.0), + selection(), + FrozenOverlayEditStyle::default(), + )); + assert!(session.append_text("Text")); + assert_eq!( + session.snapshot().active_text_edit, + Some(FrozenOverlayTextEdit { + anchor: FrozenOverlayEditPoint::new(20.0, 30.0), + text: String::from("Text"), + }) + ); + } +} diff --git a/packages/rsnap-overlay/src/frozen_export.rs b/packages/rsnap-overlay/src/frozen_export.rs new file mode 100644 index 00000000..99e73541 --- /dev/null +++ b/packages/rsnap-overlay/src/frozen_export.rs @@ -0,0 +1,831 @@ +//! Portable frozen-overlay export compositing used by native hosts. + +use std::f32::consts::PI; + +use color_eyre::eyre::{self, Result}; +use egui::Pos2; +use image::{ + Rgba, RgbaImage, + imageops::{self, FilterType}, +}; + +use crate::text_rendering::{self, RasterTextAnnotation}; +use rsnap_capture_core::{self, DisplayPointRect}; + +const SPOTLIGHT_VISIBLE_NUMERATOR: u16 = 173; +const STROKE_EXPORT_ALPHA: f32 = 0.96; +const DARK_TEXT_SHADOW_RGBA: [u8; 4] = [0, 0, 0, 115]; +const LIGHT_TEXT_SHADOW_RGBA: [u8; 4] = [255, 255, 255, 122]; + +/// Point-space coordinate used by frozen-overlay export annotations. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FrozenOverlayExportPoint { + /// X coordinate in frozen capture point-space. + pub x: f64, + /// Y coordinate in frozen capture point-space. + pub y: f64, +} +impl FrozenOverlayExportPoint { + /// Creates a frozen-overlay export point. + #[must_use] + pub const fn new(x: f64, y: f64) -> Self { + Self { x, y } + } +} + +/// Stroke style used by pen and arrow export annotations. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FrozenOverlayExportStrokeStyle { + /// Stroke width in frozen capture points. + pub stroke_width_points: f32, + /// Source color as non-premultiplied RGBA bytes. + pub rgba: [u8; 4], +} + +/// Spotlight border style used by export annotations. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FrozenOverlayExportSpotlightStyle { + /// Border width in frozen capture points. + pub border_width_points: f32, + /// Border color as non-premultiplied RGBA bytes. + pub border_rgba: [u8; 4], +} + +/// Text style used by export annotations. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FrozenOverlayExportTextStyle { + /// Font size in frozen capture points. + pub font_size_points: f32, + /// Text fill color as non-premultiplied RGBA bytes. + pub rgba: [u8; 4], +} + +/// Pen stroke export annotation. +#[derive(Clone, Debug, PartialEq)] +pub struct FrozenOverlayExportPen { + /// Stroke points in frozen capture point-space. + pub points: Vec, + /// Stroke style. + pub style: FrozenOverlayExportStrokeStyle, +} + +/// Arrow export annotation. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FrozenOverlayExportArrow { + /// Arrow tail in frozen capture point-space. + pub start: FrozenOverlayExportPoint, + /// Arrow tip in frozen capture point-space. + pub end: FrozenOverlayExportPoint, + /// Stroke style. + pub style: FrozenOverlayExportStrokeStyle, +} + +/// Mosaic privacy export annotation. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FrozenOverlayExportMosaic { + /// Mosaic rectangle in frozen capture point-space. + pub rect: DisplayPointRect, +} + +/// Spotlight export annotation. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FrozenOverlayExportSpotlight { + /// Spotlight rectangle in frozen capture point-space. + pub rect: DisplayPointRect, + /// Spotlight border style. + pub style: FrozenOverlayExportSpotlightStyle, +} + +/// Text export annotation. +#[derive(Clone, Debug, PartialEq)] +pub struct FrozenOverlayExportText { + /// Text anchor in frozen capture point-space. + pub anchor: FrozenOverlayExportPoint, + /// Text payload. + pub text: String, + /// Text style. + pub style: FrozenOverlayExportTextStyle, +} + +#[derive(Clone, Copy, Debug)] +struct ArrowGeometry { + shaft_end: Pos2, + head_left: Pos2, + head_right: Pos2, +} + +#[derive(Clone, Copy, Debug)] +struct PixelRect { + x: u32, + y: u32, + width: u32, + height: u32, +} + +#[derive(Clone, Copy, Debug)] +struct FloatRect { + x: f32, + y: f32, + width: f32, + height: f32, +} + +#[derive(Clone, Copy, Debug)] +struct ExportTransform { + selection: DisplayPointRect, + image_width: u32, + image_height: u32, + scale_x: f64, + scale_y: f64, +} +impl ExportTransform { + fn new(selection: DisplayPointRect, image_width: u32, image_height: u32) -> Result { + eyre::ensure!( + image_width > 0 && image_height > 0, + "frozen-overlay export image dimensions must be non-empty" + ); + eyre::ensure!( + valid_rect(selection), + "frozen-overlay export selection must be finite and non-empty" + ); + + Ok(Self { + selection, + image_width, + image_height, + scale_x: f64::from(image_width) / selection.width, + scale_y: f64::from(image_height) / selection.height, + }) + } + + fn scalar_scale(self) -> f32 { + ((self.scale_x + self.scale_y) * 0.5) as f32 + } + + fn point_to_pixels(self, point: FrozenOverlayExportPoint) -> Option { + if !point.x.is_finite() || !point.y.is_finite() { + return None; + } + + let x = (point.x - self.selection.x) * self.scale_x; + let y = (point.y - self.selection.y) * self.scale_y; + + f64_pair_to_pos2(x, y) + } + + fn float_rect(self, rect: DisplayPointRect) -> Option { + if !valid_rect(rect) { + return None; + } + + let x = (rect.x - self.selection.x) * self.scale_x; + let y = (rect.y - self.selection.y) * self.scale_y; + let width = rect.width * self.scale_x; + let height = rect.height * self.scale_y; + let origin = f64_pair_to_pos2(x, y)?; + let size = f64_pair_to_pos2(width, height)?; + + Some(FloatRect { x: origin.x, y: origin.y, width: size.x, height: size.y }) + } + + fn integral_image_rect(self, rect: DisplayPointRect) -> Option { + let rect = self.float_rect(rect)?; + let left = rect.x.floor().max(0.0); + let top = rect.y.floor().max(0.0); + let right = (rect.x + rect.width).ceil().min(self.image_width as f32); + let bottom = (rect.y + rect.height).ceil().min(self.image_height as f32); + + if left >= right || top >= bottom { + return None; + } + + Some(PixelRect { + x: left as u32, + y: top as u32, + width: (right - left) as u32, + height: (bottom - top) as u32, + }) + } + + fn source_image_rect(self, rect: DisplayPointRect) -> Option { + if !valid_rect(rect) { + return None; + } + + let right = rect.x + rect.width; + let bottom = rect.y + rect.height; + let selection_bottom = self.selection.y + self.selection.height; + + Some(DisplayPointRect::new( + (rect.x - self.selection.x) * self.scale_x, + (selection_bottom - bottom) * self.scale_y, + (right - rect.x) * self.scale_x, + (bottom - rect.y) * self.scale_y, + )) + } +} + +/// One committed frozen-overlay edit to composite into an exported image. +#[derive(Clone, Debug, PartialEq)] +pub enum FrozenOverlayExportElement { + /// Pen stroke annotation. + Pen(FrozenOverlayExportPen), + /// Arrow annotation. + Arrow(FrozenOverlayExportArrow), + /// Mosaic privacy rectangle. + Mosaic(FrozenOverlayExportMosaic), + /// Spotlight annotation. + Spotlight(FrozenOverlayExportSpotlight), + /// Text annotation. + Text(FrozenOverlayExportText), +} + +/// Composites committed frozen-overlay annotations into a row-major RGBA export image. +/// +/// The compositor intentionally mirrors the native host's previous export order: +/// mosaics first, spotlight scrim/restores second, then pen, arrow, and text annotations. +pub fn render_frozen_overlay_export_rgba( + width: u32, + height: u32, + rgba: &[u8], + selection: DisplayPointRect, + elements: &[FrozenOverlayExportElement], +) -> Result { + let mut image = rgba_image_from_bytes(width, height, rgba)?; + let original = image.clone(); + let transform = ExportTransform::new(selection, width, height)?; + + apply_mosaics(&mut image, transform, elements); + apply_spotlights(&mut image, &original, transform, elements); + render_pen_annotations(&mut image, transform, elements); + render_arrow_annotations(&mut image, transform, elements); + render_text_annotations(&mut image, transform, elements); + + Ok(image) +} + +fn rgba_image_from_bytes(width: u32, height: u32, rgba: &[u8]) -> Result { + let expected_len = usize::try_from(width) + .ok() + .and_then(|width| usize::try_from(height).ok().map(|height| (width, height))) + .and_then(|(width, height)| width.checked_mul(height)) + .and_then(|pixels| pixels.checked_mul(4)) + .ok_or_else(|| eyre::eyre!("frozen-overlay export dimensions overflow"))?; + + eyre::ensure!( + rgba.len() == expected_len, + "frozen-overlay export byte length mismatch: expected {} got {}", + expected_len, + rgba.len() + ); + + RgbaImage::from_raw(width, height, rgba.to_vec()) + .ok_or_else(|| eyre::eyre!("frozen-overlay export RGBA payload is invalid")) +} + +fn apply_mosaics( + image: &mut RgbaImage, + transform: ExportTransform, + elements: &[FrozenOverlayExportElement], +) { + for element in elements { + let FrozenOverlayExportElement::Mosaic(annotation) = element else { + continue; + }; + + apply_mosaic(image, transform, annotation.rect); + } +} + +fn apply_mosaic(image: &mut RgbaImage, transform: ExportTransform, rect: DisplayPointRect) { + let Some(destination) = transform.integral_image_rect(rect) else { + return; + }; + let Some(source_rect) = transform.source_image_rect(rect) else { + return; + }; + let Some(patch) = rsnap_capture_core::frozen_mosaic_light_privacy_patch( + image.width(), + image.height(), + source_rect, + ) else { + return; + }; + let patch = if patch.width() == destination.width && patch.height() == destination.height { + patch + } else { + imageops::resize(&patch, destination.width, destination.height, FilterType::Lanczos3) + }; + + imageops::replace(image, &patch, i64::from(destination.x), i64::from(destination.y)); +} + +fn apply_spotlights( + image: &mut RgbaImage, + original: &RgbaImage, + transform: ExportTransform, + elements: &[FrozenOverlayExportElement], +) { + let spotlights = elements + .iter() + .filter_map(|element| match element { + FrozenOverlayExportElement::Spotlight(annotation) => Some(annotation), + _ => None, + }) + .collect::>(); + + if spotlights.is_empty() { + return; + } + + dim_image_for_spotlight(image); + + for spotlight in &spotlights { + restore_spotlight_rect(image, original, transform, spotlight.rect); + } + for spotlight in spotlights { + render_spotlight_border(image, transform, spotlight.rect, spotlight.style); + } +} + +fn dim_image_for_spotlight(image: &mut RgbaImage) { + for pixel in image.pixels_mut() { + for channel in 0..3 { + pixel[channel] = + ((u16::from(pixel[channel]) * SPOTLIGHT_VISIBLE_NUMERATOR) / 255) as u8; + } + } +} + +fn restore_spotlight_rect( + image: &mut RgbaImage, + original: &RgbaImage, + transform: ExportTransform, + rect: DisplayPointRect, +) { + let Some(destination) = transform.integral_image_rect(rect) else { + return; + }; + let source = imageops::crop_imm( + original, + destination.x, + destination.y, + destination.width, + destination.height, + ) + .to_image(); + + imageops::replace(image, &source, i64::from(destination.x), i64::from(destination.y)); +} + +fn render_spotlight_border( + image: &mut RgbaImage, + transform: ExportTransform, + rect: DisplayPointRect, + style: FrozenOverlayExportSpotlightStyle, +) { + let line_width = style.border_width_points * transform.scalar_scale(); + + if line_width <= f32::EPSILON { + return; + } + + let Some(rect) = transform.float_rect(rect) else { + return; + }; + let inset = line_width * 0.5; + let left = rect.x + inset; + let right = rect.x + rect.width - inset; + let top = rect.y + inset; + let bottom = rect.y + rect.height - inset; + let color = with_scaled_alpha(style.border_rgba, STROKE_EXPORT_ALPHA); + + draw_segments( + image, + &[ + (Pos2::new(left, top), Pos2::new(right, top)), + (Pos2::new(right, top), Pos2::new(right, bottom)), + (Pos2::new(right, bottom), Pos2::new(left, bottom)), + (Pos2::new(left, bottom), Pos2::new(left, top)), + ], + line_width, + Rgba(color), + ); +} + +fn render_pen_annotations( + image: &mut RgbaImage, + transform: ExportTransform, + elements: &[FrozenOverlayExportElement], +) { + for element in elements { + let FrozenOverlayExportElement::Pen(annotation) = element else { + continue; + }; + let points = annotation + .points + .iter() + .filter_map(|point| transform.point_to_pixels(*point)) + .collect::>(); + let color = Rgba(with_scaled_alpha(annotation.style.rgba, STROKE_EXPORT_ALPHA)); + + draw_polyline( + image, + &points, + annotation.style.stroke_width_points * transform.scalar_scale(), + color, + ); + } +} + +fn render_arrow_annotations( + image: &mut RgbaImage, + transform: ExportTransform, + elements: &[FrozenOverlayExportElement], +) { + for element in elements { + let FrozenOverlayExportElement::Arrow(annotation) = element else { + continue; + }; + + render_arrow_annotation(image, transform, *annotation); + } +} + +fn render_arrow_annotation( + image: &mut RgbaImage, + transform: ExportTransform, + annotation: FrozenOverlayExportArrow, +) { + let Some(start) = transform.point_to_pixels(annotation.start) else { + return; + }; + let Some(end) = transform.point_to_pixels(annotation.end) else { + return; + }; + let Some(geometry) = + arrow_geometry(start, end, annotation.style.stroke_width_points, transform) + else { + return; + }; + let stroke_width = annotation.style.stroke_width_points * 1.4 * transform.scalar_scale(); + let color = Rgba(with_scaled_alpha(annotation.style.rgba, STROKE_EXPORT_ALPHA)); + + draw_segments( + image, + &[(start, geometry.shaft_end), (end, geometry.head_left), (end, geometry.head_right)], + stroke_width, + color, + ); +} + +fn render_text_annotations( + image: &mut RgbaImage, + transform: ExportTransform, + elements: &[FrozenOverlayExportElement], +) { + for element in elements { + let FrozenOverlayExportElement::Text(annotation) = element else { + continue; + }; + + render_text_annotation(image, transform, annotation); + } +} + +fn render_text_annotation( + image: &mut RgbaImage, + transform: ExportTransform, + annotation: &FrozenOverlayExportText, +) { + if annotation.text.trim().is_empty() { + return; + } + + let Some(anchor) = transform.point_to_pixels(annotation.anchor) else { + return; + }; + let font_size_px = (annotation.style.font_size_points * transform.scalar_scale()).max(1.0); + let shadow_anchor = Pos2::new(anchor.x, anchor.y + transform.scalar_scale().max(1.0)); + let shadow = RasterTextAnnotation { + anchor_px: shadow_anchor, + font_size_px, + fill_rgba: text_shadow_rgba(annotation.style.rgba), + text: annotation.text.as_str(), + }; + let fill = RasterTextAnnotation { + anchor_px: anchor, + font_size_px, + fill_rgba: annotation.style.rgba, + text: annotation.text.as_str(), + }; + + text_rendering::render_text_annotations(image, &[shadow, fill]); +} + +fn text_shadow_rgba(fill: [u8; 4]) -> [u8; 4] { + if fill[0] <= 40 && fill[1] <= 40 && fill[2] <= 40 { + LIGHT_TEXT_SHADOW_RGBA + } else { + DARK_TEXT_SHADOW_RGBA + } +} + +fn draw_polyline(image: &mut RgbaImage, points: &[Pos2], line_width: f32, color: Rgba) { + if points.is_empty() || line_width <= f32::EPSILON { + return; + } + if points.len() == 1 { + draw_segments(image, &[(points[0], points[0])], line_width, color); + + return; + } + + let segments = points.windows(2).map(|points| (points[0], points[1])).collect::>(); + + draw_segments(image, &segments, line_width, color); +} + +fn draw_segments( + image: &mut RgbaImage, + segments: &[(Pos2, Pos2)], + line_width: f32, + color: Rgba, +) { + if segments.is_empty() || image.width() == 0 || image.height() == 0 { + return; + } + + let radius = (line_width * 0.5).max(0.5); + let mut coverage_mask = vec![0_u8; image.width() as usize * image.height() as usize]; + + for (start, end) in segments { + rasterize_segment(&mut coverage_mask, image.width(), image.height(), *start, *end, radius); + } + + blend_coverage_mask(image, &coverage_mask, color); +} + +fn rasterize_segment( + coverage_mask: &mut [u8], + width: u32, + height: u32, + start: Pos2, + end: Pos2, + radius: f32, +) { + let delta = end - start; + let delta_len_sq = delta.length_sq(); + + if delta_len_sq <= f32::EPSILON { + rasterize_circle(coverage_mask, width, height, start, radius); + + return; + } + + let min_x = ((start.x.min(end.x) - radius - 0.5).floor().max(0.0)) as u32; + let min_y = ((start.y.min(end.y) - radius - 0.5).floor().max(0.0)) as u32; + let max_x = + ((start.x.max(end.x) + radius + 0.5).ceil().min(width.saturating_sub(1) as f32)) as u32; + let max_y = + ((start.y.max(end.y) + radius + 0.5).ceil().min(height.saturating_sub(1) as f32)) as u32; + + for y in min_y..=max_y { + for x in min_x..=max_x { + let sample = Pos2::new(x as f32 + 0.5, y as f32 + 0.5); + let projection = ((sample - start).dot(delta) / delta_len_sq).clamp(0.0, 1.0); + let nearest = start + delta * projection; + + update_coverage_mask( + coverage_mask, + width, + x, + y, + stroke_coverage(sample.distance(nearest), radius), + ); + } + } +} + +fn rasterize_circle(coverage_mask: &mut [u8], width: u32, height: u32, center: Pos2, radius: f32) { + let min_x = ((center.x - radius - 0.5).floor().max(0.0)) as u32; + let min_y = ((center.y - radius - 0.5).floor().max(0.0)) as u32; + let max_x = ((center.x + radius + 0.5).ceil().min(width.saturating_sub(1) as f32)) as u32; + let max_y = ((center.y + radius + 0.5).ceil().min(height.saturating_sub(1) as f32)) as u32; + + if min_x > max_x || min_y > max_y { + return; + } + + for y in min_y..=max_y { + for x in min_x..=max_x { + let sample = Pos2::new(x as f32 + 0.5, y as f32 + 0.5); + + update_coverage_mask( + coverage_mask, + width, + x, + y, + stroke_coverage(sample.distance(center), radius), + ); + } + } +} + +fn stroke_coverage(distance: f32, radius: f32) -> u8 { + ((radius + 0.5 - distance).clamp(0.0, 1.0) * 255.0).round() as u8 +} + +fn update_coverage_mask(coverage_mask: &mut [u8], width: u32, x: u32, y: u32, coverage: u8) { + if coverage == 0 { + return; + } + + let index = y as usize * width as usize + x as usize; + + coverage_mask[index] = coverage_mask[index].max(coverage); +} + +fn blend_coverage_mask(image: &mut RgbaImage, coverage_mask: &[u8], color: Rgba) { + let source_alpha = f32::from(color[3]) / 255.0; + + for (index, pixel) in image.pixels_mut().enumerate() { + let mask_alpha = coverage_mask[index]; + + if mask_alpha == 0 { + continue; + } + + blend_pixel(pixel, color, f32::from(mask_alpha) / 255.0 * source_alpha); + } +} + +fn blend_pixel(pixel: &mut Rgba, color: Rgba, src_a: f32) { + let dst_a = f32::from(pixel[3]) / 255.0; + let out_a = src_a + dst_a * (1.0 - src_a); + + if out_a <= f32::EPSILON { + return; + } + + for channel in 0..3 { + let src = f32::from(color[channel]) / 255.0; + let dst = f32::from(pixel[channel]) / 255.0; + let out = (src * src_a + dst * dst_a * (1.0 - src_a)) / out_a; + + pixel[channel] = (out * 255.0).round().clamp(0.0, 255.0) as u8; + } + + pixel[3] = (out_a * 255.0).round().clamp(0.0, 255.0) as u8; +} + +fn with_scaled_alpha(mut rgba: [u8; 4], scale: f32) -> [u8; 4] { + rgba[3] = (f32::from(rgba[3]) * scale).round().clamp(0.0, 255.0) as u8; + + rgba +} + +fn arrow_geometry( + start: Pos2, + end: Pos2, + stroke_width_points: f32, + transform: ExportTransform, +) -> Option { + let distance = start.distance(end); + + if distance <= f32::EPSILON { + return None; + } + + let stroke_width = stroke_width_points * 1.4 * transform.scalar_scale(); + let head_length = (stroke_width * 4.2).max(16.0 * transform.scalar_scale()).min(distance * 0.9); + let head_spread = PI / 7.0; + let angle = (end.y - start.y).atan2(end.x - start.x); + let direction = Pos2::new(angle.cos(), angle.sin()); + let shaft_end = Pos2::new( + end.x - direction.x * head_length * 0.72, + end.y - direction.y * head_length * 0.72, + ); + + Some(ArrowGeometry { + shaft_end, + head_left: Pos2::new( + end.x - (angle - head_spread).cos() * head_length, + end.y - (angle - head_spread).sin() * head_length, + ), + head_right: Pos2::new( + end.x - (angle + head_spread).cos() * head_length, + end.y - (angle + head_spread).sin() * head_length, + ), + }) +} + +fn valid_rect(rect: DisplayPointRect) -> bool { + rect.x.is_finite() + && rect.y.is_finite() + && rect.width.is_finite() + && rect.height.is_finite() + && rect.width > 0.0 + && rect.height > 0.0 +} + +fn f64_pair_to_pos2(x: f64, y: f64) -> Option { + if x.is_finite() + && y.is_finite() + && x >= f64::from(f32::MIN) + && y >= f64::from(f32::MIN) + && x <= f64::from(f32::MAX) + && y <= f64::from(f32::MAX) + { + Some(Pos2::new(x as f32, y as f32)) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use image::{Rgba, RgbaImage}; + + use crate::frozen_export::{ + self, FrozenOverlayExportArrow, FrozenOverlayExportElement, FrozenOverlayExportMosaic, + FrozenOverlayExportPen, FrozenOverlayExportPoint, FrozenOverlayExportSpotlight, + FrozenOverlayExportSpotlightStyle, FrozenOverlayExportStrokeStyle, FrozenOverlayExportText, + FrozenOverlayExportTextStyle, + }; + use rsnap_capture_core::DisplayPointRect; + + #[test] + fn export_compositor_applies_mosaic_spotlight_and_stroke() { + let image = RgbaImage::from_fn(20, 12, |x, y| Rgba([x as u8, y as u8, 120, 255])); + let elements = vec![ + FrozenOverlayExportElement::Mosaic(FrozenOverlayExportMosaic { + rect: DisplayPointRect::new(2.0, 2.0, 8.0, 4.0), + }), + FrozenOverlayExportElement::Spotlight(FrozenOverlayExportSpotlight { + rect: DisplayPointRect::new(10.0, 2.0, 5.0, 4.0), + style: FrozenOverlayExportSpotlightStyle { + border_width_points: 1.0, + border_rgba: [255, 255, 255, 255], + }, + }), + FrozenOverlayExportElement::Pen(FrozenOverlayExportPen { + points: vec![ + FrozenOverlayExportPoint::new(0.0, 0.0), + FrozenOverlayExportPoint::new(19.0, 11.0), + ], + style: FrozenOverlayExportStrokeStyle { + stroke_width_points: 2.0, + rgba: [102, 178, 255, 255], + }, + }), + ]; + let rendered = frozen_export::render_frozen_overlay_export_rgba( + image.width(), + image.height(), + image.as_raw(), + DisplayPointRect::new(0.0, 0.0, 20.0, 12.0), + &elements, + ) + .expect("valid export"); + + assert_eq!(rendered.dimensions(), image.dimensions()); + assert_ne!(rendered.as_raw(), image.as_raw()); + assert_eq!(rendered.get_pixel(12, 3), image.get_pixel(12, 3)); + assert!(rendered.get_pixel(0, 10)[0] < image.get_pixel(0, 10)[0] + 1); + } + + #[test] + fn export_compositor_renders_arrow_and_text() { + let image = RgbaImage::from_pixel(64, 40, Rgba([24, 24, 24, 255])); + let elements = vec![ + FrozenOverlayExportElement::Arrow(FrozenOverlayExportArrow { + start: FrozenOverlayExportPoint::new(4.0, 8.0), + end: FrozenOverlayExportPoint::new(50.0, 20.0), + style: FrozenOverlayExportStrokeStyle { + stroke_width_points: 3.0, + rgba: [255, 107, 107, 255], + }, + }), + FrozenOverlayExportElement::Text(FrozenOverlayExportText { + anchor: FrozenOverlayExportPoint::new(6.0, 24.0), + text: "Hi".to_owned(), + style: FrozenOverlayExportTextStyle { + font_size_points: 12.0, + rgba: [255, 255, 255, 255], + }, + }), + ]; + let rendered = frozen_export::render_frozen_overlay_export_rgba( + image.width(), + image.height(), + image.as_raw(), + DisplayPointRect::new(0.0, 0.0, 64.0, 40.0), + &elements, + ) + .expect("valid export"); + + assert_ne!(rendered.as_raw(), image.as_raw()); + assert!(rendered.pixels().any(|pixel| pixel[0] > 200 || pixel[1] > 100 || pixel[2] > 100)); + } +} diff --git a/packages/rsnap-overlay/src/lib.rs b/packages/rsnap-overlay/src/lib.rs index 0a6f7c9a..9f5d06fb 100644 --- a/packages/rsnap-overlay/src/lib.rs +++ b/packages/rsnap-overlay/src/lib.rs @@ -22,6 +22,8 @@ pub mod replay_support { replay_recorded_scroll_capture_trace, replay_recorded_scroll_capture_trace_with_mode, }; } +pub mod frozen_edit; +pub mod frozen_export; pub mod scroll_stitching { //! Narrow native-host wrapper around the existing scroll-capture stitching engine. diff --git a/packages/rsnap-overlay/src/live_frame_stream_macos.rs b/packages/rsnap-overlay/src/live_frame_stream_macos.rs index 430e21bb..3101cd9d 100644 --- a/packages/rsnap-overlay/src/live_frame_stream_macos.rs +++ b/packages/rsnap-overlay/src/live_frame_stream_macos.rs @@ -19,12 +19,16 @@ use std::time::{Duration, Instant}; use block2::RcBlock; use dispatch2::{DispatchQueue, DispatchQueueAttr, DispatchRetained}; +use image::Rgba; use image::RgbaImage; use objc2::rc::Retained; use objc2::runtime::ProtocolObject; use objc2::{AnyThread, DefinedClass, Message}; use objc2_core_foundation::{self, CFRetained, CGPoint, CGRect, CGSize}; use objc2_core_media::{CMSampleBuffer, kCMTimeZero}; +use objc2_core_video::CVPixelBufferCreate; +use objc2_core_video::CVPixelBufferGetHeight; +use objc2_core_video::CVPixelBufferGetWidth; use objc2_core_video::{ CVPixelBuffer, CVPixelBufferGetBaseAddress, CVPixelBufferGetBytesPerRow, CVPixelBufferLockBaseAddress, CVPixelBufferLockFlags, CVPixelBufferUnlockBaseAddress, @@ -332,7 +336,7 @@ impl MacLiveFrameStream { fn debug_test_pixel_buffer() -> SharedPixelBuffer { let mut buffer = ptr::null_mut(); let res = unsafe { - objc2_core_video::CVPixelBufferCreate( + CVPixelBufferCreate( None, 1, 1, @@ -3037,8 +3041,8 @@ fn sample_handler_queue_label(monitor_id: u32) -> String { } fn pixel_buffer_size_px(pixel_buffer: &CFRetained) -> Option<(u32, u32)> { - let width = objc2_core_video::CVPixelBufferGetWidth(pixel_buffer); - let height = objc2_core_video::CVPixelBufferGetHeight(pixel_buffer); + let width = CVPixelBufferGetWidth(pixel_buffer); + let height = CVPixelBufferGetHeight(pixel_buffer); let width = u32::try_from(width).ok()?; let height = u32::try_from(height).ok()?; @@ -3135,7 +3139,7 @@ fn sample_cursor_from_bgra_bytes( let r = *bytes.get(offset + 2)?; let a = *bytes.get(offset + 3)?; - out_patch.put_pixel(ox as u32, oy as u32, image::Rgba([r, g, b, a])); + out_patch.put_pixel(ox as u32, oy as u32, Rgba([r, g, b, a])); } } @@ -3191,7 +3195,7 @@ fn rgba_image_from_pixel_buffer( let r = row.get(idx + 2).copied().unwrap_or(0); let a = row.get(idx + 3).copied().unwrap_or(255); - out.put_pixel(x as u32, y as u32, image::Rgba([r, g, b, a])); + out.put_pixel(x as u32, y as u32, Rgba([r, g, b, a])); } } @@ -3244,7 +3248,7 @@ fn rgba_region_from_pixel_buffer( let r = row.get(idx + 2).copied().unwrap_or(0); let a = row.get(idx + 3).copied().unwrap_or(255); - out.put_pixel(x as u32, y as u32, image::Rgba([r, g, b, a])); + out.put_pixel(x as u32, y as u32, Rgba([r, g, b, a])); } } diff --git a/packages/rsnap-overlay/src/overlay.rs b/packages/rsnap-overlay/src/overlay.rs index 82de23d5..94db087f 100644 --- a/packages/rsnap-overlay/src/overlay.rs +++ b/packages/rsnap-overlay/src/overlay.rs @@ -73,7 +73,16 @@ use egui::{ Area, CentralPanel, ClippedPrimitive, Id, LayerId, Order, RichText, Sense, Shape, Stroke, StrokeKind, UiBuilder, ViewportId, Visuals, }; -use egui_phosphor::{Variant, regular}; +#[cfg(target_os = "macos")] +use egui_phosphor::regular::FILE_TEXT; +use egui_phosphor::{ + Variant, + regular::{ + self, ARROW_CLOCKWISE, ARROW_COUNTER_CLOCKWISE, ARROW_UP_RIGHT, ARROWS_DOWN_UP, + ARROWS_IN_CARDINAL, CHECKERBOARD, COPY, CURSOR, FLOPPY_DISK, FRAME_CORNERS, PENCIL_SIMPLE, + TEXT_T, + }, +}; use egui_wgpu::{Renderer, ScreenDescriptor}; use image::RgbaImage; #[cfg(target_os = "macos")] @@ -720,20 +729,20 @@ impl FrozenToolbarTool { const fn icon(self) -> &'static str { match self { - Self::Pointer => regular::CURSOR, - Self::Pen => regular::PENCIL_SIMPLE, - Self::Arrow => regular::ARROW_UP_RIGHT, - Self::Text => regular::TEXT_T, - Self::Mosaic => regular::CHECKERBOARD, - Self::Spotlight => regular::FRAME_CORNERS, - Self::Undo => regular::ARROW_COUNTER_CLOCKWISE, - Self::Redo => regular::ARROW_CLOCKWISE, - Self::AutoCenter => regular::ARROWS_IN_CARDINAL, - Self::Scroll => regular::ARROWS_DOWN_UP, + Self::Pointer => CURSOR, + Self::Pen => PENCIL_SIMPLE, + Self::Arrow => ARROW_UP_RIGHT, + Self::Text => TEXT_T, + Self::Mosaic => CHECKERBOARD, + Self::Spotlight => FRAME_CORNERS, + Self::Undo => ARROW_COUNTER_CLOCKWISE, + Self::Redo => ARROW_CLOCKWISE, + Self::AutoCenter => ARROWS_IN_CARDINAL, + Self::Scroll => ARROWS_DOWN_UP, #[cfg(target_os = "macos")] - Self::Ocr => regular::FILE_TEXT, - Self::Copy => regular::COPY, - Self::Save => regular::FLOPPY_DISK, + Self::Ocr => FILE_TEXT, + Self::Copy => COPY, + Self::Save => FLOPPY_DISK, } } diff --git a/packages/rsnap-overlay/src/overlay/frozen_selection_runtime.rs b/packages/rsnap-overlay/src/overlay/frozen_selection_runtime.rs index fbcfceac..bd4e8b5f 100644 --- a/packages/rsnap-overlay/src/overlay/frozen_selection_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/frozen_selection_runtime.rs @@ -2,6 +2,7 @@ use std::time::{Duration, Instant}; use image::{Rgba, RgbaImage}; +use crate::overlay::LIVE_DRAG_START_THRESHOLD_PX; use crate::overlay::{ CursorIcon, FrozenCaptureSource, FrozenMosaicDragState, FrozenSelectionCorner, FrozenSelectionDragState, FrozenSelectionInteractionKind, FrozenToolbarTool, GlobalPoint, @@ -151,8 +152,8 @@ impl OverlaySession { current_global: GlobalPoint, ) -> bool { monitor.local_rect_from_points(press_global, current_global).is_some_and(|rect| { - rect.width as f32 >= crate::overlay::LIVE_DRAG_START_THRESHOLD_PX - && rect.height as f32 >= crate::overlay::LIVE_DRAG_START_THRESHOLD_PX + rect.width as f32 >= LIVE_DRAG_START_THRESHOLD_PX + && rect.height as f32 >= LIVE_DRAG_START_THRESHOLD_PX }) } diff --git a/packages/rsnap-overlay/src/overlay/input_runtime.rs b/packages/rsnap-overlay/src/overlay/input_runtime.rs index 0137d418..29476be2 100644 --- a/packages/rsnap-overlay/src/overlay/input_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/input_runtime.rs @@ -8,6 +8,8 @@ use winit::keyboard::ModifiersState; #[cfg(target_os = "macos")] use crate::overlay::FrozenGlobalHotkey; +#[cfg(target_os = "macos")] +use crate::overlay::SLOW_OP_WARN_CURSOR_LOCATION; use crate::overlay::{ CURSOR_EVENT_TICK_TTL, CursorMoveTrace, DeviceCursorPointSource, ElementState, FrozenSelectionDragCursorMoveTiming, FrozenTextEditState, FrozenTextInputSource, @@ -215,7 +217,7 @@ impl OverlaySession { self.slow_op_logger.warn_if_slow( "overlay.macos_cursor_location", elapsed, - super::SLOW_OP_WARN_CURSOR_LOCATION, + SLOW_OP_WARN_CURSOR_LOCATION, || format!("sample point=({}, {})", point.x, point.y), ); diff --git a/packages/rsnap-overlay/src/overlay/rendering.rs b/packages/rsnap-overlay/src/overlay/rendering.rs index 0b5a7cd2..cf508a41 100644 --- a/packages/rsnap-overlay/src/overlay/rendering.rs +++ b/packages/rsnap-overlay/src/overlay/rendering.rs @@ -6,6 +6,27 @@ mod scroll_preview_window; pub(super) use self::{hud_surface::HudPillGeometry, scroll_preview_window::ScrollPreviewWindow}; use egui::Modifiers; +use egui_wgpu::RendererOptions; +use wgpu::BindGroupDescriptor; +use wgpu::BindGroupEntry; +use wgpu::BindGroupLayoutDescriptor; +use wgpu::BindGroupLayoutEntry; +use wgpu::BufferDescriptor; +use wgpu::Color; +use wgpu::ColorTargetState; +use wgpu::CommandEncoderDescriptor; +use wgpu::DeviceDescriptor; +use wgpu::FragmentState; +use wgpu::Operations; +use wgpu::PipelineLayoutDescriptor; +use wgpu::PrimitiveState; +use wgpu::RenderPassColorAttachment; +use wgpu::RenderPassDescriptor; +use wgpu::RenderPipelineDescriptor; +use wgpu::RequestAdapterOptions; +use wgpu::SamplerDescriptor; +use wgpu::ShaderModuleDescriptor; +use wgpu::VertexState; use winit::window::Window; use self::hud_rendering::LiveLoupeTexture; @@ -181,14 +202,14 @@ pub(super) struct GpuContext { impl GpuContext { pub(super) fn new() -> Result { let instance = wgpu::Instance::default(); - let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + let adapter = pollster::block_on(instance.request_adapter(&RequestAdapterOptions { power_preference: PowerPreference::LowPower, compatible_surface: None, force_fallback_adapter: false, })) .map_err(|err| eyre::eyre!("Failed to request GPU adapter: {err}"))?; let adapter_limits = adapter.limits(); - let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + let (device, queue) = pollster::block_on(adapter.request_device(&DeviceDescriptor { label: Some("rsnap-overlay device"), required_features: Features::empty(), // Use the adapter's actual limits. Using `downlevel_defaults()` caps max texture @@ -246,47 +267,46 @@ impl WindowRenderer { gpu: &GpuContext, format: wgpu::TextureFormat, ) -> (RenderPipeline, BindGroupLayout) { - let shader = gpu.device.create_shader_module(wgpu::ShaderModuleDescriptor { + let shader = gpu.device.create_shader_module(ShaderModuleDescriptor { label: Some("rsnap-mipgen shader"), source: ShaderSource::Wgsl(Cow::Borrowed(include_str!("../mipgen.wgsl"))), }); - let bind_group_layout = - gpu.device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("rsnap-mipgen bgl"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: ShaderStages::FRAGMENT, - ty: BindingType::Texture { - multisampled: false, - view_dimension: TextureViewDimension::D2, - sample_type: TextureSampleType::Float { filterable: true }, - }, - count: None, + let bind_group_layout = gpu.device.create_bind_group_layout(&BindGroupLayoutDescriptor { + label: Some("rsnap-mipgen bgl"), + entries: &[ + BindGroupLayoutEntry { + binding: 0, + visibility: ShaderStages::FRAGMENT, + ty: BindingType::Texture { + multisampled: false, + view_dimension: TextureViewDimension::D2, + sample_type: TextureSampleType::Float { filterable: true }, }, - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: ShaderStages::FRAGMENT, - ty: BindingType::Sampler(SamplerBindingType::Filtering), - count: None, - }, - ], - }); - let pipeline_layout = gpu.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + count: None, + }, + BindGroupLayoutEntry { + binding: 1, + visibility: ShaderStages::FRAGMENT, + ty: BindingType::Sampler(SamplerBindingType::Filtering), + count: None, + }, + ], + }); + let pipeline_layout = gpu.device.create_pipeline_layout(&PipelineLayoutDescriptor { label: Some("rsnap-mipgen pipeline layout"), bind_group_layouts: &[Some(&bind_group_layout)], immediate_size: 0, }); - let pipeline = gpu.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + let pipeline = gpu.device.create_render_pipeline(&RenderPipelineDescriptor { label: Some("rsnap-mipgen pipeline"), layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { + vertex: VertexState { module: &shader, entry_point: Some("vs_main"), compilation_options: PipelineCompilationOptions::default(), buffers: &[], }, - primitive: wgpu::PrimitiveState { + primitive: PrimitiveState { topology: PrimitiveTopology::TriangleList, strip_index_format: None, front_face: FrontFace::Ccw, @@ -297,11 +317,11 @@ impl WindowRenderer { }, depth_stencil: None, multisample: MultisampleState::default(), - fragment: Some(wgpu::FragmentState { + fragment: Some(FragmentState { module: &shader, entry_point: Some("fs_main"), compilation_options: PipelineCompilationOptions::default(), - targets: &[Some(wgpu::ColorTargetState { + targets: &[Some(ColorTargetState { format, blend: None, write_mask: ColorWrites::ALL, @@ -319,26 +339,26 @@ impl WindowRenderer { format: wgpu::TextureFormat, bind_group_layout: &BindGroupLayout, ) -> RenderPipeline { - let shader = gpu.device.create_shader_module(wgpu::ShaderModuleDescriptor { + let shader = gpu.device.create_shader_module(ShaderModuleDescriptor { label: Some("rsnap-mipgen fullscreen shader"), source: ShaderSource::Wgsl(Cow::Borrowed(include_str!("../mipgen.wgsl"))), }); - let pipeline_layout = gpu.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + let pipeline_layout = gpu.device.create_pipeline_layout(&PipelineLayoutDescriptor { label: Some("rsnap-mipgen fullscreen pipeline layout"), bind_group_layouts: &[Some(bind_group_layout)], immediate_size: 0, }); - gpu.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + gpu.device.create_render_pipeline(&RenderPipelineDescriptor { label: Some("rsnap-mipgen fullscreen pipeline"), layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { + vertex: VertexState { module: &shader, entry_point: Some("vs_main"), compilation_options: PipelineCompilationOptions::default(), buffers: &[], }, - primitive: wgpu::PrimitiveState { + primitive: PrimitiveState { topology: PrimitiveTopology::TriangleList, strip_index_format: None, front_face: FrontFace::Ccw, @@ -349,11 +369,11 @@ impl WindowRenderer { }, depth_stencil: None, multisample: MultisampleState::default(), - fragment: Some(wgpu::FragmentState { + fragment: Some(FragmentState { module: &shader, entry_point: Some("fs_main"), compilation_options: PipelineCompilationOptions::default(), - targets: &[Some(wgpu::ColorTargetState { + targets: &[Some(ColorTargetState { format, blend: None, write_mask: ColorWrites::ALL, @@ -369,7 +389,7 @@ impl WindowRenderer { return; } - let mut encoder = gpu.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + let mut encoder = gpu.device.create_command_encoder(&CommandEncoderDescriptor { label: Some("rsnap-mipgen encoder"), }); @@ -396,28 +416,28 @@ impl WindowRenderer { base_array_layer: 0, array_layer_count: Some(1), }); - let bind_group = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor { + let bind_group = gpu.device.create_bind_group(&BindGroupDescriptor { label: Some("rsnap-mipgen bind group"), layout: &self.mipgen_bind_group_layout, entries: &[ - wgpu::BindGroupEntry { + BindGroupEntry { binding: 0, resource: BindingResource::TextureView(&src_view), }, - wgpu::BindGroupEntry { + BindGroupEntry { binding: 1, resource: BindingResource::Sampler(&self.bg_sampler), }, ], }); - let rpass_desc = wgpu::RenderPassDescriptor { + let rpass_desc = RenderPassDescriptor { label: Some("rsnap-mipgen pass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { + color_attachments: &[Some(RenderPassColorAttachment { view: &dst_view, depth_slice: None, resolve_target: None, - ops: wgpu::Operations { - load: LoadOp::Clear(wgpu::Color { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), + ops: Operations { + load: LoadOp::Clear(Color { r: 0.0, g: 0.0, b: 0.0, a: 1.0 }), store: StoreOp::Store, }, })], @@ -489,7 +509,7 @@ impl WindowRenderer { } fn create_bg_sampler(gpu: &GpuContext) -> Sampler { - gpu.device.create_sampler(&wgpu::SamplerDescriptor { + gpu.device.create_sampler(&SamplerDescriptor { label: Some("rsnap-frozen-bg sampler"), address_mode_u: AddressMode::ClampToEdge, address_mode_v: AddressMode::ClampToEdge, @@ -505,59 +525,58 @@ impl WindowRenderer { gpu: &GpuContext, surface_format: wgpu::TextureFormat, ) -> (RenderPipeline, BindGroupLayout) { - let shader = gpu.device.create_shader_module(wgpu::ShaderModuleDescriptor { + let shader = gpu.device.create_shader_module(ShaderModuleDescriptor { label: Some("rsnap-hud-blur shader"), source: ShaderSource::Wgsl(Cow::Borrowed(include_str!("../hud_blur.wgsl"))), }); - let bind_group_layout = - gpu.device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { - label: Some("rsnap-hud-blur bgl"), - entries: &[ - wgpu::BindGroupLayoutEntry { - binding: 0, - visibility: ShaderStages::FRAGMENT, - ty: BindingType::Texture { - multisampled: false, - view_dimension: TextureViewDimension::D2, - sample_type: TextureSampleType::Float { filterable: true }, - }, - count: None, - }, - wgpu::BindGroupLayoutEntry { - binding: 1, - visibility: ShaderStages::FRAGMENT, - ty: BindingType::Sampler(SamplerBindingType::Filtering), - count: None, + let bind_group_layout = gpu.device.create_bind_group_layout(&BindGroupLayoutDescriptor { + label: Some("rsnap-hud-blur bgl"), + entries: &[ + BindGroupLayoutEntry { + binding: 0, + visibility: ShaderStages::FRAGMENT, + ty: BindingType::Texture { + multisampled: false, + view_dimension: TextureViewDimension::D2, + sample_type: TextureSampleType::Float { filterable: true }, }, - wgpu::BindGroupLayoutEntry { - binding: 2, - visibility: ShaderStages::FRAGMENT, - ty: BindingType::Buffer { - ty: BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: BufferSize::new( - mem::size_of::() as u64 - ), - }, - count: None, + count: None, + }, + BindGroupLayoutEntry { + binding: 1, + visibility: ShaderStages::FRAGMENT, + ty: BindingType::Sampler(SamplerBindingType::Filtering), + count: None, + }, + BindGroupLayoutEntry { + binding: 2, + visibility: ShaderStages::FRAGMENT, + ty: BindingType::Buffer { + ty: BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: BufferSize::new( + mem::size_of::() as u64 + ), }, - ], - }); - let pipeline_layout = gpu.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + count: None, + }, + ], + }); + let pipeline_layout = gpu.device.create_pipeline_layout(&PipelineLayoutDescriptor { label: Some("rsnap-hud-blur pipeline layout"), bind_group_layouts: &[Some(&bind_group_layout)], immediate_size: 0, }); - let pipeline = gpu.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + let pipeline = gpu.device.create_render_pipeline(&RenderPipelineDescriptor { label: Some("rsnap-hud-blur pipeline"), layout: Some(&pipeline_layout), - vertex: wgpu::VertexState { + vertex: VertexState { module: &shader, entry_point: Some("vs_main"), compilation_options: PipelineCompilationOptions::default(), buffers: &[], }, - primitive: wgpu::PrimitiveState { + primitive: PrimitiveState { topology: PrimitiveTopology::TriangleList, strip_index_format: None, front_face: FrontFace::Ccw, @@ -568,11 +587,11 @@ impl WindowRenderer { }, depth_stencil: None, multisample: MultisampleState::default(), - fragment: Some(wgpu::FragmentState { + fragment: Some(FragmentState { module: &shader, entry_point: Some("fs_main"), compilation_options: PipelineCompilationOptions::default(), - targets: &[Some(wgpu::ColorTargetState { + targets: &[Some(ColorTargetState { format: surface_format, blend: Some(BlendState::PREMULTIPLIED_ALPHA_BLENDING), write_mask: ColorWrites::ALL, @@ -1032,7 +1051,7 @@ impl WindowRenderer { ) -> Result<()> { let started_at = Instant::now(); let view = frame.texture.create_view(&TextureViewDescriptor::default()); - let mut encoder = gpu.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + let mut encoder = gpu.device.create_command_encoder(&CommandEncoderDescriptor { label: Some("rsnap-overlay encoder"), }); let _user_cmds = self.egui_renderer.update_buffers( @@ -1044,14 +1063,14 @@ impl WindowRenderer { ); { - let rpass_desc = wgpu::RenderPassDescriptor { + let rpass_desc = RenderPassDescriptor { label: Some("rsnap-overlay renderpass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { + color_attachments: &[Some(RenderPassColorAttachment { view: &view, depth_slice: None, resolve_target: None, - ops: wgpu::Operations { - load: LoadOp::Clear(wgpu::Color { r: 0.0, g: 0.0, b: 0.0, a: 0.0 }), + ops: Operations { + load: LoadOp::Clear(Color { r: 0.0, g: 0.0, b: 0.0, a: 0.0 }), store: StoreOp::Store, }, })], @@ -1164,7 +1183,7 @@ impl WindowRenderer { let egui_renderer = Renderer::new( &gpu.device, surface_format, - egui_wgpu::RendererOptions { + RendererOptions { msaa_samples: 1, depth_stencil_format: None, dithering: false, @@ -1178,7 +1197,7 @@ impl WindowRenderer { Self::create_mipgen_surface_pipeline(gpu, surface_format, &mipgen_bind_group_layout); let (hud_blur_pipeline, hud_blur_bind_group_layout) = Self::create_hud_blur_pipeline(gpu, surface_format); - let hud_blur_uniform = gpu.device.create_buffer(&wgpu::BufferDescriptor { + let hud_blur_uniform = gpu.device.create_buffer(&BufferDescriptor { label: Some("rsnap-hud-blur uniform"), size: mem::size_of::() as u64, usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST, diff --git a/packages/rsnap-overlay/src/overlay/rendering/affordances.rs b/packages/rsnap-overlay/src/overlay/rendering/affordances.rs index 6fe41c8b..f6ee0aac 100644 --- a/packages/rsnap-overlay/src/overlay/rendering/affordances.rs +++ b/packages/rsnap-overlay/src/overlay/rendering/affordances.rs @@ -1,3 +1,5 @@ +use std::f32::consts::FRAC_PI_2; +use std::f32::consts::PI; use std::sync::{Arc, OnceLock}; use std::time::Instant; @@ -16,7 +18,8 @@ use crate::overlay::rendering::{ }; use crate::overlay::session_state::FrozenAnnotationStyleCapsulePlacement; use crate::overlay::{ - self, Align, Align2, Area, Color32, CornerRadius, FROZEN_SELECTION_DASHED_BORDER_WIDTH_PX, + self, Align, Align2, Area, Color32, CornerRadius, FROZEN_BRUSH_RENDER_SAMPLE_STEP_POINTS, + FROZEN_SELECTION_DASHED_BORDER_WIDTH_PX, FROZEN_SELECTION_RESIZE_HANDLE_CENTER_DOT_RADIUS_POINTS, FROZEN_SELECTION_RESIZE_HANDLE_CORNER_KEEPOUT_POINTS, FROZEN_SELECTION_RESIZE_HANDLE_HIT_OFFSET_POINTS, @@ -24,27 +27,29 @@ use crate::overlay::{ FROZEN_SELECTION_RESIZE_HANDLE_INTERIOR_REACH_MAX_POINTS, FROZEN_SELECTION_RESIZE_HANDLE_OUTER_RADIUS_POINTS, FROZEN_SELECTION_RESIZE_HANDLE_STROKE_WIDTH_POINTS, FROZEN_SELECTION_SCRIM_ALPHA_DARK, - FROZEN_SELECTION_SCRIM_ALPHA_LIGHT, FROZEN_TEXT_PREVIEW_PLACEHOLDER, - FROZEN_TOOLBAR_BUTTON_SIZE_POINTS, FROZEN_TOOLBAR_ITEM_SPACING_POINTS, FontFamily, FontId, - FrozenAnnotationColor, FrozenArrowAnnotation, FrozenBrushState, FrozenCaptureSource, - FrozenCommittedOverlay, FrozenEditKind, FrozenSelectionCorner, FrozenSpotlightAnnotation, - FrozenTextAnnotation, FrozenTextEditState, FrozenTextStyle, FrozenToolbarPointerState, - FrozenToolbarState, FrozenToolbarTool, HUD_PILL_INNER_MARGIN_X_POINTS, - HUD_PILL_STROKE_WIDTH_POINTS, HudPillGeometry, HudTheme, Id, - LIVE_DRAG_SELECTION_SCRIM_ALPHA_DARK, LIVE_DRAG_SELECTION_SCRIM_ALPHA_LIGHT, - LIVE_DRAG_START_THRESHOLD_PX, LayerId, Layout, Mesh, MonitorRect, Order, OverlayMode, - OverlaySession, OverlayState, Painter, Pos2, Rect, RectPoints, SELECTION_DASHED_BORDER_ALPHA, - SELECTION_DASHED_BORDER_DASH_LENGTH_PX, SELECTION_DASHED_BORDER_GAP_LENGTH_PX, - SELECTION_DASHED_BORDER_WIDTH_PX, SELECTION_FLOW_CORE_FLOW_WIDTH, - SELECTION_FLOW_CORNER_RADIUS_PX, SELECTION_FLOW_FLOW_BOOST, SELECTION_FLOW_LIGHT_PALETTE, - SELECTION_FLOW_MAX_SEGMENTS, SELECTION_FLOW_MIN_SEGMENTS, SELECTION_FLOW_PALETTE, - SELECTION_FLOW_SAMPLE_STEP_PX, SELECTION_FLOW_SPEED, SELECTION_SIZE_BADGE_FAR_SHADOW_OFFSET_PX, - SELECTION_SIZE_BADGE_FONT_SIZE_POINTS, SELECTION_SIZE_BADGE_GAP_PX, - SELECTION_SIZE_BADGE_INSIDE_MARGIN_PX, SELECTION_SIZE_BADGE_NEAR_SHADOW_OFFSET_PX, - SELECTION_SIZE_BADGE_OUTLINE_OFFSET_PX, SELECTION_SIZE_BADGE_SCREEN_MARGIN_PX, - SELECTION_SIZE_BADGE_TEXT_OUTSET_POINTS, SelectionFlowStyle, Sense, Shape, Stroke, StrokeKind, - TOOLBAR_CAPTURE_GAP_PX, TOOLBAR_EXPANDED_HEIGHT_PX, TOOLBAR_PILL_INNER_MARGIN_Y_POINTS, - TOOLBAR_SCREEN_MARGIN_PX, ToolbarPlacement, Ui, UiBuilder, Vec2, regular, + FROZEN_SELECTION_SCRIM_ALPHA_LIGHT, FROZEN_TEXT_CARET_BLINK_PERIOD_SECS, + FROZEN_TEXT_PREVIEW_PLACEHOLDER, FROZEN_TOOLBAR_BUTTON_SIZE_POINTS, + FROZEN_TOOLBAR_ITEM_SPACING_POINTS, FontFamily, FontId, FrozenAnnotationColor, + FrozenArrowAnnotation, FrozenBrushState, FrozenCaptureSource, FrozenCommittedOverlay, + FrozenEditKind, FrozenSelectionCorner, FrozenSpotlightAnnotation, FrozenTextAnnotation, + FrozenTextEditState, FrozenTextStyle, FrozenToolbarPointerState, FrozenToolbarState, + FrozenToolbarTool, HUD_PILL_INNER_MARGIN_X_POINTS, HUD_PILL_STROKE_WIDTH_POINTS, + HudPillGeometry, HudTheme, Id, LIVE_DRAG_SELECTION_SCRIM_ALPHA_DARK, + LIVE_DRAG_SELECTION_SCRIM_ALPHA_LIGHT, LIVE_DRAG_START_THRESHOLD_PX, LayerId, Layout, Mesh, + MonitorRect, Order, OverlayMode, OverlaySession, OverlayState, Painter, Pos2, Rect, RectPoints, + SELECTION_DASHED_BORDER_ALPHA, SELECTION_DASHED_BORDER_DASH_LENGTH_PX, + SELECTION_DASHED_BORDER_GAP_LENGTH_PX, SELECTION_DASHED_BORDER_WIDTH_PX, + SELECTION_FLOW_CORE_FLOW_WIDTH, SELECTION_FLOW_CORNER_RADIUS_PX, SELECTION_FLOW_FLOW_BOOST, + SELECTION_FLOW_LIGHT_PALETTE, SELECTION_FLOW_MAX_SEGMENTS, SELECTION_FLOW_MIN_SEGMENTS, + SELECTION_FLOW_PALETTE, SELECTION_FLOW_SAMPLE_STEP_PX, SELECTION_FLOW_SPEED, + SELECTION_SIZE_BADGE_FAR_SHADOW_OFFSET_PX, SELECTION_SIZE_BADGE_FONT_SIZE_POINTS, + SELECTION_SIZE_BADGE_GAP_PX, SELECTION_SIZE_BADGE_INSIDE_MARGIN_PX, + SELECTION_SIZE_BADGE_NEAR_SHADOW_OFFSET_PX, SELECTION_SIZE_BADGE_OUTLINE_OFFSET_PX, + SELECTION_SIZE_BADGE_SCREEN_MARGIN_PX, SELECTION_SIZE_BADGE_TEXT_OUTSET_POINTS, + SelectionFlowStyle, Sense, Shape, Stroke, StrokeKind, TOOLBAR_CAPTURE_GAP_PX, + TOOLBAR_EXPANDED_HEIGHT_PX, TOOLBAR_PILL_INNER_MARGIN_Y_POINTS, TOOLBAR_SCREEN_MARGIN_PX, + ToolbarPlacement, Ui, UiBuilder, Vec2, + regular::{MINUS, PLUS}, }; const FROZEN_ANNOTATION_TOOLBAR_SECTION_GAP_POINTS: f32 = 4.0; @@ -719,7 +724,7 @@ impl WindowRenderer { ) -> bool { let rendered_points = OverlaySession::rendered_frozen_brush_points( points, - overlay::FROZEN_BRUSH_RENDER_SAMPLE_STEP_POINTS, + FROZEN_BRUSH_RENDER_SAMPLE_STEP_POINTS, ); match rendered_points.as_slice() { @@ -1007,8 +1012,8 @@ impl WindowRenderer { } pub(in crate::overlay) fn frozen_text_caret_visible(time_secs: f64) -> bool { - (time_secs.rem_euclid(crate::overlay::FROZEN_TEXT_CARET_BLINK_PERIOD_SECS)) - < crate::overlay::FROZEN_TEXT_CARET_BLINK_PERIOD_SECS * 0.5 + (time_secs.rem_euclid(FROZEN_TEXT_CARET_BLINK_PERIOD_SECS)) + < FROZEN_TEXT_CARET_BLINK_PERIOD_SECS * 0.5 } pub(in crate::overlay) fn frozen_capture_focus_rect( @@ -2462,7 +2467,7 @@ impl WindowRenderer { let remain = distance.rem_euclid(perimeter); let edge_top_len = (rect.width() - corner_radius * 2.0).max(0.0); let edge_right_len = (rect.height() - corner_radius * 2.0).max(0.0); - let corner_len = std::f32::consts::FRAC_PI_2 * corner_radius; + let corner_len = FRAC_PI_2 * corner_radius; if remain < edge_top_len { return Pos2::new(x0 + corner_radius + remain, y0); @@ -2471,7 +2476,7 @@ impl WindowRenderer { let mut offset = remain - edge_top_len; if offset < corner_len { - let angle = -std::f32::consts::FRAC_PI_2 + offset / corner_radius; + let angle = -FRAC_PI_2 + offset / corner_radius; return Pos2::new( x1 - corner_radius + corner_radius * angle.cos(), @@ -2505,7 +2510,7 @@ impl WindowRenderer { offset -= edge_top_len; if offset < corner_len { - let angle = std::f32::consts::FRAC_PI_2 + offset / corner_radius; + let angle = FRAC_PI_2 + offset / corner_radius; return Pos2::new( x0 + corner_radius + corner_radius * angle.cos(), @@ -2522,7 +2527,7 @@ impl WindowRenderer { offset -= edge_right_len; if offset < corner_len { - let angle = std::f32::consts::PI + offset / corner_radius; + let angle = PI + offset / corner_radius; return Pos2::new( x0 + corner_radius + corner_radius * angle.cos(), @@ -2536,7 +2541,7 @@ impl WindowRenderer { pub(in crate::overlay) fn selection_flow_perimeter(rect: Rect, corner_radius: f32) -> f32 { let edge_top_len = (rect.width() - corner_radius * 2.0).max(0.0); let edge_right_len = (rect.height() - corner_radius * 2.0).max(0.0); - let corner_len = std::f32::consts::FRAC_PI_2 * corner_radius; + let corner_len = FRAC_PI_2 * corner_radius; 2.0 * (edge_top_len + edge_right_len) + 4.0 * corner_len } @@ -3386,8 +3391,8 @@ impl WindowRenderer { &plus_response, appearance, ); - Self::paint_frozen_annotation_size_step_button(ui, theme, &minus_response, regular::MINUS); - Self::paint_frozen_annotation_size_step_button(ui, theme, &plus_response, regular::PLUS); + Self::paint_frozen_annotation_size_step_button(ui, theme, &minus_response, MINUS); + Self::paint_frozen_annotation_size_step_button(ui, theme, &plus_response, PLUS); Self::apply_frozen_annotation_size_control_clicks( toolbar_state, style_kind, diff --git a/packages/rsnap-overlay/src/overlay/rendering/hud_surface.rs b/packages/rsnap-overlay/src/overlay/rendering/hud_surface.rs index 64c42b5b..bc15c77b 100644 --- a/packages/rsnap-overlay/src/overlay/rendering/hud_surface.rs +++ b/packages/rsnap-overlay/src/overlay/rendering/hud_surface.rs @@ -1,4 +1,12 @@ use egui::RawInput; +use egui::epaint::Shadow; +use wgpu::BindGroupDescriptor; +use wgpu::BindGroupEntry; +use wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; +use wgpu::Extent3d; +use wgpu::TexelCopyBufferLayout; +use wgpu::TexelCopyTextureInfo; +use wgpu::TextureDescriptor; use wgpu::{BindGroup, TextureFormat}; use crate::overlay::rendering::{GpuContext, WindowRenderer, WindowRendererPhaseTimings}; @@ -281,7 +289,7 @@ impl WindowRenderer { HudTheme::Light => Color32::from_rgba_unmultiplied(0, 0, 0, 44), }; let outer_stroke = Stroke::new(1.0, outer_stroke_color); - let shadow = egui::epaint::Shadow { + let shadow = Shadow { offset: [0, 0], blur: 10, spread: 0, @@ -485,9 +493,9 @@ impl WindowRenderer { debug_assert!(width <= max_side && height <= max_side); - let texture = gpu.device.create_texture(&wgpu::TextureDescriptor { + let texture = gpu.device.create_texture(&TextureDescriptor { label: Some("rsnap-frozen-bg texture"), - size: wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + size: Extent3d { width, height, depth_or_array_layers: 1 }, mip_level_count, sample_count: 1, dimension: TextureDimension::D2, @@ -500,7 +508,7 @@ impl WindowRenderer { let upload_bytes = upload_image.as_raw(); let bytes_per_pixel = 4_usize; let unpadded_bytes_per_row = (width as usize) * bytes_per_pixel; - let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; + let align = COPY_BYTES_PER_ROW_ALIGNMENT as usize; let padded_bytes_per_row = unpadded_bytes_per_row.div_ceil(align) * align; let rgba_padded; let rgba_bytes: &[u8] = if padded_bytes_per_row == unpadded_bytes_per_row { @@ -519,19 +527,19 @@ impl WindowRenderer { }; gpu.queue.write_texture( - wgpu::TexelCopyTextureInfo { + TexelCopyTextureInfo { texture: &texture, mip_level: 0, origin: Origin3d::ZERO, aspect: TextureAspect::All, }, rgba_bytes, - wgpu::TexelCopyBufferLayout { + TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(padded_bytes_per_row as u32), rows_per_image: Some(height), }, - wgpu::Extent3d { width, height, depth_or_array_layers: 1 }, + Extent3d { width, height, depth_or_array_layers: 1 }, ); if generate_mipmaps { @@ -539,30 +547,21 @@ impl WindowRenderer { } let view = texture.create_view(&TextureViewDescriptor::default()); - let hud_blur_bind_group = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor { + let hud_blur_bind_group = gpu.device.create_bind_group(&BindGroupDescriptor { label: Some("rsnap-hud-blur bind group"), layout: &self.hud_blur_bind_group_layout, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: BindingResource::TextureView(&view) }, - wgpu::BindGroupEntry { - binding: 1, - resource: BindingResource::Sampler(&self.bg_sampler), - }, - wgpu::BindGroupEntry { - binding: 2, - resource: self.hud_blur_uniform.as_entire_binding(), - }, + BindGroupEntry { binding: 0, resource: BindingResource::TextureView(&view) }, + BindGroupEntry { binding: 1, resource: BindingResource::Sampler(&self.bg_sampler) }, + BindGroupEntry { binding: 2, resource: self.hud_blur_uniform.as_entire_binding() }, ], }); - let mipgen_bind_group = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor { + let mipgen_bind_group = gpu.device.create_bind_group(&BindGroupDescriptor { label: Some("rsnap-mipgen fullscreen bind group"), layout: &self.mipgen_bind_group_layout, entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: BindingResource::TextureView(&view) }, - wgpu::BindGroupEntry { - binding: 1, - resource: BindingResource::Sampler(&self.bg_sampler), - }, + BindGroupEntry { binding: 0, resource: BindingResource::TextureView(&view) }, + BindGroupEntry { binding: 1, resource: BindingResource::Sampler(&self.bg_sampler) }, ], }); let max_lod = (mip_level_count.saturating_sub(1)) as f32; diff --git a/packages/rsnap-overlay/src/overlay/rendering/scroll_preview_window.rs b/packages/rsnap-overlay/src/overlay/rendering/scroll_preview_window.rs index b780ba68..2320b42c 100644 --- a/packages/rsnap-overlay/src/overlay/rendering/scroll_preview_window.rs +++ b/packages/rsnap-overlay/src/overlay/rendering/scroll_preview_window.rs @@ -1,5 +1,12 @@ +use egui_wgpu::RendererOptions; +use wgpu::Color; +use wgpu::CommandEncoderDescriptor; +use wgpu::Operations; +use wgpu::RenderPassColorAttachment; +use wgpu::RenderPassDescriptor; use wgpu::SurfaceConfiguration; +use crate::overlay::rendering::overlay::CAPTURE_WINDOW_CONTENT_PROTECTION_ENABLED; use crate::overlay::rendering::{GpuContext, ScrollPreviewView, WindowRenderer}; use crate::overlay::{ AcquiredSurfaceFrame, ActiveEventLoop, Align, Arc, CentralPanel, Color32, ColorImage, @@ -31,7 +38,7 @@ impl ScrollPreviewWindow { .with_visible(false) .with_resizable(false) .with_decorations(false) - .with_content_protected(super::overlay::CAPTURE_WINDOW_CONTENT_PROTECTION_ENABLED) + .with_content_protected(CAPTURE_WINDOW_CONTENT_PROTECTION_ENABLED) .with_transparent(true) .with_inner_size(LogicalSize::new( SCROLL_PREVIEW_WINDOW_WIDTH_POINTS, @@ -69,7 +76,7 @@ impl ScrollPreviewWindow { let renderer = Renderer::new( &gpu.device, surface_config.format, - egui_wgpu::RendererOptions { + RendererOptions { msaa_samples: 1, depth_stencil_format: None, dithering: false, @@ -221,7 +228,7 @@ impl ScrollPreviewWindow { }, }; let view = frame.texture.create_view(&TextureViewDescriptor::default()); - let mut encoder = gpu.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + let mut encoder = gpu.device.create_command_encoder(&CommandEncoderDescriptor { label: Some("rsnap-scroll-preview encoder"), }); let _ = self.renderer.update_buffers( @@ -233,14 +240,14 @@ impl ScrollPreviewWindow { ); { - let rpass_desc = wgpu::RenderPassDescriptor { + let rpass_desc = RenderPassDescriptor { label: Some("rsnap-scroll-preview rpass"), - color_attachments: &[Some(wgpu::RenderPassColorAttachment { + color_attachments: &[Some(RenderPassColorAttachment { view: &view, depth_slice: None, resolve_target: None, - ops: wgpu::Operations { - load: LoadOp::Clear(wgpu::Color { r: 0.0, g: 0.0, b: 0.0, a: 0.0 }), + ops: Operations { + load: LoadOp::Clear(Color { r: 0.0, g: 0.0, b: 0.0, a: 0.0 }), store: StoreOp::Store, }, })], diff --git a/packages/rsnap-overlay/src/overlay/scroll_runtime.rs b/packages/rsnap-overlay/src/overlay/scroll_runtime.rs index d5d96be0..f69c8ccc 100644 --- a/packages/rsnap-overlay/src/overlay/scroll_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/scroll_runtime.rs @@ -11,10 +11,14 @@ use crate::live_frame_stream_macos::MacLiveFrameStream; use crate::overlay::SCROLL_CAPTURE_DUPLICATE_WORKER_FRAME_RETRY_INTERVAL; use crate::overlay::SCROLL_CAPTURE_SAMPLE_INTERVAL; #[cfg(target_os = "macos")] +use crate::overlay::SCROLL_CAPTURE_STREAM_BACKLOG_MAX_FRAMES; +#[cfg(target_os = "macos")] use crate::overlay::ScrollCaptureHostFrameRequestError; #[cfg(target_os = "macos")] use crate::overlay::ScrollCaptureTraceInputRecord; #[cfg(target_os = "macos")] +use crate::overlay::session_state::InflightScrollCaptureObservation; +#[cfg(target_os = "macos")] use crate::overlay::session_state::ScrollCaptureLiveFrame; #[cfg(target_os = "macos")] use crate::overlay::{ @@ -107,7 +111,7 @@ impl OverlaySession { #[cfg(target_os = "macos")] { self.scroll_capture.inflight_request_observation = - Some(crate::overlay::session_state::InflightScrollCaptureObservation { + Some(InflightScrollCaptureObservation { was_observable: self .scroll_capture_observation_block_reason_at(now) .is_none(), @@ -181,7 +185,7 @@ impl OverlaySession { #[cfg(target_os = "macos")] { self.scroll_capture.inflight_request_observation = - Some(crate::overlay::session_state::InflightScrollCaptureObservation { + Some(InflightScrollCaptureObservation { was_observable: self .scroll_capture_observation_block_reason_at(now) .is_none(), @@ -633,7 +637,7 @@ impl OverlaySession { fn push_scroll_capture_live_frame(&mut self, frame: ScrollCaptureLiveFrame) { let backlog = &mut self.scroll_capture.live_stream_backlog; - if backlog.len() >= super::SCROLL_CAPTURE_STREAM_BACKLOG_MAX_FRAMES { + if backlog.len() >= SCROLL_CAPTURE_STREAM_BACKLOG_MAX_FRAMES { backlog.pop_front(); } diff --git a/packages/rsnap-overlay/src/overlay/tests.rs b/packages/rsnap-overlay/src/overlay/tests.rs index 81fd73bc..a0028001 100644 --- a/packages/rsnap-overlay/src/overlay/tests.rs +++ b/packages/rsnap-overlay/src/overlay/tests.rs @@ -34,6 +34,8 @@ use winit::window::WindowId; use crate::backend; #[cfg(target_os = "macos")] use crate::live_frame_stream_macos::MacLiveFrameStream; +#[cfg(target_os = "macos")] +use crate::live_frame_stream_macos::STREAM_REGION_FRAME_MAX_AGE; use crate::overlay::FrozenCaptureSource; #[cfg(target_os = "macos")] use crate::overlay::LiveCaptureInteraction; @@ -501,9 +503,7 @@ fn fresh_live_stream_snapshot_captured_at() -> Instant { #[cfg(target_os = "macos")] fn stale_live_stream_snapshot_captured_at() -> Instant { - Instant::now() - - crate::live_frame_stream_macos::STREAM_REGION_FRAME_MAX_AGE - - Duration::from_millis(1) + Instant::now() - STREAM_REGION_FRAME_MAX_AGE - Duration::from_millis(1) } #[cfg(target_os = "macos")] diff --git a/packages/rsnap-overlay/src/overlay/tests/annotation_runtime.rs b/packages/rsnap-overlay/src/overlay/tests/annotation_runtime.rs index b96e95f7..758a6445 100644 --- a/packages/rsnap-overlay/src/overlay/tests/annotation_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/tests/annotation_runtime.rs @@ -10,7 +10,11 @@ use crate::overlay::tests::{ FrozenBrushStroke, FrozenBrushStyle, FrozenCommittedOverlay, FrozenEditKind, FrozenExportTransform, FrozenTextAnnotation, FrozenTextEditState, FrozenTextInputSource, FrozenToolbarTool, GlobalPoint, Ime, Instant, Key, MonitorRect, MouseScrollDelta, NamedKey, - OverlaySession, PhysicalPosition, Pos2, RectPoints, Rgba, Vec2, overlay, + OverlaySession, PhysicalPosition, Pos2, RectPoints, Rgba, Vec2, + overlay::{ + self, FROZEN_BRUSH_MODEL_SYNTHETIC_SAMPLE_INTERVAL_SECONDS, + FROZEN_BRUSH_RENDER_SAMPLE_STEP_POINTS, + }, }; #[test] @@ -73,7 +77,7 @@ fn rendered_frozen_brush_points_round_corners_into_a_curve() { let points = [Pos2::new(1.0, 1.0), Pos2::new(1.0, 5.0), Pos2::new(5.0, 5.0)]; let rendered = OverlaySession::rendered_frozen_brush_points( &points, - overlay::FROZEN_BRUSH_RENDER_SAMPLE_STEP_POINTS, + FROZEN_BRUSH_RENDER_SAMPLE_STEP_POINTS, ); assert_eq!(rendered.first().copied(), Some(points[0])); @@ -265,7 +269,7 @@ fn rendered_live_frozen_brush_wave_preview_avoids_hard_inflection_kinks() { for (index, point) in raw_points.iter().copied().enumerate().skip(1) { let sampled_at = started_at + Duration::from_secs_f32( - index as f32 * overlay::FROZEN_BRUSH_MODEL_SYNTHETIC_SAMPLE_INTERVAL_SECONDS, + index as f32 * FROZEN_BRUSH_MODEL_SYNTHETIC_SAMPLE_INTERVAL_SECONDS, ); OverlaySession::append_frozen_brush_raw_sample(&mut stroke, point, sampled_at); @@ -274,7 +278,7 @@ fn rendered_live_frozen_brush_wave_preview_avoids_hard_inflection_kinks() { let preview = OverlaySession::preview_frozen_brush_points(&stroke); let rendered = OverlaySession::rendered_frozen_brush_points( &preview, - overlay::FROZEN_BRUSH_RENDER_SAMPLE_STEP_POINTS, + FROZEN_BRUSH_RENDER_SAMPLE_STEP_POINTS, ); let max_turn_angle = rendered.windows(3).fold(0.0_f32, |max_turn, window| { max_turn.max(OverlaySession::frozen_brush_turn_angle(window[0], window[1], window[2])) @@ -312,7 +316,7 @@ fn rendered_live_frozen_brush_arc_preview_avoids_corner_snap() { for (index, point) in raw_points.iter().copied().enumerate().skip(1) { let sampled_at = started_at + Duration::from_secs_f32( - index as f32 * overlay::FROZEN_BRUSH_MODEL_SYNTHETIC_SAMPLE_INTERVAL_SECONDS, + index as f32 * FROZEN_BRUSH_MODEL_SYNTHETIC_SAMPLE_INTERVAL_SECONDS, ); OverlaySession::append_frozen_brush_raw_sample(&mut stroke, point, sampled_at); @@ -321,7 +325,7 @@ fn rendered_live_frozen_brush_arc_preview_avoids_corner_snap() { let preview = OverlaySession::preview_frozen_brush_points(&stroke); let rendered = OverlaySession::rendered_frozen_brush_points( &preview, - overlay::FROZEN_BRUSH_RENDER_SAMPLE_STEP_POINTS, + FROZEN_BRUSH_RENDER_SAMPLE_STEP_POINTS, ); let max_turn_angle = rendered.windows(3).fold(0.0_f32, |max_turn, window| { max_turn.max(OverlaySession::frozen_brush_turn_angle(window[0], window[1], window[2])) @@ -366,7 +370,7 @@ fn rendered_live_frozen_brush_suppresses_slow_straight_wobble() { for (index, point) in raw_points.iter().copied().enumerate().skip(1) { let sampled_at = started_at + Duration::from_secs_f32( - index as f32 * overlay::FROZEN_BRUSH_MODEL_SYNTHETIC_SAMPLE_INTERVAL_SECONDS, + index as f32 * FROZEN_BRUSH_MODEL_SYNTHETIC_SAMPLE_INTERVAL_SECONDS, ); OverlaySession::append_frozen_brush_raw_sample(&mut stroke, point, sampled_at); @@ -375,7 +379,7 @@ fn rendered_live_frozen_brush_suppresses_slow_straight_wobble() { let preview = OverlaySession::preview_frozen_brush_points(&stroke); let rendered = OverlaySession::rendered_frozen_brush_points( &preview, - overlay::FROZEN_BRUSH_RENDER_SAMPLE_STEP_POINTS, + FROZEN_BRUSH_RENDER_SAMPLE_STEP_POINTS, ); let (min_y, max_y) = rendered.iter().fold((f32::INFINITY, f32::NEG_INFINITY), |acc, point| { (acc.0.min(point.y), acc.1.max(point.y)) diff --git a/packages/rsnap-overlay/src/overlay/tests/self_capture_runtime.rs b/packages/rsnap-overlay/src/overlay/tests/self_capture_runtime.rs index 16305055..36ff675f 100644 --- a/packages/rsnap-overlay/src/overlay/tests/self_capture_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/tests/self_capture_runtime.rs @@ -4,6 +4,10 @@ use std::ptr; #[cfg(target_os = "macos")] use image::{Rgba, RgbaImage}; +#[cfg(target_os = "macos")] +use crate::overlay::DISPLAY_FIRST_FREEZE_LIVE_TIMEOUT; +#[cfg(target_os = "macos")] +use crate::overlay::LiveClickCaptureTarget; #[cfg(target_os = "macos")] use crate::overlay::OverlayMode; #[cfg(target_os = "macos")] @@ -245,7 +249,7 @@ fn pending_frozen_handoff_keeps_live_mode_and_hover_until_first_display_image_ar session.begin_frozen_capture_from_click( monitor, - crate::overlay::LiveClickCaptureTarget { + LiveClickCaptureTarget { capture_rect: Some(capture_rect), window_target: Some(WindowFreezeCaptureTarget { monitor, @@ -654,11 +658,8 @@ fn window_matte_capture_without_live_preview_escalates_to_hidden_fallback_after_ assert!(!session.capture_windows_hidden); assert!(session.state.frozen_display_image.is_none()); - session.frozen_transition_started_at = Some( - Instant::now() - - crate::overlay::DISPLAY_FIRST_FREEZE_LIVE_TIMEOUT - - Duration::from_millis(1), - ); + session.frozen_transition_started_at = + Some(Instant::now() - DISPLAY_FIRST_FREEZE_LIVE_TIMEOUT - Duration::from_millis(1)); let _ = session.about_to_wait(); @@ -760,11 +761,8 @@ fn background_capture_without_live_snapshot_escalates_to_hidden_fallback_after_t assert!(!session.capture_windows_hidden); assert!(session.state.frozen_display_image.is_none()); - session.frozen_transition_started_at = Some( - Instant::now() - - crate::overlay::DISPLAY_FIRST_FREEZE_LIVE_TIMEOUT - - Duration::from_millis(1), - ); + session.frozen_transition_started_at = + Some(Instant::now() - DISPLAY_FIRST_FREEZE_LIVE_TIMEOUT - Duration::from_millis(1)); let _ = session.about_to_wait(); @@ -789,11 +787,8 @@ fn hidden_fallback_capture_response_commits_frozen_images_while_mode_is_still_li Some(cursor), ); - session.frozen_transition_started_at = Some( - Instant::now() - - crate::overlay::DISPLAY_FIRST_FREEZE_LIVE_TIMEOUT - - Duration::from_millis(1), - ); + session.frozen_transition_started_at = + Some(Instant::now() - DISPLAY_FIRST_FREEZE_LIVE_TIMEOUT - Duration::from_millis(1)); let _ = session.about_to_wait(); diff --git a/packages/rsnap-overlay/src/overlay/window_runtime.rs b/packages/rsnap-overlay/src/overlay/window_runtime.rs index 442c3cba..4f2b0859 100644 --- a/packages/rsnap-overlay/src/overlay/window_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/window_runtime.rs @@ -10,9 +10,9 @@ use objc2_foundation::NSArray; use winit::window::{Window, WindowId}; use crate::backend; -use crate::overlay; #[cfg(target_os = "macos")] use crate::overlay::MacOSHudWindowConfigState; +use crate::overlay::{self, CAPTURE_WINDOW_CONTENT_PROTECTION_ENABLED}; use crate::overlay::{ ActiveEventLoop, GlobalPoint, GpuContext, HudOverlayWindow, LOUPE_TILE_CORNER_RADIUS_POINTS, LiveSampleApplyResult, LogicalPosition, LogicalSize, MonitorRect, OverlayMode, OverlaySession, @@ -680,7 +680,7 @@ impl OverlaySession { .with_title("rsnap-overlay") .with_decorations(false) .with_resizable(false) - .with_content_protected(overlay::CAPTURE_WINDOW_CONTENT_PROTECTION_ENABLED) + .with_content_protected(CAPTURE_WINDOW_CONTENT_PROTECTION_ENABLED) .with_transparent(true) .with_window_level(WindowLevel::AlwaysOnTop) .with_inner_size(LogicalSize::new( @@ -828,7 +828,7 @@ impl OverlaySession { .with_title("rsnap-hud") .with_decorations(false) .with_resizable(false) - .with_content_protected(overlay::CAPTURE_WINDOW_CONTENT_PROTECTION_ENABLED) + .with_content_protected(CAPTURE_WINDOW_CONTENT_PROTECTION_ENABLED) .with_transparent(true) .with_visible(false) .with_window_level(WindowLevel::AlwaysOnTop) @@ -862,7 +862,7 @@ impl OverlaySession { .with_title("rsnap-loupe") .with_decorations(false) .with_resizable(false) - .with_content_protected(overlay::CAPTURE_WINDOW_CONTENT_PROTECTION_ENABLED) + .with_content_protected(CAPTURE_WINDOW_CONTENT_PROTECTION_ENABLED) .with_transparent(true) .with_visible(false) .with_window_level(WindowLevel::AlwaysOnTop) @@ -899,7 +899,7 @@ impl OverlaySession { .with_title("rsnap-toolbar") .with_decorations(false) .with_resizable(false) - .with_content_protected(overlay::CAPTURE_WINDOW_CONTENT_PROTECTION_ENABLED) + .with_content_protected(CAPTURE_WINDOW_CONTENT_PROTECTION_ENABLED) .with_inner_size(LogicalSize::new( startup_size.x as f64, f64::from(startup_size.y.max(TOOLBAR_EXPANDED_HEIGHT_PX)), diff --git a/packages/rsnap-overlay/src/overlay/worker_runtime.rs b/packages/rsnap-overlay/src/overlay/worker_runtime.rs index a421cfc9..449a8b0c 100644 --- a/packages/rsnap-overlay/src/overlay/worker_runtime.rs +++ b/packages/rsnap-overlay/src/overlay/worker_runtime.rs @@ -1,3 +1,4 @@ +use crate::overlay::PENDING_CLICK_HIT_TEST_TIMEOUT; use crate::overlay::{ Arc, CURSOR_POLL_INTERVAL_MIN, CapturedMonitorRegionResult, Duration, FrozenCaptureWorkerState, GlobalPoint, Instant, LiveCaptureInteraction, LiveClickCaptureTarget, LiveCursorSample, @@ -7,6 +8,7 @@ use crate::overlay::{ }; #[cfg(target_os = "macos")] use crate::overlay::{CursorSampleRequest, mem}; +use crate::state::LoupeSample; pub(super) const FREEZE_CAPTURE_SEND_FULL_RETRY_LIMIT: u64 = 8; @@ -24,7 +26,7 @@ impl OverlaySession { return false; }; - if elapsed < crate::overlay::PENDING_CLICK_HIT_TEST_TIMEOUT { + if elapsed < PENDING_CLICK_HIT_TEST_TIMEOUT { return false; } @@ -462,8 +464,7 @@ impl OverlaySession { changed.hud_changed = true; } if self.state.alt_held { - let loupe = - sample.patch.map(|patch| crate::state::LoupeSample { center: point, patch }); + let loupe = sample.patch.map(|patch| LoupeSample { center: point, patch }); let loupe_changed = match (&self.state.loupe, &loupe) { (Some(current), Some(next)) => { current.center != next.center || current.patch != next.patch @@ -864,7 +865,7 @@ impl OverlaySession { self.pending_click_hit_test_request_id = Some(request_id); self.pending_click_hit_test_requested_at = Some(Instant::now()); - self.schedule_egui_repaint_after(crate::overlay::PENDING_CLICK_HIT_TEST_TIMEOUT); + self.schedule_egui_repaint_after(PENDING_CLICK_HIT_TEST_TIMEOUT); }, Err(WorkerRequestSendError::Full) => { self.hit_test_send_full_count = self.hit_test_send_full_count.saturating_add(1); diff --git a/packages/rsnap-overlay/src/png.rs b/packages/rsnap-overlay/src/png.rs index 788d7abe..504cce81 100644 --- a/packages/rsnap-overlay/src/png.rs +++ b/packages/rsnap-overlay/src/png.rs @@ -1,30 +1,8 @@ -use color_eyre::eyre::{Result, WrapErr}; +use color_eyre::eyre::Result; use image::RgbaImage; -use image::codecs::png::{CompressionType, FilterType, PngEncoder}; -use image::{ExtendedColorType, ImageEncoder}; pub(crate) fn rgba_image_to_png_bytes(image: &RgbaImage) -> Result> { - let mut bytes = Vec::new(); - // For huge images (e.g. 8K), PNG encoding can otherwise spend noticeable time reallocating - // and copying the growing output buffer. - let raw_len = image.as_raw().len(); - - if raw_len >= 16 * 1_024 * 1_024 { - let extra = (image.height() as usize).saturating_add(1_024); - let _ = bytes.try_reserve_exact(raw_len.saturating_add(extra)); - } - - let encoder = PngEncoder::new_with_quality( - &mut bytes, - CompressionType::Uncompressed, - FilterType::NoFilter, - ); - - encoder - .write_image(image.as_raw(), image.width(), image.height(), ExtendedColorType::Rgba8) - .wrap_err("failed to encode screenshot as PNG")?; - - Ok(bytes) + rsnap_capture_core::encode_png_lossless_fast(image) } #[cfg(test)] diff --git a/packages/rsnap-overlay/src/text_rendering.rs b/packages/rsnap-overlay/src/text_rendering.rs index 8a1480c9..ae67dd26 100644 --- a/packages/rsnap-overlay/src/text_rendering.rs +++ b/packages/rsnap-overlay/src/text_rendering.rs @@ -22,6 +22,12 @@ pub(crate) struct RasterTextAnnotation<'a> { pub(crate) text: &'a str, } +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct TextBounds { + pub(crate) width: f32, + pub(crate) height: f32, +} + #[derive(Debug)] struct ExportTextFont { font_data: Arc, @@ -58,6 +64,22 @@ struct TextFontRun<'a> { text: &'a str, } +pub(crate) fn measure_text_bounds(text: &str, font_size_px: f32) -> Option { + if text.is_empty() { + return None; + } + + let fonts = export_text_fonts(); + + if let Some(runs) = build_text_font_runs(fonts, text) + && let Some(bounds) = measure_with_font_stack(text, font_size_px, fonts, &runs) + { + return Some(bounds); + } + + Some(measure_bitmap_text_bounds(text, font_size_px)) +} + pub(crate) fn render_text_annotations( image: &mut RgbaImage, annotations: &[RasterTextAnnotation<'_>], @@ -149,6 +171,78 @@ fn build_text_font_runs<'a>( Some(runs) } +fn measure_with_font_stack( + text: &str, + font_size_px: f32, + fonts: &[ExportTextFont], + runs: &[TextFontRun<'_>], +) -> Option { + let parsed_fonts: Vec<_> = fonts.iter().filter_map(ExportTextFont::font).collect(); + + if parsed_fonts.is_empty() || parsed_fonts.len() != fonts.len() { + return None; + } + + let mut layout = Layout::new(CoordinateSystem::PositiveYDown); + + layout.reset(&LayoutSettings::default()); + + for run in runs { + layout.append( + &parsed_fonts, + &TextStyle::new(run.text, font_size_px.max(8.0), run.font_index), + ); + } + + let mut max_x = 0.0_f32; + let mut max_y = 0.0_f32; + + for glyph in layout.glyphs() { + max_x = max_x.max(glyph.x + glyph.width as f32); + max_y = max_y.max(glyph.y + glyph.height as f32); + } + + if max_x <= 0.0 && max_y <= 0.0 { + return None; + } + + let line_count = text.lines().count().max(1) as f32; + let line_height = font_size_px.max(8.0) * 1.2; + + Some(TextBounds { + width: max_x.ceil().max(1.0), + height: max_y.ceil().max(line_height * line_count).max(1.0), + }) +} + +fn measure_bitmap_text_bounds(text: &str, font_size_px: f32) -> TextBounds { + let scale = (font_size_px.max(8.0) / BITMAP_GLYPH_SIDE_PX as f32).round().max(1.0) as u32; + let glyph_advance = BITMAP_GLYPH_ADVANCE_PX.saturating_mul(scale) as f32; + let line_height = BITMAP_GLYPH_SIDE_PX + .saturating_mul(scale) + .saturating_add(BITMAP_LINE_GAP_PX.saturating_mul(scale)) as f32; + let mut line_width = 0.0_f32; + let mut max_width = 0.0_f32; + let mut line_count = 1_u32; + + for ch in text.chars() { + if ch == '\n' { + max_width = max_width.max(line_width); + line_width = 0.0; + line_count = line_count.saturating_add(1); + } else { + line_width += glyph_advance; + } + } + + max_width = max_width.max(line_width); + + TextBounds { + width: max_width.ceil().max(1.0), + height: (line_height * line_count as f32).ceil().max(1.0), + } +} + fn font_index_for_char( fonts: &[ExportTextFont], ch: char, diff --git a/scripts/perf/local.sh b/scripts/perf/local.sh index c7ed32da..65d75ec2 100755 --- a/scripts/perf/local.sh +++ b/scripts/perf/local.sh @@ -9,11 +9,12 @@ case "${1:-}" in Usage: local.sh Runs the local deterministic performance sweep. -No local deterministic benchmarks are enabled while scroll capture is disabled. +Checks the Rust export and scroll-capture hot paths against deterministic fixtures +and conservative local budgets. EOF exit 0 ;; esac cd "$ROOT_DIR" -echo "[perf] no local deterministic benchmarks are enabled while scroll capture is disabled." +cargo run -p rsnap-perf --release --quiet diff --git a/scripts/smoke/lib/live-hud-mouse-path.swift b/scripts/smoke/lib/live-hud-mouse-path.swift index 7360afca..ca55073a 100644 --- a/scripts/smoke/lib/live-hud-mouse-path.swift +++ b/scripts/smoke/lib/live-hud-mouse-path.swift @@ -37,7 +37,7 @@ func readString(_ key: String, default value: String) -> String { } func sleepMs(_ ms: useconds_t) { - usleep(ms * 1000) + usleep(ms * 1_000) } final class MousePathDriver { diff --git a/scripts/smoke/lib/mask-probe-capture.swift b/scripts/smoke/lib/mask-probe-capture.swift index 9ce737e5..2fd6068a 100644 --- a/scripts/smoke/lib/mask-probe-capture.swift +++ b/scripts/smoke/lib/mask-probe-capture.swift @@ -194,7 +194,7 @@ final class MaskProbeCapture: NSObject, SCStreamOutput { } private func writeReadyIfNeeded() { - guard !wroteReady, let readyPath else { + guard wroteReady == false, let readyPath else { return } wroteReady = true @@ -267,7 +267,7 @@ final class MaskProbeCapture: NSObject, SCStreamOutput { let blue = Double(pointer[index]) / 255.0 let green = Double(pointer[index + 1]) / 255.0 let red = Double(pointer[index + 2]) / 255.0 - total += 0.2126 * red + 0.7152 * green + 0.0722 * blue + total += 0.212_6 * red + 0.715_2 * green + 0.072_2 * blue count += 1 } } diff --git a/scripts/smoke/lib/visual-background-window.swift b/scripts/smoke/lib/visual-background-window.swift index 32ae2bce..67b51cc3 100644 --- a/scripts/smoke/lib/visual-background-window.swift +++ b/scripts/smoke/lib/visual-background-window.swift @@ -5,7 +5,7 @@ final class VisualBackgroundDelegate: NSObject, NSApplicationDelegate { private var window: NSWindow? func applicationDidFinishLaunching(_: Notification) { - let frame = NSScreen.main?.frame ?? CGRect(x: 0, y: 0, width: 1280, height: 720) + let frame = NSScreen.main?.frame ?? CGRect(x: 0, y: 0, width: 1_280, height: 720) let window = NSWindow( contentRect: frame, styleMask: [.borderless],