diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6f59803a6..2d3195c73 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -149,6 +149,42 @@ screenshots remain whole-desktop captures. Repeat while switching outputs and, w changing scale or transform during capture: the result must either stay exact or fail visibly, never shift, stretch, or reuse another output's image. +For changes to the native region picker, use the same compositor/scale matrix and record the +following permission-gated evidence (these checks open or foreground the overlay): + +- `Ctrl+Shift+C` delivers the selected frozen pixels; the saved PNG dimensions match the picker + readout, including while zoomed or panned. +- With `[capture].include_drawings = true`, committed annotations are already visible over the + frozen desktop while selecting and match the delivered PNG; transient strokes, handles, and UI + stay hidden. In interactive Review, toggling drawings off/on updates that preview immediately. +- Cancelling while pending, armed, or selecting leaves no picker-owned freeze behind, and the next + capture action works. +- Output, scale, transform, zoom-level, and pan changes cancel with one visible message. +- `picker = "slurp"` restores the external selector. Native acquisition failure hands off to it, + while a dismissed portal request does not. +- Pointer, touch, and stylus drags all select when the hardware is available; losing the owning + device during a drag rearms the picker. +- Holding Shift before the first press produces a square crop, and the crosshair remains on the + physical pointer while the squared frame follows the constrained endpoint. +- `Ctrl+A` during a pointer, touch, or stylus drag captures the whole output and consumes the + outstanding release without starting a canvas gesture. +- Deactivating or aborting zoom while the picker waits cancels quietly; a reported zoom failure + produces exactly one message. +- With `picker = "slurp"` and `format = "jpg"`, the legacy path remains JPEG; native selection + remains PNG-only. +- On Hyprland and Sway, `Space` enters window mode only after the provider is ready. Verify pointer, + touch, and stylus selection, plus `Super+Arrow` navigation and `Enter`, on overlapping tiled and + floating windows. The highlighted readout and delivered PNG must have identical pixel bounds. + Also switch workspaces during picker startup: candidates intentionally follow the workspace + visible at provider-query time, while output/layout mismatches still disable window mode. +- On Plasma/KWin, Niri, and GNOME, the window-mode legend and `Space` control stay absent while area + selection continues to work. Provider failure or an empty workspace must also fall back quietly. + +At 1080p@1, 4K@2, and a fractional-scale 4K setup, capture the debug-level `Region picker frame` +timings during pointer motion. Also record the process peak-RSS delta while encoding a full-output +`Ctrl+A` crop. Keep these measurements with the change's manual test evidence; they are intentionally +not collected by CI. + These captures now fail when the active output does not advertise a current `wl_output` mode. Plugging or unplugging any monitor also cancels in-flight Freeze and Zoom captures on unrelated outputs, because output count is part of the layout identity used to reject stale frames. diff --git a/README.md b/README.md index 760375b45..2c27e9bfa 100644 --- a/README.md +++ b/README.md @@ -739,12 +739,61 @@ Light passthrough (layer-shell compositors only) lets normal keyboard and pointe | Ctrl+Shift+I | Capture selection (respects `capture.copy_to_clipboard`) | | Ctrl+C | Copy full screen to clipboard | | Ctrl+S | Save full screen as PNG | -| Ctrl+Shift+C | Select region → clipboard | +| Ctrl+Shift+C | Select a region, then choose Copy, Save, Both, or Board (`capture_region_interactive`) | | Ctrl+Shift+S | Select region → save PNG | | Ctrl+6 | Region → clipboard (explicit) | | Ctrl+Alt+6 | Region → save PNG (explicit) | +| Unbound | Select region → clipboard in one step (`capture_clipboard_selection`) | +| Unbound | Measure a logical screen region without capturing it (`measure_mode`) | | Ctrl+Alt+O | Open last capture folder | +Region shortcuts use Wayscriber's native frozen-image picker by default. Set +`capture.region.picker = "slurp"` for the external selector; native selection +also falls back to `slurp` when no screen capture backend is available. The +readout, hotkey legend, and optional magnified pixel loupe are configurable +under `[capture.region]`. Region captures are encoded and +named as PNG even when the general screenshot format is set to JPEG. An +explicit `picker = "slurp"` keeps the configured legacy screenshot format. +On Hyprland and Sway, press Space in the native picker to switch +between free-area selection and current-workspace window selection. Point and +click a window, or use Super+Arrow and Enter. The window +control is omitted when the compositor cannot provide reliable geometry. +It is also omitted when the picker reuses an older user Freeze or Zoom image; +window bounds are offered only with the fresh auto-freeze created for that +picker. The provider result is checked against that source's output and layout +identity before candidates are shown; a mismatch simply leaves window mode +unavailable. Wayland has no portable workspace identity at the freeze boundary, +so candidates reflect the workspace visible when the compositor query runs. +`capture_region_interactive` owns Ctrl+Shift+C by default and is +also in the command palette. That chord previously ran the one-step +`capture_clipboard_selection`, which now ships unbound; Review's **Copy** +(Ctrl+C) reaches the same clipboard result, and binding +`capture_clipboard_selection` explicitly brings the one-step copy back. It +always uses the native picker so Review cannot be bypassed by an external +selector. In Review, **Both** (or Enter) always copies the PNG and +saves it to a file. + +Full-screen and region captures include the active board's committed drawings +by default. Set `[capture].include_drawings = false` for raw desktop pixels. +Provisional strokes, selection handles, previews, toolbars, and other +Wayscriber UI are never included. In interactive Review, **Include drawings in +exports** (or D) starts from the configured default and can override +it for that one capture. Copy, Save, Both, and Board all honour it. Adding an +annotated crop to the board it came from bakes a second, flattened copy of +those annotations into the image, so turn the toggle off first when you want +the raw crop on the board. +The native region picker shows those committed drawings over its frozen desktop +while you select, so the visible preview matches the annotated export; toggling +drawings off in Review immediately returns the preview to raw desktop pixels. +On a transparent board, full-screen and legacy `slurp` captures retain the +desktop behind the annotations; a solid board retains its canvas background. +The native region picker always composites committed drawings over its frozen +desktop crop. + +`measure_mode` is also command-palette-first and unbound by default. It shows a +crosshair and logical-pixel size readout without freezing or capturing the +screen; press Esc or run the action again to leave it. + Shortcuts marked "respects `capture.copy_to_clipboard`" send the capture to the clipboard or a file according to that `config.toml` setting; the other shortcuts always use the destination shown. Captures need the [screenshot tools](#screenshot-tools) and fall back to xdg-desktop-portal if they are missing. Use `--exit-after-capture` / `--no-exit-after-capture` to override whether the overlay closes after a capture. diff --git a/config.example.toml b/config.example.toml index 16991c905..7df456b6e 100644 --- a/config.example.toml +++ b/config.example.toml @@ -271,10 +271,18 @@ capture_selection = ["Ctrl+Shift+I"] # Clipboard/File capture variants capture_clipboard_full = ["Ctrl+C"] capture_file_full = ["Ctrl+S"] -capture_clipboard_selection = ["Ctrl+Shift+C"] +# Unbound by default: Ctrl+Shift+C now opens the interactive picker below, +# whose Copy action reaches the same clipboard result. Bind a chord here to get +# the one-step region copy back. +capture_clipboard_selection = [] capture_file_selection = ["Ctrl+Shift+S"] capture_clipboard_region = ["Ctrl+6"] capture_file_region = ["Ctrl+Alt+6"] +# Open the region review/action bar. Also available from the command palette. +capture_region_interactive = ["Ctrl+Shift+C"] +# Show a logical-pixel screen ruler without capturing anything. Available from +# the command palette and unbound by default. +measure_mode = [] export_canvas_file = [] export_canvas_clipboard = [] export_canvas_clipboard_and_file = [] @@ -457,6 +465,10 @@ show_capabilities_warning = true # The guided tour remains available manually when this is false. show_onboarding_hints = true +# Show rectangle and ellipse preview dimensions in logical board pixels. +# This is separate from capture.region.show_size_readout. +show_shape_size_readout = true + # Command palette action toast duration (ms) command_palette_toast_duration_ms = 1500 @@ -1403,10 +1415,17 @@ filename_template = "screenshot_%Y-%m-%d_%H%M%S" # Image format for saved screenshots: png, jpg, or jpeg format = "png" -# Copy screenshots to clipboard by default -# Shortcut-specific actions may override this +# Copy screenshots to clipboard by default. Image writes smaller than 64 MiB +# are read back and retried once unless the PNG bytes match; larger writes +# trust wl-copy. +# Shortcut-specific actions may override this. copy_to_clipboard = true +# Composite the active board's committed annotations into full-screen and +# region screenshots. Set to false for raw desktop pixels by default; the +# interactive region Review toggle can still override this per capture. +include_drawings = true + # Exit overlay after any capture completes (forces exit for all capture types) # When false, clipboard-only captures still auto-exit by default. # Use --no-exit-after-capture to keep the overlay open for a run. @@ -1417,6 +1436,27 @@ exit_after_capture = false # language packages must be installed. OCR also needs `enabled = true` above. ocr_languages = "eng" +[capture.region] +# "native" draws Wayscriber's picker over a frozen desktop image. +# "slurp" keeps the external selector. Native falls back to slurp when no +# screen capture backend is available. +# Native region selections are encoded and named as PNG. The explicit "slurp" +# picker keeps [capture].format for compatibility with the legacy grim path. +# On Hyprland and Sway, native selection also offers Space-triggered window +# snapping for visible windows on the active output and workspace when the +# picker creates a fresh auto-freeze (not when reusing an older Freeze/Zoom). +# Candidates reflect the workspace visible when the compositor query runs. +picker = "native" + +# Pointer-side readout: position while idle, W x H in export pixels while dragging. +show_size_readout = true + +# Magnified pixel loupe next to the pointer while dragging. +show_loupe = false + +# Short hotkey legend at the top centre until the first drag. +show_legend = true + # ═══════════════════════════════════════════════════════════════════════════════ # EXPORT SETTINGS # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/configurator/src/app/pages/capture.rs b/configurator/src/app/pages/capture.rs index bfbe4b611..700b4ff11 100644 --- a/configurator/src/app/pages/capture.rs +++ b/configurator/src/app/pages/capture.rs @@ -12,7 +12,7 @@ use crate::models::util::format_float; use crate::models::{ ColorPickerId, ColorQuadInput, PdfFitModeOption, PdfLabelContentModeOption, PdfLabelPositionOption, PdfOrientationOption, PdfPageSizeOption, - PdfTransparentBackgroundOption, TabId, TextField, ToggleField, + PdfTransparentBackgroundOption, RegionPickerOption, TabId, TextField, ToggleField, }; use wayscriber::config::validate_ocr_languages; @@ -53,6 +53,12 @@ pub(super) fn build(sender: &ComponentSender) -> BuiltPage { |app| app.draft.capture_copy_to_clipboard, |value| Message::ToggleChanged(ToggleField::CaptureCopyToClipboard, value), ) + .switch_row( + "Include drawings in screenshots", + "Composites the active board's committed annotations into full-screen and region captures by default. Interactive region capture can override this per screenshot.", + |app| app.draft.capture_include_drawings, + |value| Message::ToggleChanged(ToggleField::CaptureIncludeDrawings, value), + ) .switch_row( "Always exit overlay after capture", "", @@ -72,6 +78,34 @@ pub(super) fn build(sender: &ComponentSender) -> BuiltPage { }, ); + page.group_in_area("Region picker", SearchArea::CaptureRegion) + .combo_row( + "Selection frontend", + "Native draws the picker over a frozen desktop image. Slurp uses the external selector.", + RegionPickerOption::list(), + labels(RegionPickerOption::list(), RegionPickerOption::label), + |app| app.draft.capture_region_picker, + Message::CaptureRegionPickerChanged, + ) + .switch_row( + "Show pointer position and selection size", + "Shows coordinates while idle and the selected export size while dragging.", + |app| app.draft.capture_region_show_size_readout, + |value| Message::ToggleChanged(ToggleField::CaptureRegionShowSizeReadout, value), + ) + .switch_row( + "Show magnified pixel loupe", + "Shows a magnified pixel grid beside the pointer while selecting or reviewing.", + |app| app.draft.capture_region_show_loupe, + |value| Message::ToggleChanged(ToggleField::CaptureRegionShowLoupe, value), + ) + .switch_row( + "Show hotkey legend", + "Shows a short picker hotkey guide until the first drag.", + |app| app.draft.capture_region_show_legend, + |value| Message::ToggleChanged(ToggleField::CaptureRegionShowLegend, value), + ); + page.group_in_area("PDF export", SearchArea::CapturePdf) .entry_row( "PDF filename template (blank = capture template)", diff --git a/configurator/src/app/pages/ui/general.rs b/configurator/src/app/pages/ui/general.rs index 87c20c38c..0f2c6b921 100644 --- a/configurator/src/app/pages/ui/general.rs +++ b/configurator/src/app/pages/ui/general.rs @@ -65,6 +65,12 @@ pub(super) fn build(sender: &ComponentSender) -> BuiltPage { |app| app.draft.ui_show_onboarding_hints, |value| Message::ToggleChanged(ToggleField::UiShowOnboardingHints, value), ) + .switch_row( + "Show shape size readout", + "Shows rectangle and ellipse preview dimensions in logical board pixels. Separate from the region-capture size readout.", + |app| app.draft.ui_show_shape_size_readout, + |value| Message::ToggleChanged(ToggleField::UiShowShapeSizeReadout, value), + ) .entry_row( "Command palette toast (ms)", |app| app.draft.ui_command_palette_toast_duration_ms.clone(), diff --git a/configurator/src/app/search/summary.rs b/configurator/src/app/search/summary.rs index be5281e84..19ef8e21a 100644 --- a/configurator/src/app/search/summary.rs +++ b/configurator/src/app/search/summary.rs @@ -286,6 +286,13 @@ fn capture_matches(query: &SearchQuery, summary: &mut TabSearchSummary) { SearchArea::CaptureFiles, CAPTURE_FILE_TERMS, ); + add_area_if( + query, + summary, + TabId::Capture, + SearchArea::CaptureRegion, + CAPTURE_REGION_TERMS, + ); if query.matches_parts(CAPTURE_PDF_TERMS.iter().copied()) || (query.matches_any_raw_text(CAPTURE_PDF_IDENTITY_TERMS) && query.matches_parts_scoped_to_tab(TabId::Capture, CAPTURE_PDF_TERMS.iter().copied())) diff --git a/configurator/src/app/search/terms.rs b/configurator/src/app/search/terms.rs index 29cf387d3..55398f314 100644 --- a/configurator/src/app/search/terms.rs +++ b/configurator/src/app/search/terms.rs @@ -179,6 +179,12 @@ pub(super) const UI_GENERAL_TERMS: &[&str] = &[ "tutorial", "guidance", "tips", + "show shape size readout", + "shape size readout", + "show_shape_size_readout", + "shape dimensions", + "rectangle ellipse preview", + "logical board pixels", "command palette toast", ]; pub(super) const UI_TOOLBAR_TERMS: &[&str] = &[ @@ -373,9 +379,26 @@ pub(super) const CAPTURE_FILE_TERMS: &[&str] = &[ "capture filename template", "clipboard", "copy to clipboard", + "include drawings in screenshots", + "include drawings", + "capture annotations", + "screenshot annotations", + "include_drawings", "always exit overlay after capture", "format", ]; +pub(super) const CAPTURE_REGION_TERMS: &[&str] = &[ + "region picker", + "selection frontend", + "native", + "slurp", + "show pointer position and selection size", + "size readout", + "show magnified pixel loupe", + "pixel loupe", + "show hotkey legend", + "hotkey legend", +]; pub(super) const CAPTURE_PDF_TERMS: &[&str] = &[ "pdf", "export", diff --git a/configurator/src/app/search/tests.rs b/configurator/src/app/search/tests.rs index 76f0fb1de..5ebfa5984 100644 --- a/configurator/src/app/search/tests.rs +++ b/configurator/src/app/search/tests.rs @@ -238,6 +238,28 @@ fn exact_static_section_labels_match_their_sections() { } } +#[test] +fn region_picker_labels_match_the_region_section() { + for query in [ + "selection frontend", + "show pointer position and selection size", + "show magnified pixel loupe", + "show hotkey legend", + "slurp", + ] { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.search_query = SearchQuery::new(query); + + let summary = app.search_summary(); + let capture = summary.tab(TabId::Capture).expect("capture match"); + + assert!( + capture.area_matches(SearchArea::CaptureRegion), + "query should show Region picker: {query}", + ); + } +} + #[cfg(feature = "tablet-input")] #[test] fn exact_tablet_labels_match_tablet_section() { @@ -592,6 +614,11 @@ fn exact_general_ui_field_labels_match_general_ui_section() { "hints", "onboarding hints", "show_onboarding_hints", + "show shape size readout", + "shape size readout", + "show_shape_size_readout", + "rectangle ellipse preview", + "logical board pixels", ] { let (mut app, _effects) = ConfiguratorApp::new_app(); app.search_query = SearchQuery::new(query); @@ -619,6 +646,19 @@ fn exact_capture_file_labels_match_capture_file_section() { assert!(capture.area_matches(SearchArea::CaptureFiles)); } +#[test] +fn capture_drawing_preference_matches_capture_file_section() { + for query in ["include drawings in screenshots", "capture annotations"] { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.search_query = SearchQuery::new(query); + + let summary = app.search_summary(); + let capture = summary.tab(TabId::Capture).expect("capture match"); + + assert!(capture.area_matches(SearchArea::CaptureFiles)); + } +} + #[test] fn exact_capture_filename_labels_match_capture_sections() { let cases = [ diff --git a/configurator/src/app/search/types.rs b/configurator/src/app/search/types.rs index 4836626bf..34f84fd17 100644 --- a/configurator/src/app/search/types.rs +++ b/configurator/src/app/search/types.rs @@ -16,6 +16,7 @@ pub(crate) enum SearchArea { BoardsGeneral, RenderProfilesGeneral, CaptureFiles, + CaptureRegion, CapturePdf, DaemonStatus, DaemonService, diff --git a/configurator/src/app/update/fields.rs b/configurator/src/app/update/fields.rs index dd96ddc88..80d762cfb 100644 --- a/configurator/src/app/update/fields.rs +++ b/configurator/src/app/update/fields.rs @@ -9,7 +9,7 @@ use crate::models::{ FontStyleOption, FontWeightOption, KeybindingField, PdfFitModeOption, PdfLabelContentModeOption, PdfLabelPositionOption, PdfOrientationOption, PdfPageSizeOption, PdfTransparentBackgroundOption, PresenterToolBehaviorOption, PresenterToolbarModeOption, - SessionCompressionOption, SessionStorageModeOption, TextField, ToggleField, + RegionPickerOption, SessionCompressionOption, SessionStorageModeOption, TextField, ToggleField, }; #[cfg(feature = "tablet-input")] use crate::models::{PressureThicknessEditModeOption, PressureThicknessEntryModeOption}; @@ -72,6 +72,16 @@ impl ConfiguratorApp { Vec::new() } + pub(super) fn handle_capture_region_picker_changed( + &mut self, + option: RegionPickerOption, + ) -> Vec { + self.status = StatusMessage::idle(); + self.draft.capture_region_picker = option; + self.refresh_dirty_flag(); + Vec::new() + } + pub(super) fn handle_export_pdf_page_size_changed( &mut self, option: PdfPageSizeOption, diff --git a/configurator/src/app/update/mod.rs b/configurator/src/app/update/mod.rs index aa0aca4b1..b1c45ec34 100644 --- a/configurator/src/app/update/mod.rs +++ b/configurator/src/app/update/mod.rs @@ -248,6 +248,9 @@ impl ConfiguratorApp { Message::PresenterToolbarModeChanged(option) => { self.handle_presenter_toolbar_mode_changed(option) } + Message::CaptureRegionPickerChanged(option) => { + self.handle_capture_region_picker_changed(option) + } Message::ExportPdfPageSizeChanged(option) => { self.handle_export_pdf_page_size_changed(option) } diff --git a/configurator/src/messages.rs b/configurator/src/messages.rs index 33afe4f6d..52e62d86e 100644 --- a/configurator/src/messages.rs +++ b/configurator/src/messages.rs @@ -11,12 +11,12 @@ use crate::models::{ PdfLabelContentModeOption, PdfLabelPositionOption, PdfOrientationOption, PdfPageSizeOption, PdfTransparentBackgroundOption, PresenterToolBehaviorOption, PresenterToolbarModeOption, PresetEraserKindOption, PresetEraserModeOption, PresetTextField, PresetToggleField, - RecorderDeviceKind, ReducedMotionOption, RenderProfileExportOption, RenderProfileMappingSide, - RenderProfileTextField, SessionCatalogActionResult, SessionCatalogItem, - SessionCompressionOption, SessionStorageModeOption, ShortcutManagerFilter, ShortcutManagerSort, - StatusPositionOption, TabId, TextField, ToggleField, ToolOption, ToolbarLayoutModeOption, - ToolbarOverrideField, ToolbarRebindModifierOption, UiTabId, UiThemeOption, - ZoomChipDisplayOption, + RecorderDeviceKind, ReducedMotionOption, RegionPickerOption, RenderProfileExportOption, + RenderProfileMappingSide, RenderProfileTextField, SessionCatalogActionResult, + SessionCatalogItem, SessionCompressionOption, SessionStorageModeOption, ShortcutManagerFilter, + ShortcutManagerSort, StatusPositionOption, TabId, TextField, ToggleField, ToolOption, + ToolbarLayoutModeOption, ToolbarOverrideField, ToolbarRebindModifierOption, UiTabId, + UiThemeOption, ZoomChipDisplayOption, }; #[cfg(feature = "tablet-input")] use crate::models::{PressureThicknessEditModeOption, PressureThicknessEntryModeOption}; @@ -157,6 +157,7 @@ pub enum Message { SessionCompressionChanged(SessionCompressionOption), PresenterToolBehaviorChanged(PresenterToolBehaviorOption), PresenterToolbarModeChanged(PresenterToolbarModeOption), + CaptureRegionPickerChanged(RegionPickerOption), ExportPdfPageSizeChanged(PdfPageSizeOption), ExportPdfOrientationChanged(PdfOrientationOption), ExportPdfFitChanged(PdfFitModeOption), diff --git a/configurator/src/models/config/draft/from_config.rs b/configurator/src/models/config/draft/from_config.rs index ba4c4a384..0f7944ad0 100644 --- a/configurator/src/models/config/draft/from_config.rs +++ b/configurator/src/models/config/draft/from_config.rs @@ -5,8 +5,8 @@ use super::super::super::fields::{ InputHudPositionOption, PdfFitModeOption, PdfLabelContentModeOption, PdfLabelPositionOption, PdfOrientationOption, PdfPageSizeOption, PdfTransparentBackgroundOption, PresenterToolBehaviorOption, PresenterToolbarModeOption, ReducedMotionOption, - SessionCompressionOption, SessionStorageModeOption, StatusPositionOption, ToolOption, - ToolbarLayoutModeOption, ToolbarRebindModifierOption, UiThemeOption, + RegionPickerOption, SessionCompressionOption, SessionStorageModeOption, StatusPositionOption, + ToolOption, ToolbarLayoutModeOption, ToolbarRebindModifierOption, UiThemeOption, }; #[cfg(feature = "tablet-input")] use super::super::super::fields::{ @@ -134,6 +134,7 @@ impl ConfigDraft { ui_show_frozen_badge: config.ui.show_frozen_badge, ui_show_capabilities_warning: config.ui.show_capabilities_warning, ui_show_onboarding_hints: config.ui.show_onboarding_hints, + ui_show_shape_size_readout: config.ui.show_shape_size_readout, ui_context_menu_enabled: config.ui.context_menu.enabled, ui_preferred_output: config.ui.preferred_output.clone().unwrap_or_default(), ui_xdg_fullscreen: config.ui.xdg_fullscreen, @@ -248,8 +249,13 @@ impl ConfigDraft { capture_filename_template: config.capture.filename_template.clone(), capture_format: config.capture.format.clone(), capture_copy_to_clipboard: config.capture.copy_to_clipboard, + capture_include_drawings: config.capture.include_drawings, capture_exit_after: config.capture.exit_after_capture, capture_ocr_languages: config.capture.ocr_languages.clone(), + capture_region_picker: RegionPickerOption::from_picker(config.capture.region.picker), + capture_region_show_size_readout: config.capture.region.show_size_readout, + capture_region_show_loupe: config.capture.region.show_loupe, + capture_region_show_legend: config.capture.region.show_legend, export_pdf_filename_template: config .export .pdf diff --git a/configurator/src/models/config/draft/mod.rs b/configurator/src/models/config/draft/mod.rs index fc17663ef..3023c3d12 100644 --- a/configurator/src/models/config/draft/mod.rs +++ b/configurator/src/models/config/draft/mod.rs @@ -7,8 +7,8 @@ use super::super::fields::{ InputHudPositionOption, PdfFitModeOption, PdfLabelContentModeOption, PdfLabelPositionOption, PdfOrientationOption, PdfPageSizeOption, PdfTransparentBackgroundOption, PresenterToolBehaviorOption, PresenterToolbarModeOption, ReducedMotionOption, - SessionCompressionOption, SessionStorageModeOption, StatusPositionOption, ToolOption, - ToolbarLayoutModeOption, ToolbarRebindModifierOption, UiThemeOption, + RegionPickerOption, SessionCompressionOption, SessionStorageModeOption, StatusPositionOption, + ToolOption, ToolbarLayoutModeOption, ToolbarRebindModifierOption, UiThemeOption, }; #[cfg(feature = "tablet-input")] use super::super::fields::{PressureThicknessEditModeOption, PressureThicknessEntryModeOption}; @@ -94,6 +94,7 @@ pub struct ConfigDraft { pub ui_show_frozen_badge: bool, pub ui_show_capabilities_warning: bool, pub ui_show_onboarding_hints: bool, + pub ui_show_shape_size_readout: bool, pub ui_context_menu_enabled: bool, pub ui_preferred_output: String, pub ui_xdg_fullscreen: bool, @@ -181,8 +182,13 @@ pub struct ConfigDraft { pub capture_filename_template: String, pub capture_format: String, pub capture_copy_to_clipboard: bool, + pub capture_include_drawings: bool, pub capture_exit_after: bool, pub capture_ocr_languages: String, + pub capture_region_picker: RegionPickerOption, + pub capture_region_show_size_readout: bool, + pub capture_region_show_loupe: bool, + pub capture_region_show_legend: bool, pub export_pdf_filename_template: String, pub export_pdf_all_boards_filename_template: String, pub export_pdf_page_size: PdfPageSizeOption, diff --git a/configurator/src/models/config/setters.rs b/configurator/src/models/config/setters.rs index 190f4a107..6ad13bd9a 100644 --- a/configurator/src/models/config/setters.rs +++ b/configurator/src/models/config/setters.rs @@ -174,6 +174,7 @@ impl ConfigDraft { ToggleField::UiShowFrozenBadge => self.ui_show_frozen_badge = value, ToggleField::UiShowCapabilitiesWarning => self.ui_show_capabilities_warning = value, ToggleField::UiShowOnboardingHints => self.ui_show_onboarding_hints = value, + ToggleField::UiShowShapeSizeReadout => self.ui_show_shape_size_readout = value, ToggleField::UiShowStatusBoardBadge => self.ui_show_status_board_badge = value, ToggleField::UiShowStatusPageBadge => self.ui_show_status_page_badge = value, ToggleField::UiShowToolbarHint => self.ui_show_toolbar_hint = value, @@ -257,7 +258,13 @@ impl ConfigDraft { } ToggleField::CaptureEnabled => self.capture_enabled = value, ToggleField::CaptureCopyToClipboard => self.capture_copy_to_clipboard = value, + ToggleField::CaptureIncludeDrawings => self.capture_include_drawings = value, ToggleField::CaptureExitAfter => self.capture_exit_after = value, + ToggleField::CaptureRegionShowSizeReadout => { + self.capture_region_show_size_readout = value; + } + ToggleField::CaptureRegionShowLoupe => self.capture_region_show_loupe = value, + ToggleField::CaptureRegionShowLegend => self.capture_region_show_legend = value, ToggleField::ExportPdfLabelsEnabled => self.export_pdf_labels_enabled = value, ToggleField::ExportPdfLabelBackgroundEnabled => { self.export_pdf_label_background_enabled = value; diff --git a/configurator/src/models/config/tests.rs b/configurator/src/models/config/tests.rs index b69b28b91..16b8176a6 100644 --- a/configurator/src/models/config/tests.rs +++ b/configurator/src/models/config/tests.rs @@ -3,8 +3,9 @@ use super::super::fields::{ DragMouseButton, DragToolField, DragToolOption, FontWeightOption, InputHudModeOption, InputHudPositionOption, OverrideOption, PdfFitModeOption, PdfLabelContentModeOption, PdfOrientationOption, PdfPageSizeOption, PdfTransparentBackgroundOption, QuadField, - ReducedMotionOption, SessionStorageModeOption, TextField, ToggleField, ToolOption, - ToolbarLayoutModeOption, ToolbarOverrideField, ToolbarRebindModifierOption, UiThemeOption, + ReducedMotionOption, RegionPickerOption, SessionStorageModeOption, TextField, ToggleField, + ToolOption, ToolbarLayoutModeOption, ToolbarOverrideField, ToolbarRebindModifierOption, + UiThemeOption, }; #[test] @@ -55,6 +56,59 @@ fn config_draft_round_trips_automatic_onboarding_preference() { .expect("onboarding preference should round trip"); assert!(!round_trip.ui.show_onboarding_hints); } + +#[test] +fn config_draft_round_trips_shape_size_readout_preference() { + let mut config = Config::default(); + config.ui.show_shape_size_readout = false; + let mut draft = ConfigDraft::from_config(&config); + assert!(!draft.ui_show_shape_size_readout); + + draft.set_toggle(ToggleField::UiShowShapeSizeReadout, true); + let round_trip = draft + .to_config(&config) + .expect("shape size readout preference should round trip"); + assert!(round_trip.ui.show_shape_size_readout); +} + +#[test] +fn config_draft_round_trips_region_capture_settings() { + let config = Config::default(); + let mut draft = ConfigDraft::from_config(&config); + assert_eq!(draft.capture_region_picker, RegionPickerOption::Native); + assert!(draft.capture_region_show_size_readout); + assert!(!draft.capture_region_show_loupe); + assert!(draft.capture_region_show_legend); + + draft.capture_region_picker = RegionPickerOption::Slurp; + draft.set_toggle(ToggleField::CaptureRegionShowSizeReadout, false); + draft.set_toggle(ToggleField::CaptureRegionShowLoupe, true); + draft.set_toggle(ToggleField::CaptureRegionShowLegend, false); + + let round_trip = draft + .to_config(&config) + .expect("region capture settings should round trip"); + assert_eq!( + round_trip.capture.region.picker, + wayscriber::config::RegionPicker::Slurp + ); + assert!(!round_trip.capture.region.show_size_readout); + assert!(round_trip.capture.region.show_loupe); + assert!(!round_trip.capture.region.show_legend); +} + +#[test] +fn config_draft_round_trips_capture_drawing_preference() { + let config = Config::default(); + let mut draft = ConfigDraft::from_config(&config); + assert!(draft.capture_include_drawings); + + draft.set_toggle(ToggleField::CaptureIncludeDrawings, false); + let round_trip = draft + .to_config(&config) + .expect("capture drawing preference should round trip"); + assert!(!round_trip.capture.include_drawings); +} use super::super::{ColorMode, NamedColorOption}; use super::{ConfigDraft, RenderProfileSelectionOption}; use wayscriber::config::{ diff --git a/configurator/src/models/config/to_config/capture.rs b/configurator/src/models/config/to_config/capture.rs index d9e65aef9..4f9125852 100644 --- a/configurator/src/models/config/to_config/capture.rs +++ b/configurator/src/models/config/to_config/capture.rs @@ -23,7 +23,12 @@ impl ConfigDraft { )), } config.capture.copy_to_clipboard = self.capture_copy_to_clipboard; + config.capture.include_drawings = self.capture_include_drawings; config.capture.exit_after_capture = self.capture_exit_after; + config.capture.region.picker = self.capture_region_picker.to_picker(); + config.capture.region.show_size_readout = self.capture_region_show_size_readout; + config.capture.region.show_loupe = self.capture_region_show_loupe; + config.capture.region.show_legend = self.capture_region_show_legend; match validate_ocr_languages(&self.capture_ocr_languages) { Ok(languages) => config.capture.ocr_languages = languages, Err(reason) => errors.push(FormError::new( diff --git a/configurator/src/models/config/to_config/ui.rs b/configurator/src/models/config/to_config/ui.rs index 5cd5c458b..1e57e6434 100644 --- a/configurator/src/models/config/to_config/ui.rs +++ b/configurator/src/models/config/to_config/ui.rs @@ -25,6 +25,7 @@ impl ConfigDraft { config.ui.show_frozen_badge = self.ui_show_frozen_badge; config.ui.show_capabilities_warning = self.ui_show_capabilities_warning; config.ui.show_onboarding_hints = self.ui_show_onboarding_hints; + config.ui.show_shape_size_readout = self.ui_show_shape_size_readout; config.ui.context_menu.enabled = self.ui_context_menu_enabled; let preferred_output = self.ui_preferred_output.trim(); config.ui.preferred_output = if preferred_output.is_empty() { diff --git a/configurator/src/models/fields/capture.rs b/configurator/src/models/fields/capture.rs new file mode 100644 index 000000000..59c9a2ddf --- /dev/null +++ b/configurator/src/models/fields/capture.rs @@ -0,0 +1,40 @@ +use wayscriber::config::RegionPicker; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RegionPickerOption { + Native, + Slurp, +} + +impl RegionPickerOption { + pub fn list() -> Vec { + vec![Self::Native, Self::Slurp] + } + + pub fn label(self) -> &'static str { + match self { + Self::Native => "Native", + Self::Slurp => "Slurp", + } + } + + pub fn to_picker(self) -> RegionPicker { + match self { + Self::Native => RegionPicker::Native, + Self::Slurp => RegionPicker::Slurp, + } + } + + pub fn from_picker(picker: RegionPicker) -> Self { + match picker { + RegionPicker::Native => Self::Native, + RegionPicker::Slurp => Self::Slurp, + } + } +} + +impl std::fmt::Display for RegionPickerOption { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.label()) + } +} diff --git a/configurator/src/models/fields/mod.rs b/configurator/src/models/fields/mod.rs index 228d610c2..a7fcf9a34 100644 --- a/configurator/src/models/fields/mod.rs +++ b/configurator/src/models/fields/mod.rs @@ -1,3 +1,4 @@ +mod capture; mod eraser; mod export; mod font; @@ -12,6 +13,7 @@ mod toggles; mod tool; mod toolbar; +pub use capture::RegionPickerOption; pub use eraser::{EraserModeOption, PresetEraserKindOption, PresetEraserModeOption}; pub use export::{ PdfFitModeOption, PdfLabelContentModeOption, PdfLabelPositionOption, PdfOrientationOption, diff --git a/configurator/src/models/fields/tests.rs b/configurator/src/models/fields/tests.rs index 688c08abc..da6af0704 100644 --- a/configurator/src/models/fields/tests.rs +++ b/configurator/src/models/fields/tests.rs @@ -1,4 +1,24 @@ use super::*; + +#[test] +fn region_picker_options_cover_the_core_enum() { + assert_eq!( + RegionPickerOption::from_picker(wayscriber::config::RegionPicker::Native), + RegionPickerOption::Native + ); + assert_eq!( + RegionPickerOption::from_picker(wayscriber::config::RegionPicker::Slurp), + RegionPickerOption::Slurp + ); + assert_eq!( + RegionPickerOption::Slurp.to_picker(), + wayscriber::config::RegionPicker::Slurp + ); + assert_eq!( + RegionPickerOption::list(), + vec![RegionPickerOption::Native, RegionPickerOption::Slurp] + ); +} use wayscriber::config::{ PdfFitMode, PdfLabelContentMode, PdfLabelPosition, PdfOrientation, PdfPageSize, PdfTransparentBackground, ReducedMotion, SessionCompression, SessionStorageMode, diff --git a/configurator/src/models/fields/toggles.rs b/configurator/src/models/fields/toggles.rs index d5f843e32..c53a6d315 100644 --- a/configurator/src/models/fields/toggles.rs +++ b/configurator/src/models/fields/toggles.rs @@ -16,6 +16,7 @@ pub enum ToggleField { UiShowFrozenBadge, UiShowCapabilitiesWarning, UiShowOnboardingHints, + UiShowShapeSizeReadout, UiHelpOverlayContextFilter, UiContextMenuEnabled, UiXdgFullscreen, @@ -63,7 +64,11 @@ pub enum ToggleField { BoardsPersistCustomizations, CaptureEnabled, CaptureCopyToClipboard, + CaptureIncludeDrawings, CaptureExitAfter, + CaptureRegionShowSizeReadout, + CaptureRegionShowLoupe, + CaptureRegionShowLegend, ExportPdfLabelsEnabled, ExportPdfLabelBackgroundEnabled, SessionPersistTransparent, diff --git a/configurator/src/models/keybindings/field/config/read.rs b/configurator/src/models/keybindings/field/config/read.rs index 6dbb7783d..75c803940 100644 --- a/configurator/src/models/keybindings/field/config/read.rs +++ b/configurator/src/models/keybindings/field/config/read.rs @@ -127,6 +127,8 @@ impl KeybindingField { Self::CaptureFileSelection => &config.capture.capture_file_selection, Self::CaptureClipboardRegion => &config.capture.capture_clipboard_region, Self::CaptureFileRegion => &config.capture.capture_file_region, + Self::CaptureRegionInteractive => &config.capture.capture_region_interactive, + Self::MeasureMode => &config.capture.measure_mode, Self::ExportCanvasFile => &config.capture.export_canvas_file, Self::ExportCanvasClipboard => &config.capture.export_canvas_clipboard, Self::ExportCanvasClipboardAndFile => &config.capture.export_canvas_clipboard_and_file, diff --git a/configurator/src/models/keybindings/field/config/write.rs b/configurator/src/models/keybindings/field/config/write.rs index 097224111..8d316c247 100644 --- a/configurator/src/models/keybindings/field/config/write.rs +++ b/configurator/src/models/keybindings/field/config/write.rs @@ -128,6 +128,8 @@ impl KeybindingField { Self::CaptureFileSelection => config.capture.capture_file_selection = value, Self::CaptureClipboardRegion => config.capture.capture_clipboard_region = value, Self::CaptureFileRegion => config.capture.capture_file_region = value, + Self::CaptureRegionInteractive => config.capture.capture_region_interactive = value, + Self::MeasureMode => config.capture.measure_mode = value, Self::ExportCanvasFile => config.capture.export_canvas_file = value, Self::ExportCanvasClipboard => config.capture.export_canvas_clipboard = value, Self::ExportCanvasClipboardAndFile => { diff --git a/configurator/src/models/keybindings/field/labels.rs b/configurator/src/models/keybindings/field/labels.rs index bf964dccb..5ebe9c270 100644 --- a/configurator/src/models/keybindings/field/labels.rs +++ b/configurator/src/models/keybindings/field/labels.rs @@ -137,6 +137,8 @@ impl KeybindingField { Self::CaptureFileSelection => "capture_file_selection", Self::CaptureClipboardRegion => "capture_clipboard_region", Self::CaptureFileRegion => "capture_file_region", + Self::CaptureRegionInteractive => "capture_region_interactive", + Self::MeasureMode => "measure_mode", Self::ExportCanvasFile => "export_canvas_file", Self::ExportCanvasClipboard => "export_canvas_clipboard", Self::ExportCanvasClipboardAndFile => "export_canvas_clipboard_and_file", diff --git a/configurator/src/models/keybindings/field/list.rs b/configurator/src/models/keybindings/field/list.rs index 7ad4f4805..877bc6eaa 100644 --- a/configurator/src/models/keybindings/field/list.rs +++ b/configurator/src/models/keybindings/field/list.rs @@ -122,6 +122,8 @@ impl KeybindingField { Self::CaptureFileSelection, Self::CaptureClipboardRegion, Self::CaptureFileRegion, + Self::CaptureRegionInteractive, + Self::MeasureMode, Self::ExportCanvasFile, Self::ExportCanvasClipboard, Self::ExportCanvasClipboardAndFile, diff --git a/configurator/src/models/keybindings/field/mod.rs b/configurator/src/models/keybindings/field/mod.rs index 950ae0963..b0dbcbdd1 100644 --- a/configurator/src/models/keybindings/field/mod.rs +++ b/configurator/src/models/keybindings/field/mod.rs @@ -107,6 +107,8 @@ pub enum KeybindingField { CaptureFileSelection, CaptureClipboardRegion, CaptureFileRegion, + CaptureRegionInteractive, + MeasureMode, ExportCanvasFile, ExportCanvasClipboard, ExportCanvasClipboardAndFile, diff --git a/configurator/src/models/keybindings/field/tab.rs b/configurator/src/models/keybindings/field/tab.rs index abda464f0..faba941c0 100644 --- a/configurator/src/models/keybindings/field/tab.rs +++ b/configurator/src/models/keybindings/field/tab.rs @@ -123,6 +123,8 @@ impl KeybindingField { | Self::CaptureFileSelection | Self::CaptureClipboardRegion | Self::CaptureFileRegion + | Self::CaptureRegionInteractive + | Self::MeasureMode | Self::ExportCanvasFile | Self::ExportCanvasClipboard | Self::ExportCanvasClipboardAndFile diff --git a/configurator/src/models/keybindings/tests.rs b/configurator/src/models/keybindings/tests.rs index 604237104..bc5e9c226 100644 --- a/configurator/src/models/keybindings/tests.rs +++ b/configurator/src/models/keybindings/tests.rs @@ -168,6 +168,41 @@ fn board_pdf_export_keybinding_field_reads_and_writes_config() { ); } +#[test] +fn interactive_region_capture_keybinding_is_visible_and_round_trips() { + let field = KeybindingField::CaptureRegionInteractive; + let mut config = KeybindingsConfig::default(); + + assert!(KeybindingField::all().contains(&field)); + assert_eq!(field.tab(), KeybindingsTabId::CaptureView); + assert_eq!(field.label(), "Capture Region (Interactive)"); + assert_eq!( + field.get(&config), + &vec!["Ctrl+Shift+C".to_string()], + "the shipped default" + ); + + field.set(&mut config, vec!["Ctrl+Alt+Shift+I".to_string()]); + assert_eq!( + config.capture.capture_region_interactive, + ["Ctrl+Alt+Shift+I"] + ); +} + +#[test] +fn measure_mode_keybinding_is_visible_and_round_trips() { + let field = KeybindingField::MeasureMode; + let mut config = KeybindingsConfig::default(); + + assert!(KeybindingField::all().contains(&field)); + assert_eq!(field.tab(), KeybindingsTabId::CaptureView); + assert_eq!(field.label(), "Measure Mode"); + assert!(field.get(&config).is_empty()); + + field.set(&mut config, vec!["Ctrl+Alt+M".to_string()]); + assert_eq!(config.capture.measure_mode, ["Ctrl+Alt+M"]); +} + /// Saving in the configurator rebuilds `KeybindingsConfig` from `default()` and /// writes back only the fields present in `KeybindingField::all()`. A binding /// missing from that registry is therefore silently reset to its default the diff --git a/configurator/src/models/mod.rs b/configurator/src/models/mod.rs index 738541e37..bed9060cd 100644 --- a/configurator/src/models/mod.rs +++ b/configurator/src/models/mod.rs @@ -28,10 +28,10 @@ pub use fields::{ PdfFitModeOption, PdfLabelContentModeOption, PdfLabelPositionOption, PdfOrientationOption, PdfPageSizeOption, PdfTransparentBackgroundOption, PresenterToolBehaviorOption, PresenterToolbarModeOption, PresetEraserKindOption, PresetEraserModeOption, PresetTextField, - PresetToggleField, QuadField, ReducedMotionOption, SessionCompressionOption, - SessionStorageModeOption, StatusPositionOption, TextField, ToggleField, ToolOption, - ToolbarLayoutModeOption, ToolbarOverrideField, ToolbarRebindModifierOption, UiThemeOption, - ZoomChipDisplayOption, + PresetToggleField, QuadField, ReducedMotionOption, RegionPickerOption, + SessionCompressionOption, SessionStorageModeOption, StatusPositionOption, TextField, + ToggleField, ToolOption, ToolbarLayoutModeOption, ToolbarOverrideField, + ToolbarRebindModifierOption, UiThemeOption, ZoomChipDisplayOption, }; #[cfg(feature = "tablet-input")] pub use fields::{PressureThicknessEditModeOption, PressureThicknessEntryModeOption}; diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 241a2d488..ac1d6ff3c 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -712,6 +712,10 @@ show_capabilities_warning = true # appearances. The guided tour remains available manually when this is false. show_onboarding_hints = true +# Show rectangle and ellipse preview dimensions in logical board pixels. +# This is separate from capture.region.show_size_readout. +show_shape_size_readout = true + # Command palette action toast duration (ms) command_palette_toast_duration_ms = 1500 @@ -1183,6 +1187,7 @@ top_controls = [ - **Context-aware UI**: `context_aware_ui` shows/hides tool-specific controls (colors, thickness, arrow labels, etc.) based on the active tool; disable to always show all controls. - **Preset toasts**: `show_preset_toasts` enables toast confirmations for preset apply/save/clear. - **Automatic guidance**: `show_onboarding_hints` controls first-run cards, discovery tips, and shortcut coaching. Discovery and coaching tips offer **Got it** (permanently acknowledge that tip) and **Tip settings…** (acknowledge it, then open the Configurator at this setting); the toolbar-hidden recovery tip keeps **Show** as its primary control and offers the same settings route. Using the board picker, bottom-right zoom controls, or Canvas popover also acknowledges the matching tip. Clicking the message body dismisses a tip only for the current run; an unattended tip stops after three appearances. Set this option to `false` to disable all automatic tutorials on later overlay launches; the running overlay does not live-reload this Configurator change. The guided tour remains available manually, and capability, safety, and configuration warnings are unaffected. Completed profiles migrated from onboarding versions before v6 are not enrolled in the later status-bar, Canvas, and zoom tip series. If onboarding progress cannot be saved, automatic guidance is disabled for that run and an actionable persistence warning is shown. +- **Shape size readout**: `show_shape_size_readout` controls the live rectangle and ellipse preview dimensions, measured in logical board pixels. Ellipse values match the diameter that will be committed, so an odd drag span rounds down to the nearest even diameter. It defaults to `true` and is separate from `capture.region.show_size_readout`, which describes a region-capture selection. - **Capability warnings**: `show_capabilities_warning` independently controls compositor limitation warnings; disabling tutorials does not hide safety, configuration, or capability diagnostics. - **Tool preview**: `show_tool_preview` toggles the cursor bubble. - **Offsets**: `top_offset` and `top_offset_y` are the authored default top-toolbar position. Dragging the strip saves its position as a runtime preference in `runtime-ui.toml` and leaves these untouched; editing one here again takes over from the saved drag. @@ -1391,6 +1396,11 @@ format = "png" # Copy captures to clipboard in addition to saving files copy_to_clipboard = true +# Composite the active board's committed annotations into full-screen and +# region screenshots. Set to false for raw desktop pixels by default; the +# interactive region Review toggle can still override this per capture. +include_drawings = true + # Exit the overlay after any capture completes (forces exit for all capture types) # When false, clipboard-only captures still auto-exit by default. # Use --no-exit-after-capture to keep the overlay open for a run. @@ -1399,13 +1409,142 @@ exit_after_capture = false # Languages for "Copy text from screen" (OCR), in Tesseract's plus-separated # form. The matching Tesseract language packages must be installed. ocr_languages = "eng" + +[capture.region] +# "native" draws Wayscriber's picker over a frozen desktop image. +# "slurp" keeps the external selector. Native falls back to slurp when no +# screen capture backend is available. Native selections are PNG; explicit +# slurp selections keep [capture].format. +picker = "native" + +# Pointer position while idle; W x H in export pixels while dragging +show_size_readout = true + +# Magnified pixel grid beside the pointer while selecting or reviewing +show_loupe = false + +# Short hotkey guide shown until the first drag +show_legend = true ``` **Tips:** - Set `copy_to_clipboard = false` if you prefer file-only captures. +- Set `include_drawings = false` if full-screen and region captures should use + raw desktop pixels by default. - Clipboard-only shortcuts ignore the save directory automatically. +- Image clipboard delivery publishes PNG data with `wl-copy`, reads it back + with `wl-paste`, and retries the complete write once unless the bytes match. + Read-back is used for PNG payloads smaller than 64 MiB; larger successful + `wl-copy` publications keep the previous status-only behavior. - `filename_template` must be a single file name (no `/` or `..`). `format` is `png`, `jpg`, or `jpeg`. -- `wl-clipboard`, `grim`, and `slurp` are installed automatically by deb/rpm/AUR packages. For source/tarball installs, add them manually; otherwise wayscriber falls back to `xdg-desktop-portal`. +- `wl-clipboard`, `grim`, and `slurp` are installed automatically by deb/rpm/AUR packages. For source/tarball installs, add them manually. Wayscriber uses its native region picker by default and falls back to `slurp` when it cannot obtain the desktop image; other screenshot capture paths can fall back to `xdg-desktop-portal`. + +#### Region picker + +Bound region screenshot actions use the native picker by default. It draws over +the active output's frozen desktop image, shows a crosshair and optional size +readout/legend, and sends the selected pixels to the shortcut's existing +clipboard or file destination. If no screen capture backend is available, it +falls back to `slurp`. + +On Hyprland and Sway, the native picker also discovers visible windows on the +active output and workspace. Press `Space` to switch between free-area and +window selection, point at a window and click it, or move between candidates +with `Super+Arrow` and choose with `Enter`. Press `Space` again to return to +area selection. Window controls stay hidden when the compositor is unsupported, +the geometry helper fails, or no selectable windows are available. The +external `slurp` picker does not provide this mode. +Window mode is also hidden when the picker reuses a pre-existing Freeze or Zoom +image. Wayscriber currently offers it only for the fresh auto-freeze created by +the picker. It checks the provider result against that source's output and +layout identity before showing candidates; a mismatch leaves window mode +unavailable. At fractional scales, Wayland and Hyprland can round the same +logical output size one pixel differently. That one-pixel size difference is +accepted, but candidate bounds are restricted to the overlap; larger size +differences or any origin change disable window mode. +Wayland has no portable workspace identity at the freeze boundary, so window +candidates reflect the workspace visible when the compositor query runs. A +workspace switch in the short freeze-to-query interval can therefore make +window mode describe the new workspace while the frozen image still shows the +old one; returning to area mode remains safe. + +`capture_region_interactive` opens the same picker, then keeps the selection in +a review step where you can copy it, save it, do both, or add it to the active +board. It owns Ctrl+Shift+C by default, and is also in the command +palette. That chord previously ran `capture_clipboard_selection`, which now +ships unbound: Review's **Copy** (Ctrl+C) reaches the same clipboard +result, so bind `capture_clipboard_selection` explicitly if you want the +one-step copy back. The interactive action +always uses the native picker even when `picker = "slurp"`; if native capture +is unavailable it reports that limitation instead of silently skipping Review. +The **Both** button (and `Enter`) always copies the PNG and saves it to a file, +independently of `[capture].copy_to_clipboard`; it is the accented default in +the Review bar for that reason. + +Review drops the targeting chrome once the rectangle is committed. The +crosshair disappears, the size badge parks on the selection's corner instead of +trailing the pointer — moving inside the rectangle when the action bar or a +screen edge needs that space — and the cursor stops being a crosshair: a hand +over an action-bar button, a grab hand over the rectangle you can still drag, +and an arrow everywhere else. + +Adjust the rectangle before you send it. Drag its interior to move it, or the +arrow keys to nudge it one pixel at a time (`Shift` makes that ten). Eight +resize grips sit on the rectangle: drag a corner to move two edges at once, or +an edge midpoint to move one. A grip dragged past the opposite edge stops +rather than flipping the rectangle, and one dragged off the screen stops at the +edge of the captured image. Short sides drop their midpoint grip so it cannot +crowd the corners, and a small rectangle scales its grips down so its middle +always stays draggable; corners are always offered. Pressing outside the +rectangle starts a new selection instead. + +`[capture].include_drawings` defaults to `true`, so full-screen and region +screenshots include the active board's committed shapes. Provisional strokes, +selection handles, tool previews, toolbars, and other Wayscriber UI are not +included. Set it to `false` for raw desktop pixels by default. On a transparent +board, full-screen and legacy `slurp` capture keep the desktop behind the +annotations; a solid board keeps its canvas background. The native region +picker instead composites committed drawings over the frozen desktop crop. +Those committed drawings are visible inside the native picker itself, so the +selection preview matches the annotated export. Provisional strokes, selection +handles, tool previews, toolbars, and other transient UI remain hidden. + +The Review bar's **Include drawings in exports** toggle (or `D`) starts from +that configured default and can override it for one interactive region capture. +Copy, Save, Both, and Board all honour the current toggle. Note that adding an +annotated crop to the board it was composited from bakes a second, flattened +copy of those annotations into the image, sitting over the live shapes until +you move the pasted image. A pasted image occupies whole board pixels, so the +baked copy normally sits within half a board pixel of the live shapes. A crop +under a board pixel across — a few source pixels at any output scale above 1x — +can have both its edges round onto the same board pixel, depending on where it +falls; it then takes the whole board pixel centred on it, and its edges can sit +up to a pixel out. Turn the toggle off before pressing **Board** when you want +the raw crop instead. + +`measure_mode` opens a capture-free screen ruler over the live Wayscriber +view. Drag to measure a rectangle in logical screen pixels; the completed +rectangle stays visible so you can read it, and another drag replaces it. +`Esc`, right-click, or invoking **Measure Mode** again exits. It does not +reserve capture state, freeze the desktop, encode an image, or deliver a file +or clipboard payload. The action is available from the command palette and is +unbound by default. + +- Set `picker = "slurp"` to always use the external selector. This is also the + option for selecting a region on a monitor other than Wayscriber's active + output. +- `show_size_readout` shows pointer coordinates before the drag and the + selected width and height in exported image pixels during it. In Review the + same readout is anchored to the selection instead of the pointer. +- `show_legend` shows the short hotkey guide until the first drag. +- `show_loupe` shows a magnified pixel grid beside the pointer while dragging + or reviewing a selection. +- Native region screenshot actions encode PNG and use a `.png` filename even + when `[capture].format` is `jpg` or `jpeg`; this prevents PNG bytes from being + written under a JPEG extension. An explicit `picker = "slurp"` keeps the + configured format used by the legacy `slurp`/`grim` path. +- A configuration reload affects the next region selection; it does not change + a picker that is already open. #### Copy text from screen (OCR) @@ -1909,10 +2048,15 @@ capture_selection = ["Ctrl+Shift+I"] # Clipboard/File specific captures capture_clipboard_full = ["Ctrl+C"] capture_file_full = ["Ctrl+S"] -capture_clipboard_selection = ["Ctrl+Shift+C"] +# Unbound by default: Ctrl+Shift+C opens the interactive picker below, whose +# Copy action reaches the same clipboard result. +capture_clipboard_selection = [] capture_file_selection = ["Ctrl+Shift+S"] capture_clipboard_region = ["Ctrl+6"] capture_file_region = ["Ctrl+Alt+6"] +# Open the region review/action bar. Also available from the command palette. +capture_region_interactive = ["Ctrl+Shift+C"] +measure_mode = [] export_canvas_file = [] export_canvas_clipboard = [] export_canvas_clipboard_and_file = [] @@ -2031,9 +2175,10 @@ clear_canvas = ["X"] **Defaults:** Defaults match the original hardcoded keybindings where possible. Copy/paste selection uses -Ctrl+Alt+C/Ctrl+Alt+V, so the clipboard-selection capture shortcut -defaults to Ctrl+Shift+C to avoid conflicts. The paste action also accepts PNG/JPEG -image data and local image files copied from a file manager. +Ctrl+Alt+C/Ctrl+Alt+V, so the region capture shortcut uses +Ctrl+Shift+C to avoid conflicts. That chord now runs +`capture_region_interactive`; `capture_clipboard_selection` ships unbound. The paste action +also accepts PNG/JPEG image data and local image files copied from a file manager. ## Creating Your Configuration diff --git a/docs/codebase-overview.md b/docs/codebase-overview.md index 71c5d4072..64d93e55d 100644 --- a/docs/codebase-overview.md +++ b/docs/codebase-overview.md @@ -134,6 +134,10 @@ The result is a predictable pipeline: Wayland → handlers → `InputState` → 2. The Wayland event loop centrally drains that pending work, so keybindings, command-palette Return, and command-palette mouse clicks share the same dispatch path without the two preference saves overwriting one another. 3. Screenshot actions call `WaylandState::handle_capture_action`; explicit canvas PNG export actions call `WaylandState::handle_canvas_export_action`; board PDF actions call `WaylandState::handle_board_pdf_export_action`. 4. `WaylandState::handle_capture_action` builds a `CaptureRequest` (type + destination + save config), hides the overlay, and queues the request until the suppression frame is confirmed; it then calls `CaptureManager::request_capture`. + Region actions first reserve an immutable intent and select against the + frozen desktop image. Straight-delivery actions submit immediately after + selection; `capture_region_interactive` enters review so Copy, Save, Both, + or Board can choose the terminal request. 5. Canvas export snapshots persisted board content in the current panned viewport, renders PNG bytes, and calls `CaptureManager::request_image_delivery`. 6. Board PDF export snapshots active-board or all-board pages with per-page layout metadata, renders PDF bytes, and calls `CaptureManager::request_document_delivery`. 7. A mutable `CaptureManager` submission returns a checked `CaptureRequestId`. `CaptureState` records that ID and remains the sole event-side completion owner until the matching terminal result is consumed. @@ -141,7 +145,8 @@ The result is a predictable pipeline: Wayland → handlers → `InputState` → 9. `perform_capture`: - Calls the configured `CaptureSource` (default: `sources::capture_image` with Hyprland→portal fallback). - Optionally saves via `CaptureFileSaver`. - - Optionally copies to clipboard via `CaptureClipboard`. + - Optionally copies to clipboard via `CaptureClipboard`; the production + implementation reads image data back and retries once unless it matches. - Returns `CaptureResult` used for desktop notifications. 10. The worker publishes one identified terminal result before waking the shared Wayland runtime descriptor. `WaylandState` non-blockingly polls `CaptureManager`, accepts only the recorded ID, restores the overlay, and emits notifications. Worker loss and identity mismatches are terminal and are reported once. diff --git a/src/backend/wayland/acquisition.rs b/src/backend/wayland/acquisition.rs index 211df1f8e..f5356ba77 100644 --- a/src/backend/wayland/acquisition.rs +++ b/src/backend/wayland/acquisition.rs @@ -9,7 +9,6 @@ impl ScreenAcquisitionId { } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -#[allow(dead_code)] // Modal owners are connected by the Phase-0a owner adapters. pub(in crate::backend::wayland) enum ScreenAcquisitionOwner { UserFreeze, Eyedropper, @@ -104,7 +103,6 @@ impl ScreenAcquisitionRegistry { self.slot.take() } - #[allow(dead_code)] // Used by public modal-owner cancellation adapters. pub fn take_matching( &mut self, id: ScreenAcquisitionId, diff --git a/src/backend/wayland/backend/event_loop/capture.rs b/src/backend/wayland/backend/event_loop/capture.rs index eb005eeb1..b6c4fb56f 100644 --- a/src/backend/wayland/backend/event_loop/capture.rs +++ b/src/backend/wayland/backend/event_loop/capture.rs @@ -107,6 +107,7 @@ pub(super) fn handle_pending_actions( state.poll_hex_copy_completion(); state.poll_text_copy_completion(); state.poll_text_paste_completion(); + state.poll_region_window_query_completion(); state.poll_ocr_completion(); state.poll_session_file_dialog_completion(qh); state.poll_desktop_open_completion(); @@ -140,12 +141,19 @@ pub(super) fn handle_pending_actions( // requests that produced it; the worker also wakes the loop when it // completes, so the answer is not left waiting for unrelated input. state.drain_config_edit_completions(); + // A native region action queues its frozen acquisition. Drain that action + // before the established frozen-toggle slot so acquisition starts in this + // pass instead of waiting for an unrelated wake or protocol event. + if let Some(action) = take_pending_region_capture_action(&mut state.input_state) { + state.handle_capture_action(action); + } handle_frozen_toggle(state); state.drain_pending_board_runtime_ui_actions(); if let Some(action) = state.input_state.take_pending_backend_action() { match action { PendingBackendAction::Screenshot(action) => state.handle_capture_action(action), + PendingBackendAction::MeasureMode => state.handle_measure_mode_action(), PendingBackendAction::CanvasExport(action) => state.handle_canvas_export_action(action), PendingBackendAction::BoardPdfExport(action) => { state.handle_board_pdf_export_action(action); @@ -186,6 +194,21 @@ struct FrozenTogglePassDecision { queued_to_start: Option, } +fn take_pending_region_capture_action( + input_state: &mut crate::input::InputState, +) -> Option { + match input_state.take_pending_backend_action() { + Some(PendingBackendAction::Screenshot(action)) if action.is_region_capture() => { + Some(action) + } + Some(pending) => { + input_state.set_pending_backend_action(pending); + None + } + None => None, + } +} + fn frozen_toggle_pass_decision( user_toggle: bool, slot: Option, @@ -301,9 +324,25 @@ fn handle_capture_results(state: &mut WaylandState) { operation, error, } => { + let pending_board = + active_id.and_then(|id| state.capture.take_pending_board_paste_for(id)); if let Some(id) = active_id { let _ = state.capture.consume_accepted(id); } + if pending_board.is_some() { + state.capture.finish_capture_lifecycle(); + let message = operation + .filter(|operation| *operation == ImageOperationKind::Screenshot) + .map(|_| friendly_capture_error(&error)) + .unwrap_or_else(|| "Capture services stopped unexpectedly.".to_string()); + warn!("Board region capture worker failed: {error}"); + state.input_state.push_toast( + ToastPriority::Critical, + "capture", + Toast::error(message), + ); + return; + } handle_capture_manager_failure(state, operation, &error); return; } @@ -322,6 +361,64 @@ fn handle_capture_results(state: &mut WaylandState) { info!("Capture completed"); + let pending_board = state.capture.take_pending_board_paste_for(id); + let outcome = match (outcome, pending_board) { + (CaptureOutcome::RenderedImageReady(image), Some(pending)) => { + state.capture.finish_capture_lifecycle(); + let embedded = crate::draw::EmbeddedImage { + mime_type: image.format.mime_type, + width: image.width, + height: image.height, + bytes: image.bytes, + }; + state + .input_state + .insert_captured_image(embedded, &pending.target); + return; + } + (CaptureOutcome::RenderedImageReady(_), None) => { + state.capture.finish_capture_lifecycle(); + warn!("Rendered region {id} completed without a pending board target"); + state.input_state.push_toast( + ToastPriority::Critical, + "capture.region.board", + Toast::error("Region was not added to the board."), + ); + return; + } + (CaptureOutcome::Failed { operation, message }, Some(_)) => { + state.capture.finish_capture_lifecycle(); + let friendly_error = if matches!(operation, ImageOperationKind::Screenshot) { + friendly_capture_error(&message) + } else { + message.clone() + }; + warn!("Board region render failed: {message}"); + state.input_state.push_toast( + ToastPriority::Critical, + "capture", + Toast::error(friendly_error), + ); + return; + } + (CaptureOutcome::Cancelled { operation, reason }, Some(_)) => { + state.capture.finish_capture_lifecycle(); + info!("{} cancelled: {}", operation.saved_log_label(), reason); + return; + } + (unexpected, Some(_)) => { + state.capture.finish_capture_lifecycle(); + warn!("Board region {id} completed with unexpected outcome: {unexpected:?}"); + state.input_state.push_toast( + ToastPriority::Critical, + "capture.region.board", + Toast::error("Region was not added to the board."), + ); + return; + } + (outcome, None) => outcome, + }; + // Restore overlay. state.show_overlay(); let exit_after_capture = state.capture.exit_on_success(); @@ -504,6 +601,9 @@ fn handle_capture_results(state: &mut WaylandState) { state.capture.clear_pending_pdf_export(); info!("{} cancelled: {}", operation.saved_log_label(), reason); } + CaptureOutcome::RenderedImageReady(_) => { + unreachable!("rendered images return through the board path above") + } } if should_exit { // Exit-after-capture is intentional teardown. Mark it explicit so XDG @@ -570,6 +670,7 @@ mod tests { for owner in [ ScreenAcquisitionOwner::Eyedropper, ScreenAcquisitionOwner::Ocr, + ScreenAcquisitionOwner::RegionCapture, ] { let modal = record(owner, AcquisitionStage::Queued); @@ -585,15 +686,21 @@ mod tests { #[test] fn started_modal_ignores_same_batch_user_toggle_without_starting_another_capture() { - let modal = record(ScreenAcquisitionOwner::Ocr, AcquisitionStage::Started); + for owner in [ + ScreenAcquisitionOwner::Ocr, + ScreenAcquisitionOwner::RegionCapture, + ] { + let modal = record(owner, AcquisitionStage::Started); - let decision = frozen_toggle_pass_decision(true, Some(modal), false, true); + let decision = frozen_toggle_pass_decision(true, Some(modal), false, true); - assert_eq!( - decision.user_action, - FrozenUserToggleAction::IgnoreInProgress - ); - assert_eq!(decision.queued_to_start, None); + assert_eq!( + decision.user_action, + FrozenUserToggleAction::IgnoreInProgress, + "owner={owner:?}" + ); + assert_eq!(decision.queued_to_start, None, "owner={owner:?}"); + } } #[test] @@ -616,4 +723,32 @@ mod tests { } ); } + + #[test] + fn region_capture_action_is_partitioned_before_the_frozen_drain() { + let mut input = crate::input::state::test_support::make_test_input_state(); + input.set_pending_backend_action(PendingBackendAction::Screenshot( + Action::CaptureClipboardSelection, + )); + assert_eq!( + take_pending_region_capture_action(&mut input), + Some(Action::CaptureClipboardSelection) + ); + assert_eq!(input.take_pending_backend_action(), None); + + input.set_pending_backend_action(PendingBackendAction::Screenshot( + Action::CaptureClipboardFull, + )); + assert_eq!( + take_pending_region_capture_action(&mut input), + None, + "non-region screenshots keep their established drain position" + ); + assert_eq!( + input.take_pending_backend_action(), + Some(PendingBackendAction::Screenshot( + Action::CaptureClipboardFull + )) + ); + } } diff --git a/src/backend/wayland/backend/event_loop/mod.rs b/src/backend/wayland/backend/event_loop/mod.rs index a5bb0460b..1ffff8f34 100644 --- a/src/backend/wayland/backend/event_loop/mod.rs +++ b/src/backend/wayland/backend/event_loop/mod.rs @@ -327,6 +327,7 @@ pub(super) fn run_event_loop( finalize_event_loop( &mut loop_error, || { + state.cancel_region_capture_for_teardown(); if let Err(err) = session_save::persist_session(state) { warn!("Failed to save session state: {}", err); session_save::notify_session_failure(state, &err); diff --git a/src/backend/wayland/capture.rs b/src/backend/wayland/capture.rs index 7012c412b..ccb3ed53c 100644 --- a/src/backend/wayland/capture.rs +++ b/src/backend/wayland/capture.rs @@ -9,8 +9,11 @@ use crate::{ ImageOperationKind, file::FileSaveConfig, }, config::Action, + input::state::BoardPasteTarget, }; +use super::state::RegionCaptureIntent; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum CapturePreflight { None, @@ -49,6 +52,21 @@ pub(in crate::backend::wayland) struct CaptureLayoutContext { layout_generation: u64, } +/// Backend-owned lifecycle for a native region capture. +#[derive(Clone, Debug)] +pub(in crate::backend::wayland) enum RegionCapturePhase { + Idle, + Reserved(RegionCaptureIntent), + Submitting(RegionCaptureIntent), + Accepted, +} + +#[derive(Clone, Debug)] +pub(in crate::backend::wayland) struct PendingBoardPaste { + pub accepted_id: CaptureRequestId, + pub target: BoardPasteTarget, +} + impl CaptureLayoutContext { pub(in crate::backend::wayland) fn new(target_output_id: u32, layout_generation: u64) -> Self { Self { @@ -80,6 +98,8 @@ pub struct CaptureState { preflight: CapturePreflight, pending_request: Option, pending_pdf_export: Option, + region: RegionCapturePhase, + pending_board_paste: Option, } impl CaptureState { @@ -93,6 +113,8 @@ impl CaptureState { preflight: CapturePreflight::None, pending_request: None, pending_pdf_export: None, + region: RegionCapturePhase::Idle, + pending_board_paste: None, } } @@ -157,15 +179,89 @@ impl CaptureState { self.in_progress = true; } + /// Reserve the shared capture lifecycle for a native region picker. + /// + /// The intent is accepted only from a fully idle lifecycle. Once reserved, + /// every other capture path observes `is_in_progress() == true`. + pub(in crate::backend::wayland) fn reserve_region( + &mut self, + intent: RegionCaptureIntent, + ) -> bool { + if self.in_progress || !matches!(&self.region, RegionCapturePhase::Idle) { + return false; + } + + self.in_progress = true; + self.accepted_id = None; + self.region = RegionCapturePhase::Reserved(intent); + true + } + + /// Transfer a reserved native picker to the existing slurp lifecycle. + /// + /// The returned intent is the original immutable snapshot. Region-specific + /// ownership is cleared, while the generic in-progress reservation stays + /// held for suppression, preflight, and manager submission. + pub(in crate::backend::wayland) fn handoff_region_to_legacy( + &mut self, + ) -> Option { + let RegionCapturePhase::Reserved(_) = &self.region else { + return None; + }; + let RegionCapturePhase::Reserved(intent) = + std::mem::replace(&mut self.region, RegionCapturePhase::Idle) + else { + unreachable!("region phase changed after reservation check"); + }; + debug_assert!(self.in_progress); + debug_assert!(self.accepted_id.is_none()); + Some(intent) + } + + /// Start the synchronous crop-to-manager handoff for a reserved picker. + /// + /// A clone is returned for building the submission request; the lifecycle + /// retains its own copy until the manager accepts or terminal cleanup runs. + pub(in crate::backend::wayland) fn begin_region_submission( + &mut self, + ) -> Option { + let RegionCapturePhase::Reserved(intent) = &self.region else { + return None; + }; + let submission_intent = intent.clone(); + let RegionCapturePhase::Reserved(intent) = + std::mem::replace(&mut self.region, RegionCapturePhase::Idle) + else { + unreachable!("region phase changed after reservation check"); + }; + debug_assert!(self.in_progress); + debug_assert!(self.accepted_id.is_none()); + self.region = RegionCapturePhase::Submitting(intent); + Some(submission_intent) + } + + pub(in crate::backend::wayland) fn region_phase(&self) -> &RegionCapturePhase { + &self.region + } + + pub(in crate::backend::wayland) fn active_region_action(&self) -> Option { + match &self.region { + RegionCapturePhase::Reserved(intent) | RegionCapturePhase::Submitting(intent) => { + Some(intent.action()) + } + RegionCapturePhase::Idle | RegionCapturePhase::Accepted => None, + } + } + /// Finishes the current capture lifecycle and clears all reusable state. /// - /// Region-capture and board-delivery lifecycle fields are intentionally - /// absent until their later implementation phases. pub fn finish_capture_lifecycle(&mut self) { self.in_progress = false; self.accepted_id = None; self.exit_on_success = false; self.clear_preflight(); + self.region = RegionCapturePhase::Idle; + self.pending_board_paste = None; } /// Records the manager identity accepted for the current lifecycle. @@ -173,13 +269,26 @@ impl CaptureState { if !self.in_progress || self.accepted_id.is_some() { return false; } + + match &self.region { + RegionCapturePhase::Reserved(_) | RegionCapturePhase::Accepted => return false, + RegionCapturePhase::Idle => {} + RegionCapturePhase::Submitting(_) => { + self.region = RegionCapturePhase::Accepted; + } + } self.accepted_id = Some(id); true } /// Consumes the accepted identity only when the completion matches it. pub fn consume_accepted(&mut self, id: CaptureRequestId) -> bool { - if self.accepted_id != Some(id) { + if self.accepted_id != Some(id) + || matches!( + &self.region, + RegionCapturePhase::Reserved(_) | RegionCapturePhase::Submitting(_) + ) + { return false; } self.accepted_id = None; @@ -190,6 +299,39 @@ impl CaptureState { self.accepted_id } + pub(in crate::backend::wayland) fn set_pending_board_paste( + &mut self, + accepted_id: CaptureRequestId, + target: BoardPasteTarget, + ) -> bool { + if self.accepted_id != Some(accepted_id) + || !matches!(self.region, RegionCapturePhase::Accepted) + || self.pending_board_paste.is_some() + { + return false; + } + self.pending_board_paste = Some(PendingBoardPaste { + accepted_id, + target, + }); + true + } + + pub(in crate::backend::wayland) fn take_pending_board_paste_for( + &mut self, + accepted_id: CaptureRequestId, + ) -> Option { + if self + .pending_board_paste + .as_ref() + .is_some_and(|pending| pending.accepted_id == accepted_id) + { + self.pending_board_paste.take() + } else { + None + } + } + /// Marks whether the current capture should exit the overlay on success. pub fn set_exit_on_success(&mut self, value: bool) { self.exit_on_success = value; @@ -204,7 +346,24 @@ impl CaptureState { #[cfg(test)] mod tests { use super::*; - use crate::capture::types::{CaptureDestination, CaptureType}; + use crate::{ + backend::ExitAfterCaptureMode, + backend::wayland::state::RegionPickerOptions, + capture::types::{CaptureDestination, CaptureType}, + input::state::RegionPurposeTag, + }; + + fn region_intent(action: Action) -> RegionCaptureIntent { + RegionCaptureIntent::new( + action, + RegionPurposeTag::CaptureDeliver, + CaptureDestination::ClipboardOnly, + None, + ExitAfterCaptureMode::Auto, + RegionPickerOptions::new(true, false, true), + true, + ) + } fn screenshot_request() -> CapturePreflightRequest { CapturePreflightRequest::Screenshot(CaptureRequest { @@ -214,6 +373,15 @@ mod tests { }) } + fn board_target() -> BoardPasteTarget { + BoardPasteTarget { + board_id: "transparent".to_string(), + page_index: 0, + page_generation: 1, + world_bounds: crate::util::Rect::new(10, 20, 30, 40).unwrap(), + } + } + #[test] fn preflight_waits_for_render_before_request() { let manager = CaptureManager::with_closed_channel_for_test(); @@ -258,6 +426,27 @@ mod tests { assert!(!state.is_in_progress()); } + #[test] + fn pending_board_paste_is_correlated_and_cleared_by_terminal_cleanup() { + let manager = CaptureManager::with_closed_channel_for_test(); + let mut state = CaptureState::new(manager); + let id = CaptureRequestId::for_test(21); + assert!(state.reserve_region(region_intent(Action::CaptureRegionInteractive))); + assert!(state.begin_region_submission().is_some()); + assert!(state.record_accepted(id)); + assert!(state.set_pending_board_paste(id, board_target())); + assert!( + state + .take_pending_board_paste_for(CaptureRequestId::for_test(22)) + .is_none() + ); + + state.finish_capture_lifecycle(); + + assert!(state.take_pending_board_paste_for(id).is_none()); + assert!(!state.is_in_progress()); + } + #[test] fn lifecycle_finish_cancels_awaiting_render_and_clears_the_pending_request() { let manager = CaptureManager::with_closed_channel_for_test(); @@ -299,6 +488,128 @@ mod tests { assert!(state.take_preflight_request().is_none()); } + #[test] + fn region_reservation_is_exclusive_and_marks_the_generic_lifecycle_busy() { + let manager = CaptureManager::with_closed_channel_for_test(); + let mut state = CaptureState::new(manager); + + assert!(state.reserve_region(region_intent(Action::CaptureSelection))); + assert!(state.is_in_progress()); + assert!(matches!( + state.region_phase(), + RegionCapturePhase::Reserved(intent) + if intent.action() == Action::CaptureSelection + )); + assert!(!state.reserve_region(region_intent(Action::CaptureClipboardSelection))); + + state.finish_capture_lifecycle(); + state.mark_in_progress(); + assert!(!state.reserve_region(region_intent(Action::CaptureClipboardSelection))); + assert!(matches!(state.region_phase(), RegionCapturePhase::Idle)); + } + + #[test] + fn legacy_handoff_returns_the_snapshot_and_keeps_generic_ownership() { + let manager = CaptureManager::with_closed_channel_for_test(); + let mut state = CaptureState::new(manager); + assert!(state.reserve_region(region_intent(Action::CaptureFileRegion))); + + let handed_off = state + .handoff_region_to_legacy() + .expect("reserved intent should hand off"); + + assert_eq!(handed_off.action(), Action::CaptureFileRegion); + assert_eq!(handed_off.purpose(), RegionPurposeTag::CaptureDeliver); + assert!(state.is_in_progress()); + assert_eq!(state.accepted_id(), None); + assert!(matches!(state.region_phase(), RegionCapturePhase::Idle)); + assert!(state.handoff_region_to_legacy().is_none()); + + let legacy_id = CaptureRequestId::for_test(20); + assert!(state.record_accepted(legacy_id)); + assert!(state.consume_accepted(legacy_id)); + } + + #[test] + fn submission_can_begin_only_once_and_preserves_the_intent_until_acceptance() { + let manager = CaptureManager::with_closed_channel_for_test(); + let mut state = CaptureState::new(manager); + assert!(state.reserve_region(region_intent(Action::CaptureClipboardRegion))); + + let submission = state + .begin_region_submission() + .expect("reserved region should begin submission"); + + assert_eq!(submission.action(), Action::CaptureClipboardRegion); + assert!(matches!( + state.region_phase(), + RegionCapturePhase::Submitting(intent) + if intent.action() == Action::CaptureClipboardRegion + )); + assert!(state.begin_region_submission().is_none()); + assert!(state.handoff_region_to_legacy().is_none()); + } + + #[test] + fn region_accepted_identity_requires_the_submitting_phase() { + let manager = CaptureManager::with_closed_channel_for_test(); + let mut state = CaptureState::new(manager); + let accepted = CaptureRequestId::for_test(21); + let other = CaptureRequestId::for_test(22); + assert!(state.reserve_region(region_intent(Action::CaptureSelection))); + + assert!(!state.record_accepted(accepted)); + assert!(matches!( + state.region_phase(), + RegionCapturePhase::Reserved(_) + )); + assert!(state.begin_region_submission().is_some()); + assert!(state.record_accepted(accepted)); + assert!(matches!(state.region_phase(), RegionCapturePhase::Accepted)); + assert_eq!(state.accepted_id(), Some(accepted)); + assert!(!state.record_accepted(other)); + assert!(!state.consume_accepted(other)); + assert_eq!(state.accepted_id(), Some(accepted)); + assert!(state.consume_accepted(accepted)); + assert_eq!(state.accepted_id(), None); + assert!(!state.record_accepted(other)); + } + + #[test] + fn terminal_finish_resets_every_region_phase() { + let manager = CaptureManager::with_closed_channel_for_test(); + let mut state = CaptureState::new(manager); + + assert!(state.reserve_region(region_intent(Action::CaptureSelection))); + state.set_exit_on_success(true); + state.finish_capture_lifecycle(); + assert_region_lifecycle_is_idle(&state); + + assert!(state.reserve_region(region_intent(Action::CaptureFileSelection))); + assert!(state.begin_region_submission().is_some()); + state.set_exit_on_success(true); + state.finish_capture_lifecycle(); + assert_region_lifecycle_is_idle(&state); + + assert!(state.reserve_region(region_intent(Action::CaptureClipboardSelection))); + assert!(state.begin_region_submission().is_some()); + assert!(state.record_accepted(CaptureRequestId::for_test(23))); + state.set_exit_on_success(true); + state.finish_capture_lifecycle(); + assert_region_lifecycle_is_idle(&state); + + // Terminal cleanup remains safe when called again by teardown. + state.finish_capture_lifecycle(); + assert_region_lifecycle_is_idle(&state); + } + + fn assert_region_lifecycle_is_idle(state: &CaptureState) { + assert!(!state.is_in_progress()); + assert_eq!(state.accepted_id(), None); + assert!(!state.exit_on_success()); + assert!(matches!(state.region_phase(), RegionCapturePhase::Idle)); + } + #[test] fn capture_layout_context_rejects_output_or_geometry_generation_changes() { let context = CaptureLayoutContext::new(7, 3); diff --git a/src/backend/wayland/frozen/image.rs b/src/backend/wayland/frozen/image.rs index 2698ca242..9b62d5306 100644 --- a/src/backend/wayland/frozen/image.rs +++ b/src/backend/wayland/frozen/image.rs @@ -1,6 +1,8 @@ use anyhow::{Context, Result}; use wayland_client::protocol::{wl_output, wl_shm}; +pub use crate::screen_pixels::ScreenImage as FrozenImage; + pub(in crate::backend::wayland) struct ShmBufferLayout { pub(in crate::backend::wayland) width: i32, pub(in crate::backend::wayland) height: i32, @@ -48,14 +50,6 @@ pub(in crate::backend::wayland) fn validate_shm_buffer_layout( }) } -/// CPU-side frozen image ready for Cairo rendering. -pub struct FrozenImage { - pub width: u32, - pub height: u32, - pub stride: i32, - pub data: Vec, -} - impl FrozenImage { /// Apply the wl_output transform advertised for the captured output. /// diff --git a/src/backend/wayland/frozen/state.rs b/src/backend/wayland/frozen/state.rs index 0dfba8dd2..816ce2040 100644 --- a/src/backend/wayland/frozen/state.rs +++ b/src/backend/wayland/frozen/state.rs @@ -1,4 +1,5 @@ use log::info; +use std::sync::Arc; use std::time::{Duration, Instant}; use wayland_client::protocol::wl_output; use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1; @@ -121,7 +122,7 @@ pub struct FrozenState { /// portal captures can be rejected after a layout change. pub(super) output_layout_generation: u64, pub(super) direct_capture: Option, - pub(super) image: Option, + pub(super) image: Option>, image_provenance: Option, image_target_dimensions: Option<(u32, u32)>, image_generation: u64, @@ -241,7 +242,11 @@ impl FrozenState { } pub fn image(&self) -> Option<&FrozenImage> { - self.image.as_ref() + self.image.as_deref() + } + + pub(in crate::backend::wayland) fn shared_image(&self) -> Option> { + self.image.clone() } pub fn image_generation(&self) -> u64 { @@ -251,7 +256,7 @@ impl FrozenState { #[cfg(test)] pub fn set_image(&mut self, image: FrozenImage) { self.image_target_dimensions = Some((image.width, image.height)); - self.image = Some(image); + self.image = Some(Arc::new(image)); self.image_provenance = None; self.bump_image_generation(); } @@ -263,7 +268,7 @@ impl FrozenState { provenance: ScreenImageProvenance, ) { self.image_target_dimensions = Some((image.width, image.height)); - self.image = Some(image); + self.image = Some(Arc::new(image)); self.image_provenance = Some(provenance); self.bump_image_generation(); } @@ -488,7 +493,6 @@ impl FrozenState { input_state.needs_redraw = true; } - #[allow(dead_code)] // Used by public modal-owner cancellation adapters. pub(in crate::backend::wayland) fn acquisition_completion( &self, ) -> Option<&ScreenAcquisitionCompletion> { @@ -505,7 +509,6 @@ impl FrozenState { self.acquisition_completion.take() } - #[allow(dead_code)] // Used by public modal-owner cancellation adapters. pub(in crate::backend::wayland) fn take_matching_acquisition_completion( &mut self, id: ScreenAcquisitionId, @@ -669,7 +672,7 @@ impl FrozenState { } self.image_target_dimensions = Some((phys_width, phys_height)); - self.image = Some(image); + self.image = Some(Arc::new(image)); self.image_provenance = Some(provenance); self.bump_image_generation(); input_state.set_frozen_active(true); diff --git a/src/backend/wayland/handlers/keyboard/mod.rs b/src/backend/wayland/handlers/keyboard/mod.rs index e6e0abae0..ece5f3e36 100644 --- a/src/backend/wayland/handlers/keyboard/mod.rs +++ b/src/backend/wayland/handlers/keyboard/mod.rs @@ -90,6 +90,7 @@ impl KeyboardHandler for WaylandState { // and breaking shortcuts/tools, aggressively reset our modifier state on // focus loss. self.input_state.reset_modifiers(); + self.sync_region_square_modifier(false); self.input_state.clear_command_palette_repeat(); self.clear_key_repeat(); self.set_board_pan_key_held(false); @@ -160,17 +161,86 @@ impl KeyboardHandler for WaylandState { // re-arms it at the end of this handler. self.clear_key_repeat(); if self.input_state.region_is_engaged() { + if matches!(key, Key::Space) && self.toggle_region_window_snap() { + self.update_pointer_cursor(false, conn); + return; + } + if let Some(direction) = + region_window_snap_direction(key, self.input_state.modifiers.logo) + && self.navigate_region_window_snap(direction) + { + return; + } + if matches!(key, Key::Return) && self.choose_hovered_region_window() { + return; + } let action = self.input_state.action_for_key(key); - if action.is_some_and(|action| { - self.input_state + if let Some(action) = action { + if self + .input_state .refuse_region_capture_while_screen_modal_engaged(action) - }) { + { + return; + } + if self + .input_state + .refuse_measure_mode_while_screen_modal_engaged(action) + { + return; + } + if action == Action::MeasureMode { + self.handle_measure_mode_action(); + return; + } + if self + .input_state + .capture_region_action_reaches_backend(action) + { + // Route the opening action directly while its picker owns + // the modal. Going through InputState would clear held + // modifiers before the backend can distinguish same-action + // cancellation from a different-action refusal. + self.handle_capture_action(action); + return; + } + if action == Action::CopyTextFromScreen + && self.input_state.region_state().purpose() + == Some(crate::input::state::RegionPurposeTag::Ocr) + { + self.cancel_ocr(); + return; + } + } + if self.input_state.region_state().is_review() + && let Some(review_action) = region_review_key_action( + key, + self.input_state.modifiers.ctrl, + self.input_state.modifiers.shift, + ) + { + match review_action { + RegionReviewKeyAction::Nudge(dx, dy) => { + self.nudge_region_review(dx, dy); + } + RegionReviewKeyAction::Submit(action) => { + self.submit_region_review_action(action); + } + } + return; + } + if region_capture_select_all_pressed( + self.input_state.region_is_active(), + self.input_state.region_state().purpose(), + self.input_state.modifiers.ctrl, + key, + ) { + self.submit_whole_region_capture(); return; } // Every other shortcut is swallowed while the selector is up so a // key cannot change the active tool mid-drag. - if matches!(key, Key::Escape) || action == Some(Action::CopyTextFromScreen) { - self.cancel_ocr(); + if matches!(key, Key::Escape) { + self.cancel_active_region_selector(); } return; } @@ -182,6 +252,12 @@ impl KeyboardHandler for WaylandState { }) { return; } + if action.is_some_and(|action| { + self.input_state + .refuse_measure_mode_while_screen_modal_engaged(action) + }) { + return; + } if matches!(key, Key::Escape) || action == Some(Action::PickScreenColor) { self.cancel_eyedropper(); } @@ -311,6 +387,7 @@ impl KeyboardHandler for WaylandState { modifiers.alt, modifiers.logo, ); + self.sync_region_square_modifier(modifiers.shift); } fn repeat_key( @@ -490,6 +567,66 @@ fn screen_modal_swallows_key_release( region_selector_engaged || eyedropper_engaged } +fn region_capture_select_all_pressed( + region_active: bool, + purpose: Option, + ctrl: bool, + key: Key, +) -> bool { + region_active + && purpose.is_some_and(crate::input::state::RegionPurposeTag::is_capture) + && ctrl + && matches!(key, Key::Char('a' | 'A')) +} + +fn region_window_snap_direction( + key: Key, + logo: bool, +) -> Option { + use crate::backend::wayland::state::WindowSnapDirection; + + if !logo { + return None; + } + match key { + Key::Left => Some(WindowSnapDirection::Left), + Key::Right => Some(WindowSnapDirection::Right), + Key::Up => Some(WindowSnapDirection::Up), + Key::Down => Some(WindowSnapDirection::Down), + _ => None, + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RegionReviewKeyAction { + Nudge(i64, i64), + Submit(crate::ui::RegionAction), +} + +fn region_review_key_action(key: Key, ctrl: bool, shift: bool) -> Option { + let step = if shift { 10 } else { 1 }; + match key { + Key::Left => Some(RegionReviewKeyAction::Nudge(-step, 0)), + Key::Right => Some(RegionReviewKeyAction::Nudge(step, 0)), + Key::Up => Some(RegionReviewKeyAction::Nudge(0, -step)), + Key::Down => Some(RegionReviewKeyAction::Nudge(0, step)), + Key::Char('c' | 'C') if ctrl => { + Some(RegionReviewKeyAction::Submit(crate::ui::RegionAction::Copy)) + } + Key::Char('s' | 'S') if ctrl => { + Some(RegionReviewKeyAction::Submit(crate::ui::RegionAction::Save)) + } + Key::Return => Some(RegionReviewKeyAction::Submit(crate::ui::RegionAction::Both)), + Key::Char('b' | 'B') if !ctrl => Some(RegionReviewKeyAction::Submit( + crate::ui::RegionAction::Board, + )), + Key::Char('d' | 'D') if !ctrl => Some(RegionReviewKeyAction::Submit( + crate::ui::RegionAction::ToggleIncludeDrawings, + )), + _ => None, + } +} + #[cfg(test)] mod tests { use super::*; @@ -528,6 +665,96 @@ mod tests { ); } + #[test] + fn ctrl_a_selects_all_only_for_an_active_capture_picker() { + use crate::input::state::RegionPurposeTag; + + assert!(region_capture_select_all_pressed( + true, + Some(RegionPurposeTag::CaptureDeliver), + true, + Key::Char('a'), + )); + assert!(region_capture_select_all_pressed( + true, + Some(RegionPurposeTag::CaptureInteractive), + true, + Key::Char('A'), + )); + assert!(!region_capture_select_all_pressed( + false, + Some(RegionPurposeTag::CaptureDeliver), + true, + Key::Char('a'), + )); + assert!(!region_capture_select_all_pressed( + true, + Some(RegionPurposeTag::Ocr), + true, + Key::Char('a'), + )); + assert!(!region_capture_select_all_pressed( + true, + Some(RegionPurposeTag::CaptureDeliver), + false, + Key::Char('a'), + )); + } + + #[test] + fn super_arrows_route_window_snap_navigation_only_with_logo_held() { + use crate::backend::wayland::state::WindowSnapDirection; + + assert_eq!( + region_window_snap_direction(Key::Left, true), + Some(WindowSnapDirection::Left) + ); + assert_eq!( + region_window_snap_direction(Key::Down, true), + Some(WindowSnapDirection::Down) + ); + assert_eq!(region_window_snap_direction(Key::Right, false), None); + assert_eq!(region_window_snap_direction(Key::Return, true), None); + } + + #[test] + fn review_keys_map_to_one_shot_pixel_edits_and_typed_destinations() { + assert_eq!( + region_review_key_action(Key::Left, false, false), + Some(RegionReviewKeyAction::Nudge(-1, 0)) + ); + assert_eq!( + region_review_key_action(Key::Down, false, true), + Some(RegionReviewKeyAction::Nudge(0, 10)) + ); + assert_eq!( + region_review_key_action(Key::Char('c'), true, false), + Some(RegionReviewKeyAction::Submit(crate::ui::RegionAction::Copy)) + ); + assert_eq!( + region_review_key_action(Key::Char('s'), true, false), + Some(RegionReviewKeyAction::Submit(crate::ui::RegionAction::Save)) + ); + assert_eq!( + region_review_key_action(Key::Return, false, false), + Some(RegionReviewKeyAction::Submit(crate::ui::RegionAction::Both)) + ); + assert_eq!( + region_review_key_action(Key::Char('b'), false, false), + Some(RegionReviewKeyAction::Submit( + crate::ui::RegionAction::Board + )) + ); + assert_eq!(region_review_key_action(Key::Char('b'), true, false), None); + assert_eq!( + region_review_key_action(Key::Char('d'), false, false), + Some(RegionReviewKeyAction::Submit( + crate::ui::RegionAction::ToggleIncludeDrawings + )) + ); + assert_eq!(region_review_key_action(Key::Char('d'), true, false), None); + } + #[test] fn toolbar_routing_only_allows_activate_and_tab_keys() { assert!(should_try_toolbar_key(Key::Tab, false)); diff --git a/src/backend/wayland/handlers/pointer/cursor.rs b/src/backend/wayland/handlers/pointer/cursor.rs index d6254b7a4..5abd9485d 100644 --- a/src/backend/wayland/handlers/pointer/cursor.rs +++ b/src/backend/wayland/handlers/pointer/cursor.rs @@ -9,6 +9,68 @@ use crate::input::{ DrawingState, HelpOverlayCursorHint, SelectionHandle, }; +/// What the pointer is over on the screen-modal surfaces (the eyedropper and +/// the region picker), resolved by [`WaylandState::screen_modal_cursor_context`] +/// so that [`screen_modal_cursor`] stays a pure decision table. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +struct ScreenModalCursorContext { + window_snap_active: bool, + review: bool, + /// A device owns a Review move-drag right now. + review_dragging: bool, + over_action: bool, + /// Inside the Review bar, whether or not over one of its controls. + over_bar: bool, + over_selection: bool, + /// The grip being dragged, or hovered when nothing is being dragged. + resize_handle: Option, +} + +/// Cursor for the screen-modal surfaces. Targeting keeps the crosshair; a +/// finished selection in Review swaps to ordinary chrome cursors, because +/// nothing is being aimed any more — the pointer now drags the rectangle or +/// presses a button. +fn screen_modal_cursor(context: ScreenModalCursorContext) -> CursorIcon { + if context.window_snap_active { + return CursorIcon::Pointer; + } + if !context.review { + return CursorIcon::Crosshair; + } + // A held grip outranks everything: the pointer can leave both the grip and + // the rectangle mid-drag and the cursor must keep describing the drag. + if let Some(handle) = context.resize_handle.filter(|_| context.review_dragging) { + return resize_cursor(handle); + } + if context.review_dragging { + return CursorIcon::Grabbing; + } + if context.over_action { + return CursorIcon::Pointer; + } + if context.over_bar { + return CursorIcon::Default; + } + if let Some(handle) = context.resize_handle { + return resize_cursor(handle); + } + if context.over_selection { + return CursorIcon::Grab; + } + CursorIcon::Default +} + +/// The one place a resize grip becomes a cursor. Shared by the Review grips +/// and the canvas selection handles so the two can never disagree. +const fn resize_cursor(handle: SelectionHandle) -> CursorIcon { + match handle { + SelectionHandle::TopLeft | SelectionHandle::BottomRight => CursorIcon::NwseResize, + SelectionHandle::TopRight | SelectionHandle::BottomLeft => CursorIcon::NeswResize, + SelectionHandle::Top | SelectionHandle::Bottom => CursorIcon::NsResize, + SelectionHandle::Left | SelectionHandle::Right => CursorIcon::EwResize, + } +} + impl WaylandState { pub(in crate::backend::wayland) fn update_pointer_cursor( &mut self, @@ -36,10 +98,61 @@ impl WaylandState { } } + /// Refresh the cursor around a pointer press or release that touched the + /// screen-modal surfaces. Both dispatches can change the pointer's role + /// with no motion following: a press starts a Review move-drag, submits an + /// action that closes the picker, or right-click-cancels it; a release + /// ends a drag or moves targeting into Review. Scoped to the modal + /// surfaces so ordinary drawing, toolbar and pointer-lock paths keep their + /// existing behaviour. + pub(super) fn refresh_screen_modal_cursor( + &mut self, + modal_before: bool, + on_toolbar: bool, + conn: &Connection, + ) { + if modal_before || self.input_state.screen_modal_is_active() { + self.update_pointer_cursor(on_toolbar || self.pointer_over_toolbar(), conn); + } + } + + /// Gather what the screen-modal cursor decision needs. The hit tests each + /// re-place the Review bar, so they run only once Review is actually + /// engaged. + fn screen_modal_cursor_context(&self) -> ScreenModalCursorContext { + let window_snap_active = self.region_window_snap_active(); + let region_state = self.input_state.region_state(); + if window_snap_active || !region_state.is_review() { + return ScreenModalCursorContext { + window_snap_active, + ..ScreenModalCursorContext::default() + }; + } + let (mouse_x, mouse_y) = self.current_mouse(); + let point = (f64::from(mouse_x), f64::from(mouse_y)); + let dragging = region_state.selection_owner().is_some(); + // While a grip is held its identity comes from the drag, not from + // whatever the pointer currently happens to be over. + let resize_handle = if dragging { + self.region_review_resize_handle() + } else { + self.region_review_handle_at(point) + }; + ScreenModalCursorContext { + window_snap_active, + review: true, + review_dragging: dragging, + over_action: self.region_review_action_at(point).is_some(), + over_bar: self.region_review_bar_contains(point), + over_selection: self.region_review_selection_contains(point), + resize_handle, + } + } + /// Computes the appropriate cursor icon based on current context. fn compute_cursor_icon(&mut self, toolbar_hover: bool) -> CursorIcon { if self.input_state.screen_modal_is_active() && !toolbar_hover { - return CursorIcon::Crosshair; + return screen_modal_cursor(self.screen_modal_cursor_context()); } // Check color picker popup first (takes priority) @@ -179,16 +292,7 @@ impl WaylandState { } // Resizing selection - show appropriate resize cursor DrawingState::ResizingSelection { handle, .. } => { - return match handle { - SelectionHandle::TopLeft | SelectionHandle::BottomRight => { - CursorIcon::NwseResize - } - SelectionHandle::TopRight | SelectionHandle::BottomLeft => { - CursorIcon::NeswResize - } - SelectionHandle::Top | SelectionHandle::Bottom => CursorIcon::NsResize, - SelectionHandle::Left | SelectionHandle::Right => CursorIcon::EwResize, - }; + return resize_cursor(*handle); } // Idle - check for hover contexts DrawingState::Idle => {} @@ -212,12 +316,7 @@ impl WaylandState { // Check if hovering over selection handles let (canvas_x, canvas_y) = self.input_state.canvas_pointer_position(); if let Some(handle) = self.input_state.hit_selection_handle(canvas_x, canvas_y) { - return match handle { - SelectionHandle::TopLeft | SelectionHandle::BottomRight => CursorIcon::NwseResize, - SelectionHandle::TopRight | SelectionHandle::BottomLeft => CursorIcon::NeswResize, - SelectionHandle::Top | SelectionHandle::Bottom => CursorIcon::NsResize, - SelectionHandle::Left | SelectionHandle::Right => CursorIcon::EwResize, - }; + return resize_cursor(handle); } // Check if hovering over text resize handle @@ -261,3 +360,187 @@ impl WaylandState { self.current_pointer_shape = None; } } + +#[cfg(test)] +mod tests { + use super::{ScreenModalCursorContext, screen_modal_cursor}; + use crate::input::SelectionHandle; + use smithay_client_toolkit::seat::pointer::CursorIcon; + + fn review(context: ScreenModalCursorContext) -> ScreenModalCursorContext { + ScreenModalCursorContext { + review: true, + ..context + } + } + + #[test] + fn targeting_keeps_the_crosshair_and_window_mode_takes_the_hand() { + assert_eq!( + screen_modal_cursor(ScreenModalCursorContext::default()), + CursorIcon::Crosshair, + "armed, selecting, measuring and the eyedropper all aim" + ); + assert_eq!( + screen_modal_cursor(ScreenModalCursorContext { + window_snap_active: true, + ..ScreenModalCursorContext::default() + }), + CursorIcon::Pointer, + "window mode picks a target rather than aiming" + ); + } + + #[test] + fn window_mode_outranks_review_state() { + // Window mode replaces the selection wholesale, so any Review hover + // flags left over from a previous rectangle must not leak through. + assert_eq!( + screen_modal_cursor(review(ScreenModalCursorContext { + window_snap_active: true, + over_selection: true, + ..ScreenModalCursorContext::default() + })), + CursorIcon::Pointer + ); + } + + #[test] + fn review_resolves_dragging_then_controls_then_the_rectangle() { + let cases = [ + ( + ScreenModalCursorContext { + review_dragging: true, + over_selection: true, + ..ScreenModalCursorContext::default() + }, + CursorIcon::Grabbing, + "a live move-drag outranks every hover", + ), + ( + ScreenModalCursorContext { + over_action: true, + over_bar: true, + ..ScreenModalCursorContext::default() + }, + CursorIcon::Pointer, + "an action button", + ), + ( + ScreenModalCursorContext { + over_bar: true, + ..ScreenModalCursorContext::default() + }, + CursorIcon::Default, + "a gap inside the bar stays modal-owned but inert", + ), + ( + ScreenModalCursorContext { + over_selection: true, + ..ScreenModalCursorContext::default() + }, + CursorIcon::Grab, + "the rectangle can still be dragged", + ), + ( + ScreenModalCursorContext::default(), + CursorIcon::Default, + "the scrim outside both", + ), + ]; + for (context, expected, what) in cases { + assert_eq!(screen_modal_cursor(review(context)), expected, "{what}"); + } + } + + #[test] + fn each_grip_takes_the_resize_cursor_that_matches_its_axis() { + for (handle, expected) in [ + (SelectionHandle::TopLeft, CursorIcon::NwseResize), + (SelectionHandle::BottomRight, CursorIcon::NwseResize), + (SelectionHandle::TopRight, CursorIcon::NeswResize), + (SelectionHandle::BottomLeft, CursorIcon::NeswResize), + (SelectionHandle::Top, CursorIcon::NsResize), + (SelectionHandle::Bottom, CursorIcon::NsResize), + (SelectionHandle::Left, CursorIcon::EwResize), + (SelectionHandle::Right, CursorIcon::EwResize), + ] { + assert_eq!( + screen_modal_cursor(review(ScreenModalCursorContext { + resize_handle: Some(handle), + // A grip sits on the rectangle's own edge, so hovering one + // always also hovers the rectangle. + over_selection: true, + ..ScreenModalCursorContext::default() + })), + expected, + "{handle:?}" + ); + } + } + + #[test] + fn a_held_grip_keeps_its_resize_cursor_instead_of_the_move_hand() { + // Mid-drag the pointer can be anywhere, including off the rectangle + // entirely, and the cursor must keep describing the resize. + assert_eq!( + screen_modal_cursor(review(ScreenModalCursorContext { + review_dragging: true, + resize_handle: Some(SelectionHandle::Right), + ..ScreenModalCursorContext::default() + })), + CursorIcon::EwResize + ); + assert_eq!( + screen_modal_cursor(review(ScreenModalCursorContext { + review_dragging: true, + resize_handle: None, + over_selection: true, + ..ScreenModalCursorContext::default() + })), + CursorIcon::Grabbing, + "a move-drag still reads as grabbing" + ); + } + + #[test] + fn the_action_bar_outranks_a_grip_it_is_painted_over() { + // A bar clamped over the selection hides the grips underneath it, so + // the cursor must describe what is actually visible. + for context in [ + ScreenModalCursorContext { + over_action: true, + over_bar: true, + resize_handle: Some(SelectionHandle::Bottom), + ..ScreenModalCursorContext::default() + }, + ScreenModalCursorContext { + over_bar: true, + resize_handle: Some(SelectionHandle::Bottom), + ..ScreenModalCursorContext::default() + }, + ] { + let expected = if context.over_action { + CursorIcon::Pointer + } else { + CursorIcon::Default + }; + assert_eq!(screen_modal_cursor(review(context)), expected); + } + } + + #[test] + fn a_button_that_overlaps_the_rectangle_still_reads_as_a_button() { + // The bar can be clamped over a full-screen selection, so the two + // hover flags are not mutually exclusive. + assert_eq!( + screen_modal_cursor(review(ScreenModalCursorContext { + over_action: true, + over_bar: true, + over_selection: true, + ..ScreenModalCursorContext::default() + })), + CursorIcon::Pointer + ); + } +} diff --git a/src/backend/wayland/handlers/pointer/enter_leave.rs b/src/backend/wayland/handlers/pointer/enter_leave.rs index da0395e7e..06649e2b8 100644 --- a/src/backend/wayland/handlers/pointer/enter_leave.rs +++ b/src/backend/wayland/handlers/pointer/enter_leave.rs @@ -80,6 +80,9 @@ impl WaylandState { self.is_move_dragging() ); self.set_pointer_focus(false); + if !on_toolbar { + self.cancel_region_selection_from(RegionInputSource::Pointer); + } if on_toolbar { self.set_pointer_over_toolbar(false); self.toolbar.pointer_leave(&event.surface); diff --git a/src/backend/wayland/handlers/pointer/mod.rs b/src/backend/wayland/handlers/pointer/mod.rs index 82d8e0237..d27a3029e 100644 --- a/src/backend/wayland/handlers/pointer/mod.rs +++ b/src/backend/wayland/handlers/pointer/mod.rs @@ -53,10 +53,14 @@ impl PointerHandler for WaylandState { } PointerEventKind::Press { button, serial, .. } => { self.set_last_activation_serial(Some(serial)); + let modal_before = self.input_state.screen_modal_is_active(); self.handle_pointer_press(conn, qh, event, on_toolbar, inline_active, button); + self.refresh_screen_modal_cursor(modal_before, on_toolbar, conn); } PointerEventKind::Release { button, .. } => { + let modal_before = self.input_state.screen_modal_is_active(); self.handle_pointer_release(event, on_toolbar, inline_active, button); + self.refresh_screen_modal_cursor(modal_before, on_toolbar, conn); } PointerEventKind::Axis { vertical, .. } => { self.handle_pointer_axis(event, on_toolbar, vertical); diff --git a/src/backend/wayland/handlers/pointer/press.rs b/src/backend/wayland/handlers/pointer/press.rs index 4ba2417a5..2783d2091 100644 --- a/src/backend/wayland/handlers/pointer/press.rs +++ b/src/backend/wayland/handlers/pointer/press.rs @@ -11,6 +11,10 @@ use crate::ui::toolbar::ToolbarEvent; use super::*; +fn review_action_suppresses_next_release(action: crate::ui::RegionAction) -> bool { + action != crate::ui::RegionAction::ToggleIncludeDrawings +} + impl WaylandState { pub(super) fn handle_pointer_press( &mut self, @@ -42,10 +46,24 @@ impl WaylandState { if on_toolbar || self.pointer_over_toolbar() { // A toolbar interaction ends the region first, then runs // normally; the click never lands on the selector. - self.cancel_ocr_for_toolbar_interaction(); + self.cancel_region_for_toolbar_interaction(); } else { match button { BTN_LEFT => { + if let Some(action) = self.region_review_action_at(event.position) { + self.submit_region_review_action(action); + // The toggle keeps Review active, so its release is + // consumed by the active-region branch. Arming the + // generic post-modal latch here would swallow an + // unrelated canvas release after a keyboard exit. + if review_action_suppresses_next_release(action) { + self.suppress_next_release_from(RegionInputSource::Pointer); + } + return; + } + if self.region_review_bar_contains(event.position) { + return; + } self.begin_region_selection( RegionInputSource::Pointer, event.position.0, @@ -53,8 +71,8 @@ impl WaylandState { ); } BTN_RIGHT => { - self.cancel_ocr(); - self.set_suppress_next_release(true); + self.cancel_active_region_selector(); + self.suppress_next_release_from(RegionInputSource::Pointer); } _ => {} } @@ -69,11 +87,11 @@ impl WaylandState { match button { BTN_LEFT => { self.sample_eyedropper(event.position.0, event.position.1); - self.set_suppress_next_release(true); + self.suppress_next_release_from(RegionInputSource::Pointer); } BTN_RIGHT => { self.cancel_eyedropper(); - self.set_suppress_next_release(true); + self.suppress_next_release_from(RegionInputSource::Pointer); } _ => {} } @@ -125,7 +143,7 @@ impl WaylandState { screen_width, screen_height, ) { - self.set_suppress_next_release(true); + self.suppress_next_release_from(RegionInputSource::Pointer); } } return; @@ -384,3 +402,24 @@ fn input_hud_button_label(button: u32) -> String { .unwrap_or_else(|| format!("Button {other}")), } } + +#[cfg(test)] +mod tests { + use super::review_action_suppresses_next_release; + use crate::ui::RegionAction; + + #[test] + fn retained_review_toggle_does_not_arm_the_post_modal_release_latch() { + assert!(!review_action_suppresses_next_release( + RegionAction::ToggleIncludeDrawings + )); + for terminal in [ + RegionAction::Copy, + RegionAction::Save, + RegionAction::Both, + RegionAction::Board, + ] { + assert!(review_action_suppresses_next_release(terminal)); + } + } +} diff --git a/src/backend/wayland/handlers/pointer/release.rs b/src/backend/wayland/handlers/pointer/release.rs index e6e698476..eedd8311f 100644 --- a/src/backend/wayland/handlers/pointer/release.rs +++ b/src/backend/wayland/handlers/pointer/release.rs @@ -25,6 +25,11 @@ impl WaylandState { if self.input_state.region_is_active() { if button == BTN_LEFT { + if self + .take_suppressed_release_from(crate::input::state::RegionInputSource::Pointer) + { + return; + } let screen_position = if on_toolbar { self.toolbar_surface_screen_coords(&event.surface, event.position) } else { @@ -42,7 +47,7 @@ impl WaylandState { } // Swallow releases after modal clicks (e.g., palette dismiss) - if self.take_suppress_next_release() { + if self.take_suppressed_release_from(crate::input::state::RegionInputSource::Pointer) { self.set_pending_toast_press(None); self.set_pending_status_hud_press(false); self.set_pending_zoom_chip_press(ZoomChipPress::None); diff --git a/src/backend/wayland/handlers/seat.rs b/src/backend/wayland/handlers/seat.rs index b4c3f3b91..1823d0724 100644 --- a/src/backend/wayland/handlers/seat.rs +++ b/src/backend/wayland/handlers/seat.rs @@ -4,6 +4,7 @@ use smithay_client_toolkit::seat::{Capability, SeatHandler, SeatState, pointer:: use wayland_client::{Connection, QueueHandle, protocol::wl_seat}; use super::super::state::WaylandState; +use crate::input::RegionInputSource; impl SeatHandler for WaylandState { fn seat_state(&mut self) -> &mut SeatState { @@ -105,6 +106,7 @@ impl SeatHandler for WaylandState { } if capability == Capability::Pointer { info!("Pointer capability removed"); + self.cancel_region_selection_from(RegionInputSource::Pointer); self.themed_pointer = None; self.current_pointer_shape = None; self.cursor_hidden = false; diff --git a/src/backend/wayland/handlers/tablet/tool.rs b/src/backend/wayland/handlers/tablet/tool.rs index 09a5a6c0f..9caabfbac 100644 --- a/src/backend/wayland/handlers/tablet/tool.rs +++ b/src/backend/wayland/handlers/tablet/tool.rs @@ -245,9 +245,18 @@ impl Dispatch for WaylandState { } if state.input_state.region_is_active() { if state.stylus_on_toolbar { - state.cancel_ocr_for_toolbar_interaction(); + state.cancel_region_for_toolbar_interaction(); } else if state.stylus_on_overlay { let (x, y) = state.current_or_pending_stylus_position(); + if let Some(action) = state.region_review_action_at((x, y)) { + state.submit_region_review_action(action); + state.retire_stylus_contact(); + return; + } + if state.region_review_bar_contains((x, y)) { + state.retire_stylus_contact(); + return; + } state.begin_region_selection(RegionInputSource::Stylus, x, y); return; } diff --git a/src/backend/wayland/handlers/touch.rs b/src/backend/wayland/handlers/touch.rs index 569d12148..8174d4ff8 100644 --- a/src/backend/wayland/handlers/touch.rs +++ b/src/backend/wayland/handlers/touch.rs @@ -122,7 +122,7 @@ impl WaylandState { self.set_pending_toast_press(None); self.set_pending_status_hud_press(false); self.set_pending_zoom_chip_press(ZoomChipPress::None); - self.set_suppress_next_release(false); + self.clear_suppressed_release_from(RegionInputSource::Touch); self.input_state .clear_help_overlay_press_for(HelpOverlayPressSource::Touch); @@ -215,8 +215,15 @@ impl WaylandState { && inline_active && self.inline_toolbar_motion(screen_position); if target == TouchTarget::Toolbar || inline_hit { - self.cancel_ocr_for_toolbar_interaction(); + self.cancel_region_for_toolbar_interaction(); } else if target == TouchTarget::Overlay { + if let Some(action) = self.region_review_action_at(screen_position) { + self.submit_region_review_action(action); + return TouchTarget::Other; + } + if self.region_review_bar_contains(screen_position) { + return TouchTarget::Other; + } self.begin_region_selection( RegionInputSource::Touch, screen_position.0, @@ -267,7 +274,7 @@ impl WaylandState { screen_width, screen_height, ) { - self.set_suppress_next_release(true); + self.suppress_next_release_from(RegionInputSource::Touch); } return TouchTarget::Other; } @@ -449,6 +456,9 @@ impl WaylandState { target: TouchTarget, ) { if self.input_state.region_is_active() { + if self.take_suppressed_release_from(RegionInputSource::Touch) { + return; + } if let Some((x, y)) = self.touch_screen_position(surface, position, target) { self.finish_region_selection(RegionInputSource::Touch, x, y); } else { @@ -457,7 +467,7 @@ impl WaylandState { return; } - if self.take_suppress_next_release() { + if self.take_suppressed_release_from(RegionInputSource::Touch) { self.set_pending_toast_press(None); self.set_pending_status_hud_press(false); self.set_pending_zoom_chip_press(ZoomChipPress::None); diff --git a/src/backend/wayland/state.rs b/src/backend/wayland/state.rs index 2e0cbfce0..c620c2ab4 100644 --- a/src/backend/wayland/state.rs +++ b/src/backend/wayland/state.rs @@ -73,6 +73,7 @@ use self::data::{MoveDrag, StateData}; pub use self::data::{ MoveDragKind, OverlaySuppression, OverlaySuppressionKeyboardPolicy, XdgFrozenFullscreenState, }; +pub(in crate::backend::wayland) use self::region_capture::WindowSnapDirection; use super::{ RuntimeOperationController, RuntimeOperationIdSource, capture::{CapturePreflightRequest, CaptureState, PendingPdfExport}, @@ -114,6 +115,9 @@ mod onboarding; mod pdf_export; mod perf; mod region_capture; +pub(in crate::backend::wayland) use region_capture::RegionCaptureIntent; +#[cfg(test)] +pub(in crate::backend::wayland) use region_capture::RegionPickerOptions; mod render; mod screen_image; mod text_clipboard; @@ -267,6 +271,16 @@ pub(super) struct WaylandState { /// Async wl-paste pipeline for text-editor paste requests (Ctrl+V). pub(super) clipboard_text_paste: RuntimeOperationController, String>>, + /// Capacity-one compositor window query for the current native region picker. + /// Its context owns the picker/source correlation, so stale workers cannot + /// mutate a later picker generation. + pub(super) window_query: RuntimeOperationController< + region_capture::WindowSnapQuery, + Result< + crate::capture::window_geometry::WindowQueryResult, + crate::capture::window_geometry::WindowGeometryError, + >, + >, /// Text paste requests waiting behind an active read. Repeated requests in /// the current edit generation remain distinct; a new generation replaces /// stale queued requests from the old edit session. diff --git a/src/backend/wayland/state/acquisition.rs b/src/backend/wayland/state/acquisition.rs index f42e175e6..0d477db4d 100644 --- a/src/backend/wayland/state/acquisition.rs +++ b/src/backend/wayland/state/acquisition.rs @@ -10,8 +10,8 @@ use crate::backend::wayland::zoom::{ use crate::input::state::{EyedropperCaptureSource, ScreenCaptureSource}; use transaction::{ - AcquisitionTransactionRuntime, cancel_modal_owner_resources, report_inconsistent_capture_to, - report_screen_terminal_to, route_acquisition_transaction, + AcquisitionTransactionRuntime, cancel_modal_owner_resources, release_owned_generation, + report_inconsistent_capture_to, report_screen_terminal_to, route_acquisition_transaction, }; pub(super) use transaction::{ report_screen_source_activation_rejected_to, report_zoom_terminal_to, @@ -60,6 +60,14 @@ impl AcquisitionTransactionRuntime for WaylandState { self.finish_pending_ocr_capture(ScreenCaptureSource::Frozen, installed_generation) } + fn finish_region_capture_ready(&mut self, installed_generation: u64) -> bool { + self.finish_pending_region_capture(ScreenCaptureSource::Frozen, installed_generation) + } + + fn handoff_region_capture_to_legacy(&mut self) { + WaylandState::handoff_region_capture_to_legacy(self); + } + fn cancel_owner_ui(&mut self, owner: ScreenAcquisitionOwner) { WaylandState::cancel_owner_ui_only(self, owner); } @@ -163,7 +171,10 @@ impl WaylandState { ScreenCaptureSource::Zoom, installed_generation, ), - ZoomWaiterOwner::RegionCapture => true, + ZoomWaiterOwner::RegionCapture => self.finish_pending_region_capture( + ScreenCaptureSource::Zoom, + installed_generation, + ), }; if !activated { self.cancel_zoom_owner_ui_only(waiter.owner); @@ -198,7 +209,19 @@ impl WaylandState { super::region_capture::ActiveScreenRegion::PendingZoom { .. } ) }), - ZoomWaiterOwner::RegionCapture => false, + ZoomWaiterOwner::RegionCapture => { + self.data.active_screen_region.is_some_and(|region| { + region.purpose().is_capture() + && matches!( + region, + super::region_capture::ActiveScreenRegion::PendingZoom { .. } + ) + && matches!( + self.capture.region_phase(), + crate::backend::wayland::capture::RegionCapturePhase::Reserved(_) + ) + }) + } }; if !waiting { return false; @@ -227,7 +250,7 @@ impl WaylandState { match owner { ZoomWaiterOwner::Eyedropper => self.cancel_eyedropper_ui_only(), ZoomWaiterOwner::Ocr => self.cancel_ocr_ui_only(), - ZoomWaiterOwner::RegionCapture => {} + ZoomWaiterOwner::RegionCapture => self.cancel_region_capture_ui_and_lifecycle(), } } @@ -297,7 +320,16 @@ impl WaylandState { region.purpose() == crate::input::state::RegionPurposeTag::Ocr && region.waits_for_acquisition(completion.id) }), - ScreenAcquisitionOwner::RegionCapture => false, + ScreenAcquisitionOwner::RegionCapture => { + self.data.active_screen_region.is_some_and(|region| { + region.purpose().is_capture() + && region.waits_for_acquisition(completion.id) + && matches!( + self.capture.region_phase(), + crate::backend::wayland::capture::RegionCapturePhase::Reserved(_) + ) + }) + } }; if !waiting { return false; @@ -328,7 +360,15 @@ impl WaylandState { ) .is_some() } - ScreenAcquisitionOwner::RegionCapture => false, + ScreenAcquisitionOwner::RegionCapture => { + self.frozen.image_generation() == installed_generation + && displayed_screen_image( + &self.zoom, + &self.frozen, + self.input_state.board_is_transparent(), + ) + .is_some() + } } } @@ -354,9 +394,7 @@ impl WaylandState { ScreenAcquisitionOwner::UserFreeze => {} ScreenAcquisitionOwner::Eyedropper => self.cancel_eyedropper_ui_only(), ScreenAcquisitionOwner::Ocr => self.cancel_ocr_ui_only(), - ScreenAcquisitionOwner::RegionCapture => { - // Phase 1 supplies the region UI-only teardown. - } + ScreenAcquisitionOwner::RegionCapture => self.cancel_region_capture_ui_and_lifecycle(), } } @@ -368,4 +406,8 @@ impl WaylandState { ) { cancel_modal_owner_resources(self, owner, pending_acquisition, owned_generation); } + + pub(super) fn release_owned_frozen_generation(&mut self, generation: u64) -> bool { + release_owned_generation(self, generation) + } } diff --git a/src/backend/wayland/state/acquisition/transaction.rs b/src/backend/wayland/state/acquisition/transaction.rs index 22e81bb05..12d76bbb7 100644 --- a/src/backend/wayland/state/acquisition/transaction.rs +++ b/src/backend/wayland/state/acquisition/transaction.rs @@ -60,7 +60,17 @@ fn zoom_terminal_report( match owner? { ZoomWaiterOwner::Eyedropper => Some(("eyedropper", "Screen eyedropper capture failed.")), ZoomWaiterOwner::Ocr => Some(("ocr", "Screen capture for text recognition failed.")), - ZoomWaiterOwner::RegionCapture => None, + ZoomWaiterOwner::RegionCapture + if matches!( + outcome, + ZoomSourceOutcome::Aborted | ZoomSourceOutcome::Deactivated + ) => + { + None + } + ZoomWaiterOwner::RegionCapture => { + Some(("capture", "Screen capture for region selection failed.")) + } } } @@ -80,6 +90,17 @@ pub(super) fn report_screen_terminal_to( owner: ScreenAcquisitionOwner, outcome: &ScreenAcquisitionOutcome, ) { + if owner == ScreenAcquisitionOwner::RegionCapture + && matches!( + outcome, + ScreenAcquisitionOutcome::Ready { .. } + | ScreenAcquisitionOutcome::Cancelled + | ScreenAcquisitionOutcome::Unavailable + | ScreenAcquisitionOutcome::Failed(_) + ) + { + return; + } if owner == ScreenAcquisitionOwner::UserFreeze && matches!(outcome, ScreenAcquisitionOutcome::Unavailable) { @@ -133,6 +154,8 @@ pub(super) trait AcquisitionTransactionRuntime { fn input_state(&mut self) -> &mut crate::input::InputState; fn finish_eyedropper_ready(&mut self, installed_generation: u64) -> bool; fn finish_ocr_ready(&mut self, installed_generation: u64) -> bool; + fn finish_region_capture_ready(&mut self, installed_generation: u64) -> bool; + fn handoff_region_capture_to_legacy(&mut self); fn cancel_owner_ui(&mut self, owner: ScreenAcquisitionOwner); fn clear_zoom_waiter_effect(&mut self, owner: ZoomWaiterOwner); fn frozen_generation(&self) -> u64; @@ -151,7 +174,7 @@ pub(super) trait AcquisitionTransactionRuntime { fn end_frozen_suppression(&mut self); } -fn release_owned_generation( +pub(super) fn release_owned_generation( runtime: &mut R, generation: u64, ) -> bool { @@ -183,7 +206,10 @@ fn cancel_owner_transition( ScreenAcquisitionOwner::Ocr => { runtime.clear_zoom_waiter_effect(ZoomWaiterOwner::Ocr); } - ScreenAcquisitionOwner::UserFreeze | ScreenAcquisitionOwner::RegionCapture => {} + ScreenAcquisitionOwner::RegionCapture => { + runtime.clear_zoom_waiter_effect(ZoomWaiterOwner::RegionCapture); + } + ScreenAcquisitionOwner::UserFreeze => {} } runtime.cancel_owner_ui(owner); } @@ -278,11 +304,36 @@ pub(super) fn route_acquisition_transaction( ); } } - (ScreenAcquisitionOwner::Eyedropper | ScreenAcquisitionOwner::Ocr, _) => { - cancel_owner_transition(runtime, completion.owner); + ( + ScreenAcquisitionOwner::RegionCapture, + ScreenAcquisitionOutcome::Ready { + installed_generation, + }, + ) => { + if !runtime.finish_region_capture_ready(installed_generation) { + cancel_modal_owner_resources( + runtime, + ScreenAcquisitionOwner::RegionCapture, + None, + Some(installed_generation), + ); + report_screen_source_activation_rejected_to( + runtime.input_state(), + ScreenAcquisitionOwner::RegionCapture, + ); + } } - (ScreenAcquisitionOwner::RegionCapture, _) => { - // Phase 1 supplies the capture-purpose transition and legacy handoff. + ( + ScreenAcquisitionOwner::RegionCapture, + ScreenAcquisitionOutcome::Unavailable | ScreenAcquisitionOutcome::Failed(_), + ) => runtime.handoff_region_capture_to_legacy(), + ( + ScreenAcquisitionOwner::Eyedropper + | ScreenAcquisitionOwner::Ocr + | ScreenAcquisitionOwner::RegionCapture, + _, + ) => { + cancel_owner_transition(runtime, completion.owner); } } } diff --git a/src/backend/wayland/state/acquisition/transaction/tests.rs b/src/backend/wayland/state/acquisition/transaction/tests.rs index fe5eb46b7..8640ef75b 100644 --- a/src/backend/wayland/state/acquisition/transaction/tests.rs +++ b/src/backend/wayland/state/acquisition/transaction/tests.rs @@ -8,6 +8,8 @@ struct TransactionRuntime { input_state: crate::input::InputState, finished_eyedropper: Vec, finished_ocr: Vec, + finished_region_capture: Vec, + region_legacy_handoffs: usize, ready_activation_succeeds: bool, cancelled_owners: Vec, cleared_zoom_waiters: Vec, @@ -33,6 +35,8 @@ impl TransactionRuntime { input_state: make_test_input_state(), finished_eyedropper: Vec::new(), finished_ocr: Vec::new(), + finished_region_capture: Vec::new(), + region_legacy_handoffs: 0, ready_activation_succeeds: true, cancelled_owners: Vec::new(), cleared_zoom_waiters: Vec::new(), @@ -96,6 +100,17 @@ impl AcquisitionTransactionRuntime for TransactionRuntime { self.ready_activation_succeeds } + fn finish_region_capture_ready(&mut self, installed_generation: u64) -> bool { + if self.ready_activation_succeeds { + self.finished_region_capture.push(installed_generation); + } + self.ready_activation_succeeds + } + + fn handoff_region_capture_to_legacy(&mut self) { + self.region_legacy_handoffs += 1; + } + fn cancel_owner_ui(&mut self, owner: ScreenAcquisitionOwner) { self.cancelled_owners.push(owner); match owner { @@ -103,7 +118,8 @@ impl AcquisitionTransactionRuntime for TransactionRuntime { let _ = self.input_state.cancel_eyedropper(); } ScreenAcquisitionOwner::Ocr => self.input_state.cancel_region_ui_only(), - ScreenAcquisitionOwner::UserFreeze | ScreenAcquisitionOwner::RegionCapture => {} + ScreenAcquisitionOwner::RegionCapture => self.input_state.cancel_region_ui_only(), + ScreenAcquisitionOwner::UserFreeze => {} } } @@ -196,10 +212,40 @@ fn zoom_owner_outcome_reporting_matrix_is_typed() { } } for (outcome, _) in &outcomes { + let reports = matches!(outcome, ZoomSourceOutcome::Failed(_)); assert_eq!( zoom_terminal_report( Some(ZoomWaiterOwner::RegionCapture), &ZoomSourceTerminal::for_test(outcome.clone(), None), + ) + .is_some(), + reports, + "owner=RegionCapture outcome={outcome:?}" + ); + } +} + +#[test] +fn region_capture_zoom_failure_has_a_typed_fallback() { + assert_eq!( + zoom_terminal_report( + Some(ZoomWaiterOwner::RegionCapture), + &ZoomSourceTerminal::for_test( + ZoomSourceOutcome::Failed("backend failed".to_string()), + None, + ), + ), + Some(("capture", "Screen capture for region selection failed.")) + ); +} + +#[test] +fn region_capture_zoom_abort_and_deactivation_cancel_quietly() { + for outcome in [ZoomSourceOutcome::Aborted, ZoomSourceOutcome::Deactivated] { + assert_eq!( + zoom_terminal_report( + Some(ZoomWaiterOwner::RegionCapture), + &ZoomSourceTerminal::for_test(outcome, None), ), None ); @@ -216,7 +262,11 @@ fn specific_zoom_failure_report_replaces_the_owner_fallback() { }), ); - for owner in [Some(ZoomWaiterOwner::Ocr), None] { + for owner in [ + Some(ZoomWaiterOwner::Ocr), + Some(ZoomWaiterOwner::RegionCapture), + None, + ] { let mut input_state = make_test_input_state(); report_zoom_terminal_to(&mut input_state, owner, &terminal); @@ -282,7 +332,8 @@ fn screen_terminal_reporting_is_exactly_once_for_the_full_owner_outcome_matrix() ), ( ScreenAcquisitionOutcome::Failed("specific backend failure".to_string()), - Some((key, "specific backend failure")), + (owner != ScreenAcquisitionOwner::RegionCapture) + .then_some((key, "specific backend failure")), ), ]; @@ -316,6 +367,7 @@ fn matched_ready_completions_run_the_owner_production_transactions() { ScreenAcquisitionOwner::UserFreeze, ScreenAcquisitionOwner::Eyedropper, ScreenAcquisitionOwner::Ocr, + ScreenAcquisitionOwner::RegionCapture, ] { let mut runtime = TransactionRuntime::started(owner); if owner == ScreenAcquisitionOwner::Ocr { @@ -353,6 +405,15 @@ fn matched_ready_completions_run_the_owner_production_transactions() { }, "owner={owner:?}" ); + assert_eq!( + runtime.finished_region_capture, + if owner == ScreenAcquisitionOwner::RegionCapture { + vec![7] + } else { + Vec::new() + }, + "owner={owner:?}" + ); assert_eq!(runtime.input_state.test_toast_count(), 0); assert_eq!(runtime.restore_count, 0); assert_eq!(runtime.unfreeze_count, 0); @@ -364,6 +425,7 @@ fn ready_activation_rejection_releases_the_modal_owned_freeze_once() { for owner in [ ScreenAcquisitionOwner::Eyedropper, ScreenAcquisitionOwner::Ocr, + ScreenAcquisitionOwner::RegionCapture, ] { let mut runtime = TransactionRuntime::started(owner); runtime.ready_activation_succeeds = false; @@ -376,9 +438,14 @@ fn ready_activation_rejection_releases_the_modal_owned_freeze_once() { 1, ScreenCaptureSource::Frozen, ), - ScreenAcquisitionOwner::UserFreeze | ScreenAcquisitionOwner::RegionCapture => { - unreachable!() + ScreenAcquisitionOwner::RegionCapture => { + runtime.input_state.set_region_pending_capture( + crate::input::state::RegionPurposeTag::CaptureDeliver, + 1, + ScreenCaptureSource::Frozen, + ) } + ScreenAcquisitionOwner::UserFreeze => unreachable!(), } assert!(runtime.input_state.screen_modal_is_engaged()); let id = runtime.record().expect("started record").id; @@ -460,6 +527,51 @@ fn matched_nonready_completions_report_then_cancel_the_owner_ui() { } } +#[test] +fn region_capture_nonready_completion_falls_back_only_for_acquisition_failure() { + for (outcome, expected_handoffs, expected_cancellations, expected_toasts) in [ + (ScreenAcquisitionOutcome::Cancelled, 0, 1, 0), + (ScreenAcquisitionOutcome::Unavailable, 1, 0, 0), + ( + ScreenAcquisitionOutcome::Failed("activation rejected".to_string()), + 1, + 0, + 0, + ), + (ScreenAcquisitionOutcome::StaleLayout, 0, 1, 1), + ] { + let mut runtime = TransactionRuntime::started(ScreenAcquisitionOwner::RegionCapture); + let id = runtime.record().expect("started record").id; + + route_acquisition_transaction( + &mut runtime, + ScreenAcquisitionCompletion { + id, + owner: ScreenAcquisitionOwner::RegionCapture, + outcome: outcome.clone(), + }, + ); + + assert_eq!(runtime.record(), None, "outcome={outcome:?}"); + assert_eq!( + runtime.region_legacy_handoffs, expected_handoffs, + "outcome={outcome:?}" + ); + assert_eq!( + runtime.cancelled_owners.len(), + expected_cancellations, + "outcome={outcome:?}" + ); + assert_eq!( + runtime.input_state.test_toast_count(), + expected_toasts, + "outcome={outcome:?}" + ); + assert_eq!(runtime.restore_count, 0, "outcome={outcome:?}"); + assert_eq!(runtime.unfreeze_count, 0, "outcome={outcome:?}"); + } +} + #[test] fn id_owner_waiter_and_empty_slot_mismatches_fail_closed_once() { let cases = ["id", "owner", "waiter", "empty"]; @@ -518,82 +630,82 @@ fn id_owner_waiter_and_empty_slot_mismatches_fail_closed_once() { #[test] fn activation_terminal_then_same_batch_cancel_releases_once_and_cleans_up() { - let mut runtime = TransactionRuntime::started(ScreenAcquisitionOwner::Ocr); - let id = runtime.record().expect("started record").id; - runtime.frozen_completion = Some(ScreenAcquisitionCompletion { - id, - owner: ScreenAcquisitionOwner::Ocr, - outcome: ScreenAcquisitionOutcome::Ready { - installed_generation: 7, - }, - }); - runtime.capture_done = true; - - assert!(cancel_acquisition_transaction( - &mut runtime, - id, + for owner in [ ScreenAcquisitionOwner::Ocr, - )); + ScreenAcquisitionOwner::RegionCapture, + ] { + let mut runtime = TransactionRuntime::started(owner); + let id = runtime.record().expect("started record").id; + runtime.frozen_completion = Some(ScreenAcquisitionCompletion { + id, + owner, + outcome: ScreenAcquisitionOutcome::Ready { + installed_generation: 7, + }, + }); + runtime.capture_done = true; - assert_eq!(runtime.record(), None); - assert_eq!(runtime.frozen_completion, None); - assert!(!runtime.capture_done); - assert!(!runtime.frozen_active); - assert_eq!(runtime.restore_count, 1); - assert_eq!(runtime.unfreeze_count, 1); - assert!(!runtime.frozen_suppressed); - assert_eq!(runtime.suppression_end_count, 1); - assert_eq!(runtime.input_state.test_toast_count(), 0); + assert!(cancel_acquisition_transaction(&mut runtime, id, owner)); - assert!(!cancel_acquisition_transaction( - &mut runtime, - id, - ScreenAcquisitionOwner::Ocr, - )); - assert_eq!(runtime.restore_count, 1); - assert_eq!(runtime.unfreeze_count, 1); - assert_eq!(runtime.suppression_end_count, 1); + assert_eq!(runtime.record(), None, "owner={owner:?}"); + assert_eq!(runtime.frozen_completion, None, "owner={owner:?}"); + assert!(!runtime.capture_done, "owner={owner:?}"); + assert!(!runtime.frozen_active, "owner={owner:?}"); + assert_eq!(runtime.restore_count, 1, "owner={owner:?}"); + assert_eq!(runtime.unfreeze_count, 1, "owner={owner:?}"); + assert!(!runtime.frozen_suppressed, "owner={owner:?}"); + assert_eq!(runtime.suppression_end_count, 1, "owner={owner:?}"); + assert_eq!(runtime.input_state.test_toast_count(), 0, "owner={owner:?}"); + + assert!(!cancel_acquisition_transaction(&mut runtime, id, owner)); + assert_eq!(runtime.restore_count, 1, "owner={owner:?}"); + assert_eq!(runtime.unfreeze_count, 1, "owner={owner:?}"); + assert_eq!(runtime.suppression_end_count, 1, "owner={owner:?}"); + } } #[test] fn started_and_queued_cancellation_have_exact_resource_postconditions() { - let mut started = TransactionRuntime::started(ScreenAcquisitionOwner::Eyedropper); - let started_id = started.record().expect("started record").id; - started.capture_done = true; - - assert!(cancel_acquisition_transaction( - &mut started, - started_id, + for owner in [ ScreenAcquisitionOwner::Eyedropper, - )); - assert_eq!(started.record(), None); - assert_eq!(started.restore_count, 1); - assert_eq!(started.abandon_count, 1); - assert_eq!(started.unfreeze_count, 0); - assert!(!started.capture_done); - assert!(!started.frozen_suppressed); - assert_eq!(started.suppression_end_count, 1); - assert_eq!(started.input_state.test_toast_count(), 0); - - let mut queued = TransactionRuntime::started(ScreenAcquisitionOwner::Ocr); - let queued_record = queued.registry.take().expect("started record"); - let queued_id = queued - .registry - .request(ScreenAcquisitionOwner::Ocr) - .expect("queued replacement"); - assert_ne!(queued_record.id, queued_id); - queued.frozen_suppressed = false; - - assert!(cancel_acquisition_transaction( - &mut queued, - queued_id, ScreenAcquisitionOwner::Ocr, - )); - assert_eq!(queued.record(), None); - assert_eq!(queued.restore_count, 0); - assert_eq!(queued.abandon_count, 0); - assert_eq!(queued.unfreeze_count, 0); - assert_eq!(queued.suppression_end_count, 0); + ScreenAcquisitionOwner::RegionCapture, + ] { + let mut started = TransactionRuntime::started(owner); + let started_id = started.record().expect("started record").id; + started.capture_done = true; + + assert!(cancel_acquisition_transaction( + &mut started, + started_id, + owner, + )); + assert_eq!(started.record(), None, "owner={owner:?}"); + assert_eq!(started.restore_count, 1, "owner={owner:?}"); + assert_eq!(started.abandon_count, 1, "owner={owner:?}"); + assert_eq!(started.unfreeze_count, 0, "owner={owner:?}"); + assert!(!started.capture_done, "owner={owner:?}"); + assert!(!started.frozen_suppressed, "owner={owner:?}"); + assert_eq!(started.suppression_end_count, 1, "owner={owner:?}"); + assert_eq!(started.input_state.test_toast_count(), 0, "owner={owner:?}"); + + let mut queued = TransactionRuntime::started(owner); + let queued_record = queued.registry.take().expect("started record"); + let queued_id = queued.registry.request(owner).expect("queued replacement"); + assert_ne!(queued_record.id, queued_id); + queued.frozen_suppressed = false; + + assert!(cancel_acquisition_transaction( + &mut queued, + queued_id, + owner, + )); + assert_eq!(queued.record(), None, "owner={owner:?}"); + assert_eq!(queued.restore_count, 0, "owner={owner:?}"); + assert_eq!(queued.abandon_count, 0, "owner={owner:?}"); + assert_eq!(queued.unfreeze_count, 0, "owner={owner:?}"); + assert_eq!(queued.suppression_end_count, 0, "owner={owner:?}"); + } } #[test] @@ -699,6 +811,10 @@ fn armed_owner_cleanup_releases_generation_clears_waiter_and_cancels_ui_once() { ZoomWaiterOwner::Eyedropper, ), (ScreenAcquisitionOwner::Ocr, ZoomWaiterOwner::Ocr), + ( + ScreenAcquisitionOwner::RegionCapture, + ZoomWaiterOwner::RegionCapture, + ), ] { let mut runtime = TransactionRuntime::started(owner); runtime.registry.take(); diff --git a/src/backend/wayland/state/canvas_layer.rs b/src/backend/wayland/state/canvas_layer.rs index 0bbd9b13b..8ee5e8be2 100644 --- a/src/backend/wayland/state/canvas_layer.rs +++ b/src/backend/wayland/state/canvas_layer.rs @@ -251,6 +251,8 @@ impl WaylandState { bg_color: background, logical_to_image_scale_x: 1.0, logical_to_image_scale_y: 1.0, + logical_image_origin_x: 0.0, + logical_image_origin_y: 0.0, }; let bake_bounds = Rect { diff --git a/src/backend/wayland/state/capture.rs b/src/backend/wayland/state/capture.rs index 3307462ce..598f4beda 100644 --- a/src/backend/wayland/state/capture.rs +++ b/src/backend/wayland/state/capture.rs @@ -9,7 +9,10 @@ mod pdf; pub(super) use barrier::OverlayCaptureBarrier; -fn should_exit_after_capture(mode: ExitAfterCaptureMode, destination: CaptureDestination) -> bool { +pub(super) fn should_exit_after_capture( + mode: ExitAfterCaptureMode, + destination: CaptureDestination, +) -> bool { let is_clipboard_only = matches!(destination, CaptureDestination::ClipboardOnly); match mode { ExitAfterCaptureMode::Always => true, @@ -18,6 +21,54 @@ fn should_exit_after_capture(mode: ExitAfterCaptureMode, destination: CaptureDes } } +pub(super) const fn overlay_suppression_for_screenshot( + capture_type: CaptureType, + include_drawings: bool, +) -> OverlaySuppression { + if !include_drawings + && matches!( + capture_type, + CaptureType::FullScreen | CaptureType::Selection { .. } + ) + { + OverlaySuppression::DesktopBackdrop + } else { + OverlaySuppression::Capture + } +} + +#[cfg(test)] +mod include_drawings_tests { + use super::*; + + #[test] + fn drawing_preference_selects_the_capture_frame_for_full_and_region_only() { + for capture_type in [ + CaptureType::FullScreen, + CaptureType::Selection { + x: 0, + y: 0, + width: 0, + height: 0, + }, + ] { + assert_eq!( + overlay_suppression_for_screenshot(capture_type, true), + OverlaySuppression::Capture + ); + assert_eq!( + overlay_suppression_for_screenshot(capture_type, false), + OverlaySuppression::DesktopBackdrop + ); + } + + assert_eq!( + overlay_suppression_for_screenshot(CaptureType::ActiveWindow, false), + OverlaySuppression::Capture + ); + } +} + impl WaylandState { pub(in crate::backend::wayland) fn continue_frozen_capture_after_failure( &mut self, @@ -68,6 +119,33 @@ impl WaylandState { /// Handles capture actions by delegating to the CaptureManager. pub(in crate::backend::wayland) fn handle_capture_action(&mut self, action: Action) { + if action.is_region_capture() { + if let Some(opening_action) = self.capture.active_region_action() { + if opening_action == action { + self.cancel_region_capture(); + } else { + self.input_state.push_toast( + ToastPriority::Info, + "capture.region.refused", + Toast::info("Finish or cancel the current screen selection first."), + ); + } + return; + } + if self + .input_state + .refuse_region_capture_while_screen_modal_engaged(action) + { + return; + } + let default_destination = if self.config.capture.copy_to_clipboard { + CaptureDestination::ClipboardAndFile + } else { + CaptureDestination::FileOnly + }; + self.begin_region_capture_action(action, default_destination); + return; + } if self .input_state .refuse_region_capture_while_screen_modal_engaged(action) @@ -174,8 +252,12 @@ impl WaylandState { let exit_on_success = self.should_exit_after_capture(destination); self.capture.set_exit_on_success(exit_on_success); - // Suppress overlay before capture to prevent capturing the overlay itself - if !self.enter_overlay_suppression(OverlaySuppression::Capture) { + // Full-screen capture deliberately keeps the committed canvas visible + // unless the user requested raw desktop pixels. Other capture types + // retain their established suppression behavior. + let suppression = + overlay_suppression_for_screenshot(capture_type, self.config.capture.include_drawings); + if !self.enter_overlay_suppression(suppression) { log::warn!( "Capture action {:?} requested while overlay is suppressed; ignoring", action @@ -328,7 +410,7 @@ impl WaylandState { self.accept_capture_submission(result, operation); } - fn accept_capture_submission( + pub(super) fn accept_capture_submission( &mut self, submission: Result, operation: ImageOperationKind, diff --git a/src/backend/wayland/state/core/init.rs b/src/backend/wayland/state/core/init.rs index ffb1e5d5f..feeb32d13 100644 --- a/src/backend/wayland/state/core/init.rs +++ b/src/backend/wayland/state/core/init.rs @@ -113,6 +113,8 @@ impl WaylandState { let clipboard_text_copy = RuntimeOperationController::new(runtime_operation_ids.clone(), runtime_wake.clone()); let clipboard_text_paste = + RuntimeOperationController::new(runtime_operation_ids.clone(), runtime_wake.clone()); + let window_query = RuntimeOperationController::new(runtime_operation_ids, runtime_wake.clone()); let ocr = crate::ocr::OcrController::new(runtime_wake.clone()); @@ -148,6 +150,7 @@ impl WaylandState { pending_text_copy: Default::default(), clipboard_text_paste, pending_text_paste: Default::default(), + window_query, ocr, gtk_toolbar: None, onboarding, diff --git a/src/backend/wayland/state/core/overlay.rs b/src/backend/wayland/state/core/overlay.rs index 2a93fc43e..5bb12a8cf 100644 --- a/src/backend/wayland/state/core/overlay.rs +++ b/src/backend/wayland/state/core/overlay.rs @@ -1,6 +1,16 @@ use super::super::*; impl WaylandState { + /// Derived chrome suppression for the native capture picker. + /// + /// This never changes the user's toolbar/chrome preferences and never + /// enters the capture-preflight suppression state. Ending the picker + /// therefore reveals whatever Focus/Light/overlay suppression still + /// permits instead of restoring a stale snapshot over it. + pub(in crate::backend::wayland) fn capture_picker_chrome_suppressed(&self) -> bool { + capture_picker_chrome_suppressed_for(self.input_state.region_state()) + } + pub(in crate::backend::wayland) fn overlay_suppressed(&self) -> bool { self.data.overlay_suppression != OverlaySuppression::None } @@ -127,6 +137,10 @@ fn overlay_keyboard_passthrough_requested_for( && keyboard_policy == OverlaySuppressionKeyboardPolicy::Release) } +fn capture_picker_chrome_suppressed_for(region: crate::input::state::RegionSelectUiState) -> bool { + region.is_engaged() && region.purpose().is_some_and(|purpose| purpose.is_capture()) +} + #[cfg(test)] mod tests { use super::*; @@ -149,4 +163,54 @@ mod tests { true, )); } + + #[test] + fn capture_picker_chrome_suppression_is_derived_without_affecting_ocr() { + use crate::input::state::{ + RegionInputSource, RegionPurposeTag, RegionSelectUiState, ScreenCaptureSource, + }; + + assert!(!capture_picker_chrome_suppressed_for( + RegionSelectUiState::Inactive + )); + assert!(!capture_picker_chrome_suppressed_for( + RegionSelectUiState::Armed { + purpose: RegionPurposeTag::Ocr, + generation: 1, + } + )); + assert!(!capture_picker_chrome_suppressed_for( + RegionSelectUiState::Armed { + purpose: RegionPurposeTag::Measure, + generation: 1, + } + )); + for purpose in [ + RegionPurposeTag::CaptureDeliver, + RegionPurposeTag::CaptureInteractive, + ] { + assert!(capture_picker_chrome_suppressed_for( + RegionSelectUiState::PendingCapture { + purpose, + generation: 2, + source: ScreenCaptureSource::Frozen, + } + )); + assert!(capture_picker_chrome_suppressed_for( + RegionSelectUiState::Armed { + purpose, + generation: 2, + } + )); + assert!(capture_picker_chrome_suppressed_for( + RegionSelectUiState::Selecting { + purpose, + generation: 2, + owner: RegionInputSource::Pointer, + start: (10.0, 20.0), + current: (30.0, 40.0), + } + )); + } + } } diff --git a/src/backend/wayland/state/data.rs b/src/backend/wayland/state/data.rs index b4274e6ef..72eb14396 100644 --- a/src/backend/wayland/state/data.rs +++ b/src/backend/wayland/state/data.rs @@ -4,7 +4,7 @@ use crate::backend::wayland::acquisition::ScreenAcquisitionRegistry; use crate::backend::wayland::toolbar::hit::HitRegion; use crate::backend::wayland::zoom::ZoomWaiterRegistry; -use super::region_capture::ActiveScreenRegion; +use super::region_capture::{ActiveScreenRegion, WindowSnapSession}; use super::screen_image::ScreenSourceToken; use super::capture::OverlayCaptureBarrier; @@ -32,6 +32,43 @@ pub enum OverlaySuppressionKeyboardPolicy { Retain, } +/// One-shot release latches owned by the pointing device whose press armed +/// them. Pointer and touch can both have an outstanding release; neither may +/// consume or clear the other's sequence. Stylus contacts use tablet-tool +/// retirement instead of this latch. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(super) struct ReleaseSuppression { + pointer: bool, + touch: bool, +} + +impl ReleaseSuppression { + pub(super) fn arm(&mut self, source: crate::input::state::RegionInputSource) { + match source { + crate::input::state::RegionInputSource::Pointer => self.pointer = true, + crate::input::state::RegionInputSource::Touch => self.touch = true, + crate::input::state::RegionInputSource::Stylus => {} + } + } + + pub(super) fn clear(&mut self, source: crate::input::state::RegionInputSource) { + match source { + crate::input::state::RegionInputSource::Pointer => self.pointer = false, + crate::input::state::RegionInputSource::Touch => self.touch = false, + crate::input::state::RegionInputSource::Stylus => {} + } + } + + pub(super) fn take(&mut self, source: crate::input::state::RegionInputSource) -> bool { + let slot = match source { + crate::input::state::RegionInputSource::Pointer => &mut self.pointer, + crate::input::state::RegionInputSource::Touch => &mut self.touch, + crate::input::state::RegionInputSource::Stylus => return false, + }; + std::mem::take(slot) + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum XdgFrozenFullscreenState { #[default] @@ -66,6 +103,13 @@ impl OverlaySuppression { ) } + /// Whether pointer-driven previews and editing affordances belong in the + /// canvas pass. A capture frame retains committed annotations but omits + /// transient state that is not part of the saved drawing. + pub(in crate::backend::wayland) fn renders_canvas_transients(self) -> bool { + self == Self::None + } + pub(in crate::backend::wayland) fn renders_ui(self) -> bool { self == Self::None } @@ -145,6 +189,7 @@ pub struct StateData { pub(super) zoom_waiter: ZoomWaiterRegistry, pub(super) active_eyedropper_source: Option, pub(super) active_screen_region: Option, + pub(super) window_snap: Option, pub(super) next_screen_region_generation: u64, pub(super) frozen_enabled: bool, pub(super) has_seen_surface_enter: bool, @@ -159,8 +204,8 @@ pub struct StateData { pub(super) overlay_clickthrough: bool, /// True when surface is configured and has keyboard focus; keys are blocked until ready. pub(super) overlay_ready: bool, - /// Suppress the next pointer release after a modal click (e.g., command palette). - pub(super) suppress_next_release: bool, + /// Suppress modal-owned pointer/touch releases without crossing devices. + pub(super) release_suppression: ReleaseSuppression, /// Exact toast activation a left press began inside. A release is accepted /// only while this same activation remains visible. pub(super) pending_toast_press: Option, @@ -192,6 +237,9 @@ pub struct StateData { pub(super) prev_command_palette_damage: Option, pub(super) prev_color_picker_damage: Option, pub(super) prev_tool_preview_damage: Option, + pub(super) prev_shape_measure_badge_damage: Option, + /// Previous-frame strips for Measure Mode's crosshair, frame, and readout. + pub(super) prev_measure_picker_damage: Vec, /// Idle-fade engine for the top-strip islands; its value is published /// on every toolbar snapshot as `top_fade`. pub(super) top_strip_fade: crate::ui::toolbar::snapshot::fade::TopStripFade, @@ -253,6 +301,7 @@ impl StateData { zoom_waiter: ZoomWaiterRegistry::default(), active_eyedropper_source: None, active_screen_region: None, + window_snap: None, next_screen_region_generation: 1, frozen_enabled: false, has_seen_surface_enter: false, @@ -266,7 +315,7 @@ impl StateData { overlay_capture_barrier: OverlayCaptureBarrier::default(), overlay_clickthrough: false, overlay_ready: false, - suppress_next_release: false, + release_suppression: ReleaseSuppression::default(), pending_toast_press: None, pending_status_hud_press: false, pending_zoom_chip_press: crate::ui::ZoomChipPress::None, @@ -284,6 +333,8 @@ impl StateData { prev_command_palette_damage: None, prev_color_picker_damage: None, prev_tool_preview_damage: None, + prev_shape_measure_badge_damage: None, + prev_measure_picker_damage: Vec::new(), top_strip_fade: crate::ui::toolbar::snapshot::fade::TopStripFade::new(), shortcut_coach: super::onboarding::ShortcutCoachSession::default(), } @@ -293,6 +344,28 @@ impl StateData { #[cfg(test)] mod tests { use super::OverlaySuppression; + use crate::input::state::RegionInputSource; + + #[test] + fn release_suppression_is_owned_by_the_originating_device() { + let mut suppression = super::ReleaseSuppression::default(); + suppression.arm(RegionInputSource::Pointer); + + assert!(!suppression.take(RegionInputSource::Touch)); + assert!(suppression.take(RegionInputSource::Pointer)); + assert!(!suppression.take(RegionInputSource::Pointer)); + } + + #[test] + fn clearing_one_device_keeps_the_other_devices_latch() { + let mut suppression = super::ReleaseSuppression::default(); + suppression.arm(RegionInputSource::Pointer); + suppression.arm(RegionInputSource::Touch); + suppression.clear(RegionInputSource::Touch); + + assert!(!suppression.take(RegionInputSource::Touch)); + assert!(suppression.take(RegionInputSource::Pointer)); + } #[test] fn desktop_backdrop_suppression_hides_canvas_and_ui() { @@ -308,6 +381,8 @@ mod tests { assert!(suppression.renders_canvas()); assert!(!suppression.renders_ui()); + assert!(!suppression.renders_canvas_transients()); + assert!(OverlaySuppression::None.renders_canvas_transients()); } #[test] diff --git a/src/backend/wayland/state/eyedropper.rs b/src/backend/wayland/state/eyedropper.rs index e5bd0c45a..464f3ae67 100644 --- a/src/backend/wayland/state/eyedropper.rs +++ b/src/backend/wayland/state/eyedropper.rs @@ -12,7 +12,11 @@ use super::screen_image::{ image_point_for_screen_point, screen_source_entry, screen_source_token, }; -fn sample_at(image: &FrozenImage, image_x: f64, image_y: f64) -> Option { +pub(in crate::backend::wayland) fn sample_at( + image: &FrozenImage, + image_x: f64, + image_y: f64, +) -> Option { if image.width == 0 || image.height == 0 || image.stride <= 0 { return None; } diff --git a/src/backend/wayland/state/gtk_toolbar.rs b/src/backend/wayland/state/gtk_toolbar.rs index 6f14cb389..e45387e0a 100644 --- a/src/backend/wayland/state/gtk_toolbar.rs +++ b/src/backend/wayland/state/gtk_toolbar.rs @@ -12,6 +12,19 @@ use crate::toolbar_gtk::{GtkToolbarBridge, GtkToolbarFeedback, GtkToolbarUpdate} fn gtk_toolbar_feedback_blocked(input_state: &crate::input::InputState) -> bool { input_state.command_palette_is_engaged() + || (input_state.region_is_engaged() + && input_state + .region_state() + .purpose() + .is_some_and(|purpose| purpose.is_capture())) +} + +fn gtk_toolbar_top_visible( + requested: bool, + unmap_suppressed: bool, + capture_picker_suppressed: bool, +) -> bool { + requested && !unmap_suppressed && !capture_picker_suppressed } fn acknowledge_blocked_gtk_drag_feedback(top_seq: &mut u64, feedback: &GtkToolbarFeedback) { @@ -213,8 +226,13 @@ impl WaylandState { // snapshots. Other suppression and light passthrough still unmap. let capture_suppressed = self.data.overlay_suppression.requires_capture_barrier(); let unmap_suppressed = self.overlay_passthrough_requested() && !capture_suppressed; + let capture_picker_suppressed = self.capture_picker_chrome_suppressed(); let update = GtkToolbarUpdate { - top_visible: self.input_state.toolbar_top_visible() && !unmap_suppressed, + top_visible: gtk_toolbar_top_visible( + self.input_state.toolbar_top_visible(), + unmap_suppressed, + capture_picker_suppressed, + ), top_offset: (self.data.toolbar_top_offset, self.data.toolbar_top_offset_y), top_offset_seq: self.data.gtk_top_offset_seq, top_base_x: self.gtk_top_base_x(), @@ -278,6 +296,28 @@ mod modal_tests { assert!(gtk_toolbar_feedback_blocked(&input_state)); } + #[test] + fn capture_picker_blocks_feedback_but_ocr_does_not() { + use crate::input::state::RegionPurposeTag; + + let mut input_state = make_test_input_state(); + input_state.activate_region(RegionPurposeTag::Ocr, 1); + assert!(!gtk_toolbar_feedback_blocked(&input_state)); + + input_state.cancel_region_ui_only(); + input_state.activate_region(RegionPurposeTag::CaptureDeliver, 2); + assert!(gtk_toolbar_feedback_blocked(&input_state)); + } + + #[test] + fn capture_picker_hides_gtk_toolbar_without_mutating_requested_visibility() { + let requested = true; + assert!(!gtk_toolbar_top_visible(requested, false, true)); + assert!(gtk_toolbar_top_visible(requested, false, false)); + assert!(!gtk_toolbar_top_visible(requested, true, false)); + assert!(requested, "the persisted/live request remains untouched"); + } + #[test] fn blocked_drag_feedback_advances_the_sequence_and_never_regresses_it() { let mut top_seq = 4; diff --git a/src/backend/wayland/state/ocr.rs b/src/backend/wayland/state/ocr.rs index 5616f6513..72606c148 100644 --- a/src/backend/wayland/state/ocr.rs +++ b/src/backend/wayland/state/ocr.rs @@ -15,7 +15,8 @@ use crate::ocr::{OcrFailure, OcrLanguages, OcrPoll, OcrRequest, OcrSubmitError, use super::WaylandState; use super::acquisition::report_screen_source_activation_rejected_to; use super::region_capture::{ - ActiveScreenRegion, FreezeOwnership, RegionSelectionFinalize, finalize_region_selection_event, + ActiveScreenRegion, FreezeOwnership, RegionOwnerLoss, RegionSelectionFinalize, + finalize_region_selection_event, }; use super::screen_image::{ CropError, DisplayedScreenImage, ScreenSourceEntry, copy_image_rect, displayed_screen_image, @@ -24,6 +25,25 @@ use super::screen_image::{ const TOAST_SOURCE: &str = "ocr"; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ActiveRegionCancelTarget { + None, + Ocr, + Capture, + Measure, +} + +fn active_region_cancel_target(purpose: Option) -> ActiveRegionCancelTarget { + match purpose { + Some(RegionPurposeTag::Ocr) => ActiveRegionCancelTarget::Ocr, + Some(RegionPurposeTag::CaptureDeliver | RegionPurposeTag::CaptureInteractive) => { + ActiveRegionCancelTarget::Capture + } + Some(RegionPurposeTag::Measure) => ActiveRegionCancelTarget::Measure, + None => ActiveRegionCancelTarget::None, + } +} + impl WaylandState { /// Drain a `Copy text from screen` request into the region selector. pub(in crate::backend::wayland) fn handle_pending_ocr_request(&mut self) { @@ -207,7 +227,7 @@ impl WaylandState { /// request itself cancelled. fn activate_ocr_selector(&mut self, generation: u64, ownership: FreezeOwnership) -> bool { self.retire_stylus_contact(); - self.activate_screen_region(RegionPurposeTag::Ocr, generation, ownership) + self.activate_screen_region(RegionPurposeTag::Ocr, generation, ownership, false) } /// Leave OCR selection, releasing only a freeze OCR created itself. @@ -241,6 +261,24 @@ impl WaylandState { true } + pub(in crate::backend::wayland) fn cancel_active_region_selector(&mut self) -> bool { + match active_region_cancel_target(self.input_state.region_state().purpose()) { + ActiveRegionCancelTarget::Ocr => self.cancel_ocr(), + ActiveRegionCancelTarget::Capture => self.cancel_region_capture(), + ActiveRegionCancelTarget::Measure => self.cancel_measure_mode(), + ActiveRegionCancelTarget::None => false, + } + } + + pub(in crate::backend::wayland) fn cancel_region_for_toolbar_interaction(&mut self) -> bool { + match active_region_cancel_target(self.input_state.region_state().purpose()) { + ActiveRegionCancelTarget::Ocr => self.cancel_ocr_for_toolbar_interaction(), + ActiveRegionCancelTarget::Capture => self.cancel_region_capture(), + ActiveRegionCancelTarget::Measure => self.cancel_measure_mode(), + ActiveRegionCancelTarget::None => false, + } + } + /// Discard a region because the device dragging it went away — the pen left /// proximity, the touch sequence was cancelled. Devices that are not /// dragging have nothing to withdraw, so this leaves the selector armed for @@ -249,14 +287,21 @@ impl WaylandState { &mut self, source: RegionInputSource, ) -> bool { - if !self.input_state.region_selection_is_owned_by(source) { - return false; + match self.region_owner_lost(source) { + RegionOwnerLoss::NotOwned => false, + RegionOwnerLoss::Rearmed => true, + RegionOwnerLoss::Cancel(RegionPurposeTag::Ocr) => self.cancel_ocr(), + RegionOwnerLoss::Cancel(RegionPurposeTag::Measure) => self.cancel_measure_mode(), + RegionOwnerLoss::Cancel(purpose) => { + debug_assert!(purpose.is_capture()); + self.cancel_region_capture() + } } - self.cancel_ocr() } - /// End the drag: own the selected pixels, release an OCR-created freeze, - /// and submit. Returns whether `source` had a region drag to finish. + /// End the drag: OCR and direct capture submit their selected pixels; + /// interactive capture enters Review. Returns whether `source` had a + /// region drag or review move to finish. pub(in crate::backend::wayland) fn finish_region_selection( &mut self, source: RegionInputSource, @@ -276,7 +321,27 @@ impl WaylandState { ) { RegionSelectionFinalize::NotOwned => return false, RegionSelectionFinalize::Rearmed => return true, - RegionSelectionFinalize::Selected(rect) => rect, + RegionSelectionFinalize::Reviewed => return true, + RegionSelectionFinalize::Measured => return true, + RegionSelectionFinalize::Selected { + purpose: RegionPurposeTag::Ocr, + rect, + } => rect, + RegionSelectionFinalize::Selected { + purpose: RegionPurposeTag::CaptureDeliver, + rect, + } => { + self.submit_region_capture(rect); + return true; + } + RegionSelectionFinalize::Selected { + purpose: RegionPurposeTag::CaptureInteractive, + .. + } => return true, + RegionSelectionFinalize::Selected { + purpose: RegionPurposeTag::Measure, + .. + } => return true, }; // The crop is taken while the capture is still held: releasing first @@ -489,6 +554,31 @@ mod tests { ); } + #[test] + fn active_region_cancellation_routes_every_selector_purpose_to_its_owner() { + assert_eq!( + active_region_cancel_target(Some(RegionPurposeTag::Ocr)), + ActiveRegionCancelTarget::Ocr + ); + for purpose in [ + RegionPurposeTag::CaptureDeliver, + RegionPurposeTag::CaptureInteractive, + ] { + assert_eq!( + active_region_cancel_target(Some(purpose)), + ActiveRegionCancelTarget::Capture + ); + } + assert_eq!( + active_region_cancel_target(Some(RegionPurposeTag::Measure)), + ActiveRegionCancelTarget::Measure + ); + assert_eq!( + active_region_cancel_target(None), + ActiveRegionCancelTarget::None + ); + } + #[test] fn scan_corner_arms_stay_inside_a_small_region() { // The clamp floor is 4px; the guard is that the arm never spans more diff --git a/src/backend/wayland/state/region_capture.rs b/src/backend/wayland/state/region_capture.rs index 1a4035d9f..b8147239d 100644 --- a/src/backend/wayland/state/region_capture.rs +++ b/src/backend/wayland/state/region_capture.rs @@ -1,806 +1,46 @@ use crate::backend::wayland::acquisition::ScreenAcquisitionId; +use crate::input::SelectionHandle; use crate::input::state::{ - RegionInputSource, RegionPurposeTag, RegionSelectUiState, ScreenCaptureSource, + RegionInputSource, RegionPurposeTag, RegionSelectUiState, RegionSelection, ScreenCaptureSource, }; -use crate::screen_pixels::{ImagePixelRect, ImagePoint, clamp_edge, pixel_span}; +use crate::screen_pixels::{ImagePixelRect, ImagePoint, clamp_edge}; + +mod active_state; +mod board; +mod delivery; +mod events; +mod geometry; +mod intent; +mod measure; +mod picker; +mod review_state; +mod runtime; +mod selection_state; +mod source_guard; +mod window_snap; + +pub(super) use active_state::{ActiveScreenRegion, FreezeOwnership}; +pub(in crate::backend::wayland) use board::{ + board_bounds_for_world_rect, world_rect_for_image_rect_exact, +}; +pub(super) use events::finalize_region_selection_event; +use events::*; +pub(in crate::backend::wayland) use geometry::{RegionPickerMeasurement, RegionSelectionGeometry}; +pub(in crate::backend::wayland) use intent::{RegionCaptureIntent, RegionPickerOptions}; +use measure::*; +pub(in crate::backend::wayland) use measure::{RegionOwnerLoss, RegionSelectionFinalize}; +use review_state::ReviewResizeGrip; +#[cfg(test)] +use review_state::resized_review_rect; +pub(super) use source_guard::owned_generation_is_current; +use source_guard::*; +pub(in crate::backend::wayland) use window_snap::WindowSnapDirection; +pub(super) use window_snap::{WindowSnapQuery, WindowSnapSession}; use super::WaylandState; use super::screen_image::{ ScreenSourceToken, current_screen_source_token, image_point_for_screen_point, screen_source_is, }; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum FreezeOwnership { - PreExisting, - PickerOwned { image_generation: u64 }, -} - -#[derive(Debug, Clone, Copy, PartialEq)] -pub(super) enum ActiveScreenRegion { - PendingFrozen { - purpose: RegionPurposeTag, - generation: u64, - acquisition: ScreenAcquisitionId, - }, - PendingZoom { - purpose: RegionPurposeTag, - generation: u64, - }, - Ready { - purpose: RegionPurposeTag, - generation: u64, - source: ScreenSourceToken, - freeze_ownership: FreezeOwnership, - anchor: Option, - raw_edge: Option, - logical_anchor: Option<(f64, f64)>, - logical_edge: Option<(f64, f64)>, - }, -} - -impl ActiveScreenRegion { - pub const fn purpose(self) -> RegionPurposeTag { - match self { - Self::PendingFrozen { purpose, .. } - | Self::PendingZoom { purpose, .. } - | Self::Ready { purpose, .. } => purpose, - } - } - - pub const fn generation(self) -> u64 { - match self { - Self::PendingFrozen { generation, .. } - | Self::PendingZoom { generation, .. } - | Self::Ready { generation, .. } => generation, - } - } - - pub const fn pending_acquisition(self) -> Option { - match self { - Self::PendingFrozen { acquisition, .. } => Some(acquisition), - Self::PendingZoom { .. } | Self::Ready { .. } => None, - } - } - - pub fn waits_for_acquisition(self, id: ScreenAcquisitionId) -> bool { - matches!(self, Self::PendingFrozen { acquisition, .. } if acquisition == id) - } - - pub const fn owned_frozen_generation(self) -> Option { - match self { - Self::Ready { - freeze_ownership: FreezeOwnership::PickerOwned { image_generation }, - .. - } => Some(image_generation), - Self::PendingFrozen { .. } - | Self::PendingZoom { .. } - | Self::Ready { - freeze_ownership: FreezeOwnership::PreExisting, - .. - } => None, - } - } - - fn selection_rect(self) -> Option { - let Self::Ready { - purpose, - anchor: Some(anchor), - raw_edge: Some(raw_edge), - logical_anchor, - logical_edge, - source, - .. - } = self - else { - return None; - }; - if let Some(minimum) = purpose.selection_policy().min_submit_logical_px() { - let (Some(logical_anchor), Some(logical_edge)) = (logical_anchor, logical_edge) else { - return None; - }; - if (logical_edge.0 - logical_anchor.0).abs() < minimum - || (logical_edge.1 - logical_anchor.1).abs() < minimum - { - return None; - } - } - pixel_span(anchor, raw_edge, source.image_size)? - .try_into() - .ok() - } - - fn begin_selection(&mut self, logical: (f64, f64)) -> bool { - let Self::Ready { - purpose, - source, - anchor, - raw_edge, - logical_anchor, - logical_edge, - .. - } = self - else { - return false; - }; - if anchor.is_some() - || raw_edge.is_some() - || logical_anchor.is_some() - || logical_edge.is_some() - { - return false; - } - let Some(mapped) = clamp_edge( - image_point_for_screen_point(source, logical), - source.image_size, - ) else { - return false; - }; - debug_assert_eq!(*purpose, RegionPurposeTag::Ocr); - *anchor = Some(mapped); - *raw_edge = Some(mapped); - *logical_anchor = Some(logical); - *logical_edge = Some(logical); - true - } - - fn update_endpoint(&mut self, logical: (f64, f64)) -> bool { - let Self::Ready { - source, - raw_edge, - logical_edge, - .. - } = self - else { - return false; - }; - let Some(mapped) = clamp_edge( - image_point_for_screen_point(source, logical), - source.image_size, - ) else { - return false; - }; - *raw_edge = Some(mapped); - *logical_edge = Some(logical); - true - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum RegionSelectionFinalize { - NotOwned, - Rearmed, - Selected(ImagePixelRect), -} - -fn begin_region_selection_event( - backend: &mut Option, - input_state: &mut crate::input::InputState, - owner: RegionInputSource, - logical: (f64, f64), -) -> bool { - if !backend - .as_mut() - .is_some_and(|region| region.begin_selection(logical)) - { - return false; - } - input_state.start_region_selection(owner, logical) -} - -fn update_region_selection_event( - backend: &mut Option, - input_state: &mut crate::input::InputState, - owner: RegionInputSource, - logical: (f64, f64), -) { - if !input_state.region_selection_is_owned_by(owner) { - return; - } - if backend - .as_mut() - .is_some_and(|region| region.update_endpoint(logical)) - { - input_state.update_region_selection(owner, logical); - } -} - -fn rearm_region_selection_event( - backend: &mut Option, - input_state: &mut crate::input::InputState, -) { - if let Some(ActiveScreenRegion::Ready { - anchor, - raw_edge, - logical_anchor, - logical_edge, - .. - }) = backend.as_mut() - { - *anchor = None; - *raw_edge = None; - *logical_anchor = None; - *logical_edge = None; - } - input_state.rearm_region_selection(); -} - -pub(super) fn finalize_region_selection_event( - backend: &mut Option, - input_state: &mut crate::input::InputState, - owner: RegionInputSource, - logical: (f64, f64), -) -> RegionSelectionFinalize { - if !input_state.region_selection_is_owned_by(owner) { - return RegionSelectionFinalize::NotOwned; - } - update_region_selection_event(backend, input_state, owner, logical); - let Some(rect) = backend - .as_ref() - .copied() - .and_then(ActiveScreenRegion::selection_rect) - else { - rearm_region_selection_event(backend, input_state); - return RegionSelectionFinalize::Rearmed; - }; - RegionSelectionFinalize::Selected(rect) -} - -impl WaylandState { - pub(super) fn next_screen_region_generation(&mut self) -> u64 { - let generation = self.data.next_screen_region_generation; - self.data.next_screen_region_generation = generation - .checked_add(1) - .expect("screen region generation space exhausted"); - generation - } - - pub(super) fn set_pending_screen_region( - &mut self, - purpose: RegionPurposeTag, - generation: u64, - source: ScreenCaptureSource, - acquisition: Option, - ) { - self.data.active_screen_region = Some(match source { - ScreenCaptureSource::Frozen => ActiveScreenRegion::PendingFrozen { - purpose, - generation, - acquisition: acquisition.expect("frozen region wait has an acquisition id"), - }, - ScreenCaptureSource::Zoom => ActiveScreenRegion::PendingZoom { - purpose, - generation, - }, - }); - self.input_state - .set_region_pending_capture(purpose, generation, source); - self.debug_assert_screen_region_invariant(); - } - - pub(super) fn activate_screen_region( - &mut self, - purpose: RegionPurposeTag, - generation: u64, - freeze_ownership: FreezeOwnership, - ) -> bool { - let Some(source) = super::screen_image::displayed_screen_image( - &self.zoom, - &self.frozen, - self.input_state.board_is_transparent(), - ) else { - return false; - }; - let Some(token) = current_screen_source_token( - &source, - &self.zoom, - &self.frozen, - (self.surface.width(), self.surface.height()), - ) else { - return false; - }; - self.data.active_screen_region = Some(ActiveScreenRegion::Ready { - purpose, - generation, - source: token, - freeze_ownership, - anchor: None, - raw_edge: None, - logical_anchor: None, - logical_edge: None, - }); - self.input_state.activate_region(purpose, generation); - self.debug_assert_screen_region_invariant(); - true - } - - pub(super) fn clear_screen_region_ui_only(&mut self) { - self.data.active_screen_region = None; - self.input_state.cancel_region_ui_only(); - self.debug_assert_screen_region_invariant(); - } - - pub(in crate::backend::wayland) fn begin_region_selection( - &mut self, - owner: RegionInputSource, - x: f64, - y: f64, - ) -> bool { - self.cancel_screen_modals_if_source_changed(); - begin_region_selection_event( - &mut self.data.active_screen_region, - &mut self.input_state, - owner, - (x, y), - ) - } - - pub(in crate::backend::wayland) fn update_region_selection( - &mut self, - owner: RegionInputSource, - x: f64, - y: f64, - ) { - self.cancel_screen_modals_if_source_changed(); - update_region_selection_event( - &mut self.data.active_screen_region, - &mut self.input_state, - owner, - (x, y), - ); - } - - pub(super) fn debug_assert_screen_region_invariant(&self) { - debug_assert!(screen_region_invariant( - self.data.active_screen_region, - self.input_state.region_state(), - )); - } - - pub(in crate::backend::wayland) fn cancel_screen_modals_if_source_changed(&mut self) { - let (cancel_eyedropper, cancel_ocr) = { - let current_source = super::screen_image::displayed_screen_image( - &self.zoom, - &self.frozen, - self.input_state.board_is_transparent(), - ); - let source_matches = |expected| { - current_source.as_ref().is_some_and(|source| { - screen_source_is( - &expected, - source, - &self.zoom, - &self.frozen, - (self.surface.width(), self.surface.height()), - ) - }) - }; - ( - active_eyedropper_source_changed( - self.input_state.eyedropper_is_active(), - self.data.active_eyedropper_source, - &source_matches, - ), - active_ocr_source_changed(self.data.active_screen_region, &source_matches), - ) - }; - if cancel_eyedropper { - self.cancel_eyedropper(); - } - if cancel_ocr { - self.cancel_ocr(); - } - } -} - -fn screen_region_invariant(backend: Option, ui: RegionSelectUiState) -> bool { - match (backend, ui) { - (None, RegionSelectUiState::Inactive) => true, - (None, _) | (Some(_), RegionSelectUiState::Inactive) => false, - (Some(region), ui) => { - ui.generation() == Some(region.generation()) && ui.purpose() == Some(region.purpose()) - } - } -} - -fn active_ocr_source_changed( - region: Option, - source_matches: &impl Fn(ScreenSourceToken) -> bool, -) -> bool { - matches!( - region, - Some(ActiveScreenRegion::Ready { - purpose: RegionPurposeTag::Ocr, - source, - .. - }) if !source_matches(source) - ) -} - -fn active_eyedropper_source_changed( - active: bool, - expected_source: Option, - source_matches: &impl Fn(ScreenSourceToken) -> bool, -) -> bool { - active && !expected_source.is_some_and(source_matches) -} - -pub(super) fn owned_generation_is_current( - expected: u64, - current: u64, - frozen_active: bool, -) -> bool { - frozen_active && expected == current -} - #[cfg(test)] -mod tests { - use super::*; - use crate::input::state::test_support::make_test_input_state; - - #[test] - fn pending_and_ready_region_state_preserve_generation_and_freeze_ownership() { - let pending = ActiveScreenRegion::PendingZoom { - purpose: RegionPurposeTag::Ocr, - generation: 9, - }; - assert_eq!(pending.generation(), 9); - assert_eq!(pending.purpose(), RegionPurposeTag::Ocr); - assert_eq!(pending.owned_frozen_generation(), None); - - let ready = ActiveScreenRegion::Ready { - purpose: RegionPurposeTag::Ocr, - generation: 9, - source: ScreenSourceToken { - output_id: 1, - output_layout_generation: 0, - kind: super::super::screen_image::ScreenImageKind::Frozen, - image_generation: 44, - image_size: (100, 80), - stride: 400, - surface: (100, 80), - output_scale: 1, - output_transform: wayland_client::protocol::wl_output::Transform::Normal, - zoom_transformed: false, - zoom_scale: 1.0, - zoom_view_offset: (0.0, 0.0), - }, - freeze_ownership: FreezeOwnership::PickerOwned { - image_generation: 44, - }, - anchor: None, - raw_edge: None, - logical_anchor: None, - logical_edge: None, - }; - assert_eq!(ready.generation(), pending.generation()); - assert_eq!(ready.owned_frozen_generation(), Some(44)); - } - - #[test] - fn backend_and_ui_generation_must_match() { - let backend = Some(ActiveScreenRegion::PendingZoom { - purpose: RegionPurposeTag::Ocr, - generation: 7, - }); - assert!(screen_region_invariant( - backend, - RegionSelectUiState::PendingCapture { - purpose: RegionPurposeTag::Ocr, - generation: 7, - source: ScreenCaptureSource::Zoom, - } - )); - assert!(!screen_region_invariant( - backend, - RegionSelectUiState::PendingCapture { - purpose: RegionPurposeTag::Ocr, - generation: 8, - source: ScreenCaptureSource::Zoom, - } - )); - assert!(!screen_region_invariant( - backend, - RegionSelectUiState::Inactive - )); - } - - #[test] - fn armed_freeze_release_is_generation_checked_and_consumed_once() { - assert!(owned_generation_is_current(44, 44, true)); - assert!(!owned_generation_is_current(44, 45, true)); - assert!(!owned_generation_is_current(44, 44, false)); - - let mut owned = ocr_region(1.0); - if let ActiveScreenRegion::Ready { - freeze_ownership, .. - } = &mut owned - { - *freeze_ownership = FreezeOwnership::PickerOwned { - image_generation: 44, - }; - } - let mut armed = Some(owned); - let first = armed - .take() - .and_then(ActiveScreenRegion::owned_frozen_generation); - let second = armed - .take() - .and_then(ActiveScreenRegion::owned_frozen_generation); - assert_eq!(first, Some(44)); - assert_eq!( - second, None, - "a second cancellation has no region to release" - ); - } - - fn ocr_region(scale: f64) -> ActiveScreenRegion { - ActiveScreenRegion::Ready { - purpose: RegionPurposeTag::Ocr, - generation: 1, - source: ScreenSourceToken { - output_id: 1, - output_layout_generation: 0, - kind: super::super::screen_image::ScreenImageKind::Frozen, - image_generation: 1, - image_size: ((100.0 * scale) as u32, (80.0 * scale) as u32), - stride: (400.0 * scale) as i32, - surface: (100, 80), - output_scale: 1, - output_transform: wayland_client::protocol::wl_output::Transform::Normal, - zoom_transformed: false, - zoom_scale: 1.0, - zoom_view_offset: (0.0, 0.0), - }, - freeze_ownership: FreezeOwnership::PreExisting, - anchor: None, - raw_edge: None, - logical_anchor: None, - logical_edge: None, - } - } - - #[test] - fn ready_ocr_detects_replaced_or_missing_source_but_pending_ocr_keeps_waiting() { - let ready = ocr_region(1.0); - let ActiveScreenRegion::Ready { source, .. } = ready else { - unreachable!("OCR fixture is ready") - }; - let mut replacement = source; - replacement.image_generation += 1; - - assert!(!active_ocr_source_changed(Some(ready), &|expected| { - expected == source - })); - assert!(active_ocr_source_changed(Some(ready), &|expected| { - expected == replacement - })); - assert!(active_ocr_source_changed(Some(ready), &|_| false)); - assert!(!active_ocr_source_changed( - Some(ActiveScreenRegion::PendingZoom { - purpose: RegionPurposeTag::Ocr, - generation: 1, - }), - &|_| false, - )); - } - - #[test] - fn active_eyedropper_detects_replaced_missing_or_untracked_source() { - let ActiveScreenRegion::Ready { source, .. } = ocr_region(1.0) else { - unreachable!("OCR fixture is ready") - }; - let mut replacement = source; - replacement.image_generation += 1; - - assert!(!active_eyedropper_source_changed( - true, - Some(source), - &|expected| expected == source, - )); - assert!(active_eyedropper_source_changed( - true, - Some(source), - &|expected| expected == replacement, - )); - assert!(active_eyedropper_source_changed( - true, - Some(source), - &|_| false, - )); - assert!(active_eyedropper_source_changed(true, None, &|_| true)); - assert!(!active_eyedropper_source_changed( - false, - Some(source), - &|_| false, - )); - } - - #[test] - fn ocr_three_logical_pixels_rearms_but_exactly_four_submits() { - let mut region = ocr_region(1.0); - assert!(region.begin_selection((10.0, 20.0))); - assert!(region.update_endpoint((13.0, 23.0))); - assert_eq!(region.selection_rect(), None); - - assert!(region.update_endpoint((14.0, 24.0))); - assert_eq!( - region - .selection_rect() - .map(|rect| (rect.x(), rect.y(), rect.size())), - Some((10, 20, (4, 4))) - ); - } - - #[test] - fn ocr_release_endpoint_without_motion_matches_forward_and_reversed_scale_oracle() { - for (scale, expected_forward, expected_reversed) in [ - (1.0, (10, 20, (5, 5)), (10, 20, (5, 5))), - (2.0, (20, 41, (9, 8)), (20, 41, (9, 8))), - (1.5, (15, 30, (7, 7)), (15, 30, (7, 7))), - ] { - let mut forward = ocr_region(scale); - assert!(forward.begin_selection((10.25, 20.5))); - assert!(forward.update_endpoint((14.25, 24.5))); - let forward_rect = forward.selection_rect().unwrap(); - assert_eq!( - (forward_rect.x(), forward_rect.y(), forward_rect.size()), - expected_forward, - "forward at scale {scale}" - ); - - let mut reversed = ocr_region(scale); - assert!(reversed.begin_selection((14.25, 24.5))); - assert!(reversed.update_endpoint((10.25, 20.5))); - let reversed_rect = reversed.selection_rect().unwrap(); - assert_eq!( - (reversed_rect.x(), reversed_rect.y(), reversed_rect.size()), - expected_reversed, - "reversed at scale {scale}" - ); - } - } - - #[test] - fn ocr_release_replaces_the_last_motion_endpoint() { - let mut region = ocr_region(1.0); - assert!(region.begin_selection((10.0, 20.0))); - assert!(region.update_endpoint((18.0, 32.0))); - assert_eq!(region.selection_rect().unwrap().size(), (8, 12)); - - assert!(region.update_endpoint((14.0, 24.0))); - assert_eq!(region.selection_rect().unwrap().size(), (4, 4)); - } - - #[test] - fn second_device_press_cannot_replace_an_in_progress_backend_region() { - let mut region = ocr_region(1.0); - assert!(region.begin_selection((10.0, 20.0))); - assert!(region.update_endpoint((18.0, 28.0))); - - assert!(!region.begin_selection((50.0, 60.0))); - assert_eq!( - region - .selection_rect() - .map(|rect| (rect.x(), rect.y(), rect.size())), - Some((10, 20, (8, 8))) - ); - } - - #[test] - fn ocr_policy_keeps_shift_held_drag_rectangular() { - let mut region = ocr_region(1.0); - assert!(region.begin_selection((10.0, 20.0))); - assert!(region.update_endpoint((14.0, 28.0))); - let rect = region.selection_rect().unwrap(); - assert_eq!(rect.size(), (4, 8)); - assert!(!RegionPurposeTag::Ocr.selection_policy().allow_square()); - } - - #[test] - fn production_ocr_event_adapter_uses_release_endpoint_at_every_scale() { - for (scale, expected) in [ - (1.0, (10, 20, (5, 5))), - (2.0, (20, 41, (9, 8))), - (1.5, (15, 30, (7, 7))), - ] { - for reversed in [false, true] { - for has_motion in [false, true] { - let (press, release) = if reversed { - ((14.25, 24.5), (10.25, 20.5)) - } else { - ((10.25, 20.5), (14.25, 24.5)) - }; - let mut backend = Some(ocr_region(scale)); - let mut input = make_test_input_state(); - input.activate_region(RegionPurposeTag::Ocr, 1); - - assert!(begin_region_selection_event( - &mut backend, - &mut input, - RegionInputSource::Pointer, - press, - )); - if has_motion { - update_region_selection_event( - &mut backend, - &mut input, - RegionInputSource::Pointer, - (30.0, 35.0), - ); - } - let RegionSelectionFinalize::Selected(rect) = finalize_region_selection_event( - &mut backend, - &mut input, - RegionInputSource::Pointer, - release, - ) else { - panic!( - "release must submit at scale={scale} reversed={reversed} motion={has_motion}" - ); - }; - - assert_eq!( - (rect.x(), rect.y(), rect.size()), - expected, - "scale={scale} reversed={reversed} motion={has_motion}" - ); - assert_eq!( - input - .region_state() - .selection() - .map(|selection| selection.end), - Some(release), - "the UI preview and crop adapter diverged" - ); - } - } - } - } - - #[test] - fn production_ocr_event_adapter_rearms_small_drag_and_ignores_shift_square_policy() { - let mut backend = Some(ocr_region(1.0)); - let mut input = make_test_input_state(); - input.activate_region(RegionPurposeTag::Ocr, 1); - input.sync_modifiers(true, false, false, false); - - assert!(begin_region_selection_event( - &mut backend, - &mut input, - RegionInputSource::Pointer, - (10.0, 20.0), - )); - let RegionSelectionFinalize::Selected(rect) = finalize_region_selection_event( - &mut backend, - &mut input, - RegionInputSource::Pointer, - (14.0, 28.0), - ) else { - panic!("Shift-held OCR drag must submit"); - }; - assert_eq!(rect.size(), (4, 8)); - - let mut backend = Some(ocr_region(1.0)); - let mut input = make_test_input_state(); - input.activate_region(RegionPurposeTag::Ocr, 1); - assert!(begin_region_selection_event( - &mut backend, - &mut input, - RegionInputSource::Pointer, - (10.0, 20.0), - )); - assert_eq!( - finalize_region_selection_event( - &mut backend, - &mut input, - RegionInputSource::Pointer, - (13.0, 23.0), - ), - RegionSelectionFinalize::Rearmed - ); - assert!(matches!( - input.region_state(), - RegionSelectUiState::Armed { .. } - )); - } -} +mod tests; diff --git a/src/backend/wayland/state/region_capture/active_state.rs b/src/backend/wayland/state/region_capture/active_state.rs new file mode 100644 index 000000000..df18f0c45 --- /dev/null +++ b/src/backend/wayland/state/region_capture/active_state.rs @@ -0,0 +1,321 @@ +use super::*; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::backend::wayland::state) enum FreezeOwnership { + PreExisting, + PickerOwned { image_generation: u64 }, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(in crate::backend::wayland::state) enum ActiveScreenRegion { + Measure { + generation: u64, + bounds: (u32, u32), + anchor: Option<(f64, f64)>, + edge: Option<(f64, f64)>, + }, + PendingFrozen { + purpose: RegionPurposeTag, + generation: u64, + acquisition: ScreenAcquisitionId, + }, + PendingZoom { + purpose: RegionPurposeTag, + generation: u64, + }, + Ready { + purpose: RegionPurposeTag, + generation: u64, + source: ScreenSourceToken, + freeze_ownership: FreezeOwnership, + anchor: Option, + raw_edge: Option, + logical_anchor: Option<(f64, f64)>, + logical_edge: Option<(f64, f64)>, + square_modifier: bool, + legend_dismissed: bool, + include_drawings: bool, + /// The grip a device is dragging in Review, if any. Mutually exclusive + /// with `logical_anchor`, which owns a Review move-drag. + review_resize: Option, + }, +} + +impl ActiveScreenRegion { + pub const fn purpose(self) -> RegionPurposeTag { + match self { + Self::Measure { .. } => RegionPurposeTag::Measure, + Self::PendingFrozen { purpose, .. } + | Self::PendingZoom { purpose, .. } + | Self::Ready { purpose, .. } => purpose, + } + } + + pub const fn generation(self) -> u64 { + match self { + Self::Measure { generation, .. } + | Self::PendingFrozen { generation, .. } + | Self::PendingZoom { generation, .. } + | Self::Ready { generation, .. } => generation, + } + } + + pub const fn pending_acquisition(self) -> Option { + match self { + Self::PendingFrozen { acquisition, .. } => Some(acquisition), + Self::Measure { .. } | Self::PendingZoom { .. } | Self::Ready { .. } => None, + } + } + + pub fn waits_for_acquisition(self, id: ScreenAcquisitionId) -> bool { + matches!(self, Self::PendingFrozen { acquisition, .. } if acquisition == id) + } + + pub const fn owned_frozen_generation(self) -> Option { + match self { + Self::Ready { + freeze_ownership: FreezeOwnership::PickerOwned { image_generation }, + .. + } => Some(image_generation), + Self::PendingFrozen { .. } + | Self::Measure { .. } + | Self::PendingZoom { .. } + | Self::Ready { + freeze_ownership: FreezeOwnership::PreExisting, + .. + } => None, + } + } + + pub const fn legend_dismissed(self) -> bool { + matches!( + self, + Self::Ready { + legend_dismissed: true, + .. + } + ) + } + + pub const fn include_drawings(self) -> bool { + matches!( + self, + Self::Ready { + include_drawings: true, + .. + } + ) + } + + pub(super) fn toggle_include_drawings(&mut self) -> Option { + let Self::Ready { + purpose: RegionPurposeTag::CaptureInteractive, + include_drawings, + .. + } = self + else { + return None; + }; + *include_drawings = !*include_drawings; + Some(*include_drawings) + } + + pub(super) fn measure_selection(self) -> Option { + let Self::Measure { + anchor: Some(start), + edge: Some(end), + .. + } = self + else { + return None; + }; + Some(RegionSelection { start, end }) + } + + pub(super) fn display_selection(self) -> Option { + self.measure_selection().or_else(|| { + self.selection_geometry() + .map(RegionSelectionGeometry::display_selection) + }) + } + + pub(super) fn selection_rect(self) -> Option { + if let Some(rect) = self.stored_review_rect() { + return Some(rect); + } + let Self::Ready { + purpose, + logical_anchor, + logical_edge, + .. + } = self + else { + return None; + }; + if let Some(minimum) = purpose.selection_policy().min_submit_logical_px() { + let (Some(logical_anchor), Some(logical_edge)) = (logical_anchor, logical_edge) else { + return None; + }; + if (logical_edge.0 - logical_anchor.0).abs() < minimum + || (logical_edge.1 - logical_anchor.1).abs() < minimum + { + return None; + } + } + self.selection_geometry()?.image_rect() + } + + pub(super) fn selection_geometry(self) -> Option { + if self.stored_review_rect().is_some() { + return self.review_geometry(); + } + let Self::Ready { + purpose, + source, + anchor: Some(anchor), + raw_edge: Some(raw_edge), + logical_anchor, + logical_edge, + square_modifier, + .. + } = self + else { + return None; + }; + geometry::selection_geometry( + purpose, + source, + anchor, + raw_edge, + logical_anchor + .zip(logical_edge) + .map(|(start, end)| RegionSelection { start, end }), + square_modifier, + ) + } + + pub(super) fn review_geometry(self) -> Option { + let Self::Ready { + purpose, source, .. + } = self + else { + return None; + }; + let image_rect = self.stored_review_rect()?; + let display = crate::backend::wayland::state::screen_image::screen_rect_for_image_rect( + &source, image_rect, + ); + Some(RegionSelectionGeometry::review( + purpose, + image_rect, + RegionSelection { + start: (f64::from(display.x), f64::from(display.y)), + end: ( + f64::from(display.x.saturating_add(display.width)), + f64::from(display.y.saturating_add(display.height)), + ), + }, + )) + } + + pub(super) fn stored_review_rect(self) -> Option { + let Self::Ready { + purpose: RegionPurposeTag::CaptureInteractive, + source, + anchor: Some(anchor), + raw_edge: Some(raw_edge), + logical_edge: None, + .. + } = self + else { + return None; + }; + ImagePixelRect::from_points(anchor, raw_edge, source.image_size) + } + + pub(super) fn store_review_rect(&mut self, rect: ImagePixelRect) -> bool { + let Self::Ready { + anchor, + raw_edge, + logical_edge, + .. + } = self + else { + return false; + }; + *anchor = Some(ImagePoint::new(f64::from(rect.x()), f64::from(rect.y()))); + *raw_edge = Some(ImagePoint::new( + f64::from(rect.x() + rect.width()), + f64::from(rect.y() + rect.height()), + )); + *logical_edge = None; + true + } + + pub(super) fn set_square_modifier(&mut self, active: bool) -> bool { + let Self::Ready { + purpose, + square_modifier, + .. + } = self + else { + return false; + }; + let next = active && purpose.selection_policy().allow_square(); + if *square_modifier == next { + return false; + } + *square_modifier = next; + true + } + + pub(super) fn whole_image_selection(self) -> Option { + let Self::Ready { + purpose, source, .. + } = self + else { + return None; + }; + Some(RegionSelectionFinalize::Selected { + purpose, + rect: geometry::whole_image_rect(purpose, source.image_size)?, + }) + } + + pub(super) fn picker_measurement(self, pointer: (f64, f64)) -> Option { + if let Self::Measure { bounds, .. } = self { + if let Some(selection) = self.measure_selection() { + return Some(RegionPickerMeasurement::Size { + width: (selection.end.0 - selection.start.0).abs() as u32, + height: (selection.end.1 - selection.start.1).abs() as u32, + }); + } + let point = measure_anchor(pointer, bounds)?; + return Some(RegionPickerMeasurement::Point { + x: point.0.max(0.0) as u32, + y: point.1.max(0.0) as u32, + }); + } + let Self::Ready { + purpose, source, .. + } = self + else { + return None; + }; + if !purpose.is_capture() { + return None; + } + if let Some(geometry) = self.selection_geometry() { + let span = geometry.image_span(); + return Some(RegionPickerMeasurement::Size { + width: span.width(), + height: span.height(), + }); + } + geometry::point_measurement( + purpose, + image_point_for_screen_point(&source, pointer), + source.image_size, + ) + } +} diff --git a/src/backend/wayland/state/region_capture/board.rs b/src/backend/wayland/state/region_capture/board.rs new file mode 100644 index 000000000..a818ba120 --- /dev/null +++ b/src/backend/wayland/state/region_capture/board.rs @@ -0,0 +1,401 @@ +use crate::util::Rect; +use crate::{canvas_export::CanvasExportRect, screen_pixels::ImagePixelRect}; + +use super::super::screen_image::ScreenSourceToken; + +/// Round an exact world rectangle to the integer bounds `Shape::Image` needs. +/// +/// Each edge rounds to nearest, so the pasted image normally lands within half +/// a board pixel of where composition drew it. Going through the picker's +/// display rectangle instead would compound two outward quantizations and could +/// only ever stretch the crop, never shrink it. +/// +/// One exception: when both rounded edges of a sub-pixel crop coincide, the +/// image would have no extent, so it is given the single board pixel centred on +/// it. Each edge then stays strictly within one board pixel of its exact +/// position. Pushing the far edge out from an already-rounded near edge instead +/// would allow 1.5. Being under a board pixel wide is necessary but not +/// sufficient — position decides too, and `[0, ⅔]` rounds normally to `[0, 1]`. +/// +/// This is not confined to fractional scales: at any output scale above 1x a +/// crop a few source pixels wide is under a board pixel across. +pub(in crate::backend::wayland) fn board_bounds_for_world_rect( + exact: CanvasExportRect, +) -> Option { + let (left, right) = board_axis(exact.x, exact.width)?; + let (top, bottom) = board_axis(exact.y, exact.height)?; + Rect::from_min_max(left, top, right, bottom) +} + +/// One axis of the placement: the rounded edge pair, widened to the centred +/// board pixel when rounding collapses them. +fn board_axis(origin: f64, extent: f64) -> Option<(i32, i32)> { + let near = round_edge(origin)?; + let far = round_edge(origin + extent)?; + if far > near { + return Some((near, far)); + } + // The unit cell centred on the crop. Its start is clamped into the range a + // pair of edges can express: a crop straddling `i32::MAX` still has + // `[MAX - 1, MAX]` available, and one at `i32::MIN` has `[MIN, MIN + 1]`, + // so neither should fail for want of a representable neighbour. + let centre_start = origin + extent / 2.0 - 0.5; + if !centre_start.is_finite() { + return None; + } + let start = centre_start + .round() + .clamp(f64::from(i32::MIN), f64::from(i32::MAX - 1)) as i32; + let end = start + 1; + // Clamping only bites within a pixel of the representable edges, but a crop + // pushed past them cannot keep the bound this function promises. Refuse + // rather than place it somewhere it does not belong. + if (f64::from(start) - origin).abs() >= 1.0 || (f64::from(end) - (origin + extent)).abs() >= 1.0 + { + return None; + } + Some((start, end)) +} + +fn round_edge(value: f64) -> Option { + if !value.is_finite() { + return None; + } + let rounded = value.round(); + if rounded < f64::from(i32::MIN) || rounded > f64::from(i32::MAX) { + return None; + } + Some(rounded as i32) +} + +/// Map authoritative source-image pixel edges directly into board-world space. +/// +/// This intentionally bypasses the integer display rectangle used by picker +/// chrome. Its floor/ceil quantization can expand a crop at fractional output +/// scales. Zoom does not appear here: selecting an image-space rectangle has +/// already inverted the captured zoom view, so only the board offset and the +/// source image-to-logical-surface ratio remain. +pub(in crate::backend::wayland) fn world_rect_for_image_rect_exact( + rect: ImagePixelRect, + board_offset: (f64, f64), + source: ScreenSourceToken, +) -> Option { + if !board_offset.0.is_finite() + || !board_offset.1.is_finite() + || source.image_size.0 == 0 + || source.image_size.1 == 0 + || source.surface.0 == 0 + || source.surface.1 == 0 + || rect.x().checked_add(rect.width())? > source.image_size.0 + || rect.y().checked_add(rect.height())? > source.image_size.1 + { + return None; + } + let scale_x = f64::from(source.surface.0) / f64::from(source.image_size.0); + let scale_y = f64::from(source.surface.1) / f64::from(source.image_size.1); + CanvasExportRect::new( + board_offset.0 + f64::from(rect.x()) * scale_x, + board_offset.1 + f64::from(rect.y()) * scale_y, + f64::from(rect.width()) * scale_x, + f64::from(rect.height()) * scale_y, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::wayland::state::screen_image::ScreenImageKind; + + fn source(zoom_transformed: bool) -> ScreenSourceToken { + ScreenSourceToken { + output_id: 1, + output_layout_generation: 2, + kind: ScreenImageKind::Frozen, + image_generation: 3, + image_size: (800, 600), + stride: 3200, + surface: (800, 600), + output_scale: 1, + output_transform: wayland_client::protocol::wl_output::Transform::Normal, + zoom_transformed, + zoom_scale: if zoom_transformed { 2.0 } else { 1.0 }, + zoom_view_offset: if zoom_transformed { + (100.0, 50.0) + } else { + (0.0, 0.0) + }, + } + } + + #[test] + fn exact_image_mapping_preserves_native_and_fractional_scale_edges() { + let rect = ImagePixelRect::new(150, 225, 300, 150, (1200, 900)).unwrap(); + let mut token = source(false); + token.image_size = (1200, 900); + token.surface = (800, 600); + token.output_scale = 2; + assert_eq!( + world_rect_for_image_rect_exact(rect, (7.0, 11.0), token), + CanvasExportRect::new(107.0, 161.0, 200.0, 100.0) + ); + + token.image_size = (1600, 1200); + let rect = ImagePixelRect::new(200, 300, 400, 200, token.image_size).unwrap(); + assert_eq!( + world_rect_for_image_rect_exact(rect, (7.0, 11.0), token), + CanvasExportRect::new(107.0, 161.0, 200.0, 100.0) + ); + } + + #[test] + fn exact_image_mapping_is_invariant_to_the_captured_zoom_view() { + let rect = ImagePixelRect::new(20, 30, 80, 40, (800, 600)).unwrap(); + let plain = source(false); + let zoomed = source(true); + assert_eq!( + world_rect_for_image_rect_exact(rect, (7.0, 11.0), plain), + world_rect_for_image_rect_exact(rect, (7.0, 11.0), zoomed) + ); + assert_eq!( + world_rect_for_image_rect_exact(rect, (7.0, 11.0), zoomed), + CanvasExportRect::new(27.0, 41.0, 80.0, 40.0) + ); + } + + #[test] + fn board_bounds_round_each_edge_to_the_nearest_board_pixel() { + // Composition draws the crop over the exact world rectangle; placement + // has to land on integers, so each edge takes the nearest one and the + // pasted image stays within half a board pixel of the annotations. + assert_eq!( + board_bounds_for_world_rect(CanvasExportRect::new(10.4, 20.6, 30.4, 40.6).unwrap()), + Rect::new(10, 21, 31, 40), + "left 10.4 -> 10, right 40.8 -> 41, top 20.6 -> 21, bottom 61.2 -> 61" + ); + // Negative origins round half away from zero, matching `f64::round`. + assert_eq!( + board_bounds_for_world_rect(CanvasExportRect::new(-3.5, -0.4, 2.0, 1.0).unwrap()), + Rect::new(-4, 0, 2, 1) + ); + } + + #[test] + fn a_sub_pixel_crop_takes_a_whole_board_pixel_and_says_so() { + let bounds = + board_bounds_for_world_rect(CanvasExportRect::new(10.1, 20.1, 0.2, 0.2).unwrap()) + .expect("a rectangle narrower than a board pixel still places"); + assert_eq!( + (bounds.x, bounds.y, bounds.width, bounds.height), + (10, 20, 1, 1) + ); + + // This is the documented exception to the half-pixel bound. Both edges + // of a crop this thin round onto the same integer, so it takes the + // whole board pixel centred on it — here landing 0.7 past its far edge, + // still inside the one-pixel bound. + let far_edge_drift = f64::from(bounds.x + bounds.width) - (10.1 + 0.2); + assert!( + (far_edge_drift - 0.7).abs() < 1e-9, + "expected the far edge 0.7 out, got {far_edge_drift}" + ); + + // A crop whose edges do land in different buckets keeps the bound. + let ordinary = board_bounds_for_world_rect( + CanvasExportRect::new(0.0, 0.0, 2.0 / 3.0, 2.0 / 3.0).unwrap(), + ) + .expect("a two-thirds-pixel crop places"); + assert_eq!((ordinary.width, ordinary.height), (1, 1)); + let ordinary_drift = f64::from(ordinary.x + ordinary.width) - 2.0 / 3.0; + assert!( + ordinary_drift <= 0.5, + "no minimum was forced here, so the bound holds: {ordinary_drift}" + ); + } + + #[test] + fn board_placement_tracks_the_exact_crop_at_fractional_scale_and_under_zoom() { + // A 2x output: the display rectangle the picker paints is the image + // rectangle rounded outward, so placing from it would stretch the crop. + // Placing from the exact mapping does not. + let mut token = source(false); + token.image_size = (1600, 1200); + token.surface = (800, 600); + token.output_scale = 2; + let rect = ImagePixelRect::new(21, 17, 43, 31, token.image_size).unwrap(); + + let exact = world_rect_for_image_rect_exact(rect, (0.0, 0.0), token).unwrap(); + assert_eq!( + (exact.x, exact.y, exact.width, exact.height), + (10.5, 8.5, 21.5, 15.5) + ); + let placed = board_bounds_for_world_rect(exact).unwrap(); + assert_eq!( + (placed.x, placed.y, placed.width, placed.height), + (11, 9, 21, 15), + "each edge rounds once, from the true world position" + ); + for (edge, exact_edge) in [ + (f64::from(placed.x), exact.x), + (f64::from(placed.y), exact.y), + (f64::from(placed.x + placed.width), exact.x + exact.width), + (f64::from(placed.y + placed.height), exact.y + exact.height), + ] { + assert!( + (edge - exact_edge).abs() <= 0.5, + "{edge} drifted more than half a board pixel from {exact_edge}" + ); + } + + // Zoom is already inverted by the image-space selection, so a zoomed + // token places identically to a plain one. + let mut zoomed = token; + zoomed.zoom_transformed = true; + zoomed.zoom_scale = 2.0; + zoomed.zoom_view_offset = (100.0, 50.0); + assert_eq!( + world_rect_for_image_rect_exact(rect, (0.0, 0.0), zoomed) + .and_then(board_bounds_for_world_rect), + Some(placed) + ); + } + + /// The case that motivated centring: rounding the near edge first and then + /// pushing the far edge out puts `[10.5, 10.75]` at `[11, 12]`, 1.25 board + /// pixels past its far edge. The centred pixel keeps both edges under one. + #[test] + fn a_collapsed_axis_is_centred_rather_than_pushed_off_its_near_edge() { + let placed = + board_bounds_for_world_rect(CanvasExportRect::new(10.5, 10.5, 0.25, 0.25).unwrap()) + .expect("a quarter-pixel crop places"); + assert_eq!( + (placed.x, placed.y, placed.width, placed.height), + (10, 10, 1, 1) + ); + assert!( + (f64::from(placed.x + placed.width) - 10.75).abs() <= 0.5, + "the far edge stays close, not 1.25 out" + ); + } + + /// A crop under a board pixel across is not a fractional-scale curiosity: + /// two source pixels at an integer 2x output scale is one board pixel, so a + /// one-pixel crop there already collapses. + #[test] + fn an_integer_two_times_scale_also_reaches_the_collapsed_case() { + let mut token = source(false); + token.image_size = (1600, 1200); + token.surface = (800, 600); + token.output_scale = 2; + let one_pixel = ImagePixelRect::new(41, 41, 1, 1, token.image_size).unwrap(); + + let exact = world_rect_for_image_rect_exact(one_pixel, (0.0, 0.0), token).unwrap(); + assert_eq!( + (exact.x, exact.width), + (20.5, 0.5), + "half a board pixel wide" + ); + let placed = board_bounds_for_world_rect(exact).expect("it still places"); + assert_eq!((placed.width, placed.height), (1, 1)); + assert!( + edge_drifts(exact, placed) + .into_iter() + .all(|drift| drift < 1.0) + ); + } + + /// The bound the documentation promises, swept rather than sampled: every + /// edge lands strictly within one board pixel, and within half of one + /// whenever rounding did not collapse the axis. + #[test] + fn every_placement_stays_within_a_board_pixel_of_the_exact_crop() { + let steps = 0..40; + for origin_step in steps.clone() { + for extent_step in 1..40 { + let x = -3.0 + f64::from(origin_step) * 0.17; + let width = f64::from(extent_step) * 0.13; + let exact = CanvasExportRect::new(x, x, width, width).unwrap(); + let placed = board_bounds_for_world_rect(exact) + .unwrap_or_else(|| panic!("{x} + {width} must place")); + // Collapse is decided by the rounded edges, not by the placed + // width: an ordinary interval can round to width one too, and + // counting it as collapsed would excuse it from the tighter + // bound it actually keeps. + let collapsed = (x + width).round() <= x.round(); + for drift in edge_drifts(exact, placed) { + if collapsed { + assert!( + drift < 1.0, + "x={x} width={width} placed={placed:?} drifted {drift}, \ + outside the collapsed bound" + ); + } else { + assert!( + drift <= 0.5 + f64::EPSILON, + "x={x} width={width} placed={placed:?} drifted {drift}, \ + outside the ordinary bound" + ); + } + } + } + } + } + + /// Distance from each placed edge to the exact edge it represents. + fn edge_drifts(exact: CanvasExportRect, placed: Rect) -> [f64; 4] { + [ + (f64::from(placed.x) - exact.x).abs(), + (f64::from(placed.y) - exact.y).abs(), + (f64::from(placed.x + placed.width) - (exact.x + exact.width)).abs(), + (f64::from(placed.y + placed.height) - (exact.y + exact.height)).abs(), + ] + } + + /// A crop straddling the far end of the representable range still has + /// `[MAX - 1, MAX]` to sit in, and one at the near end has `[MIN, MIN + 1]`. + /// Neither may fail for want of a neighbour on the side it collapsed toward. + #[test] + fn a_collapsed_axis_at_the_integer_limits_still_places() { + let max = f64::from(i32::MAX); + let placed = + board_bounds_for_world_rect(CanvasExportRect::new(max - 0.4, 0.0, 0.8, 1.0).unwrap()) + .expect("a crop straddling i32::MAX places"); + assert_eq!((placed.x, placed.width), (i32::MAX - 1, 1)); + assert!( + edge_drifts_x(max - 0.4, 0.8, placed) + .into_iter() + .all(|d| d < 1.0) + ); + + let min = f64::from(i32::MIN); + let placed = + board_bounds_for_world_rect(CanvasExportRect::new(min - 0.4, 0.0, 0.8, 1.0).unwrap()) + .expect("a crop straddling i32::MIN places"); + assert_eq!((placed.x, placed.width), (i32::MIN, 1)); + assert!( + edge_drifts_x(min - 0.4, 0.8, placed) + .into_iter() + .all(|d| d < 1.0) + ); + } + + /// Clamping must not become a licence to place a crop that has run off the + /// representable range entirely: that keeps no bound at all. + #[test] + fn a_collapsed_axis_pushed_past_the_limits_is_refused() { + let max = f64::from(i32::MAX); + assert_eq!( + board_bounds_for_world_rect(CanvasExportRect::new(max + 0.3, 0.0, 0.1, 1.0).unwrap()), + None, + "a cell clamped back inside would sit more than a pixel from the crop" + ); + } + + /// Horizontal drifts only, for the cases whose vertical axis is an ordinary + /// one-pixel interval. + fn edge_drifts_x(origin: f64, extent: f64, placed: Rect) -> [f64; 2] { + [ + (f64::from(placed.x) - origin).abs(), + (f64::from(placed.x + placed.width) - (origin + extent)).abs(), + ] + } +} diff --git a/src/backend/wayland/state/region_capture/delivery.rs b/src/backend/wayland/state/region_capture/delivery.rs new file mode 100644 index 000000000..1696e9372 --- /dev/null +++ b/src/backend/wayland/state/region_capture/delivery.rs @@ -0,0 +1,390 @@ +use crate::canvas_export::{CanvasExportRect, CanvasRegionExportSnapshot, CanvasRegionSource}; +use crate::capture::{ + CaptureDestination, ImageFormatMetadata, ImageOperationKind, RenderImageRequest, + RenderedImageDeliveryRequest, +}; +use crate::input::state::{BoardPasteTarget, RegionPurposeTag, Toast, ToastPriority}; +use crate::screen_pixels::{EmbeddedImageLimits, ImagePixelRect}; +use crate::ui::RegionAction; + +use super::super::capture::should_exit_after_capture; +use super::super::screen_image::{ + CropError, copy_image_rect, displayed_screen_image, screen_source_is, + shared_displayed_screen_image, +}; +use super::{ActiveScreenRegion, FreezeOwnership, RegionCaptureIntent, RegionSelectionFinalize}; +use crate::backend::wayland::state::WaylandState; + +const TOAST_SOURCE: &str = "capture"; + +pub(super) fn region_delivery_request( + render: crate::capture::ImageRenderJob, + intent: &RegionCaptureIntent, + destination: CaptureDestination, +) -> RenderedImageDeliveryRequest { + let save_config = intent.save_config().cloned().map(|mut save_config| { + // The source crop is encoded by the shared Cairo PNG path regardless + // of the configured screenshot format. Keep both initial delivery and + // any clipboard fallback named for the bytes they actually contain. + save_config.format = ImageFormatMetadata::png().extension; + save_config + }); + RenderedImageDeliveryRequest { + render, + destination, + save_config, + operation: ImageOperationKind::Screenshot, + fallback_format_override: Some(ImageFormatMetadata::png()), + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum RegionSubmit { + Deliver(CaptureDestination), + Board(BoardPasteTarget), +} + +/// What a submission renders from. One or the other, never both and never +/// neither, so the render job cannot be handed an impossible pair. +#[derive(Debug)] +pub(super) enum RegionRenderSource { + /// Committed board drawings composited over the crop, on the worker. + Annotated(Box), + /// The checked crop taken on the event loop. + Raw(crate::screen_pixels::PackedArgb32), +} + +/// The PNG job for a submission. Shared by every destination, so what Copy +/// writes and what Board pastes can only differ by the Review toggle. +pub(super) fn region_render_job(source: RegionRenderSource) -> crate::capture::ImageRenderJob { + match source { + RegionRenderSource::Annotated(snapshot) => { + Box::new(move || crate::canvas_export::render_canvas_region_png(*snapshot)) + } + RegionRenderSource::Raw(pixels) => { + Box::new(move || crate::capture::png::encode_packed_argb32_png(&pixels)) + } + } +} + +pub(super) const fn review_delivery_destination( + action: RegionAction, +) -> Option { + match action { + RegionAction::Copy => Some(CaptureDestination::ClipboardOnly), + RegionAction::Save => Some(CaptureDestination::FileOnly), + RegionAction::Both => Some(CaptureDestination::ClipboardAndFile), + RegionAction::Board | RegionAction::ToggleIncludeDrawings => None, + } +} + +impl WaylandState { + pub(in crate::backend::wayland) fn region_review_action_at( + &self, + point: (f64, f64), + ) -> Option { + if !self.input_state.region_state().is_review() { + return None; + } + let selection = self.region_selection_geometry()?.display_selection(); + crate::ui::RegionActionBar::place(selection, (self.surface.width(), self.surface.height())) + .hit(point) + } + + /// Whether the pointer sits over the reviewed rectangle — the area a press + /// would start moving. Cursor feedback only; `begin_review_move` remains + /// the authoritative test in image space. + pub(in crate::backend::wayland) fn region_review_selection_contains( + &self, + point: (f64, f64), + ) -> bool { + if !self.input_state.region_state().is_review() { + return false; + } + self.region_selection_geometry().is_some_and(|geometry| { + let selection = geometry.display_selection(); + let left = selection.start.0.min(selection.end.0); + let right = selection.start.0.max(selection.end.0); + let top = selection.start.1.min(selection.end.1); + let bottom = selection.start.1.max(selection.end.1); + point.0 >= left && point.0 < right && point.1 >= top && point.1 < bottom + }) + } + + pub(in crate::backend::wayland) fn region_review_bar_contains( + &self, + point: (f64, f64), + ) -> bool { + if !self.input_state.region_state().is_review() { + return false; + } + self.region_selection_geometry().is_some_and(|geometry| { + crate::ui::RegionActionBar::place( + geometry.display_selection(), + (self.surface.width(), self.surface.height()), + ) + .contains(point) + }) + } + + pub(in crate::backend::wayland) fn submit_region_review_action( + &mut self, + action: RegionAction, + ) -> bool { + if action == RegionAction::ToggleIncludeDrawings { + return self.toggle_region_picker_include_drawings(); + } + let Some(rect) = self.region_review_rect() else { + return false; + }; + let submit = match review_delivery_destination(action) { + Some(destination) => RegionSubmit::Deliver(destination), + None => { + debug_assert_eq!(action, RegionAction::Board); + let limits = EmbeddedImageLimits::default(); + if !limits.allows_pixels(rect.width(), rect.height()) { + self.input_state.push_toast( + ToastPriority::Info, + TOAST_SOURCE, + Toast::warning("Region is too large to add to the board."), + ); + return true; + } + // Placement maps the authoritative image rectangle, not the + // picker's outward-rounded display rectangle: composition uses + // the same exact world rectangle, so the pasted image lands + // where the annotations were drawn instead of stretched by a + // quantization the chrome introduced. + // + // A missing source or a rectangle that will not map is a dead + // end for this capture, not a no-op: falling through silently + // would leave Review painted over a reservation nothing can + // ever complete, so it retires through the usual funnel. + let placement = match self.data.active_screen_region { + Some(ActiveScreenRegion::Ready { source, .. }) => { + super::world_rect_for_image_rect_exact( + rect, + self.board_view_offset(), + source, + ) + .and_then(super::board_bounds_for_world_rect) + } + _ => None, + }; + let Some(world_bounds) = placement else { + self.cancel_region_capture(); + self.input_state.push_toast( + ToastPriority::Critical, + TOAST_SOURCE, + Toast::error("Could not place that region on the board."), + ); + return true; + }; + RegionSubmit::Board(BoardPasteTarget { + board_id: self.input_state.boards.active_board_id().to_string(), + page_index: self.input_state.boards.active_page_index(), + page_generation: self.input_state.boards.active_page_generation(), + world_bounds, + }) + } + }; + self.submit_region_capture_with(rect, submit); + true + } + + /// Submit the whole displayed image and retire any in-flight drag owner. + /// + /// Keeping this transaction on WaylandState leaves the keyboard protocol + /// callback responsible only for translating Ctrl+A into one state action. + pub(in crate::backend::wayland) fn submit_whole_region_capture(&mut self) { + let Some(RegionSelectionFinalize::Selected { purpose, rect }) = + self.whole_image_region_selection() + else { + return; + }; + self.clear_region_window_snap(); + self.retire_region_selection_owner(self.input_state.region_state().selection_owner()); + if purpose == RegionPurposeTag::CaptureInteractive { + self.enter_region_review(rect); + } else { + self.submit_region_capture(rect); + } + } + + pub(in crate::backend::wayland) fn submit_region_capture(&mut self, rect: ImagePixelRect) { + let destination = match self.capture.region_phase() { + crate::backend::wayland::capture::RegionCapturePhase::Reserved(intent) => { + intent.destination() + } + _ => return, + }; + self.submit_region_capture_with(rect, RegionSubmit::Deliver(destination)); + } + + fn submit_region_capture_with(&mut self, rect: ImagePixelRect, submit: RegionSubmit) { + let Some(ActiveScreenRegion::Ready { + source: token, + freeze_ownership, + purpose, + include_drawings, + .. + }) = self.data.active_screen_region + else { + self.cancel_region_capture_ui_and_lifecycle(); + return; + }; + if !purpose.is_capture() { + return; + } + let Some(source) = displayed_screen_image( + &self.zoom, + &self.frozen, + self.input_state.board_is_transparent(), + ) else { + self.cancel_region_capture_for_source_change(); + return; + }; + if !screen_source_is( + &token, + &source, + &self.zoom, + &self.frozen, + (self.surface.width(), self.surface.height()), + ) { + self.cancel_region_capture_for_source_change(); + return; + } + // Every destination honours the Review toggle, Board included. Pasting a + // composited crop onto the board it came from bakes a second, flattened + // copy of those annotations over the live shapes; the toggle is the + // control for that, so it is not overridden per destination here. + // + // The composed path validates and crops on the capture worker. The raw + // path retains the existing checked event-loop crop, but never pays + // that copy when the result would be discarded by composition. + let raw_pixels = if include_drawings { + None + } else { + match copy_image_rect(source.image, rect) { + Ok(pixels) => Some(pixels), + Err(error) => { + let message = match error { + CropError::Empty => "That selection has no screen pixels.", + CropError::OutOfBounds => "Could not read that region of the screen image.", + }; + self.cancel_region_capture(); + self.input_state.push_toast( + ToastPriority::Critical, + TOAST_SOURCE, + Toast::error(message), + ); + return; + } + } + }; + let drawing_snapshot = include_drawings + .then(|| { + let shared_image = + shared_displayed_screen_image(&self.zoom, &self.frozen, source.kind)?; + let (origin_x, origin_y) = self.board_view_offset(); + super::world_rect_for_image_rect_exact(rect, (origin_x, origin_y), token)?; + let logical_bounds = CanvasExportRect::new( + origin_x, + origin_y, + f64::from(token.surface.0), + f64::from(token.surface.1), + )?; + // This explicit option shares the full captured source so blur + // at a crop edge can sample the same neighboring pixels as the + // live canvas. The immutable image handle and frame snapshot + // make the worker job independent of later edits. + Some(CanvasRegionExportSnapshot { + source: CanvasRegionSource { + image: shared_image, + logical_bounds, + }, + selection: rect, + frame: self + .input_state + .boards + .active_frame() + .clone_without_history(), + spotlight: crate::canvas_export::SpotlightPassSnapshot { + dim_opacity: self.input_state.spotlight_dim_opacity, + feather: self.input_state.spotlight_feather, + }, + }) + }) + .flatten(); + let render_source = match (drawing_snapshot, raw_pixels) { + (Some(snapshot), _) => RegionRenderSource::Annotated(Box::new(snapshot)), + (None, Some(pixels)) => RegionRenderSource::Raw(pixels), + (None, None) => { + // The composed path skips the event-loop crop, so a snapshot + // that could not be built leaves nothing at all to render. + self.cancel_region_capture(); + self.input_state.push_toast( + ToastPriority::Critical, + TOAST_SOURCE, + Toast::error("Could not map the selected drawings into the captured region."), + ); + return; + } + }; + + let Some(intent) = self.capture.begin_region_submission() else { + self.cancel_region_capture(); + self.input_state.push_toast( + ToastPriority::Critical, + TOAST_SOURCE, + Toast::error("Region capture state was inconsistent; try again."), + ); + return; + }; + self.clear_screen_region_ui_only(); + if let FreezeOwnership::PickerOwned { image_generation } = freeze_ownership { + self.release_owned_frozen_generation(image_generation); + } + + let render = region_render_job(render_source); + match submit { + RegionSubmit::Deliver(destination) => { + self.capture.set_exit_on_success(should_exit_after_capture( + intent.exit_mode(), + destination, + )); + let request = region_delivery_request(render, &intent, destination); + let submission = self + .capture + .manager_mut() + .request_rendered_image_delivery(request); + self.accept_capture_submission(submission, ImageOperationKind::Screenshot); + } + RegionSubmit::Board(target) => { + self.capture.set_exit_on_success(false); + let submission = + self.capture + .manager_mut() + .request_render_image(RenderImageRequest { + render, + operation: ImageOperationKind::Screenshot, + }); + let accepted_id = submission.as_ref().ok().copied(); + if self.accept_capture_submission(submission, ImageOperationKind::Screenshot) { + let Some(id) = accepted_id else { + unreachable!("an accepted submission has an id") + }; + if !self.capture.set_pending_board_paste(id, target) { + self.capture.manager_mut().mark_unhealthy(); + self.capture.finish_capture_lifecycle(); + self.input_state.push_toast( + ToastPriority::Critical, + TOAST_SOURCE, + Toast::error("Region was not added to the board."), + ); + } + } + } + } + } +} diff --git a/src/backend/wayland/state/region_capture/events.rs b/src/backend/wayland/state/region_capture/events.rs new file mode 100644 index 000000000..a18143fe9 --- /dev/null +++ b/src/backend/wayland/state/region_capture/events.rs @@ -0,0 +1,248 @@ +use super::*; + +/// The grip under `logical`, placed from the rectangle Review is showing. The +/// renderer places its chips from the same geometry, so what is painted and +/// what is grabbable cannot drift. +pub(super) fn review_resize_handle_at( + region: &ActiveScreenRegion, + logical: (f64, f64), +) -> Option { + let selection = region + .review_geometry() + .map(RegionSelectionGeometry::display_selection)?; + crate::ui::RegionResizeHandles::place(selection).hit(logical) +} + +pub(super) fn begin_region_selection_event( + backend: &mut Option, + input_state: &mut crate::input::InputState, + owner: RegionInputSource, + logical: (f64, f64), +) -> bool { + if input_state.region_state().selection_owner().is_some() { + return false; + } + let Some(region) = backend.as_mut() else { + return false; + }; + if input_state.region_state().is_review() { + // A grip is checked before the rectangle it decorates: corner chips sit + // on the rectangle's own edge, so the interior test would otherwise + // swallow every resize press. + if let Some(handle) = review_resize_handle_at(region, logical) + && region.begin_review_resize(handle, logical) + { + return input_state.begin_region_review_move(owner); + } + if region.begin_review_move(logical) { + return input_state.begin_region_review_move(owner); + } + region.reset_review_for_selection(); + } + if !region.begin_selection(logical) { + return false; + } + let Some(preview) = region.display_selection() else { + return false; + }; + if !input_state.start_region_selection(owner, preview.start) { + return false; + } + input_state.update_region_selection(owner, preview.end); + true +} + +pub(super) fn update_region_selection_event( + backend: &mut Option, + input_state: &mut crate::input::InputState, + owner: RegionInputSource, + logical: (f64, f64), +) { + if backend + .as_ref() + .is_some_and(|region| region.purpose().is_capture()) + && input_state.region_is_active() + { + // Capture chrome follows hover even when no device owns a drag: Armed + // paints the crosshair/readout, while Review paints bar hover and the + // optional loupe. Motion adapters call this only after a position + // event, so schedule the selector's full-surface repaint here. + input_state.dirty_tracker.mark_full(); + input_state.needs_redraw = true; + } else if backend + .as_ref() + .is_some_and(|region| region.purpose() == RegionPurposeTag::Measure) + && input_state.region_is_active() + { + // Measure has no full-screen scrim. Its old/current chrome strips are + // added by collect_ui_effect_damage, so motion only schedules a frame. + input_state.needs_redraw = true; + } + if !input_state.region_selection_is_owned_by(owner) { + return; + } + if input_state.region_state().is_review() { + if let Some(region) = backend.as_mut() + && (region.update_review_resize(logical) || region.update_review_move(logical)) + && let Some(preview) = region + .review_geometry() + .map(RegionSelectionGeometry::display_selection) + { + input_state.update_region_review_display(preview); + } + return; + } + if let Some(region) = backend.as_mut() + && region.update_endpoint(logical) + && let Some(preview) = region.display_selection() + { + input_state.update_region_selection(owner, preview.end); + } +} + +/// End whichever Review drag a device owned. Exactly one of the two can be in +/// flight, so this reports a single "the drag ended" answer that stays in step +/// with the UI state's own move owner. +fn finish_review_drag(region: &mut ActiveScreenRegion) -> bool { + region.finish_review_resize() || region.finish_review_move() +} + +pub(super) fn sync_region_square_modifier_event( + backend: &mut Option, + input_state: &mut crate::input::InputState, + shift: bool, +) -> bool { + let Some(region) = backend.as_mut() else { + return false; + }; + if !region.set_square_modifier(shift) { + return false; + } + if let Some(owner) = input_state.region_state().selection_owner() + && let Some(preview) = region + .selection_geometry() + .map(RegionSelectionGeometry::display_selection) + { + input_state.update_region_selection(owner, preview.end); + } + true +} + +pub(super) fn initial_square_modifier(purpose: RegionPurposeTag, shift: bool) -> bool { + shift && purpose.selection_policy().allow_square() +} + +pub(super) fn rearm_region_selection_event( + backend: &mut Option, + input_state: &mut crate::input::InputState, +) { + if let Some(ActiveScreenRegion::Ready { + anchor, + raw_edge, + logical_anchor, + logical_edge, + review_resize, + .. + }) = backend.as_mut() + { + *anchor = None; + *raw_edge = None; + *logical_anchor = None; + *logical_edge = None; + *review_resize = None; + } + if let Some(ActiveScreenRegion::Measure { anchor, edge, .. }) = backend.as_mut() { + *anchor = None; + *edge = None; + } + input_state.rearm_region_selection(); +} + +pub(super) fn region_owner_lost_event( + backend: &mut Option, + input_state: &mut crate::input::InputState, + source: RegionInputSource, +) -> RegionOwnerLoss { + if !input_state.region_selection_is_owned_by(source) { + return RegionOwnerLoss::NotOwned; + } + if input_state.region_state().purpose() == Some(RegionPurposeTag::Measure) { + rearm_region_selection_event(backend, input_state); + return RegionOwnerLoss::Rearmed; + } + let Some(purpose) = backend.as_ref().map(|region| region.purpose()) else { + return RegionOwnerLoss::NotOwned; + }; + if !purpose.is_capture() { + return RegionOwnerLoss::Cancel(purpose); + } + if input_state.region_state().is_review() { + let finished_backend = backend.as_mut().is_some_and(finish_review_drag); + let finished_ui = input_state.finish_region_review_move(source); + debug_assert_eq!(finished_backend, finished_ui); + return RegionOwnerLoss::Rearmed; + } + rearm_region_selection_event(backend, input_state); + RegionOwnerLoss::Rearmed +} + +pub(in crate::backend::wayland::state) fn finalize_region_selection_event( + backend: &mut Option, + input_state: &mut crate::input::InputState, + owner: RegionInputSource, + logical: (f64, f64), +) -> RegionSelectionFinalize { + if !input_state.region_selection_is_owned_by(owner) { + return RegionSelectionFinalize::NotOwned; + } + if input_state.region_state().purpose() == Some(RegionPurposeTag::Measure) { + update_region_selection_event(backend, input_state, owner, logical); + return if backend + .as_ref() + .and_then(|region| region.measure_selection()) + .is_some() + && input_state.complete_measurement(owner) + { + RegionSelectionFinalize::Measured + } else { + RegionSelectionFinalize::NotOwned + }; + } + if input_state.region_state().is_review() { + update_region_selection_event(backend, input_state, owner, logical); + let finished_backend = backend.as_mut().is_some_and(finish_review_drag); + let finished_ui = input_state.finish_region_review_move(owner); + debug_assert_eq!(finished_backend, finished_ui); + return if finished_backend { + RegionSelectionFinalize::Reviewed + } else { + RegionSelectionFinalize::NotOwned + }; + } + update_region_selection_event(backend, input_state, owner, logical); + let Some(rect) = backend + .as_ref() + .copied() + .and_then(ActiveScreenRegion::selection_rect) + else { + rearm_region_selection_event(backend, input_state); + return RegionSelectionFinalize::Rearmed; + }; + let purpose = backend + .as_ref() + .expect("a selected region still has backend state") + .purpose(); + if purpose == RegionPurposeTag::CaptureInteractive { + let generation = backend + .as_ref() + .expect("a reviewed region still has backend state") + .generation(); + let display = backend + .as_mut() + .and_then(|region| region.enter_review(rect)) + .expect("an interactive rectangle enters review"); + input_state.activate_region_review(purpose, generation, display); + return RegionSelectionFinalize::Reviewed; + } + RegionSelectionFinalize::Selected { purpose, rect } +} diff --git a/src/backend/wayland/state/region_capture/geometry.rs b/src/backend/wayland/state/region_capture/geometry.rs new file mode 100644 index 000000000..d7a83a524 --- /dev/null +++ b/src/backend/wayland/state/region_capture/geometry.rs @@ -0,0 +1,131 @@ +use crate::input::state::{RegionPurposeTag, RegionSelection}; +use crate::screen_pixels::{ + ImagePixelRect, ImagePoint, PixelSpan, clamp_edge, pixel_span, snap_anchor, squared_cursor, +}; + +use super::super::screen_image::{ScreenSourceToken, screen_point_for_image_point}; + +/// One coherent view of the active drag for painting, readout, and delivery. +/// +/// `image_span` deliberately permits an empty axis while the pointer is down; +/// `image_rect` becomes available only once both axes contain pixels. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(in crate::backend::wayland) struct RegionSelectionGeometry { + image_span: PixelSpan, + display_selection: RegionSelection, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::backend::wayland) enum RegionPickerMeasurement { + Point { x: u32, y: u32 }, + Size { width: u32, height: u32 }, +} + +impl RegionSelectionGeometry { + pub(super) fn review( + purpose: RegionPurposeTag, + rect: ImagePixelRect, + display_selection: RegionSelection, + ) -> Self { + debug_assert_eq!(purpose, RegionPurposeTag::CaptureInteractive); + Self::authoritative(purpose, rect, display_selection) + } + + pub(super) fn authoritative( + purpose: RegionPurposeTag, + rect: ImagePixelRect, + display_selection: RegionSelection, + ) -> Self { + debug_assert!(purpose.is_capture()); + let image_span = pixel_span( + ImagePoint::new(f64::from(rect.x()), f64::from(rect.y())), + ImagePoint::new( + f64::from(rect.x() + rect.width()), + f64::from(rect.y() + rect.height()), + ), + (rect.x() + rect.width(), rect.y() + rect.height()), + ) + .expect("a review rectangle is a non-empty pixel span"); + Self { + image_span, + display_selection, + } + } + + pub const fn image_span(self) -> PixelSpan { + self.image_span + } + + pub const fn display_selection(self) -> RegionSelection { + self.display_selection + } + + pub fn image_rect(self) -> Option { + self.image_span.try_into().ok() + } +} + +pub(super) fn selection_anchor( + purpose: RegionPurposeTag, + mapped: ImagePoint, + bounds: (u32, u32), +) -> Option { + if purpose.selection_policy().snap_anchor() { + snap_anchor(mapped, bounds) + } else { + clamp_edge(mapped, bounds) + } +} + +pub(super) fn selection_geometry( + purpose: RegionPurposeTag, + source: ScreenSourceToken, + anchor: ImagePoint, + raw_edge: ImagePoint, + logical_selection: Option, + square_modifier: bool, +) -> Option { + let effective_edge = if square_modifier && purpose.selection_policy().allow_square() { + squared_cursor(anchor, raw_edge, source.image_size)? + } else { + raw_edge + }; + let image_span = pixel_span(anchor, effective_edge, source.image_size)?; + let display_selection = if purpose.is_capture() { + RegionSelection { + start: screen_point_for_image_point(&source, anchor), + end: screen_point_for_image_point(&source, effective_edge), + } + } else { + logical_selection? + }; + Some(RegionSelectionGeometry { + image_span, + display_selection, + }) +} + +pub(super) fn whole_image_rect( + purpose: RegionPurposeTag, + bounds: (u32, u32), +) -> Option { + if !purpose.is_capture() { + return None; + } + ImagePixelRect::whole(bounds) +} + +pub(super) fn point_measurement( + purpose: RegionPurposeTag, + mapped: ImagePoint, + bounds: (u32, u32), +) -> Option { + if !purpose.is_capture() { + return None; + } + let point = snap_anchor(mapped, bounds)?; + Some(RegionPickerMeasurement::Point { + x: point.x as u32, + y: point.y as u32, + }) +} diff --git a/src/backend/wayland/state/region_capture/intent.rs b/src/backend/wayland/state/region_capture/intent.rs new file mode 100644 index 000000000..b5e81a810 --- /dev/null +++ b/src/backend/wayland/state/region_capture/intent.rs @@ -0,0 +1,175 @@ +use crate::{ + backend::ExitAfterCaptureMode, + capture::{CaptureDestination, file::FileSaveConfig}, + config::Action, + input::state::RegionPurposeTag, +}; + +/// Runtime picker settings captured when a region action is accepted. +/// +/// Keeping these values outside the live config prevents a reload from +/// changing a picker that is already open. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(in crate::backend::wayland) struct RegionPickerOptions { + show_size_readout: bool, + show_loupe: bool, + show_legend: bool, +} + +impl RegionPickerOptions { + pub(in crate::backend::wayland) const fn new( + show_size_readout: bool, + show_loupe: bool, + show_legend: bool, + ) -> Self { + Self { + show_size_readout, + show_loupe, + show_legend, + } + } + + pub(in crate::backend::wayland) const fn show_size_readout(self) -> bool { + self.show_size_readout + } + + pub(in crate::backend::wayland) const fn show_loupe(self) -> bool { + self.show_loupe + } + + pub(in crate::backend::wayland) const fn show_legend(self) -> bool { + self.show_legend + } +} + +/// Immutable snapshot of a region capture action. +/// +/// The action entry point resolves config-derived values once and stores them +/// here. Acquisition, fallback, and submission must use this snapshot instead +/// of consulting mutable runtime config again. +#[derive(Clone, Debug)] +pub(in crate::backend::wayland) struct RegionCaptureIntent { + action: Action, + purpose: RegionPurposeTag, + destination: CaptureDestination, + save_config: Option, + exit_mode: ExitAfterCaptureMode, + options: RegionPickerOptions, + include_drawings: bool, +} + +impl RegionCaptureIntent { + pub(in crate::backend::wayland) fn new( + action: Action, + purpose: RegionPurposeTag, + destination: CaptureDestination, + save_config: Option, + exit_mode: ExitAfterCaptureMode, + options: RegionPickerOptions, + include_drawings: bool, + ) -> Self { + Self { + action, + purpose, + destination, + save_config, + exit_mode, + options, + include_drawings, + } + } + + pub(in crate::backend::wayland) const fn action(&self) -> Action { + self.action + } + + pub(in crate::backend::wayland) const fn purpose(&self) -> RegionPurposeTag { + self.purpose + } + + pub(in crate::backend::wayland) const fn destination(&self) -> CaptureDestination { + self.destination + } + + pub(in crate::backend::wayland) const fn save_config(&self) -> Option<&FileSaveConfig> { + self.save_config.as_ref() + } + + pub(in crate::backend::wayland) const fn exit_mode(&self) -> ExitAfterCaptureMode { + self.exit_mode + } + + pub(in crate::backend::wayland) const fn options(&self) -> RegionPickerOptions { + self.options + } + + pub(in crate::backend::wayland) const fn include_drawings(&self) -> bool { + self.include_drawings + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use super::*; + + #[test] + fn intent_is_a_complete_immutable_action_snapshot() { + let save_config = FileSaveConfig { + save_directory: PathBuf::from("/tmp/region-captures"), + filename_template: "region-%Y%m%d".to_string(), + format: "png".to_string(), + }; + let options = RegionPickerOptions::new(true, false, true); + let intent = RegionCaptureIntent::new( + Action::CaptureClipboardRegion, + RegionPurposeTag::CaptureDeliver, + CaptureDestination::ClipboardOnly, + Some(save_config), + ExitAfterCaptureMode::Auto, + options, + true, + ); + + assert_eq!(intent.action(), Action::CaptureClipboardRegion); + assert_eq!(intent.purpose(), RegionPurposeTag::CaptureDeliver); + assert_eq!(intent.destination(), CaptureDestination::ClipboardOnly); + assert_eq!( + intent.save_config().unwrap().save_directory, + PathBuf::from("/tmp/region-captures") + ); + assert_eq!( + intent.save_config().unwrap().filename_template, + "region-%Y%m%d" + ); + assert_eq!(intent.save_config().unwrap().format, "png"); + assert_eq!(intent.exit_mode(), ExitAfterCaptureMode::Auto); + assert_eq!(intent.options(), options); + assert!(intent.include_drawings()); + assert!(intent.options().show_size_readout()); + assert!(!intent.options().show_loupe()); + assert!(intent.options().show_legend()); + } + + #[test] + fn interactive_purpose_and_all_option_values_are_representable() { + let options = RegionPickerOptions::new(false, true, false); + let intent = RegionCaptureIntent::new( + Action::CaptureSelection, + RegionPurposeTag::CaptureInteractive, + CaptureDestination::ClipboardAndFile, + None, + ExitAfterCaptureMode::Never, + options, + false, + ); + + assert_eq!(intent.purpose(), RegionPurposeTag::CaptureInteractive); + assert!(intent.save_config().is_none()); + assert!(!intent.options().show_size_readout()); + assert!(intent.options().show_loupe()); + assert!(!intent.options().show_legend()); + assert!(!intent.include_drawings()); + } +} diff --git a/src/backend/wayland/state/region_capture/measure.rs b/src/backend/wayland/state/region_capture/measure.rs new file mode 100644 index 000000000..679583478 --- /dev/null +++ b/src/backend/wayland/state/region_capture/measure.rs @@ -0,0 +1,71 @@ +use super::*; + +pub(super) fn measure_anchor(point: (f64, f64), bounds: (u32, u32)) -> Option<(f64, f64)> { + if !point.0.is_finite() || !point.1.is_finite() || bounds.0 == 0 || bounds.1 == 0 { + return None; + } + Some(( + point.0.floor().clamp(0.0, f64::from(bounds.0 - 1)), + point.1.floor().clamp(0.0, f64::from(bounds.1 - 1)), + )) +} + +pub(super) fn measure_edge( + anchor: (f64, f64), + point: (f64, f64), + bounds: (u32, u32), +) -> Option<(f64, f64)> { + if !point.0.is_finite() || !point.1.is_finite() { + return None; + } + let edge = |origin: f64, value: f64| { + if value >= origin { + value.ceil() + } else { + value.floor() + } + }; + Some(( + edge(anchor.0, point.0).clamp(0.0, f64::from(bounds.0)), + edge(anchor.1, point.1).clamp(0.0, f64::from(bounds.1)), + )) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum MeasureModeTransition { + Start, + Cancel, + Refuse, +} + +pub(super) fn measure_mode_transition( + purpose: Option, + screen_modal_engaged: bool, +) -> MeasureModeTransition { + if purpose == Some(RegionPurposeTag::Measure) { + MeasureModeTransition::Cancel + } else if screen_modal_engaged { + MeasureModeTransition::Refuse + } else { + MeasureModeTransition::Start + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::backend::wayland) enum RegionSelectionFinalize { + NotOwned, + Rearmed, + Reviewed, + Measured, + Selected { + purpose: RegionPurposeTag, + rect: ImagePixelRect, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::backend::wayland) enum RegionOwnerLoss { + NotOwned, + Rearmed, + Cancel(RegionPurposeTag), +} diff --git a/src/backend/wayland/state/region_capture/picker.rs b/src/backend/wayland/state/region_capture/picker.rs new file mode 100644 index 000000000..4322097fb --- /dev/null +++ b/src/backend/wayland/state/region_capture/picker.rs @@ -0,0 +1,401 @@ +use crate::backend::wayland::acquisition::ScreenAcquisitionOwner; +use crate::backend::wayland::zoom::ZoomWaiterOwner; +use crate::capture::{ + CaptureDestination, CaptureType, + file::{FileSaveConfig, expand_tilde}, +}; +use crate::config::{Action, RegionPicker}; +use crate::input::state::{RegionPurposeTag, ScreenCaptureSource, Toast, ToastPriority}; + +use super::super::capture::{overlay_suppression_for_screenshot, should_exit_after_capture}; +use super::super::screen_image::displayed_screen_image; +use super::{ActiveScreenRegion, FreezeOwnership, RegionCaptureIntent, RegionPickerOptions}; +use crate::backend::wayland::state::WaylandState; + +const TOAST_SOURCE: &str = "capture"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum RegionPickerEntry { + Activate, + WaitForZoom, + AutoFreeze, + Legacy, + RefuseSolidZoom, + ZoomImageUnavailable, +} + +pub(super) fn region_picker_entry( + has_source: bool, + board_is_transparent: bool, + zoom_engaged: bool, + zoom_active: bool, + frozen_enabled: bool, +) -> RegionPickerEntry { + if has_source { + RegionPickerEntry::Activate + } else if !board_is_transparent && zoom_engaged { + RegionPickerEntry::RefuseSolidZoom + } else if zoom_engaged && !zoom_active { + RegionPickerEntry::WaitForZoom + } else if !frozen_enabled { + RegionPickerEntry::Legacy + } else if zoom_active { + RegionPickerEntry::ZoomImageUnavailable + } else { + // Unlike OCR and the eyedropper, the capture picker deliberately + // freezes over a solid board so the crop matches the forced backdrop. + RegionPickerEntry::AutoFreeze + } +} + +pub(super) fn region_destination( + action: Action, + default_destination: CaptureDestination, +) -> Option { + match action { + Action::CaptureSelection => Some(default_destination), + Action::CaptureClipboardSelection | Action::CaptureClipboardRegion => { + Some(CaptureDestination::ClipboardOnly) + } + Action::CaptureFileSelection | Action::CaptureFileRegion => { + Some(CaptureDestination::FileOnly) + } + Action::CaptureRegionInteractive => Some(default_destination), + _ => None, + } +} + +pub(super) fn legacy_region_request( + intent: &RegionCaptureIntent, +) -> crate::capture::CaptureRequest { + crate::capture::CaptureRequest { + capture_type: CaptureType::Selection { + x: 0, + y: 0, + width: 0, + height: 0, + }, + destination: intent.destination(), + save_config: intent.save_config().cloned(), + } +} + +impl WaylandState { + pub(in crate::backend::wayland::state) fn begin_region_capture_action( + &mut self, + action: Action, + default_destination: CaptureDestination, + ) { + let Some(destination) = region_destination(action, default_destination) else { + return; + }; + if !self.config.capture.enabled { + self.input_state.push_toast( + ToastPriority::Info, + TOAST_SOURCE, + Toast::warning("Screen capture is disabled."), + ); + return; + } + if self.capture.is_in_progress() { + self.input_state.push_toast( + ToastPriority::Info, + TOAST_SOURCE, + Toast::warning("Another capture operation is still in progress."), + ); + return; + } + + let interactive = action == Action::CaptureRegionInteractive; + let purpose = if interactive { + RegionPurposeTag::CaptureInteractive + } else { + RegionPurposeTag::CaptureDeliver + }; + let save_config = + (interactive || !matches!(destination, CaptureDestination::ClipboardOnly)).then(|| { + FileSaveConfig { + save_directory: expand_tilde(&self.config.capture.save_directory), + filename_template: self.config.capture.filename_template.clone(), + // Keep the configured format in the immutable intent so an + // explicit slurp handoff preserves legacy behavior. Native + // delivery converts its own save metadata to PNG below. + format: self.config.capture.format.clone(), + } + }); + let region = &self.config.capture.region; + let options = RegionPickerOptions::new( + region.show_size_readout, + region.show_loupe, + region.show_legend, + ); + let intent = RegionCaptureIntent::new( + action, + purpose, + destination, + save_config, + self.exit_after_capture_mode, + options, + self.config.capture.include_drawings, + ); + let include_drawings = intent.include_drawings(); + if !self.capture.reserve_region(intent) { + self.input_state.push_toast( + ToastPriority::Info, + TOAST_SOURCE, + Toast::warning("Another capture operation is still in progress."), + ); + return; + } + + if interactive && !self.frozen_enabled() { + self.cancel_region_capture_ui_and_lifecycle(); + self.input_state.push_toast( + ToastPriority::Info, + TOAST_SOURCE, + Toast::warning("Interactive region capture requires the native screen picker."), + ); + return; + } + if !interactive && (region.picker == RegionPicker::Slurp || !self.frozen_enabled()) { + self.handoff_region_capture_to_legacy(); + return; + } + + self.input_state.prepare_for_screen_modal(); + self.zoom.stop_pan(); + self.stop_board_pan(); + self.set_board_pan_key_held(false); + self.cancel_toolbar_move_drag(); + self.unlock_pointer(); + self.retire_stylus_contact(); + + let generation = self.next_screen_region_generation(); + let has_source = displayed_screen_image( + &self.zoom, + &self.frozen, + self.input_state.board_is_transparent(), + ) + .is_some(); + match region_picker_entry( + has_source, + self.input_state.board_is_transparent(), + self.zoom.is_engaged(), + self.zoom.active, + self.frozen_enabled(), + ) { + RegionPickerEntry::Activate => { + if !self.activate_screen_region( + purpose, + generation, + FreezeOwnership::PreExisting, + include_drawings, + ) { + self.finish_region_activation_rejection(); + } + } + RegionPickerEntry::WaitForZoom => { + if self.wait_for_current_zoom_capture(ZoomWaiterOwner::RegionCapture) { + self.set_pending_screen_region( + purpose, + generation, + ScreenCaptureSource::Zoom, + None, + ); + } else { + self.cancel_region_capture_ui_and_lifecycle(); + self.report_region_zoom_unavailable(); + } + } + RegionPickerEntry::AutoFreeze => { + match self.request_screen_acquisition(ScreenAcquisitionOwner::RegionCapture) { + Ok(acquisition) => self.set_pending_screen_region( + purpose, + generation, + ScreenCaptureSource::Frozen, + Some(acquisition), + ), + Err(_) => { + self.cancel_region_capture_ui_and_lifecycle(); + self.input_state.push_toast( + ToastPriority::Info, + TOAST_SOURCE, + Toast::warning( + "Capture is already preparing another overlay operation.", + ), + ); + } + } + } + RegionPickerEntry::Legacy => self.handoff_region_capture_to_legacy(), + RegionPickerEntry::RefuseSolidZoom => { + self.cancel_region_capture_ui_and_lifecycle(); + self.input_state.push_toast( + ToastPriority::Info, + TOAST_SOURCE, + Toast::warning("Region capture is unavailable while zoomed on a solid board."), + ); + } + RegionPickerEntry::ZoomImageUnavailable => { + self.cancel_region_capture_ui_and_lifecycle(); + self.report_region_zoom_unavailable(); + } + } + } + + fn report_region_zoom_unavailable(&mut self) { + self.input_state.push_toast( + ToastPriority::Info, + TOAST_SOURCE, + Toast::warning("Region capture is unavailable because zoom has no screen image."), + ); + } + + fn finish_region_activation_rejection(&mut self) { + self.cancel_region_capture_ui_and_lifecycle(); + super::super::acquisition::report_screen_source_activation_rejected_to( + &mut self.input_state, + ScreenAcquisitionOwner::RegionCapture, + ); + } + + pub(in crate::backend::wayland::state) fn finish_pending_region_capture( + &mut self, + source: ScreenCaptureSource, + installed_generation: u64, + ) -> bool { + let Some(region) = self.data.active_screen_region else { + return false; + }; + if !region.purpose().is_capture() { + return false; + } + let waiting = matches!( + (region, source), + ( + ActiveScreenRegion::PendingFrozen { .. }, + ScreenCaptureSource::Frozen + ) | ( + ActiveScreenRegion::PendingZoom { .. }, + ScreenCaptureSource::Zoom + ) + ); + if !waiting { + return false; + } + let ownership = if source == ScreenCaptureSource::Frozen { + FreezeOwnership::PickerOwned { + image_generation: installed_generation, + } + } else { + FreezeOwnership::PreExisting + }; + let include_drawings = match self.capture.region_phase() { + crate::backend::wayland::capture::RegionCapturePhase::Reserved(intent) => { + intent.include_drawings() + } + crate::backend::wayland::capture::RegionCapturePhase::Idle + | crate::backend::wayland::capture::RegionCapturePhase::Submitting(_) + | crate::backend::wayland::capture::RegionCapturePhase::Accepted => return false, + }; + self.activate_screen_region( + region.purpose(), + region.generation(), + ownership, + include_drawings, + ) + } + + pub(in crate::backend::wayland) fn cancel_region_capture(&mut self) -> bool { + let Some(region) = self.data.active_screen_region else { + let reserved = self.capture.active_region_action().is_some(); + if reserved { + self.clear_zoom_waiter_for(ZoomWaiterOwner::RegionCapture); + self.clear_screen_region_ui_only(); + self.capture.finish_capture_lifecycle(); + } + return reserved; + }; + if !region.purpose().is_capture() { + return false; + } + self.cancel_modal_owner_resources( + ScreenAcquisitionOwner::RegionCapture, + region.pending_acquisition(), + region.owned_frozen_generation(), + ); + true + } + + /// Settle native picker ownership before the Wayland state is dropped. + /// + /// Accepted manager work and legacy preflight already own their terminal + /// paths. Reserved native UI, acquisition waiters, and picker-owned frozen + /// generations must instead leave through the normal correlated cancel so + /// XDG restoration and unfreeze happen exactly once. + pub(in crate::backend::wayland) fn cancel_region_capture_for_teardown(&mut self) { + let ui_owns_region = self + .data + .active_screen_region + .is_some_and(|region| region.purpose().is_capture()); + if ui_owns_region || self.capture.active_region_action().is_some() { + self.cancel_region_capture(); + } + } + + pub(in crate::backend::wayland::state) fn cancel_region_capture_ui_and_lifecycle(&mut self) { + self.clear_zoom_waiter_for(ZoomWaiterOwner::RegionCapture); + self.clear_screen_region_ui_only(); + self.capture.finish_capture_lifecycle(); + } + + pub(super) fn clear_region_capture_ui_for_handoff(&mut self) { + self.clear_zoom_waiter_for(ZoomWaiterOwner::RegionCapture); + self.clear_screen_region_ui_only(); + } + + pub(in crate::backend::wayland::state) fn handoff_region_capture_to_legacy(&mut self) { + self.clear_region_capture_ui_for_handoff(); + let Some(intent) = self.capture.handoff_region_to_legacy() else { + self.capture.finish_capture_lifecycle(); + return; + }; + debug_assert!(intent.purpose().is_capture()); + self.capture.set_exit_on_success(should_exit_after_capture( + intent.exit_mode(), + intent.destination(), + )); + let suppression = overlay_suppression_for_screenshot( + CaptureType::Selection { + x: 0, + y: 0, + width: 0, + height: 0, + }, + intent.include_drawings(), + ); + if !self.enter_overlay_suppression(suppression) { + self.capture.finish_capture_lifecycle(); + self.input_state.push_toast( + ToastPriority::Info, + TOAST_SOURCE, + Toast::warning("Capture is already preparing another overlay operation."), + ); + return; + } + let request = legacy_region_request(&intent); + self.capture.queue_preflight( + crate::backend::wayland::capture::CapturePreflightRequest::Screenshot(request), + ); + } + + pub(super) fn cancel_region_capture_for_source_change(&mut self) { + if self.cancel_region_capture() { + self.input_state.push_toast( + ToastPriority::Info, + TOAST_SOURCE, + Toast::warning("The screen changed, so region capture was cancelled."), + ); + } + } +} diff --git a/src/backend/wayland/state/region_capture/review_state.rs b/src/backend/wayland/state/region_capture/review_state.rs new file mode 100644 index 000000000..3413d05da --- /dev/null +++ b/src/backend/wayland/state/region_capture/review_state.rs @@ -0,0 +1,368 @@ +use super::*; + +impl ActiveScreenRegion { + pub(super) fn enter_review(&mut self, rect: ImagePixelRect) -> Option { + let Self::Ready { + purpose, + anchor, + raw_edge, + logical_anchor, + logical_edge, + review_resize, + .. + } = self + else { + return None; + }; + if *purpose != RegionPurposeTag::CaptureInteractive { + return None; + } + // Re-entering Review replaces the rectangle wholesale — `Ctrl+A` can + // do that while a grip is still held — so the old grip must not + // survive to block the next move, resize or nudge. + *review_resize = None; + *anchor = None; + *raw_edge = None; + *logical_anchor = None; + *logical_edge = None; + *anchor = Some(ImagePoint::new(f64::from(rect.x()), f64::from(rect.y()))); + *raw_edge = Some(ImagePoint::new( + f64::from(rect.x() + rect.width()), + f64::from(rect.y() + rect.height()), + )); + self.review_geometry() + .map(|geometry| geometry.display_selection()) + } + + pub(super) fn begin_review_move(&mut self, logical: (f64, f64)) -> bool { + let Some(rect) = (*self).stored_review_rect() else { + return false; + }; + let Self::Ready { + source, + logical_anchor, + review_resize, + .. + } = self + else { + return false; + }; + if logical_anchor.is_some() || review_resize.is_some() { + return false; + } + let display = + crate::backend::wayland::state::screen_image::screen_rect_for_image_rect(source, rect); + let x = logical.0.floor() as i32; + let y = logical.1.floor() as i32; + if x < display.x + || y < display.y + || x >= display.x.saturating_add(display.width) + || y >= display.y.saturating_add(display.height) + { + return false; + } + let origin = image_point_for_screen_point(source, logical); + *logical_anchor = Some((origin.x, origin.y)); + true + } + + pub(super) fn update_review_move(&mut self, logical: (f64, f64)) -> bool { + let Some(image_rect) = (*self).stored_review_rect() else { + return false; + }; + let Self::Ready { + source, + logical_anchor: Some(origin), + .. + } = self + else { + return false; + }; + let current = image_point_for_screen_point(source, logical); + let delta_x = (current.x - origin.0).round() as i64; + let delta_y = (current.y - origin.1).round() as i64; + if delta_x == 0 && delta_y == 0 { + return false; + } + let Some(next) = image_rect.translated_clamped(delta_x, delta_y, source.image_size) else { + return false; + }; + if image_rect == next { + return false; + } + let applied_x = i64::from(next.x()) - i64::from(image_rect.x()); + let applied_y = i64::from(next.y()) - i64::from(image_rect.y()); + let next_origin = (origin.0 + applied_x as f64, origin.1 + applied_y as f64); + if !self.store_review_rect(next) { + return false; + } + if let Self::Ready { logical_anchor, .. } = self { + *logical_anchor = Some(next_origin); + } + true + } + + pub(super) fn finish_review_move(&mut self) -> bool { + let Self::Ready { logical_anchor, .. } = self else { + return false; + }; + if logical_anchor.is_none() { + return false; + } + *logical_anchor = None; + true + } + + pub(in crate::backend::wayland::state) fn review_resize_handle( + self, + ) -> Option { + match self { + Self::Ready { review_resize, .. } => review_resize.map(ReviewResizeGrip::handle), + Self::Measure { .. } | Self::PendingFrozen { .. } | Self::PendingZoom { .. } => None, + } + } + + pub(super) fn begin_review_resize( + &mut self, + handle: SelectionHandle, + logical: (f64, f64), + ) -> bool { + let Some(rect) = (*self).stored_review_rect() else { + return false; + }; + let Self::Ready { + source, + logical_anchor, + review_resize, + .. + } = self + else { + return false; + }; + if logical_anchor.is_some() || review_resize.is_some() { + return false; + } + let pointer = image_point_for_screen_point(source, logical); + let Some(grip) = ReviewResizeGrip::grab(handle, rect, pointer) else { + return false; + }; + *review_resize = Some(grip); + true + } + + /// Move the edges the held grip owns, keeping the offset captured when the + /// grip was grabbed, so the edge tracks the pointer without jumping to it. + pub(super) fn update_review_resize(&mut self, logical: (f64, f64)) -> bool { + let Some(rect) = (*self).stored_review_rect() else { + return false; + }; + let Self::Ready { + source, + review_resize: Some(grip), + .. + } = *self + else { + return false; + }; + let edge = grip.edge_for(image_point_for_screen_point(&source, logical)); + let Some(next) = resized_review_rect(rect, grip.handle, edge, source.image_size) else { + return false; + }; + if next == rect { + return false; + } + self.store_review_rect(next) + } + + pub(super) fn finish_review_resize(&mut self) -> bool { + let Self::Ready { review_resize, .. } = self else { + return false; + }; + if review_resize.is_none() { + return false; + } + *review_resize = None; + true + } + + pub(super) fn reset_review_for_selection(&mut self) { + if let Self::Ready { + anchor, + raw_edge, + logical_anchor, + review_resize, + .. + } = self + { + *anchor = None; + *raw_edge = None; + *logical_anchor = None; + *review_resize = None; + } + } + + pub(super) fn nudge_review(&mut self, delta_x: i64, delta_y: i64) -> Option { + let review_rect = (*self).stored_review_rect()?; + let Self::Ready { + source, + logical_anchor, + review_resize, + .. + } = self + else { + return None; + }; + if logical_anchor.is_some() || review_resize.is_some() { + return None; + } + let rect = review_rect.translated_clamped(delta_x, delta_y, source.image_size)?; + self.store_review_rect(rect); + self.review_geometry() + .map(|geometry| geometry.display_selection()) + } +} + +/// The smallest rectangle a resize may leave behind, in image pixels. A grip +/// dragged past its opposite edge stops here instead of collapsing or +/// inverting the rectangle, so the grip under the pointer keeps its identity +/// for the whole drag. +const MIN_REVIEW_SIDE: u32 = 1; + +/// A grip being dragged, with the offset between the pointer and the edges it +/// owns, captured when the grip was grabbed. +/// +/// The offset is what keeps a click-without-motion inert. Grips are placed on +/// the *display* rectangle, which is the image rectangle rounded outward, so +/// at a fractional scale or under zoom the pointer's image coordinate on the +/// chip is not the edge's own coordinate. Without the offset, the release +/// event alone would re-round that position and shift the edge by a pixel. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(in crate::backend::wayland::state) struct ReviewResizeGrip { + handle: SelectionHandle, + offset: (f64, f64), +} + +impl ReviewResizeGrip { + const fn handle(self) -> SelectionHandle { + self.handle + } + + fn grab(handle: SelectionHandle, rect: ImagePixelRect, pointer: ImagePoint) -> Option { + if !pointer.x.is_finite() || !pointer.y.is_finite() { + return None; + } + let (moves_left, moves_right) = handle_owns_horizontal(handle); + let (moves_top, moves_bottom) = handle_owns_vertical(handle); + let right = f64::from(rect.x().checked_add(rect.width())?); + let bottom = f64::from(rect.y().checked_add(rect.height())?); + // An axis the grip does not own contributes no offset, so a stray + // component of the pointer position can never leak into it. + let offset_x = match (moves_left, moves_right) { + (true, _) => pointer.x - f64::from(rect.x()), + (_, true) => pointer.x - right, + _ => 0.0, + }; + let offset_y = match (moves_top, moves_bottom) { + (true, _) => pointer.y - f64::from(rect.y()), + (_, true) => pointer.y - bottom, + _ => 0.0, + }; + Some(Self { + handle, + offset: (offset_x, offset_y), + }) + } + + /// Where the owned edges should sit for a pointer now at `pointer`. + fn edge_for(self, pointer: ImagePoint) -> ImagePoint { + ImagePoint::new(pointer.x - self.offset.0, pointer.y - self.offset.1) + } +} + +const fn handle_owns_horizontal(handle: SelectionHandle) -> (bool, bool) { + match handle { + SelectionHandle::TopLeft | SelectionHandle::BottomLeft | SelectionHandle::Left => { + (true, false) + } + SelectionHandle::TopRight | SelectionHandle::BottomRight | SelectionHandle::Right => { + (false, true) + } + SelectionHandle::Top | SelectionHandle::Bottom => (false, false), + } +} + +const fn handle_owns_vertical(handle: SelectionHandle) -> (bool, bool) { + match handle { + SelectionHandle::TopLeft | SelectionHandle::TopRight | SelectionHandle::Top => { + (true, false) + } + SelectionHandle::BottomLeft | SelectionHandle::BottomRight | SelectionHandle::Bottom => { + (false, true) + } + SelectionHandle::Left | SelectionHandle::Right => (false, false), + } +} + +/// Apply a grip drag to `rect`: the edges the grip owns follow `edge`, the +/// opposite ones stay put. Returns `None` only when the result cannot be a +/// valid rectangle inside `bounds`. +pub(super) fn resized_review_rect( + rect: ImagePixelRect, + handle: SelectionHandle, + edge: ImagePoint, + bounds: (u32, u32), +) -> Option { + let mut left = rect.x(); + let mut top = rect.y(); + let mut right = rect.x().checked_add(rect.width())?; + let mut bottom = rect.y().checked_add(rect.height())?; + let pointer_x = clamp_image_coordinate(edge.x, bounds.0)?; + let pointer_y = clamp_image_coordinate(edge.y, bounds.1)?; + let (moves_left, moves_right) = handle_owns_horizontal(handle); + let (moves_top, moves_bottom) = handle_owns_vertical(handle); + + if moves_left { + left = pointer_x.min(right.saturating_sub(MIN_REVIEW_SIDE)); + } + if moves_right { + right = pointer_x + .max(left.saturating_add(MIN_REVIEW_SIDE)) + .min(bounds.0); + } + if moves_top { + top = pointer_y.min(bottom.saturating_sub(MIN_REVIEW_SIDE)); + } + if moves_bottom { + bottom = pointer_y + .max(top.saturating_add(MIN_REVIEW_SIDE)) + .min(bounds.1); + } + + ImagePixelRect::new( + left, + top, + right.checked_sub(left)?, + bottom.checked_sub(top)?, + bounds, + ) +} + +/// Quantize a fractional image coordinate onto the pixel grid, clamped to the +/// closed edge range so a rectangle may end exactly at the image boundary. +/// +/// A finite coordinate far off-screen is a real drag and clamps; a non-finite +/// one is a broken event and yields `None`, so the caller ignores it instead of +/// snapping the edge to the image origin. +fn clamp_image_coordinate(value: f64, bound: u32) -> Option { + if !value.is_finite() { + return None; + } + if value <= 0.0 { + return Some(0); + } + let rounded = value.round(); + if rounded >= f64::from(bound) { + return Some(bound); + } + Some(rounded as u32) +} diff --git a/src/backend/wayland/state/region_capture/runtime.rs b/src/backend/wayland/state/region_capture/runtime.rs new file mode 100644 index 000000000..1efa9f892 --- /dev/null +++ b/src/backend/wayland/state/region_capture/runtime.rs @@ -0,0 +1,439 @@ +use super::*; + +impl WaylandState { + pub(in crate::backend::wayland::state) fn retire_region_selection_owner( + &mut self, + owner: Option, + ) { + match owner { + Some(source @ (RegionInputSource::Pointer | RegionInputSource::Touch)) => { + self.suppress_next_release_from(source); + } + Some(RegionInputSource::Stylus) => self.retire_stylus_contact(), + None => {} + } + } + + pub(in crate::backend::wayland) fn handle_measure_mode_action(&mut self) { + match measure_mode_transition( + self.input_state.region_state().purpose(), + self.input_state.screen_modal_is_engaged(), + ) { + MeasureModeTransition::Cancel => { + self.cancel_measure_mode(); + return; + } + MeasureModeTransition::Refuse => { + self.input_state.push_toast( + crate::input::state::ToastPriority::Info, + "measure.refused", + crate::input::state::Toast::info( + "Finish or cancel the current screen selection first.", + ), + ); + return; + } + MeasureModeTransition::Start => {} + } + + self.input_state.prepare_for_screen_modal(); + self.zoom.stop_pan(); + self.stop_board_pan(); + self.set_board_pan_key_held(false); + self.cancel_toolbar_move_drag(); + self.unlock_pointer(); + self.retire_stylus_contact(); + + let generation = self.next_screen_region_generation(); + self.data.active_screen_region = Some(ActiveScreenRegion::Measure { + generation, + bounds: (self.surface.width(), self.surface.height()), + anchor: None, + edge: None, + }); + self.input_state.activate_measure_mode(generation); + self.debug_assert_screen_region_invariant(); + } + + pub(in crate::backend::wayland) fn cancel_measure_mode(&mut self) -> bool { + if self.input_state.region_state().purpose() != Some(RegionPurposeTag::Measure) { + return false; + } + self.clear_screen_region_ui_only(); + true + } + + pub(in crate::backend::wayland) fn region_picker_include_drawings(&self) -> bool { + self.data + .active_screen_region + .is_some_and(ActiveScreenRegion::include_drawings) + } + + pub(in crate::backend::wayland) fn toggle_region_picker_include_drawings(&mut self) -> bool { + if !self.input_state.region_state().is_review() { + return false; + } + let Some(checked) = self + .data + .active_screen_region + .as_mut() + .and_then(ActiveScreenRegion::toggle_include_drawings) + else { + return false; + }; + log::debug!("region picker include drawings: {checked}"); + self.input_state.dirty_tracker.mark_full(); + self.input_state.needs_redraw = true; + true + } + + pub(in crate::backend::wayland::state) fn next_screen_region_generation(&mut self) -> u64 { + let generation = self.data.next_screen_region_generation; + self.data.next_screen_region_generation = generation + .checked_add(1) + .expect("screen region generation space exhausted"); + generation + } + + pub(in crate::backend::wayland::state) fn set_pending_screen_region( + &mut self, + purpose: RegionPurposeTag, + generation: u64, + source: ScreenCaptureSource, + acquisition: Option, + ) { + self.data.active_screen_region = Some(match source { + ScreenCaptureSource::Frozen => ActiveScreenRegion::PendingFrozen { + purpose, + generation, + acquisition: acquisition.expect("frozen region wait has an acquisition id"), + }, + ScreenCaptureSource::Zoom => ActiveScreenRegion::PendingZoom { + purpose, + generation, + }, + }); + self.input_state + .set_region_pending_capture(purpose, generation, source); + self.debug_assert_screen_region_invariant(); + } + + pub(in crate::backend::wayland::state) fn activate_screen_region( + &mut self, + purpose: RegionPurposeTag, + generation: u64, + freeze_ownership: FreezeOwnership, + include_drawings: bool, + ) -> bool { + let Some(source) = crate::backend::wayland::state::screen_image::displayed_screen_image( + &self.zoom, + &self.frozen, + self.input_state.board_is_transparent(), + ) else { + return false; + }; + let Some(token) = current_screen_source_token( + &source, + &self.zoom, + &self.frozen, + (self.surface.width(), self.surface.height()), + ) else { + return false; + }; + self.data.active_screen_region = Some(ActiveScreenRegion::Ready { + purpose, + generation, + source: token, + freeze_ownership, + anchor: None, + raw_edge: None, + logical_anchor: None, + logical_edge: None, + square_modifier: initial_square_modifier(purpose, self.input_state.modifiers.shift), + legend_dismissed: false, + include_drawings, + review_resize: None, + }); + self.input_state.activate_region(purpose, generation); + self.start_region_window_query(purpose, generation, token, freeze_ownership); + self.debug_assert_screen_region_invariant(); + true + } + + pub(in crate::backend::wayland::state) fn clear_screen_region_ui_only(&mut self) { + self.clear_region_window_snap(); + self.data.active_screen_region = None; + self.input_state.cancel_region_ui_only(); + self.debug_assert_screen_region_invariant(); + } + + pub(in crate::backend::wayland) fn begin_region_selection( + &mut self, + owner: RegionInputSource, + x: f64, + y: f64, + ) -> bool { + self.cancel_screen_modals_if_source_changed(); + if self.begin_region_window_choice(owner, (x, y)) { + return true; + } + begin_region_selection_event( + &mut self.data.active_screen_region, + &mut self.input_state, + owner, + (x, y), + ) + } + + pub(in crate::backend::wayland) fn update_region_selection( + &mut self, + owner: RegionInputSource, + x: f64, + y: f64, + ) { + self.cancel_screen_modals_if_source_changed(); + if self.update_region_window_hover((x, y)) { + return; + } + update_region_selection_event( + &mut self.data.active_screen_region, + &mut self.input_state, + owner, + (x, y), + ); + } + + pub(in crate::backend::wayland) fn region_selection_geometry( + &self, + ) -> Option { + if let Some(rect) = self.highlighted_region_window_rect() + && let Some(ActiveScreenRegion::Ready { + purpose, source, .. + }) = self.data.active_screen_region + { + let display = super::super::screen_image::screen_rect_for_image_rect(&source, rect); + return Some(RegionSelectionGeometry::authoritative( + purpose, + rect, + RegionSelection { + start: (f64::from(display.x), f64::from(display.y)), + end: ( + f64::from(display.x + display.width), + f64::from(display.y + display.height), + ), + }, + )); + } + self.data + .active_screen_region + .and_then(ActiveScreenRegion::selection_geometry) + } + + pub(in crate::backend::wayland) fn region_measure_selection(&self) -> Option { + self.data + .active_screen_region + .and_then(ActiveScreenRegion::measure_selection) + } + + pub(in crate::backend::wayland::state) fn region_picker_source_token( + &self, + ) -> Option { + match self.data.active_screen_region { + Some(ActiveScreenRegion::Ready { source, .. }) => Some(source), + Some(ActiveScreenRegion::Measure { .. }) + | Some(ActiveScreenRegion::PendingFrozen { .. }) + | Some(ActiveScreenRegion::PendingZoom { .. }) + | None => None, + } + } + + pub(in crate::backend::wayland) fn region_picker_legend_dismissed(&self) -> bool { + self.data + .active_screen_region + .is_some_and(ActiveScreenRegion::legend_dismissed) + } + + pub(in crate::backend::wayland) fn whole_image_region_selection( + &self, + ) -> Option { + self.data + .active_screen_region + .and_then(ActiveScreenRegion::whole_image_selection) + } + + pub(in crate::backend::wayland) fn enter_region_review( + &mut self, + rect: ImagePixelRect, + ) -> bool { + let Some(region) = self.data.active_screen_region.as_mut() else { + return false; + }; + let purpose = region.purpose(); + let generation = region.generation(); + let Some(display) = region.enter_review(rect) else { + return false; + }; + self.input_state + .activate_region_review(purpose, generation, display); + self.debug_assert_screen_region_invariant(); + true + } + + pub(in crate::backend::wayland) fn nudge_region_review( + &mut self, + delta_x: i64, + delta_y: i64, + ) -> bool { + self.cancel_screen_modals_if_source_changed(); + if !self.input_state.region_state().is_review() { + return false; + } + let Some(display) = self + .data + .active_screen_region + .as_mut() + .and_then(|region| region.nudge_review(delta_x, delta_y)) + else { + return false; + }; + self.input_state.update_region_review_display(display); + true + } + + /// The grip a device is currently dragging in Review, if any. + pub(in crate::backend::wayland) fn region_review_resize_handle( + &self, + ) -> Option { + if !self.input_state.region_state().is_review() { + return None; + } + self.data + .active_screen_region + .and_then(ActiveScreenRegion::review_resize_handle) + } + + /// The grip under `point`, for hover feedback. Shares its placement with + /// the press path and the renderer. + pub(in crate::backend::wayland) fn region_review_handle_at( + &self, + point: (f64, f64), + ) -> Option { + if !self.input_state.region_state().is_review() { + return None; + } + self.data + .active_screen_region + .as_ref() + .and_then(|region| review_resize_handle_at(region, point)) + } + + pub(in crate::backend::wayland) fn region_review_rect(&self) -> Option { + match self.data.active_screen_region { + Some(region) if self.input_state.region_state().is_review() => { + region.stored_review_rect() + } + _ => None, + } + } + + pub(in crate::backend::wayland) fn region_picker_measurement( + &self, + pointer: (f64, f64), + ) -> Option { + if let Some(rect) = self.highlighted_region_window_rect() { + return Some(RegionPickerMeasurement::Size { + width: rect.width(), + height: rect.height(), + }); + } + self.data + .active_screen_region + .and_then(|region| region.picker_measurement(pointer)) + } + + /// Recompute capture geometry from compositor-authoritative Shift state. + /// Individual key events remain swallowed by the modal selector. + pub(in crate::backend::wayland) fn sync_region_square_modifier(&mut self, shift: bool) -> bool { + sync_region_square_modifier_event( + &mut self.data.active_screen_region, + &mut self.input_state, + shift, + ) + } + + /// Resolve loss of the device that owns a drag without crossing lifecycle + /// ownership. Capture keeps its reservation and image; OCR asks its owner + /// to run the existing terminal cancellation path. + pub(in crate::backend::wayland) fn region_owner_lost( + &mut self, + source: RegionInputSource, + ) -> RegionOwnerLoss { + region_owner_lost_event( + &mut self.data.active_screen_region, + &mut self.input_state, + source, + ) + } + + pub(in crate::backend::wayland::state) fn debug_assert_screen_region_invariant(&self) { + debug_assert!(screen_region_invariant( + self.data.active_screen_region, + self.input_state.region_state(), + )); + } + + pub(in crate::backend::wayland) fn cancel_screen_modals_if_source_changed(&mut self) { + let (cancel_eyedropper, cancel_region) = { + let current_source = + crate::backend::wayland::state::screen_image::displayed_screen_image( + &self.zoom, + &self.frozen, + self.input_state.board_is_transparent(), + ); + let source_matches = |expected| { + current_source.as_ref().is_some_and(|source| { + screen_source_is( + &expected, + source, + &self.zoom, + &self.frozen, + (self.surface.width(), self.surface.height()), + ) + }) + }; + ( + active_eyedropper_source_changed( + self.input_state.eyedropper_is_active(), + self.data.active_eyedropper_source, + &source_matches, + ), + active_region_source_changed( + self.data.active_screen_region, + (self.surface.width(), self.surface.height()), + &source_matches, + ), + ) + }; + if cancel_eyedropper { + self.cancel_eyedropper(); + } + if cancel_region { + match self + .data + .active_screen_region + .map(ActiveScreenRegion::purpose) + { + Some(RegionPurposeTag::Ocr) => { + self.cancel_ocr(); + } + Some(RegionPurposeTag::CaptureDeliver | RegionPurposeTag::CaptureInteractive) => { + self.cancel_region_capture_for_source_change(); + } + Some(RegionPurposeTag::Measure) => { + self.cancel_measure_mode(); + } + None => {} + } + } + } +} diff --git a/src/backend/wayland/state/region_capture/selection_state.rs b/src/backend/wayland/state/region_capture/selection_state.rs new file mode 100644 index 000000000..e1eb30ac7 --- /dev/null +++ b/src/backend/wayland/state/region_capture/selection_state.rs @@ -0,0 +1,94 @@ +use super::*; + +impl ActiveScreenRegion { + pub(super) fn begin_selection(&mut self, logical: (f64, f64)) -> bool { + if let Self::Measure { + bounds, + anchor, + edge, + .. + } = self + { + let Some(point) = measure_anchor(logical, *bounds) else { + return false; + }; + *anchor = Some(point); + *edge = Some(point); + return true; + } + let Self::Ready { + purpose, + source, + anchor, + raw_edge, + logical_anchor, + logical_edge, + legend_dismissed, + .. + } = self + else { + return false; + }; + if anchor.is_some() + || raw_edge.is_some() + || logical_anchor.is_some() + || logical_edge.is_some() + { + return false; + } + let Some(mapped) = clamp_edge( + image_point_for_screen_point(source, logical), + source.image_size, + ) else { + return false; + }; + let Some(anchor_point) = geometry::selection_anchor(*purpose, mapped, source.image_size) + else { + return false; + }; + *anchor = Some(anchor_point); + *raw_edge = Some(if purpose.is_capture() { + anchor_point + } else { + mapped + }); + *logical_anchor = Some(logical); + *logical_edge = Some(logical); + *legend_dismissed = true; + true + } + + pub(super) fn update_endpoint(&mut self, logical: (f64, f64)) -> bool { + if let Self::Measure { + bounds, + anchor: Some(anchor), + edge, + .. + } = self + { + let Some(point) = measure_edge(*anchor, logical, *bounds) else { + return false; + }; + *edge = Some(point); + return true; + } + let Self::Ready { + source, + raw_edge, + logical_edge, + .. + } = self + else { + return false; + }; + let Some(mapped) = clamp_edge( + image_point_for_screen_point(source, logical), + source.image_size, + ) else { + return false; + }; + *raw_edge = Some(mapped); + *logical_edge = Some(logical); + true + } +} diff --git a/src/backend/wayland/state/region_capture/source_guard.rs b/src/backend/wayland/state/region_capture/source_guard.rs new file mode 100644 index 000000000..39ae070fb --- /dev/null +++ b/src/backend/wayland/state/region_capture/source_guard.rs @@ -0,0 +1,43 @@ +use super::*; + +pub(super) fn screen_region_invariant( + backend: Option, + ui: RegionSelectUiState, +) -> bool { + match (backend, ui) { + (None, RegionSelectUiState::Inactive) => true, + (None, _) | (Some(_), RegionSelectUiState::Inactive) => false, + (Some(region), ui) => { + ui.generation() == Some(region.generation()) && ui.purpose() == Some(region.purpose()) + } + } +} + +pub(super) fn active_region_source_changed( + region: Option, + surface: (u32, u32), + source_matches: &impl Fn(ScreenSourceToken) -> bool, +) -> bool { + match region { + Some(ActiveScreenRegion::Ready { source, .. }) => !source_matches(source), + Some(ActiveScreenRegion::Measure { bounds, .. }) => bounds != surface, + Some(ActiveScreenRegion::PendingFrozen { .. } | ActiveScreenRegion::PendingZoom { .. }) + | None => false, + } +} + +pub(super) fn active_eyedropper_source_changed( + active: bool, + expected_source: Option, + source_matches: &impl Fn(ScreenSourceToken) -> bool, +) -> bool { + active && !expected_source.is_some_and(source_matches) +} + +pub(in crate::backend::wayland::state) fn owned_generation_is_current( + expected: u64, + current: u64, + frozen_active: bool, +) -> bool { + frozen_active && expected == current +} diff --git a/src/backend/wayland/state/region_capture/tests/capture_selection.rs b/src/backend/wayland/state/region_capture/tests/capture_selection.rs new file mode 100644 index 000000000..c3e9a43c5 --- /dev/null +++ b/src/backend/wayland/state/region_capture/tests/capture_selection.rs @@ -0,0 +1,121 @@ +use super::lifecycle_and_measure::ocr_region; +use super::*; + +pub(super) fn capture_region() -> ActiveScreenRegion { + capture_region_at_scale(1.0) +} + +fn capture_region_with_drawings(include_drawings: bool) -> ActiveScreenRegion { + let mut region = capture_region(); + if let ActiveScreenRegion::Ready { + include_drawings: value, + .. + } = &mut region + { + *value = include_drawings; + } + region +} + +pub(super) fn interactive_region() -> ActiveScreenRegion { + let mut region = capture_region(); + if let ActiveScreenRegion::Ready { purpose, .. } = &mut region { + *purpose = RegionPurposeTag::CaptureInteractive; + } + region +} + +pub(super) fn capture_region_at_scale(scale: f64) -> ActiveScreenRegion { + let ActiveScreenRegion::Ready { + generation, + source, + freeze_ownership, + .. + } = ocr_region(scale) + else { + unreachable!("OCR fixture is ready") + }; + ActiveScreenRegion::Ready { + purpose: RegionPurposeTag::CaptureDeliver, + generation, + source, + freeze_ownership, + anchor: None, + raw_edge: None, + logical_anchor: None, + logical_edge: None, + square_modifier: false, + legend_dismissed: false, + include_drawings: false, + review_resize: None, + } +} + +#[test] +fn include_drawings_is_session_local_and_survives_review_reselection() { + let mut region = capture_region_with_drawings(true); + if let ActiveScreenRegion::Ready { purpose, .. } = &mut region { + *purpose = RegionPurposeTag::CaptureInteractive; + } + assert!(region.include_drawings()); + assert_eq!(region.toggle_include_drawings(), Some(false)); + assert!(!region.include_drawings()); + + region.reset_review_for_selection(); + assert!(!region.include_drawings()); + + let mut direct = capture_region_with_drawings(true); + assert_eq!(direct.toggle_include_drawings(), None); + assert!(direct.include_drawings()); + assert!(!interactive_region().include_drawings()); +} + +#[test] +fn capture_press_snaps_its_anchor_and_starts_with_a_zero_pixel_span() { + let mut region = capture_region(); + + assert!(region.begin_selection((10.8, 20.6))); + + let geometry = region.selection_geometry().expect("fresh drag geometry"); + assert_eq!(geometry.image_span().size(), (0, 0)); + assert_eq!(geometry.display_selection().start, (10.0, 20.0)); + assert_eq!(geometry.display_selection().end, (10.0, 20.0)); + assert_eq!(geometry.image_rect(), None); +} + +#[test] +fn interactive_release_enters_integer_authoritative_review() { + let mut backend = Some(interactive_region()); + let mut input = make_test_input_state(); + input.activate_region(RegionPurposeTag::CaptureInteractive, 1); + + assert!(begin_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (10.2, 20.7), + )); + assert_eq!( + finalize_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (30.1, 42.2), + ), + RegionSelectionFinalize::Reviewed + ); + assert!(input.region_state().is_review()); + let rect = backend + .and_then(ActiveScreenRegion::selection_rect) + .expect("review owns a non-empty pixel rectangle"); + assert_eq!( + (rect.x(), rect.y(), rect.width(), rect.height()), + (10, 20, 21, 23) + ); + assert_eq!( + input.region_state().selection(), + backend + .and_then(ActiveScreenRegion::selection_geometry) + .map(|geometry| geometry.display_selection()) + ); +} diff --git a/src/backend/wayland/state/region_capture/tests/lifecycle_and_measure.rs b/src/backend/wayland/state/region_capture/tests/lifecycle_and_measure.rs new file mode 100644 index 000000000..9c7b00e06 --- /dev/null +++ b/src/backend/wayland/state/region_capture/tests/lifecycle_and_measure.rs @@ -0,0 +1,384 @@ +use super::capture_selection::capture_region; +use super::*; + +#[test] +fn pending_and_ready_region_state_preserve_generation_and_freeze_ownership() { + let pending = ActiveScreenRegion::PendingZoom { + purpose: RegionPurposeTag::Ocr, + generation: 9, + }; + assert_eq!(pending.generation(), 9); + assert_eq!(pending.purpose(), RegionPurposeTag::Ocr); + assert_eq!(pending.owned_frozen_generation(), None); + + let ready = ActiveScreenRegion::Ready { + purpose: RegionPurposeTag::Ocr, + generation: 9, + source: ScreenSourceToken { + output_id: 1, + output_layout_generation: 0, + kind: crate::backend::wayland::state::screen_image::ScreenImageKind::Frozen, + image_generation: 44, + image_size: (100, 80), + stride: 400, + surface: (100, 80), + output_scale: 1, + output_transform: wayland_client::protocol::wl_output::Transform::Normal, + zoom_transformed: false, + zoom_scale: 1.0, + zoom_view_offset: (0.0, 0.0), + }, + freeze_ownership: FreezeOwnership::PickerOwned { + image_generation: 44, + }, + anchor: None, + raw_edge: None, + logical_anchor: None, + logical_edge: None, + square_modifier: false, + legend_dismissed: false, + include_drawings: false, + review_resize: None, + }; + assert_eq!(ready.generation(), pending.generation()); + assert_eq!(ready.owned_frozen_generation(), Some(44)); +} + +#[test] +fn measure_mode_owns_logical_geometry_without_a_screen_image() { + let mut backend = Some(ActiveScreenRegion::Measure { + generation: 7, + bounds: (800, 600), + anchor: None, + edge: None, + }); + let mut input = make_test_input_state(); + input.activate_measure_mode(7); + + assert!(begin_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (10.2, 20.7), + )); + update_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (35.2, 60.1), + ); + assert_eq!( + finalize_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (35.2, 60.1), + ), + RegionSelectionFinalize::Measured + ); + + let expected = RegionSelection { + start: (10.0, 20.0), + end: (36.0, 61.0), + }; + assert_eq!(input.region_state().selection(), Some(expected)); + assert!(input.region_state().is_measured()); + assert_eq!( + backend.and_then(ActiveScreenRegion::measure_selection), + Some(expected) + ); + assert!(screen_region_invariant(backend, input.region_state())); + assert_eq!( + backend.and_then(|region| region.picker_measurement((0.0, 0.0))), + Some(RegionPickerMeasurement::Size { + width: 26, + height: 41, + }) + ); +} + +#[test] +fn measure_mode_action_toggles_itself_and_refuses_other_modals() { + assert_eq!( + measure_mode_transition(None, false), + MeasureModeTransition::Start + ); + assert_eq!( + measure_mode_transition(Some(RegionPurposeTag::Measure), true), + MeasureModeTransition::Cancel + ); + for purpose in [ + RegionPurposeTag::Ocr, + RegionPurposeTag::CaptureDeliver, + RegionPurposeTag::CaptureInteractive, + ] { + assert_eq!( + measure_mode_transition(Some(purpose), true), + MeasureModeTransition::Refuse + ); + } +} + +#[test] +fn lost_measure_drag_rearms_for_another_device() { + let mut backend = Some(ActiveScreenRegion::Measure { + generation: 8, + bounds: (800, 600), + anchor: None, + edge: None, + }); + let mut input = make_test_input_state(); + input.activate_measure_mode(8); + assert!(begin_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Stylus, + (12.0, 14.0), + )); + + assert_eq!( + region_owner_lost_event(&mut backend, &mut input, RegionInputSource::Stylus), + RegionOwnerLoss::Rearmed + ); + assert!(matches!( + input.region_state(), + RegionSelectUiState::Armed { .. } + )); + assert!( + backend + .and_then(ActiveScreenRegion::measure_selection) + .is_none() + ); +} + +#[test] +fn reversed_measure_drag_uses_outward_integer_edges() { + let mut backend = Some(ActiveScreenRegion::Measure { + generation: 9, + bounds: (800, 600), + anchor: None, + edge: None, + }); + let mut input = make_test_input_state(); + input.activate_measure_mode(9); + assert!(begin_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Touch, + (50.8, 60.2), + )); + update_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Touch, + (9.8, 19.9), + ); + + assert_eq!( + input.region_state().selection(), + Some(RegionSelection { + start: (50.0, 60.0), + end: (9.0, 19.0), + }) + ); + assert_eq!( + backend.and_then(|region| region.picker_measurement((0.0, 0.0))), + Some(RegionPickerMeasurement::Size { + width: 41, + height: 41, + }) + ); +} + +#[test] +fn measure_points_and_edges_clamp_to_the_logical_surface() { + assert_eq!( + measure_anchor((-20.0, 900.0), (800, 600)), + Some((0.0, 599.0)) + ); + assert_eq!( + measure_edge((0.0, 599.0), (900.0, 900.0), (800, 600)), + Some((800.0, 600.0)) + ); + assert_eq!(measure_anchor((1.0, 1.0), (0, 600)), None); +} + +#[test] +fn backend_and_ui_generation_must_match() { + let backend = Some(ActiveScreenRegion::PendingZoom { + purpose: RegionPurposeTag::Ocr, + generation: 7, + }); + assert!(screen_region_invariant( + backend, + RegionSelectUiState::PendingCapture { + purpose: RegionPurposeTag::Ocr, + generation: 7, + source: ScreenCaptureSource::Zoom, + } + )); + assert!(!screen_region_invariant( + backend, + RegionSelectUiState::PendingCapture { + purpose: RegionPurposeTag::Ocr, + generation: 8, + source: ScreenCaptureSource::Zoom, + } + )); + assert!(!screen_region_invariant( + backend, + RegionSelectUiState::Inactive + )); +} + +#[test] +fn armed_freeze_release_is_generation_checked_and_consumed_once() { + assert!(owned_generation_is_current(44, 44, true)); + assert!(!owned_generation_is_current(44, 45, true)); + assert!(!owned_generation_is_current(44, 44, false)); + + let mut owned = ocr_region(1.0); + if let ActiveScreenRegion::Ready { + freeze_ownership, .. + } = &mut owned + { + *freeze_ownership = FreezeOwnership::PickerOwned { + image_generation: 44, + }; + } + let mut armed = Some(owned); + let first = armed + .take() + .and_then(ActiveScreenRegion::owned_frozen_generation); + let second = armed + .take() + .and_then(ActiveScreenRegion::owned_frozen_generation); + assert_eq!(first, Some(44)); + assert_eq!( + second, None, + "a second cancellation has no region to release" + ); +} + +pub(super) fn ocr_region(scale: f64) -> ActiveScreenRegion { + ActiveScreenRegion::Ready { + purpose: RegionPurposeTag::Ocr, + generation: 1, + source: ScreenSourceToken { + output_id: 1, + output_layout_generation: 0, + kind: crate::backend::wayland::state::screen_image::ScreenImageKind::Frozen, + image_generation: 1, + image_size: ((100.0 * scale) as u32, (80.0 * scale) as u32), + stride: (400.0 * scale) as i32, + surface: (100, 80), + output_scale: 1, + output_transform: wayland_client::protocol::wl_output::Transform::Normal, + zoom_transformed: false, + zoom_scale: 1.0, + zoom_view_offset: (0.0, 0.0), + }, + freeze_ownership: FreezeOwnership::PreExisting, + anchor: None, + raw_edge: None, + logical_anchor: None, + logical_edge: None, + square_modifier: false, + legend_dismissed: false, + include_drawings: false, + review_resize: None, + } +} + +#[test] +fn ready_regions_detect_replaced_or_missing_source_but_pending_regions_keep_waiting() { + let ready = ocr_region(1.0); + let ActiveScreenRegion::Ready { source, .. } = ready else { + unreachable!("OCR fixture is ready") + }; + let mut replacement = source; + replacement.image_generation += 1; + + assert!(!active_region_source_changed( + Some(ready), + (100, 80), + &|expected| { expected == source } + )); + assert!(active_region_source_changed( + Some(ready), + (100, 80), + &|expected| { expected == replacement } + )); + assert!(active_region_source_changed( + Some(ready), + (100, 80), + &|_| false + )); + assert!(!active_region_source_changed( + Some(ActiveScreenRegion::PendingZoom { + purpose: RegionPurposeTag::Ocr, + generation: 1, + }), + (100, 80), + &|_| false, + )); + + let capture = capture_region(); + assert!(active_region_source_changed( + Some(capture), + (100, 80), + &|_| false + )); +} + +#[test] +fn measure_detects_a_real_surface_resize_without_needing_a_screen_source() { + let measure = ActiveScreenRegion::Measure { + generation: 1, + bounds: (100, 80), + anchor: Some((10.0, 20.0)), + edge: Some((30.0, 40.0)), + }; + + assert!(!active_region_source_changed( + Some(measure), + (100, 80), + &|_| false, + )); + assert!(active_region_source_changed( + Some(measure), + (101, 80), + &|_| false, + )); +} + +#[test] +fn active_eyedropper_detects_replaced_missing_or_untracked_source() { + let ActiveScreenRegion::Ready { source, .. } = ocr_region(1.0) else { + unreachable!("OCR fixture is ready") + }; + let mut replacement = source; + replacement.image_generation += 1; + + assert!(!active_eyedropper_source_changed( + true, + Some(source), + &|expected| expected == source, + )); + assert!(active_eyedropper_source_changed( + true, + Some(source), + &|expected| expected == replacement, + )); + assert!(active_eyedropper_source_changed( + true, + Some(source), + &|_| false, + )); + assert!(active_eyedropper_source_changed(true, None, &|_| true)); + assert!(!active_eyedropper_source_changed( + false, + Some(source), + &|_| false, + )); +} diff --git a/src/backend/wayland/state/region_capture/tests/mod.rs b/src/backend/wayland/state/region_capture/tests/mod.rs new file mode 100644 index 000000000..96f6652ee --- /dev/null +++ b/src/backend/wayland/state/region_capture/tests/mod.rs @@ -0,0 +1,8 @@ +use super::*; +use crate::input::state::test_support::make_test_input_state; + +mod capture_selection; +mod lifecycle_and_measure; +mod ocr; +mod picker; +mod review; diff --git a/src/backend/wayland/state/region_capture/tests/ocr.rs b/src/backend/wayland/state/region_capture/tests/ocr.rs new file mode 100644 index 000000000..d1dedf6d7 --- /dev/null +++ b/src/backend/wayland/state/region_capture/tests/ocr.rs @@ -0,0 +1,216 @@ +use super::lifecycle_and_measure::ocr_region; +use super::*; + +#[test] +fn ocr_three_logical_pixels_rearms_but_exactly_four_submits() { + let mut region = ocr_region(1.0); + assert!(region.begin_selection((10.0, 20.0))); + assert!(region.update_endpoint((13.0, 23.0))); + assert_eq!(region.selection_rect(), None); + + assert!(region.update_endpoint((14.0, 24.0))); + assert_eq!( + region + .selection_rect() + .map(|rect| (rect.x(), rect.y(), rect.size())), + Some((10, 20, (4, 4))) + ); +} + +#[test] +fn ocr_release_endpoint_without_motion_matches_forward_and_reversed_scale_oracle() { + for (scale, expected_forward, expected_reversed) in [ + (1.0, (10, 20, (5, 5)), (10, 20, (5, 5))), + (2.0, (20, 41, (9, 8)), (20, 41, (9, 8))), + (1.5, (15, 30, (7, 7)), (15, 30, (7, 7))), + ] { + let mut forward = ocr_region(scale); + assert!(forward.begin_selection((10.25, 20.5))); + assert!(forward.update_endpoint((14.25, 24.5))); + let forward_rect = forward.selection_rect().unwrap(); + assert_eq!( + (forward_rect.x(), forward_rect.y(), forward_rect.size()), + expected_forward, + "forward at scale {scale}" + ); + + let mut reversed = ocr_region(scale); + assert!(reversed.begin_selection((14.25, 24.5))); + assert!(reversed.update_endpoint((10.25, 20.5))); + let reversed_rect = reversed.selection_rect().unwrap(); + assert_eq!( + (reversed_rect.x(), reversed_rect.y(), reversed_rect.size()), + expected_reversed, + "reversed at scale {scale}" + ); + } +} + +#[test] +fn ocr_release_replaces_the_last_motion_endpoint() { + let mut region = ocr_region(1.0); + assert!(region.begin_selection((10.0, 20.0))); + assert!(region.update_endpoint((18.0, 32.0))); + assert_eq!(region.selection_rect().unwrap().size(), (8, 12)); + + assert!(region.update_endpoint((14.0, 24.0))); + assert_eq!(region.selection_rect().unwrap().size(), (4, 4)); +} + +#[test] +fn second_device_press_cannot_replace_an_in_progress_backend_region() { + let mut region = ocr_region(1.0); + assert!(region.begin_selection((10.0, 20.0))); + assert!(region.update_endpoint((18.0, 28.0))); + + assert!(!region.begin_selection((50.0, 60.0))); + assert_eq!( + region + .selection_rect() + .map(|rect| (rect.x(), rect.y(), rect.size())), + Some((10, 20, (8, 8))) + ); +} + +#[test] +fn ocr_policy_keeps_shift_held_drag_rectangular() { + let mut region = ocr_region(1.0); + assert!(region.begin_selection((10.0, 20.0))); + assert!(region.update_endpoint((14.0, 28.0))); + let rect = region.selection_rect().unwrap(); + assert_eq!(rect.size(), (4, 8)); + assert!(!RegionPurposeTag::Ocr.selection_policy().allow_square()); +} + +#[test] +fn held_shift_seeds_capture_but_not_ocr_before_the_first_press() { + assert!(initial_square_modifier( + RegionPurposeTag::CaptureDeliver, + true + )); + assert!(initial_square_modifier( + RegionPurposeTag::CaptureInteractive, + true + )); + assert!(!initial_square_modifier(RegionPurposeTag::Ocr, true)); + assert!(!initial_square_modifier( + RegionPurposeTag::CaptureDeliver, + false + )); +} + +#[test] +fn production_ocr_event_adapter_uses_release_endpoint_at_every_scale() { + for (scale, expected) in [ + (1.0, (10, 20, (5, 5))), + (2.0, (20, 41, (9, 8))), + (1.5, (15, 30, (7, 7))), + ] { + for reversed in [false, true] { + for has_motion in [false, true] { + let (press, release) = if reversed { + ((14.25, 24.5), (10.25, 20.5)) + } else { + ((10.25, 20.5), (14.25, 24.5)) + }; + let mut backend = Some(ocr_region(scale)); + let mut input = make_test_input_state(); + input.activate_region(RegionPurposeTag::Ocr, 1); + + assert!(begin_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + press, + )); + if has_motion { + update_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (30.0, 35.0), + ); + } + let RegionSelectionFinalize::Selected { + purpose: RegionPurposeTag::Ocr, + rect, + } = finalize_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + release, + ) + else { + panic!( + "release must submit at scale={scale} reversed={reversed} motion={has_motion}" + ); + }; + + assert_eq!( + (rect.x(), rect.y(), rect.size()), + expected, + "scale={scale} reversed={reversed} motion={has_motion}" + ); + assert_eq!( + input + .region_state() + .selection() + .map(|selection| selection.end), + Some(release), + "the UI preview and crop adapter diverged" + ); + } + } + } +} + +#[test] +fn production_ocr_event_adapter_rearms_small_drag_and_ignores_shift_square_policy() { + let mut backend = Some(ocr_region(1.0)); + let mut input = make_test_input_state(); + input.activate_region(RegionPurposeTag::Ocr, 1); + input.sync_modifiers(true, false, false, false); + + assert!(begin_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (10.0, 20.0), + )); + let RegionSelectionFinalize::Selected { + purpose: RegionPurposeTag::Ocr, + rect, + } = finalize_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (14.0, 28.0), + ) + else { + panic!("Shift-held OCR drag must submit"); + }; + assert_eq!(rect.size(), (4, 8)); + + let mut backend = Some(ocr_region(1.0)); + let mut input = make_test_input_state(); + input.activate_region(RegionPurposeTag::Ocr, 1); + assert!(begin_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (10.0, 20.0), + )); + assert_eq!( + finalize_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (13.0, 23.0), + ), + RegionSelectionFinalize::Rearmed + ); + assert!(matches!( + input.region_state(), + RegionSelectUiState::Armed { .. } + )); +} diff --git a/src/backend/wayland/state/region_capture/tests/picker.rs b/src/backend/wayland/state/region_capture/tests/picker.rs new file mode 100644 index 000000000..fe6a61698 --- /dev/null +++ b/src/backend/wayland/state/region_capture/tests/picker.rs @@ -0,0 +1,324 @@ +use super::*; +use crate::backend::ExitAfterCaptureMode; +use crate::backend::wayland::state::region_capture::delivery::{ + RegionRenderSource, region_delivery_request, region_render_job, review_delivery_destination, +}; +use crate::backend::wayland::state::region_capture::picker::{ + RegionPickerEntry, legacy_region_request, region_destination, region_picker_entry, +}; +use crate::canvas_export::{ + CanvasExportRect, CanvasRegionExportSnapshot, CanvasRegionSource, SpotlightPassSnapshot, +}; +use crate::capture::{ + CaptureDestination, CaptureType, ImageFormatMetadata, ImageOperationKind, file::FileSaveConfig, +}; +use crate::config::{Action, RegionPicker}; +use crate::input::state::RegionPurposeTag; +use crate::screen_pixels::PackedArgb32; +use crate::ui::RegionAction; + +#[test] +fn every_bound_region_action_has_the_declared_destination() { + let default = CaptureDestination::ClipboardAndFile; + assert_eq!( + region_destination(Action::CaptureSelection, default), + Some(default) + ); + for action in [ + Action::CaptureClipboardSelection, + Action::CaptureClipboardRegion, + ] { + assert_eq!( + region_destination(action, default), + Some(CaptureDestination::ClipboardOnly) + ); + } + for action in [Action::CaptureFileSelection, Action::CaptureFileRegion] { + assert_eq!( + region_destination(action, default), + Some(CaptureDestination::FileOnly) + ); + } + assert_eq!( + region_destination(Action::CaptureRegionInteractive, default), + Some(default) + ); + assert_eq!(region_destination(Action::CaptureFullScreen, default), None); +} + +#[test] +fn review_destination_labels_match_the_delivery_they_request() { + assert_eq!( + review_delivery_destination(RegionAction::Copy), + Some(CaptureDestination::ClipboardOnly) + ); + assert_eq!( + review_delivery_destination(RegionAction::Save), + Some(CaptureDestination::FileOnly) + ); + assert_eq!( + review_delivery_destination(RegionAction::Both), + Some(CaptureDestination::ClipboardAndFile), + "Both must always match its label" + ); + assert_eq!(review_delivery_destination(RegionAction::Board), None); + assert_eq!( + review_delivery_destination(RegionAction::ToggleIncludeDrawings), + None + ); +} + +#[test] +fn the_render_job_composes_drawings_when_asked_and_stays_raw_otherwise() { + // Every destination — Copy, Save, Both and Board alike — takes its PNG + // from this one job, so what the toggle produces cannot differ between + // them. Board therefore honours the toggle by construction. + // + // A deliberately non-uniform 3x2 crop: a first-pixel check would pass on a + // flat image even if the encoder transposed or truncated it. + let swatch: [u32; 6] = [ + 0xFF11_2233, + 0xFF44_5566, + 0xFF77_8899, + 0xFFAA_BBCC, + 0xFFDD_EEFF, + 0xFF01_0203, + ]; + let bytes: Vec = swatch + .iter() + .flat_map(|pixel| pixel.to_ne_bytes()) + .collect(); + let raw = PackedArgb32::new(3, 2, 12, bytes.clone()).expect("a 3x2 crop"); + + let rendered = region_render_job(RegionRenderSource::Raw(raw))().expect("raw render"); + let direct = crate::capture::png::encode_packed_argb32_png( + &PackedArgb32::new(3, 2, 12, bytes).expect("a 3x2 crop"), + ) + .expect("direct encode"); + assert_eq!( + rendered.bytes, direct.bytes, + "the raw path must hand the crop to the encoder untouched" + ); + assert_eq!( + (rendered.width, rendered.height), + (direct.width, direct.height) + ); + assert_eq!(rendered.format, direct.format); + + let decoded = decode_png_pixels(&rendered.bytes); + assert_eq!(decoded, swatch.to_vec(), "every pixel survives in order"); + + // The composed path renders the same selection with the board's committed + // shapes over it, so the crop's pixels are no longer what comes back. + let mut frame = crate::draw::Frame::new(); + frame.add_shape(crate::draw::Shape::Rect { + x: 10, + y: 20, + w: 3, + h: 2, + fill: true, + color: crate::draw::RED, + thick: 1.0, + }); + let snapshot = CanvasRegionExportSnapshot { + source: CanvasRegionSource { + image: std::sync::Arc::new(crate::screen_pixels::ScreenImage { + data: swatch + .iter() + .flat_map(|pixel| pixel.to_ne_bytes()) + .collect(), + width: 3, + height: 2, + stride: 12, + }), + logical_bounds: CanvasExportRect::new(10.0, 20.0, 3.0, 2.0).expect("bounds"), + }, + selection: ImagePixelRect::new(0, 0, 3, 2, (3, 2)).expect("selection"), + frame, + spotlight: SpotlightPassSnapshot { + dim_opacity: 0.0, + feather: 0.0, + }, + }; + let composed = + region_render_job(RegionRenderSource::Annotated(Box::new(snapshot)))().expect("composed"); + assert_eq!( + (composed.width, composed.height), + (3, 2), + "composition keeps the selection's pixel dimensions" + ); + assert_ne!( + decode_png_pixels(&composed.bytes), + swatch.to_vec(), + "the board's committed shapes are composited in" + ); +} + +fn decode_png_pixels(bytes: &[u8]) -> Vec { + let mut surface = + cairo::ImageSurface::create_from_png(&mut { bytes }).expect("the job produced a PNG"); + surface.flush(); + let width = surface.width() as usize; + let height = surface.height() as usize; + let stride = surface.stride() as usize; + let data = surface.data().expect("decoded pixels"); + let mut pixels = Vec::with_capacity(width * height); + for y in 0..height { + for x in 0..width { + let offset = y * stride + x * 4; + pixels.push(u32::from_ne_bytes( + data[offset..offset + 4].try_into().expect("pixel"), + )); + } + } + pixels +} + +#[test] +fn native_entry_auto_freezes_solid_boards_but_refuses_solid_zoom() { + assert_eq!( + region_picker_entry(false, false, false, false, true), + RegionPickerEntry::AutoFreeze + ); + assert_eq!( + region_picker_entry(false, false, true, true, true), + RegionPickerEntry::RefuseSolidZoom + ); +} + +#[test] +fn native_entry_uses_existing_waiting_legacy_and_missing_zoom_paths() { + assert_eq!( + region_picker_entry(true, false, true, true, true), + RegionPickerEntry::Activate + ); + assert_eq!( + region_picker_entry(false, true, true, false, true), + RegionPickerEntry::WaitForZoom + ); + assert_eq!( + region_picker_entry(false, true, false, false, false), + RegionPickerEntry::Legacy + ); + assert_eq!( + region_picker_entry(false, true, true, true, true), + RegionPickerEntry::ZoomImageUnavailable + ); +} + +#[test] +fn picker_options_snapshot_is_independent_of_later_config_changes() { + let mut live = crate::config::CaptureConfig::default(); + let options = RegionPickerOptions::new( + live.region.show_size_readout, + live.region.show_loupe, + live.region.show_legend, + ); + let intent = RegionCaptureIntent::new( + Action::CaptureSelection, + RegionPurposeTag::CaptureDeliver, + CaptureDestination::ClipboardOnly, + None, + ExitAfterCaptureMode::Auto, + options, + live.include_drawings, + ); + live.include_drawings = false; + live.region.show_size_readout = false; + live.region.show_loupe = true; + live.region.show_legend = false; + live.region.picker = RegionPicker::Slurp; + + assert_eq!(live.region.picker, RegionPicker::Slurp); + assert!(!live.region.show_size_readout); + assert!(live.region.show_loupe); + assert!(!live.region.show_legend); + assert!(!live.include_drawings); + assert!(intent.include_drawings()); + assert!(intent.options().show_size_readout()); + assert!(!intent.options().show_loupe()); + assert!(intent.options().show_legend()); +} + +#[test] +fn native_delivery_forces_png_naming_metadata_and_bytes() { + let intent = RegionCaptureIntent::new( + Action::CaptureFileRegion, + RegionPurposeTag::CaptureDeliver, + CaptureDestination::FileOnly, + Some(FileSaveConfig { + save_directory: std::path::PathBuf::from("/tmp/captures"), + filename_template: "region-{timestamp}".to_string(), + format: "jpg".to_string(), + }), + ExitAfterCaptureMode::Never, + RegionPickerOptions::new(true, false, true), + true, + ); + let pixels = PackedArgb32::new(1, 1, 4, 0xff33_2211_u32.to_ne_bytes().to_vec()) + .expect("one native ARGB32 pixel"); + + let render: crate::capture::ImageRenderJob = + Box::new(move || crate::capture::png::encode_packed_argb32_png(&pixels)); + let request = region_delivery_request(render, &intent, intent.destination()); + + assert_eq!(request.destination, CaptureDestination::FileOnly); + assert_eq!(request.operation, ImageOperationKind::Screenshot); + assert_eq!( + request + .save_config + .as_ref() + .map(|save| save.format.as_str()), + Some("png") + ); + assert_eq!( + request.fallback_format_override, + Some(ImageFormatMetadata::png()) + ); + let rendered = (request.render)().expect("native region crop encodes"); + assert_eq!(rendered.format, ImageFormatMetadata::png()); + assert_eq!((rendered.width, rendered.height), (1, 1)); + assert!(rendered.bytes.starts_with(b"\x89PNG\r\n\x1a\n")); +} + +#[test] +fn legacy_handoff_preserves_the_reserved_selection_request_snapshot() { + let intent = RegionCaptureIntent::new( + Action::CaptureFileSelection, + RegionPurposeTag::CaptureDeliver, + CaptureDestination::FileOnly, + Some(FileSaveConfig { + save_directory: std::path::PathBuf::from("/tmp/original"), + filename_template: "shot-{timestamp}".to_string(), + format: "jpg".to_string(), + }), + ExitAfterCaptureMode::Always, + RegionPickerOptions::new(false, true, false), + true, + ); + + let request = legacy_region_request(&intent); + + assert!(matches!( + request.capture_type, + CaptureType::Selection { + x: 0, + y: 0, + width: 0, + height: 0 + } + )); + assert_eq!(request.destination, CaptureDestination::FileOnly); + let save = request + .save_config + .expect("file selection keeps save config"); + assert_eq!( + save.save_directory, + std::path::PathBuf::from("/tmp/original") + ); + assert_eq!(save.filename_template, "shot-{timestamp}"); + assert_eq!( + save.format, "jpg", + "explicit slurp keeps the legacy configured format" + ); +} diff --git a/src/backend/wayland/state/region_capture/tests/review.rs b/src/backend/wayland/state/region_capture/tests/review.rs new file mode 100644 index 000000000..55a9c56de --- /dev/null +++ b/src/backend/wayland/state/region_capture/tests/review.rs @@ -0,0 +1,834 @@ +use super::capture_selection::{capture_region, capture_region_at_scale, interactive_region}; +use super::lifecycle_and_measure::ocr_region; +use super::*; + +#[test] +fn capture_hover_motion_requests_a_repaint_while_armed_and_in_review() { + let mut armed_backend = Some(capture_region()); + let mut armed_input = make_test_input_state(); + armed_input.activate_region(RegionPurposeTag::CaptureDeliver, 1); + let _ = armed_input.dirty_tracker.take_region_report(100, 80); + armed_input.needs_redraw = false; + + update_region_selection_event( + &mut armed_backend, + &mut armed_input, + RegionInputSource::Pointer, + (35.0, 45.0), + ); + + assert!(armed_input.needs_redraw, "armed crosshair follows hover"); + assert_eq!( + armed_input + .dirty_tracker + .take_region_report(100, 80) + .regions, + vec![crate::util::Rect::new(0, 0, 100, 80).unwrap()] + ); + + let mut review_region = interactive_region(); + let rect = ImagePixelRect::new(20, 20, 30, 25, (100, 80)).unwrap(); + let display = review_region.enter_review(rect).unwrap(); + let mut review_backend = Some(review_region); + let mut review_input = make_test_input_state(); + review_input.activate_region_review(RegionPurposeTag::CaptureInteractive, 1, display); + let _ = review_input.dirty_tracker.take_region_report(100, 80); + review_input.needs_redraw = false; + + update_region_selection_event( + &mut review_backend, + &mut review_input, + RegionInputSource::Pointer, + (70.0, 70.0), + ); + + assert!( + review_input.needs_redraw, + "review hover, action bar, and loupe follow the pointer" + ); + assert_eq!( + review_input + .dirty_tracker + .take_region_report(100, 80) + .regions, + vec![crate::util::Rect::new(0, 0, 100, 80).unwrap()] + ); +} + +#[test] +fn review_nudge_and_move_clamp_without_resizing_and_owner_loss_keeps_review() { + let mut region = interactive_region(); + let rect = ImagePixelRect::new(70, 60, 20, 15, (100, 80)).unwrap(); + let display = region.enter_review(rect).unwrap(); + let mut backend = Some(region); + let mut input = make_test_input_state(); + input.activate_region_review(RegionPurposeTag::CaptureInteractive, 1, display); + + let nudged = backend + .as_mut() + .and_then(|region| region.nudge_review(50, 50)) + .unwrap(); + input.update_region_review_display(nudged); + let clamped = backend + .and_then(ActiveScreenRegion::selection_rect) + .unwrap(); + assert_eq!( + (clamped.x(), clamped.y(), clamped.size()), + (80, 65, (20, 15)) + ); + + assert!(begin_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Stylus, + (85.0, 70.0), + )); + update_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Stylus, + (20.0, 20.0), + ); + assert_eq!( + region_owner_lost_event(&mut backend, &mut input, RegionInputSource::Stylus), + RegionOwnerLoss::Rearmed + ); + assert!(input.region_state().is_review()); + assert!(input.region_state().selection_owner().is_none()); + assert!( + backend + .and_then(ActiveScreenRegion::selection_rect) + .is_some() + ); +} + +#[test] +fn review_move_preserves_subpixel_motion_until_it_reaches_a_pixel() { + let mut region = interactive_region(); + let rect = ImagePixelRect::new(20, 20, 30, 25, (100, 80)).unwrap(); + region.enter_review(rect).unwrap(); + assert!(region.begin_review_move((25.0, 25.0))); + + for x in [25.6, 26.2, 26.8, 27.4, 28.0] { + region.update_review_move((x, 25.0)); + } + + let moved = region.stored_review_rect().unwrap(); + assert_eq!( + (moved.x(), moved.y(), moved.size()), + (23, 20, (30, 25)), + "three logical pixels of motion must move exactly three image pixels" + ); +} + +#[test] +fn second_device_press_cannot_replace_an_in_progress_review_move() { + let mut region = interactive_region(); + let rect = ImagePixelRect::new(20, 20, 30, 25, (100, 80)).unwrap(); + let display = region.enter_review(rect).unwrap(); + let mut backend = Some(region); + let mut input = make_test_input_state(); + input.activate_region_review(RegionPurposeTag::CaptureInteractive, 1, display); + + assert!(begin_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (25.0, 25.0), + )); + assert!(!begin_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Touch, + (70.0, 70.0), + )); + + assert!(input.region_state().is_review()); + assert!(input.region_selection_is_owned_by(RegionInputSource::Pointer)); + assert_eq!( + backend.and_then(ActiveScreenRegion::selection_rect), + Some(rect) + ); +} + +#[test] +fn capture_pixel_span_reports_one_axis_empty_without_submitting_it() { + let mut region = capture_region(); + assert!(region.begin_selection((10.8, 20.6))); + assert!(region.update_endpoint((10.0, 40.0))); + + let geometry = region.selection_geometry().expect("drag geometry"); + assert_eq!(geometry.image_span().size(), (0, 20)); + assert_eq!(geometry.image_rect(), None); + assert_eq!(region.selection_rect(), None); +} + +#[test] +fn capture_finalize_is_purpose_aware_and_one_axis_empty_rearms() { + let mut backend = Some(capture_region()); + let mut input = make_test_input_state(); + input.activate_region(RegionPurposeTag::CaptureDeliver, 1); + assert!(begin_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (10.8, 20.6), + )); + assert_eq!( + finalize_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (18.2, 35.8), + ), + RegionSelectionFinalize::Selected { + purpose: RegionPurposeTag::CaptureDeliver, + rect: ImagePixelRect::new(10, 20, 9, 16, (100, 80)).unwrap(), + } + ); + + let mut backend = Some(capture_region()); + let mut input = make_test_input_state(); + input.activate_region(RegionPurposeTag::CaptureDeliver, 1); + assert!(begin_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Touch, + (10.8, 20.6), + )); + assert_eq!( + finalize_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Touch, + (10.0, 40.0), + ), + RegionSelectionFinalize::Rearmed + ); + assert!(matches!( + input.region_state(), + RegionSelectUiState::Armed { + purpose: RegionPurposeTag::CaptureDeliver, + generation: 1, + } + )); + assert_eq!(backend.unwrap().selection_geometry(), None); +} + +#[test] +fn capture_shift_square_uses_the_dominant_image_axis_and_restores_the_raw_edge() { + let mut region = capture_region(); + assert!(region.begin_selection((10.8, 20.6))); + assert!(region.update_endpoint((18.2, 35.8))); + assert_eq!( + region.selection_geometry().unwrap().image_span().size(), + (9, 16) + ); + + assert!(region.set_square_modifier(true)); + assert_eq!( + region.selection_geometry().unwrap().image_span().size(), + (16, 16) + ); + + assert!(region.set_square_modifier(false)); + assert_eq!( + region.selection_geometry().unwrap().image_span().size(), + (9, 16), + "releasing Shift must recompute from the canonical raw edge" + ); +} + +#[test] +fn capture_shift_square_caps_the_side_to_the_first_image_edge() { + let mut region = capture_region(); + assert!(region.begin_selection((90.8, 70.4))); + assert!(region.update_endpoint((10.0, 40.0))); + assert!(region.set_square_modifier(true)); + + let geometry = region.selection_geometry().unwrap(); + assert_eq!(geometry.image_span().size(), (70, 70)); + assert_eq!(geometry.image_span().x(), 20); + assert_eq!(geometry.image_span().y(), 0); +} + +#[test] +fn capture_square_and_readout_share_image_pixels_at_integer_fractional_and_zoom_views() { + let mut cases = [ + ("scale-1", capture_region_at_scale(1.0)), + ("scale-2", capture_region_at_scale(2.0)), + ("fractional-1.5", capture_region_at_scale(1.5)), + ("zoom-2", capture_region_at_scale(1.0)), + ]; + if let ActiveScreenRegion::Ready { source, .. } = &mut cases[3].1 { + source.zoom_transformed = true; + source.zoom_scale = 2.0; + source.zoom_view_offset = (10.0, 5.0); + } + + for (name, mut region) in cases { + assert!(region.begin_selection((20.25, 20.5)), "{name}"); + assert!(region.update_endpoint((40.25, 50.5)), "{name}"); + assert!(region.set_square_modifier(true), "{name}"); + + let geometry = region.selection_geometry().expect("square geometry"); + let (width, height) = geometry.image_span().size(); + assert_eq!( + width, height, + "square must be square in image pixels: {name}" + ); + assert!(width > 0, "test drag must cover pixels: {name}"); + assert_eq!( + region.picker_measurement((0.0, 0.0)), + Some(RegionPickerMeasurement::Size { width, height }), + "readout must describe the exact square crop: {name}" + ); + assert_eq!( + geometry.image_rect().map(ImagePixelRect::size), + Some((width, height)), + "submitted crop must share the readout's pixel span: {name}" + ); + } +} + +#[test] +fn capture_measurement_maps_armed_pointer_and_reports_exact_selecting_span() { + let mut region = capture_region_at_scale(2.0); + assert_eq!( + region.picker_measurement((10.25, 20.5)), + Some(RegionPickerMeasurement::Point { x: 20, y: 41 }) + ); + + assert!(region.begin_selection((10.25, 20.5))); + assert_eq!( + region.picker_measurement((999.0, 999.0)), + Some(RegionPickerMeasurement::Size { + width: 0, + height: 0, + }) + ); + assert!(region.update_endpoint((14.25, 24.5))); + assert_eq!( + region.picker_measurement((0.0, 0.0)), + Some(RegionPickerMeasurement::Size { + width: 9, + height: 8, + }) + ); + assert_eq!(ocr_region(2.0).picker_measurement((10.25, 20.5)), None); +} + +#[test] +fn compositor_shift_sync_recomputes_capture_preview_without_changing_ownership() { + let mut backend = Some(capture_region()); + let mut input = make_test_input_state(); + input.activate_region(RegionPurposeTag::CaptureDeliver, 1); + + assert!(sync_region_square_modifier_event( + &mut backend, + &mut input, + true, + )); + assert!(begin_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Touch, + (10.8, 20.6), + )); + update_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Touch, + (18.2, 35.8), + ); + let square = backend.unwrap().selection_geometry().unwrap(); + assert_eq!(square.image_span().size(), (16, 16)); + assert_eq!( + input.region_state().selection().unwrap(), + square.display_selection() + ); + assert!(input.region_selection_is_owned_by(RegionInputSource::Touch)); + + assert!(sync_region_square_modifier_event( + &mut backend, + &mut input, + false, + )); + let raw = backend.unwrap().selection_geometry().unwrap(); + assert_eq!(raw.image_span().size(), (9, 16)); + assert_eq!( + input.region_state().selection().unwrap(), + raw.display_selection() + ); + assert!(input.region_selection_is_owned_by(RegionInputSource::Touch)); +} + +#[test] +fn capture_owner_loss_rearms_without_releasing_backend_ownership() { + let mut backend = Some(capture_region()); + let mut input = make_test_input_state(); + input.activate_region(RegionPurposeTag::CaptureDeliver, 1); + assert!(begin_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Stylus, + (10.0, 20.0), + )); + assert!( + backend.is_some_and(ActiveScreenRegion::legend_dismissed), + "the first press permanently dismisses this picker's legend" + ); + update_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Stylus, + (30.0, 40.0), + ); + + assert_eq!( + region_owner_lost_event(&mut backend, &mut input, RegionInputSource::Touch), + RegionOwnerLoss::NotOwned + ); + assert!(input.region_selection_is_owned_by(RegionInputSource::Stylus)); + + assert_eq!( + region_owner_lost_event(&mut backend, &mut input, RegionInputSource::Stylus), + RegionOwnerLoss::Rearmed + ); + assert!(matches!( + input.region_state(), + RegionSelectUiState::Armed { + purpose: RegionPurposeTag::CaptureDeliver, + generation: 1, + } + )); + assert!(backend.is_some(), "capture source ownership was released"); + let backend = backend.unwrap(); + assert_eq!(backend.selection_geometry(), None); + assert!( + backend.legend_dismissed(), + "rearming must not show the first-use legend again" + ); +} + +#[test] +fn ocr_owner_loss_requests_its_existing_terminal_cancel_path() { + let mut backend = Some(ocr_region(1.0)); + let mut input = make_test_input_state(); + input.activate_region(RegionPurposeTag::Ocr, 1); + assert!(begin_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (10.0, 20.0), + )); + + assert_eq!( + region_owner_lost_event(&mut backend, &mut input, RegionInputSource::Pointer), + RegionOwnerLoss::Cancel(RegionPurposeTag::Ocr) + ); + assert!( + input.region_selection_is_owned_by(RegionInputSource::Pointer), + "the lifecycle owner must perform OCR cleanup" + ); +} + +#[test] +fn whole_image_is_available_only_to_capture_purposes() { + let capture = capture_region(); + let RegionSelectionFinalize::Selected { purpose, rect } = capture + .whole_image_selection() + .expect("capture whole image") + else { + panic!("whole image must be a selected result") + }; + assert_eq!(purpose, RegionPurposeTag::CaptureDeliver); + assert_eq!((rect.x(), rect.y(), rect.size()), (0, 0, (100, 80))); + assert_eq!(ocr_region(1.0).whole_image_selection(), None); +} + +#[test] +fn a_grip_moves_the_edges_it_owns_and_leaves_the_opposite_ones_put() { + let bounds = (100, 80); + let rect = ImagePixelRect::new(20, 20, 40, 30, bounds).unwrap(); + let at = |x: f64, y: f64| ImagePoint::new(x, y); + + for (handle, edge, expected) in [ + (SelectionHandle::TopLeft, at(10.0, 5.0), (10, 5, 50, 45)), + (SelectionHandle::TopRight, at(80.0, 5.0), (20, 5, 60, 45)), + ( + SelectionHandle::BottomLeft, + at(10.0, 70.0), + (10, 20, 50, 50), + ), + ( + SelectionHandle::BottomRight, + at(80.0, 70.0), + (20, 20, 60, 50), + ), + (SelectionHandle::Top, at(0.0, 5.0), (20, 5, 40, 45)), + (SelectionHandle::Bottom, at(0.0, 70.0), (20, 20, 40, 50)), + (SelectionHandle::Left, at(10.0, 0.0), (10, 20, 50, 30)), + (SelectionHandle::Right, at(80.0, 0.0), (20, 20, 60, 30)), + ] { + let resized = resized_review_rect(rect, handle, edge, bounds).unwrap(); + assert_eq!( + (resized.x(), resized.y(), resized.width(), resized.height()), + expected, + "{handle:?}" + ); + } +} + +#[test] +fn a_grip_dragged_past_its_opposite_edge_stops_instead_of_inverting() { + let bounds = (100, 80); + let rect = ImagePixelRect::new(20, 20, 40, 30, bounds).unwrap(); + + let crossed = resized_review_rect( + rect, + SelectionHandle::Left, + ImagePoint::new(95.0, 0.0), + bounds, + ) + .unwrap(); + assert_eq!( + (crossed.x(), crossed.width()), + (59, 1), + "the left edge stops one pixel short of the right" + ); + + let crossed = resized_review_rect( + rect, + SelectionHandle::Bottom, + ImagePoint::new(0.0, 0.0), + bounds, + ) + .unwrap(); + assert_eq!( + (crossed.y(), crossed.height()), + (20, 1), + "the bottom edge stops one pixel below the top" + ); +} + +#[test] +fn a_grip_dragged_off_screen_clamps_to_the_image_bounds() { + let bounds = (100, 80); + let rect = ImagePixelRect::new(20, 20, 40, 30, bounds).unwrap(); + + let out = resized_review_rect( + rect, + SelectionHandle::BottomRight, + ImagePoint::new(400.0, 400.0), + bounds, + ) + .unwrap(); + assert_eq!( + (out.x(), out.y(), out.width(), out.height()), + (20, 20, 80, 60), + "the rectangle may end exactly on the image edge" + ); + + let out = resized_review_rect( + rect, + SelectionHandle::TopLeft, + ImagePoint::new(-40.0, -40.0), + bounds, + ) + .unwrap(); + assert_eq!( + (out.x(), out.y()), + (0, 0), + "a finite drag past the origin clamps to it" + ); + + for broken in [ + ImagePoint::new(f64::NAN, 10.0), + ImagePoint::new(10.0, f64::INFINITY), + ImagePoint::new(f64::NEG_INFINITY, f64::NAN), + ] { + assert!( + resized_review_rect(rect, SelectionHandle::TopLeft, broken, bounds).is_none(), + "a non-finite coordinate is a broken event, not a drag to the origin" + ); + } +} + +#[test] +fn pressing_a_grip_resizes_while_pressing_the_interior_still_moves() { + let bounds = (100, 80); + let mut region = interactive_region(); + let rect = ImagePixelRect::new(20, 20, 40, 30, bounds).unwrap(); + let display = region.enter_review(rect).unwrap(); + let mut backend = Some(region); + let mut input = make_test_input_state(); + input.activate_region_review(RegionPurposeTag::CaptureInteractive, 1, display); + + // The bottom-right grip sits on the rectangle's own corner. + assert!(begin_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (60.0, 50.0), + )); + assert_eq!( + backend.and_then(ActiveScreenRegion::review_resize_handle), + Some(SelectionHandle::BottomRight), + "a press on a corner grip starts a resize, not a move" + ); + + update_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (75.0, 65.0), + ); + assert_eq!( + backend + .and_then(ActiveScreenRegion::selection_rect) + .map(|rect| (rect.x(), rect.y(), rect.width(), rect.height())), + Some((20, 20, 55, 45)), + "the held corner follows the pointer" + ); + + assert_eq!( + finalize_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (75.0, 65.0), + ), + RegionSelectionFinalize::Reviewed + ); + assert!( + backend + .and_then(ActiveScreenRegion::review_resize_handle) + .is_none(), + "release drops the grip" + ); + assert!(input.region_state().selection_owner().is_none()); + + // Well inside the rectangle there is no grip, so the press moves it. + assert!(begin_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (45.0, 40.0), + )); + assert!( + backend + .and_then(ActiveScreenRegion::review_resize_handle) + .is_none(), + "an interior press is still a move-drag" + ); + update_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (50.0, 40.0), + ); + assert_eq!( + backend + .and_then(ActiveScreenRegion::selection_rect) + .map(|rect| (rect.x(), rect.y(), rect.width(), rect.height())), + Some((25, 20, 55, 45)), + "the whole rectangle translates, keeping its size" + ); +} + +#[test] +fn a_held_grip_blocks_nudging_and_a_second_devices_press() { + let bounds = (100, 80); + let mut region = interactive_region(); + let rect = ImagePixelRect::new(20, 20, 40, 30, bounds).unwrap(); + let display = region.enter_review(rect).unwrap(); + let mut backend = Some(region); + let mut input = make_test_input_state(); + input.activate_region_review(RegionPurposeTag::CaptureInteractive, 1, display); + + assert!(begin_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (20.0, 20.0), + )); + assert_eq!( + backend.and_then(ActiveScreenRegion::review_resize_handle), + Some(SelectionHandle::TopLeft) + ); + assert!( + backend + .as_mut() + .and_then(|region| region.nudge_review(1, 0)) + .is_none(), + "arrows must not fight a live grip drag" + ); + assert!(!begin_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Touch, + (60.0, 50.0), + )); + + assert_eq!( + region_owner_lost_event(&mut backend, &mut input, RegionInputSource::Pointer), + RegionOwnerLoss::Rearmed + ); + assert!( + backend + .and_then(ActiveScreenRegion::review_resize_handle) + .is_none(), + "losing the device drops the grip and keeps Review" + ); + assert!(input.region_state().is_review()); +} + +#[test] +fn selecting_the_whole_image_mid_resize_leaves_review_usable() { + // `Ctrl+A` replaces the rectangle while a grip is still held. The grip must + // not survive into the new rectangle, or every later move, resize and + // nudge would be refused. + let bounds = (100, 80); + let mut region = interactive_region(); + let rect = ImagePixelRect::new(20, 20, 40, 30, bounds).unwrap(); + let display = region.enter_review(rect).unwrap(); + let mut backend = Some(region); + let mut input = make_test_input_state(); + input.activate_region_review(RegionPurposeTag::CaptureInteractive, 1, display); + + assert!(begin_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (20.0, 20.0), + )); + assert!( + backend + .and_then(ActiveScreenRegion::review_resize_handle) + .is_some() + ); + + let whole = ImagePixelRect::whole(bounds).unwrap(); + let display = backend + .as_mut() + .and_then(|region| region.enter_review(whole)) + .unwrap(); + input.activate_region_review(RegionPurposeTag::CaptureInteractive, 1, display); + + assert!( + backend + .and_then(ActiveScreenRegion::review_resize_handle) + .is_none(), + "the held grip does not survive the new rectangle" + ); + assert!( + backend + .as_mut() + .and_then(|region| region.nudge_review(1, 0)) + .is_some(), + "nudging is answered again; a stale grip would refuse it" + ); + assert!( + backend + .as_mut() + .is_some_and(|region| region.begin_review_move((50.0, 40.0))), + "and the rectangle can be dragged again" + ); +} + +#[test] +fn a_grip_click_without_motion_leaves_the_rectangle_untouched_at_any_scale() { + // Grips are placed on the display rectangle, which rounds outward from the + // image rectangle. Without the grab offset the release event alone would + // re-round that position and shift an edge by a pixel. + let bounds = (100, 80); + for scale in [1.0, 1.5, 2.0] { + let mut region = capture_region_at_scale(scale); + if let ActiveScreenRegion::Ready { purpose, .. } = &mut region { + *purpose = RegionPurposeTag::CaptureInteractive; + } + let rect = ImagePixelRect::new(21, 17, 43, 31, bounds).unwrap(); + let Some(display) = region.enter_review(rect) else { + continue; + }; + let mut backend = Some(region); + let mut input = make_test_input_state(); + input.activate_region_review(RegionPurposeTag::CaptureInteractive, 1, display); + + let left = display.start.0.min(display.end.0); + let top = display.start.1.min(display.end.1); + let right = display.start.0.max(display.end.0); + let bottom = display.start.1.max(display.end.1); + for grip in [(left, top), (right, bottom), (right, top), (left, bottom)] { + assert!( + begin_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + grip, + ), + "scale {scale}: {grip:?} must grab a grip" + ); + assert!( + backend + .and_then(ActiveScreenRegion::review_resize_handle) + .is_some(), + "scale {scale}: {grip:?} grabbed the interior instead of a grip" + ); + assert_eq!( + finalize_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + grip, + ), + RegionSelectionFinalize::Reviewed + ); + assert_eq!( + backend.and_then(ActiveScreenRegion::selection_rect), + Some(rect), + "scale {scale}: pressing and releasing {grip:?} must not move an edge" + ); + } + } +} + +#[test] +fn a_grip_drag_tracks_the_pointer_from_where_it_was_grabbed() { + let bounds = (100, 80); + let mut region = interactive_region(); + // Tall enough that the right edge keeps its midpoint grip. + let rect = ImagePixelRect::new(20, 10, 40, 60, bounds).unwrap(); + let display = region.enter_review(rect).unwrap(); + let mut backend = Some(region); + let mut input = make_test_input_state(); + input.activate_region_review(RegionPurposeTag::CaptureInteractive, 1, display); + + // Grab the right-edge grip two pixels outside the edge itself, then drag + // ten pixels right: the edge advances by ten and keeps the two-pixel + // offset, rather than jumping onto the pointer. + assert!(begin_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (62.0, 40.0), + )); + assert_eq!( + backend.and_then(ActiveScreenRegion::review_resize_handle), + Some(SelectionHandle::Right) + ); + update_region_selection_event( + &mut backend, + &mut input, + RegionInputSource::Pointer, + (72.0, 40.0), + ); + assert_eq!( + backend + .and_then(ActiveScreenRegion::selection_rect) + .map(|rect| (rect.x(), rect.width())), + Some((20, 50)), + "the grabbed edge keeps its offset from the pointer" + ); +} diff --git a/src/backend/wayland/state/region_capture/window_snap.rs b/src/backend/wayland/state/region_capture/window_snap.rs new file mode 100644 index 000000000..34dc8838e --- /dev/null +++ b/src/backend/wayland/state/region_capture/window_snap.rs @@ -0,0 +1,451 @@ +use crate::backend::wayland::state::WaylandState; +use crate::backend::wayland::state::screen_image::{ScreenSourceToken, screen_rect_for_image_rect}; +use crate::capture::window_geometry::{WindowQueryContext, WindowTarget}; +use crate::input::state::{RegionInputSource, RegionPurposeTag}; +use crate::screen_pixels::{ImagePixelRect, ImagePoint}; +use crate::util::Rect; + +mod query; +#[cfg(test)] +mod tests; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(in crate::backend::wayland::state) struct WindowSnapCorrelation { + generation: u64, + source: ScreenSourceToken, +} + +impl WindowSnapCorrelation { + pub(super) const fn new(generation: u64, source: ScreenSourceToken) -> Self { + Self { generation, source } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub(in crate::backend::wayland) struct WindowSnapQuery { + pub(super) correlation: WindowSnapCorrelation, + pub(super) context: WindowQueryContext, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::backend::wayland) struct WindowSnapTarget { + image_rect: ImagePixelRect, + screen_rect: Rect, +} + +impl WindowSnapTarget { + pub(in crate::backend::wayland) const fn image_rect(&self) -> ImagePixelRect { + self.image_rect + } + + pub(in crate::backend::wayland) const fn screen_rect(&self) -> Rect { + self.screen_rect + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum WindowSnapAvailability { + Pending(WindowSnapQueryStage), + Ready(Vec), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum WindowSnapQueryStage { + Queued(WindowQueryContext), + Started, +} + +#[derive(Debug, Clone, PartialEq)] +pub(in crate::backend::wayland::state) struct WindowSnapSession { + correlation: WindowSnapCorrelation, + availability: WindowSnapAvailability, + mode_active: bool, + hovered: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::backend::wayland) enum WindowSnapDirection { + Left, + Right, + Up, + Down, +} + +impl WindowSnapSession { + #[cfg(test)] + pub(super) const fn pending(correlation: WindowSnapCorrelation) -> Self { + Self { + correlation, + availability: WindowSnapAvailability::Pending(WindowSnapQueryStage::Started), + mode_active: false, + hovered: None, + } + } + + pub(super) fn queued(correlation: WindowSnapCorrelation, context: WindowQueryContext) -> Self { + Self { + correlation, + availability: WindowSnapAvailability::Pending(WindowSnapQueryStage::Queued(context)), + mode_active: false, + hovered: None, + } + } + + pub(super) fn queued_query(&self) -> Option { + let WindowSnapAvailability::Pending(WindowSnapQueryStage::Queued(context)) = + &self.availability + else { + return None; + }; + Some(WindowSnapQuery { + correlation: self.correlation, + context: context.clone(), + }) + } + + pub(super) fn mark_query_started(&mut self, correlation: WindowSnapCorrelation) -> bool { + if self.correlation != correlation + || !matches!( + self.availability, + WindowSnapAvailability::Pending(WindowSnapQueryStage::Queued(_)) + ) + { + return false; + } + self.availability = WindowSnapAvailability::Pending(WindowSnapQueryStage::Started); + true + } + + pub(super) const fn correlation(&self) -> WindowSnapCorrelation { + self.correlation + } + + pub(super) fn is_ready(&self) -> bool { + matches!(self.availability, WindowSnapAvailability::Ready(_)) + } + + pub(super) const fn mode_active(&self) -> bool { + self.mode_active + } + + pub(super) fn targets(&self) -> &[WindowSnapTarget] { + match &self.availability { + WindowSnapAvailability::Pending(_) => &[], + WindowSnapAvailability::Ready(targets) => targets, + } + } + + pub(super) fn hovered_target(&self) -> Option<&WindowSnapTarget> { + self.hovered.and_then(|index| self.targets().get(index)) + } + + pub(super) const fn hovered_index(&self) -> Option { + self.hovered + } + + pub(super) fn toggle_mode(&mut self) -> bool { + if !self.is_ready() { + return false; + } + self.mode_active = !self.mode_active; + self.hovered = None; + true + } + + pub(super) fn update_hover(&mut self, point: (f64, f64)) -> bool { + let next = if self.mode_active && point.0.is_finite() && point.1.is_finite() { + let x = point.0.floor() as i32; + let y = point.1.floor() as i32; + self.targets() + .iter() + .rposition(|target| target.screen_rect.contains(x, y)) + } else { + None + }; + if self.hovered == next { + return false; + } + self.hovered = next; + true + } + + pub(super) fn navigate(&mut self, direction: WindowSnapDirection, pointer: (f64, f64)) -> bool { + if !self.mode_active || !pointer.0.is_finite() || !pointer.1.is_finite() { + return false; + } + let origin = self + .hovered_target() + .map(|target| rect_center(target.screen_rect)) + .unwrap_or(pointer); + let current = self.hovered; + let mut best: Option<(usize, f64)> = None; + // Reverse provider order makes the visually topmost target win an + // exact score tie, matching pointer hit testing. + for (index, target) in self.targets().iter().enumerate().rev() { + if Some(index) == current { + continue; + } + let center = rect_center(target.screen_rect); + let delta = (center.0 - origin.0, center.1 - origin.1); + let (along, across) = match direction { + WindowSnapDirection::Left if delta.0 < 0.0 => (-delta.0, delta.1.abs()), + WindowSnapDirection::Right if delta.0 > 0.0 => (delta.0, delta.1.abs()), + WindowSnapDirection::Up if delta.1 < 0.0 => (-delta.1, delta.0.abs()), + WindowSnapDirection::Down if delta.1 > 0.0 => (delta.1, delta.0.abs()), + WindowSnapDirection::Left + | WindowSnapDirection::Right + | WindowSnapDirection::Up + | WindowSnapDirection::Down => continue, + }; + let score = along + across * 1.75; + if best.is_none_or(|(_, best_score)| score < best_score) { + best = Some((index, score)); + } + } + let Some((next, _)) = best else { + return false; + }; + self.hovered = Some(next); + true + } +} + +fn rect_center(rect: Rect) -> (f64, f64) { + ( + f64::from(rect.x) + f64::from(rect.width) / 2.0, + f64::from(rect.y) + f64::from(rect.height) / 2.0, + ) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum WindowQueryApply { + Stale, + Unavailable, + Ready, +} + +pub(super) fn apply_window_query_completion( + session: &mut Option, + correlation: WindowSnapCorrelation, + targets: Vec, +) -> WindowQueryApply { + let Some(current) = session.as_ref() else { + return WindowQueryApply::Stale; + }; + if current.correlation != correlation + || !matches!( + current.availability, + WindowSnapAvailability::Pending(WindowSnapQueryStage::Started) + ) + { + return WindowQueryApply::Stale; + } + let mapped: Vec<_> = targets + .into_iter() + .filter_map(|target| map_window_target(correlation.source, target)) + .collect(); + if mapped.is_empty() { + *session = None; + return WindowQueryApply::Unavailable; + } + let current = session + .as_mut() + .expect("a correlated pending window-snap session still exists"); + current.availability = WindowSnapAvailability::Ready(mapped); + WindowQueryApply::Ready +} + +fn map_window_target(source: ScreenSourceToken, target: WindowTarget) -> Option { + let logical_rect = target.logical_rect; + let first = output_logical_to_image_point(source, (logical_rect.x, logical_rect.y))?; + let second = output_logical_to_image_point( + source, + ( + logical_rect.x.checked_add(logical_rect.width)?, + logical_rect.y.checked_add(logical_rect.height)?, + ), + )?; + let image_rect = ImagePixelRect::from_points(first, second, source.image_size)?; + Some(WindowSnapTarget { + image_rect, + screen_rect: screen_rect_for_image_rect(&source, image_rect), + }) +} + +fn output_logical_to_image_point( + source: ScreenSourceToken, + point: (i32, i32), +) -> Option { + if point.0 < 0 || point.1 < 0 || source.surface.0 == 0 || source.surface.1 == 0 { + return None; + } + Some(ImagePoint::new( + f64::from(point.0) * f64::from(source.image_size.0) / f64::from(source.surface.0), + f64::from(point.1) * f64::from(source.image_size.1) / f64::from(source.surface.1), + )) +} + +impl WaylandState { + pub(in crate::backend::wayland) fn region_window_snap_available(&self) -> bool { + self.data + .window_snap + .as_ref() + .is_some_and(WindowSnapSession::is_ready) + } + + pub(in crate::backend::wayland) fn region_window_snap_active(&self) -> bool { + self.data + .window_snap + .as_ref() + .is_some_and(WindowSnapSession::mode_active) + } + + pub(in crate::backend::wayland) fn region_window_snap_targets(&self) -> &[WindowSnapTarget] { + self.data + .window_snap + .as_ref() + .map(WindowSnapSession::targets) + .unwrap_or_default() + } + + pub(in crate::backend::wayland) fn region_window_snap_highlighted_index( + &self, + ) -> Option { + self.data + .window_snap + .as_ref() + .and_then(WindowSnapSession::hovered_index) + } + + pub(in crate::backend::wayland) fn toggle_region_window_snap(&mut self) -> bool { + self.cancel_screen_modals_if_source_changed(); + let ui = self.input_state.region_state(); + if !ui.is_active() + || ui.is_review() + || !ui.purpose().is_some_and(RegionPurposeTag::is_capture) + { + return false; + } + let owner = ui.selection_owner(); + let toggled = self + .data + .window_snap + .as_mut() + .is_some_and(WindowSnapSession::toggle_mode); + if !toggled { + return false; + } + if owner.is_some() { + super::events::rearm_region_selection_event( + &mut self.data.active_screen_region, + &mut self.input_state, + ); + self.retire_region_selection_owner(owner); + } + self.update_region_window_hover(self.current_region_pointer()); + self.mark_region_window_snap_dirty(); + true + } + + pub(in crate::backend::wayland) fn navigate_region_window_snap( + &mut self, + direction: WindowSnapDirection, + ) -> bool { + self.cancel_screen_modals_if_source_changed(); + let pointer = self.current_region_pointer(); + let changed = self + .data + .window_snap + .as_mut() + .is_some_and(|session| session.navigate(direction, pointer)); + if changed { + self.mark_region_window_snap_dirty(); + } + changed + } + + pub(in crate::backend::wayland) fn choose_hovered_region_window(&mut self) -> bool { + self.choose_region_window(None, None) + } + + pub(super) fn begin_region_window_choice( + &mut self, + owner: RegionInputSource, + point: (f64, f64), + ) -> bool { + if !self.region_window_snap_active() { + return false; + } + self.choose_region_window(Some(owner), Some(point)); + true + } + + pub(super) fn update_region_window_hover(&mut self, point: (f64, f64)) -> bool { + if !self.region_window_snap_active() { + return false; + } + let changed = self + .data + .window_snap + .as_mut() + .is_some_and(|session| session.update_hover(point)); + if changed { + self.mark_region_window_snap_dirty(); + } + true + } + + fn choose_region_window( + &mut self, + owner: Option, + point: Option<(f64, f64)>, + ) -> bool { + if let Some(point) = point { + self.update_region_window_hover(point); + } + let Some(rect) = self + .data + .window_snap + .as_ref() + .and_then(WindowSnapSession::hovered_target) + .map(WindowSnapTarget::image_rect) + else { + return false; + }; + let Some(purpose) = self + .data + .active_screen_region + .map(|region| region.purpose()) + .filter(|purpose| purpose.is_capture()) + else { + return false; + }; + self.clear_region_window_snap(); + self.retire_region_selection_owner(owner); + if purpose == RegionPurposeTag::CaptureInteractive { + self.enter_region_review(rect) + } else { + self.submit_region_capture(rect); + true + } + } + + pub(super) fn highlighted_region_window_rect(&self) -> Option { + self.data + .window_snap + .as_ref() + .filter(|session| session.mode_active()) + .and_then(WindowSnapSession::hovered_target) + .map(WindowSnapTarget::image_rect) + } + + fn current_region_pointer(&self) -> (f64, f64) { + let (x, y) = self.current_mouse(); + (f64::from(x), f64::from(y)) + } + + fn mark_region_window_snap_dirty(&mut self) { + self.input_state.dirty_tracker.mark_full(); + self.input_state.needs_redraw = true; + } +} diff --git a/src/backend/wayland/state/region_capture/window_snap/query.rs b/src/backend/wayland/state/region_capture/window_snap/query.rs new file mode 100644 index 000000000..cc845a2c0 --- /dev/null +++ b/src/backend/wayland/state/region_capture/window_snap/query.rs @@ -0,0 +1,337 @@ +use crate::backend::wayland::runtime_operation::RuntimeOperationSubmitError; +use crate::backend::wayland::{ + RuntimeOperationController, RuntimeOperationPoll, state::WaylandState, +}; +use crate::capture::window_geometry::{ + WindowQueryContext, WindowQueryResult, detect_backend, query_window_targets, +}; +use crate::input::state::RegionPurposeTag; +use crate::util::Rect; + +use super::{ + ScreenSourceToken, WindowQueryApply, WindowSnapCorrelation, WindowSnapQuery, WindowSnapSession, + apply_window_query_completion, +}; +use crate::backend::wayland::state::region_capture::FreezeOwnership; + +fn source_has_correlated_window_layout( + purpose: RegionPurposeTag, + freeze_ownership: FreezeOwnership, +) -> bool { + purpose.is_capture() && matches!(freeze_ownership, FreezeOwnership::PickerOwned { .. }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WindowQuerySubmit { + NoneQueued, + Started, + Busy, + Failed, +} + +fn submit_queued_window_query_with( + controller: &mut RuntimeOperationController< + WindowSnapQuery, + Result, + >, + session: &mut Option, + operation: impl FnOnce( + WindowQueryContext, + ) -> Result< + WindowQueryResult, + crate::capture::window_geometry::WindowGeometryError, + > + Send + + 'static, +) -> WindowQuerySubmit { + let Some(query) = session.as_ref().and_then(WindowSnapSession::queued_query) else { + return WindowQuerySubmit::NoneQueued; + }; + let correlation = query.correlation; + let worker_context = query.context.clone(); + match controller.try_submit(query, "wayscriber-window-query", move || { + operation(worker_context) + }) { + Ok(_) => { + let marked = session + .as_mut() + .is_some_and(|session| session.mark_query_started(correlation)); + debug_assert!(marked, "submitted window query must retain its session"); + WindowQuerySubmit::Started + } + Err(failure) => { + let (error, _) = failure.into_parts(); + if matches!(error, RuntimeOperationSubmitError::Busy { .. }) { + log::debug!("Window snap query queued behind the active query: {error}"); + return WindowQuerySubmit::Busy; + } + log::debug!("Window snapping unavailable: {error}"); + clear_matching_window_session(session, correlation); + WindowQuerySubmit::Failed + } + } +} + +fn poll_window_query_with( + controller: &mut RuntimeOperationController< + WindowSnapQuery, + Result, + >, + session: &mut Option, + retry: impl FnOnce( + WindowQueryContext, + ) -> Result< + WindowQueryResult, + crate::capture::window_geometry::WindowGeometryError, + > + Send + + 'static, +) -> bool { + let (consumed_terminal, changed) = match controller.poll() { + RuntimeOperationPoll::Idle | RuntimeOperationPoll::Pending { .. } => (false, false), + RuntimeOperationPoll::Ready { + context, + outcome: Ok(WindowQueryResult { backend, targets }), + .. + } => { + log::debug!( + "Window snap provider {backend:?} returned {} target(s)", + targets.len() + ); + let applied = apply_window_query_completion(session, context.correlation, targets); + (true, applied != WindowQueryApply::Stale) + } + RuntimeOperationPoll::Ready { + context, + outcome: Err(error), + .. + } => { + log::debug!("Window snapping unavailable: {error}"); + ( + true, + clear_matching_window_session(session, context.correlation), + ) + } + RuntimeOperationPoll::ProducerFailed { + context, reason, .. + } => { + log::debug!("Window snap query worker failed: {reason}"); + ( + true, + clear_matching_window_session(session, context.correlation), + ) + } + RuntimeOperationPoll::Disconnected { context, .. } => { + log::debug!("Window snap query worker disconnected"); + ( + true, + clear_matching_window_session(session, context.correlation), + ) + } + }; + if consumed_terminal { + let _ = submit_queued_window_query_with(controller, session, retry); + } + changed +} + +impl WaylandState { + pub(in crate::backend::wayland::state) fn start_region_window_query( + &mut self, + purpose: RegionPurposeTag, + generation: u64, + source: ScreenSourceToken, + freeze_ownership: FreezeOwnership, + ) { + self.clear_region_window_snap(); + if !source_has_correlated_window_layout(purpose, freeze_ownership) + || detect_backend().is_none() + { + return; + } + let Some(provider) = self.region_window_query_context(source) else { + return; + }; + let correlation = WindowSnapCorrelation::new(generation, source); + self.data.window_snap = Some(WindowSnapSession::queued(correlation, provider)); + let _ = submit_queued_window_query_with( + &mut self.window_query, + &mut self.data.window_snap, + |provider| query_window_targets(&provider), + ); + } + + fn region_window_query_context(&self, source: ScreenSourceToken) -> Option { + let output = self.surface.current_output()?; + let info = self.output_state.info(&output)?; + if info.id != source.output_id { + return None; + } + let output_name = info.name.filter(|name| !name.is_empty())?; + let (x, y) = info.logical_position?; + let (width, height) = info.logical_size?; + let surface = (u32::try_from(width).ok()?, u32::try_from(height).ok()?); + if surface != source.surface { + return None; + } + Some(WindowQueryContext { + output_name, + output_logical_rect: Rect::new(x, y, width, height)?, + }) + } + + pub(in crate::backend::wayland) fn poll_region_window_query_completion(&mut self) { + if poll_window_query_with( + &mut self.window_query, + &mut self.data.window_snap, + |provider| query_window_targets(&provider), + ) { + self.mark_region_window_snap_dirty(); + } + } + + pub(in crate::backend::wayland::state) fn clear_region_window_snap(&mut self) { + self.data.window_snap = None; + } +} + +fn clear_matching_window_session( + session: &mut Option, + correlation: WindowSnapCorrelation, +) -> bool { + if session + .as_ref() + .is_none_or(|session| session.correlation() != correlation) + { + return false; + } + *session = None; + true +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::wayland::{ + RuntimeOperationIdSource, RuntimeWakeSource, + state::screen_image::{ScreenImageKind, ScreenSourceToken}, + }; + use crate::capture::window_geometry::{WindowGeometryBackend, WindowTarget}; + use std::sync::mpsc; + use std::time::{Duration, Instant}; + use wayland_client::protocol::wl_output; + + fn source() -> ScreenSourceToken { + ScreenSourceToken { + output_id: 7, + output_layout_generation: 11, + kind: ScreenImageKind::Frozen, + image_generation: 13, + image_size: (1_000, 800), + stride: 4_000, + surface: (1_000, 800), + output_scale: 1, + output_transform: wl_output::Transform::Normal, + zoom_transformed: false, + zoom_scale: 1.0, + zoom_view_offset: (0.0, 0.0), + } + } + + fn provider() -> WindowQueryContext { + WindowQueryContext { + output_name: "DP-1".to_string(), + output_logical_rect: Rect::new(0, 0, 1_000, 800).unwrap(), + } + } + + fn result( + id: &str, + ) -> Result { + Ok(WindowQueryResult { + backend: WindowGeometryBackend::Hyprland, + targets: vec![WindowTarget { + id: id.to_string(), + title: id.to_string(), + logical_rect: Rect::new(20, 20, 100, 80).unwrap(), + }], + }) + } + + #[test] + fn only_a_picker_owned_fresh_freeze_can_offer_window_snapping() { + assert!(source_has_correlated_window_layout( + RegionPurposeTag::CaptureDeliver, + FreezeOwnership::PickerOwned { + image_generation: 7 + } + )); + assert!(source_has_correlated_window_layout( + RegionPurposeTag::CaptureInteractive, + FreezeOwnership::PickerOwned { + image_generation: 8 + } + )); + assert!(!source_has_correlated_window_layout( + RegionPurposeTag::CaptureDeliver, + FreezeOwnership::PreExisting + )); + assert!(!source_has_correlated_window_layout( + RegionPurposeTag::Ocr, + FreezeOwnership::PickerOwned { + image_generation: 9 + } + )); + } + + #[test] + fn real_capacity_one_terminal_routes_the_reopened_picker_into_retry() { + let wake = RuntimeWakeSource::new().unwrap(); + let mut controller = + RuntimeOperationController::new(RuntimeOperationIdSource::new(), wake.handle()); + let old = WindowSnapCorrelation::new(1, source()); + let middle = WindowSnapCorrelation::new(2, source()); + let fresh = WindowSnapCorrelation::new(3, source()); + let mut session = Some(WindowSnapSession::queued(old, provider())); + let (release_tx, release_rx) = mpsc::channel(); + assert_eq!( + submit_queued_window_query_with(&mut controller, &mut session, move |_| { + release_rx.recv().unwrap(); + result("old") + }), + WindowQuerySubmit::Started + ); + + // Cancel drops only the UI session; immediate reopen queues behind the + // real controller's still-active old worker. + session = Some(WindowSnapSession::queued(middle, provider())); + assert_eq!( + submit_queued_window_query_with(&mut controller, &mut session, |_| { + panic!("a busy submission must not run") + }), + WindowQuerySubmit::Busy + ); + // A second cancel/reopen replaces only the queued UI request. The + // single old worker remains the controller's sole active operation. + session = Some(WindowSnapSession::queued(fresh, provider())); + assert_eq!( + submit_queued_window_query_with(&mut controller, &mut session, |_| { + panic!("a repeated busy submission must not run") + }), + WindowQuerySubmit::Busy + ); + release_tx.send(()).unwrap(); + + let deadline = Instant::now() + Duration::from_secs(1); + while !session.as_ref().is_some_and(WindowSnapSession::is_ready) { + poll_window_query_with(&mut controller, &mut session, |_| result("fresh")); + assert!(Instant::now() < deadline, "reopened query did not retry"); + std::thread::yield_now(); + } + let session = session.unwrap(); + assert_eq!(session.correlation(), fresh); + assert_eq!(session.targets()[0].image_rect().size(), (100, 80)); + assert_eq!( + session.targets()[0].screen_rect(), + Rect::new(20, 20, 100, 80).unwrap() + ); + } +} diff --git a/src/backend/wayland/state/region_capture/window_snap/tests.rs b/src/backend/wayland/state/region_capture/window_snap/tests.rs new file mode 100644 index 000000000..13f3937bf --- /dev/null +++ b/src/backend/wayland/state/region_capture/window_snap/tests.rs @@ -0,0 +1,214 @@ +use super::*; +use crate::backend::wayland::state::screen_image::{ScreenImageKind, ScreenSourceToken}; +use crate::capture::window_geometry::{WindowQueryContext, WindowTarget}; +use crate::util::Rect; +use wayland_client::protocol::wl_output; + +fn source() -> ScreenSourceToken { + ScreenSourceToken { + output_id: 7, + output_layout_generation: 11, + kind: ScreenImageKind::Frozen, + image_generation: 13, + image_size: (1_500, 1_200), + stride: 6_000, + surface: (1_000, 800), + output_scale: 2, + output_transform: wl_output::Transform::Normal, + zoom_transformed: false, + zoom_scale: 1.0, + zoom_view_offset: (0.0, 0.0), + } +} + +fn target(rect: Rect) -> WindowTarget { + WindowTarget { + id: String::new(), + title: String::new(), + logical_rect: rect, + } +} + +fn context() -> WindowQueryContext { + WindowQueryContext { + output_name: "DP-1".to_string(), + output_logical_rect: Rect::new(0, 0, 1_000, 800).unwrap(), + } +} + +#[test] +fn hover_chooses_the_topmost_window_in_provider_order() { + let correlation = WindowSnapCorrelation::new(3, source()); + let mut session = Some(WindowSnapSession::pending(correlation)); + assert_eq!( + apply_window_query_completion( + &mut session, + correlation, + vec![ + target(Rect::new(10, 10, 200, 200).unwrap()), + target(Rect::new(50, 50, 100, 100).unwrap()), + ], + ), + WindowQueryApply::Ready + ); + let session = session.as_mut().unwrap(); + assert!(session.toggle_mode()); + + assert!(session.update_hover((75.0, 75.0))); + assert_eq!(session.hovered_index(), Some(1)); + assert_eq!( + session.hovered_target().map(WindowSnapTarget::screen_rect), + Some(Rect::new(50, 50, 100, 100).unwrap()) + ); +} + +#[test] +fn directional_walk_uses_omasnap_along_plus_weighted_across_score() { + let correlation = WindowSnapCorrelation::new(3, source()); + let mut session = Some(WindowSnapSession::pending(correlation)); + assert_eq!( + apply_window_query_completion( + &mut session, + correlation, + vec![ + target(Rect::new(0, 0, 100, 100).unwrap()), + target(Rect::new(100, 100, 100, 100).unwrap()), + target(Rect::new(200, 0, 100, 100).unwrap()), + ], + ), + WindowQueryApply::Ready + ); + let session = session.as_mut().unwrap(); + assert!(session.toggle_mode()); + assert!(session.update_hover((50.0, 50.0))); + + assert!(session.navigate(WindowSnapDirection::Right, (999.0, 999.0))); + assert_eq!(session.hovered_index(), Some(2)); + assert_eq!( + session.hovered_target().map(WindowSnapTarget::screen_rect), + Some(Rect::new(200, 0, 100, 100).unwrap()) + ); +} + +#[test] +fn output_logical_window_maps_to_authoritative_pixels_then_zoomed_screen() { + let mut zoomed = source(); + zoomed.zoom_transformed = true; + zoomed.zoom_scale = 1.5; + zoomed.zoom_view_offset = (100.0, 50.0); + let correlation = WindowSnapCorrelation::new(8, zoomed); + let mut session = Some(WindowSnapSession::pending(correlation)); + + assert_eq!( + apply_window_query_completion( + &mut session, + correlation, + vec![target(Rect::new(200, 100, 300, 200).unwrap())], + ), + WindowQueryApply::Ready + ); + let session = session.unwrap(); + let mapped = &session.targets()[0]; + assert_eq!(mapped.image_rect().size(), (450, 300)); + assert_eq!(mapped.screen_rect(), Rect::new(150, 75, 450, 300).unwrap()); +} + +#[test] +fn stale_completion_cannot_replace_a_fresh_generation() { + let stale = WindowSnapCorrelation::new(3, source()); + let fresh = WindowSnapCorrelation::new(4, source()); + let mut session = Some(WindowSnapSession::pending(fresh)); + + assert_eq!( + apply_window_query_completion( + &mut session, + stale, + vec![target(Rect::new(0, 0, 100, 100).unwrap())], + ), + WindowQueryApply::Stale + ); + assert_eq!(session.unwrap().correlation(), fresh); +} + +#[test] +fn empty_completion_removes_window_snap_availability_quietly() { + let correlation = WindowSnapCorrelation::new(3, source()); + let mut session = Some(WindowSnapSession::pending(correlation)); + + assert_eq!( + apply_window_query_completion(&mut session, correlation, Vec::new()), + WindowQueryApply::Unavailable + ); + assert_eq!(session, None); +} + +#[test] +fn cancel_then_immediate_reopen_retries_after_the_old_terminal() { + let old = WindowSnapCorrelation::new(3, source()); + // Cancelling discarded the old session while its capacity-one broker job + // was still running. Reopening installs the new request as queued. + let fresh = WindowSnapCorrelation::new(4, source()); + let mut session = Some(WindowSnapSession::queued(fresh, context())); + + assert_eq!( + apply_window_query_completion( + &mut session, + old, + vec![target(Rect::new(0, 0, 100, 100).unwrap())], + ), + WindowQueryApply::Stale + ); + let retry = session + .as_ref() + .and_then(WindowSnapSession::queued_query) + .expect("the reopened picker remains queued for retry"); + assert_eq!(retry.correlation, fresh); + assert!( + session + .as_mut() + .unwrap() + .mark_query_started(retry.correlation) + ); + assert_eq!( + apply_window_query_completion( + &mut session, + fresh, + vec![target(Rect::new(20, 20, 100, 100).unwrap())], + ), + WindowQueryApply::Ready + ); + let session = session.unwrap(); + let mapped = &session.targets()[0]; + assert_eq!(mapped.image_rect().size(), (150, 150)); + assert_eq!(mapped.screen_rect(), Rect::new(20, 20, 100, 100).unwrap()); +} + +#[test] +fn repeated_reopen_and_cancel_only_retries_the_latest_live_picker() { + let old = WindowSnapCorrelation::new(1, source()); + let mut session = None; + assert!(session.is_none()); + for generation in 2..=5 { + let correlation = WindowSnapCorrelation::new(generation, source()); + session = Some(WindowSnapSession::queued(correlation, context())); + assert_eq!(session.as_ref().unwrap().correlation(), correlation); + session = None; + assert!(session.is_none()); + } + let latest = WindowSnapCorrelation::new(6, source()); + session = Some(WindowSnapSession::queued(latest, context())); + + assert_eq!( + apply_window_query_completion( + &mut session, + old, + vec![target(Rect::new(0, 0, 100, 100).unwrap())], + ), + WindowQueryApply::Stale + ); + let retry = session + .as_ref() + .and_then(WindowSnapSession::queued_query) + .expect("only the latest live picker is retried"); + assert_eq!(retry.correlation, latest); +} diff --git a/src/backend/wayland/state/render/canvas/background.rs b/src/backend/wayland/state/render/canvas/background.rs index d98128d03..a0314eb46 100644 --- a/src/backend/wayland/state/render/canvas/background.rs +++ b/src/backend/wayland/state/render/canvas/background.rs @@ -20,6 +20,8 @@ impl CanvasEraserContext { bg_color: self.bg_color, logical_to_image_scale_x: self.logical_to_image_scale_x, logical_to_image_scale_y: self.logical_to_image_scale_y, + logical_image_origin_x: 0.0, + logical_image_origin_y: 0.0, } } } diff --git a/src/backend/wayland/state/render/canvas/mod.rs b/src/backend/wayland/state/render/canvas/mod.rs index c59a800bd..309dee1ce 100644 --- a/src/backend/wayland/state/render/canvas/mod.rs +++ b/src/backend/wayland/state/render/canvas/mod.rs @@ -16,8 +16,15 @@ impl WaylandState { phys_height: u32, now: Instant, damage_world: &[crate::util::Rect], + render_transients: bool, mut perf: Option<&mut PerfRenderBreakdown>, ) -> Result<()> { + let capture_picker_active = self.capture_picker_chrome_suppressed(); + let capture_picker_draws_committed = capture_picker_draws_committed( + capture_picker_active, + self.region_picker_include_drawings(), + ); + let render_transients = render_transients && !capture_picker_active; let canvas_transform_active = self.canvas_transform_active(); let (canvas_origin_x, canvas_origin_y) = self.canvas_view_origin(); let shapes_total = self.input_state.boards.active_frame().shapes.len(); @@ -26,7 +33,7 @@ impl WaylandState { // shapes from the baked layer cache: pan frames force full damage, so // this turns an O(shapes) Cairo replay into a single aligned blit. let layer_cache_start = perf.as_ref().map(|_| Instant::now()); - let layer_cache_ready = if self.canvas_layer_cache_usable() { + let layer_cache_ready = if !capture_picker_active && self.canvas_layer_cache_usable() { self.ensure_canvas_layer_cache(width, height, scale) } else { self.canvas_layer_cache.clear(); @@ -48,6 +55,24 @@ impl WaylandState { .saturating_add(Instant::now().saturating_duration_since(background_start)); } + // A capture picker always selects against the frozen desktop. When its + // captured intent includes drawings, replay committed annotations over + // that backdrop so the preview matches the exported PNG. The layer + // cache stays disabled because its baked board background is not the + // frozen capture. Turning the Review toggle off keeps only the raw + // backdrop. Transient handles, provisional strokes, text previews, + // hover effects, and click highlights remain suppressed in both cases. + if !capture_picker_draws_committed { + self.spotlight_dimmed_last_frame = false; + if let Some(perf) = perf.as_mut() { + perf.shapes_total = shapes_total; + perf.shapes_tested = 0; + perf.shapes_rendered = 0; + perf.canvas_layer_cache_used = false; + } + return Ok(()); + } + // Scale subsequent drawing to logical coordinates let _ = ctx.save(); if scale > 1 { @@ -200,11 +225,13 @@ impl WaylandState { // strokes clear their path and replay the original backdrop into it, so a // dim layer painted earlier would be punched away and every past erasure // would show as a bright trail outside the openings. - let spotlight_cursor = { + let spotlight_regions = if render_transients { let (screen_x, screen_y) = self.current_mouse(); - self.canvas_world_coords(screen_x as f64, screen_y as f64) + let spotlight_cursor = self.canvas_world_coords(screen_x as f64, screen_y as f64); + self.input_state.spotlight_regions(spotlight_cursor) + } else { + crate::draw::spotlight_regions_for_frame(self.input_state.boards.active_frame()) }; - let spotlight_regions = self.input_state.spotlight_regions(spotlight_cursor); // Remember for the next frame's damage decision: once the last spotlight // is gone this buffer still carries its dim until a full repaint. self.spotlight_dimmed_last_frame = !spotlight_regions.is_empty(); @@ -217,6 +244,14 @@ impl WaylandState { }, ); + if !render_transients { + if canvas_transform_active { + let _ = ctx.restore(); + } + let _ = ctx.restore(); + return Ok(()); + } + self.render_selection_overlays(ctx); let (mx, my) = @@ -280,3 +315,23 @@ fn provisional_point_count(stroke: &crate::input::tool::ProvisionalToolStroke<'_ | crate::input::tool::ProvisionalToolStroke::None => 0, } } + +const fn capture_picker_draws_committed( + capture_picker_active: bool, + include_drawings: bool, +) -> bool { + !capture_picker_active || include_drawings +} + +#[cfg(test)] +mod tests { + use super::capture_picker_draws_committed; + + #[test] + fn picker_preview_follows_the_annotated_export_choice() { + assert!(capture_picker_draws_committed(false, false)); + assert!(capture_picker_draws_committed(false, true)); + assert!(capture_picker_draws_committed(true, true)); + assert!(!capture_picker_draws_committed(true, false)); + } +} diff --git a/src/backend/wayland/state/render/measure_badge.rs b/src/backend/wayland/state/render/measure_badge.rs new file mode 100644 index 000000000..9eace9e61 --- /dev/null +++ b/src/backend/wayland/state/render/measure_badge.rs @@ -0,0 +1,55 @@ +use super::super::*; +use crate::ui::theme; +use crate::ui_text::text_layout; + +impl WaylandState { + pub(super) fn shape_measure_badge_visual( + &self, + width: u32, + height: u32, + ) -> Option { + let pointer = self.current_mouse(); + let world = self.canvas_world_coords(pointer.0 as f64, pointer.1 as f64); + let size = self.input_state.provisional_shape_size(world.0, world.1)?; + crate::ui::measure_shape_badge( + self.config.ui.show_shape_size_readout, + size, + (pointer.0 as f64, pointer.1 as f64), + width, + height, + ) + } + + pub(super) fn render_shape_measure_badge(&self, ctx: &cairo::Context, width: u32, height: u32) { + let Some(badge) = self.shape_measure_badge_visual(width, height) else { + return; + }; + let (x, y, badge_width, badge_height) = badge.bounds; + let _ = ctx.save(); + + crate::ui::draw_pill( + ctx, + x, + y, + badge_width, + badge_height, + 6.0, + (12.0 / 255.0, 12.0 / 255.0, 15.0 / 255.0, 0.92), + (1.0, 1.0, 1.0, 0.16), + None, + ); + + theme::set_color(ctx, (1.0, 1.0, 1.0, 1.0)); + ctx.rectangle(x, y, badge_width, badge_height); + ctx.clip(); + text_layout( + ctx, + crate::ui::shape_measure_badge_text_style(), + &badge.text, + None, + ) + .show_at_baseline(ctx, badge.baseline.0, badge.baseline.1); + + let _ = ctx.restore(); + } +} diff --git a/src/backend/wayland/state/render/mod.rs b/src/backend/wayland/state/render/mod.rs index a93d428b5..655796d75 100644 --- a/src/backend/wayland/state/render/mod.rs +++ b/src/backend/wayland/state/render/mod.rs @@ -1,6 +1,7 @@ use super::*; mod canvas; +mod measure_badge; mod tool_preview; mod ui; mod ui_effect_damage; @@ -30,6 +31,7 @@ impl WaylandState { .overlay_suppression .effective_for_board(board_is_transparent); let render_canvas = suppression.renders_canvas(); + let render_canvas_transients = suppression.renders_canvas_transients(); let render_ui = suppression.renders_ui(); // Create pool if needed @@ -146,6 +148,7 @@ impl WaylandState { let command_palette_active = render_ui && self.input_state.command_palette_is_engaged(); let color_picker_active = render_ui && self.input_state.is_color_picker_popup_open(); let input_hud_active = render_ui && self.input_state.input_hud_visible(); + let shape_measure_badge_active = render_ui && !self.capture_picker_chrome_suppressed(); let ui_effect_damage = self.collect_ui_effect_damage( ui_toast_active, preset_feedback_active, @@ -157,6 +160,7 @@ impl WaylandState { command_palette_active, color_picker_active, tool_preview_active, + shape_measure_badge_active, width, height, ); @@ -292,6 +296,7 @@ impl WaylandState { phys_height, now, &damage_world, + render_canvas_transients, render_breakdown.as_mut(), )?; } @@ -370,6 +375,18 @@ impl WaylandState { }); let draw_duration = draw_start.elapsed(); + if self.input_state.region_is_active() + && self + .input_state + .region_state() + .purpose() + .is_some_and(|purpose| purpose.is_capture()) + { + debug!( + "Region picker frame: logical={}x{}, physical={}x{}, scale={}, cairo_draw={:?}", + width, height, phys_width, phys_height, scale, draw_duration + ); + } if draw_duration > std::time::Duration::from_millis(2) { debug!("Cairo draw took {:?}", draw_duration); } diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index acfd2628d..166803175 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -1,5 +1,9 @@ use super::tool_preview::{draw_stylus_hover_cursor, draw_tool_preview, mouse_tool_preview_redraw}; use super::*; +use crate::backend::wayland::state::region_capture::RegionPickerMeasurement; +use crate::backend::wayland::state::screen_image::{ + displayed_screen_image, image_point_for_screen_point, screen_source_is, +}; impl WaylandState { pub(super) fn render_ui_layer( @@ -20,7 +24,12 @@ impl WaylandState { fn render_ui_layers(&mut self, ctx: &cairo::Context, width: u32, height: u32, render_ui: bool) { if render_ui { - if self.mouse_tool_preview_eligible() { + let capture_picker = self.capture_picker_chrome_suppressed(); + if capture_picker { + self.render_capture_picker(ctx, width, height); + } + + if !capture_picker && self.mouse_tool_preview_eligible() { let (cursor_x, cursor_y) = self.stylus_hover_cursor_position().unwrap_or_else(|| { let (x, y) = self.current_mouse(); @@ -38,7 +47,11 @@ impl WaylandState { height as f64, ); } + if !capture_picker { + self.render_shape_measure_badge(ctx, width, height); + } if let Some((cursor_x, cursor_y)) = self.stylus_hover_cursor_position() + && !capture_picker && !self.cursor_blocked_by_toolbar() { draw_stylus_hover_cursor( @@ -55,7 +68,8 @@ impl WaylandState { // top-corner badges below cover the hidden-status-bar case only. // Focus Mode suppresses the whole fallback family; otherwise its // intentional status/chip hide would make these badges reappear. - let fallback_mode_badges_visible = self.input_state.fallback_mode_badges_visible(); + let fallback_mode_badges_visible = + !capture_picker && self.input_state.fallback_mode_badges_visible(); let status_hud_visible = self.input_state.status_hud_effectively_visible(); if self.input_state.frozen_active() && !self.zoom.active @@ -111,7 +125,7 @@ impl WaylandState { { crate::ui::render_editing_badge(ctx, width, height, top_badge_offset); } - if self.input_state.floating_badge_visible() { + if !capture_picker && self.input_state.floating_badge_visible() { let board_count = self.input_state.boards.board_count(); let page_count = self.input_state.boards.page_count(); let board_index = self.input_state.boards.active_index(); @@ -144,7 +158,7 @@ impl WaylandState { // Render the interactive bottom-right zoom chip (layout cached for // this frame by collect_ui_effect_damage). Reuses the status-bar // style tokens so it reads as the same chrome family. - if self.zoom_chip_visible() { + if !capture_picker && self.zoom_chip_visible() { crate::ui::render_zoom_chip( ctx, &self.input_state, @@ -158,7 +172,7 @@ impl WaylandState { // the status pills it uses the status-bar style tokens, and it // lives behind the same `render_ui` gate so overlay suppression // hides it with the rest of the chrome. - if self.input_state.input_hud_visible() { + if !capture_picker && self.input_state.input_hud_visible() { crate::ui::render_input_hud( ctx, &self.input_state, @@ -169,7 +183,7 @@ impl WaylandState { } // Render help overlay if toggled - if self.input_state.show_help { + if !capture_picker && self.input_state.show_help { let bindings = crate::ui::HelpOverlayBindings::from_input_state(&self.input_state); let scroll_max = crate::ui::render_help_overlay( ctx, @@ -191,7 +205,7 @@ impl WaylandState { self.input_state.help_overlay_scroll.clamp(0.0, scroll_max); } - if self.input_state.is_board_picker_open() { + if !capture_picker && self.input_state.is_board_picker_open() { self.input_state .update_board_picker_layout(ctx, width, height); crate::ui::render_board_picker(ctx, &self.input_state, width, height); @@ -199,7 +213,7 @@ impl WaylandState { self.input_state.clear_board_picker_layout(); } - if self.input_state.is_color_picker_popup_open() { + if !capture_picker && self.input_state.is_color_picker_popup_open() { self.input_state .update_color_picker_popup_layout(width, height); crate::ui::render_color_picker_popup(ctx, &self.input_state, width, height); @@ -207,7 +221,7 @@ impl WaylandState { self.input_state.clear_color_picker_popup_layout(); } - if self.input_state.is_precision_entry_open() { + if !capture_picker && self.input_state.is_precision_entry_open() { // Anchor under the top strip (the pill is its bottom row): // the same base position both the inline fallback and the // layer-shell margins derive from. @@ -226,10 +240,12 @@ impl WaylandState { ); } - self.render_eyedropper_loupe(ctx, width, height); + if !capture_picker { + self.render_eyedropper_loupe(ctx, width, height); + } self.render_ocr_selection(ctx, width, height); - if self.input_state.is_radial_menu_open() { + if !capture_picker && self.input_state.is_radial_menu_open() { // Layout (and with it hit-testing) is live from the moment // of opening so pre-paint flicks resolve correctly; painting // waits out the flick window (RADIAL_PAINT_DELAY). @@ -252,7 +268,7 @@ impl WaylandState { crate::ui::render_preset_toast(ctx, &self.input_state, width, height); crate::ui::render_blocked_feedback(ctx, &self.input_state, width, height); - if !self.zoom.active && !self.input_state.is_board_picker_open() { + if !capture_picker && !self.zoom.active && !self.input_state.is_board_picker_open() { if self.input_state.is_properties_panel_open() { self.input_state .update_properties_panel_layout(ctx, width, height); @@ -276,7 +292,8 @@ impl WaylandState { } // Inline toolbars (xdg fallback or drag preview) render directly into main surface. - if self.toolbar.is_visible() && self.inline_toolbars_render_active() { + if !capture_picker && self.toolbar.is_visible() && self.inline_toolbars_render_active() + { let snapshot = self.toolbar_snapshot(); if self.toolbar.update_snapshot(&snapshot) { self.toolbar.mark_dirty(); @@ -284,17 +301,166 @@ impl WaylandState { self.render_inline_toolbars(ctx, &snapshot); } + if self.input_state.region_state().purpose() + == Some(crate::input::state::RegionPurposeTag::Measure) + { + // Measure mode is screen chrome over the live annotated + // canvas, not capture suppression. Paint it once, above the + // ordinary Wayscriber UI. + self.render_capture_picker(ctx, width, height); + } + // Modal overlays render last (on top of everything including toolbars) - if let Some(card) = self.first_run_onboarding_card() { - crate::ui::render_onboarding_card(ctx, width, height, &card); + if !capture_picker { + if let Some(card) = self.first_run_onboarding_card() { + crate::ui::render_onboarding_card(ctx, width, height, &card); + } + crate::ui::render_command_palette(ctx, &self.input_state, width, height); + crate::ui::render_tour(ctx, &self.input_state, width, height); } - crate::ui::render_command_palette(ctx, &self.input_state, width, height); - crate::ui::render_tour(ctx, &self.input_state, width, height); } else { self.input_state.clear_context_menu_layout(); } } + fn render_capture_picker(&self, ctx: &cairo::Context, width: u32, height: u32) { + let purpose = self.input_state.region_state().purpose(); + if !self.input_state.region_is_active() + || !purpose.is_some_and(|purpose| { + purpose.is_capture() || purpose == crate::input::state::RegionPurposeTag::Measure + }) + { + return; + } + + let measure_mode = purpose == Some(crate::input::state::RegionPurposeTag::Measure); + + let options = match self.capture.region_phase() { + crate::backend::wayland::capture::RegionCapturePhase::Reserved(intent) => { + Some(intent.options()) + } + crate::backend::wayland::capture::RegionCapturePhase::Idle + | crate::backend::wayland::capture::RegionCapturePhase::Submitting(_) + | crate::backend::wayland::capture::RegionCapturePhase::Accepted => None, + }; + let geometry = self.region_selection_geometry(); + let selection = if measure_mode { + self.region_measure_selection() + } else { + geometry.map(|geometry| geometry.display_selection()) + }; + let (pointer_x, pointer_y) = self.current_mouse(); + let pointer = (f64::from(pointer_x), f64::from(pointer_y)); + let measurement = (measure_mode + || options.is_some_and(|options| options.show_size_readout())) + .then(|| self.region_picker_measurement(pointer)) + .flatten() + .map(|measurement| match measurement { + RegionPickerMeasurement::Point { x, y } => format!("{x}, {y}"), + RegionPickerMeasurement::Size { width, height } => { + crate::ui::capture_size_text((width, height)) + } + }); + let region_state = self.input_state.region_state(); + let action_bar = if region_state.is_review() { + geometry.map(|geometry| { + crate::ui::RegionActionBar::place(geometry.display_selection(), (width, height)) + }) + } else { + None + }; + let hovered_action: Option = + action_bar.and_then(|bar| bar.hit(pointer)); + // Grips belong to the reviewed rectangle, not the window-mode + // candidate, so they follow `region_selection_geometry` rather than + // the effective selection the scrim cuts out. + let resize_handles = region_state.is_review().then(|| { + geometry + .map(|geometry| crate::ui::RegionResizeHandles::place(geometry.display_selection())) + }); + let resize_handles = resize_handles.flatten(); + // A grip sitting under the action bar is painted over by it, so the + // bar's hover wins the pointer. + let hovered_handle = (hovered_action.is_none() + && !action_bar.is_some_and(|bar| bar.contains(pointer))) + .then(|| resize_handles.and_then(|handles| handles.hit(pointer))) + .flatten(); + let loupe_enabled = options.is_some_and(|options| options.show_loupe()) + && (region_state.is_selecting() || region_state.is_review()); + let loupe_source = if loupe_enabled { + self.region_picker_source_token().and_then(|token| { + let source = displayed_screen_image( + &self.zoom, + &self.frozen, + self.input_state.board_is_transparent(), + )?; + screen_source_is(&token, &source, &self.zoom, &self.frozen, (width, height)) + .then_some((source, token)) + }) + } else { + None + }; + let loupe = loupe_source.as_ref().and_then(|(_source, token)| { + let image_point = image_point_for_screen_point(token, pointer); + crate::ui::RegionCaptureLoupeVisual::when_enabled( + loupe_enabled, + pointer, + (image_point.x, image_point.y), + ) + }); + let window_targets: Vec<_> = self + .region_window_snap_targets() + .iter() + .map(|target| { + let rect = target.screen_rect(); + crate::input::state::RegionSelection { + start: (f64::from(rect.x), f64::from(rect.y)), + end: ( + f64::from(rect.x + rect.width), + f64::from(rect.y + rect.height), + ), + } + }) + .collect(); + let window = crate::ui::RegionCaptureWindowVisual { + available: self.region_window_snap_available(), + active: self.region_window_snap_active(), + targets: &window_targets, + highlighted_target: self.region_window_snap_highlighted_index(), + }; + + crate::ui::render_region_capture_picker( + ctx, + width, + height, + crate::ui::RegionCapturePickerVisual { + selection, + pointer, + measurement: measurement.as_deref(), + show_scrim: !measure_mode, + review: region_state.is_review(), + resize_handles, + hovered_handle, + show_legend: options.is_some_and(|options| options.show_legend()) + && !self.region_picker_legend_dismissed(), + loupe, + action_bar, + hovered_action, + include_drawings: self.region_picker_include_drawings(), + window, + }, + |image_x, image_y| { + loupe_source.as_ref().and_then(|(source, _token)| { + crate::backend::wayland::state::eyedropper::sample_at( + source.image, + image_x, + image_y, + ) + }) + }, + ); + } + /// The render-time gate for the mouse-anchored tool-preview bubble: it is /// drawn only for eligible idle states, with cursor focus and no toolbar /// blocking. Shared by the render pass (above) and the pointer handler so diff --git a/src/backend/wayland/state/render/ui_effect_damage.rs b/src/backend/wayland/state/render/ui_effect_damage.rs index e0be51f7e..f703e0cbe 100644 --- a/src/backend/wayland/state/render/ui_effect_damage.rs +++ b/src/backend/wayland/state/render/ui_effect_damage.rs @@ -97,6 +97,7 @@ impl WaylandState { command_palette_active: bool, color_picker_active: bool, tool_preview_active: bool, + shape_measure_badge_active: bool, width: u32, height: u32, ) -> Vec { @@ -259,6 +260,33 @@ impl WaylandState { regions.extend(preview_update.rects); self.data.prev_tool_preview_damage = preview_update.current; + let measure_badge_rect = shape_measure_badge_active + .then(|| self.shape_measure_badge_visual(width, height)) + .flatten() + .and_then(|badge| effect_rect(badge.bounds, width, height)); + push_effect_damage( + &mut regions, + self.data.prev_shape_measure_badge_damage, + measure_badge_rect, + ); + self.data.prev_shape_measure_badge_damage = measure_badge_rect; + + let measure_picker_damage = if self.input_state.region_state().purpose() + == Some(crate::input::state::RegionPurposeTag::Measure) + { + let (pointer_x, pointer_y) = self.current_mouse(); + crate::ui::measure_picker_damage( + self.input_state.region_state().selection(), + (f64::from(pointer_x), f64::from(pointer_y)), + (width, height), + ) + } else { + Vec::new() + }; + regions.extend(self.data.prev_measure_picker_damage.iter().copied()); + regions.extend(measure_picker_damage.iter().copied()); + self.data.prev_measure_picker_damage = measure_picker_damage; + regions } @@ -308,6 +336,29 @@ mod tests { assert_eq!(clamped.y, 0); } + #[test] + fn shape_badge_damage_unions_appear_move_and_disappear_footprints() { + let first = crate::ui::measure_shape_badge(true, (20, 30), (100.0, 100.0), 800, 600) + .and_then(|badge| effect_rect(badge.bounds, 800, 600)); + let second = crate::ui::measure_shape_badge(true, (200, 300), (300.0, 250.0), 800, 600) + .and_then(|badge| effect_rect(badge.bounds, 800, 600)); + let mut damage = Vec::new(); + + push_effect_damage(&mut damage, None, first); + assert_eq!(damage, vec![first.expect("appeared")]); + + damage.clear(); + push_effect_damage(&mut damage, first, second); + assert_eq!( + damage, + vec![first.expect("previous"), second.expect("moved")] + ); + + damage.clear(); + push_effect_damage(&mut damage, second, None); + assert_eq!(damage, vec![second.expect("disappeared")]); + } + #[test] fn push_effect_damage_dedupes_identical_bounds() { let rect = Rect::new(5, 5, 10, 10).unwrap(); diff --git a/src/backend/wayland/state/screen_image.rs b/src/backend/wayland/state/screen_image.rs index ca9bf229b..4a3ee5a12 100644 --- a/src/backend/wayland/state/screen_image.rs +++ b/src/backend/wayland/state/screen_image.rs @@ -5,6 +5,8 @@ //! screenshot, so source resolution, the logical→image mapping, and the checked //! rectangle copy live here instead of inside either feature. +use std::sync::Arc; + use crate::backend::wayland::frozen::{FrozenImage, FrozenState, ScreenImageProvenance}; use crate::backend::wayland::zoom::ZoomState; use crate::screen_pixels::{ImagePixelRect, ImagePoint, PixelSpan}; @@ -134,6 +136,20 @@ pub(super) fn displayed_screen_image<'a>( }) } +/// Share the exact displayed image without copying its compositor-sized pixel +/// buffer. Callers validate the source token before retaining this handle for +/// asynchronous work. +pub(super) fn shared_displayed_screen_image( + zoom: &ZoomState, + frozen: &FrozenState, + kind: ScreenImageKind, +) -> Option> { + match kind { + ScreenImageKind::Zoom => zoom.shared_image(), + ScreenImageKind::Frozen => frozen.shared_image(), + } +} + /// Map a logical screen point onto source-image pixel coordinates. The result /// is unclamped on purpose: samplers clamp to the nearest pixel, while region /// selection clips against the image bounds. @@ -209,7 +225,6 @@ pub(super) fn image_point_for_screen_point( ) } -#[allow(dead_code)] // Phase 0b derives the region preview from image-space state. pub(super) fn screen_point_for_image_point( token: &ScreenSourceToken, point: ImagePoint, @@ -228,7 +243,6 @@ pub(super) fn screen_point_for_image_point( } } -#[allow(dead_code)] // Phase 0b derives the region preview from image-space state. pub(super) fn screen_rect_for_image_rect(token: &ScreenSourceToken, rect: ImagePixelRect) -> Rect { let first = screen_point_for_image_point( token, @@ -249,7 +263,7 @@ pub(super) fn screen_rect_for_image_rect(token: &ScreenSourceToken, rect: ImageP .expect("a non-empty image rectangle must map to a non-empty screen rectangle") } -#[allow(dead_code)] // Phase 0b derives the selecting preview from a possibly empty span. +#[allow(dead_code)] // The empty-span contract is exercised by mapping tests. pub(super) fn screen_rect_for_pixel_span( token: &ScreenSourceToken, span: PixelSpan, @@ -436,6 +450,19 @@ mod tests { assert!(displayed_screen_image(&zoom, &frozen, false).is_none()); } + #[test] + fn asynchronous_region_work_shares_the_exact_displayed_pixel_buffer() { + let zoom = zoom_state(); + let mut frozen = FrozenState::new(None); + install_frozen(&mut frozen, opaque(3840, 2)); + let source = displayed_screen_image(&zoom, &frozen, true).unwrap(); + + let shared = shared_displayed_screen_image(&zoom, &frozen, source.kind).unwrap(); + + assert!(std::ptr::eq(shared.as_ref(), source.image)); + assert_eq!(shared.data.as_ptr(), source.image.data.as_ptr()); + } + #[test] fn unzoomed_points_scale_by_the_image_to_surface_ratio() { let mut frozen = FrozenState::new(None); diff --git a/src/backend/wayland/state/toolbar/events.rs b/src/backend/wayland/state/toolbar/events.rs index ae14ab49c..f46360d91 100644 --- a/src/backend/wayland/state/toolbar/events.rs +++ b/src/backend/wayland/state/toolbar/events.rs @@ -134,7 +134,7 @@ impl WaylandState { // A toolbar interaction replaces the modal sampler. Do this before // shortcut capture so the capture modal owns subsequent keys. self.cancel_eyedropper(); - self.cancel_ocr_for_toolbar_interaction(); + self.cancel_region_for_toolbar_interaction(); if rebind_requested && let Some(action) = crate::ui::toolbar::model::action_for_event(&event) { diff --git a/src/backend/wayland/state/toolbar/visibility/access.rs b/src/backend/wayland/state/toolbar/visibility/access.rs index 1ac87185b..13ee34c63 100644 --- a/src/backend/wayland/state/toolbar/visibility/access.rs +++ b/src/backend/wayland/state/toolbar/visibility/access.rs @@ -78,14 +78,25 @@ impl WaylandState { Some(self.local_to_screen_coords(MoveDragKind::Top, position)) } - pub(in crate::backend::wayland) fn set_suppress_next_release(&mut self, value: bool) { - self.data.suppress_next_release = value; + pub(in crate::backend::wayland) fn suppress_next_release_from( + &mut self, + source: crate::input::state::RegionInputSource, + ) { + self.data.release_suppression.arm(source); } - pub(in crate::backend::wayland) fn take_suppress_next_release(&mut self) -> bool { - let value = self.data.suppress_next_release; - self.data.suppress_next_release = false; - value + pub(in crate::backend::wayland) fn clear_suppressed_release_from( + &mut self, + source: crate::input::state::RegionInputSource, + ) { + self.data.release_suppression.clear(source); + } + + pub(in crate::backend::wayland) fn take_suppressed_release_from( + &mut self, + source: crate::input::state::RegionInputSource, + ) -> bool { + self.data.release_suppression.take(source) } pub(in crate::backend::wayland) fn set_pending_toast_press( diff --git a/src/backend/wayland/state/toolbar/visibility/sync.rs b/src/backend/wayland/state/toolbar/visibility/sync.rs index 59f3070d4..c5e665a54 100644 --- a/src/backend/wayland/state/toolbar/visibility/sync.rs +++ b/src/backend/wayland/state/toolbar/visibility/sync.rs @@ -5,7 +5,11 @@ fn toolbar_visibility_for_frontend( requested: bool, gtk_active: bool, gtk_drag_preview: Option, + capture_picker_suppressed: bool, ) -> bool { + if capture_picker_suppressed { + return false; + } if !gtk_active { return requested; } @@ -23,8 +27,9 @@ impl WaylandState { // from Exclusive to OnDemand while they are mapped, or compositors // that honor exclusivity (Hyprland) lock all input to the canvas // and the bars become click-through. - let toolbar_visible = self.toolbar.is_visible() - || (self.gtk_toolbars_active() && self.input_state.toolbar_top_visible()); + let toolbar_visible = !self.capture_picker_chrome_suppressed() + && (self.toolbar.is_visible() + || (self.gtk_toolbars_active() && self.input_state.toolbar_top_visible())); desired_keyboard_interactivity_for( self.layer_shell.is_some(), toolbar_visible, @@ -81,6 +86,7 @@ impl WaylandState { self.input_state.toolbar_top_visible(), gtk_active, self.data.gtk_drag_preview, + self.capture_picker_chrome_suppressed(), ); let inline_active = self.inline_toolbars_active(); let drag_preview = @@ -247,16 +253,29 @@ mod tests { #[test] fn gtk_frontend_maps_only_the_inline_preview_target() { - assert!(!toolbar_visibility_for_frontend(true, true, None)); + assert!(!toolbar_visibility_for_frontend(true, true, None, false)); assert!(toolbar_visibility_for_frontend( true, true, - Some(GtkToolbarKind::Top) + Some(GtkToolbarKind::Top), + false, )); } #[test] fn builtin_frontend_ignores_gtk_preview_state() { - assert!(toolbar_visibility_for_frontend(true, false, None)); + assert!(toolbar_visibility_for_frontend(true, false, None, false)); + } + + #[test] + fn capture_picker_suppresses_every_toolbar_frontend_without_changing_request() { + assert!(!toolbar_visibility_for_frontend(true, false, None, true)); + assert!(!toolbar_visibility_for_frontend( + true, + true, + Some(GtkToolbarKind::Top), + true, + )); + assert!(toolbar_visibility_for_frontend(true, false, None, false)); } } diff --git a/src/backend/wayland/zoom/state.rs b/src/backend/wayland/zoom/state.rs index 9e74d58af..2416c43a3 100644 --- a/src/backend/wayland/zoom/state.rs +++ b/src/backend/wayland/zoom/state.rs @@ -1,3 +1,4 @@ +use std::sync::Arc; use wayland_client::protocol::wl_output; use wayland_protocols_wlr::screencopy::v1::client::zwlr_screencopy_manager_v1::ZwlrScreencopyManagerV1; @@ -52,7 +53,6 @@ impl ZoomSourceTerminal { pub(in crate::backend::wayland) enum ZoomWaiterOwner { Eyedropper, Ocr, - #[allow(dead_code)] // Phase 1 connects the native region-capture owner. RegionCapture, } @@ -109,7 +109,7 @@ pub struct ZoomState { /// portal captures can be rejected after a layout change. pub(super) output_layout_generation: u64, pub(super) capture: Option, - pub(super) image: Option, + pub(super) image: Option>, image_provenance: Option, pub(super) image_target_dimensions: Option<(u32, u32)>, image_generation: u64, @@ -214,7 +214,11 @@ impl ZoomState { } pub fn image(&self) -> Option<&FrozenImage> { - self.image.as_ref() + self.image.as_deref() + } + + pub(in crate::backend::wayland) fn shared_image(&self) -> Option> { + self.image.clone() } pub fn image_generation(&self) -> u64 { @@ -231,7 +235,7 @@ impl ZoomState { .as_ref() .map(OutputGeometry::buffer_size) .or(Some((image.width, image.height))); - self.image = Some(image); + self.image = Some(Arc::new(image)); self.image_provenance = Some(provenance); self.bump_image_generation(); } @@ -243,7 +247,7 @@ impl ZoomState { .as_ref() .map(OutputGeometry::buffer_size) .or(Some((image.width, image.height))); - self.image = Some(image); + self.image = Some(Arc::new(image)); self.image_provenance = None; self.bump_image_generation(); } @@ -259,7 +263,7 @@ impl ZoomState { .as_ref() .map(OutputGeometry::buffer_size) .or(Some((image.width, image.height))); - self.image = Some(image); + self.image = Some(Arc::new(image)); self.image_provenance = Some(provenance); self.bump_image_generation(); } diff --git a/src/canvas_export/mod.rs b/src/canvas_export/mod.rs index 7291c4375..4afecb417 100644 --- a/src/canvas_export/mod.rs +++ b/src/canvas_export/mod.rs @@ -2,6 +2,7 @@ mod page; mod pdf; mod pdf_labels; mod png; +mod region; pub use page::{ CanvasExportBackdropSnapshot, CanvasExportRect, CanvasPageExportSnapshot, SpotlightPassSnapshot, @@ -12,6 +13,7 @@ pub use pdf::{ render_board_pdf, resolve_pdf_page_layout, }; pub use png::{BoardExportSnapshot, CanvasExportSnapshot, CanvasExportViewport, render_canvas_png}; +pub(crate) use region::{CanvasRegionExportSnapshot, CanvasRegionSource, render_canvas_region_png}; #[cfg(test)] mod tests { diff --git a/src/canvas_export/page.rs b/src/canvas_export/page.rs index fd60166c5..4814f17f1 100644 --- a/src/canvas_export/page.rs +++ b/src/canvas_export/page.rs @@ -5,6 +5,7 @@ use crate::draw::{ BlurRectParams, Color, EraserReplayContext, Frame, Shape, SpotlightPass, render_blur_rect, render_eraser_stroke, render_shape, render_spotlight_pass, spotlight_regions_for_frame, }; +use crate::screen_pixels::ScreenImage; #[derive(Debug, Clone)] pub struct CanvasPageExportSnapshot { @@ -153,6 +154,11 @@ pub(crate) struct ExportBackdrop { bg_color: Option, logical_to_image_scale_x: f64, logical_to_image_scale_y: f64, + logical_image_origin_x: f64, + logical_image_origin_y: f64, + // Keeps zero-copy region pixels alive until Cairo's borrowed surface and + // pattern have both been dropped. + _region_source: Option>, } impl ExportBackdrop { @@ -164,6 +170,9 @@ impl ExportBackdrop { bg_color: None, logical_to_image_scale_x: 1.0, logical_to_image_scale_y: 1.0, + logical_image_origin_x: 0.0, + logical_image_origin_y: 0.0, + _region_source: None, }), CanvasExportBackdropSnapshot::Solid(color) => Ok(Self { surface: None, @@ -171,6 +180,9 @@ impl ExportBackdrop { bg_color: Some(*color), logical_to_image_scale_x: 1.0, logical_to_image_scale_y: 1.0, + logical_image_origin_x: 0.0, + logical_image_origin_y: 0.0, + _region_source: None, }), CanvasExportBackdropSnapshot::PersistedImage { data, @@ -211,11 +223,70 @@ impl ExportBackdrop { bg_color: None, logical_to_image_scale_x: *logical_to_image_scale_x, logical_to_image_scale_y: *logical_to_image_scale_y, + logical_image_origin_x: 0.0, + logical_image_origin_y: 0.0, + _region_source: None, }) } } } + pub(crate) fn from_region_source( + image: Arc, + logical_bounds: CanvasExportRect, + ) -> Result { + let width = i32::try_from(image.width).map_err(|_| { + CaptureError::ImageError("Region backdrop width is too large".to_string()) + })?; + let height = i32::try_from(image.height).map_err(|_| { + CaptureError::ImageError("Region backdrop height is too large".to_string()) + })?; + validate_persisted_image_backdrop(image.data.len(), width, height, image.stride)?; + let scale_x = f64::from(width) / logical_bounds.width; + let scale_y = f64::from(height) / logical_bounds.height; + if !scale_x.is_finite() || !scale_y.is_finite() || scale_x <= 0.0 || scale_y <= 0.0 { + return Err(CaptureError::ImageError( + "Region backdrop has an invalid canvas mapping".to_string(), + )); + } + // SAFETY: ScreenImage owns at least every validated row and remains + // alive in `_region_source` until after this private Cairo source + // surface and its pattern are dropped. This backdrop is read-only: + // it is used only as a paint/blur/eraser replay source. + let surface = unsafe { + cairo::ImageSurface::create_for_data_unsafe( + image.data.as_ptr() as *mut u8, + cairo::Format::ARgb32, + width, + height, + image.stride, + ) + } + .map_err(|err| { + CaptureError::ImageError(format!("Failed to create region backdrop: {err}")) + })?; + let pattern = cairo::SurfacePattern::create(&surface); + pattern.set_extend(cairo::Extend::Pad); + pattern.set_matrix(cairo::Matrix::new( + scale_x, + 0.0, + 0.0, + scale_y, + -logical_bounds.x * scale_x, + -logical_bounds.y * scale_y, + )); + Ok(Self { + surface: Some(surface), + pattern: Some(pattern), + bg_color: None, + logical_to_image_scale_x: scale_x, + logical_to_image_scale_y: scale_y, + logical_image_origin_x: logical_bounds.x, + logical_image_origin_y: logical_bounds.y, + _region_source: Some(image), + }) + } + fn paint(&self, ctx: &cairo::Context) { if let Some(color) = self.bg_color { ctx.set_source_rgba(color.r, color.g, color.b, color.a); @@ -227,6 +298,7 @@ impl ExportBackdrop { return; }; let _ = ctx.save(); + ctx.translate(self.logical_image_origin_x, self.logical_image_origin_y); ctx.scale( 1.0 / self.logical_to_image_scale_x.max(f64::MIN_POSITIVE), 1.0 / self.logical_to_image_scale_y.max(f64::MIN_POSITIVE), @@ -245,6 +317,8 @@ impl ExportBackdrop { bg_color: self.bg_color, logical_to_image_scale_x: self.logical_to_image_scale_x, logical_to_image_scale_y: self.logical_to_image_scale_y, + logical_image_origin_x: self.logical_image_origin_x, + logical_image_origin_y: self.logical_image_origin_y, } } } @@ -343,3 +417,31 @@ fn validate_persisted_image_backdrop( Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn region_backdrop_retains_the_exact_shared_image_without_copying_pixels() { + let image = Arc::new(ScreenImage { + width: 2, + height: 2, + stride: 8, + data: vec![0xFF; 16], + }); + let pixels = image.data.as_ptr(); + let backdrop = ExportBackdrop::from_region_source( + Arc::clone(&image), + CanvasExportRect::new(10.0, 20.0, 2.0, 2.0).unwrap(), + ) + .expect("shared region backdrop"); + + let retained = backdrop + ._region_source + .as_ref() + .expect("region source is retained"); + assert!(Arc::ptr_eq(retained, &image)); + assert_eq!(retained.data.as_ptr(), pixels); + } +} diff --git a/src/canvas_export/png.rs b/src/canvas_export/png.rs index 5c5795a1d..bd2ed447c 100644 --- a/src/canvas_export/png.rs +++ b/src/canvas_export/png.rs @@ -33,22 +33,23 @@ pub struct BoardExportSnapshot { pub fn render_canvas_png(snapshot: &CanvasExportSnapshot) -> Result { let surface = render_canvas_surface(snapshot)?; + encode_surface_png(&surface, "canvas") +} + +pub(crate) fn encode_surface_png( + surface: &cairo::ImageSurface, + subject: &str, +) -> Result { let mut bytes = Vec::new(); - surface - .write_to_png(&mut bytes) - .map_err(|err| CaptureError::ImageError(format!("Failed to encode canvas PNG: {err}")))?; + surface.write_to_png(&mut bytes).map_err(|err| { + CaptureError::ImageError(format!("Failed to encode {subject} PNG: {err}")) + })?; Ok(RenderedImage { bytes, format: ImageFormatMetadata::png(), - width: snapshot - .viewport - .logical_width - .saturating_mul(snapshot.viewport.scale.max(1) as u32), - height: snapshot - .viewport - .logical_height - .saturating_mul(snapshot.viewport.scale.max(1) as u32), + width: surface.width() as u32, + height: surface.height() as u32, }) } diff --git a/src/canvas_export/region.rs b/src/canvas_export/region.rs new file mode 100644 index 000000000..2cf74ca65 --- /dev/null +++ b/src/canvas_export/region.rs @@ -0,0 +1,358 @@ +use std::sync::Arc; + +use crate::canvas_export::page::{ + CanvasExportBackdropSnapshot, CanvasExportRect, CanvasPageExportSnapshot, ExportBackdrop, + SpotlightPassSnapshot, draw_canvas_page_region, +}; +use crate::canvas_export::png::encode_surface_png; +use crate::capture::{CaptureError, RenderedImage}; +use crate::draw::Frame; +use crate::screen_pixels::{ImagePixelRect, PackedArgb32, ScreenImage}; + +#[derive(Debug, Clone)] +pub(crate) struct CanvasRegionSource { + pub image: std::sync::Arc, + /// World-space bounds occupied by the entire captured image. + pub logical_bounds: CanvasExportRect, +} + +#[derive(Debug, Clone)] +pub(crate) struct CanvasRegionExportSnapshot { + pub source: CanvasRegionSource, + pub selection: ImagePixelRect, + pub frame: Frame, + pub spotlight: SpotlightPassSnapshot, +} + +impl CanvasRegionSource { + fn selection_source_rect(&self, selection: ImagePixelRect) -> Option { + if selection.x().checked_add(selection.width())? > self.image.width + || selection.y().checked_add(selection.height())? > self.image.height + { + return None; + } + let scale_x = self.logical_bounds.width / f64::from(self.image.width); + let scale_y = self.logical_bounds.height / f64::from(self.image.height); + CanvasExportRect::new( + self.logical_bounds.x + f64::from(selection.x()) * scale_x, + self.logical_bounds.y + f64::from(selection.y()) * scale_y, + f64::from(selection.width()) * scale_x, + f64::from(selection.height()) * scale_y, + ) + } + + fn copy_selection(&self, selection: ImagePixelRect) -> Result { + let source_stride = usize::try_from(self.image.stride) + .map_err(|_| CaptureError::ImageError("Region source stride is invalid".to_string()))?; + let row_bytes = usize::try_from(selection.width()) + .ok() + .and_then(|width| width.checked_mul(4)) + .ok_or_else(|| CaptureError::ImageError("Region row size overflow".to_string()))?; + let target_stride = i32::try_from(row_bytes) + .map_err(|_| CaptureError::ImageError("Region stride is too large".to_string()))?; + let capacity = row_bytes + .checked_mul(selection.height() as usize) + .ok_or_else(|| CaptureError::ImageError("Region buffer size overflow".to_string()))?; + let start_column = usize::try_from(selection.x()) + .ok() + .and_then(|x| x.checked_mul(4)) + .ok_or_else(|| CaptureError::ImageError("Region offset overflow".to_string()))?; + let mut data = Vec::with_capacity(capacity); + for row in 0..selection.height() { + let start = usize::try_from(selection.y().saturating_add(row)) + .ok() + .and_then(|row| row.checked_mul(source_stride)) + .and_then(|row| row.checked_add(start_column)) + .ok_or_else(|| CaptureError::ImageError("Region offset overflow".to_string()))?; + let end = start + .checked_add(row_bytes) + .ok_or_else(|| CaptureError::ImageError("Region offset overflow".to_string()))?; + data.extend_from_slice(self.image.data.get(start..end).ok_or_else(|| { + CaptureError::ImageError("Region leaves the captured image".to_string()) + })?); + } + PackedArgb32::new(selection.width(), selection.height(), target_stride, data) + .ok_or_else(|| CaptureError::ImageError("Region pixels are invalid".to_string())) + } +} + +pub(crate) fn render_canvas_region_png( + snapshot: CanvasRegionExportSnapshot, +) -> Result { + let source_rect = snapshot + .source + .selection_source_rect(snapshot.selection) + .ok_or_else(|| CaptureError::ImageError("Region source mapping is invalid".to_string()))?; + let pixels = snapshot.source.copy_selection(snapshot.selection)?; + let output_width = pixels.width(); + let output_height = pixels.height(); + let width = i32::try_from(output_width) + .map_err(|_| CaptureError::ImageError("Region width is too large".to_string()))?; + let height = i32::try_from(output_height) + .map_err(|_| CaptureError::ImageError("Region height is too large".to_string()))?; + let stride = pixels.stride(); + let surface = cairo::ImageSurface::create_for_data( + pixels.into_data(), + cairo::Format::ARgb32, + width, + height, + stride, + ) + .map_err(|err| CaptureError::ImageError(format!("Failed to create region surface: {err}")))?; + let ctx = cairo::Context::new(&surface).map_err(|err| { + CaptureError::ImageError(format!("Failed to create region context: {err}")) + })?; + let backdrop = ExportBackdrop::from_region_source( + Arc::clone(&snapshot.source.image), + snapshot.source.logical_bounds, + )?; + let page = CanvasPageExportSnapshot { + frame: snapshot.frame, + backdrop: CanvasExportBackdropSnapshot::Transparent, + viewport_width: output_width, + viewport_height: output_height, + origin_x: source_rect.x.floor() as i32, + origin_y: source_rect.y.floor() as i32, + spotlight: snapshot.spotlight, + }; + let destination = + CanvasExportRect::new(0.0, 0.0, f64::from(output_width), f64::from(output_height)) + .expect("validated non-empty destination"); + draw_canvas_page_region(&ctx, &page, &backdrop, source_rect, destination, false); + drop(ctx); + + encode_surface_png(&surface, "region") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::draw::{BlurStyle, EraserBrush, EraserKind, Frame, RED, Shape}; + + fn solid_source(width: u32, height: u32, pixel: u32) -> CanvasRegionSource { + CanvasRegionSource { + image: std::sync::Arc::new(ScreenImage { + data: (0..width.saturating_mul(height)) + .flat_map(|_| pixel.to_ne_bytes()) + .collect(), + width, + height, + stride: (width * 4) as i32, + }), + logical_bounds: CanvasExportRect::new(10.0, 20.0, 4.0, 4.0).unwrap(), + } + } + + fn decoded_pixel(rendered: &RenderedImage, x: i32, y: i32) -> u32 { + let mut surface = cairo::ImageSurface::create_from_png(&mut rendered.bytes.as_slice()) + .expect("region PNG decodes"); + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().expect("decoded pixels"); + let offset = y as usize * stride + x as usize * 4; + u32::from_ne_bytes(data[offset..offset + 4].try_into().expect("pixel")) + } + + #[test] + fn region_renderer_maps_world_shapes_into_native_crop_pixels_and_clips() { + let backdrop = 0xFF20_3040; + let mut frame = Frame::new(); + frame.add_shape(Shape::Rect { + x: 11, + y: 21, + w: 1, + h: 1, + fill: true, + color: RED, + thick: 1.0, + }); + frame.add_shape(Shape::Rect { + x: 100, + y: 100, + w: 10, + h: 10, + fill: true, + color: RED, + thick: 1.0, + }); + let rendered = render_canvas_region_png(CanvasRegionExportSnapshot { + source: solid_source(8, 8, backdrop), + selection: ImagePixelRect::new(0, 0, 8, 8, (8, 8)).unwrap(), + frame, + spotlight: SpotlightPassSnapshot::default(), + }) + .expect("region renders"); + + assert_eq!((rendered.width, rendered.height), (8, 8)); + assert_eq!(decoded_pixel(&rendered, 0, 0), backdrop); + assert_eq!(decoded_pixel(&rendered, 3, 3), 0xFFFF_0000); + assert_eq!(decoded_pixel(&rendered, 7, 7), backdrop); + } + + #[test] + fn selected_output_is_cropped_while_shape_mapping_uses_the_full_source() { + let backdrop = 0xFF20_3040; + let mut frame = Frame::new(); + frame.add_shape(Shape::Rect { + x: 11, + y: 21, + w: 1, + h: 1, + fill: true, + color: RED, + thick: 1.0, + }); + let rendered = render_canvas_region_png(CanvasRegionExportSnapshot { + source: solid_source(8, 8, backdrop), + selection: ImagePixelRect::new(2, 2, 4, 4, (8, 8)).unwrap(), + frame, + spotlight: SpotlightPassSnapshot::default(), + }) + .expect("region renders"); + + assert_eq!((rendered.width, rendered.height), (4, 4)); + assert_eq!(decoded_pixel(&rendered, 1, 1), 0xFFFF_0000); + assert_eq!(decoded_pixel(&rendered, 3, 3), backdrop); + } + + #[test] + fn empty_frame_preserves_every_selected_screen_pixel() { + let source = CanvasRegionSource { + image: std::sync::Arc::new(ScreenImage { + data: (0_u32..16) + .flat_map(|pixel| (0xFF00_0000 | pixel).to_ne_bytes()) + .collect(), + width: 4, + height: 4, + stride: 16, + }), + logical_bounds: CanvasExportRect::new(-2.5, 7.25, 8.0, 8.0).unwrap(), + }; + let selection = ImagePixelRect::new(1, 1, 2, 2, (4, 4)).unwrap(); + let rendered = render_canvas_region_png(CanvasRegionExportSnapshot { + source, + selection, + frame: Frame::new(), + spotlight: SpotlightPassSnapshot::default(), + }) + .expect("raw crop renders"); + + assert_eq!(decoded_pixel(&rendered, 0, 0), 0xFF00_0005); + assert_eq!(decoded_pixel(&rendered, 1, 0), 0xFF00_0006); + assert_eq!(decoded_pixel(&rendered, 0, 1), 0xFF00_0009); + assert_eq!(decoded_pixel(&rendered, 1, 1), 0xFF00_000A); + } + + #[test] + fn malformed_full_source_fails_closed() { + let mut source = solid_source(8, 8, 0xFFFF_FFFF); + std::sync::Arc::get_mut(&mut source.image) + .expect("test owns source") + .data + .pop(); + let result = render_canvas_region_png(CanvasRegionExportSnapshot { + source, + selection: ImagePixelRect::new(0, 0, 1, 1, (8, 8)).unwrap(), + frame: Frame::new(), + spotlight: SpotlightPassSnapshot::default(), + }); + assert!(matches!(result, Err(CaptureError::ImageError(_)))); + } + + #[test] + fn eraser_replays_the_original_screen_pixel_at_a_nonzero_world_origin() { + let backdrop = 0xFF20_3040; + let mut frame = Frame::new(); + frame.add_shape(Shape::Rect { + x: 10, + y: 20, + w: 4, + h: 4, + fill: true, + color: RED, + thick: 1.0, + }); + frame.add_shape(Shape::EraserStroke { + points: vec![(12, 22)], + brush: EraserBrush { + size: 1.0, + kind: EraserKind::Rect, + }, + }); + let rendered = render_canvas_region_png(CanvasRegionExportSnapshot { + source: solid_source(8, 8, backdrop), + selection: ImagePixelRect::new(0, 0, 8, 8, (8, 8)).unwrap(), + frame, + spotlight: SpotlightPassSnapshot::default(), + }) + .expect("region renders"); + + assert_eq!(decoded_pixel(&rendered, 4, 4), backdrop); + assert_eq!(decoded_pixel(&rendered, 1, 1), 0xFFFF_0000); + } + + #[test] + fn blur_at_a_crop_edge_matches_full_image_render_then_crop() { + let width = 32; + let height = 16; + let mut data = Vec::with_capacity(width * height * 4); + for _y in 0..height { + for x in 0..width { + let channel = if (x / 2) % 2 == 0 { 0x10 } else { 0xE0 }; + data.extend_from_slice( + &u32::from_be_bytes([0xFF, channel, channel, channel]).to_ne_bytes(), + ); + } + } + let source = CanvasRegionSource { + image: std::sync::Arc::new(ScreenImage { + data, + width: width as u32, + height: height as u32, + stride: (width * 4) as i32, + }), + logical_bounds: CanvasExportRect::new(10.0, 20.0, width as f64, height as f64).unwrap(), + }; + let mut frame = Frame::new(); + frame.add_shape(Shape::BlurRect { + x: 14, + y: 22, + w: 14, + h: 10, + strength: 20.0, + style: BlurStyle::Gaussian, + }); + let full = render_canvas_region_png(CanvasRegionExportSnapshot { + source: source.clone(), + selection: ImagePixelRect::new( + 0, + 0, + width as u32, + height as u32, + (width as u32, height as u32), + ) + .unwrap(), + frame: frame.clone_without_history(), + spotlight: SpotlightPassSnapshot::default(), + }) + .expect("full region renders"); + let selection = ImagePixelRect::new(8, 4, 10, 8, (width as u32, height as u32)).unwrap(); + let cropped = render_canvas_region_png(CanvasRegionExportSnapshot { + source, + selection, + frame, + spotlight: SpotlightPassSnapshot::default(), + }) + .expect("cropped region renders"); + + for y in 0..selection.height() as i32 { + for x in 0..selection.width() as i32 { + assert_eq!( + decoded_pixel(&cropped, x, y), + decoded_pixel(&full, x + selection.x() as i32, y + selection.y() as i32), + "pixel ({x}, {y})", + ); + } + } + } +} diff --git a/src/capture/clipboard.rs b/src/capture/clipboard.rs index 3702f8f13..24ef18a09 100644 --- a/src/capture/clipboard.rs +++ b/src/capture/clipboard.rs @@ -1,9 +1,29 @@ //! Clipboard integration for copying screenshots. -use super::types::CaptureError; +use super::{dependencies::CaptureClipboard, types::CaptureError}; +use crate::process_broker::max_publish_bytes; use std::ffi::OsStr; use std::time::Duration; +const COPY_TIMEOUT: Duration = Duration::from_secs(10); +const VERIFY_TIMEOUT: Duration = Duration::from_secs(5); +const PERSIST_ATTEMPTS: usize = 2; +const MAX_VERIFY_OUTPUT_BYTES: usize = 64 * 1024 * 1024; + +pub(super) struct BrokerClipboard; + +impl CaptureClipboard for BrokerClipboard { + fn copy(&self, image_data: &[u8]) -> Result<(), CaptureError> { + copy_via_command(image_data).map_err(CaptureError::ClipboardError) + } + + fn verify(&self, expected: &[u8], output_cap: usize) -> Result { + read_back_via_command(output_cap) + .map(|read_back| read_back == expected) + .map_err(CaptureError::ClipboardError) + } +} + /// Copy image data to the Wayland clipboard. /// /// Uses `wl-copy`, which is already a packaged/runtime dependency. @@ -19,20 +39,84 @@ pub fn copy_to_clipboard(image_data: &[u8]) -> Result<(), CaptureError> { image_data.len() ); - copy_to_clipboard_with(image_data, copy_via_command) + copy_to_clipboard_with(image_data, &BrokerClipboard, max_publish_bytes()) } -fn copy_to_clipboard_with(image_data: &[u8], copy_cmd: F) -> Result<(), CaptureError> +pub(super) fn copy_to_clipboard_with( + image_data: &[u8], + commands: &C, + publish_cap: usize, +) -> Result<(), CaptureError> where - F: FnOnce(&[u8]) -> Result<(), CaptureError>, + C: CaptureClipboard + ?Sized, { - copy_cmd(image_data)?; - log::info!("Successfully copied to clipboard via wl-copy command"); - Ok(()) + copy_to_clipboard_with_limits(image_data, commands, publish_cap, MAX_VERIFY_OUTPUT_BYTES) +} + +fn copy_to_clipboard_with_limits( + image_data: &[u8], + commands: &C, + publish_cap: usize, + verify_output_cap: usize, +) -> Result<(), CaptureError> +where + C: CaptureClipboard + ?Sized, +{ + if image_data.len() > publish_cap { + return Err(persistence_error(format!( + "PNG payload is {} bytes, exceeding the {publish_cap}-byte clipboard publication limit", + image_data.len() + ))); + } + + let mut last_error = "clipboard verification did not match".to_string(); + for attempt in 0..PERSIST_ATTEMPTS { + if let Err(error) = commands.copy(image_data) { + last_error = clipboard_error_detail(error); + log::warn!( + "Clipboard publication attempt {}/{} failed: {}", + attempt + 1, + PERSIST_ATTEMPTS, + last_error + ); + continue; + } + + if image_data.len() >= verify_output_cap { + log::info!( + "Published {} clipboard bytes; skipped read-back above the {}-byte verification threshold", + image_data.len(), + verify_output_cap + ); + return Ok(()); + } + + match commands.verify(image_data, image_data.len() + 1) { + Ok(true) => { + log::info!("Successfully published and verified the clipboard image"); + return Ok(()); + } + Ok(false) => { + last_error = "clipboard verification did not match".to_string(); + } + Err(error) => { + last_error = clipboard_error_detail(error); + } + } + + log::warn!( + "Clipboard verification attempt {}/{} failed: {}", + attempt + 1, + PERSIST_ATTEMPTS, + last_error + ); + } + + Err(persistence_error(last_error)) } /// Copy to the clipboard through the brokered `wl-copy` helper. -fn copy_via_command(image_data: &[u8]) -> Result<(), CaptureError> { +fn copy_via_command(image_data: &[u8]) -> Result<(), String> { let output = crate::process_broker::current() .and_then(|broker| { broker.publish( @@ -40,76 +124,236 @@ fn copy_via_command(image_data: &[u8]) -> Result<(), CaptureError> { OsStr::new("wl-copy"), [OsStr::new("--type"), OsStr::new("image/png")], image_data.to_vec(), - Duration::from_secs(10), + COPY_TIMEOUT, ) }) - .map_err(|e| { - CaptureError::ClipboardError(format!( - "Failed to run wl-copy through the process broker: {}", - e - )) - })?; + .map_err(|error| format!("Failed to run wl-copy through the process broker: {error}"))?; if output.timed_out { - return Err(CaptureError::ClipboardError( - "wl-copy timed out".to_string(), - )); + return Err("wl-copy timed out".to_string()); } if output.status != 0 { - return Err(CaptureError::ClipboardError(format!( - "wl-copy exited unsuccessfully: {}", - String::from_utf8_lossy(&output.stderr).trim() - ))); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return if stderr.is_empty() { + Err("wl-copy exited unsuccessfully".to_string()) + } else { + Err(format!("wl-copy exited unsuccessfully: {stderr}")) + }; } log::debug!("wl-copy command completed successfully"); Ok(()) } +/// Read the published image back through brokered `wl-paste`. +fn read_back_via_command(output_cap: usize) -> Result, String> { + let output = crate::process_broker::current() + .and_then(|broker| { + broker.run( + crate::process_broker::HelperKind::WlPaste, + OsStr::new("wl-paste"), + clipboard_image_read_args(), + Vec::new(), + VERIFY_TIMEOUT, + output_cap, + ) + }) + .map_err(|error| format!("Failed to run wl-paste through the process broker: {error}"))?; + if output.timed_out { + return Err("wl-paste timed out".to_string()); + } + if output.status != 0 { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + return if stderr.is_empty() { + Err("wl-paste exited unsuccessfully".to_string()) + } else { + Err(format!("wl-paste exited unsuccessfully: {stderr}")) + }; + } + Ok(output.stdout) +} + +fn clipboard_image_read_args() -> [&'static OsStr; 3] { + [ + OsStr::new("--no-newline"), + OsStr::new("--type"), + OsStr::new("image/png"), + ] +} + +fn persistence_error(error: impl std::fmt::Display) -> CaptureError { + CaptureError::ClipboardError(format!("Could not persist clipboard: {error}")) +} + +fn clipboard_error_detail(error: CaptureError) -> String { + match error { + CaptureError::ClipboardError(message) => message, + error => error.to_string(), + } +} + #[cfg(test)] mod tests { use super::*; - use std::cell::Cell; - use std::rc::Rc; + use std::collections::VecDeque; + use std::sync::Mutex; + + #[derive(Default)] + struct FakeClipboardCommands { + copies: Mutex>>, + copy_results: Mutex>>, + verify_expected: Mutex>>, + verify_caps: Mutex>, + verify_results: Mutex>>, + } + + impl FakeClipboardCommands { + fn with_results( + copy_results: impl IntoIterator>, + verify_results: impl IntoIterator>, + ) -> Self { + Self { + copy_results: Mutex::new(copy_results.into_iter().collect()), + verify_results: Mutex::new(verify_results.into_iter().collect()), + ..Self::default() + } + } + } + + impl CaptureClipboard for FakeClipboardCommands { + fn copy(&self, image_data: &[u8]) -> Result<(), CaptureError> { + self.copies.lock().unwrap().push(image_data.to_vec()); + self.copy_results + .lock() + .unwrap() + .pop_front() + .expect("unexpected wl-copy call") + } + + fn verify(&self, expected: &[u8], output_cap: usize) -> Result { + self.verify_expected.lock().unwrap().push(expected.to_vec()); + self.verify_caps.lock().unwrap().push(output_cap); + self.verify_results + .lock() + .unwrap() + .pop_front() + .expect("unexpected wl-paste call") + } + } #[test] - fn copy_to_clipboard_uses_command_success() { - let cmd_calls = Rc::new(Cell::new(0)); - let cmd_calls_handle = cmd_calls.clone(); + fn verified_clipboard_accepts_an_exact_read_back() { + let commands = FakeClipboardCommands::with_results([Ok(())], [Ok(true)]); - let result = copy_to_clipboard_with(b"data", move |_| { - cmd_calls_handle.set(cmd_calls_handle.get() + 1); - Ok(()) - }); + copy_to_clipboard_with(b"data", &commands, max_publish_bytes()).unwrap(); - assert!(result.is_ok()); - assert_eq!(cmd_calls.get(), 1); + assert_eq!(&*commands.copies.lock().unwrap(), &[b"data".to_vec()]); + assert_eq!( + &*commands.verify_expected.lock().unwrap(), + &[b"data".to_vec()] + ); + assert_eq!(&*commands.verify_caps.lock().unwrap(), &[b"data".len() + 1]); } #[test] - fn copy_to_clipboard_returns_command_error() { - let cmd_calls = Rc::new(Cell::new(0)); - let cmd_calls_handle = cmd_calls.clone(); + fn verified_clipboard_republishes_after_a_mismatch() { + let commands = FakeClipboardCommands::with_results([Ok(()), Ok(())], [Ok(false), Ok(true)]); - let result = copy_to_clipboard_with(b"data", move |_| { - cmd_calls_handle.set(cmd_calls_handle.get() + 1); - Err(CaptureError::ClipboardError("cmd failed".to_string())) - }); + copy_to_clipboard_with(b"data", &commands, max_publish_bytes()).unwrap(); - assert!(result.is_err()); - assert_eq!(cmd_calls.get(), 1); + assert_eq!(commands.copies.lock().unwrap().len(), 2); + assert_eq!(&*commands.verify_caps.lock().unwrap(), &[5, 5]); } #[test] - fn copy_to_clipboard_preserves_command_error() { - let result = copy_to_clipboard_with(b"data", |_| { - Err(CaptureError::ClipboardError("cmd failed".to_string())) - }) - .expect_err("expected error"); + fn verified_clipboard_retries_the_complete_transaction_after_a_write_failure() { + let commands = FakeClipboardCommands::with_results( + [ + Err(CaptureError::ClipboardError("wl-copy failed".to_string())), + Ok(()), + ], + [Ok(true)], + ); - match result { - CaptureError::ClipboardError(msg) => { - assert!(msg.contains("cmd failed")); - } - other => panic!("unexpected error variant: {other:?}"), - } + copy_to_clipboard_with(b"data", &commands, max_publish_bytes()).unwrap(); + + assert_eq!(commands.copies.lock().unwrap().len(), 2); + assert_eq!(&*commands.verify_caps.lock().unwrap(), &[5]); + } + + #[test] + fn verified_clipboard_reports_two_mismatches() { + let commands = + FakeClipboardCommands::with_results([Ok(()), Ok(())], [Ok(false), Ok(false)]); + + let error = copy_to_clipboard_with(b"data", &commands, max_publish_bytes()) + .expect_err("two mismatches must fail"); + + assert_eq!(commands.copies.lock().unwrap().len(), 2); + assert!( + matches!(error, CaptureError::ClipboardError(message) if message == "Could not persist clipboard: clipboard verification did not match") + ); + } + + #[test] + fn verified_clipboard_retries_a_timeout_then_reports_it() { + let commands = FakeClipboardCommands::with_results( + [Ok(()), Ok(())], + [ + Err(CaptureError::ClipboardError( + "wl-paste timed out".to_string(), + )), + Err(CaptureError::ClipboardError( + "wl-paste timed out".to_string(), + )), + ], + ); + + let error = copy_to_clipboard_with(b"data", &commands, max_publish_bytes()) + .expect_err("two verification timeouts must fail"); + + assert_eq!(commands.copies.lock().unwrap().len(), 2); + assert!( + matches!(error, CaptureError::ClipboardError(message) if message == "Could not persist clipboard: wl-paste timed out") + ); + } + + #[test] + fn verified_clipboard_rejects_payloads_above_the_publication_cap() { + let commands = FakeClipboardCommands::default(); + + let error = copy_to_clipboard_with_limits(b"four", &commands, 3, 64) + .expect_err("oversized clipboard payload must fail"); + + assert!(commands.copies.lock().unwrap().is_empty()); + assert!(commands.verify_caps.lock().unwrap().is_empty()); + assert!( + matches!(error, CaptureError::ClipboardError(message) if message.contains("Could not persist clipboard") && message.contains("3-byte")) + ); + } + + #[test] + fn verified_clipboard_skips_read_back_above_the_verification_threshold() { + let commands = FakeClipboardCommands::with_results([Ok(())], []); + + copy_to_clipboard_with_limits(b"data", &commands, 8, 4).unwrap(); + + assert_eq!(&*commands.copies.lock().unwrap(), &[b"data".to_vec()]); + assert!(commands.verify_caps.lock().unwrap().is_empty()); + } + + #[test] + fn image_read_back_requests_the_exact_png_mime_without_a_newline() { + assert_eq!( + clipboard_image_read_args(), + [ + OsStr::new("--no-newline"), + OsStr::new("--type"), + OsStr::new("image/png"), + ] + ); + } + + #[test] + fn publication_cap_retains_the_large_capture_limit() { + assert_eq!(max_publish_bytes(), 256 * 1024 * 1024); } } diff --git a/src/capture/dependencies.rs b/src/capture/dependencies.rs index 096c9241c..5502c5e66 100644 --- a/src/capture/dependencies.rs +++ b/src/capture/dependencies.rs @@ -20,9 +20,10 @@ pub trait CaptureFileSaver: Send + Sync { fn save(&self, image_data: &[u8], config: &FileSaveConfig) -> Result; } -/// Abstraction over copying screenshots to the clipboard. +/// Abstraction over publishing and verifying screenshot clipboard data. pub trait CaptureClipboard: Send + Sync { fn copy(&self, image_data: &[u8]) -> Result<(), CaptureError>; + fn verify(&self, expected: &[u8], output_cap: usize) -> Result; } /// Bundle of dependencies used by the capture pipeline. Each component can be mocked in tests. @@ -38,14 +39,13 @@ impl Default for CaptureDependencies { Self { source: Arc::new(DefaultCaptureSource), saver: Arc::new(DefaultFileSaver), - clipboard: Arc::new(DefaultClipboard), + clipboard: Arc::new(clipboard::BrokerClipboard), } } } struct DefaultCaptureSource; struct DefaultFileSaver; -struct DefaultClipboard; impl CaptureSource for DefaultCaptureSource { fn capture(&self, capture_type: CaptureType) -> CaptureFuture<'_> { @@ -58,9 +58,3 @@ impl CaptureFileSaver for DefaultFileSaver { file::save_screenshot(image_data, config) } } - -impl CaptureClipboard for DefaultClipboard { - fn copy(&self, image_data: &[u8]) -> Result<(), CaptureError> { - clipboard::copy_to_clipboard(image_data) - } -} diff --git a/src/capture/manager.rs b/src/capture/manager.rs index ecdb73aac..8a818b4a6 100644 --- a/src/capture/manager.rs +++ b/src/capture/manager.rs @@ -12,11 +12,13 @@ use crate::capture::{ pipeline::{ CaptureManagerRequest, CaptureManagerResult, CaptureRequest, deliver_document, deliver_image, perform_capture, render_and_deliver_document, render_and_deliver_image, + render_image, }, types::{ CaptureDestination, CaptureError, CaptureOutcome, CaptureType, DesktopBackdropCaptureRequest, DocumentDeliveryRequest, ImageDeliveryRequest, - ImageOperationKind, RenderedDocumentDeliveryRequest, RenderedImageDeliveryRequest, + ImageOperationKind, RenderImageRequest, RenderedDocumentDeliveryRequest, + RenderedImageDeliveryRequest, }, }; @@ -225,6 +227,13 @@ impl CaptureManager { self.try_submit(CaptureManagerRequest::RenderAndDeliverImage(request)) } + pub fn request_render_image( + &mut self, + request: RenderImageRequest, + ) -> Result { + self.try_submit(CaptureManagerRequest::RenderImage(request)) + } + pub fn request_rendered_document_delivery( &mut self, request: RenderedDocumentDeliveryRequest, @@ -405,6 +414,9 @@ async fn run_capture_worker( .await .map(CaptureManagerResult::Capture) } + CaptureManagerRequest::RenderImage(request) => render_image(request) + .await + .map(CaptureManagerResult::RenderedImage), CaptureManagerRequest::RenderAndDeliverImage(request) => { render_and_deliver_image(request, dependencies.clone()) .await @@ -439,6 +451,10 @@ fn outcome_from_result( log::info!("Desktop backdrop capture successful"); CaptureOutcome::DesktopBackdropSuccess(result) } + Ok(CaptureManagerResult::RenderedImage(image)) => { + log::info!("Deferred image render successful"); + CaptureOutcome::RenderedImageReady(image) + } Err(CaptureError::Cancelled(reason)) => { log::info!("Image operation cancelled: {reason}"); CaptureOutcome::Cancelled { operation, reason } diff --git a/src/capture/mod.rs b/src/capture/mod.rs index 704784dd0..7f8497eb8 100644 --- a/src/capture/mod.rs +++ b/src/capture/mod.rs @@ -14,6 +14,7 @@ pub(crate) mod png; pub mod portal; pub mod sources; pub mod types; +pub(crate) mod window_geometry; mod dependencies; mod desktop_backdrop; @@ -31,5 +32,6 @@ pub use types::{ DesktopBackdropCaptureRequest, DesktopBackdropCaptureResult, DesktopBackdropGeometry, DesktopBackdropOutputGeometry, DocumentDeliveryRequest, DocumentRenderJob, ImageDeliveryRequest, ImageFormatMetadata, ImageOperationKind, ImageRenderJob, - RenderedDocument, RenderedDocumentDeliveryRequest, RenderedImage, RenderedImageDeliveryRequest, + RenderImageRequest, RenderedDocument, RenderedDocumentDeliveryRequest, RenderedImage, + RenderedImageDeliveryRequest, }; diff --git a/src/capture/pipeline.rs b/src/capture/pipeline.rs index 493fd528b..fc6db0be5 100644 --- a/src/capture/pipeline.rs +++ b/src/capture/pipeline.rs @@ -6,8 +6,8 @@ use crate::capture::{ types::{ CaptureDestination, CaptureError, CaptureResult, CaptureType, DesktopBackdropCaptureRequest, DesktopBackdropCaptureResult, DocumentDeliveryRequest, - ImageDeliveryRequest, ImageOperationKind, RenderedDocumentDeliveryRequest, - RenderedImageDeliveryRequest, + ImageDeliveryRequest, ImageOperationKind, RenderImageRequest, + RenderedDocumentDeliveryRequest, RenderedImage, RenderedImageDeliveryRequest, }, }; use tokio::task; @@ -40,6 +40,7 @@ pub(crate) enum CaptureManagerRequest { CaptureDesktopBackdrop(DesktopBackdropCaptureRequest), DeliverImage(ImageDeliveryRequest), DeliverDocument(DocumentDeliveryRequest), + RenderImage(RenderImageRequest), RenderAndDeliverImage(RenderedImageDeliveryRequest), RenderAndDeliverDocument(RenderedDocumentDeliveryRequest), } @@ -51,6 +52,7 @@ impl CaptureManagerRequest { Self::CaptureDesktopBackdrop(request) => request.operation, Self::DeliverImage(request) => request.operation, Self::DeliverDocument(request) => request.operation, + Self::RenderImage(request) => request.operation, Self::RenderAndDeliverImage(request) => request.operation, Self::RenderAndDeliverDocument(request) => request.operation, } @@ -84,6 +86,10 @@ impl fmt::Debug for CaptureManagerRequest { .field("extension", &request.document.extension) .field("mime_type", &request.document.mime_type) .finish(), + Self::RenderImage(request) => f + .debug_struct("RenderImage") + .field("operation", &request.operation) + .finish(), Self::RenderAndDeliverImage(request) => f .debug_struct("RenderAndDeliverImage") .field("destination", &request.destination) @@ -101,6 +107,7 @@ impl fmt::Debug for CaptureManagerRequest { pub(crate) enum CaptureManagerResult { Capture(CaptureResult), DesktopBackdrop(DesktopBackdropCaptureResult), + RenderedImage(RenderedImage), } pub(crate) async fn perform_capture( @@ -291,11 +298,11 @@ pub(crate) async fn render_and_deliver_image( request: RenderedImageDeliveryRequest, dependencies: Arc, ) -> Result { - log::info!("Starting deferred image render: {:?}", request.operation); - let render = request.render; - let image = task::spawn_blocking(render) - .await - .map_err(|e| CaptureError::ImageError(format!("Render task failed: {}", e)))??; + let image = render_image(RenderImageRequest { + render: request.render, + operation: request.operation, + }) + .await?; log::info!("Deferred image render completed: {:?}", request.operation); deliver_image( ImageDeliveryRequest { @@ -310,6 +317,16 @@ pub(crate) async fn render_and_deliver_image( .await } +/// Runs an image render on a blocking worker without applying delivery policy. +pub(crate) async fn render_image( + request: RenderImageRequest, +) -> Result { + log::info!("Starting deferred image render: {:?}", request.operation); + task::spawn_blocking(request.render) + .await + .map_err(|error| CaptureError::ImageError(format!("Render task failed: {error}")))? +} + /// Runs the deferred render on a blocking worker, then delivers like /// [`deliver_document`]. Keeps export render + encode off the submitting /// thread. @@ -393,9 +410,15 @@ async fn save_bytes( } async fn copy_to_clipboard(clipboard: Arc, image_data: Vec) -> bool { - match task::spawn_blocking(move || clipboard.copy(&image_data)) - .await - .map_err(|e| CaptureError::ClipboardError(format!("Clipboard task failed: {}", e))) + match task::spawn_blocking(move || { + super::clipboard::copy_to_clipboard_with( + &image_data, + clipboard.as_ref(), + crate::process_broker::max_publish_bytes(), + ) + }) + .await + .map_err(|e| CaptureError::ClipboardError(format!("Clipboard task failed: {}", e))) { Ok(Ok(())) => { log::info!("Successfully copied to clipboard"); diff --git a/src/capture/sources/hyprland.rs b/src/capture/sources/hyprland.rs index 7b39f5be0..c043e8bb7 100644 --- a/src/capture/sources/hyprland.rs +++ b/src/capture/sources/hyprland.rs @@ -75,7 +75,7 @@ pub async fn capture_active_window_hyprland() -> Result, CaptureError> { // Query Hyprland for the active window geometry let output = run_helper( - HelperKind::Hyprctl, + HelperKind::HyprctlActiveWindow, "hyprctl", &["activewindow", "-j"], Duration::from_secs(5), @@ -255,7 +255,7 @@ fn hyprland_monitor_scale( } let output = run_helper( - HelperKind::Hyprctl, + HelperKind::HyprctlActiveWindow, "hyprctl", &["monitors", "-j"], Duration::from_secs(5), diff --git a/src/capture/tests/fixtures.rs b/src/capture/tests/fixtures.rs index a90703dfa..c15e5e523 100644 --- a/src/capture/tests/fixtures.rs +++ b/src/capture/tests/fixtures.rs @@ -71,6 +71,10 @@ impl CaptureClipboard for MockClipboard { Ok(()) } } + + fn verify(&self, _expected: &[u8], _output_cap: usize) -> Result { + Ok(true) + } } pub(super) fn create_placeholder_image() -> Vec { diff --git a/src/capture/tests/manager.rs b/src/capture/tests/manager.rs index 6b34824d4..7ddb1574a 100644 --- a/src/capture/tests/manager.rs +++ b/src/capture/tests/manager.rs @@ -11,7 +11,8 @@ use tokio::time::{Duration, sleep}; use crate::capture::{ DesktopBackdropCaptureRequest, DesktopBackdropGeometry, DocumentDeliveryRequest, - ImageDeliveryRequest, ImageFormatMetadata, ImageOperationKind, RenderedDocument, RenderedImage, + ImageDeliveryRequest, ImageFormatMetadata, ImageOperationKind, RenderImageRequest, + RenderedDocument, RenderedImage, dependencies::{CaptureDependencies, CaptureFuture, CaptureSource}, file::FileSaveConfig, manager::{CaptureManager, CapturePoll, CaptureSubmitError}, @@ -724,3 +725,59 @@ async fn request_image_delivery_preserves_clipboard_success_when_file_fails() { assert_eq!(*clipboard_calls.lock().unwrap(), 1); assert!(captured_types.lock().unwrap().is_empty()); } + +#[tokio::test] +async fn request_render_image_returns_the_rendered_image_without_delivery() { + let mut manager = CaptureManager::new(&tokio::runtime::Handle::current()); + + manager + .request_render_image(RenderImageRequest { + render: Box::new(|| Ok(rendered_png(vec![7, 8, 9]))), + operation: ImageOperationKind::Screenshot, + }) + .unwrap(); + + match wait_for_manager_outcome(&mut manager).await { + Some(CaptureOutcome::RenderedImageReady(image)) => { + assert_eq!(image.bytes, vec![7, 8, 9]); + assert_eq!(image.format, ImageFormatMetadata::png()); + assert_eq!((image.width, image.height), (1, 1)); + } + other => panic!("Expected rendered-image outcome, got {other:?}"), + } +} + +#[tokio::test] +async fn request_render_image_reports_render_failure_and_keeps_the_manager_healthy() { + let mut manager = CaptureManager::new(&tokio::runtime::Handle::current()); + + manager + .request_render_image(RenderImageRequest { + render: Box::new(|| { + Err(CaptureError::ImageError( + "generic render failed".to_string(), + )) + }), + operation: ImageOperationKind::Screenshot, + }) + .unwrap(); + + assert!(matches!( + wait_for_manager_outcome(&mut manager).await, + Some(CaptureOutcome::Failed { + operation: ImageOperationKind::Screenshot, + message, + }) if message.contains("generic render failed") + )); + + manager + .request_render_image(RenderImageRequest { + render: Box::new(|| Ok(rendered_png(vec![4, 5, 6]))), + operation: ImageOperationKind::Screenshot, + }) + .expect("a render failure must not poison the manager"); + assert!(matches!( + wait_for_manager_outcome(&mut manager).await, + Some(CaptureOutcome::RenderedImageReady(image)) if image.bytes == vec![4, 5, 6] + )); +} diff --git a/src/capture/tests/mod.rs b/src/capture/tests/mod.rs index 237661248..352b2fc9e 100644 --- a/src/capture/tests/mod.rs +++ b/src/capture/tests/mod.rs @@ -3,3 +3,4 @@ mod fixtures; mod manager; mod perform_capture; mod placeholder; +mod window_geometry; diff --git a/src/capture/tests/perform_capture.rs b/src/capture/tests/perform_capture.rs index 21387706b..070e5dc91 100644 --- a/src/capture/tests/perform_capture.rs +++ b/src/capture/tests/perform_capture.rs @@ -57,6 +57,10 @@ impl CaptureClipboard for RecordingClipboard { Ok(()) } } + + fn verify(&self, _expected: &[u8], _output_cap: usize) -> Result { + Ok(true) + } } fn rendered_png(bytes: Vec) -> RenderedImage { @@ -503,7 +507,7 @@ async fn test_perform_capture_clipboard_failure() { .await .unwrap(); assert!(!result.copied_to_clipboard); - assert_eq!(*clipboard_handle.calls.lock().unwrap(), 1); + assert_eq!(*clipboard_handle.calls.lock().unwrap(), 2); } #[tokio::test] diff --git a/src/capture/tests/window_geometry.rs b/src/capture/tests/window_geometry.rs new file mode 100644 index 000000000..34c1e8552 --- /dev/null +++ b/src/capture/tests/window_geometry.rs @@ -0,0 +1,331 @@ +use std::ffi::OsStr; + +use crate::capture::window_geometry::{ + WindowGeometryBackend, WindowQueryContext, WindowTarget, detect_backend_from_env, + parse_hyprland_targets, parse_sway_targets, +}; +use crate::util::Rect; + +fn context(name: &str, rect: Rect) -> WindowQueryContext { + WindowQueryContext { + output_name: name.to_owned(), + output_logical_rect: rect, + } +} + +fn assert_target(target: &WindowTarget, id: &str, title: &str, rect: Rect) { + assert_eq!(target.id, id); + assert_eq!(target.title, title); + assert_eq!(target.logical_rect, rect); +} + +#[test] +fn environment_detection_prefers_hyprland_then_sway() { + assert_eq!( + detect_backend_from_env(Some(OsStr::new("hypr")), Some(OsStr::new("sway"))), + Some(WindowGeometryBackend::Hyprland) + ); + assert_eq!( + detect_backend_from_env(None, Some(OsStr::new("sway"))), + Some(WindowGeometryBackend::Sway) + ); + assert_eq!(detect_backend_from_env(None, None), None); +} + +#[test] +fn empty_environment_markers_do_not_advertise_a_provider() { + assert_eq!( + detect_backend_from_env(Some(OsStr::new("")), Some(OsStr::new(""))), + None + ); +} + +#[test] +fn hyprland_keeps_visible_current_workspace_windows_and_clips_to_output() { + let monitors = br#"[ + {"id":2,"name":"DP-1","x":1920,"y":-100,"width":2400,"height":1920, + "scale":1.5,"transform":0,"activeWorkspace":{"id":4,"name":"dev"}} + ]"#; + let clients = br#"[ + {"address":"0xa","title":"Editor","mapped":true,"hidden":false, + "at":[2000,-50],"size":[1000,700],"monitor":2, + "workspace":{"id":4,"name":"dev"}}, + {"address":"0xb","title":"Clipped","mapped":true,"hidden":false, + "at":[3400,0],"size":[300,500],"monitor":2, + "workspace":{"id":4,"name":"dev"}}, + {"address":"0xc","title":"Other workspace","mapped":true,"hidden":false, + "at":[2000,0],"size":[100,100],"monitor":2, + "workspace":{"id":5,"name":"chat"}}, + {"address":"0xd","title":"Hidden","mapped":true,"hidden":true, + "at":[2000,0],"size":[100,100],"monitor":2, + "workspace":{"id":4,"name":"dev"}}, + {"address":"0xe","title":"Unmapped","mapped":false,"hidden":false, + "at":[2000,0],"size":[100,100],"monitor":2, + "workspace":{"id":4,"name":"dev"}}, + {"address":"0xf","title":"Other output","mapped":true,"hidden":false, + "at":[0,0],"size":[100,100],"monitor":1, + "workspace":{"id":4,"name":"dev"}} + ]"#; + let targets = parse_hyprland_targets( + monitors, + clients, + &context("DP-1", Rect::new(1920, -100, 1600, 1280).unwrap()), + ) + .unwrap(); + + assert_eq!(targets.len(), 2); + assert_target( + &targets[0], + "0xa", + "Editor", + Rect::new(80, 50, 1000, 700).unwrap(), + ); + assert_target( + &targets[1], + "0xb", + "Clipped", + Rect::new(1480, 100, 120, 500).unwrap(), + ); +} + +#[test] +fn hyprland_converts_rotated_fractional_geometry_with_outward_window_edges() { + let monitors = br#"[ + {"id":9,"name":"DP-2","x":-1080,"y":200,"width":2160,"height":3840, + "scale":2.0,"transform":1,"activeWorkspace":{"id":7,"name":"7"}} + ]"#; + let clients = br#"[ + {"address":"0x9","title":"Fractional","mapped":true,"hidden":false, + "at":[-1079.75,200.25],"size":[10.5,20.1],"monitor":9, + "workspace":{"id":7,"name":"7"}} + ]"#; + let targets = parse_hyprland_targets( + monitors, + clients, + &context("DP-2", Rect::new(-1080, 200, 1920, 1080).unwrap()), + ) + .unwrap(); + + assert_eq!(targets.len(), 1); + assert_target( + &targets[0], + "0x9", + "Fractional", + Rect::new(0, 0, 11, 21).unwrap(), + ); +} + +#[test] +fn hyprland_uses_visible_special_workspace_and_keeps_pinned_windows() { + let monitors = br#"[ + {"id":1,"name":"eDP-1","x":0,"y":0,"width":1920,"height":1080, + "scale":1.0,"transform":0,"activeWorkspace":{"id":1,"name":"1"}, + "specialWorkspace":{"id":-99,"name":"special:scratch"}} + ]"#; + let clients = br#"[ + {"address":"scratch","title":"Scratch","mapped":true,"hidden":false, + "at":[10,10],"size":[200,100],"monitor":1, + "workspace":{"id":-99,"name":"special:scratch"}}, + {"address":"pinned","title":"Pinned","mapped":true,"hidden":false, + "at":[30,30],"size":[200,100],"monitor":1,"pinned":true, + "workspace":{"id":1,"name":"1"}}, + {"address":"under","title":"Underlying","mapped":true,"hidden":false, + "at":[50,50],"size":[200,100],"monitor":1, + "workspace":{"id":1,"name":"1"}} + ]"#; + let targets = parse_hyprland_targets( + monitors, + clients, + &context("eDP-1", Rect::new(0, 0, 1920, 1080).unwrap()), + ) + .unwrap(); + + assert_eq!( + targets + .iter() + .map(|target| target.id.as_str()) + .collect::>(), + ["scratch", "pinned"] + ); +} + +#[test] +fn hyprland_correlates_exact_origin_with_one_pixel_size_tolerance() { + let monitors = br#"[ + {"id":1,"name":"DP-1","x":0,"y":0,"width":1920,"height":1080, + "scale":1.0,"transform":0,"activeWorkspace":{"id":1,"name":"1"}} + ]"#; + let clients = br#"[ + {"address":"edge","title":"Edge","mapped":true,"hidden":false, + "at":[1919,0],"size":[2,100],"monitor":1, + "workspace":{"id":1,"name":"1"}} + ]"#; + + let smaller = parse_hyprland_targets( + monitors, + clients, + &context("DP-1", Rect::new(0, 0, 1919, 1080).unwrap()), + ) + .unwrap(); + assert!(smaller.is_empty(), "only the correlated overlap is safe"); + let larger = parse_hyprland_targets( + monitors, + clients, + &context("DP-1", Rect::new(0, 0, 1921, 1080).unwrap()), + ) + .unwrap(); + assert_eq!(larger[0].logical_rect, Rect::new(1919, 0, 1, 100).unwrap()); + + assert!( + parse_hyprland_targets( + monitors, + clients, + &context("DP-1", Rect::new(1, 0, 1920, 1080).unwrap()), + ) + .is_err(), + "origins must match exactly" + ); + assert!( + parse_hyprland_targets( + monitors, + clients, + &context("DP-1", Rect::new(0, 0, 1918, 1080).unwrap()), + ) + .is_err(), + "size differences above one pixel are stale" + ); +} + +#[test] +fn hyprland_rejects_invalid_monitor_scale() { + let monitors = br#"[ + {"id":1,"name":"DP-1","x":0,"y":0,"width":1920,"height":1080, + "scale":0.0,"transform":0,"activeWorkspace":{"id":1,"name":"1"}} + ]"#; + assert!( + parse_hyprland_targets( + monitors, + br#"[]"#, + &context("DP-1", Rect::new(0, 0, 1920, 1080).unwrap()), + ) + .is_err() + ); +} + +#[test] +fn sway_keeps_visible_tiled_and_floating_leaves_and_clips_to_output() { + let tree = br#"{ + "id":1,"type":"root","name":"root","visible":true, + "rect":{"x":0,"y":0,"width":3520,"height":1280}, + "nodes":[{ + "id":2,"type":"output","name":"DP-1","visible":true, + "rect":{"x":1920,"y":-100,"width":1600,"height":1280}, + "nodes":[{ + "id":3,"type":"workspace","name":"dev","visible":true, + "rect":{"x":1920,"y":-100,"width":1600,"height":1280}, + "nodes":[ + {"id":10,"type":"con","name":"Editor","visible":true,"app_id":"editor", + "rect":{"x":2000,"y":-50,"width":1000,"height":700}}, + {"id":20,"type":"con","name":"split","visible":true, + "rect":{"x":3300,"y":0,"width":300,"height":500}, + "nodes":[{"id":11,"type":"con","name":"Terminal","visible":true,"window":44, + "rect":{"x":3400,"y":0,"width":300,"height":500}}]}, + {"id":21,"type":"con","name":"Hidden","visible":false,"app_id":"hidden", + "rect":{"x":2000,"y":0,"width":100,"height":100}} + ], + "floating_nodes":[{"id":12,"type":"floating_con","name":"Palette","visible":true, + "pid":55,"rect":{"x":2100,"y":800,"width":400,"height":200}}] + }] + }] + }"#; + let targets = parse_sway_targets( + tree, + &context("DP-1", Rect::new(1920, -100, 1600, 1280).unwrap()), + ) + .unwrap(); + + assert_eq!(targets.len(), 3); + assert_target( + &targets[0], + "10", + "Editor", + Rect::new(80, 50, 1000, 700).unwrap(), + ); + assert_target( + &targets[1], + "11", + "Terminal", + Rect::new(1480, 100, 120, 500).unwrap(), + ); + assert_target( + &targets[2], + "12", + "Palette", + Rect::new(180, 900, 400, 200).unwrap(), + ); +} + +#[test] +fn sway_walks_floating_wrappers_and_ignores_invalid_leaves() { + let tree = br#"{ + "id":1,"type":"root","visible":true,"rect":{"x":-1080,"y":0,"width":1080,"height":900}, + "nodes":[{"id":2,"type":"output","name":"DP-3","visible":true, + "rect":{"x":-1080,"y":0,"width":1080,"height":900}, + "nodes":[{"id":3,"type":"workspace","name":"4","visible":true, + "rect":{"x":-1080,"y":0,"width":1080,"height":900}, + "floating_nodes":[{"id":4,"type":"floating_con","visible":true, + "rect":{"x":-1000,"y":50,"width":500,"height":400}, + "nodes":[ + {"id":5,"type":"con","name":"Dialog","visible":true,"window_properties":{}, + "rect":{"x":-1000,"y":50,"width":500,"height":400}}, + {"id":6,"type":"con","name":"Invalid","visible":true,"app_id":"bad", + "rect":{"x":-1000,"y":50,"width":0,"height":20}} + ]}] + }] + }] + }"#; + let targets = parse_sway_targets( + tree, + &context("DP-3", Rect::new(-1080, 0, 1080, 900).unwrap()), + ) + .unwrap(); + + assert_eq!(targets.len(), 1); + assert_target( + &targets[0], + "5", + "Dialog", + Rect::new(80, 50, 500, 400).unwrap(), + ); +} + +#[test] +fn sway_correlates_exact_origin_with_one_pixel_size_tolerance() { + let tree = br#"{ + "id":1,"type":"root","visible":true,"rect":{"x":0,"y":0,"width":1920,"height":1080}, + "nodes":[{"id":2,"type":"output","name":"DP-1","visible":true, + "rect":{"x":0,"y":0,"width":1920,"height":1080}, + "nodes":[{"id":3,"type":"workspace","name":"1","visible":true, + "rect":{"x":0,"y":0,"width":1920,"height":1080}, + "nodes":[{"id":10,"type":"con","name":"Edge","visible":true,"app_id":"edge", + "rect":{"x":1919,"y":0,"width":2,"height":100}}] + }] + }] + }"#; + + let smaller = + parse_sway_targets(tree, &context("DP-1", Rect::new(0, 0, 1919, 1080).unwrap())).unwrap(); + assert!(smaller.is_empty(), "only the correlated overlap is safe"); + let larger = + parse_sway_targets(tree, &context("DP-1", Rect::new(0, 0, 1921, 1080).unwrap())).unwrap(); + assert_eq!(larger[0].logical_rect, Rect::new(1919, 0, 1, 100).unwrap()); + + assert!( + parse_sway_targets(tree, &context("DP-1", Rect::new(1, 0, 1920, 1080).unwrap()),).is_err(), + "origins must match exactly" + ); + assert!( + parse_sway_targets(tree, &context("DP-1", Rect::new(0, 0, 1918, 1080).unwrap()),).is_err(), + "size differences above one pixel are stale" + ); +} diff --git a/src/capture/types.rs b/src/capture/types.rs index 895672686..972a93644 100644 --- a/src/capture/types.rs +++ b/src/capture/types.rs @@ -150,6 +150,12 @@ pub struct ImageDeliveryRequest { /// snapshot moves to the worker; it is never shared. pub type ImageRenderJob = Box Result + Send>; +/// An image render performed by the capture worker without delivery. +pub struct RenderImageRequest { + pub render: ImageRenderJob, + pub operation: ImageOperationKind, +} + /// See [`ImageRenderJob`]. pub type DocumentRenderJob = Box Result + Send>; @@ -431,6 +437,7 @@ pub struct CaptureResult { pub enum CaptureOutcome { Success(CaptureResult), DesktopBackdropSuccess(DesktopBackdropCaptureResult), + RenderedImageReady(RenderedImage), Failed { operation: ImageOperationKind, message: String, diff --git a/src/capture/window_geometry/geometry.rs b/src/capture/window_geometry/geometry.rs new file mode 100644 index 000000000..c1c6b7641 --- /dev/null +++ b/src/capture/window_geometry/geometry.rs @@ -0,0 +1,41 @@ +use crate::util::Rect; + +pub(super) fn intersect(left: Rect, right: Rect) -> Option { + let min_x = i64::from(left.x).max(i64::from(right.x)); + let min_y = i64::from(left.y).max(i64::from(right.y)); + let max_x = (i64::from(left.x) + i64::from(left.width)) + .min(i64::from(right.x) + i64::from(right.width)); + let max_y = (i64::from(left.y) + i64::from(left.height)) + .min(i64::from(right.y) + i64::from(right.height)); + Rect::from_min_max( + i32::try_from(min_x).ok()?, + i32::try_from(min_y).ok()?, + i32::try_from(max_x).ok()?, + i32::try_from(max_y).ok()?, + ) +} + +pub(super) fn localize(rect: Rect, output: Rect) -> Option { + Rect::new( + rect.x.checked_sub(output.x)?, + rect.y.checked_sub(output.y)?, + rect.width, + rect.height, + ) +} + +/// Correlate independently reported logical output bounds. +/// +/// Wayland and compositor control APIs can integerize the same fractional +/// scale one logical pixel differently. Origins must match exactly; size may +/// differ by one pixel per axis. Only their overlap is safe for targets. +pub(super) fn correlated_output_overlap(provider: Rect, source: Rect) -> Option { + if provider.x != source.x + || provider.y != source.y + || provider.width.abs_diff(source.width) > 1 + || provider.height.abs_diff(source.height) > 1 + { + return None; + } + intersect(provider, source) +} diff --git a/src/capture/window_geometry/hyprland.rs b/src/capture/window_geometry/hyprland.rs new file mode 100644 index 000000000..f8fa6ea00 --- /dev/null +++ b/src/capture/window_geometry/hyprland.rs @@ -0,0 +1,207 @@ +use serde::Deserialize; + +use crate::process_broker::{HelperKind, ProcessBroker}; +use crate::util::Rect; + +use super::geometry::{correlated_output_overlap, intersect, localize}; +use super::query::run; +use super::{ + WindowGeometryBackend, WindowGeometryError, WindowGeometryProvider, WindowQueryContext, + WindowTarget, +}; + +const MONITORS_OUTPUT_CAP: usize = 2 * 1024 * 1024; +const WINDOWS_OUTPUT_CAP: usize = 16 * 1024 * 1024; + +pub(super) struct HyprlandProvider; + +impl WindowGeometryProvider for HyprlandProvider { + fn backend(&self) -> WindowGeometryBackend { + WindowGeometryBackend::Hyprland + } + + fn query( + &self, + broker: &ProcessBroker, + context: &WindowQueryContext, + ) -> Result, WindowGeometryError> { + let backend = self.backend(); + let monitors = run( + broker, + backend, + HelperKind::Hyprctl, + "hyprctl", + &["monitors", "-j"], + MONITORS_OUTPUT_CAP, + )?; + let clients = run( + broker, + backend, + HelperKind::Hyprctl, + "hyprctl", + &["clients", "-j"], + WINDOWS_OUTPUT_CAP, + )?; + parse_targets(&monitors, &clients, context) + .map_err(|message| WindowGeometryError::InvalidResponse { backend, message }) + } +} + +#[derive(Debug, Deserialize)] +struct Workspace { + id: i64, + name: String, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +struct Monitor { + id: i64, + name: String, + x: f64, + y: f64, + width: f64, + height: f64, + scale: f64, + transform: i32, + #[serde(default)] + disabled: bool, + active_workspace: Workspace, + #[serde(default)] + special_workspace: Option, +} + +#[derive(Debug, Deserialize)] +struct Client { + address: String, + #[serde(default)] + title: String, + mapped: bool, + #[serde(default)] + hidden: bool, + at: [f64; 2], + size: [f64; 2], + monitor: i64, + workspace: Workspace, + #[serde(default)] + pinned: bool, +} + +pub(in crate::capture) fn parse_targets( + monitors: &[u8], + clients: &[u8], + context: &WindowQueryContext, +) -> Result, String> { + let monitors: Vec = serde_json::from_slice(monitors) + .map_err(|error| format!("could not parse monitors JSON: {error}"))?; + let clients: Vec = serde_json::from_slice(clients) + .map_err(|error| format!("could not parse clients JSON: {error}"))?; + let monitor = monitors + .iter() + .find(|monitor| monitor.name == context.output_name && !monitor.disabled) + .ok_or_else(|| format!("output {:?} is not active", context.output_name))?; + let monitor_rect = monitor_logical_rect(monitor) + .ok_or_else(|| format!("output {:?} has invalid geometry", context.output_name))?; + let visible_workspace = visible_workspace(monitor); + let clip_rect = correlated_output_overlap(monitor_rect, context.output_logical_rect) + .ok_or_else(|| { + format!( + "output {:?} no longer matches the source", + context.output_name + ) + })?; + + let mut targets = Vec::new(); + for client in clients { + if !client.mapped || client.hidden || client.monitor != monitor.id { + continue; + } + let workspace_matches = client.pinned + || client.workspace.id == visible_workspace.id + || client.workspace.name == visible_workspace.name; + if !workspace_matches { + continue; + } + let Some(window_rect) = rect_from_origin_size(client.at, client.size) else { + continue; + }; + let Some(clipped) = intersect(window_rect, clip_rect) else { + continue; + }; + let Some(logical_rect) = localize(clipped, context.output_logical_rect) else { + continue; + }; + targets.push(WindowTarget { + id: client.address, + title: client.title, + logical_rect, + }); + } + Ok(targets) +} + +fn visible_workspace(monitor: &Monitor) -> &Workspace { + monitor + .special_workspace + .as_ref() + .filter(|workspace| workspace.id != 0 || !workspace.name.is_empty()) + .unwrap_or(&monitor.active_workspace) +} + +fn monitor_logical_rect(monitor: &Monitor) -> Option { + if !monitor.x.is_finite() + || !monitor.y.is_finite() + || !monitor.width.is_finite() + || !monitor.height.is_finite() + || !monitor.scale.is_finite() + || monitor.scale <= 0.0 + { + return None; + } + let (mode_width, mode_height) = if monitor.transform.rem_euclid(2) == 1 { + (monitor.height, monitor.width) + } else { + (monitor.width, monitor.height) + }; + let width = checked_round_i32(mode_width / monitor.scale)?; + let height = checked_round_i32(mode_height / monitor.scale)?; + Rect::new( + checked_round_i32(monitor.x)?, + checked_round_i32(monitor.y)?, + width, + height, + ) +} + +fn rect_from_origin_size(origin: [f64; 2], size: [f64; 2]) -> Option { + if origin + .into_iter() + .chain(size) + .any(|value| !value.is_finite()) + || size[0] <= 0.0 + || size[1] <= 0.0 + { + return None; + } + let min_x = checked_floor_i32(origin[0])?; + let min_y = checked_floor_i32(origin[1])?; + let max_x = checked_ceil_i32(origin[0] + size[0])?; + let max_y = checked_ceil_i32(origin[1] + size[1])?; + Rect::from_min_max(min_x, min_y, max_x, max_y) +} + +fn checked_round_i32(value: f64) -> Option { + checked_i32(value.round()) +} + +fn checked_floor_i32(value: f64) -> Option { + checked_i32(value.floor()) +} + +fn checked_ceil_i32(value: f64) -> Option { + checked_i32(value.ceil()) +} + +fn checked_i32(value: f64) -> Option { + (value >= f64::from(i32::MIN) && value <= f64::from(i32::MAX)).then_some(value as i32) +} diff --git a/src/capture/window_geometry/mod.rs b/src/capture/window_geometry/mod.rs new file mode 100644 index 000000000..422417281 --- /dev/null +++ b/src/capture/window_geometry/mod.rs @@ -0,0 +1,65 @@ +//! Compositor window geometry queries for the native region picker. +//! +//! Queries are synchronous because the process broker is synchronous. Callers +//! must run [`query_window_targets`] from an off-event-loop worker. + +use std::ffi::OsStr; + +use crate::process_broker::ProcessBroker; + +mod geometry; +mod hyprland; +mod query; +mod sway; +mod types; + +pub(crate) use query::query_window_targets; +pub(crate) use types::{WindowGeometryError, WindowQueryContext, WindowQueryResult, WindowTarget}; + +#[cfg(test)] +pub(in crate::capture) use hyprland::parse_targets as parse_hyprland_targets; +#[cfg(test)] +pub(in crate::capture) use sway::parse_targets as parse_sway_targets; + +/// Compositor backend used to discover windows. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum WindowGeometryBackend { + Hyprland, + Sway, +} + +/// Synchronous compositor window query executed by the picker worker. +pub(crate) trait WindowGeometryProvider { + fn backend(&self) -> WindowGeometryBackend; + + fn query( + &self, + broker: &ProcessBroker, + context: &WindowQueryContext, + ) -> Result, WindowGeometryError>; +} + +/// Detect a supported provider from already-captured environment values. +/// +/// Hyprland wins when both markers are present because nested compositor test +/// sessions can inherit the outer Sway socket. +pub(crate) fn detect_backend_from_env( + hyprland_signature: Option<&OsStr>, + sway_socket: Option<&OsStr>, +) -> Option { + let present = |value: Option<&OsStr>| value.is_some_and(|value| !value.is_empty()); + if present(hyprland_signature) { + Some(WindowGeometryBackend::Hyprland) + } else if present(sway_socket) { + Some(WindowGeometryBackend::Sway) + } else { + None + } +} + +/// Detect the current compositor from its session environment marker. +pub(crate) fn detect_backend() -> Option { + let hyprland = std::env::var_os(crate::env_vars::HYPRLAND_INSTANCE_SIGNATURE_ENV); + let sway = std::env::var_os(crate::env_vars::SWAYSOCK_ENV); + detect_backend_from_env(hyprland.as_deref(), sway.as_deref()) +} diff --git a/src/capture/window_geometry/query.rs b/src/capture/window_geometry/query.rs new file mode 100644 index 000000000..2f906343b --- /dev/null +++ b/src/capture/window_geometry/query.rs @@ -0,0 +1,79 @@ +use std::ffi::OsStr; +use std::time::Duration; + +use crate::process_broker::{HelperKind, ProcessBroker, current}; + +use super::{ + WindowGeometryBackend, WindowGeometryError, WindowGeometryProvider, WindowQueryContext, + WindowQueryResult, detect_backend, +}; + +const QUERY_TIMEOUT: Duration = Duration::from_secs(5); + +static HYPRLAND_PROVIDER: super::hyprland::HyprlandProvider = super::hyprland::HyprlandProvider; +static SWAY_PROVIDER: super::sway::SwayProvider = super::sway::SwayProvider; + +pub(crate) fn query_window_targets( + context: &WindowQueryContext, +) -> Result { + let backend = detect_backend().ok_or(WindowGeometryError::Unsupported)?; + let broker = current().map_err(|error| WindowGeometryError::Broker(format!("{error:#}")))?; + let provider = provider_for_backend(backend); + let targets = provider.query(&broker, context)?; + Ok(WindowQueryResult { + backend: provider.backend(), + targets, + }) +} + +fn provider_for_backend(backend: WindowGeometryBackend) -> &'static dyn WindowGeometryProvider { + match backend { + WindowGeometryBackend::Hyprland => &HYPRLAND_PROVIDER, + WindowGeometryBackend::Sway => &SWAY_PROVIDER, + } +} + +pub(super) fn run( + broker: &ProcessBroker, + backend: WindowGeometryBackend, + kind: HelperKind, + program: &str, + arguments: &[&str], + output_cap: usize, +) -> Result, WindowGeometryError> { + let output = broker + .run( + kind, + OsStr::new(program), + arguments.iter().map(OsStr::new), + Vec::new(), + QUERY_TIMEOUT, + output_cap, + ) + .map_err(|error| WindowGeometryError::Broker(format!("{error:#}")))?; + if output.timed_out { + return Err(WindowGeometryError::CommandFailed { + backend, + message: format!("{program} timed out"), + }); + } + if output.status != 0 { + let stderr = String::from_utf8_lossy(&output.stderr); + let detail = stderr.trim(); + return Err(WindowGeometryError::CommandFailed { + backend, + message: if detail.is_empty() { + format!("{program} exited with status {}", output.status) + } else { + detail.to_owned() + }, + }); + } + if output.stdout_limit_reached { + return Err(WindowGeometryError::CommandFailed { + backend, + message: format!("{program} response exceeded its size limit"), + }); + } + Ok(output.stdout) +} diff --git a/src/capture/window_geometry/sway.rs b/src/capture/window_geometry/sway.rs new file mode 100644 index 000000000..71c495f49 --- /dev/null +++ b/src/capture/window_geometry/sway.rs @@ -0,0 +1,156 @@ +use serde::Deserialize; + +use crate::process_broker::{HelperKind, ProcessBroker}; +use crate::util::Rect; + +use super::geometry::{correlated_output_overlap, intersect, localize}; +use super::query::run; +use super::{ + WindowGeometryBackend, WindowGeometryError, WindowGeometryProvider, WindowQueryContext, + WindowTarget, +}; + +const WINDOWS_OUTPUT_CAP: usize = 16 * 1024 * 1024; + +pub(super) struct SwayProvider; + +impl WindowGeometryProvider for SwayProvider { + fn backend(&self) -> WindowGeometryBackend { + WindowGeometryBackend::Sway + } + + fn query( + &self, + broker: &ProcessBroker, + context: &WindowQueryContext, + ) -> Result, WindowGeometryError> { + let backend = self.backend(); + let tree = run( + broker, + backend, + HelperKind::Swaymsg, + "swaymsg", + &["-t", "get_tree", "-r"], + WINDOWS_OUTPUT_CAP, + )?; + parse_targets(&tree, context) + .map_err(|message| WindowGeometryError::InvalidResponse { backend, message }) + } +} + +#[derive(Debug, Clone, Copy, Deserialize)] +struct SwayRect { + x: i32, + y: i32, + width: i32, + height: i32, +} + +impl SwayRect { + fn checked(self) -> Option { + Rect::new(self.x, self.y, self.width, self.height) + } +} + +#[derive(Debug, Deserialize)] +struct Node { + id: i64, + #[serde(rename = "type")] + kind: String, + #[serde(default)] + name: Option, + #[serde(default)] + visible: bool, + rect: SwayRect, + #[serde(default)] + nodes: Vec, + #[serde(default)] + floating_nodes: Vec, + #[serde(default)] + app_id: Option, + #[serde(default)] + window: Option, + #[serde(default)] + pid: Option, + #[serde(default)] + window_properties: Option, +} + +pub(in crate::capture) fn parse_targets( + tree: &[u8], + context: &WindowQueryContext, +) -> Result, String> { + let tree: Node = serde_json::from_slice(tree) + .map_err(|error| format!("could not parse get_tree JSON: {error}"))?; + let output = tree + .nodes + .iter() + .find(|node| { + node.kind == "output" && node.name.as_deref() == Some(context.output_name.as_str()) + }) + .ok_or_else(|| format!("output {:?} is not in the Sway tree", context.output_name))?; + let output_rect = output + .rect + .checked() + .ok_or_else(|| format!("output {:?} has invalid geometry", context.output_name))?; + let clip_rect = correlated_output_overlap(output_rect, context.output_logical_rect) + .ok_or_else(|| { + format!( + "output {:?} no longer matches the source", + context.output_name + ) + })?; + + let workspace = output + .nodes + .iter() + .find(|node| node.kind == "workspace" && node.visible); + let workspace = workspace.ok_or_else(|| { + format!( + "output {:?} has no matching visible workspace", + context.output_name + ) + })?; + + let mut targets = Vec::new(); + collect_visible_leaves(&workspace.nodes, clip_rect, context, &mut targets); + collect_visible_leaves(&workspace.floating_nodes, clip_rect, context, &mut targets); + Ok(targets) +} + +fn collect_visible_leaves( + nodes: &[Node], + clip_rect: Rect, + context: &WindowQueryContext, + targets: &mut Vec, +) { + for node in nodes { + if !node.visible { + continue; + } + let is_container = matches!(node.kind.as_str(), "con" | "floating_con"); + let is_window = node.app_id.is_some() + || node.window.is_some() + || node.pid.is_some() + || node.window_properties.is_some(); + if node.nodes.is_empty() && node.floating_nodes.is_empty() && is_container && is_window { + let Some(rect) = node.rect.checked() else { + continue; + }; + let Some(clipped) = intersect(rect, clip_rect) else { + continue; + }; + let Some(logical_rect) = localize(clipped, context.output_logical_rect) else { + continue; + }; + targets.push(WindowTarget { + id: node.id.to_string(), + title: node.name.clone().unwrap_or_default(), + logical_rect, + }); + continue; + } + collect_visible_leaves(&node.nodes, clip_rect, context, targets); + collect_visible_leaves(&node.floating_nodes, clip_rect, context, targets); + } +} diff --git a/src/capture/window_geometry/types.rs b/src/capture/window_geometry/types.rs new file mode 100644 index 000000000..979960581 --- /dev/null +++ b/src/capture/window_geometry/types.rs @@ -0,0 +1,62 @@ +use crate::util::Rect; + +use super::WindowGeometryBackend; + +/// Frozen-source output identity and compositor-global logical bounds for one query. +/// +/// Wayland exposes no portable workspace identity at the freeze boundary. The +/// provider therefore returns windows visible when its compositor query runs; +/// output identity and geometry remain correlated fail-closed to the frozen +/// source. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WindowQueryContext { + pub(crate) output_name: String, + pub(crate) output_logical_rect: Rect, +} + +/// A visible window clipped to the active output and normalized to its origin. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WindowTarget { + pub(crate) id: String, + pub(crate) title: String, + pub(crate) logical_rect: Rect, +} + +/// One complete provider query. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WindowQueryResult { + pub(crate) backend: WindowGeometryBackend, + pub(crate) targets: Vec, +} + +/// Window-provider failure classified for the picker worker boundary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum WindowGeometryError { + Unsupported, + Broker(String), + CommandFailed { + backend: WindowGeometryBackend, + message: String, + }, + InvalidResponse { + backend: WindowGeometryBackend, + message: String, + }, +} + +impl std::fmt::Display for WindowGeometryError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unsupported => formatter.write_str("window snapping is unavailable"), + Self::Broker(message) => write!(formatter, "window geometry broker failed: {message}"), + Self::CommandFailed { backend, message } => { + write!(formatter, "{backend:?} window query failed: {message}") + } + Self::InvalidResponse { backend, message } => { + write!(formatter, "invalid {backend:?} window response: {message}") + } + } + } +} + +impl std::error::Error for WindowGeometryError {} diff --git a/src/config/action_meta/entries/capture.rs b/src/config/action_meta/entries/capture.rs index b7dea6925..31c699f7f 100644 --- a/src/config/action_meta/entries/capture.rs +++ b/src/config/action_meta/entries/capture.rs @@ -7,7 +7,7 @@ pub const ENTRIES: &[ActionMeta] = &[ None, "Capture the full screen", Capture, - false, + true, false, false ), @@ -17,7 +17,7 @@ pub const ENTRIES: &[ActionMeta] = &[ None, "Capture the active window", Capture, - false, + true, true, false ), @@ -27,7 +27,7 @@ pub const ENTRIES: &[ActionMeta] = &[ None, "Capture a selection using defaults", Capture, - false, + true, true, true ), @@ -57,7 +57,7 @@ pub const ENTRIES: &[ActionMeta] = &[ None, "Selection to clipboard", Capture, - false, + true, true, false ), @@ -67,7 +67,7 @@ pub const ENTRIES: &[ActionMeta] = &[ None, "Selection to file", Capture, - false, + true, true, false ), @@ -77,7 +77,7 @@ pub const ENTRIES: &[ActionMeta] = &[ None, "Region to clipboard", Capture, - false, + true, false, false ), @@ -87,10 +87,32 @@ pub const ENTRIES: &[ActionMeta] = &[ None, "Region to file", Capture, - false, + true, false, false ), + meta!( + CaptureRegionInteractive, + "Capture Region (Interactive)", + Some("Region…"), + "Select a region, then choose how to use it", + Capture, + true, + true, + false, + &["region", "snip", "paste to board", "screenshot"] + ), + meta!( + MeasureMode, + "Measure Mode", + Some("Measure"), + "Measure a screen region without capturing it", + Capture, + true, + true, + false, + &["ruler", "dimensions", "screen size"] + ), meta!( ExportCanvasFile, "Export Canvas to File", diff --git a/src/config/action_meta/tests.rs b/src/config/action_meta/tests.rs index 956344645..afc721dc3 100644 --- a/src/config/action_meta/tests.rs +++ b/src/config/action_meta/tests.rs @@ -93,6 +93,8 @@ const HELP_ACTIONS: &[Action] = &[ Action::CaptureFileSelection, Action::CaptureActiveWindow, Action::CaptureSelection, + Action::CaptureRegionInteractive, + Action::MeasureMode, Action::OpenCaptureFolder, ]; @@ -251,8 +253,20 @@ const EXPECTED_COMMAND_PALETTE_ACTIONS: &[Action] = &[ Action::SetColorWhite, Action::SetColorBlack, Action::PickScreenColor, + Action::CaptureRegionInteractive, + Action::MeasureMode, + // Every bindable capture action is searchable here. The help overlay is + // curated for density and omits some of these, but an action a user can + // bind must be runnable without knowing its shortcut. + Action::CaptureFullScreen, + Action::CaptureActiveWindow, + Action::CaptureSelection, Action::CaptureClipboardFull, Action::CaptureFileFull, + Action::CaptureClipboardSelection, + Action::CaptureFileSelection, + Action::CaptureClipboardRegion, + Action::CaptureFileRegion, Action::ExportCanvasFile, Action::ExportCanvasClipboard, Action::ExportCanvasClipboardAndFile, @@ -477,3 +491,31 @@ fn action_display_label_strips_mode_suffix() { fn action_display_label_uses_short_label_for_ellipse_tool() { assert_eq!(action_display_label(Action::SelectEllipseTool), "Circle"); } + +#[test] +fn interactive_region_capture_metadata_matches_its_public_surfaces() { + let meta = action_meta(Action::CaptureRegionInteractive).expect("interactive capture metadata"); + + assert_eq!(meta.label, "Capture Region (Interactive)"); + assert_eq!(meta.short_label(), "Region…"); + assert_eq!( + meta.search_aliases, + ["region", "snip", "paste to board", "screenshot"] + ); + assert_eq!(meta.category, ActionCategory::Capture); + assert!(meta.in_command_palette); + assert!(meta.in_help); + assert!(!meta.in_toolbar); +} + +#[test] +fn measure_mode_metadata_exposes_a_palette_first_screen_tool() { + let meta = action_meta(Action::MeasureMode).expect("measure mode metadata"); + + assert_eq!(meta.label, "Measure Mode"); + assert_eq!(meta.short_label(), "Measure"); + assert_eq!(meta.category, ActionCategory::Capture); + assert!(meta.in_command_palette); + assert!(meta.in_help); + assert!(!meta.in_toolbar); +} diff --git a/src/config/enums.rs b/src/config/enums.rs index 9454fbab2..4f2919ad0 100644 --- a/src/config/enums.rs +++ b/src/config/enums.rs @@ -5,6 +5,18 @@ use crate::util::ConfigHexColorError; use log::warn; use serde::{Deserialize, Serialize}; +/// Region selection frontend used by screenshot actions. +#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))] +#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)] +#[serde(rename_all = "kebab-case")] +pub enum RegionPicker { + /// Draw Wayscriber's picker over a captured desktop image. + #[default] + Native, + /// Delegate region selection to the external `slurp` command. + Slurp, +} + /// Status bar position on screen. /// /// Controls where the status bar appears relative to screen edges. diff --git a/src/config/keybindings/config/map/capture.rs b/src/config/keybindings/config/map/capture.rs index 7df961f65..5ac36dc38 100644 --- a/src/config/keybindings/config/map/capture.rs +++ b/src/config/keybindings/config/map/capture.rs @@ -31,6 +31,11 @@ impl KeybindingsConfig { Action::CaptureClipboardRegion, )?; inserter.insert_all(&self.capture.capture_file_region, Action::CaptureFileRegion)?; + inserter.insert_all( + &self.capture.capture_region_interactive, + Action::CaptureRegionInteractive, + )?; + inserter.insert_all(&self.capture.measure_mode, Action::MeasureMode)?; inserter.insert_all(&self.capture.export_canvas_file, Action::ExportCanvasFile)?; inserter.insert_all( &self.capture.export_canvas_clipboard, diff --git a/src/config/keybindings/config/map/edit.rs b/src/config/keybindings/config/map/edit.rs index 62ca291b1..ee94148c6 100644 --- a/src/config/keybindings/config/map/edit.rs +++ b/src/config/keybindings/config/map/edit.rs @@ -183,6 +183,8 @@ define_action_binding_accessors! { CaptureFileSelection => capture.capture_file_selection, CaptureClipboardRegion => capture.capture_clipboard_region, CaptureFileRegion => capture.capture_file_region, + CaptureRegionInteractive => capture.capture_region_interactive, + MeasureMode => capture.measure_mode, ExportCanvasFile => capture.export_canvas_file, ExportCanvasClipboard => capture.export_canvas_clipboard, ExportCanvasClipboardAndFile => capture.export_canvas_clipboard_and_file, diff --git a/src/config/keybindings/config/types/bindings/capture.rs b/src/config/keybindings/config/types/bindings/capture.rs index 6390239b2..dc8a39348 100644 --- a/src/config/keybindings/config/types/bindings/capture.rs +++ b/src/config/keybindings/config/types/bindings/capture.rs @@ -32,6 +32,17 @@ pub struct CaptureKeybindingsConfig { #[serde(default = "default_capture_file_region")] pub capture_file_region: Vec, + /// Opens the post-selection review UI. Owns `Ctrl+Shift+C` by default, + /// taking it from `capture_clipboard_selection`, which now ships unbound: + /// Review's Copy reaches the same clipboard result from the same chord. + #[serde(default = "default_capture_region_interactive")] + pub capture_region_interactive: Vec, + + /// Opens the screen ruler without capturing pixels. Palette-first and + /// unbound by default. + #[serde(default = "default_measure_mode")] + pub measure_mode: Vec, + #[serde(default = "default_export_canvas_file")] pub export_canvas_file: Vec, @@ -68,6 +79,8 @@ impl Default for CaptureKeybindingsConfig { capture_file_selection: default_capture_file_selection(), capture_clipboard_region: default_capture_clipboard_region(), capture_file_region: default_capture_file_region(), + capture_region_interactive: default_capture_region_interactive(), + measure_mode: default_measure_mode(), export_canvas_file: default_export_canvas_file(), export_canvas_clipboard: default_export_canvas_clipboard(), export_canvas_clipboard_and_file: default_export_canvas_clipboard_and_file(), diff --git a/src/config/keybindings/defaults/capture.rs b/src/config/keybindings/defaults/capture.rs index b640603eb..b362a1601 100644 --- a/src/config/keybindings/defaults/capture.rs +++ b/src/config/keybindings/defaults/capture.rs @@ -18,8 +18,11 @@ pub(crate) fn default_capture_file_full() -> Vec { vec!["Ctrl+S".to_string()] } +/// Deliberately empty: `Ctrl+Shift+C` now opens the interactive picker, whose +/// **Copy** action (`Ctrl+C` in Review) produces the same clipboard result. +/// Bind this action explicitly to get the one-step copy back. pub(crate) fn default_capture_clipboard_selection() -> Vec { - vec!["Ctrl+Shift+C".to_string()] + Vec::new() } pub(crate) fn default_capture_file_selection() -> Vec { @@ -34,6 +37,19 @@ pub(crate) fn default_capture_file_region() -> Vec { vec!["Ctrl+Alt+6".to_string()] } +/// The region chord: interactive capture is a superset of the immediate +/// region-to-clipboard action it replaces here, since Review's **Copy** +/// reaches the same result with one more keystroke. +pub(crate) fn default_capture_region_interactive() -> Vec { + vec!["Ctrl+Shift+C".to_string()] +} + +/// Deliberately empty: measure mode is palette-first until the user chooses a +/// shortcut that fits their compositor and existing bindings. +pub(crate) fn default_measure_mode() -> Vec { + Vec::new() +} + pub(crate) fn default_export_canvas_file() -> Vec { Vec::new() } diff --git a/src/config/keybindings/tests.rs b/src/config/keybindings/tests.rs index 4a3b1185f..f7b45959e 100644 --- a/src/config/keybindings/tests.rs +++ b/src/config/keybindings/tests.rs @@ -505,6 +505,53 @@ fn screen_text_recognition_is_unbound_by_default_and_leaves_o_to_orange() { ); } +#[test] +fn interactive_region_capture_owns_the_region_chord_and_remaps_when_configured() { + let mut config = KeybindingsConfig::default(); + assert_eq!( + config.capture.capture_region_interactive, + vec!["Ctrl+Shift+C".to_string()] + ); + assert!( + config.capture.capture_clipboard_selection.is_empty(), + "the immediate region copy gives up the chord rather than colliding" + ); + assert_eq!( + config + .build_action_map() + .unwrap() + .get(&Shortcut::parse("Ctrl+Shift+C").unwrap()), + Some(&Action::CaptureRegionInteractive) + ); + + config.capture.capture_region_interactive = vec!["Ctrl+Alt+Shift+I".to_string()]; + let map = config.build_action_map().unwrap(); + assert_eq!( + map.get(&Shortcut::parse("Ctrl+Alt+Shift+I").unwrap()), + Some(&Action::CaptureRegionInteractive) + ); +} + +#[test] +fn measure_mode_is_unbound_by_default_and_maps_when_configured() { + let mut config = KeybindingsConfig::default(); + assert!(config.capture.measure_mode.is_empty()); + assert!( + !config + .build_action_map() + .unwrap() + .values() + .any(|action| *action == Action::MeasureMode) + ); + + config.capture.measure_mode = vec!["Ctrl+Alt+M".to_string()]; + let map = config.build_action_map().unwrap(); + assert_eq!( + map.get(&Shortcut::parse("Ctrl+Alt+M").unwrap()), + Some(&Action::MeasureMode) + ); +} + #[test] fn canvas_export_actions_deserialize_from_config_names() { #[derive(serde::Deserialize)] @@ -772,10 +819,12 @@ const DEFAULT_BINDING_SNAPSHOT: &[(&str, &[&str])] = &[ ("capture_selection", &["Ctrl+Shift+I"]), ("capture_clipboard_full", &["Ctrl+C"]), ("capture_file_full", &["Ctrl+S"]), - ("capture_clipboard_selection", &["Ctrl+Shift+C"]), + ("capture_clipboard_selection", &[]), ("capture_file_selection", &["Ctrl+Shift+S"]), ("capture_clipboard_region", &["Ctrl+6"]), ("capture_file_region", &["Ctrl+Alt+6"]), + ("capture_region_interactive", &["Ctrl+Shift+C"]), + ("measure_mode", &[]), ("export_canvas_file", &[]), ("export_canvas_clipboard", &[]), ("export_canvas_clipboard_and_file", &[]), diff --git a/src/config/mod.rs b/src/config/mod.rs index a061f032c..08d9a0bb3 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -38,7 +38,8 @@ pub use document::{ ConfigWriteLockTimeout, }; pub use enums::{ - RadialMenuMouseBinding, ReducedMotion, StatusPosition, UiTheme, XdgFocusLossBehavior, + RadialMenuMouseBinding, ReducedMotion, RegionPicker, StatusPosition, UiTheme, + XdgFocusLossBehavior, }; pub use field_metadata::{ PERFORMANCE_BUFFER_COUNT_MAX, PERFORMANCE_BUFFER_COUNT_MIN, PERFORMANCE_BUFFER_COUNTS, @@ -71,18 +72,18 @@ pub use types::{ PresenterModeConfig, PresenterToolBehavior, PresenterToolbarMode, PresetSlotsConfig, PresetToolSettingConfig, PresetToolStatesConfig, QUICK_COLOR_RENDER_LIMIT, QuickColorConfig, QuickColorPalette, QuickColorPaletteEntry, QuickColorSlot, QuickColorWrite, QuickColorsConfig, - RenderColorMappingConfig, RenderProfileConfig, RenderProfileExportMode, RenderProfilesConfig, - ResolvedToolbarItems, SessionCompression, SessionConfig, SessionStorageMode, SpotlightConfig, - StatusBarItem, StatusBarStyle, ToolPresetConfig, ToolbarBackendKind, ToolbarConfig, - ToolbarGroupId, ToolbarItemCategory, ToolbarItemDefinition, ToolbarItemId, - ToolbarItemOrderConfig, ToolbarItemOrderGroup, ToolbarItemSurface, ToolbarItemsConfig, - ToolbarLayoutMode, ToolbarModeOverride, ToolbarModeOverrides, ToolbarRebindModifier, - ToolbarSectionFlag, ToolbarSectionVisibility, TopDisplayMode, TrayConfig, TrayIconStyle, - UiConfig, UpdatesConfig, ZoomChipDisplay, default_quick_color_for_index, - fold_legacy_section_flags, resolve_section_visibility, section_flag_for_item, - set_section_visibility, toolbar_item_definitions, toolbar_item_ids, toolbar_item_order_group, - validate_capture_format, validate_filename_template, validate_ocr_languages, - validate_pdf_label_template, + RegionCaptureConfig, RenderColorMappingConfig, RenderProfileConfig, RenderProfileExportMode, + RenderProfilesConfig, ResolvedToolbarItems, SessionCompression, SessionConfig, + SessionStorageMode, SpotlightConfig, StatusBarItem, StatusBarStyle, ToolPresetConfig, + ToolbarBackendKind, ToolbarConfig, ToolbarGroupId, ToolbarItemCategory, ToolbarItemDefinition, + ToolbarItemId, ToolbarItemOrderConfig, ToolbarItemOrderGroup, ToolbarItemSurface, + ToolbarItemsConfig, ToolbarLayoutMode, ToolbarModeOverride, ToolbarModeOverrides, + ToolbarRebindModifier, ToolbarSectionFlag, ToolbarSectionVisibility, TopDisplayMode, + TrayConfig, TrayIconStyle, UiConfig, UpdatesConfig, ZoomChipDisplay, + default_quick_color_for_index, fold_legacy_section_flags, resolve_section_visibility, + section_flag_for_item, set_section_visibility, toolbar_item_definitions, toolbar_item_ids, + toolbar_item_order_group, validate_capture_format, validate_filename_template, + validate_ocr_languages, validate_pdf_label_template, }; #[cfg(feature = "tablet-input")] #[allow(unused_imports)] diff --git a/src/config/tests/load.rs b/src/config/tests/load.rs index 813fec6c8..57f813d14 100644 --- a/src/config/tests/load.rs +++ b/src/config/tests/load.rs @@ -38,6 +38,52 @@ fn load_parses_xdg_focus_loss_behavior_stay() { }); } +#[test] +fn region_capture_defaults_and_explicit_values_round_trip() { + let defaults: Config = toml::from_str("").expect("empty config should use defaults"); + assert_eq!(defaults.capture.region.picker, RegionPicker::Native); + assert!(defaults.capture.region.show_size_readout); + assert!(!defaults.capture.region.show_loupe); + assert!(defaults.capture.region.show_legend); + + let config: Config = toml::from_str( + "[capture.region]\npicker = 'slurp'\nshow_size_readout = false\nshow_loupe = true\nshow_legend = false\n", + ) + .expect("supported region capture settings should parse"); + assert_eq!(config.capture.region.picker, RegionPicker::Slurp); + assert!(!config.capture.region.show_size_readout); + assert!(config.capture.region.show_loupe); + assert!(!config.capture.region.show_legend); + + let serialized = toml::to_string(&config).expect("region capture settings serialize"); + let reloaded: Config = toml::from_str(&serialized).expect("serialized settings reload"); + assert_eq!(reloaded.capture.region.picker, RegionPicker::Slurp); + assert!(!reloaded.capture.region.show_size_readout); + assert!(reloaded.capture.region.show_loupe); + assert!(!reloaded.capture.region.show_legend); +} + +#[test] +fn capture_drawings_default_on_and_explicit_false_round_trips() { + let defaults: Config = toml::from_str("").expect("empty config should use defaults"); + assert!(defaults.capture.include_drawings); + + let disabled: Config = toml::from_str("[capture]\ninclude_drawings = false\n") + .expect("capture drawing preference should parse"); + assert!(!disabled.capture.include_drawings); + + let serialized = toml::to_string(&disabled).expect("capture drawing preference serializes"); + let reloaded: Config = toml::from_str(&serialized).expect("serialized preference reloads"); + assert!(!reloaded.capture.include_drawings); +} + +#[test] +fn region_capture_rejects_unknown_picker_values() { + let error = toml::from_str::("[capture.region]\npicker = 'automatic'\n") + .expect_err("unknown region picker should fail"); + assert!(error.to_string().contains("unknown variant")); +} + #[test] fn ui_theme_defaults_to_auto_and_parses_explicit_values() { let default_config: Config = toml::from_str("").expect("empty config should use defaults"); @@ -68,6 +114,20 @@ fn automatic_onboarding_hints_default_on_and_can_be_disabled() { assert!(!reloaded.ui.show_onboarding_hints); } +#[test] +fn shape_size_readout_defaults_on_and_can_be_disabled_after_reload() { + let default_config: Config = toml::from_str("").expect("empty config should use defaults"); + assert!(default_config.ui.show_shape_size_readout); + + let disabled: Config = toml::from_str("[ui]\nshow_shape_size_readout = false\n") + .expect("shape size readout preference should parse"); + assert!(!disabled.ui.show_shape_size_readout); + + let serialized = toml::to_string(&disabled).expect("config should serialize"); + let reloaded: Config = toml::from_str(&serialized).expect("round trip should parse"); + assert!(!reloaded.ui.show_shape_size_readout); +} + #[test] fn presenter_toolbar_mode_defaults_to_hidden_and_round_trips() { let default_config: Config = toml::from_str("").expect("empty config should use defaults"); diff --git a/src/config/tests/schema.rs b/src/config/tests/schema.rs index 755ce2777..4a69aca85 100644 --- a/src/config/tests/schema.rs +++ b/src/config/tests/schema.rs @@ -38,6 +38,49 @@ fn performance_metadata_paths_exist_in_json_schema() { } } +#[test] +fn region_capture_paths_exist_in_json_schema() { + let schema = Config::json_schema(); + for path in [ + "capture.region.picker", + "capture.region.show_size_readout", + "capture.region.show_loupe", + "capture.region.show_legend", + ] { + assert!( + schema_contains_path(&schema, path), + "schema missing region capture path {path}" + ); + } +} + +#[test] +fn capture_include_drawings_exists_in_json_schema() { + let schema = Config::json_schema(); + assert!( + schema_contains_path(&schema, "capture.include_drawings"), + "schema missing capture drawing preference" + ); +} + +#[test] +fn shape_size_readout_exists_in_json_schema() { + let schema = Config::json_schema(); + assert!( + schema_contains_path(&schema, "ui.show_shape_size_readout"), + "schema missing shape size readout preference" + ); +} + +#[test] +fn interactive_region_capture_keybinding_exists_in_json_schema() { + let schema = Config::json_schema(); + assert!( + schema_contains_path(&schema, "keybindings.capture_region_interactive"), + "schema missing interactive region capture keybinding" + ); +} + fn schema_contains_path(schema: &serde_json::Value, path: &str) -> bool { let mut node = schema; for segment in path.split('.') { diff --git a/src/config/tests/validate.rs b/src/config/tests/validate.rs index 80565e83b..8d5375478 100644 --- a/src/config/tests/validate.rs +++ b/src/config/tests/validate.rs @@ -988,6 +988,57 @@ fn an_omitted_default_loses_only_the_key_an_authored_list_claims() { assert!(!report.is_empty(), "the user has to be told"); } +/// The upgrade case for the `Ctrl+Shift+C` handover. A config written before +/// interactive capture took that chord spells out `capture_clipboard_selection` +/// and says nothing about `capture_region_interactive`. The authored one-step +/// copy has to survive untouched: the new default stands down and the user is +/// told, rather than a shortcut they chose being taken away by an upgrade. +#[test] +fn an_authored_region_copy_keeps_the_chord_the_new_interactive_default_wants() { + let mut config = + config_from_toml("[keybindings]\ncapture_clipboard_selection = [\"Ctrl+Shift+C\"]\n"); + assert_eq!( + config.keybindings.capture.capture_region_interactive, + ["Ctrl+Shift+C"], + "fixture depends on the interactive default owning the same chord" + ); + + let report = config.validate_and_clamp(); + + assert_eq!( + config.keybindings.capture.capture_clipboard_selection, + ["Ctrl+Shift+C"], + "the authored binding is untouched" + ); + assert!( + config + .keybindings + .capture + .capture_region_interactive + .is_empty(), + "the omitted default stands down" + ); + assert_eq!( + config + .keybindings + .build_action_map() + .unwrap() + .get(&Shortcut::parse("Ctrl+Shift+C").unwrap()), + Some(&Action::CaptureClipboardSelection) + ); + + assert!( + report.keybinding_conflicts.is_empty(), + "nothing for the user to settle: they never wrote the collision" + ); + assert_eq!(report.skipped_default_shortcuts.len(), 1); + let skipped = &report.skipped_default_shortcuts[0]; + assert_eq!(skipped.action(), Action::CaptureRegionInteractive); + assert_eq!(skipped.claimed_by(), Action::CaptureClipboardSelection); + assert_eq!(skipped.binding(), "Ctrl+Shift+C"); + assert_eq!(skipped.config_key(), Some("capture_region_interactive")); +} + /// A default that only overlaps part of an authored list keeps the rest of its /// own keys: the offer is made one key at a time, not per action. #[test] diff --git a/src/config/types/capture.rs b/src/config/types/capture.rs index 78f393906..b214c690d 100644 --- a/src/config/types/capture.rs +++ b/src/config/types/capture.rs @@ -1,5 +1,42 @@ use serde::{Deserialize, Serialize}; +use crate::config::enums::RegionPicker; + +/// Region screenshot picker configuration. +#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RegionCaptureConfig { + /// Region selection frontend. + #[serde(default)] + pub picker: RegionPicker, + + /// Show pointer coordinates and the selected export size. + #[serde(default = "default_region_show_size_readout")] + pub show_size_readout: bool, + + /// Show a magnified pixel loupe while selecting a region. + /// + /// The setting is persisted for the picker; the loupe renderer may be + /// unavailable in builds that do not yet consume it. + #[serde(default = "default_region_show_loupe")] + pub show_loupe: bool, + + /// Show the short picker hotkey legend until the first drag. + #[serde(default = "default_region_show_legend")] + pub show_legend: bool, +} + +impl Default for RegionCaptureConfig { + fn default() -> Self { + Self { + picker: RegionPicker::default(), + show_size_readout: default_region_show_size_readout(), + show_loupe: default_region_show_loupe(), + show_legend: default_region_show_legend(), + } + } +} + /// Screenshot capture configuration. /// /// Controls the behavior of screenshot capture features including file saving, @@ -27,6 +64,11 @@ pub struct CaptureConfig { #[serde(default = "default_capture_clipboard")] pub copy_to_clipboard: bool, + /// Composite the active board's committed drawings into full-screen and + /// region screenshot exports. + #[serde(default = "default_capture_include_drawings")] + pub include_drawings: bool, + /// Exit the overlay after any capture completes (forces exit for all capture types). /// When false, clipboard-only captures still auto-exit by default. #[serde(default = "default_capture_exit_after")] @@ -37,6 +79,10 @@ pub struct CaptureConfig { /// Tesseract language packages must be installed. #[serde(default = "default_capture_ocr_languages")] pub ocr_languages: String, + + /// Region screenshot picker behavior and chrome. + #[serde(default)] + pub region: RegionCaptureConfig, } impl Default for CaptureConfig { @@ -47,8 +93,10 @@ impl Default for CaptureConfig { filename_template: default_capture_filename(), format: default_capture_format(), copy_to_clipboard: default_capture_clipboard(), + include_drawings: default_capture_include_drawings(), exit_after_capture: default_capture_exit_after(), ocr_languages: default_capture_ocr_languages(), + region: RegionCaptureConfig::default(), } } } @@ -150,10 +198,26 @@ fn default_capture_clipboard() -> bool { true } +fn default_capture_include_drawings() -> bool { + true +} + fn default_capture_exit_after() -> bool { false } +fn default_region_show_size_readout() -> bool { + true +} + +fn default_region_show_loupe() -> bool { + false +} + +fn default_region_show_legend() -> bool { + true +} + #[cfg(test)] mod tests { use super::*; @@ -164,6 +228,16 @@ mod tests { assert_eq!(CaptureConfig::default().resolved_ocr_languages(), "eng"); } + #[test] + fn region_picker_defaults_match_the_documented_native_experience() { + let region = RegionCaptureConfig::default(); + + assert_eq!(region.picker, RegionPicker::Native); + assert!(region.show_size_readout); + assert!(!region.show_loupe); + assert!(region.show_legend); + } + #[test] fn plus_separated_languages_round_trip_after_trimming() { assert_eq!(validate_ocr_languages(" eng+deu \n").unwrap(), "eng+deu"); diff --git a/src/config/types/mod.rs b/src/config/types/mod.rs index c270cd758..31e816f5b 100644 --- a/src/config/types/mod.rs +++ b/src/config/types/mod.rs @@ -29,8 +29,8 @@ pub use arrow::ArrowConfig; pub use board::BoardConfig; pub use boards::{BoardBackgroundConfig, BoardColorConfig, BoardItemConfig, BoardsConfig}; pub use capture::{ - CaptureConfig, DEFAULT_OCR_LANGUAGES, validate_capture_format, validate_filename_template, - validate_ocr_languages, + CaptureConfig, DEFAULT_OCR_LANGUAGES, RegionCaptureConfig, validate_capture_format, + validate_filename_template, validate_ocr_languages, }; pub use click_highlight::ClickHighlightConfig; pub use context_menu::ContextMenuUiConfig; diff --git a/src/config/types/ui.rs b/src/config/types/ui.rs index 253e1645a..daf314b69 100644 --- a/src/config/types/ui.rs +++ b/src/config/types/ui.rs @@ -121,6 +121,10 @@ pub struct UiConfig { #[serde(default = "default_show_onboarding_hints")] pub show_onboarding_hints: bool, + /// Show rectangle and ellipse preview dimensions in logical board pixels. + #[serde(default = "default_show_shape_size_readout")] + pub show_shape_size_readout: bool, + /// Preferred output name for the xdg-shell fallback overlay (GNOME). /// Falls back to last entered output or first available. #[serde(default)] @@ -199,6 +203,7 @@ impl Default for UiConfig { help_overlay_context_filter: default_help_overlay_context_filter(), show_capabilities_warning: default_show_capabilities_warning(), show_onboarding_hints: default_show_onboarding_hints(), + show_shape_size_readout: default_show_shape_size_readout(), preferred_output: None, multi_monitor_enabled: default_multi_monitor_enabled(), active_output_badge: default_active_output_badge(), @@ -321,6 +326,10 @@ fn default_show_onboarding_hints() -> bool { true } +fn default_show_shape_size_readout() -> bool { + true +} + fn default_command_palette_toast_duration_ms() -> u64 { 1500 } diff --git a/src/configurator_destination.rs b/src/configurator_destination.rs index a900d647b..ce2bbc661 100644 --- a/src/configurator_destination.rs +++ b/src/configurator_destination.rs @@ -205,6 +205,8 @@ pub fn keybindings_section_for_action(action: Action) -> Option bool { + matches!( + self, + Self::CaptureSelection + | Self::CaptureClipboardSelection + | Self::CaptureFileSelection + | Self::CaptureClipboardRegion + | Self::CaptureFileRegion + | Self::CaptureRegionInteractive + ) + } +} diff --git a/src/domain/tests.rs b/src/domain/tests.rs index f67779956..002f7a09e 100644 --- a/src/domain/tests.rs +++ b/src/domain/tests.rs @@ -199,6 +199,11 @@ fn action_serialization_matches_established_contract() { (Action::CaptureFileSelection, "capture_file_selection"), (Action::CaptureClipboardRegion, "capture_clipboard_region"), (Action::CaptureFileRegion, "capture_file_region"), + ( + Action::CaptureRegionInteractive, + "capture_region_interactive", + ), + (Action::MeasureMode, "measure_mode"), (Action::ExportCanvasFile, "export_canvas_file"), (Action::ExportCanvasClipboard, "export_canvas_clipboard"), ( @@ -390,3 +395,27 @@ fn production_domain_sources_have_no_upward_crate_dependencies() { "architecture test must cover every domain source" ); } + +#[test] +fn region_capture_action_classification_is_complete_and_narrow() { + for action in [ + Action::CaptureSelection, + Action::CaptureClipboardSelection, + Action::CaptureFileSelection, + Action::CaptureClipboardRegion, + Action::CaptureFileRegion, + Action::CaptureRegionInteractive, + ] { + assert!(action.is_region_capture(), "action={action:?}"); + } + for action in [ + Action::CaptureFullScreen, + Action::CaptureActiveWindow, + Action::CaptureClipboardFull, + Action::CaptureFileFull, + Action::CopyTextFromScreen, + Action::MeasureMode, + ] { + assert!(!action.is_region_capture(), "action={action:?}"); + } +} diff --git a/src/draw/render/blur.rs b/src/draw/render/blur.rs index b639aae38..a2b32fe2f 100644 --- a/src/draw/render/blur.rs +++ b/src/draw/render/blur.rs @@ -534,10 +534,12 @@ pub fn render_blur_rect( let scale_y = replay_ctx.logical_to_image_scale_y.max(f64::MIN_POSITIVE); let recipe = blur_recipe(strength, style); - let src_x = ((left * scale_x).floor() as i32).saturating_sub(recipe.padding_px); - let src_y = ((top * scale_y).floor() as i32).saturating_sub(recipe.padding_px); - let src_x2 = ((left + width) * scale_x).ceil() as i32 + recipe.padding_px; - let src_y2 = ((top + height) * scale_y).ceil() as i32 + recipe.padding_px; + let origin_x = replay_ctx.logical_image_origin_x; + let origin_y = replay_ctx.logical_image_origin_y; + let src_x = (((left - origin_x) * scale_x).floor() as i32).saturating_sub(recipe.padding_px); + let src_y = (((top - origin_y) * scale_y).floor() as i32).saturating_sub(recipe.padding_px); + let src_x2 = ((left + width - origin_x) * scale_x).ceil() as i32 + recipe.padding_px; + let src_y2 = ((top + height - origin_y) * scale_y).ceil() as i32 + recipe.padding_px; let src_x = src_x.clamp(0, surface.width().saturating_sub(1)); let src_y = src_y.clamp(0, surface.height().saturating_sub(1)); @@ -556,8 +558,8 @@ pub fn render_blur_rect( }; let overlay_palette = blur_overlay_palette(blurred.stats, recipe.overlay_alpha); - let dest_x = src_x as f64 / scale_x; - let dest_y = src_y as f64 / scale_y; + let dest_x = origin_x + src_x as f64 / scale_x; + let dest_y = origin_y + src_y as f64 / scale_y; let dest_w = src_w as f64 / scale_x; let dest_h = src_h as f64 / scale_y; diff --git a/src/draw/render/blur/style_tests.rs b/src/draw/render/blur/style_tests.rs index 52a7198f1..c3a796d3e 100644 --- a/src/draw/render/blur/style_tests.rs +++ b/src/draw/render/blur/style_tests.rs @@ -21,6 +21,8 @@ fn no_backdrop<'a>() -> EraserReplayContext<'a> { bg_color: None, logical_to_image_scale_x: 1.0, logical_to_image_scale_y: 1.0, + logical_image_origin_x: 0.0, + logical_image_origin_y: 0.0, } } diff --git a/src/draw/render/types.rs b/src/draw/render/types.rs index 36e5ecfc9..811899942 100644 --- a/src/draw/render/types.rs +++ b/src/draw/render/types.rs @@ -14,4 +14,8 @@ pub struct EraserReplayContext<'a> { pub logical_to_image_scale_x: f64, /// Vertical scale from logical canvas coordinates to captured image pixels. pub logical_to_image_scale_y: f64, + /// Logical canvas coordinate represented by captured-image pixel edge zero. + pub logical_image_origin_x: f64, + /// Logical canvas coordinate represented by captured-image pixel edge zero. + pub logical_image_origin_y: f64, } diff --git a/src/input/state/actions/action_capture_zoom.rs b/src/input/state/actions/action_capture_zoom.rs index 43aa88c40..4db260e99 100644 --- a/src/input/state/actions/action_capture_zoom.rs +++ b/src/input/state/actions/action_capture_zoom.rs @@ -1,11 +1,17 @@ use crate::domain::Action; -use crate::input::state::{Toast, ToastPriority}; +use crate::input::state::{RegionPurposeTag, Toast, ToastPriority}; use crate::input::{OutputFocusAction, ZoomAction}; use super::super::{InputState, PendingBackendAction}; impl InputState { pub(in crate::input::state) fn handle_capture_zoom_action(&mut self, action: Action) -> bool { + if action == Action::MeasureMode { + if !self.refuse_measure_mode_while_screen_modal_engaged(action) { + self.set_pending_backend_action(PendingBackendAction::MeasureMode); + } + return true; + } if self.refuse_region_capture_while_screen_modal_engaged(action) { return true; } @@ -18,16 +24,20 @@ impl InputState { | Action::CaptureClipboardSelection | Action::CaptureFileSelection | Action::CaptureClipboardRegion - | Action::CaptureFileRegion => { + | Action::CaptureFileRegion + | Action::CaptureRegionInteractive => { // Capture actions are handled externally by WaylandState // since they require access to CaptureManager // Store the action for later retrieval log::debug!("Capture action {:?} pending for backend", action); self.set_pending_backend_action(PendingBackendAction::Screenshot(action)); - // Clear modifiers to prevent them from being "stuck" after capture - // (portal dialog causes key releases to be missed or focus to flicker) - self.reset_modifiers(); + // Native region selection needs the compositor-synced Shift + // state when it arms. Focus loss still resets modifiers for + // slurp/portal handoffs, where releases may be missed. + if !action.is_region_capture() { + self.reset_modifiers(); + } true } Action::ExportCanvasFile @@ -110,16 +120,16 @@ impl InputState { &mut self, action: Action, ) -> bool { - if !self.screen_modal_is_engaged() - || !matches!( - action, - Action::CaptureSelection - | Action::CaptureClipboardSelection - | Action::CaptureFileSelection - | Action::CaptureClipboardRegion - | Action::CaptureFileRegion - ) + if !self.screen_modal_is_engaged() || !action.is_region_capture() { + return false; + } + if self + .region_state() + .purpose() + .is_some_and(RegionPurposeTag::is_capture) { + // Capture-owned selectors pass their immutable intent to the + // backend, which decides same-opener cancellation versus refusal. return false; } self.push_toast( @@ -129,6 +139,31 @@ impl InputState { ); true } + + pub(crate) fn capture_region_action_reaches_backend(&self, action: Action) -> bool { + self.region_state() + .purpose() + .is_some_and(RegionPurposeTag::is_capture) + && action.is_region_capture() + } + + pub(crate) fn refuse_measure_mode_while_screen_modal_engaged( + &mut self, + action: Action, + ) -> bool { + if action != Action::MeasureMode + || !self.screen_modal_is_engaged() + || self.region_state().purpose() == Some(RegionPurposeTag::Measure) + { + return false; + } + self.push_toast( + ToastPriority::Info, + "measure.refused", + Toast::info("Finish or cancel the current screen selection first."), + ); + true + } } #[cfg(test)] @@ -145,6 +180,7 @@ mod tests { Action::CaptureFileSelection, Action::CaptureClipboardRegion, Action::CaptureFileRegion, + Action::CaptureRegionInteractive, ] { for modal in ["ocr", "eyedropper"] { let mut state = make_test_input_state(); @@ -171,4 +207,52 @@ mod tests { } } } + + #[test] + fn region_actions_reach_the_backend_when_a_capture_selector_owns_the_modal() { + let mut state = make_test_input_state(); + state.set_region_pending_capture( + RegionPurposeTag::CaptureDeliver, + 1, + ScreenCaptureSource::Frozen, + ); + + assert!(state.handle_capture_zoom_action(Action::CaptureClipboardSelection)); + + assert_eq!( + state.take_pending_backend_action(), + Some(PendingBackendAction::Screenshot( + Action::CaptureClipboardSelection + )) + ); + assert_eq!(state.test_toast_count(), 0); + } + + #[test] + fn measure_mode_requests_its_non_capture_backend_path() { + let mut state = make_test_input_state(); + + assert!(state.handle_capture_zoom_action(Action::MeasureMode)); + assert_eq!(state.region_state().purpose(), None); + assert_eq!( + state.take_pending_backend_action(), + Some(PendingBackendAction::MeasureMode) + ); + } + + #[test] + fn measure_mode_refuses_to_replace_another_screen_modal() { + let mut state = make_test_input_state(); + state.set_region_pending_capture(RegionPurposeTag::Ocr, 1, ScreenCaptureSource::Frozen); + + assert!(state.handle_capture_zoom_action(Action::MeasureMode)); + + assert_eq!(state.region_state().purpose(), Some(RegionPurposeTag::Ocr)); + assert_eq!(state.test_toast_count(), 1); + assert_eq!( + state.test_active_toast_message(), + Some("Finish or cancel the current screen selection first.") + ); + assert!(state.take_pending_backend_action().is_none()); + } } diff --git a/src/input/state/core/base/types.rs b/src/input/state/core/base/types.rs index 176d4396b..29b47d913 100644 --- a/src/input/state/core/base/types.rs +++ b/src/input/state/core/base/types.rs @@ -500,6 +500,7 @@ pub(crate) struct PendingClipboardFallback { #[derive(Debug, Clone, PartialEq, Eq)] pub enum PendingBackendAction { Screenshot(Action), + MeasureMode, CanvasExport(Action), BoardPdfExport(Action), DesktopOpen(crate::desktop_open::DesktopOpenRequest), diff --git a/src/input/state/core/captured_image.rs b/src/input/state/core/captured_image.rs new file mode 100644 index 000000000..718d53264 --- /dev/null +++ b/src/input/state/core/captured_image.rs @@ -0,0 +1,195 @@ +use super::InputState; +use crate::draw::frame::UndoAction; +use crate::draw::{EmbeddedImage, Shape}; +use crate::screen_pixels::EmbeddedImageLimits; +use crate::util::Rect; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct BoardPasteTarget { + pub board_id: String, + pub page_index: usize, + pub page_generation: u64, + pub world_bounds: Rect, +} + +impl InputState { + pub(crate) fn insert_captured_image( + &mut self, + image: EmbeddedImage, + target: &BoardPasteTarget, + ) -> bool { + let limits = EmbeddedImageLimits::default(); + if !limits.allows_bytes(image.bytes.len()) { + self.push_toast( + super::ToastPriority::Info, + "capture.region.board", + super::Toast::warning("Region is too large to add to the board."), + ); + return false; + } + + let target_active = self.boards.active_board_id() == target.board_id + && self.boards.active_page_index() == target.page_index + && self.boards.active_page_generation() == target.page_generation; + if !target_active { + self.push_toast( + super::ToastPriority::Info, + "capture.region.board", + super::Toast::warning("Paste target changed; region was not added."), + ); + return false; + } + let max_shapes = self.max_shapes_per_frame; + let undo_limit = self.undo_stack_limit; + let Some(frame) = self + .boards + .board_state_by_id_mut(&target.board_id) + .filter(|board| board.pages.generation() == target.page_generation) + .and_then(|board| board.pages.frame_mut(target.page_index)) + else { + self.push_toast( + super::ToastPriority::Info, + "capture.region.board", + super::Toast::warning("Paste target changed; region was not added."), + ); + return false; + }; + + let shape = Shape::Image { + x: target.world_bounds.x, + y: target.world_bounds.y, + w: target.world_bounds.width, + h: target.world_bounds.height, + data: image, + }; + let Some(id) = frame.try_add_shape_with_id(shape, max_shapes) else { + self.push_toast( + super::ToastPriority::Info, + "capture.region.board", + super::Toast::warning("Shape limit reached; region was not added."), + ); + return false; + }; + let Some((index, stored)) = frame + .find_index(id) + .and_then(|index| frame.shape(id).map(|shape| (index, shape.clone()))) + else { + return false; + }; + let bounds = stored.bounding_box(); + frame.push_undo_action( + UndoAction::Create { + shapes: vec![(index, stored)], + }, + undo_limit, + ); + self.mark_session_dirty(); + if target_active { + self.mark_selection_dirty_region(bounds); + self.invalidate_hit_cache_for(id); + self.set_selection(vec![id]); + } + self.needs_redraw = true; + self.push_toast( + super::ToastPriority::Info, + "capture.region.board", + super::Toast::info("Region added to board."), + ); + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::input::state::test_support::make_test_input_state; + + fn image(bytes: usize) -> EmbeddedImage { + EmbeddedImage { + mime_type: "image/png".to_string(), + width: 10, + height: 8, + bytes: vec![0; bytes], + } + } + + fn target(state: &InputState) -> BoardPasteTarget { + BoardPasteTarget { + board_id: state.boards.active_board_id().to_string(), + page_index: state.boards.active_page_index(), + page_generation: state.boards.active_page_generation(), + world_bounds: Rect::new(12, 23, 40, 32).unwrap(), + } + } + + #[test] + fn capture_image_adds_one_selected_shape_and_one_undo_entry() { + let mut state = make_test_input_state(); + let target = target(&state); + + assert!(state.insert_captured_image(image(16), &target)); + + let frame = state.boards.active_frame(); + assert_eq!(frame.shapes.len(), 1); + assert_eq!(frame.undo_stack_len(), 1); + assert_eq!(state.selected_shape_ids().len(), 1); + assert!(matches!( + &frame.shapes[0].shape, + Shape::Image { + x: 12, + y: 23, + w: 40, + h: 32, + .. + } + )); + } + + #[test] + fn capture_image_rejects_a_stale_page_generation_without_mutation() { + let mut state = make_test_input_state(); + let mut target = target(&state); + target.page_generation = target.page_generation.wrapping_add(1); + + assert!(!state.insert_captured_image(image(16), &target)); + assert!(state.boards.active_frame().shapes.is_empty()); + assert_eq!( + state.test_active_toast_message(), + Some("Paste target changed; region was not added.") + ); + } + + #[test] + fn capture_image_rejects_a_target_that_is_no_longer_active() { + let mut state = make_test_input_state(); + let target = target(&state); + assert!(state.boards.next_board(), "test config has another board"); + + assert!(!state.insert_captured_image(image(16), &target)); + assert!(state.boards.board_states().iter().all(|board| { + board + .pages + .pages() + .iter() + .all(|frame| frame.shapes.is_empty()) + })); + assert_eq!( + state.test_active_toast_message(), + Some("Paste target changed; region was not added.") + ); + } + + #[test] + fn capture_image_rechecks_encoded_byte_limit_before_insertion() { + let mut state = make_test_input_state(); + let target = target(&state); + let too_large = EmbeddedImageLimits::default().max_bytes() + 1; + + assert!(!state.insert_captured_image(image(too_large), &target)); + assert!(state.boards.active_frame().shapes.is_empty()); + assert_eq!( + state.test_active_toast_message(), + Some("Region is too large to add to the board.") + ); + } +} diff --git a/src/input/state/core/mod.rs b/src/input/state/core/mod.rs index f126c48c4..3d4ca2e08 100644 --- a/src/input/state/core/mod.rs +++ b/src/input/state/core/mod.rs @@ -1,6 +1,7 @@ mod base; mod board; pub(crate) mod board_picker; +mod captured_image; pub(crate) mod color_picker_popup; mod command_palette; mod dirty; @@ -45,6 +46,7 @@ pub(crate) use base::{ }; pub(crate) use base::{KeybindingEditOperation, KeybindingEditRequest}; pub use board_picker::{BoardPickerCursorHint, BoardPickerLayout}; +pub(crate) use captured_image::BoardPasteTarget; pub(crate) use color_picker_popup::HexPasteTarget; pub use color_picker_popup::PickerDrag; pub use color_picker_popup::{ diff --git a/src/input/state/core/modal.rs b/src/input/state/core/modal.rs index 78a547305..771407f82 100644 --- a/src/input/state/core/modal.rs +++ b/src/input/state/core/modal.rs @@ -165,9 +165,9 @@ impl InputState { .any(|surface| surface.blocks_canvas_key_repeat() && self.modal_is_open(surface)) } - /// Whether either screen-region modal — the eyedropper or the OCR region - /// selector — has been asked for, including while it still waits on a - /// capture. + /// Whether either screen-region modal — the eyedropper or the generalized + /// OCR/capture/measure region selector — has been asked for, including + /// while a capture-backed purpose still waits on its screen image. /// /// This is the *keyboard* boundary: the key that requested the modal is the /// last one the canvas sees, and every press from then on is swallowed, so diff --git a/src/input/state/core/region_select.rs b/src/input/state/core/region_select.rs index 38f521b25..60f5323ca 100644 --- a/src/input/state/core/region_select.rs +++ b/src/input/state/core/region_select.rs @@ -6,6 +6,7 @@ pub enum RegionPurposeTag { Ocr, CaptureDeliver, CaptureInteractive, + Measure, } #[derive(Debug, Clone, Copy, PartialEq)] @@ -30,6 +31,10 @@ impl SelectionPolicy { } impl RegionPurposeTag { + pub const fn is_capture(self) -> bool { + matches!(self, Self::CaptureDeliver | Self::CaptureInteractive) + } + pub const fn selection_policy(self) -> SelectionPolicy { match self { Self::Ocr => SelectionPolicy { @@ -42,6 +47,11 @@ impl RegionPurposeTag { allow_square: true, snap_anchor: true, }, + Self::Measure => SelectionPolicy { + min_submit_logical_px: None, + allow_square: false, + snap_anchor: false, + }, } } } @@ -73,7 +83,8 @@ pub struct RegionSelection { pub end: (f64, f64), } -/// UI-facing lifecycle for the modal `Copy text from screen` region selector. +/// UI-facing lifecycle shared by OCR, native screen-region capture, and the +/// capture-free logical screen ruler. /// /// It owns transient input and render state only. The selected pixels and the /// recognition work belong outside `InputState`, so cancelling here can never @@ -101,11 +112,33 @@ pub enum RegionSelectUiState { start: (f64, f64), current: (f64, f64), }, + /// An interactive capture has a non-empty, image-space rectangle ready + /// for an explicit destination choice. `display` is derived from that + /// rectangle and exists here only for painting and hit testing. + Review { + purpose: RegionPurposeTag, + generation: u64, + display: RegionSelection, + move_owner: Option, + }, + /// Measure mode keeps the last completed logical rectangle visible until + /// a new press replaces it or the user exits the mode. + Measured { + purpose: RegionPurposeTag, + generation: u64, + display: RegionSelection, + }, } impl RegionSelectUiState { pub fn is_active(self) -> bool { - matches!(self, Self::Armed { .. } | Self::Selecting { .. }) + matches!( + self, + Self::Armed { .. } + | Self::Selecting { .. } + | Self::Review { .. } + | Self::Measured { .. } + ) } pub fn is_pending(self) -> bool { @@ -120,6 +153,14 @@ impl RegionSelectUiState { matches!(self, Self::Selecting { .. }) } + pub fn is_review(self) -> bool { + matches!(self, Self::Review { .. }) + } + + pub fn is_measured(self) -> bool { + matches!(self, Self::Measured { .. }) + } + /// The region drag in logical screen coordinates, if one is in progress. pub fn selection(self) -> Option { match self { @@ -127,6 +168,7 @@ impl RegionSelectUiState { start, end: current, }), + Self::Review { display, .. } | Self::Measured { display, .. } => Some(display), Self::Inactive | Self::PendingCapture { .. } | Self::Armed { .. } => None, } } @@ -135,14 +177,22 @@ impl RegionSelectUiState { pub fn selection_owner(self) -> Option { match self { Self::Selecting { owner, .. } => Some(owner), - Self::Inactive | Self::PendingCapture { .. } | Self::Armed { .. } => None, + Self::Review { move_owner, .. } => move_owner, + Self::Inactive + | Self::PendingCapture { .. } + | Self::Armed { .. } + | Self::Measured { .. } => None, } } pub fn pending_source(self) -> Option { match self { Self::PendingCapture { source, .. } => Some(source), - Self::Inactive | Self::Armed { .. } | Self::Selecting { .. } => None, + Self::Inactive + | Self::Armed { .. } + | Self::Selecting { .. } + | Self::Review { .. } + | Self::Measured { .. } => None, } } @@ -150,7 +200,9 @@ impl RegionSelectUiState { match self { Self::PendingCapture { purpose, .. } | Self::Armed { purpose, .. } - | Self::Selecting { purpose, .. } => Some(purpose), + | Self::Selecting { purpose, .. } + | Self::Review { purpose, .. } + | Self::Measured { purpose, .. } => Some(purpose), Self::Inactive => None, } } @@ -159,13 +211,19 @@ impl RegionSelectUiState { match self { Self::PendingCapture { generation, .. } | Self::Armed { generation, .. } - | Self::Selecting { generation, .. } => Some(generation), + | Self::Selecting { generation, .. } + | Self::Review { generation, .. } + | Self::Measured { generation, .. } => Some(generation), Self::Inactive => None, } } } impl InputState { + pub(crate) fn activate_measure_mode(&mut self, generation: u64) { + self.activate_region(RegionPurposeTag::Measure, generation); + } + pub(crate) fn request_copy_text_from_screen(&mut self) { self.pending_ocr_request = true; } @@ -231,12 +289,22 @@ impl InputState { owner: RegionInputSource, point: (f64, f64), ) -> bool { - let RegionSelectUiState::Armed { - purpose, - generation, - } = self.region_select_ui_state - else { - return false; + let (purpose, generation) = match self.region_select_ui_state { + RegionSelectUiState::Armed { + purpose, + generation, + } + | RegionSelectUiState::Review { + purpose, + generation, + .. + } + | RegionSelectUiState::Measured { + purpose, + generation, + .. + } => (purpose, generation), + _ => return false, }; self.region_select_ui_state = RegionSelectUiState::Selecting { purpose, @@ -249,6 +317,61 @@ impl InputState { true } + pub(crate) fn activate_region_review( + &mut self, + purpose: RegionPurposeTag, + generation: u64, + display: RegionSelection, + ) { + debug_assert_eq!(purpose, RegionPurposeTag::CaptureInteractive); + self.region_select_ui_state = RegionSelectUiState::Review { + purpose, + generation, + display, + move_owner: None, + }; + self.mark_region_dirty(); + } + + pub(crate) fn begin_region_review_move(&mut self, owner: RegionInputSource) -> bool { + let RegionSelectUiState::Review { move_owner, .. } = &mut self.region_select_ui_state + else { + return false; + }; + if move_owner.is_some() { + return false; + } + *move_owner = Some(owner); + self.mark_region_dirty(); + true + } + + pub(crate) fn update_region_review_display(&mut self, display: RegionSelection) { + let RegionSelectUiState::Review { + display: current, .. + } = &mut self.region_select_ui_state + else { + return; + }; + if *current != display { + *current = display; + self.mark_region_dirty(); + } + } + + pub(crate) fn finish_region_review_move(&mut self, owner: RegionInputSource) -> bool { + let RegionSelectUiState::Review { move_owner, .. } = &mut self.region_select_ui_state + else { + return false; + }; + if *move_owner != Some(owner) { + return false; + } + *move_owner = None; + self.mark_region_dirty(); + true + } + /// Move the region, if `source` is the device dragging it. Motion from any /// other device is somebody else's and is dropped. pub(crate) fn update_region_selection(&mut self, source: RegionInputSource, point: (f64, f64)) { @@ -262,6 +385,32 @@ impl InputState { } } + pub(crate) fn complete_measurement(&mut self, source: RegionInputSource) -> bool { + let RegionSelectUiState::Selecting { + purpose: RegionPurposeTag::Measure, + generation, + owner, + start, + current, + } = self.region_select_ui_state + else { + return false; + }; + if owner != source { + return false; + } + self.region_select_ui_state = RegionSelectUiState::Measured { + purpose: RegionPurposeTag::Measure, + generation, + display: RegionSelection { + start, + end: current, + }, + }; + self.mark_region_dirty(); + true + } + /// Whether `source` is the device dragging the region right now. The /// release and per-device cancellation paths check this before acting, so /// one device cannot submit or discard another's region. @@ -289,8 +438,10 @@ impl InputState { pub(crate) fn cancel_region_ui_only(&mut self) { if !matches!(self.region_select_ui_state, RegionSelectUiState::Inactive) { + let was_measure = + self.region_select_ui_state.purpose() == Some(RegionPurposeTag::Measure); self.region_select_ui_state = RegionSelectUiState::Inactive; - self.mark_region_dirty(); + self.mark_region_dirty_for(was_measure); } } @@ -298,7 +449,15 @@ impl InputState { /// out of it, so every change repaints the whole surface: an incremental /// buffer would otherwise keep the scrim from an earlier frame. fn mark_region_dirty(&mut self) { - self.dirty_tracker.mark_full(); + self.mark_region_dirty_for( + self.region_select_ui_state.purpose() == Some(RegionPurposeTag::Measure), + ); + } + + fn mark_region_dirty_for(&mut self, targeted_measure_damage: bool) { + if !targeted_measure_damage { + self.dirty_tracker.mark_full(); + } self.needs_redraw = true; } } @@ -320,6 +479,7 @@ mod tests { #[test] fn region_policy_preserves_ocr_geometry_and_reserves_capture_geometry() { + assert!(!RegionPurposeTag::Ocr.is_capture()); let ocr = RegionPurposeTag::Ocr.selection_policy(); assert_eq!(ocr.min_submit_logical_px(), Some(4.0)); assert!(!ocr.allow_square()); @@ -329,6 +489,7 @@ mod tests { RegionPurposeTag::CaptureDeliver, RegionPurposeTag::CaptureInteractive, ] { + assert!(purpose.is_capture()); let capture = purpose.selection_policy(); assert_eq!(capture.min_submit_logical_px(), None); assert!(capture.allow_square()); @@ -386,6 +547,77 @@ mod tests { assert_eq!(state.region_state(), RegionSelectUiState::Inactive); } + #[test] + fn interactive_review_keeps_display_and_move_ownership_explicit() { + let mut state = make_test_input_state(); + let display = RegionSelection { + start: (20.0, 30.0), + end: (80.0, 90.0), + }; + state.activate_region_review(RegionPurposeTag::CaptureInteractive, 14, display); + + assert!(state.region_state().is_review()); + assert!(state.region_is_active()); + assert_eq!(state.region_state().selection(), Some(display)); + assert!(state.begin_region_review_move(RegionInputSource::Pointer)); + assert!(!state.begin_region_review_move(RegionInputSource::Touch)); + assert!(state.region_selection_is_owned_by(RegionInputSource::Pointer)); + + let moved = RegionSelection { + start: (25.0, 35.0), + end: (85.0, 95.0), + }; + state.update_region_review_display(moved); + assert_eq!(state.region_state().selection(), Some(moved)); + assert!(!state.finish_region_review_move(RegionInputSource::Stylus)); + assert!(state.finish_region_review_move(RegionInputSource::Pointer)); + assert!(state.region_state().selection_owner().is_none()); + } + + #[test] + fn measure_mode_keeps_the_completed_rectangle_without_capture_state() { + let mut state = make_test_input_state(); + state.activate_measure_mode(7); + assert!(state.start_region_selection(RegionInputSource::Pointer, (12.0, 18.0))); + state.update_region_selection(RegionInputSource::Pointer, (42.0, 63.0)); + + assert!(state.complete_measurement(RegionInputSource::Pointer)); + + assert!(state.region_state().is_measured()); + assert_eq!( + state.region_state().selection(), + Some(RegionSelection { + start: (12.0, 18.0), + end: (42.0, 63.0), + }) + ); + assert!(state.take_pending_backend_action().is_none()); + } + + #[test] + fn pressing_outside_review_can_start_a_fresh_owned_selection() { + let mut state = make_test_input_state(); + state.activate_region_review( + RegionPurposeTag::CaptureInteractive, + 15, + RegionSelection { + start: (20.0, 30.0), + end: (80.0, 90.0), + }, + ); + + assert!(state.start_region_selection(RegionInputSource::Touch, (4.0, 5.0))); + assert!(state.region_state().is_selecting()); + assert!(state.region_selection_is_owned_by(RegionInputSource::Touch)); + assert_eq!( + state.region_state().selection(), + Some(RegionSelection { + start: (4.0, 5.0), + end: (4.0, 5.0), + }) + ); + } + #[test] fn a_press_before_the_capture_lands_does_not_start_a_region() { let mut state = make_test_input_state(); diff --git a/src/input/state/interaction/actions.rs b/src/input/state/interaction/actions.rs index 0687efa83..96c3d662c 100644 --- a/src/input/state/interaction/actions.rs +++ b/src/input/state/interaction/actions.rs @@ -135,6 +135,8 @@ pub(crate) fn classify_action(action: Action) -> ActionRoute { | Action::CaptureFileSelection | Action::CaptureClipboardRegion | Action::CaptureFileRegion + | Action::CaptureRegionInteractive + | Action::MeasureMode | Action::ExportCanvasFile | Action::ExportCanvasClipboard | Action::ExportCanvasClipboardAndFile diff --git a/src/input/state/mod.rs b/src/input/state/mod.rs index ddc7800d0..74c904514 100644 --- a/src/input/state/mod.rs +++ b/src/input/state/mod.rs @@ -41,6 +41,14 @@ pub use core::{ color_picker_rgb_to_hsv, compass_slice, size_ring_angle_for_value, size_ring_value_for_angle, slice_parent, sub_ring_child_count, sub_ring_children, }; +#[allow(unused_imports)] +pub(crate) use core::{ + BoardPasteTarget, ClipboardFingerprint, ClipboardPasteRequest, HelpOverlayPressSource, + HexPasteTarget, KeybindingEditOperation, KeybindingEditRequest, PasteAnchor, + PendingBackendAction, PendingOnboardingUsage, PendingSelectionClipboardPublish, + PendingToolbarPersistence, SelectionPublishState, TextClipboardRequest, TextCutTarget, + TextPasteEdit, TextPasteTarget, ToastCommand, ToastPress, WayscriberClipboardSelection, +}; pub(crate) use core::{ COMMAND_PALETTE_INPUT_HEIGHT, COMMAND_PALETTE_ITEM_HEIGHT, COMMAND_PALETTE_LIST_GAP, COMMAND_PALETTE_PADDING, COMMAND_PALETTE_QUERY_PLACEHOLDER, COMMAND_PALETTE_ROW_ACTION_COUNT, @@ -48,14 +56,6 @@ pub(crate) use core::{ COMMAND_PALETTE_ROW_ICON_SIZE, COMMAND_PALETTE_TOP_RATIO, action_meta_token_score, default_step_marker_size, fuzzy_score, query_tokens, }; -#[allow(unused_imports)] -pub(crate) use core::{ - ClipboardFingerprint, ClipboardPasteRequest, HelpOverlayPressSource, HexPasteTarget, - KeybindingEditOperation, KeybindingEditRequest, PasteAnchor, PendingBackendAction, - PendingOnboardingUsage, PendingSelectionClipboardPublish, PendingToolbarPersistence, - SelectionPublishState, TextClipboardRequest, TextCutTarget, TextPasteEdit, TextPasteTarget, - ToastCommand, ToastPress, WayscriberClipboardSelection, -}; pub use highlight::ClickHighlightSettings; #[allow(unused_imports)] pub use input_hud::{ diff --git a/src/input/state/render.rs b/src/input/state/render.rs index 94ea0f805..1ae4a725b 100644 --- a/src/input/state/render.rs +++ b/src/input/state/render.rs @@ -13,6 +13,34 @@ use std::ops::Range; use super::{DrawingState, InputState}; impl InputState { + /// Logical canvas dimensions for the in-progress shape-size badge. + /// + /// Only the rectangle and ellipse tools expose a width/height readout; + /// path-like and line-like tools keep their existing uncluttered preview. + pub(crate) fn provisional_shape_size( + &self, + current_x: i32, + current_y: i32, + ) -> Option<(u32, u32)> { + let DrawingState::Drawing { tool, .. } = &self.state else { + return None; + }; + if !matches!(tool, Tool::Rect | Tool::Ellipse) { + return None; + } + + match self.provisional_tool_stroke(current_x, current_y) { + ProvisionalToolStroke::Shape(Shape::Rect { w, h, .. }) => { + Some((w.unsigned_abs(), h.unsigned_abs())) + } + ProvisionalToolStroke::Shape(Shape::Ellipse { rx, ry, .. }) => Some(( + rx.unsigned_abs().saturating_mul(2), + ry.unsigned_abs().saturating_mul(2), + )), + _ => None, + } + } + pub(crate) fn provisional_tool_stroke( &self, current_x: i32, diff --git a/src/input/state/tests/drawing.rs b/src/input/state/tests/drawing.rs index 28d0e1335..887d4ea86 100644 --- a/src/input/state/tests/drawing.rs +++ b/src/input/state/tests/drawing.rs @@ -802,6 +802,64 @@ fn sync_highlight_color_marks_dirty_when_pen_color_changes() { assert!(state.needs_redraw); } +#[test] +fn provisional_shape_size_reports_rect_and_ellipse_logical_extents() { + let mut state = create_test_input_state(); + + for tool in [Tool::Rect, Tool::Ellipse] { + state.state = DrawingState::Drawing { + tool, + start_x: 140, + start_y: 95, + points: Vec::new(), + point_thicknesses: Vec::new(), + }; + + assert_eq!(state.provisional_shape_size(20, 175), Some((120, 80))); + } + + state.state = DrawingState::Drawing { + tool: Tool::Ellipse, + start_x: 11, + start_y: 15, + points: Vec::new(), + point_thicknesses: Vec::new(), + }; + assert_eq!(state.provisional_shape_size(0, 0), Some((10, 14))); + + state.state = DrawingState::Drawing { + tool: Tool::Rect, + start_x: 7, + start_y: 9, + points: Vec::new(), + point_thicknesses: Vec::new(), + }; + assert_eq!(state.provisional_shape_size(7, 9), Some((0, 0))); +} + +#[test] +fn provisional_shape_size_excludes_freehand_and_non_size_badge_tools() { + let mut state = create_test_input_state(); + + for tool in Tool::ALL + .into_iter() + .filter(|tool| !matches!(tool, Tool::Rect | Tool::Ellipse)) + { + state.state = DrawingState::Drawing { + tool, + start_x: 10, + start_y: 20, + points: vec![(10, 20)], + point_thicknesses: vec![2.0], + }; + + assert_eq!(state.provisional_shape_size(40, 70), None, "{tool:?}"); + } + + state.state = DrawingState::Idle; + assert_eq!(state.provisional_shape_size(40, 70), None); +} + fn test_rects_intersect(a: crate::util::Rect, b: crate::util::Rect) -> bool { let a_right = a.x.saturating_add(a.width); let a_bottom = a.y.saturating_add(a.height); diff --git a/src/input/state/tests/text_input/actions.rs b/src/input/state/tests/text_input/actions.rs index a8cd68aca..b297ed968 100644 --- a/src/input/state/tests/text_input/actions.rs +++ b/src/input/state/tests/text_input/actions.rs @@ -58,6 +58,24 @@ fn capture_action_sets_pending_and_clears_modifiers() { assert!(state.take_pending_backend_action().is_none()); } +#[test] +fn region_capture_action_preserves_live_shift_until_the_picker_arms() { + let mut state = create_test_input_state(); + state.modifiers.ctrl = true; + state.modifiers.shift = true; + + state.handle_action(Action::CaptureClipboardSelection); + + assert!(state.modifiers.ctrl); + assert!(state.modifiers.shift); + assert_eq!( + state.take_pending_backend_action(), + Some(PendingBackendAction::Screenshot( + Action::CaptureClipboardSelection + )) + ); +} + #[test] fn canvas_export_action_sets_pending_backend_action() { let mut state = create_test_input_state(); diff --git a/src/process_broker/manifest.rs b/src/process_broker/manifest.rs index cc28711a2..a14580fa4 100644 --- a/src/process_broker/manifest.rs +++ b/src/process_broker/manifest.rs @@ -3,13 +3,11 @@ use std::process::Command; use anyhow::{Result, anyhow, bail}; -use super::wire::{ - HelperKind, MAX_ARGUMENT_BYTES, MAX_ARGUMENTS, MAX_INPUT_BYTES, MAX_OUTPUT_BYTES, OsWire, -}; +use super::wire::{HelperKind, MAX_ARGUMENT_BYTES, MAX_ARGUMENTS, MAX_INPUT_BYTES, OsWire}; pub(super) fn input_cap(kind: HelperKind) -> usize { if matches!(kind, HelperKind::WlCopy) { - MAX_OUTPUT_BYTES + super::max_publish_bytes() } else { MAX_INPUT_BYTES } @@ -55,7 +53,8 @@ pub(super) fn validate( "grim" | "hyprctl" | "slurp" | "wl-copy" | "wl-paste" | "zenity" | "kdialog" ), HelperKind::Grim => basename == "grim", - HelperKind::Hyprctl => basename == "hyprctl", + HelperKind::Hyprctl | HelperKind::HyprctlActiveWindow => basename == "hyprctl", + HelperKind::Swaymsg => basename == "swaymsg", HelperKind::Slurp => basename == "slurp", HelperKind::Tesseract => basename == "tesseract", HelperKind::WlPaste => basename == "wl-paste", @@ -81,6 +80,13 @@ pub(super) fn validate( if !allowed { bail!("program {basename:?} is not allowed for helper kind {kind:?}"); } + if matches!( + kind, + HelperKind::Hyprctl | HelperKind::HyprctlActiveWindow | HelperKind::Swaymsg + ) && (!environment.is_empty() || !input.is_empty()) + { + bail!("window geometry helpers do not accept environment overrides or input"); + } validate_arguments(kind, &basename, arguments)?; for (name, _) in environment { let name = std::str::from_utf8(&name.0)?; @@ -99,10 +105,40 @@ pub(super) fn validate( Ok(()) } -/// Cheap exec-gate checks for helpers whose safety relies on one indispensable -/// argument. Complete argv content remains the caller's policy. +/// Helper-specific argv gates. +/// +/// Window-geometry helpers use exact read-only query vectors. Other helpers +/// below retain their established indispensable-argument policy. fn validate_arguments(kind: HelperKind, basename: &str, arguments: &[OsWire]) -> Result<()> { match kind { + HelperKind::Hyprctl => { + // Named rather than inlined into the `if`: a lone `if` as the arm + // body trips `collapsible_match` on the toolchain CI pins, and its + // suggested match guard would silently fall through to the + // permissive catch-all when the guard is false. + let allowed = arguments_equal(arguments, &[b"clients", b"-j"]) + || arguments_equal(arguments, &[b"monitors", b"-j"]); + if !allowed { + bail!("hyprctl window geometry is restricted to JSON clients or monitors queries"); + } + } + HelperKind::HyprctlActiveWindow => { + let allowed = arguments_equal(arguments, &[b"activewindow", b"-j"]) + || arguments_equal(arguments, &[b"monitors", b"-j"]); + if !allowed { + bail!("active-window hyprctl is restricted to its two JSON queries"); + } + } + HelperKind::Swaymsg => { + let expected = [b"-t".as_slice(), b"get_tree".as_slice(), b"-r".as_slice()]; + if arguments + .iter() + .map(|argument| argument.0.as_slice()) + .ne(expected) + { + bail!("swaymsg helper is restricted to a raw get_tree query"); + } + } HelperKind::UpdateFetcher => { let required_first = if basename == "curl" { b"--disable".as_slice() @@ -171,6 +207,14 @@ fn validate_arguments(kind: HelperKind, basename: &str, arguments: &[OsWire]) -> Ok(()) } +fn arguments_equal(arguments: &[OsWire], expected: &[&[u8]]) -> bool { + arguments.len() == expected.len() + && arguments + .iter() + .zip(expected) + .all(|(argument, expected)| argument.0.as_slice() == *expected) +} + fn is_curl_config_argument(argument: &[u8]) -> bool { if long_option_matches(argument, b"config") { return true; diff --git a/src/process_broker/mod.rs b/src/process_broker/mod.rs index ee79023ea..9b424ce90 100644 --- a/src/process_broker/mod.rs +++ b/src/process_broker/mod.rs @@ -18,3 +18,8 @@ mod tests; pub(crate) use client::{BROKER_BUSY, BrokerChild, ProcessBroker, current, start_for_runtime}; pub(crate) use server::run_internal_broker_if_requested; pub(crate) use wire::{BrokerOutput, HelperKind, HelperLifetime, STDOUT_CAP_EXCEEDED}; + +/// Largest payload accepted by retained clipboard publication. +pub(crate) const fn max_publish_bytes() -> usize { + wire::MAX_OUTPUT_BYTES +} diff --git a/src/process_broker/tests.rs b/src/process_broker/tests.rs index 24686e8dc..5d55093a5 100644 --- a/src/process_broker/tests.rs +++ b/src/process_broker/tests.rs @@ -141,6 +141,140 @@ fn update_fetcher_manifest_allows_only_curl_and_wget() { } } +#[test] +fn swaymsg_manifest_allows_only_tree_queries() { + let program = super::wire::OsWire::from_os(OsStr::new("/usr/bin/swaymsg")).unwrap(); + super::manifest::validate( + HelperKind::Swaymsg, + &program, + &wire_arguments(&["-t", "get_tree", "-r"]), + &[], + &[], + ) + .unwrap(); + + for arguments in [ + &[][..], + &["-t", "get_outputs", "-r"][..], + &["-t", "command", "exit"][..], + &["-r", "-t", "get_tree"][..], + ] { + assert!( + super::manifest::validate( + HelperKind::Swaymsg, + &program, + &wire_arguments(arguments), + &[], + &[], + ) + .is_err(), + "swaymsg accepted arguments {arguments:?}" + ); + } + + let unrelated = super::wire::OsWire::from_os(OsStr::new("/usr/bin/sh")).unwrap(); + assert!( + super::manifest::validate( + HelperKind::Swaymsg, + &unrelated, + &wire_arguments(&["-t", "get_tree", "-r"]), + &[], + &[], + ) + .is_err() + ); + assert!( + super::manifest::validate( + HelperKind::Swaymsg, + &program, + &wire_arguments(&["-t", "get_tree", "-r"]), + &[], + b"command payload", + ) + .is_err() + ); +} + +#[test] +fn hyprctl_window_geometry_manifest_allows_only_clients_and_monitors_json_queries() { + let program = super::wire::OsWire::from_os(OsStr::new("/usr/bin/hyprctl")).unwrap(); + for arguments in [&["clients", "-j"][..], &["monitors", "-j"][..]] { + super::manifest::validate( + HelperKind::Hyprctl, + &program, + &wire_arguments(arguments), + &[], + &[], + ) + .unwrap(); + } + + for arguments in [ + &[][..], + &["clients"][..], + &["activewindow", "-j"][..], + &["-j", "clients"][..], + &["clients", "-j", "extra"][..], + &["dispatch", "exit"][..], + ] { + assert!( + super::manifest::validate( + HelperKind::Hyprctl, + &program, + &wire_arguments(arguments), + &[], + &[], + ) + .is_err(), + "window-geometry hyprctl accepted arguments {arguments:?}" + ); + } + + assert!( + super::manifest::validate( + HelperKind::Hyprctl, + &program, + &wire_arguments(&["clients", "-j"]), + &[], + b"command payload", + ) + .is_err() + ); +} + +#[test] +fn active_window_hyprctl_manifest_preserves_only_its_existing_queries() { + let program = super::wire::OsWire::from_os(OsStr::new("hyprctl")).unwrap(); + for arguments in [&["activewindow", "-j"][..], &["monitors", "-j"][..]] { + super::manifest::validate( + HelperKind::HyprctlActiveWindow, + &program, + &wire_arguments(arguments), + &[], + &[], + ) + .unwrap(); + } + + for arguments in [ + &["clients", "-j"][..], + &["activewindow"][..], + &["activewindow", "-j", "extra"][..], + ] { + assert!( + super::manifest::validate( + HelperKind::HyprctlActiveWindow, + &program, + &wire_arguments(arguments), + &[], + &[], + ) + .is_err(), + "active-window hyprctl accepted arguments {arguments:?}" + ); + } +} + #[test] fn desktop_open_manifest_accepts_one_path_or_trusted_url_without_a_shell() { for target in [ @@ -1117,10 +1251,6 @@ fn wl_copy_publication_accepts_capture_sized_input() { helper.as_os_str(), [count_path.as_os_str()], vec![b'x'; PUBLICATION_BYTES], - // Generous on purpose: this test proves byte-count integrity, not - // latency, and pushing 16 MiB through a real subprocess has been - // observed to exceed 5 s when the full parallel suite saturates - // the machine - the broker then SIGKILLs the helper (status 137). Duration::from_secs(30), ) .unwrap(); diff --git a/src/process_broker/wire.rs b/src/process_broker/wire.rs index ac5827606..34946015a 100644 --- a/src/process_broker/wire.rs +++ b/src/process_broker/wire.rs @@ -35,6 +35,8 @@ pub(crate) enum HelperKind { CapabilityProbe, Grim, Hyprctl, + HyprctlActiveWindow, + Swaymsg, Slurp, Tesseract, WlPaste, diff --git a/src/screen_pixels.rs b/src/screen_pixels.rs index 6d5c52c62..af8d99ee6 100644 --- a/src/screen_pixels.rs +++ b/src/screen_pixels.rs @@ -1,7 +1,3 @@ -// Phase 0a establishes capture-purpose pixel geometry whose remaining callers -// arrive with the native region picker in Phase 1. -#![allow(dead_code)] - /// A point in source-image pixel-edge coordinates. #[derive(Debug, Clone, Copy, PartialEq)] pub struct ImagePoint { @@ -9,6 +5,19 @@ pub struct ImagePoint { pub y: f64, } +/// Immutable CPU-side screen pixels in Cairo-compatible ARGB32 layout. +/// +/// Acquisition backends may attach their own provenance and transform +/// behavior, while render/export workers can share this neutral value without +/// depending on a compositor implementation. +#[derive(Debug)] +pub struct ScreenImage { + pub width: u32, + pub height: u32, + pub stride: i32, + pub data: Vec, +} + impl ImagePoint { pub const fn new(x: f64, y: f64) -> Self { Self { x, y } @@ -102,10 +111,12 @@ pub struct PixelSpan { } impl PixelSpan { + #[allow(dead_code)] // Geometry diagnostics and tests inspect the quantized origin. pub const fn x(self) -> u32 { self.x } + #[allow(dead_code)] // Geometry diagnostics and tests inspect the quantized origin. pub const fn y(self) -> u32 { self.y } @@ -118,6 +129,7 @@ impl PixelSpan { self.height } + #[allow(dead_code)] // Tests and Phase 2 review use the paired dimensions. pub const fn size(self) -> (u32, u32) { (self.width, self.height) } @@ -166,6 +178,7 @@ impl ImagePixelRect { }) } + #[allow(dead_code)] // Tests and Phase 2 review use the paired dimensions. pub const fn size(self) -> (u32, u32) { (self.width, self.height) } @@ -192,11 +205,13 @@ impl ImagePixelRect { } /// Quantize two unordered image edges into a non-empty in-bounds rectangle. + #[allow(dead_code)] // Phase 2 review constructs rectangles from edited endpoints. pub fn from_points(first: ImagePoint, second: ImagePoint, bounds: (u32, u32)) -> Option { pixel_span(first, second, bounds)?.try_into().ok() } /// Translate the rectangle and clamp its origin while preserving its size. + #[allow(dead_code)] // Phase 2 review uses this for keyboard nudging. pub fn translated_clamped( self, delta_x: i64, @@ -272,6 +287,10 @@ impl PackedArgb32 { pub fn data(&self) -> &[u8] { &self.data } + + pub(crate) fn into_data(self) -> Vec { + self.data + } } impl std::fmt::Debug for PackedArgb32 { diff --git a/src/ui.rs b/src/ui.rs index 404933ba7..58dddfebc 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -9,11 +9,15 @@ mod context_menu; mod eyedropper_loupe; mod help_overlay; mod input_hud; +mod measure_badge; mod onboarding_card; mod precision_entry; mod primitives; mod properties_panel; mod radial_menu; +mod region_action_bar; +mod region_capture_picker; +mod region_resize_handles; mod status; mod text_highlight; pub mod theme; @@ -34,13 +38,22 @@ pub use help_overlay::{ HelpOverlayRegion, clear_help_overlay_hit_map, help_overlay_region_at, render_help_overlay, }; pub use input_hud::{input_hud_geometry, render_input_hud}; +pub(crate) use measure_badge::{ + ShapeMeasureBadge, measure_shape_badge, shape_measure_badge_text_style, +}; pub use onboarding_card::{OnboardingCard, OnboardingChecklistItem, render_onboarding_card}; pub use precision_entry::render_precision_entry_popup; -pub(crate) use primitives::checkerboard_behind; /// Shared measured-text trimming, also used by the standalone about dialog. pub(crate) use primitives::ellipsize_to_fit; +pub(crate) use primitives::{checkerboard_behind, draw_pill}; pub use properties_panel::render_properties_panel; pub use radial_menu::render_radial_menu; +pub(crate) use region_action_bar::{RegionAction, RegionActionBar}; +pub(crate) use region_capture_picker::{ + RegionCaptureLoupeVisual, RegionCapturePickerVisual, RegionCaptureWindowVisual, + capture_size_text, measure_picker_damage, render_region_capture_picker, +}; +pub(crate) use region_resize_handles::RegionResizeHandles; pub use status::{ StatusHudLayout, StatusHudSegmentKind, ZoomChipButtonKind, ZoomChipLayout, ZoomChipPress, compute_status_hud_layout, compute_zoom_chip_layout, render_editing_badge, render_frozen_badge, diff --git a/src/ui/board_picker/page_panel/thumbnail/content.rs b/src/ui/board_picker/page_panel/thumbnail/content.rs index f741fbb9b..438fdc542 100644 --- a/src/ui/board_picker/page_panel/thumbnail/content.rs +++ b/src/ui/board_picker/page_panel/thumbnail/content.rs @@ -88,6 +88,8 @@ fn render_frame_shapes( }, logical_to_image_scale_x: 1.0, logical_to_image_scale_y: 1.0, + logical_image_origin_x: 0.0, + logical_image_origin_y: 0.0, }; for drawn in &frame.shapes { diff --git a/src/ui/help_overlay/sections/builder/sections.rs b/src/ui/help_overlay/sections/builder/sections.rs index 11a4b0e06..623f57551 100644 --- a/src/ui/help_overlay/sections/builder/sections.rs +++ b/src/ui/help_overlay/sections/builder/sections.rs @@ -279,9 +279,11 @@ pub(super) fn build_main_sections( "Selection (capture defaults)", ) .with_action(Action::CaptureSelection), + action_row(bindings, Action::CaptureRegionInteractive, NOT_BOUND_LABEL), ]); } screenshot_rows.extend([ + action_row(bindings, Action::MeasureMode, NOT_BOUND_LABEL), action_row(bindings, Action::ExportCanvasClipboard, NOT_BOUND_LABEL), action_row(bindings, Action::ExportCanvasFile, NOT_BOUND_LABEL), action_row( diff --git a/src/ui/help_overlay/sections/tests.rs b/src/ui/help_overlay/sections/tests.rs index 518c36e23..67aee4c40 100644 --- a/src/ui/help_overlay/sections/tests.rs +++ b/src/ui/help_overlay/sections/tests.rs @@ -60,3 +60,33 @@ fn canvas_export_rows_remain_visible_when_capture_context_is_disabled() { "Screenshot rows should stay hidden when capture context is disabled" ); } + +#[test] +fn interactive_region_capture_has_a_help_row_when_capture_is_enabled() { + let bindings = HelpOverlayBindings::default(); + let sections = build_section_sets(&bindings, false, true, true, true).all; + let rows = sections + .iter() + .flat_map(|section| section.rows.iter()) + .collect::>(); + + assert!(rows.iter().any(|row| { + row.action == action_label(Action::CaptureRegionInteractive) + && row.key == crate::label_format::NOT_BOUND_LABEL + })); +} + +#[test] +fn capture_free_measure_mode_stays_visible_when_capture_is_disabled() { + let bindings = HelpOverlayBindings::default(); + let sections = build_section_sets(&bindings, false, true, true, false).all; + let rows = sections + .iter() + .flat_map(|section| section.rows.iter()) + .collect::>(); + + assert!(rows.iter().any(|row| { + row.action == action_label(Action::MeasureMode) + && row.key == crate::label_format::NOT_BOUND_LABEL + })); +} diff --git a/src/ui/measure_badge.rs b/src/ui/measure_badge.rs new file mode 100644 index 000000000..f8f809585 --- /dev/null +++ b/src/ui/measure_badge.rs @@ -0,0 +1,122 @@ +//! Pure layout for the live rectangle/ellipse size readout. + +use crate::ui_text::{UiTextStyle, measure_text}; + +const FONT_SIZE: f64 = 12.0; +const PADDING_X: f64 = 8.0; +const POINTER_GAP: f64 = 15.0; +const SCREEN_MARGIN: f64 = 6.0; +const BADGE_HEIGHT: f64 = 22.0; + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct ShapeMeasureBadge { + pub text: String, + pub bounds: (f64, f64, f64, f64), + pub baseline: (f64, f64), +} + +pub(crate) fn shape_measure_badge_text_style() -> UiTextStyle<'static> { + UiTextStyle { + family: "monospace", + slant: cairo::FontSlant::Normal, + weight: cairo::FontWeight::Bold, + size: FONT_SIZE, + } +} + +/// Lay out a fixed-size badge near the pointer, flipping it away from screen +/// edges. The displayed dimensions are logical canvas pixels, independent of +/// output scale and the current zoom transform. +pub(crate) fn measure_shape_badge( + enabled: bool, + size: (u32, u32), + pointer: (f64, f64), + screen_width: u32, + screen_height: u32, +) -> Option { + if !enabled { + return None; + } + let text = format!("{} × {}", size.0, size.1); + let extents = measure_text(shape_measure_badge_text_style(), &text, None)?; + let width = (extents.width() + PADDING_X * 2.0) + .min((screen_width as f64 - SCREEN_MARGIN * 2.0).max(0.0)); + let height = BADGE_HEIGHT.min((screen_height as f64 - SCREEN_MARGIN * 2.0).max(0.0)); + if width <= 0.0 || height <= 0.0 { + return None; + } + let x = trailing_axis(pointer.0, width, screen_width as f64); + let y = trailing_axis(pointer.1, height, screen_height as f64); + let baseline = ( + x + (width - extents.width()) / 2.0 - extents.x_bearing(), + y + (height - extents.height()) / 2.0 - extents.y_bearing(), + ); + + Some(ShapeMeasureBadge { + text, + bounds: (x, y, width, height), + baseline, + }) +} + +fn trailing_axis(pointer: f64, extent: f64, screen_extent: f64) -> f64 { + let available_max = (screen_extent - extent - SCREEN_MARGIN).max(0.0); + let preferred = if pointer + POINTER_GAP + extent + SCREEN_MARGIN <= screen_extent { + pointer + POINTER_GAP + } else { + pointer - POINTER_GAP - extent + }; + preferred.clamp(SCREEN_MARGIN.min(available_max), available_max) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn badge_text_uses_logical_width_and_height() { + let badge = measure_shape_badge(true, (120, 80), (100.0, 100.0), 1920, 1080) + .expect("text measurement"); + let zero = measure_shape_badge(true, (0, 0), (100.0, 100.0), 1920, 1080) + .expect("text measurement"); + + assert_eq!(badge.text, "120 × 80"); + assert_eq!(zero.text, "0 × 0"); + } + + #[test] + fn disabled_badge_has_no_visual() { + assert!(measure_shape_badge(false, (120, 80), (100.0, 100.0), 1920, 1080).is_none()); + } + + #[test] + fn badge_handles_every_horizontal_and_vertical_flip_combination() { + let layout = |pointer| { + measure_shape_badge(true, (120, 80), pointer, 400, 300) + .expect("text measurement") + .bounds + }; + let below_right = layout((20.0, 30.0)); + assert!(below_right.0 > 20.0 && below_right.1 > 30.0); + + let below_left = layout((390.0, 30.0)); + assert!(below_left.0 < 390.0 && below_left.1 > 30.0); + + let above_right = layout((20.0, 290.0)); + assert!(above_right.0 > 20.0 && above_right.1 < 290.0); + + let above_left = layout((390.0, 290.0)); + assert!(above_left.0 < 390.0 && above_left.1 < 290.0); + } + + #[test] + fn badge_clamps_to_a_tiny_surface() { + let (x, y, width, height) = measure_shape_badge(true, (3840, 2160), (2.0, 2.0), 80, 20) + .expect("text measurement") + .bounds; + + assert_eq!((x, y), (SCREEN_MARGIN, SCREEN_MARGIN)); + assert_eq!(width, 68.0); + assert_eq!(height, 8.0); + } +} diff --git a/src/ui/region_action_bar.rs b/src/ui/region_action_bar.rs new file mode 100644 index 000000000..aafbbaa89 --- /dev/null +++ b/src/ui/region_action_bar.rs @@ -0,0 +1,672 @@ +use crate::input::state::RegionSelection; +use crate::ui::theme::{self, Rgba, overlay}; +use crate::ui_text::{UiTextStyle, text_layout}; + +use super::primitives::{draw_keycap, draw_rounded_rect, keycap_size}; + +const SURFACE_MARGIN: f64 = 8.0; +const SELECTION_GAP: f64 = 12.0; +const BAR_PADDING: f64 = 8.0; +const ITEM_GAP: f64 = 6.0; +/// Vertical gap between the action row and the drawings toggle. The hairline +/// divider is centred in it. +const ROW_GAP: f64 = 8.0; +const ACTION_ROW_HEIGHT: f64 = 38.0; +const TOGGLE_ROW_HEIGHT: f64 = 26.0; +const BAR_HEIGHT: f64 = BAR_PADDING * 2.0 + ACTION_ROW_HEIGHT + ROW_GAP + TOGGLE_ROW_HEIGHT; +/// Resting width of one action control. The bar sizes itself from this instead +/// of stretching controls across an arbitrary fixed width. +const ACTION_ITEM_WIDTH: f64 = 74.0; +const BAR_WIDTH: f64 = BAR_PADDING * 2.0 + ACTION_ITEM_WIDTH * 4.0 + ITEM_GAP * 3.0; + +const BAR_RADIUS: f64 = overlay::RADIUS_PANEL; +const ITEM_RADIUS: f64 = overlay::RADIUS_MD; +/// Downward-only two-layer drop shadow, matching the command palette frame, so +/// the bar reads as floating above the frozen screenshot rather than painted +/// into it. +const SHADOW_OFFSET: f64 = 8.0; +const SHADOW_SOFT: Rgba = (0.0, 0.0, 0.0, 0.20); + +const LABEL_FONT_SIZE: f64 = 11.0; +const KEYCAP_FONT_SIZE: f64 = 8.5; +const TOGGLE_FONT_SIZE: f64 = 10.5; +/// Gap between an action's label and the keycap chip under it. +const LABEL_KEYCAP_GAP: f64 = 3.0; + +const ITEM_BG: Rgba = (1.0, 1.0, 1.0, 0.06); +const ITEM_BORDER: Rgba = (1.0, 1.0, 1.0, 0.10); +const ITEM_BG_HOVER: Rgba = overlay::BG_HOVER; +const ITEM_BORDER_HOVER: Rgba = overlay::BORDER_FOCUS; +/// `Both` is what Enter does, so it carries the accent as the bar's default +/// action. Resting alpha stays below full so hover still reads as a change. +const PRIMARY_BG: Rgba = theme::rgba(theme::ACCENT_RGB, 0.80); +const PRIMARY_BG_HOVER: Rgba = theme::rgba(theme::ACCENT_RGB, 1.0); +const PRIMARY_BORDER: Rgba = theme::rgba(theme::ACCENT_BRIGHT_RGB, 0.45); + +const KEYCAP_BG: Rgba = (1.0, 1.0, 1.0, 0.10); +const KEYCAP_BG_ON_ACCENT: Rgba = (1.0, 1.0, 1.0, 0.20); + +const CHECKBOX_SIZE: f64 = 14.0; +const CHECKBOX_BORDER: Rgba = (1.0, 1.0, 1.0, 0.38); +const CHECKBOX_BG: Rgba = (1.0, 1.0, 1.0, 0.06); +const CHECKBOX_BG_CHECKED: Rgba = theme::rgba(theme::ACCENT_RGB, 0.95); +const TOGGLE_BG_HOVER: Rgba = (1.0, 1.0, 1.0, 0.07); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum RegionAction { + Copy, + Save, + Both, + Board, + ToggleIncludeDrawings, +} + +impl RegionAction { + pub(crate) const fn label(self) -> &'static str { + match self { + Self::Copy => "Copy", + Self::Save => "Save", + Self::Both => "Both", + Self::Board => "Board", + Self::ToggleIncludeDrawings => "Include drawings in exports", + } + } + + pub(crate) const fn shortcut(self) -> &'static str { + match self { + Self::Copy => "Ctrl+C", + Self::Save => "Ctrl+S", + Self::Both => "Enter", + Self::Board => "B", + Self::ToggleIncludeDrawings => "D", + } + } + + /// The accented default action: the one `Enter` submits. + const fn is_primary(self) -> bool { + matches!(self, Self::Both) + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct RegionActionRect { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +impl RegionActionRect { + pub(crate) const fn new(x: f64, y: f64, width: f64, height: f64) -> Self { + Self { + x, + y, + width, + height, + } + } + + fn contains(self, point: (f64, f64)) -> bool { + point.0 >= self.x + && point.0 < self.x + self.width + && point.1 >= self.y + && point.1 < self.y + self.height + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct RegionActionItem { + action: RegionAction, + bounds: RegionActionRect, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct RegionActionBar { + bounds: RegionActionRect, + items: [RegionActionItem; 4], + toggle: RegionActionItem, +} + +impl RegionActionBar { + pub(crate) fn place(selection: RegionSelection, surface: (u32, u32)) -> Self { + let left = selection.start.0.min(selection.end.0); + let right = selection.start.0.max(selection.end.0); + let top = selection.start.1.min(selection.end.1); + let bottom = selection.start.1.max(selection.end.1); + let surface_width = f64::from(surface.0); + let surface_height = f64::from(surface.1); + let width = BAR_WIDTH.min((surface_width - SURFACE_MARGIN * 2.0).max(0.0)); + let height = BAR_HEIGHT.min((surface_height - SURFACE_MARGIN * 2.0).max(0.0)); + let x = ((left + right - width) / 2.0).clamp( + SURFACE_MARGIN, + (surface_width - width - SURFACE_MARGIN).max(SURFACE_MARGIN), + ); + let below = bottom + SELECTION_GAP; + let preferred_y = if below + height + SURFACE_MARGIN <= surface_height { + below + } else { + top - SELECTION_GAP - height + }; + let y = preferred_y.clamp( + SURFACE_MARGIN, + (surface_height - height - SURFACE_MARGIN).max(SURFACE_MARGIN), + ); + let bounds = RegionActionRect::new(x, y, width, height); + let item_height = ACTION_ROW_HEIGHT.min((height - BAR_PADDING * 2.0).max(0.0)); + let item_count = 4.0; + let item_width = + ((width - BAR_PADDING * 2.0 - ITEM_GAP * (item_count - 1.0)) / item_count).max(0.0); + let item = |index: usize, action| RegionActionItem { + action, + bounds: RegionActionRect::new( + x + BAR_PADDING + index as f64 * (item_width + ITEM_GAP), + y + BAR_PADDING, + item_width, + item_height, + ), + }; + let toggle_y = y + BAR_PADDING + item_height + ROW_GAP; + let toggle = RegionActionItem { + action: RegionAction::ToggleIncludeDrawings, + bounds: RegionActionRect::new( + x + BAR_PADDING, + toggle_y, + (width - BAR_PADDING * 2.0).max(0.0), + (y + height - BAR_PADDING - toggle_y).max(0.0), + ), + }; + Self { + bounds, + items: [ + item(0, RegionAction::Copy), + item(1, RegionAction::Save), + item(2, RegionAction::Both), + item(3, RegionAction::Board), + ], + toggle, + } + } + + /// The painted frame, without its drop shadow. The picker uses it to keep + /// the Review size badge out from under the bar. + pub(crate) const fn bounds(self) -> RegionActionRect { + self.bounds + } + + pub(crate) fn hit(self, point: (f64, f64)) -> Option { + self.items + .iter() + .find(|item| item.bounds.contains(point)) + .map(|item| item.action) + .or_else(|| { + self.toggle + .bounds + .contains(point) + .then_some(self.toggle.action) + }) + } + + pub(crate) fn contains(self, point: (f64, f64)) -> bool { + self.bounds.contains(point) + } +} + +pub(crate) fn render_region_action_bar( + ctx: &cairo::Context, + bar: RegionActionBar, + hovered: Option, + include_drawings: bool, +) { + let _ = ctx.save(); + draw_bar_frame(ctx, bar.bounds); + + for item in bar.items { + draw_action(ctx, item, hovered == Some(item.action)); + } + draw_row_divider(ctx, bar); + draw_toggle(ctx, bar.toggle, hovered, include_drawings); + let _ = ctx.restore(); +} + +fn draw_bar_frame(ctx: &cairo::Context, bounds: RegionActionRect) { + if bounds.width <= 0.0 || bounds.height <= 0.0 { + return; + } + for (offset, color) in [ + (SHADOW_OFFSET, SHADOW_SOFT), + (SHADOW_OFFSET * 0.5, overlay::SHADOW), + ] { + theme::set_color(ctx, color); + draw_rounded_rect( + ctx, + bounds.x, + bounds.y + offset, + bounds.width, + bounds.height, + BAR_RADIUS, + ); + let _ = ctx.fill(); + } + + theme::set_color(ctx, crate::ui::theme::popup::bg_context_menu()); + draw_rounded_rect( + ctx, + bounds.x, + bounds.y, + bounds.width, + bounds.height, + BAR_RADIUS, + ); + let _ = ctx.fill(); + + theme::set_color(ctx, crate::ui::theme::popup::border_context_menu()); + ctx.set_line_width(1.0); + draw_rounded_rect( + ctx, + bounds.x + 0.5, + bounds.y + 0.5, + bounds.width - 1.0, + bounds.height - 1.0, + BAR_RADIUS - 0.5, + ); + let _ = ctx.stroke(); +} + +/// Hairline between the action row and the toggle row, inset from the bar's +/// padding so it reads as a grouping rule rather than a border. +fn draw_row_divider(ctx: &cairo::Context, bar: RegionActionBar) { + let toggle = bar.toggle.bounds; + if toggle.width <= 0.0 || toggle.height <= 0.0 { + return; + } + let y = (toggle.y - ROW_GAP / 2.0).floor() + 0.5; + theme::set_color(ctx, overlay::DIVIDER_LIGHT); + ctx.set_line_width(1.0); + ctx.move_to(toggle.x, y); + ctx.line_to(toggle.x + toggle.width, y); + let _ = ctx.stroke(); +} + +fn draw_action(ctx: &cairo::Context, item: RegionActionItem, hovered: bool) { + if item.bounds.width <= 0.0 || item.bounds.height <= 0.0 { + return; + } + let primary = item.action.is_primary(); + let (fill, border) = match (primary, hovered) { + (true, false) => (PRIMARY_BG, PRIMARY_BORDER), + (true, true) => (PRIMARY_BG_HOVER, ITEM_BORDER_HOVER), + (false, false) => (ITEM_BG, ITEM_BORDER), + (false, true) => (ITEM_BG_HOVER, ITEM_BORDER_HOVER), + }; + + theme::set_color(ctx, fill); + draw_rounded_rect( + ctx, + item.bounds.x, + item.bounds.y, + item.bounds.width, + item.bounds.height, + ITEM_RADIUS, + ); + let _ = ctx.fill(); + + theme::set_color(ctx, border); + ctx.set_line_width(1.0); + draw_rounded_rect( + ctx, + item.bounds.x + 0.5, + item.bounds.y + 0.5, + (item.bounds.width - 1.0).max(0.0), + (item.bounds.height - 1.0).max(0.0), + (ITEM_RADIUS - 0.5).max(0.0), + ); + let _ = ctx.stroke(); + + draw_action_content(ctx, item, primary); +} + +/// Label over keycap, the pair centred as one block so every control's text +/// sits on the same optical line regardless of ascenders or descenders. +fn draw_action_content(ctx: &cairo::Context, item: RegionActionItem, primary: bool) { + let _ = ctx.save(); + ctx.rectangle( + item.bounds.x, + item.bounds.y, + item.bounds.width, + item.bounds.height, + ); + ctx.clip(); + + let center_x = item.bounds.x + item.bounds.width / 2.0; + let label = item.action.label(); + let layout = text_layout(ctx, label_style(), label, None); + let label_extents = layout.ink_extents(); + let (keycap_width, keycap_height) = keycap_size(ctx, item.action.shortcut(), KEYCAP_FONT_SIZE); + + let stack_height = label_extents.height() + LABEL_KEYCAP_GAP + keycap_height; + let stack_top = item.bounds.y + (item.bounds.height - stack_height) / 2.0; + + theme::set_color(ctx, overlay::TEXT_PRIMARY); + layout.show_at_baseline( + ctx, + center_x - label_extents.width() / 2.0 - label_extents.x_bearing(), + stack_top - label_extents.y_bearing(), + ); + + draw_keycap( + ctx, + center_x - keycap_width / 2.0, + stack_top + label_extents.height() + LABEL_KEYCAP_GAP, + item.action.shortcut(), + KEYCAP_FONT_SIZE, + if primary { + KEYCAP_BG_ON_ACCENT + } else { + KEYCAP_BG + }, + if primary { + overlay::TEXT_WHITE + } else { + overlay::TEXT_HINT + }, + ); + let _ = ctx.restore(); +} + +fn draw_toggle( + ctx: &cairo::Context, + item: RegionActionItem, + hovered: Option, + checked: bool, +) { + if item.bounds.width <= 0.0 || item.bounds.height <= 0.0 { + return; + } + let _ = ctx.save(); + ctx.rectangle( + item.bounds.x, + item.bounds.y, + item.bounds.width, + item.bounds.height, + ); + ctx.clip(); + + // The row itself stays quiet: the checkbox carries the on/off state, so an + // enabled toggle no longer paints a full-width slab across the bar. + if hovered == Some(item.action) { + theme::set_color(ctx, TOGGLE_BG_HOVER); + draw_rounded_rect( + ctx, + item.bounds.x, + item.bounds.y, + item.bounds.width, + item.bounds.height, + ITEM_RADIUS, + ); + let _ = ctx.fill(); + } + + let box_size = CHECKBOX_SIZE.min(item.bounds.height - 2.0).max(0.0); + let box_x = item.bounds.x + 6.0; + let box_y = item.bounds.y + (item.bounds.height - box_size) / 2.0; + draw_checkbox(ctx, box_x, box_y, box_size, checked); + + let label = item.action.label(); + let layout = text_layout(ctx, toggle_label_style(), label, None); + let extents = layout.ink_extents(); + theme::set_color( + ctx, + if checked { + overlay::TEXT_PRIMARY + } else { + overlay::TEXT_TERTIARY + }, + ); + layout.show_at_baseline( + ctx, + box_x + box_size + 8.0 - extents.x_bearing(), + item.bounds.y + (item.bounds.height - extents.height()) / 2.0 - extents.y_bearing(), + ); + + let (keycap_width, keycap_height) = keycap_size(ctx, item.action.shortcut(), KEYCAP_FONT_SIZE); + draw_keycap( + ctx, + item.bounds.x + item.bounds.width - 6.0 - keycap_width, + item.bounds.y + (item.bounds.height - keycap_height) / 2.0, + item.action.shortcut(), + KEYCAP_FONT_SIZE, + KEYCAP_BG, + overlay::TEXT_HINT, + ); + let _ = ctx.restore(); +} + +fn draw_checkbox(ctx: &cairo::Context, x: f64, y: f64, size: f64, checked: bool) { + if size <= 0.0 { + return; + } + theme::set_color( + ctx, + if checked { + CHECKBOX_BG_CHECKED + } else { + CHECKBOX_BG + }, + ); + draw_rounded_rect(ctx, x, y, size, size, overlay::RADIUS_SM); + let _ = ctx.fill(); + + theme::set_color( + ctx, + if checked { + theme::rgba(theme::ACCENT_BRIGHT_RGB, 0.8) + } else { + CHECKBOX_BORDER + }, + ); + ctx.set_line_width(1.0); + draw_rounded_rect( + ctx, + x + 0.5, + y + 0.5, + (size - 1.0).max(0.0), + (size - 1.0).max(0.0), + (overlay::RADIUS_SM - 0.5).max(0.0), + ); + let _ = ctx.stroke(); + + if !checked { + return; + } + theme::set_color(ctx, overlay::TEXT_WHITE); + ctx.set_line_width((size * 0.14).max(1.4)); + ctx.set_line_cap(cairo::LineCap::Round); + ctx.set_line_join(cairo::LineJoin::Round); + ctx.move_to(x + size * 0.26, y + size * 0.52); + ctx.line_to(x + size * 0.44, y + size * 0.70); + ctx.line_to(x + size * 0.76, y + size * 0.32); + let _ = ctx.stroke(); +} + +fn label_style() -> UiTextStyle<'static> { + UiTextStyle { + family: "Sans", + slant: cairo::FontSlant::Normal, + weight: cairo::FontWeight::Bold, + size: LABEL_FONT_SIZE, + } +} + +fn toggle_label_style() -> UiTextStyle<'static> { + UiTextStyle { + family: "Sans", + slant: cairo::FontSlant::Normal, + weight: cairo::FontWeight::Normal, + size: TOGGLE_FONT_SIZE, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn action_bar_prefers_below_then_flips_above_and_clamps_to_the_surface() { + let centered = RegionActionBar::place( + RegionSelection { + start: (100.0, 100.0), + end: (300.0, 200.0), + }, + (800, 600), + ); + assert_eq!( + centered.bounds(), + RegionActionRect::new(35.0, 212.0, 330.0, 88.0) + ); + + let flipped = RegionActionBar::place( + RegionSelection { + start: (730.0, 560.0), + end: (790.0, 590.0), + }, + (800, 600), + ); + assert_eq!( + flipped.bounds(), + RegionActionRect::new(462.0, 460.0, 330.0, 88.0) + ); + } + + #[test] + fn action_bar_hit_returns_typed_controls_and_rejects_gaps() { + let bar = RegionActionBar::place( + RegionSelection { + start: (100.0, 100.0), + end: (300.0, 200.0), + }, + (800, 600), + ); + + assert_eq!(bar.hit((80.0, 239.0)), Some(RegionAction::Copy)); + assert_eq!(bar.hit((160.0, 239.0)), Some(RegionAction::Save)); + assert_eq!(bar.hit((240.0, 239.0)), Some(RegionAction::Both)); + assert_eq!(bar.hit((320.0, 239.0)), Some(RegionAction::Board)); + assert_eq!( + bar.hit((200.0, 279.0)), + Some(RegionAction::ToggleIncludeDrawings) + ); + assert_eq!(bar.hit((119.0, 239.0)), None, "inter-item gap"); + assert!(bar.contains((119.0, 239.0)), "bar gaps stay modal-owned"); + assert_eq!(bar.hit((20.0, 20.0)), None, "outside the bar"); + assert!(!bar.contains((20.0, 20.0))); + } + + #[test] + fn action_bar_rows_never_overlap_and_stay_inside_the_padded_frame() { + let bar = RegionActionBar::place( + RegionSelection { + start: (100.0, 100.0), + end: (300.0, 200.0), + }, + (800, 600), + ); + let bounds = bar.bounds(); + let toggle = bar.toggle.bounds; + + for item in bar.items { + assert!(item.bounds.y >= bounds.y + BAR_PADDING); + assert!(item.bounds.y + item.bounds.height <= toggle.y - ROW_GAP + f64::EPSILON); + assert!(item.bounds.x >= bounds.x + BAR_PADDING); + assert!(item.bounds.x + item.bounds.width <= bounds.x + bounds.width - BAR_PADDING); + assert_eq!(item.bounds.width, ACTION_ITEM_WIDTH); + } + assert!(toggle.y + toggle.height <= bounds.y + bounds.height - BAR_PADDING); + } + + #[test] + fn action_bar_exposes_the_requested_labels_and_shortcuts() { + assert_eq!(RegionAction::Copy.label(), "Copy"); + assert_eq!(RegionAction::Copy.shortcut(), "Ctrl+C"); + assert_eq!(RegionAction::Save.label(), "Save"); + assert_eq!(RegionAction::Save.shortcut(), "Ctrl+S"); + assert_eq!(RegionAction::Both.label(), "Both"); + assert_eq!(RegionAction::Both.shortcut(), "Enter"); + assert_eq!(RegionAction::Board.label(), "Board"); + assert_eq!(RegionAction::Board.shortcut(), "B"); + assert_eq!( + RegionAction::ToggleIncludeDrawings.label(), + "Include drawings in exports" + ); + assert_eq!(RegionAction::ToggleIncludeDrawings.shortcut(), "D"); + } + + #[test] + fn enter_is_the_only_accented_default_action() { + assert!(RegionAction::Both.is_primary()); + for action in [ + RegionAction::Copy, + RegionAction::Save, + RegionAction::Board, + RegionAction::ToggleIncludeDrawings, + ] { + assert!(!action.is_primary(), "{action:?} must stay neutral"); + } + } + + #[test] + fn rendering_paints_the_bar_and_each_control() { + let bar = RegionActionBar::place( + RegionSelection { + start: (100.0, 100.0), + end: (300.0, 200.0), + }, + (800, 600), + ); + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 800, 600).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_region_action_bar(&ctx, bar, Some(RegionAction::Both), true); + drop(ctx); + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().unwrap(); + let alpha = |x: usize, y: usize| data[y * stride + x * 4 + 3]; + + assert!(alpha(40, 250) > 0, "bar surface"); + for x in [80, 160, 240, 320] { + assert!(alpha(x, 239) > 0, "control at x={x}"); + } + assert!(alpha(56, 279) > 0, "checked drawings checkbox"); + assert_eq!(alpha(20, 20), 0, "outside remains untouched"); + } + + #[test] + fn the_drawings_checkbox_carries_the_state_instead_of_a_full_width_slab() { + let bar = RegionActionBar::place( + RegionSelection { + start: (100.0, 100.0), + end: (300.0, 200.0), + }, + (800, 600), + ); + let row_alpha = |checked: bool| { + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 800, 600).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_region_action_bar(&ctx, bar, None, checked); + drop(ctx); + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().unwrap(); + // Far right of the toggle row, clear of the checkbox, the label and + // the keycap chip: unfilled in both states. + ( + u32::from(data[279 * stride + 300 * 4 + 3]), + u32::from(data[279 * stride + 56 * 4 + 3]), + ) + }; + + let (off_row, off_box) = row_alpha(false); + let (on_row, on_box) = row_alpha(true); + assert_eq!(off_row, on_row, "the row background must not change"); + assert!(on_box > 0 && off_box > 0, "the box is drawn either way"); + } +} diff --git a/src/ui/region_capture_picker.rs b/src/ui/region_capture_picker.rs new file mode 100644 index 000000000..15c1a22ea --- /dev/null +++ b/src/ui/region_capture_picker.rs @@ -0,0 +1,1209 @@ +use crate::input::SelectionHandle; +use crate::input::state::RegionSelection; +use crate::util::Rect; + +use super::primitives::{draw_rounded_rect, text_extents_for}; +use super::region_action_bar::{ + RegionAction, RegionActionBar, RegionActionRect, render_region_action_bar, +}; +use super::region_resize_handles::{RegionResizeHandles, render_region_resize_handles}; + +const SCRIM: (f64, f64, f64, f64) = (0.02, 0.03, 0.05, 0.48); +const PANEL_FILL: (f64, f64, f64, f64) = (12.0 / 255.0, 12.0 / 255.0, 15.0 / 255.0, 0.92); +const PANEL_BORDER: (f64, f64, f64, f64) = (1.0, 1.0, 1.0, 0.16); +const POINTER_GAP: f64 = 15.0; +/// Gap between the reviewed selection and its size badge. The badge is parked +/// on the selection during Review instead of trailing the pointer, so a +/// finished rectangle stops behaving like one that is still being dragged. +const SELECTION_BADGE_GAP: f64 = 6.0; +const PANEL_MARGIN: f64 = 6.0; +const PANEL_PADDING_X: f64 = 8.0; +const PANEL_HEIGHT: f64 = 22.0; +const PANEL_RADIUS: f64 = 6.0; +const READOUT_FONT_SIZE: f64 = 12.0; +const LEGEND_FONT_SIZE: f64 = 12.0; +const AREA_LEGEND_TEXT: &str = "Drag to select Shift: square Ctrl+A: all Esc: cancel"; +const AREA_WITH_WINDOWS_LEGEND_TEXT: &str = + "Drag to select Shift: square Ctrl+A: all Space: window Esc: cancel"; +const WINDOW_LEGEND_TEXT: &str = + "Click: select Super+Arrows: choose Enter: select Space: area Esc: cancel"; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct RegionCaptureWindowVisual<'a> { + pub available: bool, + pub active: bool, + pub targets: &'a [RegionSelection], + /// The pointer-hovered or keyboard-focused window candidate. + pub highlighted_target: Option, +} + +impl RegionCaptureWindowVisual<'_> { + #[cfg(test)] + pub(crate) const fn disabled() -> Self { + Self { + available: false, + active: false, + targets: &[], + highlighted_target: None, + } + } + + fn highlighted_selection(self) -> Option { + self.active + .then(|| { + self.highlighted_target + .and_then(|index| self.targets.get(index).copied()) + }) + .flatten() + } +} + +fn picker_legend_text(window: RegionCaptureWindowVisual<'_>) -> &'static str { + if window.active { + WINDOW_LEGEND_TEXT + } else if window.available { + AREA_WITH_WINDOWS_LEGEND_TEXT + } else { + AREA_LEGEND_TEXT + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct RegionCapturePickerVisual<'a> { + pub selection: Option, + pub pointer: (f64, f64), + /// Authoritative pixel coordinates or size supplied by the picker owner. + pub measurement: Option<&'a str>, + pub show_scrim: bool, + pub show_legend: bool, + /// The selection is committed and awaiting a destination choice. Review + /// drops the targeting chrome: no crosshair, and the size badge anchors to + /// the rectangle rather than following the pointer. + pub review: bool, + /// Resize grips on the reviewed rectangle. Present only in Review, where + /// they replace the corner arms the targeting frame draws. + pub resize_handles: Option, + pub hovered_handle: Option, + pub loupe: Option, + pub action_bar: Option, + pub hovered_action: Option, + pub include_drawings: bool, + pub window: RegionCaptureWindowVisual<'a>, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct RegionCaptureLoupeVisual { + pub pointer: (f64, f64), + pub image_center: (f64, f64), +} + +impl RegionCaptureLoupeVisual { + pub(crate) fn when_enabled( + show_loupe: bool, + pointer: (f64, f64), + image_center: (f64, f64), + ) -> Option { + show_loupe.then_some(Self { + pointer, + image_center, + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct PointerPanelLayout { + x: f64, + y: f64, + width: f64, + height: f64, +} + +pub(crate) fn capture_size_text(size: (u32, u32)) -> String { + format!("{} × {}", size.0, size.1) +} + +/// Conservative targeted damage for Measure Mode's chrome. The crosshair is +/// represented as two thin strips; the selection as four edge strips; and the +/// pointer readout by a bounded box covering every flip direction. This avoids +/// the capture picker's full-surface scrim damage without leaving trails. +pub(crate) fn measure_picker_damage( + selection: Option, + pointer: (f64, f64), + screen: (u32, u32), +) -> Vec { + let width = screen.0.min(i32::MAX as u32) as i32; + let height = screen.1.min(i32::MAX as u32) as i32; + if width <= 0 || height <= 0 { + return Vec::new(); + } + let x = pointer.0.round().clamp(0.0, f64::from(width - 1)) as i32; + let y = pointer.1.round().clamp(0.0, f64::from(height - 1)) as i32; + let mut damage = Vec::with_capacity(7); + push_clipped_damage(&mut damage, x - 2, 0, 5, height, width, height); + push_clipped_damage(&mut damage, 0, y - 2, width, 5, width, height); + // The monospace readout is short, but cover both horizontal and vertical + // flip choices so the damage remains correct without a Cairo text pass. + push_clipped_damage(&mut damage, x - 240, y - 48, 480, 96, width, height); + + if let Some(selection) = selection { + let min_x = selection.start.0.min(selection.end.0).floor() as i32; + let min_y = selection.start.1.min(selection.end.1).floor() as i32; + let max_x = selection.start.0.max(selection.end.0).ceil() as i32; + let max_y = selection.start.1.max(selection.end.1).ceil() as i32; + let rect_width = max_x.saturating_sub(min_x); + let rect_height = max_y.saturating_sub(min_y); + push_clipped_damage( + &mut damage, + min_x - 4, + min_y - 4, + rect_width + 8, + 8, + width, + height, + ); + push_clipped_damage( + &mut damage, + min_x - 4, + max_y - 4, + rect_width + 8, + 8, + width, + height, + ); + push_clipped_damage( + &mut damage, + min_x - 4, + min_y - 4, + 8, + rect_height + 8, + width, + height, + ); + push_clipped_damage( + &mut damage, + max_x - 4, + min_y - 4, + 8, + rect_height + 8, + width, + height, + ); + } + damage +} + +fn push_clipped_damage( + damage: &mut Vec, + x: i32, + y: i32, + width: i32, + height: i32, + screen_width: i32, + screen_height: i32, +) { + let min_x = x.clamp(0, screen_width); + let min_y = y.clamp(0, screen_height); + let max_x = x.saturating_add(width).clamp(0, screen_width); + let max_y = y.saturating_add(height).clamp(0, screen_height); + if let Some(rect) = Rect::from_min_max(min_x, min_y, max_x, max_y) { + damage.push(rect); + } +} + +pub(crate) fn render_region_capture_loupe( + ctx: &cairo::Context, + screen: (u32, u32), + visual: RegionCaptureLoupeVisual, + sample: impl FnMut(f64, f64) -> Option, +) { + let layout = crate::ui::compute_eyedropper_loupe_layout(visual.pointer, screen); + crate::ui::render_eyedropper_loupe(ctx, layout, visual.image_center, sample); +} + +pub(crate) fn render_region_capture_picker( + ctx: &cairo::Context, + screen_width: u32, + screen_height: u32, + visual: RegionCapturePickerVisual<'_>, + mut sample_loupe: impl FnMut(f64, f64) -> Option, +) { + let width = f64::from(screen_width); + let height = f64::from(screen_height); + + let highlighted_window = visual.window.highlighted_selection(); + let effective_selection = if visual.window.active { + highlighted_window + } else { + visual.selection + }; + + let _ = ctx.save(); + if visual.show_scrim { + ctx.set_source_rgba(SCRIM.0, SCRIM.1, SCRIM.2, SCRIM.3); + ctx.rectangle(0.0, 0.0, width, height); + if let Some(selection) = effective_selection { + let (x, y, w, h) = normalized_rect(selection); + ctx.rectangle(x, y, w, h); + ctx.set_fill_rule(cairo::FillRule::EvenOdd); + } + let _ = ctx.fill(); + ctx.set_fill_rule(cairo::FillRule::Winding); + } + + if visual.window.active { + draw_window_target_frames(ctx, visual.window); + } + if let Some(selection) = effective_selection { + let (x, y, w, h) = normalized_rect(selection); + // Corner arms and corner grips would occupy the same pixels, so the + // frame drops its arms wherever grips are offered. + draw_selection_frame(ctx, x, y, w, h, visual.resize_handles.is_none()); + } + if let Some(handles) = visual.resize_handles { + render_region_resize_handles(ctx, handles, visual.hovered_handle); + } + if !visual.window.active && !visual.review { + draw_crosshair(ctx, visual.pointer, (width, height)); + } + + if let Some(measurement) = visual.measurement { + let anchor = effective_selection + .filter(|_| visual.review) + .map(normalized_rect); + draw_readout_panel( + ctx, + measurement, + READOUT_FONT_SIZE, + visual.pointer, + anchor, + visual.action_bar.map(RegionActionBar::bounds), + (screen_width, screen_height), + cairo::FontWeight::Bold, + ); + } + if visual.show_legend && (visual.window.active || visual.selection.is_none()) { + draw_legend( + ctx, + (screen_width, screen_height), + picker_legend_text(visual.window), + ); + } + if let Some(loupe) = visual.loupe { + render_region_capture_loupe(ctx, (screen_width, screen_height), loupe, &mut sample_loupe); + } + if let Some(action_bar) = visual.action_bar { + render_region_action_bar( + ctx, + action_bar, + visual.hovered_action, + visual.include_drawings, + ); + } + let _ = ctx.restore(); +} + +fn normalized_rect(selection: RegionSelection) -> (f64, f64, f64, f64) { + let x = selection.start.0.min(selection.end.0); + let y = selection.start.1.min(selection.end.1); + ( + x, + y, + (selection.end.0 - selection.start.0).abs(), + (selection.end.1 - selection.start.1).abs(), + ) +} + +fn draw_selection_frame(ctx: &cairo::Context, x: f64, y: f64, w: f64, h: f64, corner_arms: bool) { + ctx.set_source_rgba(1.0, 1.0, 1.0, 0.95); + ctx.set_line_width(1.0); + ctx.rectangle(x + 0.5, y + 0.5, (w - 1.0).max(0.0), (h - 1.0).max(0.0)); + let _ = ctx.stroke(); + + if !corner_arms { + return; + } + let arm = (w.min(h) / 4.0).clamp(4.0, 20.0); + ctx.set_line_width(2.0); + ctx.set_line_cap(cairo::LineCap::Square); + for (corner_x, corner_y, dx, dy) in [ + (x, y, 1.0, 1.0), + (x + w, y, -1.0, 1.0), + (x, y + h, 1.0, -1.0), + (x + w, y + h, -1.0, -1.0), + ] { + ctx.move_to(corner_x + dx * arm, corner_y); + ctx.line_to(corner_x, corner_y); + ctx.line_to(corner_x, corner_y + dy * arm); + let _ = ctx.stroke(); + } +} + +fn draw_window_target_frames(ctx: &cairo::Context, window: RegionCaptureWindowVisual<'_>) { + ctx.set_source_rgba(1.0, 1.0, 1.0, 0.34); + ctx.set_line_width(1.0); + for target in window.targets { + let (x, y, width, height) = normalized_rect(*target); + ctx.rectangle( + x + 0.5, + y + 0.5, + (width - 1.0).max(0.0), + (height - 1.0).max(0.0), + ); + let _ = ctx.stroke(); + } +} + +fn draw_crosshair(ctx: &cairo::Context, pointer: (f64, f64), screen: (f64, f64)) { + ctx.set_source_rgba(1.0, 1.0, 1.0, 0.22); + ctx.set_line_width(1.0); + ctx.move_to(0.0, pointer.1 + 0.5); + ctx.line_to(screen.0, pointer.1 + 0.5); + ctx.move_to(pointer.0 + 0.5, 0.0); + ctx.line_to(pointer.0 + 0.5, screen.1); + let _ = ctx.stroke(); +} + +fn pointer_panel_layout( + pointer: (f64, f64), + text_width: f64, + screen: (u32, u32), +) -> PointerPanelLayout { + let screen_width = f64::from(screen.0); + let screen_height = f64::from(screen.1); + let width = + (text_width + PANEL_PADDING_X * 2.0).min((screen_width - PANEL_MARGIN * 2.0).max(0.0)); + let height = PANEL_HEIGHT.min((screen_height - PANEL_MARGIN * 2.0).max(0.0)); + let mut x = pointer.0 + POINTER_GAP; + let mut y = pointer.1 + POINTER_GAP; + if x + width + PANEL_MARGIN > screen_width { + x = pointer.0 - POINTER_GAP - width; + } + if y + height + PANEL_MARGIN > screen_height { + y = pointer.1 - POINTER_GAP - height; + } + PointerPanelLayout { + x: x.clamp( + PANEL_MARGIN, + (screen_width - width - PANEL_MARGIN).max(PANEL_MARGIN), + ), + y: y.clamp( + PANEL_MARGIN, + (screen_height - height - PANEL_MARGIN).max(PANEL_MARGIN), + ), + width, + height, + } +} + +/// Park the size badge on the selection's top-left corner. The action bar is +/// painted after the badge, and it flips above the selection when it does not +/// fit below, so each placement is checked against the bar and skipped rather +/// than drawn under it. Candidates run outside-above, inside-top-left, +/// inside-top-right; inside-top-left is the fallback when a clamped bar covers +/// all three. +fn selection_badge_layout( + rect: (f64, f64, f64, f64), + text_width: f64, + action_bar: Option, + screen: (u32, u32), +) -> PointerPanelLayout { + let screen_width = f64::from(screen.0); + let screen_height = f64::from(screen.1); + let width = + (text_width + PANEL_PADDING_X * 2.0).min((screen_width - PANEL_MARGIN * 2.0).max(0.0)); + let height = PANEL_HEIGHT.min((screen_height - PANEL_MARGIN * 2.0).max(0.0)); + let (rect_x, rect_y, rect_width, ..) = rect; + let clamp_x = |x: f64| { + x.clamp( + PANEL_MARGIN, + (screen_width - width - PANEL_MARGIN).max(PANEL_MARGIN), + ) + }; + let clamp_y = |y: f64| { + y.clamp( + PANEL_MARGIN, + (screen_height - height - PANEL_MARGIN).max(PANEL_MARGIN), + ) + }; + let left = clamp_x(rect_x); + let right = clamp_x(rect_x + rect_width - width); + let inside = clamp_y(rect_y + SELECTION_BADGE_GAP); + let above = rect_y - SELECTION_BADGE_GAP - height; + let fallback = (left, inside); + let (x, y) = [ + (above >= PANEL_MARGIN).then(|| (left, clamp_y(above))), + Some(fallback), + Some((right, inside)), + ] + .into_iter() + .flatten() + .find(|&(x, y)| !covered_by_action_bar(x, y, width, height, action_bar)) + .unwrap_or(fallback); + PointerPanelLayout { + x, + y, + width, + height, + } +} + +fn covered_by_action_bar( + x: f64, + y: f64, + width: f64, + height: f64, + action_bar: Option, +) -> bool { + action_bar.is_some_and(|bar| { + x < bar.x + bar.width && bar.x < x + width && y < bar.y + bar.height && bar.y < y + height + }) +} + +/// The measurement chip. `selection`, when present, anchors it to that +/// rectangle; otherwise it trails the pointer. +#[allow(clippy::too_many_arguments)] +fn draw_readout_panel( + ctx: &cairo::Context, + text: &str, + font_size: f64, + pointer: (f64, f64), + selection: Option<(f64, f64, f64, f64)>, + action_bar: Option, + screen: (u32, u32), + weight: cairo::FontWeight, +) { + let extents = text_extents_for( + ctx, + "monospace", + cairo::FontSlant::Normal, + weight, + font_size, + text, + ); + let layout = match selection { + Some(rect) => selection_badge_layout(rect, extents.width(), action_bar, screen), + None => pointer_panel_layout(pointer, extents.width(), screen), + }; + if layout.width <= 0.0 || layout.height <= 0.0 { + return; + } + ctx.set_source_rgba(PANEL_FILL.0, PANEL_FILL.1, PANEL_FILL.2, PANEL_FILL.3); + let radius = PANEL_RADIUS + .min(layout.width / 2.0) + .min(layout.height / 2.0); + draw_rounded_rect(ctx, layout.x, layout.y, layout.width, layout.height, radius); + let _ = ctx.fill(); + ctx.set_source_rgba( + PANEL_BORDER.0, + PANEL_BORDER.1, + PANEL_BORDER.2, + PANEL_BORDER.3, + ); + ctx.set_line_width(1.0); + draw_rounded_rect( + ctx, + layout.x + 0.5, + layout.y + 0.5, + layout.width - 1.0, + layout.height - 1.0, + (radius - 0.5).max(0.0), + ); + let _ = ctx.stroke(); + + ctx.set_source_rgb(1.0, 1.0, 1.0); + ctx.select_font_face( + "monospace", + cairo::FontSlant::Normal, + cairo::FontWeight::Bold, + ); + ctx.set_font_size(font_size); + let baseline = layout.y + (layout.height - extents.height()) / 2.0 - extents.y_bearing(); + let _ = ctx.save(); + ctx.rectangle(layout.x, layout.y, layout.width, layout.height); + ctx.clip(); + ctx.move_to(layout.x + PANEL_PADDING_X - extents.x_bearing(), baseline); + let _ = ctx.show_text(text); + let _ = ctx.restore(); +} + +fn draw_legend(ctx: &cairo::Context, screen: (u32, u32), text: &str) { + let extents = text_extents_for( + ctx, + "Sans", + cairo::FontSlant::Normal, + cairo::FontWeight::Normal, + LEGEND_FONT_SIZE, + text, + ); + let screen_width = f64::from(screen.0); + let screen_height = f64::from(screen.1); + let width = (extents.width() + 24.0).min((screen_width - 12.0).max(0.0)); + let height = 28.0_f64.min((screen_height - 12.0).max(0.0)); + if width <= 0.0 || height <= 0.0 { + return; + } + let x = ((screen_width - width) / 2.0).max(6.0); + let y = 12.0_f64.min((screen_height - height).max(0.0)); + let radius = PANEL_RADIUS.min(width / 2.0).min(height / 2.0); + ctx.set_source_rgba(PANEL_FILL.0, PANEL_FILL.1, PANEL_FILL.2, PANEL_FILL.3); + draw_rounded_rect(ctx, x, y, width, height, radius); + let _ = ctx.fill(); + ctx.set_source_rgba(1.0, 1.0, 1.0, 0.88); + ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Normal); + ctx.set_font_size(LEGEND_FONT_SIZE); + let text_x = x + ((width - extents.width()) / 2.0).max(6.0) - extents.x_bearing(); + let baseline = y + (height - extents.height()) / 2.0 - extents.y_bearing(); + let _ = ctx.save(); + ctx.rectangle(x, y, width, height); + ctx.clip(); + ctx.move_to(text_x, baseline); + let _ = ctx.show_text(text); + let _ = ctx.restore(); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn size_readout_uses_export_pixel_units_and_multiplication_sign() { + assert_eq!(capture_size_text((0, 0)), "0 × 0"); + assert_eq!(capture_size_text((900, 620)), "900 × 620"); + } + + #[test] + fn legend_only_advertises_supported_window_controls() { + assert_eq!( + picker_legend_text(RegionCaptureWindowVisual { + available: false, + active: false, + targets: &[], + highlighted_target: None, + }), + "Drag to select Shift: square Ctrl+A: all Esc: cancel" + ); + assert_eq!( + picker_legend_text(RegionCaptureWindowVisual { + available: true, + active: false, + targets: &[], + highlighted_target: None, + }), + "Drag to select Shift: square Ctrl+A: all Space: window Esc: cancel" + ); + assert_eq!( + picker_legend_text(RegionCaptureWindowVisual { + available: true, + active: true, + targets: &[], + highlighted_target: None, + }), + "Click: select Super+Arrows: choose Enter: select Space: area Esc: cancel" + ); + } + + #[test] + fn pointer_panel_prefers_below_right_then_flips_and_clamps() { + assert_eq!( + pointer_panel_layout((20.0, 30.0), 60.0, (400, 300)), + PointerPanelLayout { + x: 35.0, + y: 45.0, + width: 76.0, + height: 22.0, + } + ); + assert_eq!( + pointer_panel_layout((390.0, 290.0), 60.0, (400, 300)), + PointerPanelLayout { + x: 299.0, + y: 253.0, + width: 76.0, + height: 22.0, + } + ); + assert_eq!( + pointer_panel_layout((2.0, 2.0), 120.0, (80, 20)), + PointerPanelLayout { + x: 6.0, + y: 6.0, + width: 68.0, + height: 8.0, + } + ); + } + + #[test] + fn selected_area_is_cut_out_of_the_scrim() { + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 40, 40).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_region_capture_picker( + &ctx, + 40, + 40, + RegionCapturePickerVisual { + selection: Some(RegionSelection { + start: (10.0, 10.0), + end: (30.0, 30.0), + }), + pointer: (30.0, 30.0), + measurement: None, + show_scrim: true, + review: false, + resize_handles: None, + hovered_handle: None, + show_legend: false, + loupe: None, + action_bar: None, + hovered_action: None, + include_drawings: false, + window: RegionCaptureWindowVisual::disabled(), + }, + |_x, _y| None, + ); + drop(ctx); + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().unwrap(); + let alpha = |x: usize, y: usize| data[y * stride + x * 4 + 3]; + assert!(alpha(2, 2) > 0, "outside the selection must be scrimmed"); + assert_eq!(alpha(20, 20), 0, "the selected pixels must remain clear"); + } + + #[test] + fn highlighted_window_is_cut_out_in_window_mode() { + let targets = [ + RegionSelection { + start: (4.0, 4.0), + end: (14.0, 14.0), + }, + RegionSelection { + start: (20.0, 20.0), + end: (36.0, 36.0), + }, + ]; + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 40, 40).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_region_capture_picker( + &ctx, + 40, + 40, + RegionCapturePickerVisual { + selection: None, + pointer: (28.0, 28.0), + measurement: None, + show_scrim: true, + review: false, + resize_handles: None, + hovered_handle: None, + show_legend: false, + loupe: None, + action_bar: None, + hovered_action: None, + include_drawings: false, + window: RegionCaptureWindowVisual { + available: true, + active: true, + targets: &targets, + highlighted_target: Some(1), + }, + }, + |_x, _y| None, + ); + drop(ctx); + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().unwrap(); + let alpha = |x: usize, y: usize| data[y * stride + x * 4 + 3]; + assert!(alpha(1, 1) > 0, "outside windows remains scrimmed"); + assert!(alpha(9, 9) > 0, "an unhighlighted window remains scrimmed"); + assert_eq!(alpha(28, 28), 0, "highlighted window is the clear target"); + } + + #[test] + fn window_mode_omits_the_area_crosshair() { + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 40, 40).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_region_capture_picker( + &ctx, + 40, + 40, + RegionCapturePickerVisual { + selection: None, + pointer: (20.0, 20.0), + measurement: None, + show_scrim: false, + review: false, + resize_handles: None, + hovered_handle: None, + show_legend: false, + loupe: None, + action_bar: None, + hovered_action: None, + include_drawings: false, + window: RegionCaptureWindowVisual { + available: true, + active: true, + targets: &[], + highlighted_target: None, + }, + }, + |_x, _y| None, + ); + drop(ctx); + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().unwrap(); + assert_eq!(data[20 * stride + 20 * 4 + 3], 0); + } + + #[test] + fn highlighted_window_outline_is_stronger_than_other_targets() { + let targets = [ + RegionSelection { + start: (4.0, 4.0), + end: (16.0, 16.0), + }, + RegionSelection { + start: (24.0, 4.0), + end: (36.0, 16.0), + }, + ]; + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 40, 20).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_region_capture_picker( + &ctx, + 40, + 20, + RegionCapturePickerVisual { + selection: None, + pointer: (20.0, 18.0), + measurement: None, + show_scrim: false, + review: false, + resize_handles: None, + hovered_handle: None, + show_legend: false, + loupe: None, + action_bar: None, + hovered_action: None, + include_drawings: false, + window: RegionCaptureWindowVisual { + available: true, + active: true, + targets: &targets, + highlighted_target: Some(1), + }, + }, + |_x, _y| None, + ); + drop(ctx); + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().unwrap(); + let edge_alpha = |x: usize| { + (3..=6) + .flat_map(|y| ((x - 1)..=(x + 1)).map(move |sample_x| (sample_x, y))) + .map(|(sample_x, y)| u32::from(data[y * stride + sample_x * 4 + 3])) + .sum::() + }; + assert!( + edge_alpha(24) > edge_alpha(4), + "the highlighted candidate must be visually stronger" + ); + } + + #[test] + fn measure_visual_leaves_the_screen_unscrimmed() { + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 40, 40).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_region_capture_picker( + &ctx, + 40, + 40, + RegionCapturePickerVisual { + selection: None, + pointer: (20.0, 20.0), + measurement: Some("20, 20"), + show_scrim: false, + review: false, + resize_handles: None, + hovered_handle: None, + show_legend: false, + loupe: None, + action_bar: None, + hovered_action: None, + include_drawings: false, + window: RegionCaptureWindowVisual::disabled(), + }, + |_x, _y| None, + ); + drop(ctx); + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().unwrap(); + assert_eq!(data[2 * stride + 2 * 4 + 3], 0, "no measure scrim"); + assert!(data[20 * stride + 20 * 4 + 3] > 0, "measure crosshair"); + } + + #[test] + fn measure_damage_uses_thin_chrome_regions_instead_of_the_full_surface() { + let damage = measure_picker_damage( + Some(RegionSelection { + start: (100.0, 80.0), + end: (700.0, 500.0), + }), + (400.0, 300.0), + (800, 600), + ); + + assert!(damage.len() >= 7); + assert!(damage.iter().all(|rect| { + rect.x >= 0 + && rect.y >= 0 + && rect.x + rect.width <= 800 + && rect.y + rect.height <= 600 + && (rect.width < 800 || rect.height < 600) + })); + assert!( + damage + .iter() + .any(|rect| rect.width == 800 && rect.height <= 5) + ); + assert!( + damage + .iter() + .any(|rect| rect.height == 600 && rect.width <= 5) + ); + } + + #[test] + fn crosshair_remains_visible_while_selecting() { + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 40, 40).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_region_capture_picker( + &ctx, + 40, + 40, + RegionCapturePickerVisual { + selection: Some(RegionSelection { + start: (10.0, 10.0), + end: (30.0, 30.0), + }), + pointer: (20.0, 20.0), + measurement: None, + show_scrim: true, + review: false, + resize_handles: None, + hovered_handle: None, + show_legend: false, + loupe: None, + action_bar: None, + hovered_action: None, + include_drawings: false, + window: RegionCaptureWindowVisual::disabled(), + }, + |_x, _y| None, + ); + drop(ctx); + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().unwrap(); + assert!( + data[20 * stride + 20 * 4 + 3] > 0, + "crosshair must be painted inside the clear selection" + ); + } + + #[test] + fn review_visual_composes_the_action_bar_after_the_scrim() { + let selection = RegionSelection { + start: (100.0, 100.0), + end: (300.0, 200.0), + }; + let bar = crate::ui::RegionActionBar::place(selection, (800, 600)); + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 800, 600).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_region_capture_picker( + &ctx, + 800, + 600, + RegionCapturePickerVisual { + selection: Some(selection), + pointer: (200.0, 150.0), + measurement: Some("200 × 100"), + show_scrim: true, + review: true, + resize_handles: None, + hovered_handle: None, + show_legend: false, + loupe: None, + action_bar: Some(bar), + hovered_action: Some(crate::ui::RegionAction::Both), + include_drawings: true, + window: RegionCaptureWindowVisual::disabled(), + }, + |_x, _y| None, + ); + drop(ctx); + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().unwrap(); + assert!(data[250 * stride + 40 * 4 + 3] > 0, "action bar surface"); + assert_eq!( + data[150 * stride + 200 * 4 + 3], + 0, + "review drops the targeting crosshair; the selection stays clear" + ); + assert!( + data[80 * stride + 110 * 4 + 3] > 0, + "the size badge parks above the selection's top-left corner" + ); + } + + #[test] + fn the_review_size_badge_drops_inside_a_selection_flush_with_the_top_edge() { + let above = selection_badge_layout((40.0, 120.0, 200.0, 100.0), 60.0, None, (400, 300)); + assert_eq!( + above, + PointerPanelLayout { + x: 40.0, + y: 92.0, + width: 76.0, + height: 22.0, + } + ); + + let flush = selection_badge_layout((40.0, 4.0, 200.0, 100.0), 60.0, None, (400, 300)); + assert_eq!( + flush, + PointerPanelLayout { + x: 40.0, + y: 10.0, + width: 76.0, + height: 22.0, + }, + "no room above: the badge drops just inside the rectangle" + ); + + let clamped = selection_badge_layout((380.0, 200.0, 20.0, 20.0), 60.0, None, (400, 300)); + assert_eq!(clamped.x, 318.0, "the badge stays on screen"); + } + + #[test] + fn a_bar_that_flipped_above_the_selection_pushes_the_badge_inside_it() { + // A selection low on the screen leaves no room for the bar below it, + // so the bar takes the space the badge would otherwise use. + let selection = RegionSelection { + start: (730.0, 560.0), + end: (790.0, 590.0), + }; + let bar = RegionActionBar::place(selection, (800, 600)); + let bounds = bar.bounds(); + assert!( + bounds.y + bounds.height < 560.0, + "precondition: the bar flipped above the selection" + ); + + let rect = normalized_rect(selection); + let badge = selection_badge_layout(rect, 60.0, Some(bounds), (800, 600)); + assert!( + !covered_by_action_bar(badge.x, badge.y, badge.width, badge.height, Some(bounds)), + "the badge must not be painted under the bar" + ); + assert!( + badge.y >= 560.0, + "it drops inside the rectangle instead of above it" + ); + + // Without the bar the same selection keeps the outside-above spot. + let unobstructed = selection_badge_layout(rect, 60.0, None, (800, 600)); + assert!(unobstructed.y < 560.0); + } + + /// The layout choice above only helps if the composed frame agrees: the + /// bar is painted after the badge, so a badge under it would simply + /// disappear. + #[test] + fn the_flipped_above_review_bar_never_paints_over_the_size_badge() { + let selection = RegionSelection { + start: (730.0, 560.0), + end: (790.0, 590.0), + }; + let bar = RegionActionBar::place(selection, (800, 600)); + let render = |measurement: Option<&str>| { + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 800, 600).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_region_capture_picker( + &ctx, + 800, + 600, + RegionCapturePickerVisual { + selection: Some(selection), + pointer: (760.0, 575.0), + measurement, + show_scrim: true, + review: true, + resize_handles: None, + hovered_handle: None, + show_legend: false, + loupe: None, + action_bar: Some(bar), + hovered_action: None, + include_drawings: false, + window: RegionCaptureWindowVisual::disabled(), + }, + |_x, _y| None, + ); + drop(ctx); + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().unwrap().to_vec(); + (data, stride) + }; + + let (with_badge, stride) = render(Some("60 × 30")); + let (without_badge, _) = render(None); + let bounds = bar.bounds(); + let mut visible_badge_pixels = 0usize; + for y in 0..600 { + for x in 0..800 { + let offset = y * stride + x * 4; + if with_badge[offset..offset + 4] == without_badge[offset..offset + 4] { + continue; + } + let inside_bar = (bounds.x..bounds.x + bounds.width).contains(&(x as f64)) + && (bounds.y..bounds.y + bounds.height).contains(&(y as f64)); + if !inside_bar { + visible_badge_pixels += 1; + } + } + } + // The badge box is 76 x 22; essentially all of it must survive. + assert!( + visible_badge_pixels > 1_000, + "only {visible_badge_pixels} badge pixels escaped the action bar" + ); + } + + #[test] + fn a_badge_blocked_on_the_left_slides_to_the_selections_right_edge() { + // A bar clamped over the top-left of a large selection: above and + // inside-left are both covered, inside-right is clear. + let bar = RegionActionRect::new(0.0, 0.0, 300.0, 90.0); + let badge = selection_badge_layout((20.0, 40.0, 600.0, 400.0), 60.0, Some(bar), (800, 600)); + assert!(!covered_by_action_bar( + badge.x, + badge.y, + badge.width, + badge.height, + Some(bar) + )); + assert_eq!(badge.x, 544.0, "right-aligned inside the selection"); + } + + #[test] + fn a_badge_with_no_clear_placement_falls_back_inside_the_selection() { + // A full-width bar over the whole rectangle leaves nothing clear; the + // badge still lands on the selection rather than somewhere arbitrary. + let bar = RegionActionRect::new(0.0, 0.0, 800.0, 600.0); + let rect = (20.0, 40.0, 600.0, 400.0); + assert_eq!( + selection_badge_layout(rect, 60.0, Some(bar), (800, 600)), + PointerPanelLayout { + x: 20.0, + y: 46.0, + width: 76.0, + height: 22.0, + }, + "inside the rectangle's top-left corner is the fallback" + ); + } + + #[test] + fn review_paints_grips_where_targeting_paints_corner_arms() { + let selection = RegionSelection { + start: (60.0, 60.0), + end: (240.0, 200.0), + }; + let render = |review: bool| { + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 300, 260).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_region_capture_picker( + &ctx, + 300, + 260, + RegionCapturePickerVisual { + selection: Some(selection), + // Park the pointer in a corner: targeting still paints a + // crosshair, and its two lines must not cross the pixels + // this test samples. + pointer: (8.0, 252.0), + measurement: None, + // No scrim, so only chrome puts ink on the surface and + // the two frames can be compared pixel for pixel. + show_scrim: false, + review, + resize_handles: review + .then(|| crate::ui::RegionResizeHandles::place(selection)), + hovered_handle: None, + show_legend: false, + loupe: None, + action_bar: None, + hovered_action: None, + include_drawings: false, + window: RegionCaptureWindowVisual::disabled(), + }, + |_x, _y| None, + ); + drop(ctx); + surface.flush(); + let stride = surface.stride() as usize; + (surface.data().unwrap().to_vec(), stride) + }; + + let (reviewing, stride) = render(true); + let (targeting, _) = render(false); + let alpha = |data: &[u8], x: usize, y: usize| data[y * stride + x * 4 + 3]; + + // An edge midpoint carries a grip only in Review, and the chip reaches + // above the frame line the two modes share. + assert!(alpha(&reviewing, 150, 57) > 0, "top edge grip"); + assert_eq!(alpha(&targeting, 150, 57), 0, "targeting has no edge grip"); + assert!(alpha(&reviewing, 60, 60) > 0, "top-left corner grip"); + + // The corner arms run inward along both edges from each corner. Review + // drops them so they cannot fight the corner grip for the same pixels; + // this row is past the grip but still inside the arm's reach. + assert!( + alpha(&targeting, 59, 75) > 0, + "targeting draws a corner arm below the top-left corner" + ); + assert_eq!( + alpha(&reviewing, 59, 75), + 0, + "review drops the arm; the grip covers the corner instead" + ); + } + + #[test] + fn capture_loupe_reuses_the_pixel_loupe_renderer_when_enabled() { + let visual = RegionCaptureLoupeVisual::when_enabled(true, (20.0, 30.0), (50.0, 50.0)) + .expect("enabled immutable option"); + assert!( + RegionCaptureLoupeVisual::when_enabled(false, (20.0, 30.0), (50.0, 50.0),).is_none() + ); + + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 200, 200).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_region_capture_loupe(&ctx, (200, 200), visual, |_x, _y| { + Some(crate::draw::Color::new(1.0, 0.0, 0.0, 1.0)) + }); + drop(ctx); + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().unwrap(); + assert!(data[98 * stride + 88 * 4 + 3] > 0, "loupe center pixel"); + assert_eq!(data[5 * stride + 5 * 4 + 3], 0, "outside untouched"); + } +} diff --git a/src/ui/region_resize_handles.rs b/src/ui/region_resize_handles.rs new file mode 100644 index 000000000..c5bcaf3b9 --- /dev/null +++ b/src/ui/region_resize_handles.rs @@ -0,0 +1,395 @@ +use crate::input::SelectionHandle; +use crate::input::state::RegionSelection; +use crate::ui::theme::{self, Rgba}; + +use super::primitives::draw_rounded_rect; + +/// Side length of a corner chip. One step up from the canvas selection handles +/// because the picker paints over a screenshot rather than the board, and the +/// rectangle it decorates is usually much larger than a shape's bounds. +const CORNER_SIZE: f64 = 12.0; +/// Edge-midpoint chips are three quarters of a corner — the same ratio the +/// canvas selection handles use, so the two surfaces read as one language. +const EDGE_SIZE: f64 = CORNER_SIZE * 0.75; +const RADIUS: f64 = 3.0; +/// Extra forgiveness around a chip, for hit testing only. The drawn chip stays +/// small; the grab target is deliberately larger than it looks. +const HIT_TOLERANCE: f64 = 4.0; +/// How far a full-size corner grip reaches from its centre. +const CORNER_REACH: f64 = CORNER_SIZE / 2.0 + HIT_TOLERANCE; +/// Grips may claim at most this fraction of a side, measured from each end, so +/// a central move area always survives. Four corner reaches therefore need a +/// side of `CORNER_REACH / GRIP_SIDE_SHARE` before the grips stop shrinking. +const GRIP_SIDE_SHARE: f64 = 0.25; +/// A side shorter than this cannot seat an edge chip between its two corner +/// chips without them touching, so that side's midpoint handle is dropped. +/// Corners are always offered, so a rectangle is never unresizable. +const MIN_SIDE_FOR_EDGE_HANDLE: f64 = 48.0; + +const FILL: Rgba = (1.0, 1.0, 1.0, 0.96); +const FILL_HOVER: Rgba = theme::rgba(theme::ACCENT_BRIGHT_RGB, 1.0); +const BORDER: Rgba = theme::rgba(theme::ACCENT_RGB, 0.95); +const BORDER_WIDTH: f64 = 1.5; +/// A chip narrower than this cannot show a white body behind a ring at all, so +/// it is painted as a solid accent dot instead. Keeps the ring from collapsing +/// into sub-pixel geometry on a rectangle whose grips have scaled right down. +const MIN_BORDERED_SIZE: f64 = 4.0; + +#[derive(Debug, Clone, Copy, PartialEq)] +struct HandleChip { + handle: SelectionHandle, + center: (f64, f64), + size: f64, + /// Half-extent of the grab target, already scaled with the chip. + reach: f64, +} + +impl HandleChip { + fn contains(self, point: (f64, f64)) -> bool { + (point.0 - self.center.0).abs() <= self.reach + && (point.1 - self.center.1).abs() <= self.reach + } + + fn squared_distance_to(self, point: (f64, f64)) -> f64 { + let dx = point.0 - self.center.0; + let dy = point.1 - self.center.1; + dx * dx + dy * dy + } + + const fn rect(self) -> (f64, f64, f64, f64) { + let half = self.size / 2.0; + ( + self.center.0 - half, + self.center.1 - half, + self.size, + self.size, + ) + } +} + +/// The eight resize grips on a reviewed rectangle: four corners, always +/// present, plus the four edge midpoints when their side is long enough to +/// seat one. Chips are centred on the edge rather than parked outside it, so a +/// selection flush with the screen edge keeps every grip reachable. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct RegionResizeHandles { + /// Corners first: their order breaks a tie when two chips sit on the same + /// point, matching the priority `selection_handle_probes` uses. + chips: [Option; 8], +} + +impl RegionResizeHandles { + pub(crate) fn place(selection: RegionSelection) -> Self { + let left = selection.start.0.min(selection.end.0); + let right = selection.start.0.max(selection.end.0); + let top = selection.start.1.min(selection.end.1); + let bottom = selection.start.1.max(selection.end.1); + let mid_x = (left + right) / 2.0; + let mid_y = (top + bottom) / 2.0; + let horizontal_room = (right - left) >= MIN_SIDE_FOR_EDGE_HANDLE; + let vertical_room = (bottom - top) >= MIN_SIDE_FOR_EDGE_HANDLE; + // On a rectangle small enough that four full-size corner targets would + // meet in the middle, the grips shrink together rather than swallowing + // the interior — otherwise a small rectangle could be resized but never + // dragged. Chip and target scale as one, so what is drawn stays what is + // grabbable. + let shortest = (right - left).min(bottom - top).max(0.0); + let scale = (shortest * GRIP_SIDE_SHARE / CORNER_REACH).min(1.0); + let corner = |handle, center| { + Some(HandleChip { + handle, + center, + size: CORNER_SIZE * scale, + reach: CORNER_REACH * scale, + }) + }; + let edge = |handle, center, room: bool| { + room.then_some(HandleChip { + handle, + center, + size: EDGE_SIZE * scale, + reach: (EDGE_SIZE / 2.0 + HIT_TOLERANCE) * scale, + }) + }; + Self { + chips: [ + corner(SelectionHandle::TopLeft, (left, top)), + corner(SelectionHandle::TopRight, (right, top)), + corner(SelectionHandle::BottomLeft, (left, bottom)), + corner(SelectionHandle::BottomRight, (right, bottom)), + edge(SelectionHandle::Top, (mid_x, top), horizontal_room), + edge(SelectionHandle::Bottom, (mid_x, bottom), horizontal_room), + edge(SelectionHandle::Left, (left, mid_y), vertical_room), + edge(SelectionHandle::Right, (right, mid_y), vertical_room), + ], + } + } + + /// The grip under `point`, or the nearest one where two grab targets + /// overlap. Nearest rather than first matters on a rectangle small enough + /// that every chip's target covers the whole thing: taking the first would + /// leave only the top-left corner reachable, so the rectangle could be + /// grown up and left but never down and right. + pub(crate) fn hit(self, point: (f64, f64)) -> Option { + self.chips + .into_iter() + .flatten() + .filter(|chip| chip.contains(point)) + .min_by(|left, right| { + left.squared_distance_to(point) + .total_cmp(&right.squared_distance_to(point)) + }) + .map(|chip| chip.handle) + } +} + +pub(crate) fn render_region_resize_handles( + ctx: &cairo::Context, + handles: RegionResizeHandles, + hovered: Option, +) { + let _ = ctx.save(); + for chip in handles.chips.into_iter().flatten() { + let (x, y, width, height) = chip.rect(); + if width <= 0.0 || height <= 0.0 { + continue; + } + let hovered_chip = hovered == Some(chip.handle); + // Radius and border track the chip, so a scaled-down grip stays a + // rounded chip rather than becoming accidental geometry. + let radius = RADIUS.min(chip.size / 4.0); + if chip.size < MIN_BORDERED_SIZE { + theme::set_color(ctx, if hovered_chip { FILL_HOVER } else { BORDER }); + draw_rounded_rect(ctx, x, y, width, height, radius); + let _ = ctx.fill(); + continue; + } + + theme::set_color(ctx, if hovered_chip { FILL_HOVER } else { FILL }); + draw_rounded_rect(ctx, x, y, width, height, radius); + let _ = ctx.fill(); + + // At most an eighth of the chip per side, so the inset rectangle is + // always positive. + let border = BORDER_WIDTH.min(chip.size / 8.0); + theme::set_color(ctx, BORDER); + ctx.set_line_width(border); + draw_rounded_rect( + ctx, + x + border / 2.0, + y + border / 2.0, + width - border, + height - border, + (radius - border / 2.0).max(0.0), + ); + let _ = ctx.stroke(); + } + let _ = ctx.restore(); +} + +#[cfg(test)] +mod tests { + use super::*; + + fn selection(x: f64, y: f64, width: f64, height: f64) -> RegionSelection { + RegionSelection { + start: (x, y), + end: (x + width, y + height), + } + } + + #[test] + fn a_roomy_rectangle_offers_all_eight_grips_at_its_corners_and_midpoints() { + let handles = RegionResizeHandles::place(selection(100.0, 50.0, 200.0, 120.0)); + for (point, handle) in [ + ((100.0, 50.0), SelectionHandle::TopLeft), + ((300.0, 50.0), SelectionHandle::TopRight), + ((100.0, 170.0), SelectionHandle::BottomLeft), + ((300.0, 170.0), SelectionHandle::BottomRight), + ((200.0, 50.0), SelectionHandle::Top), + ((200.0, 170.0), SelectionHandle::Bottom), + ((100.0, 110.0), SelectionHandle::Left), + ((300.0, 110.0), SelectionHandle::Right), + ] { + assert_eq!(handles.hit(point), Some(handle), "at {point:?}"); + } + assert_eq!( + handles.hit((200.0, 110.0)), + None, + "the interior is not a grip" + ); + assert_eq!(handles.hit((400.0, 400.0)), None, "far outside"); + } + + #[test] + fn a_reversed_selection_places_the_same_grips() { + let forward = RegionResizeHandles::place(selection(100.0, 50.0, 200.0, 120.0)); + let backward = RegionResizeHandles::place(RegionSelection { + start: (300.0, 170.0), + end: (100.0, 50.0), + }); + assert_eq!(forward, backward); + } + + #[test] + fn grab_targets_are_larger_than_the_drawn_chips() { + let handles = RegionResizeHandles::place(selection(100.0, 50.0, 200.0, 120.0)); + // A corner chip is 12 wide, so its ink stops 6px out; a rectangle this + // roomy gets the full CORNER_REACH of 10. + assert_eq!( + handles.hit((109.0, 50.0)), + Some(SelectionHandle::TopLeft), + "inside the tolerance ring" + ); + assert_eq!( + handles.hit((111.0, 50.0)), + None, + "beyond the tolerance ring" + ); + } + + #[test] + fn a_short_side_drops_its_midpoint_grip_but_never_its_corners() { + // 200 wide is roomy; 20 tall is not, so Left/Right go and Top/Bottom stay. + let wide = RegionResizeHandles::place(selection(100.0, 50.0, 200.0, 20.0)); + assert_eq!(wide.hit((200.0, 50.0)), Some(SelectionHandle::Top)); + assert_eq!(wide.hit((200.0, 70.0)), Some(SelectionHandle::Bottom)); + assert_eq!(wide.hit((100.0, 52.0)), Some(SelectionHandle::TopLeft)); + assert_eq!(wide.hit((300.0, 52.0)), Some(SelectionHandle::TopRight)); + + let tiny = RegionResizeHandles::place(selection(100.0, 50.0, 6.0, 6.0)); + for (point, handle) in [ + ((100.0, 50.0), SelectionHandle::TopLeft), + ((106.0, 56.0), SelectionHandle::BottomRight), + ] { + assert_eq!( + tiny.hit(point), + Some(handle), + "a collapsed rectangle stays resizable" + ); + } + } + + #[test] + fn grips_never_swallow_the_move_area_however_small_the_rectangle() { + // Grips are hit-tested before the interior, so a rectangle whose grab + // targets met in the middle could be resized but never dragged. + for side in [6.0, 12.0, 20.0, 30.0, 40.0, 41.0, 80.0, 400.0] { + let handles = RegionResizeHandles::place(selection(0.0, 0.0, side, side)); + let middle = (side / 2.0, side / 2.0); + assert_eq!( + handles.hit(middle), + None, + "a {side} x {side} rectangle must keep a draggable centre" + ); + assert_eq!( + handles.hit((0.0, 0.0)), + Some(SelectionHandle::TopLeft), + "and must stay resizable at {side} x {side}" + ); + } + } + + #[test] + fn grips_stop_shrinking_once_the_rectangle_can_seat_them() { + // Above the threshold every rectangle gets identical, full-size grips. + let threshold = CORNER_REACH / GRIP_SIDE_SHARE; + let at = RegionResizeHandles::place(selection(0.0, 0.0, threshold, threshold)); + let far_above = RegionResizeHandles::place(selection(0.0, 0.0, 600.0, 600.0)); + let chip = |handles: RegionResizeHandles| handles.chips[0].unwrap(); + assert_eq!(chip(at).size, CORNER_SIZE); + assert_eq!(chip(at).reach, CORNER_REACH); + assert_eq!(chip(far_above).size, CORNER_SIZE); + + // Below it they scale linearly with the shortest side. + let half = RegionResizeHandles::place(selection(0.0, 0.0, threshold / 2.0, 600.0)); + assert_eq!(chip(half).size, CORNER_SIZE / 2.0); + assert_eq!(chip(half).reach, CORNER_REACH / 2.0); + } + + #[test] + fn corners_win_where_their_targets_overlap_an_edge_chip() { + // At exactly the threshold the Top chip's target and the TopLeft chip's + // target still meet; the corner must answer, matching hit priority in + // the canvas selection handles. + let handles = + RegionResizeHandles::place(selection(0.0, 0.0, MIN_SIDE_FOR_EDGE_HANDLE, 200.0)); + assert_eq!(handles.hit((0.0, 0.0)), Some(SelectionHandle::TopLeft)); + assert_eq!( + handles.hit((MIN_SIDE_FOR_EDGE_HANDLE / 2.0, 0.0)), + Some(SelectionHandle::Top) + ); + } + + #[test] + fn tiny_rectangles_render_solid_dots_without_negative_border_geometry() { + // Grips scale with the rectangle, so a 1x1 crop drives every chip below + // the border width. Nothing may be asked to stroke a negative rect. + for side in [1.0, 2.0, 3.0, 4.0, 6.0, 12.0] { + let handles = RegionResizeHandles::place(selection(20.0, 20.0, side, side)); + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 60, 60).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_region_resize_handles(&ctx, handles, Some(SelectionHandle::TopLeft)); + assert_eq!( + ctx.status(), + Ok(()), + "a {side} x {side} rectangle must leave Cairo healthy" + ); + drop(ctx); + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().unwrap(); + assert!( + data[20 * stride + 20 * 4 + 3] > 0, + "the top-left grip is still visible at {side} x {side}" + ); + assert_eq!( + data[55 * stride + 55 * 4 + 3], + 0, + "and nothing leaks far outside it" + ); + } + } + + #[test] + fn a_scaled_chip_keeps_its_border_inside_itself() { + // The inset the renderer strokes must stay positive at every size. + for size in [1.0_f64, 1.5, 4.0, 8.0, CORNER_SIZE] { + let border = BORDER_WIDTH.min(size / 8.0); + assert!( + size - border > 0.0, + "a {size} chip would stroke a {}-wide rectangle", + size - border + ); + let radius = RADIUS.min(size / 4.0); + assert!(radius >= 0.0 && radius <= size / 2.0); + } + } + + #[test] + fn rendering_paints_a_chip_at_every_offered_grip() { + let handles = RegionResizeHandles::place(selection(20.0, 20.0, 160.0, 160.0)); + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 200, 200).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + render_region_resize_handles(&ctx, handles, Some(SelectionHandle::TopLeft)); + drop(ctx); + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().unwrap(); + let alpha = |x: usize, y: usize| data[y * stride + x * 4 + 3]; + + for (x, y) in [ + (20, 20), + (180, 20), + (20, 180), + (180, 180), + (100, 20), + (100, 180), + (20, 100), + (180, 100), + ] { + assert!(alpha(x, y) > 0, "grip at ({x}, {y})"); + } + assert_eq!(alpha(100, 100), 0, "the interior stays clear"); + } +}