From 64c56c29dd4ccf515263bb42810671c58b6b3551 Mon Sep 17 00:00:00 2001
From: devmobasa <4170275+devmobasa@users.noreply.github.com>
Date: Sat, 22 Aug 2026 15:17:32 +0200
Subject: [PATCH 01/13] feat(capture): add native region picker
---
CONTRIBUTING.md | 26 +
README.md | 8 +
config.example.toml | 18 +
configurator/src/app/pages/capture.rs | 30 +-
configurator/src/app/search/summary.rs | 7 +
configurator/src/app/search/terms.rs | 12 +
configurator/src/app/search/tests.rs | 22 +
configurator/src/app/search/types.rs | 1 +
configurator/src/app/update/fields.rs | 12 +-
configurator/src/app/update/mod.rs | 3 +
configurator/src/messages.rs | 13 +-
.../src/models/config/draft/from_config.rs | 8 +-
configurator/src/models/config/draft/mod.rs | 8 +-
configurator/src/models/config/setters.rs | 5 +
configurator/src/models/config/tests.rs | 31 +-
.../src/models/config/to_config/capture.rs | 4 +
configurator/src/models/fields/capture.rs | 40 +
configurator/src/models/fields/mod.rs | 2 +
configurator/src/models/fields/tests.rs | 20 +
configurator/src/models/fields/toggles.rs | 3 +
configurator/src/models/mod.rs | 8 +-
docs/CONFIG.md | 41 +-
src/backend/wayland/acquisition.rs | 2 -
.../wayland/backend/event_loop/capture.rs | 70 +-
src/backend/wayland/backend/event_loop/mod.rs | 1 +
src/backend/wayland/capture.rs | 245 +++++-
src/backend/wayland/frozen/state.rs | 2 -
src/backend/wayland/handlers/keyboard/mod.rs | 97 ++-
.../wayland/handlers/pointer/enter_leave.rs | 3 +
src/backend/wayland/handlers/pointer/press.rs | 4 +-
src/backend/wayland/handlers/seat.rs | 2 +
src/backend/wayland/handlers/tablet/tool.rs | 2 +-
src/backend/wayland/handlers/touch.rs | 2 +-
src/backend/wayland/state.rs | 3 +
src/backend/wayland/state/acquisition.rs | 62 +-
.../wayland/state/acquisition/transaction.rs | 65 +-
.../state/acquisition/transaction/tests.rs | 260 +++++--
src/backend/wayland/state/capture.rs | 34 +-
src/backend/wayland/state/core/overlay.rs | 58 ++
src/backend/wayland/state/gtk_toolbar.rs | 42 +-
src/backend/wayland/state/ocr.rs | 82 +-
src/backend/wayland/state/region_capture.rs | 702 ++++++++++++++++--
.../wayland/state/region_capture/geometry.rs | 101 +++
.../wayland/state/region_capture/intent.rs | 165 ++++
.../wayland/state/region_capture/picker.rs | 647 ++++++++++++++++
.../wayland/state/render/canvas/mod.rs | 30 +-
src/backend/wayland/state/render/mod.rs | 12 +
src/backend/wayland/state/render/ui.rs | 94 ++-
src/backend/wayland/state/screen_image.rs | 5 +-
src/backend/wayland/state/toolbar/events.rs | 2 +-
.../wayland/state/toolbar/visibility/sync.rs | 29 +-
src/backend/wayland/zoom/state.rs | 1 -
src/config/enums.rs | 12 +
src/config/mod.rs | 27 +-
src/config/tests/load.rs | 32 +
src/config/tests/schema.rs | 16 +
src/config/types/capture.rs | 64 ++
src/config/types/mod.rs | 4 +-
src/domain/action.rs | 14 +
src/domain/tests.rs | 22 +
.../state/actions/action_capture_zoom.rs | 56 +-
src/input/state/core/region_select.rs | 6 +
src/input/state/tests/text_input/actions.rs | 18 +
src/screen_pixels.rs | 10 +-
src/ui.rs | 4 +
src/ui/region_capture_picker.rs | 343 +++++++++
66 files changed, 3527 insertions(+), 247 deletions(-)
create mode 100644 configurator/src/models/fields/capture.rs
create mode 100644 src/backend/wayland/state/region_capture/geometry.rs
create mode 100644 src/backend/wayland/state/region_capture/intent.rs
create mode 100644 src/backend/wayland/state/region_capture/picker.rs
create mode 100644 src/ui/region_capture_picker.rs
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 6f59803a6..b86389209 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -149,6 +149,32 @@ 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.
+- 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.
+
+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..53d9db438 100644
--- a/README.md
+++ b/README.md
@@ -745,6 +745,14 @@ Light passthrough (layer-shell compositors only) lets normal keyboard and pointe
| Ctrl+Alt+6 | Region → save PNG (explicit) |
| 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 and hotkey legend are configurable under `[capture.region]`; the loupe
+setting is accepted but is not rendered yet. 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.
+
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..4f4e8dc38 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -1417,6 +1417,24 @@ 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.
+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.
+# This key is accepted now; the loupe is not rendered yet.
+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..9c2a64cab 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;
@@ -72,6 +72,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",
+ "The preference is saved now; loupe rendering will become available in a later release.",
+ |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/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..6df0b03c0 100644
--- a/configurator/src/app/search/terms.rs
+++ b/configurator/src/app/search/terms.rs
@@ -376,6 +376,18 @@ pub(super) const CAPTURE_FILE_TERMS: &[&str] = &[
"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..4cda704bd 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() {
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..574af2bbd 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::{
@@ -250,6 +250,10 @@ impl ConfigDraft {
capture_copy_to_clipboard: config.capture.copy_to_clipboard,
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..7a7424003 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};
@@ -183,6 +183,10 @@ pub struct ConfigDraft {
pub capture_copy_to_clipboard: 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..843060528 100644
--- a/configurator/src/models/config/setters.rs
+++ b/configurator/src/models/config/setters.rs
@@ -258,6 +258,11 @@ impl ConfigDraft {
ToggleField::CaptureEnabled => self.capture_enabled = value,
ToggleField::CaptureCopyToClipboard => self.capture_copy_to_clipboard = 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..eab269bd4 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,32 @@ 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_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);
+}
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..2147a09bc 100644
--- a/configurator/src/models/config/to_config/capture.rs
+++ b/configurator/src/models/config/to_config/capture.rs
@@ -24,6 +24,10 @@ impl ConfigDraft {
}
config.capture.copy_to_clipboard = self.capture_copy_to_clipboard;
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/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..533ae113b 100644
--- a/configurator/src/models/fields/toggles.rs
+++ b/configurator/src/models/fields/toggles.rs
@@ -64,6 +64,9 @@ pub enum ToggleField {
CaptureEnabled,
CaptureCopyToClipboard,
CaptureExitAfter,
+ CaptureRegionShowSizeReadout,
+ CaptureRegionShowLoupe,
+ CaptureRegionShowLegend,
ExportPdfLabelsEnabled,
ExportPdfLabelBackgroundEnabled,
SessionPersistTransparent,
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..05a39ef7d 100644
--- a/docs/CONFIG.md
+++ b/docs/CONFIG.md
@@ -1399,13 +1399,52 @@ 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
+
+# Accepted and saved now, but the picker does not render the loupe yet
+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.
- Clipboard-only shortcuts ignore the save directory automatically.
- `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`.
+
+- 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.
+- `show_legend` shows the short hotkey guide until the first drag.
+- `show_loupe` is accepted and preserved in configuration, but the native
+ picker does not render the loupe yet.
+- 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)
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..f339475eb 100644
--- a/src/backend/wayland/backend/event_loop/capture.rs
+++ b/src/backend/wayland/backend/event_loop/capture.rs
@@ -140,6 +140,12 @@ 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();
@@ -186,6 +192,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,
@@ -570,6 +591,7 @@ mod tests {
for owner in [
ScreenAcquisitionOwner::Eyedropper,
ScreenAcquisitionOwner::Ocr,
+ ScreenAcquisitionOwner::RegionCapture,
] {
let modal = record(owner, AcquisitionStage::Queued);
@@ -585,15 +607,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 +644,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..dc7680a4b 100644
--- a/src/backend/wayland/capture.rs
+++ b/src/backend/wayland/capture.rs
@@ -11,6 +11,8 @@ use crate::{
config::Action,
};
+use super::state::RegionCaptureIntent;
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CapturePreflight {
None,
@@ -49,6 +51,15 @@ 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,
+}
+
impl CaptureLayoutContext {
pub(in crate::backend::wayland) fn new(target_output_id: u32, layout_generation: u64) -> Self {
Self {
@@ -80,6 +91,7 @@ pub struct CaptureState {
preflight: CapturePreflight,
pending_request: Option,
pending_pdf_export: Option,
+ region: RegionCapturePhase,
}
impl CaptureState {
@@ -93,6 +105,7 @@ impl CaptureState {
preflight: CapturePreflight::None,
pending_request: None,
pending_pdf_export: None,
+ region: RegionCapturePhase::Idle,
}
}
@@ -157,15 +170,88 @@ 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;
}
/// Records the manager identity accepted for the current lifecycle.
@@ -173,13 +259,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;
@@ -204,7 +303,23 @@ 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),
+ )
+ }
fn screenshot_request() -> CapturePreflightRequest {
CapturePreflightRequest::Screenshot(CaptureRequest {
@@ -299,6 +414,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/state.rs b/src/backend/wayland/frozen/state.rs
index 0dfba8dd2..c2160fcb1 100644
--- a/src/backend/wayland/frozen/state.rs
+++ b/src/backend/wayland/frozen/state.rs
@@ -488,7 +488,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 +504,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,
diff --git a/src/backend/wayland/handlers/keyboard/mod.rs b/src/backend/wayland/handlers/keyboard/mod.rs
index e6e0abae0..67e041cfb 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,16 +161,53 @@ 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 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;
+ }
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;
+ {
+ 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, visibly
+ // changing a Shift-squared drag that remains active.
+ self.handle_capture_action(action);
+ 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) {
+ if matches!(key, Key::Escape) {
+ if self
+ .input_state
+ .region_state()
+ .purpose()
+ .is_some_and(crate::input::state::RegionPurposeTag::is_capture)
+ {
+ self.cancel_region_capture();
+ } else {
+ self.cancel_ocr();
+ }
+ } else if action == Some(Action::CopyTextFromScreen)
+ && self.input_state.region_state().purpose()
+ == Some(crate::input::state::RegionPurposeTag::Ocr)
+ {
self.cancel_ocr();
}
return;
@@ -311,6 +349,7 @@ impl KeyboardHandler for WaylandState {
modifiers.alt,
modifiers.logo,
);
+ self.sync_region_square_modifier(modifiers.shift);
}
fn repeat_key(
@@ -490,6 +529,18 @@ 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'))
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -528,6 +579,42 @@ 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 toolbar_routing_only_allows_activate_and_tab_keys() {
assert!(should_try_toolbar_key(Key::Tab, false));
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/press.rs b/src/backend/wayland/handlers/pointer/press.rs
index 4ba2417a5..7bc4659d4 100644
--- a/src/backend/wayland/handlers/pointer/press.rs
+++ b/src/backend/wayland/handlers/pointer/press.rs
@@ -42,7 +42,7 @@ 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 => {
@@ -53,7 +53,7 @@ impl WaylandState {
);
}
BTN_RIGHT => {
- self.cancel_ocr();
+ self.cancel_active_region_selector();
self.set_suppress_next_release(true);
}
_ => {}
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..c8457a5f0 100644
--- a/src/backend/wayland/handlers/tablet/tool.rs
+++ b/src/backend/wayland/handlers/tablet/tool.rs
@@ -245,7 +245,7 @@ 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();
state.begin_region_selection(RegionInputSource::Stylus, x, y);
diff --git a/src/backend/wayland/handlers/touch.rs b/src/backend/wayland/handlers/touch.rs
index 569d12148..32851da30 100644
--- a/src/backend/wayland/handlers/touch.rs
+++ b/src/backend/wayland/handlers/touch.rs
@@ -215,7 +215,7 @@ 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 {
self.begin_region_selection(
RegionInputSource::Touch,
diff --git a/src/backend/wayland/state.rs b/src/backend/wayland/state.rs
index 2e0cbfce0..46e5618f2 100644
--- a/src/backend/wayland/state.rs
+++ b/src/backend/wayland/state.rs
@@ -114,6 +114,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;
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/capture.rs b/src/backend/wayland/state/capture.rs
index 3307462ce..0eb141510 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,
@@ -68,6 +71,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)
@@ -328,7 +358,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/overlay.rs b/src/backend/wayland/state/core/overlay.rs
index 2a93fc43e..ac8bc643e 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,48 @@ 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,
+ }
+ ));
+ 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/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..84984080c 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,21 @@ use super::screen_image::{
const TOAST_SOURCE: &str = "ocr";
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum ActiveRegionCancelTarget {
+ None,
+ Ocr,
+ Capture,
+}
+
+fn active_region_cancel_target(purpose: Option) -> ActiveRegionCancelTarget {
+ match purpose {
+ Some(RegionPurposeTag::Ocr) => ActiveRegionCancelTarget::Ocr,
+ Some(purpose) if purpose.is_capture() => ActiveRegionCancelTarget::Capture,
+ Some(_) | 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) {
@@ -241,6 +257,22 @@ 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::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::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,10 +281,15 @@ 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::RearmedCapture => true,
+ RegionOwnerLoss::Cancel(RegionPurposeTag::Ocr) => self.cancel_ocr(),
+ 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,
@@ -276,7 +313,21 @@ impl WaylandState {
) {
RegionSelectionFinalize::NotOwned => return false,
RegionSelectionFinalize::Rearmed => return true,
- RegionSelectionFinalize::Selected(rect) => rect,
+ 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 false,
};
// The crop is taken while the capture is still held: releasing first
@@ -489,6 +540,27 @@ 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(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..1ac2cfe7e 100644
--- a/src/backend/wayland/state/region_capture.rs
+++ b/src/backend/wayland/state/region_capture.rs
@@ -1,8 +1,15 @@
use crate::backend::wayland::acquisition::ScreenAcquisitionId;
use crate::input::state::{
- RegionInputSource, RegionPurposeTag, RegionSelectUiState, ScreenCaptureSource,
+ RegionInputSource, RegionPurposeTag, RegionSelectUiState, RegionSelection, ScreenCaptureSource,
};
-use crate::screen_pixels::{ImagePixelRect, ImagePoint, clamp_edge, pixel_span};
+
+mod geometry;
+mod intent;
+mod picker;
+
+use crate::screen_pixels::{ImagePixelRect, ImagePoint, clamp_edge};
+pub(in crate::backend::wayland) use geometry::{RegionPickerMeasurement, RegionSelectionGeometry};
+pub(in crate::backend::wayland) use intent::{RegionCaptureIntent, RegionPickerOptions};
use super::WaylandState;
use super::screen_image::{
@@ -35,6 +42,8 @@ pub(super) enum ActiveScreenRegion {
raw_edge: Option,
logical_anchor: Option<(f64, f64)>,
logical_edge: Option<(f64, f64)>,
+ square_modifier: bool,
+ legend_dismissed: bool,
},
}
@@ -81,14 +90,21 @@ impl ActiveScreenRegion {
}
}
+ pub const fn legend_dismissed(self) -> bool {
+ matches!(
+ self,
+ Self::Ready {
+ legend_dismissed: true,
+ ..
+ }
+ )
+ }
+
fn selection_rect(self) -> Option {
let Self::Ready {
purpose,
- anchor: Some(anchor),
- raw_edge: Some(raw_edge),
logical_anchor,
logical_edge,
- source,
..
} = self
else {
@@ -104,9 +120,87 @@ impl ActiveScreenRegion {
return None;
}
}
- pixel_span(anchor, raw_edge, source.image_size)?
- .try_into()
- .ok()
+ self.selection_geometry()?.image_rect()
+ }
+
+ fn selection_geometry(self) -> Option {
+ 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,
+ )
+ }
+
+ 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
+ }
+
+ 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)?,
+ })
+ }
+
+ fn picker_measurement(self, pointer: (f64, f64)) -> Option {
+ 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,
+ )
}
fn begin_selection(&mut self, logical: (f64, f64)) -> bool {
@@ -117,6 +211,7 @@ impl ActiveScreenRegion {
raw_edge,
logical_anchor,
logical_edge,
+ legend_dismissed,
..
} = self
else {
@@ -135,11 +230,19 @@ impl ActiveScreenRegion {
) else {
return false;
};
- debug_assert_eq!(*purpose, RegionPurposeTag::Ocr);
- *anchor = Some(mapped);
- *raw_edge = Some(mapped);
+ 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
}
@@ -166,10 +269,20 @@ impl ActiveScreenRegion {
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub(super) enum RegionSelectionFinalize {
+pub(in crate::backend::wayland) enum RegionSelectionFinalize {
NotOwned,
Rearmed,
- Selected(ImagePixelRect),
+ Selected {
+ purpose: RegionPurposeTag,
+ rect: ImagePixelRect,
+ },
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(in crate::backend::wayland) enum RegionOwnerLoss {
+ NotOwned,
+ RearmedCapture,
+ Cancel(RegionPurposeTag),
}
fn begin_region_selection_event(
@@ -178,13 +291,23 @@ fn begin_region_selection_event(
owner: RegionInputSource,
logical: (f64, f64),
) -> bool {
- if !backend
- .as_mut()
- .is_some_and(|region| region.begin_selection(logical))
- {
+ let Some(region) = backend.as_mut() else {
+ return false;
+ };
+ if !region.begin_selection(logical) {
return false;
}
- input_state.start_region_selection(owner, logical)
+ let Some(preview) = region
+ .selection_geometry()
+ .map(RegionSelectionGeometry::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
}
fn update_region_selection_event(
@@ -196,12 +319,39 @@ fn update_region_selection_event(
if !input_state.region_selection_is_owned_by(owner) {
return;
}
- if backend
- .as_mut()
- .is_some_and(|region| region.update_endpoint(logical))
+ if let Some(region) = backend.as_mut()
+ && region.update_endpoint(logical)
+ && let Some(preview) = region
+ .selection_geometry()
+ .map(RegionSelectionGeometry::display_selection)
+ {
+ input_state.update_region_selection(owner, preview.end);
+ }
+}
+
+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, logical);
+ input_state.update_region_selection(owner, preview.end);
}
+ true
+}
+
+fn initial_square_modifier(purpose: RegionPurposeTag, shift: bool) -> bool {
+ shift && purpose.selection_policy().allow_square()
}
fn rearm_region_selection_event(
@@ -224,6 +374,24 @@ fn rearm_region_selection_event(
input_state.rearm_region_selection();
}
+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;
+ }
+ let Some(purpose) = backend.as_ref().map(|region| region.purpose()) else {
+ return RegionOwnerLoss::NotOwned;
+ };
+ if !purpose.is_capture() {
+ return RegionOwnerLoss::Cancel(purpose);
+ }
+ rearm_region_selection_event(backend, input_state);
+ RegionOwnerLoss::RearmedCapture
+}
+
pub(super) fn finalize_region_selection_event(
backend: &mut Option,
input_state: &mut crate::input::InputState,
@@ -242,7 +410,11 @@ pub(super) fn finalize_region_selection_event(
rearm_region_selection_event(backend, input_state);
return RegionSelectionFinalize::Rearmed;
};
- RegionSelectionFinalize::Selected(rect)
+ let purpose = backend
+ .as_ref()
+ .expect("a selected region still has backend state")
+ .purpose();
+ RegionSelectionFinalize::Selected { purpose, rect }
}
impl WaylandState {
@@ -307,6 +479,8 @@ impl WaylandState {
raw_edge: None,
logical_anchor: None,
logical_edge: None,
+ square_modifier: initial_square_modifier(purpose, self.input_state.modifiers.shift),
+ legend_dismissed: false,
});
self.input_state.activate_region(purpose, generation);
self.debug_assert_screen_region_invariant();
@@ -349,6 +523,73 @@ impl WaylandState {
);
}
+ pub(in crate::backend::wayland) fn region_selection_geometry(
+ &self,
+ ) -> Option {
+ self.data
+ .active_screen_region
+ .and_then(ActiveScreenRegion::selection_geometry)
+ }
+
+ 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 whole_image_capture_rect(&self) -> Option {
+ match self.whole_image_region_selection()? {
+ RegionSelectionFinalize::Selected {
+ purpose: RegionPurposeTag::CaptureDeliver,
+ rect,
+ } => Some(rect),
+ RegionSelectionFinalize::Selected { .. }
+ | RegionSelectionFinalize::NotOwned
+ | RegionSelectionFinalize::Rearmed => None,
+ }
+ }
+
+ pub(in crate::backend::wayland) fn region_picker_measurement(
+ &self,
+ pointer: (f64, f64),
+ ) -> Option {
+ 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(super) fn debug_assert_screen_region_invariant(&self) {
debug_assert!(screen_region_invariant(
self.data.active_screen_region,
@@ -357,7 +598,7 @@ impl WaylandState {
}
pub(in crate::backend::wayland) fn cancel_screen_modals_if_source_changed(&mut self) {
- let (cancel_eyedropper, cancel_ocr) = {
+ let (cancel_eyedropper, cancel_region) = {
let current_source = super::screen_image::displayed_screen_image(
&self.zoom,
&self.frozen,
@@ -380,14 +621,26 @@ impl WaylandState {
self.data.active_eyedropper_source,
&source_matches,
),
- active_ocr_source_changed(self.data.active_screen_region, &source_matches),
+ active_region_source_changed(self.data.active_screen_region, &source_matches),
)
};
if cancel_eyedropper {
self.cancel_eyedropper();
}
- if cancel_ocr {
- self.cancel_ocr();
+ if cancel_region {
+ match self
+ .data
+ .active_screen_region
+ .map(ActiveScreenRegion::purpose)
+ {
+ Some(RegionPurposeTag::Ocr) => {
+ self.cancel_ocr();
+ }
+ Some(purpose) if purpose.is_capture() => {
+ self.cancel_region_capture_for_source_change();
+ }
+ Some(_) | None => {}
+ }
}
}
}
@@ -402,17 +655,13 @@ fn screen_region_invariant(backend: Option, ui: RegionSelect
}
}
-fn active_ocr_source_changed(
+fn active_region_source_changed(
region: Option,
source_matches: &impl Fn(ScreenSourceToken) -> bool,
) -> bool {
matches!(
region,
- Some(ActiveScreenRegion::Ready {
- purpose: RegionPurposeTag::Ocr,
- source,
- ..
- }) if !source_matches(source)
+ Some(ActiveScreenRegion::Ready { source, .. }) if !source_matches(source)
)
}
@@ -471,6 +720,8 @@ mod tests {
raw_edge: None,
logical_anchor: None,
logical_edge: None,
+ square_modifier: false,
+ legend_dismissed: false,
};
assert_eq!(ready.generation(), pending.generation());
assert_eq!(ready.owned_frozen_generation(), Some(44));
@@ -556,11 +807,13 @@ mod tests {
raw_edge: None,
logical_anchor: None,
logical_edge: None,
+ square_modifier: false,
+ legend_dismissed: false,
}
}
#[test]
- fn ready_ocr_detects_replaced_or_missing_source_but_pending_ocr_keeps_waiting() {
+ 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")
@@ -568,20 +821,23 @@ mod tests {
let mut replacement = source;
replacement.image_generation += 1;
- assert!(!active_ocr_source_changed(Some(ready), &|expected| {
+ assert!(!active_region_source_changed(Some(ready), &|expected| {
expected == source
}));
- assert!(active_ocr_source_changed(Some(ready), &|expected| {
+ assert!(active_region_source_changed(Some(ready), &|expected| {
expected == replacement
}));
- assert!(active_ocr_source_changed(Some(ready), &|_| false));
- assert!(!active_ocr_source_changed(
+ assert!(active_region_source_changed(Some(ready), &|_| false));
+ assert!(!active_region_source_changed(
Some(ActiveScreenRegion::PendingZoom {
purpose: RegionPurposeTag::Ocr,
generation: 1,
}),
&|_| false,
));
+
+ let capture = capture_region();
+ assert!(active_region_source_changed(Some(capture), &|_| false));
}
#[test]
@@ -696,6 +952,23 @@ mod tests {
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 [
@@ -728,12 +1001,16 @@ mod tests {
(30.0, 35.0),
);
}
- let RegionSelectionFinalize::Selected(rect) = finalize_region_selection_event(
+ let RegionSelectionFinalize::Selected {
+ purpose: RegionPurposeTag::Ocr,
+ rect,
+ } = finalize_region_selection_event(
&mut backend,
&mut input,
RegionInputSource::Pointer,
release,
- ) else {
+ )
+ else {
panic!(
"release must submit at scale={scale} reversed={reversed} motion={has_motion}"
);
@@ -770,12 +1047,16 @@ mod tests {
RegionInputSource::Pointer,
(10.0, 20.0),
));
- let RegionSelectionFinalize::Selected(rect) = finalize_region_selection_event(
+ let RegionSelectionFinalize::Selected {
+ purpose: RegionPurposeTag::Ocr,
+ rect,
+ } = finalize_region_selection_event(
&mut backend,
&mut input,
RegionInputSource::Pointer,
(14.0, 28.0),
- ) else {
+ )
+ else {
panic!("Shift-held OCR drag must submit");
};
assert_eq!(rect.size(), (4, 8));
@@ -803,4 +1084,341 @@ mod tests {
RegionSelectUiState::Armed { .. }
));
}
+
+ fn capture_region() -> ActiveScreenRegion {
+ capture_region_at_scale(1.0)
+ }
+
+ 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,
+ }
+ }
+
+ #[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 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::RearmedCapture
+ );
+ 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);
+ }
}
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..53bd02af3
--- /dev/null
+++ b/src/backend/wayland/state/region_capture/geometry.rs
@@ -0,0 +1,101 @@
+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 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..8ebdda741
--- /dev/null
+++ b/src/backend/wayland/state/region_capture/intent.rs
@@ -0,0 +1,165 @@
+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
+ }
+
+ #[allow(dead_code)] // Persisted in Phase 1; the loupe renderer arrives in Phase 2.
+ 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,
+}
+
+impl RegionCaptureIntent {
+ pub(in crate::backend::wayland) fn new(
+ action: Action,
+ purpose: RegionPurposeTag,
+ destination: CaptureDestination,
+ save_config: Option,
+ exit_mode: ExitAfterCaptureMode,
+ options: RegionPickerOptions,
+ ) -> Self {
+ Self {
+ action,
+ purpose,
+ destination,
+ save_config,
+ exit_mode,
+ options,
+ }
+ }
+
+ 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
+ }
+}
+
+#[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,
+ );
+
+ 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.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,
+ );
+
+ 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());
+ }
+}
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..74ef4a6a6
--- /dev/null
+++ b/src/backend/wayland/state/region_capture/picker.rs
@@ -0,0 +1,647 @@
+use crate::backend::wayland::acquisition::ScreenAcquisitionOwner;
+use crate::backend::wayland::zoom::ZoomWaiterOwner;
+use crate::capture::{
+ CaptureDestination, CaptureType, ImageFormatMetadata, ImageOperationKind,
+ RenderedImageDeliveryRequest,
+ file::{FileSaveConfig, expand_tilde},
+};
+use crate::config::{Action, RegionPicker};
+use crate::input::state::{
+ RegionInputSource, RegionPurposeTag, ScreenCaptureSource, Toast, ToastPriority,
+};
+use crate::screen_pixels::{ImagePixelRect, PackedArgb32};
+
+use super::super::capture::should_exit_after_capture;
+use super::super::screen_image::{
+ CropError, copy_image_rect, displayed_screen_image, screen_source_is,
+};
+use super::{ActiveScreenRegion, FreezeOwnership, RegionCaptureIntent, RegionPickerOptions};
+use crate::backend::wayland::state::WaylandState;
+
+const TOAST_SOURCE: &str = "capture";
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum RegionPickerEntry {
+ Activate,
+ WaitForZoom,
+ AutoFreeze,
+ Legacy,
+ RefuseSolidZoom,
+ ZoomImageUnavailable,
+}
+
+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
+ }
+}
+
+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)
+ }
+ _ => None,
+ }
+}
+
+fn region_delivery_request(
+ pixels: PackedArgb32,
+ intent: &RegionCaptureIntent,
+) -> 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
+ });
+ let render: crate::capture::ImageRenderJob =
+ Box::new(move || crate::capture::png::encode_packed_argb32_png(&pixels));
+ RenderedImageDeliveryRequest {
+ render,
+ destination: intent.destination(),
+ save_config,
+ operation: ImageOperationKind::Screenshot,
+ fallback_format_override: Some(ImageFormatMetadata::png()),
+ }
+}
+
+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 {
+ /// 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(rect) = self.whole_image_capture_rect() else {
+ return;
+ };
+ match self.input_state.region_state().selection_owner() {
+ Some(RegionInputSource::Pointer | RegionInputSource::Touch) => {
+ self.set_suppress_next_release(true);
+ }
+ Some(RegionInputSource::Stylus) => self.retire_stylus_contact(),
+ None => {}
+ }
+ self.submit_region_capture(rect);
+ }
+
+ 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 save_config = (!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,
+ RegionPurposeTag::CaptureDeliver,
+ destination,
+ save_config,
+ self.exit_after_capture_mode,
+ options,
+ );
+ 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 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(
+ RegionPurposeTag::CaptureDeliver,
+ generation,
+ FreezeOwnership::PreExisting,
+ ) {
+ self.finish_region_activation_rejection();
+ }
+ }
+ RegionPickerEntry::WaitForZoom => {
+ if self.wait_for_current_zoom_capture(ZoomWaiterOwner::RegionCapture) {
+ self.set_pending_screen_region(
+ RegionPurposeTag::CaptureDeliver,
+ 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(
+ RegionPurposeTag::CaptureDeliver,
+ 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
+ };
+ self.activate_screen_region(region.purpose(), region.generation(), ownership)
+ }
+
+ 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(),
+ ));
+ if !self.enter_overlay_suppression(super::super::OverlaySuppression::Capture) {
+ 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(in crate::backend::wayland) fn submit_region_capture(&mut self, rect: ImagePixelRect) {
+ let Some(ActiveScreenRegion::Ready {
+ source: token,
+ freeze_ownership,
+ purpose,
+ ..
+ }) = 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;
+ }
+ let pixels = match copy_image_rect(source.image, rect) {
+ Ok(pixels) => 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 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);
+ }
+
+ self.capture.set_exit_on_success(should_exit_after_capture(
+ intent.exit_mode(),
+ intent.destination(),
+ ));
+ let request = region_delivery_request(pixels, &intent);
+ let submission = self
+ .capture
+ .manager_mut()
+ .request_rendered_image_delivery(request);
+ self.accept_capture_submission(submission, ImageOperationKind::Screenshot);
+ }
+
+ 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."),
+ );
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::backend::ExitAfterCaptureMode;
+
+ #[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::CaptureFullScreen, default), None);
+ }
+
+ #[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::RegionCaptureConfig::default();
+ let options =
+ RegionPickerOptions::new(live.show_size_readout, live.show_loupe, live.show_legend);
+ let intent = RegionCaptureIntent::new(
+ Action::CaptureSelection,
+ RegionPurposeTag::CaptureDeliver,
+ CaptureDestination::ClipboardOnly,
+ None,
+ ExitAfterCaptureMode::Auto,
+ options,
+ );
+ live.show_size_readout = false;
+ live.show_loupe = true;
+ live.show_legend = false;
+ live.picker = RegionPicker::Slurp;
+
+ assert_eq!(live.picker, RegionPicker::Slurp);
+ assert!(!live.show_size_readout);
+ assert!(live.show_loupe);
+ assert!(!live.show_legend);
+ 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),
+ );
+ let pixels = PackedArgb32::new(1, 1, 4, 0xff33_2211_u32.to_ne_bytes().to_vec())
+ .expect("one native ARGB32 pixel");
+
+ let request = region_delivery_request(pixels, &intent);
+
+ 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),
+ );
+
+ 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/render/canvas/mod.rs b/src/backend/wayland/state/render/canvas/mod.rs
index c59a800bd..5c547297a 100644
--- a/src/backend/wayland/state/render/canvas/mod.rs
+++ b/src/backend/wayland/state/render/canvas/mod.rs
@@ -18,6 +18,7 @@ impl WaylandState {
damage_world: &[crate::util::Rect],
mut perf: Option<&mut PerfRenderBreakdown>,
) -> Result<()> {
+ let capture_picker_suppresses_canvas_chrome = self.capture_picker_chrome_suppressed();
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,12 +27,13 @@ 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() {
- self.ensure_canvas_layer_cache(width, height, scale)
- } else {
- self.canvas_layer_cache.clear();
- false
- };
+ let layer_cache_ready =
+ if !capture_picker_suppresses_canvas_chrome && self.canvas_layer_cache_usable() {
+ self.ensure_canvas_layer_cache(width, height, scale)
+ } else {
+ self.canvas_layer_cache.clear();
+ false
+ };
if let (Some(perf), Some(layer_cache_start)) = (perf.as_mut(), layer_cache_start) {
perf.stages.completed_shapes = perf
.stages
@@ -48,6 +50,22 @@ impl WaylandState {
.saturating_add(Instant::now().saturating_duration_since(background_start));
}
+ // A capture picker selects pixels from the frozen desktop, never from
+ // Wayscriber's annotation canvas. Keep only the backdrop: committed
+ // shapes, spotlight, selection handles, provisional/text previews,
+ // and click/highlight effects all return automatically when the
+ // derived picker state ends.
+ if capture_picker_suppresses_canvas_chrome {
+ 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 {
diff --git a/src/backend/wayland/state/render/mod.rs b/src/backend/wayland/state/render/mod.rs
index a93d428b5..0b102ce7e 100644
--- a/src/backend/wayland/state/render/mod.rs
+++ b/src/backend/wayland/state/render/mod.rs
@@ -370,6 +370,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..0eebd4b36 100644
--- a/src/backend/wayland/state/render/ui.rs
+++ b/src/backend/wayland/state/render/ui.rs
@@ -1,5 +1,6 @@
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;
impl WaylandState {
pub(super) fn render_ui_layer(
@@ -20,7 +21,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();
@@ -39,6 +45,7 @@ impl WaylandState {
);
}
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 +62,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 +119,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 +152,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 +166,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 +177,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 +199,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 +207,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 +215,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 +234,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 +262,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 +286,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();
@@ -285,16 +296,65 @@ impl WaylandState {
}
// 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) {
+ if !self.input_state.region_is_active()
+ || !self
+ .input_state
+ .region_state()
+ .purpose()
+ .is_some_and(|purpose| purpose.is_capture())
+ {
+ return;
+ }
+
+ 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 (pointer_x, pointer_y) = self.current_mouse();
+ let pointer = (f64::from(pointer_x), f64::from(pointer_y));
+ let measurement = 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))
+ }
+ });
+
+ crate::ui::render_region_capture_picker(
+ ctx,
+ width,
+ height,
+ crate::ui::RegionCapturePickerVisual {
+ selection: geometry.map(|geometry| geometry.display_selection()),
+ pointer,
+ measurement: measurement.as_deref(),
+ show_legend: options.is_some_and(|options| options.show_legend())
+ && !self.region_picker_legend_dismissed(),
+ },
+ );
+ }
+
/// 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/screen_image.rs b/src/backend/wayland/state/screen_image.rs
index ca9bf229b..5f13df451 100644
--- a/src/backend/wayland/state/screen_image.rs
+++ b/src/backend/wayland/state/screen_image.rs
@@ -209,7 +209,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 +227,7 @@ pub(super) fn screen_point_for_image_point(
}
}
-#[allow(dead_code)] // Phase 0b derives the region preview from image-space state.
+#[allow(dead_code)] // Phase 2 review maps its authoritative pixel rectangle back to the overlay.
pub(super) fn screen_rect_for_image_rect(token: &ScreenSourceToken, rect: ImagePixelRect) -> Rect {
let first = screen_point_for_image_point(
token,
@@ -249,7 +248,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)] // Phase 2 review reuses the non-empty pixel-span mapping.
pub(super) fn screen_rect_for_pixel_span(
token: &ScreenSourceToken,
span: PixelSpan,
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/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..f2287a266 100644
--- a/src/backend/wayland/zoom/state.rs
+++ b/src/backend/wayland/zoom/state.rs
@@ -52,7 +52,6 @@ impl ZoomSourceTerminal {
pub(in crate::backend::wayland) enum ZoomWaiterOwner {
Eyedropper,
Ocr,
- #[allow(dead_code)] // Phase 1 connects the native region-capture owner.
RegionCapture,
}
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/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..f0779d28b 100644
--- a/src/config/tests/load.rs
+++ b/src/config/tests/load.rs
@@ -38,6 +38,38 @@ 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 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");
diff --git a/src/config/tests/schema.rs b/src/config/tests/schema.rs
index 755ce2777..d9aaa95a3 100644
--- a/src/config/tests/schema.rs
+++ b/src/config/tests/schema.rs
@@ -38,6 +38,22 @@ 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}"
+ );
+ }
+}
+
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/types/capture.rs b/src/config/types/capture.rs
index 78f393906..22d6c8bd4 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,
@@ -37,6 +74,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 {
@@ -49,6 +90,7 @@ impl Default for CaptureConfig {
copy_to_clipboard: default_capture_clipboard(),
exit_after_capture: default_capture_exit_after(),
ocr_languages: default_capture_ocr_languages(),
+ region: RegionCaptureConfig::default(),
}
}
}
@@ -154,6 +196,18 @@ 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 +218,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/domain/action.rs b/src/domain/action.rs
index 9841283a9..705476a19 100644
--- a/src/domain/action.rs
+++ b/src/domain/action.rs
@@ -218,3 +218,17 @@ pub enum Action {
// Clipboard fallback
SavePendingToFile,
}
+
+impl Action {
+ /// Whether this action opens or addresses the native screen-region picker.
+ pub const fn is_region_capture(self) -> bool {
+ matches!(
+ self,
+ Self::CaptureSelection
+ | Self::CaptureClipboardSelection
+ | Self::CaptureFileSelection
+ | Self::CaptureClipboardRegion
+ | Self::CaptureFileRegion
+ )
+ }
+}
diff --git a/src/domain/tests.rs b/src/domain/tests.rs
index f67779956..eade772cf 100644
--- a/src/domain/tests.rs
+++ b/src/domain/tests.rs
@@ -390,3 +390,25 @@ 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,
+ ] {
+ assert!(action.is_region_capture(), "action={action:?}");
+ }
+ for action in [
+ Action::CaptureFullScreen,
+ Action::CaptureActiveWindow,
+ Action::CaptureClipboardFull,
+ Action::CaptureFileFull,
+ Action::CopyTextFromScreen,
+ ] {
+ assert!(!action.is_region_capture(), "action={action:?}");
+ }
+}
diff --git a/src/input/state/actions/action_capture_zoom.rs b/src/input/state/actions/action_capture_zoom.rs
index 43aa88c40..930462111 100644
--- a/src/input/state/actions/action_capture_zoom.rs
+++ b/src/input/state/actions/action_capture_zoom.rs
@@ -1,5 +1,5 @@
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};
@@ -25,9 +25,12 @@ impl InputState {
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 +113,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 +132,13 @@ 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()
+ }
}
#[cfg(test)]
@@ -171,4 +181,24 @@ 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);
+ }
}
diff --git a/src/input/state/core/region_select.rs b/src/input/state/core/region_select.rs
index 38f521b25..427ed3332 100644
--- a/src/input/state/core/region_select.rs
+++ b/src/input/state/core/region_select.rs
@@ -30,6 +30,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 {
@@ -320,6 +324,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 +334,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());
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/screen_pixels.rs b/src/screen_pixels.rs
index 6d5c52c62..71a4f6474 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 {
@@ -102,10 +98,12 @@ pub struct PixelSpan {
}
impl PixelSpan {
+ #[allow(dead_code)] // Phase 2 review nudging and board placement need the span origin.
pub const fn x(self) -> u32 {
self.x
}
+ #[allow(dead_code)] // Phase 2 review nudging and board placement need the span origin.
pub const fn y(self) -> u32 {
self.y
}
@@ -118,6 +116,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 +165,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 +192,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,
diff --git a/src/ui.rs b/src/ui.rs
index 404933ba7..a4940d8bb 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -14,6 +14,7 @@ mod precision_entry;
mod primitives;
mod properties_panel;
mod radial_menu;
+mod region_capture_picker;
mod status;
mod text_highlight;
pub mod theme;
@@ -41,6 +42,9 @@ pub(crate) use primitives::checkerboard_behind;
pub(crate) use primitives::ellipsize_to_fit;
pub use properties_panel::render_properties_panel;
pub use radial_menu::render_radial_menu;
+pub(crate) use region_capture_picker::{
+ RegionCapturePickerVisual, capture_size_text, render_region_capture_picker,
+};
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/region_capture_picker.rs b/src/ui/region_capture_picker.rs
new file mode 100644
index 000000000..c5f80766c
--- /dev/null
+++ b/src/ui/region_capture_picker.rs
@@ -0,0 +1,343 @@
+use crate::input::state::RegionSelection;
+
+use super::primitives::{draw_rounded_rect, text_extents_for};
+
+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;
+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 LEGEND_TEXT: &str = "Drag to select Shift: square Ctrl+A: all Esc: cancel";
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub(crate) struct RegionCapturePickerVisual<'a> {
+ pub selection: Option,
+ pub pointer: (f64, f64),
+ pub measurement: Option<&'a str>,
+ pub show_legend: bool,
+}
+
+#[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)
+}
+
+pub(crate) fn render_region_capture_picker(
+ ctx: &cairo::Context,
+ screen_width: u32,
+ screen_height: u32,
+ visual: RegionCapturePickerVisual<'_>,
+) {
+ let width = f64::from(screen_width);
+ let height = f64::from(screen_height);
+
+ let _ = ctx.save();
+ ctx.set_source_rgba(SCRIM.0, SCRIM.1, SCRIM.2, SCRIM.3);
+ ctx.rectangle(0.0, 0.0, width, height);
+ if let Some(selection) = visual.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 let Some(selection) = visual.selection {
+ let (x, y, w, h) = normalized_rect(selection);
+ draw_selection_frame(ctx, x, y, w, h);
+ }
+ draw_crosshair(ctx, visual.pointer, (width, height));
+
+ if let Some(measurement) = visual.measurement {
+ draw_pointer_panel(
+ ctx,
+ measurement,
+ READOUT_FONT_SIZE,
+ visual.pointer,
+ (screen_width, screen_height),
+ cairo::FontWeight::Bold,
+ );
+ }
+ if visual.show_legend && visual.selection.is_none() {
+ draw_legend(ctx, (screen_width, screen_height));
+ }
+ 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) {
+ 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();
+
+ 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_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,
+ }
+}
+
+fn draw_pointer_panel(
+ ctx: &cairo::Context,
+ text: &str,
+ font_size: f64,
+ pointer: (f64, f64),
+ screen: (u32, u32),
+ weight: cairo::FontWeight,
+) {
+ let extents = text_extents_for(
+ ctx,
+ "monospace",
+ cairo::FontSlant::Normal,
+ weight,
+ font_size,
+ text,
+ );
+ let layout = 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)) {
+ let extents = text_extents_for(
+ ctx,
+ "Sans",
+ cairo::FontSlant::Normal,
+ cairo::FontWeight::Normal,
+ LEGEND_FONT_SIZE,
+ LEGEND_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(LEGEND_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 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_legend: false,
+ },
+ );
+ 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 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_legend: false,
+ },
+ );
+ 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"
+ );
+ }
+}
From baf1ebeb8873be1d2727cfdfdda2b4d4e49aa2b2 Mon Sep 17 00:00:00 2001
From: devmobasa <4170275+devmobasa@users.noreply.github.com>
Date: Sat, 22 Aug 2026 17:28:25 +0200
Subject: [PATCH 02/13] feat(capture): add interactive region review
---
README.md | 10 +-
config.example.toml | 4 +-
configurator/src/app/pages/capture.rs | 2 +-
.../models/keybindings/field/config/read.rs | 1 +
.../models/keybindings/field/config/write.rs | 1 +
.../src/models/keybindings/field/labels.rs | 1 +
.../src/models/keybindings/field/list.rs | 1 +
.../src/models/keybindings/field/mod.rs | 1 +
.../src/models/keybindings/field/tab.rs | 1 +
configurator/src/models/keybindings/tests.rs | 17 +
docs/CONFIG.md | 18 +-
docs/codebase-overview.md | 4 +
.../wayland/backend/event_loop/capture.rs | 77 +++
src/backend/wayland/capture.rs | 73 +++
src/backend/wayland/handlers/keyboard/mod.rs | 108 +++-
src/backend/wayland/handlers/pointer/press.rs | 8 +
src/backend/wayland/handlers/tablet/tool.rs | 9 +
src/backend/wayland/handlers/touch.rs | 7 +
src/backend/wayland/state/eyedropper.rs | 6 +-
src/backend/wayland/state/ocr.rs | 8 +-
src/backend/wayland/state/region_capture.rs | 525 +++++++++++++++++-
.../wayland/state/region_capture/board.rs | 85 +++
.../wayland/state/region_capture/geometry.rs | 21 +
.../wayland/state/region_capture/intent.rs | 1 -
.../wayland/state/region_capture/picker.rs | 247 ++++++--
src/backend/wayland/state/render/ui.rs | 48 ++
src/backend/wayland/state/screen_image.rs | 3 +-
src/capture/manager.rs | 18 +-
src/capture/mod.rs | 3 +-
src/capture/pipeline.rs | 31 +-
src/capture/tests/manager.rs | 59 +-
src/capture/types.rs | 7 +
src/config/action_meta/entries/capture.rs | 11 +
src/config/action_meta/tests.rs | 18 +
src/config/keybindings/config/map/capture.rs | 4 +
src/config/keybindings/config/map/edit.rs | 1 +
.../config/types/bindings/capture.rs | 6 +
src/config/keybindings/defaults/capture.rs | 6 +
src/config/keybindings/tests.rs | 21 +
src/config/tests/schema.rs | 9 +
src/configurator_destination.rs | 1 +
src/domain/action.rs | 3 +
src/domain/tests.rs | 5 +
.../state/actions/action_capture_zoom.rs | 4 +-
src/input/state/core/captured_image.rs | 195 +++++++
src/input/state/core/mod.rs | 2 +
src/input/state/core/region_select.rs | 155 +++++-
src/input/state/interaction/actions.rs | 1 +
src/input/state/mod.rs | 16 +-
src/screen_pixels.rs | 4 +-
src/ui.rs | 5 +-
.../help_overlay/sections/builder/sections.rs | 1 +
src/ui/help_overlay/sections/tests.rs | 15 +
src/ui/region_action_bar.rs | 329 +++++++++++
src/ui/region_capture_picker.rs | 104 ++++
55 files changed, 2212 insertions(+), 109 deletions(-)
create mode 100644 src/backend/wayland/state/region_capture/board.rs
create mode 100644 src/input/state/core/captured_image.rs
create mode 100644 src/ui/region_action_bar.rs
diff --git a/README.md b/README.md
index 53d9db438..cb8491d2c 100644
--- a/README.md
+++ b/README.md
@@ -743,15 +743,21 @@ Light passthrough (layer-shell compositors only) lets normal keyboard and pointe
| Ctrl+Shift+S | Select region → save PNG |
| Ctrl+6 | Region → clipboard (explicit) |
| Ctrl+Alt+6 | Region → save PNG (explicit) |
+| Unbound | Capture region interactively, then choose Copy, Save, Both, or Board (`capture_region_interactive`) |
| 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 and hotkey legend are configurable under `[capture.region]`; the loupe
-setting is accepted but is not rendered yet. Region captures are encoded and
+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.
+`capture_region_interactive` is available from the command palette and is
+unbound by default; bind it in the configurator or under `[keybindings]`. 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.
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.
diff --git a/config.example.toml b/config.example.toml
index 4f4e8dc38..e22d45c0e 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -275,6 +275,9 @@ capture_clipboard_selection = ["Ctrl+Shift+C"]
capture_file_selection = ["Ctrl+Shift+S"]
capture_clipboard_region = ["Ctrl+6"]
capture_file_region = ["Ctrl+Alt+6"]
+# Open the region review/action bar. Available from the command palette and
+# unbound by default.
+capture_region_interactive = []
export_canvas_file = []
export_canvas_clipboard = []
export_canvas_clipboard_and_file = []
@@ -1429,7 +1432,6 @@ picker = "native"
show_size_readout = true
# Magnified pixel loupe next to the pointer while dragging.
-# This key is accepted now; the loupe is not rendered yet.
show_loupe = false
# Short hotkey legend at the top centre until the first drag.
diff --git a/configurator/src/app/pages/capture.rs b/configurator/src/app/pages/capture.rs
index 9c2a64cab..f2498ec4e 100644
--- a/configurator/src/app/pages/capture.rs
+++ b/configurator/src/app/pages/capture.rs
@@ -89,7 +89,7 @@ pub(super) fn build(sender: &ComponentSender) -> BuiltPage {
)
.switch_row(
"Show magnified pixel loupe",
- "The preference is saved now; loupe rendering will become available in a later release.",
+ "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),
)
diff --git a/configurator/src/models/keybindings/field/config/read.rs b/configurator/src/models/keybindings/field/config/read.rs
index 6dbb7783d..e9e511d38 100644
--- a/configurator/src/models/keybindings/field/config/read.rs
+++ b/configurator/src/models/keybindings/field/config/read.rs
@@ -127,6 +127,7 @@ 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::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..a430bde82 100644
--- a/configurator/src/models/keybindings/field/config/write.rs
+++ b/configurator/src/models/keybindings/field/config/write.rs
@@ -128,6 +128,7 @@ 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::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..b78cd0529 100644
--- a/configurator/src/models/keybindings/field/labels.rs
+++ b/configurator/src/models/keybindings/field/labels.rs
@@ -137,6 +137,7 @@ impl KeybindingField {
Self::CaptureFileSelection => "capture_file_selection",
Self::CaptureClipboardRegion => "capture_clipboard_region",
Self::CaptureFileRegion => "capture_file_region",
+ Self::CaptureRegionInteractive => "capture_region_interactive",
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..a4cde3f1f 100644
--- a/configurator/src/models/keybindings/field/list.rs
+++ b/configurator/src/models/keybindings/field/list.rs
@@ -122,6 +122,7 @@ impl KeybindingField {
Self::CaptureFileSelection,
Self::CaptureClipboardRegion,
Self::CaptureFileRegion,
+ Self::CaptureRegionInteractive,
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..da7692ab8 100644
--- a/configurator/src/models/keybindings/field/mod.rs
+++ b/configurator/src/models/keybindings/field/mod.rs
@@ -107,6 +107,7 @@ pub enum KeybindingField {
CaptureFileSelection,
CaptureClipboardRegion,
CaptureFileRegion,
+ CaptureRegionInteractive,
ExportCanvasFile,
ExportCanvasClipboard,
ExportCanvasClipboardAndFile,
diff --git a/configurator/src/models/keybindings/field/tab.rs b/configurator/src/models/keybindings/field/tab.rs
index abda464f0..ab82fa71d 100644
--- a/configurator/src/models/keybindings/field/tab.rs
+++ b/configurator/src/models/keybindings/field/tab.rs
@@ -123,6 +123,7 @@ impl KeybindingField {
| Self::CaptureFileSelection
| Self::CaptureClipboardRegion
| Self::CaptureFileRegion
+ | Self::CaptureRegionInteractive
| Self::ExportCanvasFile
| Self::ExportCanvasClipboard
| Self::ExportCanvasClipboardAndFile
diff --git a/configurator/src/models/keybindings/tests.rs b/configurator/src/models/keybindings/tests.rs
index 604237104..beec64443 100644
--- a/configurator/src/models/keybindings/tests.rs
+++ b/configurator/src/models/keybindings/tests.rs
@@ -168,6 +168,23 @@ 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!(field.get(&config).is_empty());
+
+ field.set(&mut config, vec!["Ctrl+Alt+Shift+I".to_string()]);
+ assert_eq!(
+ config.capture.capture_region_interactive,
+ ["Ctrl+Alt+Shift+I"]
+ );
+}
+
/// 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/docs/CONFIG.md b/docs/CONFIG.md
index 05a39ef7d..e96513c69 100644
--- a/docs/CONFIG.md
+++ b/docs/CONFIG.md
@@ -1410,7 +1410,7 @@ picker = "native"
# Pointer position while idle; W x H in export pixels while dragging
show_size_readout = true
-# Accepted and saved now, but the picker does not render the loupe yet
+# Magnified pixel grid beside the pointer while selecting or reviewing
show_loupe = false
# Short hotkey guide shown until the first drag
@@ -1431,14 +1431,23 @@ 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`.
+`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 is available from the command palette and has no default shortcut;
+bind it in the configurator or under `[keybindings]`. 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`.
+
- 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.
- `show_legend` shows the short hotkey guide until the first drag.
-- `show_loupe` is accepted and preserved in configuration, but the native
- picker does not render the loupe yet.
+- `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
@@ -1952,6 +1961,9 @@ capture_clipboard_selection = ["Ctrl+Shift+C"]
capture_file_selection = ["Ctrl+Shift+S"]
capture_clipboard_region = ["Ctrl+6"]
capture_file_region = ["Ctrl+Alt+6"]
+# Open the region review/action bar. Available from the command palette and
+# unbound by default.
+capture_region_interactive = []
export_canvas_file = []
export_canvas_clipboard = []
export_canvas_clipboard_and_file = []
diff --git a/docs/codebase-overview.md b/docs/codebase-overview.md
index 71c5d4072..27bd31607 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.
diff --git a/src/backend/wayland/backend/event_loop/capture.rs b/src/backend/wayland/backend/event_loop/capture.rs
index f339475eb..9cfab3e6c 100644
--- a/src/backend/wayland/backend/event_loop/capture.rs
+++ b/src/backend/wayland/backend/event_loop/capture.rs
@@ -322,9 +322,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;
}
@@ -343,6 +359,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();
@@ -525,6 +599,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
diff --git a/src/backend/wayland/capture.rs b/src/backend/wayland/capture.rs
index dc7680a4b..e97840ff1 100644
--- a/src/backend/wayland/capture.rs
+++ b/src/backend/wayland/capture.rs
@@ -9,6 +9,7 @@ use crate::{
ImageOperationKind, file::FileSaveConfig,
},
config::Action,
+ input::state::BoardPasteTarget,
};
use super::state::RegionCaptureIntent;
@@ -60,6 +61,12 @@ pub(in crate::backend::wayland) enum RegionCapturePhase {
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 {
@@ -92,6 +99,7 @@ pub struct CaptureState {
pending_request: Option,
pending_pdf_export: Option,
region: RegionCapturePhase,
+ pending_board_paste: Option,
}
impl CaptureState {
@@ -106,6 +114,7 @@ impl CaptureState {
pending_request: None,
pending_pdf_export: None,
region: RegionCapturePhase::Idle,
+ pending_board_paste: None,
}
}
@@ -252,6 +261,7 @@ impl CaptureState {
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.
@@ -289,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;
@@ -329,6 +372,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();
@@ -373,6 +425,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();
diff --git a/src/backend/wayland/handlers/keyboard/mod.rs b/src/backend/wayland/handlers/keyboard/mod.rs
index 67e041cfb..323d8711e 100644
--- a/src/backend/wayland/handlers/keyboard/mod.rs
+++ b/src/backend/wayland/handlers/keyboard/mod.rs
@@ -161,15 +161,6 @@ 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 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;
- }
let action = self.input_state.action_for_key(key);
if let Some(action) = action {
if self
@@ -185,11 +176,43 @@ impl KeyboardHandler for WaylandState {
// 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, visibly
- // changing a Shift-squared drag that remains active.
+ // 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.
@@ -204,11 +227,6 @@ impl KeyboardHandler for WaylandState {
} else {
self.cancel_ocr();
}
- } else if action == Some(Action::CopyTextFromScreen)
- && self.input_state.region_state().purpose()
- == Some(crate::input::state::RegionPurposeTag::Ocr)
- {
- self.cancel_ocr();
}
return;
}
@@ -541,6 +559,33 @@ fn region_capture_select_all_pressed(
&& matches!(key, Key::Char('a' | 'A'))
}
+#[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,
+ )),
+ _ => None,
+ }
+}
+
#[cfg(test)]
mod tests {
use super::*;
@@ -615,6 +660,37 @@ mod tests {
));
}
+ #[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);
+ }
+
#[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/press.rs b/src/backend/wayland/handlers/pointer/press.rs
index 7bc4659d4..8aef5ec4a 100644
--- a/src/backend/wayland/handlers/pointer/press.rs
+++ b/src/backend/wayland/handlers/pointer/press.rs
@@ -46,6 +46,14 @@ impl WaylandState {
} else {
match button {
BTN_LEFT => {
+ if let Some(action) = self.region_review_action_at(event.position) {
+ self.submit_region_review_action(action);
+ self.set_suppress_next_release(true);
+ return;
+ }
+ if self.region_review_bar_contains(event.position) {
+ return;
+ }
self.begin_region_selection(
RegionInputSource::Pointer,
event.position.0,
diff --git a/src/backend/wayland/handlers/tablet/tool.rs b/src/backend/wayland/handlers/tablet/tool.rs
index c8457a5f0..9caabfbac 100644
--- a/src/backend/wayland/handlers/tablet/tool.rs
+++ b/src/backend/wayland/handlers/tablet/tool.rs
@@ -248,6 +248,15 @@ impl Dispatch for WaylandState {
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 32851da30..730e99e16 100644
--- a/src/backend/wayland/handlers/touch.rs
+++ b/src/backend/wayland/handlers/touch.rs
@@ -217,6 +217,13 @@ impl WaylandState {
if target == TouchTarget::Toolbar || inline_hit {
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,
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/ocr.rs b/src/backend/wayland/state/ocr.rs
index 84984080c..018aa4fb6 100644
--- a/src/backend/wayland/state/ocr.rs
+++ b/src/backend/wayland/state/ocr.rs
@@ -292,8 +292,9 @@ impl WaylandState {
}
}
- /// 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,
@@ -313,6 +314,7 @@ impl WaylandState {
) {
RegionSelectionFinalize::NotOwned => return false,
RegionSelectionFinalize::Rearmed => return true,
+ RegionSelectionFinalize::Reviewed => return true,
RegionSelectionFinalize::Selected {
purpose: RegionPurposeTag::Ocr,
rect,
@@ -327,7 +329,7 @@ impl WaylandState {
RegionSelectionFinalize::Selected {
purpose: RegionPurposeTag::CaptureInteractive,
..
- } => return false,
+ } => return true,
};
// The crop is taken while the capture is still held: releasing first
diff --git a/src/backend/wayland/state/region_capture.rs b/src/backend/wayland/state/region_capture.rs
index 1ac2cfe7e..6ecf5b76b 100644
--- a/src/backend/wayland/state/region_capture.rs
+++ b/src/backend/wayland/state/region_capture.rs
@@ -3,11 +3,13 @@ use crate::input::state::{
RegionInputSource, RegionPurposeTag, RegionSelectUiState, RegionSelection, ScreenCaptureSource,
};
+mod board;
mod geometry;
mod intent;
mod picker;
use crate::screen_pixels::{ImagePixelRect, ImagePoint, clamp_edge};
+pub(in crate::backend::wayland) use board::world_rect_for_screen_rect;
pub(in crate::backend::wayland) use geometry::{RegionPickerMeasurement, RegionSelectionGeometry};
pub(in crate::backend::wayland) use intent::{RegionCaptureIntent, RegionPickerOptions};
@@ -101,6 +103,9 @@ impl ActiveScreenRegion {
}
fn selection_rect(self) -> Option {
+ if let Some(rect) = self.stored_review_rect() {
+ return Some(rect);
+ }
let Self::Ready {
purpose,
logical_anchor,
@@ -124,6 +129,9 @@ impl ActiveScreenRegion {
}
fn selection_geometry(self) -> Option {
+ if self.stored_review_rect().is_some() {
+ return self.review_geometry();
+ }
let Self::Ready {
purpose,
source,
@@ -149,6 +157,62 @@ impl ActiveScreenRegion {
)
}
+ fn review_geometry(self) -> Option {
+ let Self::Ready {
+ purpose, source, ..
+ } = self
+ else {
+ return None;
+ };
+ let image_rect = self.stored_review_rect()?;
+ let display = super::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)),
+ ),
+ },
+ ))
+ }
+
+ 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)
+ }
+
+ 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
+ }
+
fn set_square_modifier(&mut self, active: bool) -> bool {
let Self::Ready {
purpose,
@@ -203,6 +267,144 @@ impl ActiveScreenRegion {
)
}
+ fn enter_review(&mut self, rect: ImagePixelRect) -> Option {
+ let Self::Ready {
+ purpose,
+ anchor,
+ raw_edge,
+ logical_anchor,
+ logical_edge,
+ ..
+ } = self
+ else {
+ return None;
+ };
+ if *purpose != RegionPurposeTag::CaptureInteractive {
+ return 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())
+ }
+
+ 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,
+ ..
+ } = self
+ else {
+ return false;
+ };
+ if logical_anchor.is_some() {
+ return false;
+ }
+ let display = super::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
+ }
+
+ 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
+ }
+
+ 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
+ }
+
+ fn reset_review_for_selection(&mut self) {
+ if let Self::Ready {
+ anchor,
+ raw_edge,
+ logical_anchor,
+ ..
+ } = self
+ {
+ *anchor = None;
+ *raw_edge = None;
+ *logical_anchor = None;
+ }
+ }
+
+ 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,
+ ..
+ } = self
+ else {
+ return None;
+ };
+ if logical_anchor.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())
+ }
+
fn begin_selection(&mut self, logical: (f64, f64)) -> bool {
let Self::Ready {
purpose,
@@ -272,6 +474,7 @@ impl ActiveScreenRegion {
pub(in crate::backend::wayland) enum RegionSelectionFinalize {
NotOwned,
Rearmed,
+ Reviewed,
Selected {
purpose: RegionPurposeTag,
rect: ImagePixelRect,
@@ -291,9 +494,18 @@ fn begin_region_selection_event(
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() {
+ 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;
}
@@ -316,9 +528,32 @@ fn update_region_selection_event(
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;
+ }
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_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
@@ -388,6 +623,14 @@ fn region_owner_lost_event(
if !purpose.is_capture() {
return RegionOwnerLoss::Cancel(purpose);
}
+ if input_state.region_state().is_review() {
+ let finished_backend = backend
+ .as_mut()
+ .is_some_and(ActiveScreenRegion::finish_review_move);
+ let finished_ui = input_state.finish_region_review_move(source);
+ debug_assert_eq!(finished_backend, finished_ui);
+ return RegionOwnerLoss::RearmedCapture;
+ }
rearm_region_selection_event(backend, input_state);
RegionOwnerLoss::RearmedCapture
}
@@ -401,6 +644,19 @@ pub(super) fn finalize_region_selection_event(
if !input_state.region_selection_is_owned_by(owner) {
return 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(ActiveScreenRegion::finish_review_move);
+ 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()
@@ -414,6 +670,18 @@ pub(super) fn finalize_region_selection_event(
.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 }
}
@@ -531,6 +799,15 @@ impl WaylandState {
.and_then(ActiveScreenRegion::selection_geometry)
}
+ pub(super) fn region_picker_source_token(&self) -> Option {
+ match self.data.active_screen_region {
+ Some(ActiveScreenRegion::Ready { source, .. }) => Some(source),
+ 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
@@ -545,15 +822,51 @@ impl WaylandState {
.and_then(ActiveScreenRegion::whole_image_selection)
}
- pub(in crate::backend::wayland) fn whole_image_capture_rect(&self) -> Option {
- match self.whole_image_region_selection()? {
- RegionSelectionFinalize::Selected {
- purpose: RegionPurposeTag::CaptureDeliver,
- rect,
- } => Some(rect),
- RegionSelectionFinalize::Selected { .. }
- | RegionSelectionFinalize::NotOwned
- | RegionSelectionFinalize::Rearmed => None,
+ 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
+ }
+
+ 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,
}
}
@@ -1089,6 +1402,14 @@ mod tests {
capture_region_at_scale(1.0)
}
+ fn interactive_region() -> ActiveScreenRegion {
+ let mut region = capture_region();
+ if let ActiveScreenRegion::Ready { purpose, .. } = &mut region {
+ *purpose = RegionPurposeTag::CaptureInteractive;
+ }
+ region
+ }
+
fn capture_region_at_scale(scale: f64) -> ActiveScreenRegion {
let ActiveScreenRegion::Ready {
generation,
@@ -1126,6 +1447,192 @@ mod tests {
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())
+ );
+ }
+
+ #[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::RearmedCapture
+ );
+ 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();
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..17200507f
--- /dev/null
+++ b/src/backend/wayland/state/region_capture/board.rs
@@ -0,0 +1,85 @@
+use crate::util::Rect;
+
+use super::super::screen_image::ScreenSourceToken;
+
+pub(in crate::backend::wayland) fn world_rect_for_screen_rect(
+ display: Rect,
+ board_offset: (f64, f64),
+ source: ScreenSourceToken,
+) -> Option {
+ if !board_offset.0.is_finite()
+ || !board_offset.1.is_finite()
+ || (source.zoom_transformed && (!source.zoom_scale.is_finite() || source.zoom_scale <= 0.0))
+ {
+ return None;
+ }
+ let map = |x: f64, y: f64| {
+ if source.zoom_transformed {
+ (
+ board_offset.0 + source.zoom_view_offset.0 + x / source.zoom_scale,
+ board_offset.1 + source.zoom_view_offset.1 + y / source.zoom_scale,
+ )
+ } else {
+ (board_offset.0 + x, board_offset.1 + y)
+ }
+ };
+ let first = map(f64::from(display.x), f64::from(display.y));
+ let second = map(
+ f64::from(display.x.saturating_add(display.width)),
+ f64::from(display.y.saturating_add(display.height)),
+ );
+ let left = first.0.min(second.0).floor() as i32;
+ let top = first.1.min(second.1).floor() as i32;
+ let right = first.0.max(second.0).ceil() as i32;
+ let bottom = first.1.max(second.1).ceil() as i32;
+ Rect::from_min_max(left, top, right, bottom)
+}
+
+#[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 identity_world_bounds_equal_the_display_bounds() {
+ let display = Rect::new(20, 30, 80, 40).unwrap();
+ assert_eq!(
+ world_rect_for_screen_rect(display, (0.0, 0.0), source(false)),
+ Some(display)
+ );
+ }
+
+ #[test]
+ fn world_bounds_include_board_pan_and_captured_zoom_view() {
+ let display = Rect::new(20, 30, 80, 40).unwrap();
+ assert_eq!(
+ world_rect_for_screen_rect(display, (7.0, 11.0), source(true)),
+ Rect::new(117, 76, 40, 20)
+ );
+ assert_ne!(
+ world_rect_for_screen_rect(display, (7.0, 11.0), source(true)),
+ Some(display)
+ );
+ }
+}
diff --git a/src/backend/wayland/state/region_capture/geometry.rs b/src/backend/wayland/state/region_capture/geometry.rs
index 53bd02af3..3acc0b85b 100644
--- a/src/backend/wayland/state/region_capture/geometry.rs
+++ b/src/backend/wayland/state/region_capture/geometry.rs
@@ -22,6 +22,27 @@ pub(in crate::backend::wayland) enum RegionPickerMeasurement {
}
impl RegionSelectionGeometry {
+ pub(super) fn review(
+ purpose: RegionPurposeTag,
+ rect: ImagePixelRect,
+ display_selection: RegionSelection,
+ ) -> Self {
+ debug_assert_eq!(purpose, RegionPurposeTag::CaptureInteractive);
+ 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
}
diff --git a/src/backend/wayland/state/region_capture/intent.rs b/src/backend/wayland/state/region_capture/intent.rs
index 8ebdda741..9504f36fe 100644
--- a/src/backend/wayland/state/region_capture/intent.rs
+++ b/src/backend/wayland/state/region_capture/intent.rs
@@ -33,7 +33,6 @@ impl RegionPickerOptions {
self.show_size_readout
}
- #[allow(dead_code)] // Persisted in Phase 1; the loupe renderer arrives in Phase 2.
pub(in crate::backend::wayland) const fn show_loupe(self) -> bool {
self.show_loupe
}
diff --git a/src/backend/wayland/state/region_capture/picker.rs b/src/backend/wayland/state/region_capture/picker.rs
index 74ef4a6a6..390090e77 100644
--- a/src/backend/wayland/state/region_capture/picker.rs
+++ b/src/backend/wayland/state/region_capture/picker.rs
@@ -1,21 +1,26 @@
use crate::backend::wayland::acquisition::ScreenAcquisitionOwner;
use crate::backend::wayland::zoom::ZoomWaiterOwner;
use crate::capture::{
- CaptureDestination, CaptureType, ImageFormatMetadata, ImageOperationKind,
+ CaptureDestination, CaptureType, ImageFormatMetadata, ImageOperationKind, RenderImageRequest,
RenderedImageDeliveryRequest,
file::{FileSaveConfig, expand_tilde},
};
use crate::config::{Action, RegionPicker};
use crate::input::state::{
- RegionInputSource, RegionPurposeTag, ScreenCaptureSource, Toast, ToastPriority,
+ BoardPasteTarget, RegionInputSource, RegionPurposeTag, ScreenCaptureSource, Toast,
+ ToastPriority,
};
-use crate::screen_pixels::{ImagePixelRect, PackedArgb32};
+use crate::screen_pixels::{EmbeddedImageLimits, ImagePixelRect, PackedArgb32};
+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,
};
-use super::{ActiveScreenRegion, FreezeOwnership, RegionCaptureIntent, RegionPickerOptions};
+use super::{
+ ActiveScreenRegion, FreezeOwnership, RegionCaptureIntent, RegionPickerOptions,
+ RegionSelectionFinalize,
+};
use crate::backend::wayland::state::WaylandState;
const TOAST_SOURCE: &str = "capture";
@@ -66,6 +71,7 @@ fn region_destination(
Action::CaptureFileSelection | Action::CaptureFileRegion => {
Some(CaptureDestination::FileOnly)
}
+ Action::CaptureRegionInteractive => Some(default_destination),
_ => None,
}
}
@@ -73,6 +79,7 @@ fn region_destination(
fn region_delivery_request(
pixels: PackedArgb32,
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
@@ -85,13 +92,28 @@ fn region_delivery_request(
Box::new(move || crate::capture::png::encode_packed_argb32_png(&pixels));
RenderedImageDeliveryRequest {
render,
- destination: intent.destination(),
+ destination,
save_config,
operation: ImageOperationKind::Screenshot,
fallback_format_override: Some(ImageFormatMetadata::png()),
}
}
+#[derive(Debug, Clone, PartialEq, Eq)]
+enum RegionSubmit {
+ Deliver(CaptureDestination),
+ Board(BoardPasteTarget),
+}
+
+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 => None,
+ }
+}
+
fn legacy_region_request(intent: &RegionCaptureIntent) -> crate::capture::CaptureRequest {
crate::capture::CaptureRequest {
capture_type: CaptureType::Selection {
@@ -106,12 +128,84 @@ fn legacy_region_request(intent: &RegionCaptureIntent) -> crate::capture::Captur
}
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)
+ }
+
+ 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 {
+ 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;
+ }
+ let Some(ActiveScreenRegion::Ready { source, .. }) = self.data.active_screen_region
+ else {
+ return false;
+ };
+ let display = super::super::screen_image::screen_rect_for_image_rect(&source, rect);
+ let Some(world_bounds) =
+ super::world_rect_for_screen_rect(display, self.board_view_offset(), source)
+ else {
+ return false;
+ };
+ 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(rect) = self.whole_image_capture_rect() else {
+ let Some(RegionSelectionFinalize::Selected { purpose, rect }) =
+ self.whole_image_region_selection()
+ else {
return;
};
match self.input_state.region_state().selection_owner() {
@@ -121,7 +215,11 @@ impl WaylandState {
Some(RegionInputSource::Stylus) => self.retire_stylus_contact(),
None => {}
}
- self.submit_region_capture(rect);
+ if purpose == RegionPurposeTag::CaptureInteractive {
+ self.enter_region_review(rect);
+ } else {
+ self.submit_region_capture(rect);
+ }
}
pub(in crate::backend::wayland::state) fn begin_region_capture_action(
@@ -149,16 +247,23 @@ impl WaylandState {
return;
}
- let save_config = (!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 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,
@@ -167,7 +272,7 @@ impl WaylandState {
);
let intent = RegionCaptureIntent::new(
action,
- RegionPurposeTag::CaptureDeliver,
+ purpose,
destination,
save_config,
self.exit_after_capture_mode,
@@ -182,7 +287,16 @@ impl WaylandState {
return;
}
- if region.picker == RegionPicker::Slurp || !self.frozen_enabled() {
+ 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;
}
@@ -210,18 +324,14 @@ impl WaylandState {
self.frozen_enabled(),
) {
RegionPickerEntry::Activate => {
- if !self.activate_screen_region(
- RegionPurposeTag::CaptureDeliver,
- generation,
- FreezeOwnership::PreExisting,
- ) {
+ if !self.activate_screen_region(purpose, generation, FreezeOwnership::PreExisting) {
self.finish_region_activation_rejection();
}
}
RegionPickerEntry::WaitForZoom => {
if self.wait_for_current_zoom_capture(ZoomWaiterOwner::RegionCapture) {
self.set_pending_screen_region(
- RegionPurposeTag::CaptureDeliver,
+ purpose,
generation,
ScreenCaptureSource::Zoom,
None,
@@ -234,7 +344,7 @@ impl WaylandState {
RegionPickerEntry::AutoFreeze => {
match self.request_screen_acquisition(ScreenAcquisitionOwner::RegionCapture) {
Ok(acquisition) => self.set_pending_screen_region(
- RegionPurposeTag::CaptureDeliver,
+ purpose,
generation,
ScreenCaptureSource::Frozen,
Some(acquisition),
@@ -392,6 +502,16 @@ impl WaylandState {
}
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,
@@ -454,16 +574,47 @@ impl WaylandState {
self.release_owned_frozen_generation(image_generation);
}
- self.capture.set_exit_on_success(should_exit_after_capture(
- intent.exit_mode(),
- intent.destination(),
- ));
- let request = region_delivery_request(pixels, &intent);
- let submission = self
- .capture
- .manager_mut()
- .request_rendered_image_delivery(request);
- self.accept_capture_submission(submission, ImageOperationKind::Screenshot);
+ match submit {
+ RegionSubmit::Deliver(destination) => {
+ self.capture.set_exit_on_success(should_exit_after_capture(
+ intent.exit_mode(),
+ destination,
+ ));
+ let request = region_delivery_request(pixels, &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 render =
+ Box::new(move || crate::capture::png::encode_packed_argb32_png(&pixels));
+ 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."),
+ );
+ }
+ }
+ }
+ }
}
pub(super) fn cancel_region_capture_for_source_change(&mut self) {
@@ -504,9 +655,31 @@ mod tests {
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);
+ }
+
#[test]
fn native_entry_auto_freezes_solid_boards_but_refuses_solid_zoom() {
assert_eq!(
@@ -583,7 +756,7 @@ mod tests {
let pixels = PackedArgb32::new(1, 1, 4, 0xff33_2211_u32.to_ne_bytes().to_vec())
.expect("one native ARGB32 pixel");
- let request = region_delivery_request(pixels, &intent);
+ let request = region_delivery_request(pixels, &intent, intent.destination());
assert_eq!(request.destination, CaptureDestination::FileOnly);
assert_eq!(request.operation, ImageOperationKind::Screenshot);
diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs
index 0eebd4b36..38d31bfe1 100644
--- a/src/backend/wayland/state/render/ui.rs
+++ b/src/backend/wayland/state/render/ui.rs
@@ -1,6 +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(
@@ -340,6 +343,39 @@ impl WaylandState {
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));
+ 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),
+ )
+ });
crate::ui::render_region_capture_picker(
ctx,
@@ -351,6 +387,18 @@ impl WaylandState {
measurement: measurement.as_deref(),
show_legend: options.is_some_and(|options| options.show_legend())
&& !self.region_picker_legend_dismissed(),
+ loupe,
+ action_bar,
+ hovered_action,
+ },
+ |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,
+ )
+ })
},
);
}
diff --git a/src/backend/wayland/state/screen_image.rs b/src/backend/wayland/state/screen_image.rs
index 5f13df451..247121f28 100644
--- a/src/backend/wayland/state/screen_image.rs
+++ b/src/backend/wayland/state/screen_image.rs
@@ -227,7 +227,6 @@ pub(super) fn screen_point_for_image_point(
}
}
-#[allow(dead_code)] // Phase 2 review maps its authoritative pixel rectangle back to the overlay.
pub(super) fn screen_rect_for_image_rect(token: &ScreenSourceToken, rect: ImagePixelRect) -> Rect {
let first = screen_point_for_image_point(
token,
@@ -248,7 +247,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 2 review reuses the non-empty pixel-span mapping.
+#[allow(dead_code)] // The empty-span contract is exercised by mapping tests.
pub(super) fn screen_rect_for_pixel_span(
token: &ScreenSourceToken,
span: PixelSpan,
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..6c3d51e7f 100644
--- a/src/capture/mod.rs
+++ b/src/capture/mod.rs
@@ -31,5 +31,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..ced6c3781 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.
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/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/config/action_meta/entries/capture.rs b/src/config/action_meta/entries/capture.rs
index b7dea6925..082a50737 100644
--- a/src/config/action_meta/entries/capture.rs
+++ b/src/config/action_meta/entries/capture.rs
@@ -91,6 +91,17 @@ pub const ENTRIES: &[ActionMeta] = &[
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!(
ExportCanvasFile,
"Export Canvas to File",
diff --git a/src/config/action_meta/tests.rs b/src/config/action_meta/tests.rs
index 956344645..eea478055 100644
--- a/src/config/action_meta/tests.rs
+++ b/src/config/action_meta/tests.rs
@@ -93,6 +93,7 @@ const HELP_ACTIONS: &[Action] = &[
Action::CaptureFileSelection,
Action::CaptureActiveWindow,
Action::CaptureSelection,
+ Action::CaptureRegionInteractive,
Action::OpenCaptureFolder,
];
@@ -251,6 +252,7 @@ const EXPECTED_COMMAND_PALETTE_ACTIONS: &[Action] = &[
Action::SetColorWhite,
Action::SetColorBlack,
Action::PickScreenColor,
+ Action::CaptureRegionInteractive,
Action::CaptureClipboardFull,
Action::CaptureFileFull,
Action::ExportCanvasFile,
@@ -477,3 +479,19 @@ 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);
+}
diff --git a/src/config/keybindings/config/map/capture.rs b/src/config/keybindings/config/map/capture.rs
index 7df961f65..942fd8642 100644
--- a/src/config/keybindings/config/map/capture.rs
+++ b/src/config/keybindings/config/map/capture.rs
@@ -31,6 +31,10 @@ 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.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..1acc1ed86 100644
--- a/src/config/keybindings/config/map/edit.rs
+++ b/src/config/keybindings/config/map/edit.rs
@@ -183,6 +183,7 @@ define_action_binding_accessors! {
CaptureFileSelection => capture.capture_file_selection,
CaptureClipboardRegion => capture.capture_clipboard_region,
CaptureFileRegion => capture.capture_file_region,
+ CaptureRegionInteractive => capture.capture_region_interactive,
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..bf0e679f2 100644
--- a/src/config/keybindings/config/types/bindings/capture.rs
+++ b/src/config/keybindings/config/types/bindings/capture.rs
@@ -32,6 +32,11 @@ pub struct CaptureKeybindingsConfig {
#[serde(default = "default_capture_file_region")]
pub capture_file_region: Vec,
+ /// Opens the post-selection review UI. Palette-first and unbound by
+ /// default so it does not claim another screenshot chord.
+ #[serde(default = "default_capture_region_interactive")]
+ pub capture_region_interactive: Vec,
+
#[serde(default = "default_export_canvas_file")]
pub export_canvas_file: Vec,
@@ -68,6 +73,7 @@ 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(),
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..8dd707aa6 100644
--- a/src/config/keybindings/defaults/capture.rs
+++ b/src/config/keybindings/defaults/capture.rs
@@ -34,6 +34,12 @@ pub(crate) fn default_capture_file_region() -> Vec {
vec!["Ctrl+Alt+6".to_string()]
}
+/// Deliberately empty: interactive capture is palette-first until the user
+/// chooses a shortcut that fits their compositor and existing bindings.
+pub(crate) fn default_capture_region_interactive() -> 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..78e4ebcf3 100644
--- a/src/config/keybindings/tests.rs
+++ b/src/config/keybindings/tests.rs
@@ -505,6 +505,26 @@ fn screen_text_recognition_is_unbound_by_default_and_leaves_o_to_orange() {
);
}
+#[test]
+fn interactive_region_capture_is_unbound_by_default_and_maps_when_configured() {
+ let mut config = KeybindingsConfig::default();
+ assert!(config.capture.capture_region_interactive.is_empty());
+ assert!(
+ !config
+ .build_action_map()
+ .unwrap()
+ .values()
+ .any(|action| *action == 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 canvas_export_actions_deserialize_from_config_names() {
#[derive(serde::Deserialize)]
@@ -776,6 +796,7 @@ const DEFAULT_BINDING_SNAPSHOT: &[(&str, &[&str])] = &[
("capture_file_selection", &["Ctrl+Shift+S"]),
("capture_clipboard_region", &["Ctrl+6"]),
("capture_file_region", &["Ctrl+Alt+6"]),
+ ("capture_region_interactive", &[]),
("export_canvas_file", &[]),
("export_canvas_clipboard", &[]),
("export_canvas_clipboard_and_file", &[]),
diff --git a/src/config/tests/schema.rs b/src/config/tests/schema.rs
index d9aaa95a3..6b6b1949d 100644
--- a/src/config/tests/schema.rs
+++ b/src/config/tests/schema.rs
@@ -54,6 +54,15 @@ fn region_capture_paths_exist_in_json_schema() {
}
}
+#[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/configurator_destination.rs b/src/configurator_destination.rs
index a900d647b..2b8d91ba5 100644
--- a/src/configurator_destination.rs
+++ b/src/configurator_destination.rs
@@ -205,6 +205,7 @@ pub fn keybindings_section_for_action(action: Action) -> Option {
+ | Action::CaptureFileRegion
+ | Action::CaptureRegionInteractive => {
// Capture actions are handled externally by WaylandState
// since they require access to CaptureManager
// Store the action for later retrieval
@@ -155,6 +156,7 @@ mod tests {
Action::CaptureFileSelection,
Action::CaptureClipboardRegion,
Action::CaptureFileRegion,
+ Action::CaptureRegionInteractive,
] {
for modal in ["ocr", "eyedropper"] {
let mut state = make_test_input_state();
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/region_select.rs b/src/input/state/core/region_select.rs
index 427ed3332..4bc9fa50a 100644
--- a/src/input/state/core/region_select.rs
+++ b/src/input/state/core/region_select.rs
@@ -77,7 +77,7 @@ 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 and native screen-region capture.
///
/// It owns transient input and render state only. The selected pixels and the
/// recognition work belong outside `InputState`, so cancelling here can never
@@ -105,11 +105,23 @@ 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,
+ },
}
impl RegionSelectUiState {
pub fn is_active(self) -> bool {
- matches!(self, Self::Armed { .. } | Self::Selecting { .. })
+ matches!(
+ self,
+ Self::Armed { .. } | Self::Selecting { .. } | Self::Review { .. }
+ )
}
pub fn is_pending(self) -> bool {
@@ -124,6 +136,10 @@ impl RegionSelectUiState {
matches!(self, Self::Selecting { .. })
}
+ pub fn is_review(self) -> bool {
+ matches!(self, Self::Review { .. })
+ }
+
/// The region drag in logical screen coordinates, if one is in progress.
pub fn selection(self) -> Option {
match self {
@@ -131,6 +147,7 @@ impl RegionSelectUiState {
start,
end: current,
}),
+ Self::Review { display, .. } => Some(display),
Self::Inactive | Self::PendingCapture { .. } | Self::Armed { .. } => None,
}
}
@@ -139,6 +156,7 @@ impl RegionSelectUiState {
pub fn selection_owner(self) -> Option {
match self {
Self::Selecting { owner, .. } => Some(owner),
+ Self::Review { move_owner, .. } => move_owner,
Self::Inactive | Self::PendingCapture { .. } | Self::Armed { .. } => None,
}
}
@@ -146,7 +164,9 @@ impl RegionSelectUiState {
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 { .. } => {
+ None
+ }
}
}
@@ -154,7 +174,8 @@ impl RegionSelectUiState {
match self {
Self::PendingCapture { purpose, .. }
| Self::Armed { purpose, .. }
- | Self::Selecting { purpose, .. } => Some(purpose),
+ | Self::Selecting { purpose, .. }
+ | Self::Review { purpose, .. } => Some(purpose),
Self::Inactive => None,
}
}
@@ -163,7 +184,8 @@ impl RegionSelectUiState {
match self {
Self::PendingCapture { generation, .. }
| Self::Armed { generation, .. }
- | Self::Selecting { generation, .. } => Some(generation),
+ | Self::Selecting { generation, .. }
+ | Self::Review { generation, .. } => Some(generation),
Self::Inactive => None,
}
}
@@ -235,12 +257,17 @@ 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,
+ ..
+ } => (purpose, generation),
+ _ => return false,
};
self.region_select_ui_state = RegionSelectUiState::Selecting {
purpose,
@@ -253,6 +280,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)) {
@@ -392,6 +474,57 @@ 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 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..dc553b694 100644
--- a/src/input/state/interaction/actions.rs
+++ b/src/input/state/interaction/actions.rs
@@ -135,6 +135,7 @@ pub(crate) fn classify_action(action: Action) -> ActionRoute {
| Action::CaptureFileSelection
| Action::CaptureClipboardRegion
| Action::CaptureFileRegion
+ | Action::CaptureRegionInteractive
| 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/screen_pixels.rs b/src/screen_pixels.rs
index 71a4f6474..e9101a76f 100644
--- a/src/screen_pixels.rs
+++ b/src/screen_pixels.rs
@@ -98,12 +98,12 @@ pub struct PixelSpan {
}
impl PixelSpan {
- #[allow(dead_code)] // Phase 2 review nudging and board placement need the span origin.
+ #[allow(dead_code)] // Geometry diagnostics and tests inspect the quantized origin.
pub const fn x(self) -> u32 {
self.x
}
- #[allow(dead_code)] // Phase 2 review nudging and board placement need the span origin.
+ #[allow(dead_code)] // Geometry diagnostics and tests inspect the quantized origin.
pub const fn y(self) -> u32 {
self.y
}
diff --git a/src/ui.rs b/src/ui.rs
index a4940d8bb..5b7939ec3 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -14,6 +14,7 @@ mod precision_entry;
mod primitives;
mod properties_panel;
mod radial_menu;
+mod region_action_bar;
mod region_capture_picker;
mod status;
mod text_highlight;
@@ -42,8 +43,10 @@ pub(crate) use primitives::checkerboard_behind;
pub(crate) use primitives::ellipsize_to_fit;
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::{
- RegionCapturePickerVisual, capture_size_text, render_region_capture_picker,
+ RegionCaptureLoupeVisual, RegionCapturePickerVisual, capture_size_text,
+ render_region_capture_picker,
};
pub use status::{
StatusHudLayout, StatusHudSegmentKind, ZoomChipButtonKind, ZoomChipLayout, ZoomChipPress,
diff --git a/src/ui/help_overlay/sections/builder/sections.rs b/src/ui/help_overlay/sections/builder/sections.rs
index 11a4b0e06..2ff74cd1f 100644
--- a/src/ui/help_overlay/sections/builder/sections.rs
+++ b/src/ui/help_overlay/sections/builder/sections.rs
@@ -279,6 +279,7 @@ pub(super) fn build_main_sections(
"Selection (capture defaults)",
)
.with_action(Action::CaptureSelection),
+ action_row(bindings, Action::CaptureRegionInteractive, NOT_BOUND_LABEL),
]);
}
screenshot_rows.extend([
diff --git a/src/ui/help_overlay/sections/tests.rs b/src/ui/help_overlay/sections/tests.rs
index 518c36e23..dc95edb5e 100644
--- a/src/ui/help_overlay/sections/tests.rs
+++ b/src/ui/help_overlay/sections/tests.rs
@@ -60,3 +60,18 @@ 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
+ }));
+}
diff --git a/src/ui/region_action_bar.rs b/src/ui/region_action_bar.rs
new file mode 100644
index 000000000..b61dd4929
--- /dev/null
+++ b/src/ui/region_action_bar.rs
@@ -0,0 +1,329 @@
+use crate::input::state::RegionSelection;
+
+use super::primitives::{draw_rounded_rect, text_extents_for};
+
+const SURFACE_MARGIN: f64 = 8.0;
+const SELECTION_GAP: f64 = 10.0;
+const BAR_WIDTH: f64 = 296.0;
+const BAR_HEIGHT: f64 = 44.0;
+const BAR_PADDING: f64 = 6.0;
+const ITEM_GAP: f64 = 4.0;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum RegionAction {
+ Copy,
+ Save,
+ Both,
+ Board,
+}
+
+impl RegionAction {
+ pub(crate) const fn label(self) -> &'static str {
+ match self {
+ Self::Copy => "Copy",
+ Self::Save => "Save",
+ Self::Both => "Both",
+ Self::Board => "Board",
+ }
+ }
+
+ pub(crate) const fn shortcut(self) -> &'static str {
+ match self {
+ Self::Copy => "Ctrl+C",
+ Self::Save => "Ctrl+S",
+ Self::Both => "Enter",
+ Self::Board => "B",
+ }
+ }
+}
+
+#[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],
+}
+
+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 = (height - BAR_PADDING * 2.0).max(0.0);
+ let item_width = ((width - BAR_PADDING * 2.0 - ITEM_GAP * 3.0) / 4.0).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,
+ ),
+ };
+ Self {
+ bounds,
+ items: [
+ item(0, RegionAction::Copy),
+ item(1, RegionAction::Save),
+ item(2, RegionAction::Both),
+ item(3, RegionAction::Board),
+ ],
+ }
+ }
+
+ #[cfg_attr(not(test), allow(dead_code))]
+ 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)
+ }
+
+ 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,
+) {
+ let _ = ctx.save();
+ ctx.set_source_rgba(0.045, 0.05, 0.065, 0.96);
+ draw_rounded_rect(
+ ctx,
+ bar.bounds.x,
+ bar.bounds.y,
+ bar.bounds.width,
+ bar.bounds.height,
+ 9.0,
+ );
+ let _ = ctx.fill();
+ ctx.set_source_rgba(1.0, 1.0, 1.0, 0.18);
+ ctx.set_line_width(1.0);
+ draw_rounded_rect(
+ ctx,
+ bar.bounds.x + 0.5,
+ bar.bounds.y + 0.5,
+ bar.bounds.width - 1.0,
+ bar.bounds.height - 1.0,
+ 8.5,
+ );
+ let _ = ctx.stroke();
+
+ for item in bar.items {
+ if item.bounds.width <= 0.0 || item.bounds.height <= 0.0 {
+ continue;
+ }
+ let is_hovered = hovered == Some(item.action);
+ if is_hovered {
+ ctx.set_source_rgba(0.24, 0.48, 1.0, 0.34);
+ } else {
+ ctx.set_source_rgba(1.0, 1.0, 1.0, 0.07);
+ }
+ draw_rounded_rect(
+ ctx,
+ item.bounds.x,
+ item.bounds.y,
+ item.bounds.width,
+ item.bounds.height,
+ 6.0,
+ );
+ let _ = ctx.fill();
+ draw_action_text(ctx, item);
+ }
+ let _ = ctx.restore();
+}
+
+fn draw_action_text(ctx: &cairo::Context, item: RegionActionItem) {
+ 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 label_extents = text_extents_for(
+ ctx,
+ "Sans",
+ cairo::FontSlant::Normal,
+ cairo::FontWeight::Bold,
+ 10.5,
+ label,
+ );
+ ctx.set_source_rgba(1.0, 1.0, 1.0, 0.94);
+ ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Bold);
+ ctx.set_font_size(10.5);
+ ctx.move_to(
+ center_x - label_extents.width() / 2.0 - label_extents.x_bearing(),
+ item.bounds.y + 13.0 - label_extents.y_bearing(),
+ );
+ let _ = ctx.show_text(label);
+
+ let shortcut = item.action.shortcut();
+ let shortcut_extents = text_extents_for(
+ ctx,
+ "Sans",
+ cairo::FontSlant::Normal,
+ cairo::FontWeight::Normal,
+ 8.0,
+ shortcut,
+ );
+ ctx.set_source_rgba(1.0, 1.0, 1.0, 0.58);
+ ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Normal);
+ ctx.set_font_size(8.0);
+ ctx.move_to(
+ center_x - shortcut_extents.width() / 2.0 - shortcut_extents.x_bearing(),
+ item.bounds.y + 25.0 - shortcut_extents.y_bearing(),
+ );
+ let _ = ctx.show_text(shortcut);
+ let _ = ctx.restore();
+}
+
+#[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(52.0, 210.0, 296.0, 44.0)
+ );
+
+ let flipped = RegionActionBar::place(
+ RegionSelection {
+ start: (730.0, 560.0),
+ end: (790.0, 590.0),
+ },
+ (800, 600),
+ );
+ assert_eq!(
+ flipped.bounds(),
+ RegionActionRect::new(496.0, 506.0, 296.0, 44.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((92.0, 232.0)), Some(RegionAction::Copy));
+ assert_eq!(bar.hit((164.0, 232.0)), Some(RegionAction::Save));
+ assert_eq!(bar.hit((236.0, 232.0)), Some(RegionAction::Both));
+ assert_eq!(bar.hit((308.0, 232.0)), Some(RegionAction::Board));
+ assert_eq!(bar.hit((128.0, 232.0)), None, "inter-item gap");
+ assert!(bar.contains((128.0, 232.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_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");
+ }
+
+ #[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));
+ 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(54, 212) > 0, "bar surface");
+ for x in [92, 164, 236, 308] {
+ assert!(alpha(x, 232) > 0, "control at x={x}");
+ }
+ assert_eq!(alpha(20, 20), 0, "outside remains untouched");
+ }
+}
diff --git a/src/ui/region_capture_picker.rs b/src/ui/region_capture_picker.rs
index c5f80766c..6144ec234 100644
--- a/src/ui/region_capture_picker.rs
+++ b/src/ui/region_capture_picker.rs
@@ -1,6 +1,7 @@
use crate::input::state::RegionSelection;
use super::primitives::{draw_rounded_rect, text_extents_for};
+use super::region_action_bar::{RegionAction, RegionActionBar, render_region_action_bar};
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);
@@ -20,6 +21,28 @@ pub(crate) struct RegionCapturePickerVisual<'a> {
pub pointer: (f64, f64),
pub measurement: Option<&'a str>,
pub show_legend: bool,
+ pub loupe: Option,
+ pub action_bar: Option,
+ pub hovered_action: Option,
+}
+
+#[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)]
@@ -34,11 +57,22 @@ pub(crate) fn capture_size_text(size: (u32, u32)) -> String {
format!("{} × {}", size.0, size.1)
}
+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);
@@ -73,6 +107,12 @@ pub(crate) fn render_region_capture_picker(
if visual.show_legend && visual.selection.is_none() {
draw_legend(ctx, (screen_width, screen_height));
}
+ 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);
+ }
let _ = ctx.restore();
}
@@ -302,7 +342,11 @@ mod tests {
pointer: (30.0, 30.0),
measurement: None,
show_legend: false,
+ loupe: None,
+ action_bar: None,
+ hovered_action: None,
},
+ |_x, _y| None,
);
drop(ctx);
surface.flush();
@@ -329,7 +373,11 @@ mod tests {
pointer: (20.0, 20.0),
measurement: None,
show_legend: false,
+ loupe: None,
+ action_bar: None,
+ hovered_action: None,
},
+ |_x, _y| None,
);
drop(ctx);
surface.flush();
@@ -340,4 +388,60 @@ mod tests {
"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_legend: false,
+ loupe: None,
+ action_bar: Some(bar),
+ hovered_action: Some(crate::ui::RegionAction::Both),
+ },
+ |_x, _y| None,
+ );
+ drop(ctx);
+ surface.flush();
+ let stride = surface.stride() as usize;
+ let data = surface.data().unwrap();
+ assert!(data[212 * stride + 54 * 4 + 3] > 0, "action bar surface");
+ assert!(
+ data[150 * stride + 200 * 4 + 3] > 0,
+ "review keeps the pointer crosshair with its size readout"
+ );
+ }
+
+ #[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");
+ }
}
From eebe28e33195065605a590a4857acb799d3545a2 Mon Sep 17 00:00:00 2001
From: devmobasa <4170275+devmobasa@users.noreply.github.com>
Date: Sat, 22 Aug 2026 18:19:42 +0200
Subject: [PATCH 03/13] feat(capture): verify image clipboard writes
---
config.example.toml | 6 +-
docs/CONFIG.md | 4 +
docs/codebase-overview.md | 3 +-
src/capture/clipboard.rs | 350 +++++++++++++++++++++++----
src/capture/dependencies.rs | 12 +-
src/capture/pipeline.rs | 12 +-
src/capture/tests/fixtures.rs | 4 +
src/capture/tests/perform_capture.rs | 6 +-
src/process_broker/manifest.rs | 6 +-
src/process_broker/mod.rs | 5 +
src/process_broker/tests.rs | 4 -
11 files changed, 335 insertions(+), 77 deletions(-)
diff --git a/config.example.toml b/config.example.toml
index e22d45c0e..afb7879cf 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -1406,8 +1406,10 @@ 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
# Exit overlay after any capture completes (forces exit for all capture types)
diff --git a/docs/CONFIG.md b/docs/CONFIG.md
index e96513c69..1b2fdfb69 100644
--- a/docs/CONFIG.md
+++ b/docs/CONFIG.md
@@ -1420,6 +1420,10 @@ show_legend = true
**Tips:**
- Set `copy_to_clipboard = false` if you prefer file-only captures.
- 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. 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`.
diff --git a/docs/codebase-overview.md b/docs/codebase-overview.md
index 27bd31607..64d93e55d 100644
--- a/docs/codebase-overview.md
+++ b/docs/codebase-overview.md
@@ -145,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/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/pipeline.rs b/src/capture/pipeline.rs
index ced6c3781..fc6db0be5 100644
--- a/src/capture/pipeline.rs
+++ b/src/capture/pipeline.rs
@@ -410,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/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/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/process_broker/manifest.rs b/src/process_broker/manifest.rs
index cc28711a2..c868a498e 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
}
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..b32d97da4 100644
--- a/src/process_broker/tests.rs
+++ b/src/process_broker/tests.rs
@@ -1117,10 +1117,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();
From ea646b344290c70699533cfe380d7b3300b2fee8 Mon Sep 17 00:00:00 2001
From: devmobasa <4170275+devmobasa@users.noreply.github.com>
Date: Sat, 22 Aug 2026 20:04:25 +0200
Subject: [PATCH 04/13] feat(ui): show shape dimensions while drawing
---
config.example.toml | 4 +
configurator/src/app/pages/ui/general.rs | 6 +
configurator/src/app/search/terms.rs | 6 +
configurator/src/app/search/tests.rs | 5 +
.../src/models/config/draft/from_config.rs | 1 +
configurator/src/models/config/draft/mod.rs | 1 +
configurator/src/models/config/setters.rs | 1 +
configurator/src/models/config/tests.rs | 14 ++
.../src/models/config/to_config/ui.rs | 1 +
configurator/src/models/fields/toggles.rs | 1 +
docs/CONFIG.md | 5 +
src/backend/wayland/state/data.rs | 2 +
.../wayland/state/render/measure_badge.rs | 55 ++++++++
src/backend/wayland/state/render/mod.rs | 3 +
src/backend/wayland/state/render/ui.rs | 3 +
.../wayland/state/render/ui_effect_damage.rs | 35 +++++
src/config/tests/load.rs | 14 ++
src/config/tests/schema.rs | 9 ++
src/config/types/ui.rs | 9 ++
src/input/state/render.rs | 28 ++++
src/input/state/tests/drawing.rs | 58 +++++++++
src/ui.rs | 6 +-
src/ui/measure_badge.rs | 122 ++++++++++++++++++
23 files changed, 388 insertions(+), 1 deletion(-)
create mode 100644 src/backend/wayland/state/render/measure_badge.rs
create mode 100644 src/ui/measure_badge.rs
diff --git a/config.example.toml b/config.example.toml
index afb7879cf..fa29f5595 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -460,6 +460,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
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/terms.rs b/configurator/src/app/search/terms.rs
index 6df0b03c0..7de90341b 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] = &[
diff --git a/configurator/src/app/search/tests.rs b/configurator/src/app/search/tests.rs
index 4cda704bd..74cb1638e 100644
--- a/configurator/src/app/search/tests.rs
+++ b/configurator/src/app/search/tests.rs
@@ -614,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);
diff --git a/configurator/src/models/config/draft/from_config.rs b/configurator/src/models/config/draft/from_config.rs
index 574af2bbd..e7fee6bf5 100644
--- a/configurator/src/models/config/draft/from_config.rs
+++ b/configurator/src/models/config/draft/from_config.rs
@@ -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,
diff --git a/configurator/src/models/config/draft/mod.rs b/configurator/src/models/config/draft/mod.rs
index 7a7424003..3253988c6 100644
--- a/configurator/src/models/config/draft/mod.rs
+++ b/configurator/src/models/config/draft/mod.rs
@@ -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,
diff --git a/configurator/src/models/config/setters.rs b/configurator/src/models/config/setters.rs
index 843060528..e55b740ba 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,
diff --git a/configurator/src/models/config/tests.rs b/configurator/src/models/config/tests.rs
index eab269bd4..46a868256 100644
--- a/configurator/src/models/config/tests.rs
+++ b/configurator/src/models/config/tests.rs
@@ -57,6 +57,20 @@ fn config_draft_round_trips_automatic_onboarding_preference() {
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();
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/toggles.rs b/configurator/src/models/fields/toggles.rs
index 533ae113b..60e01b8c6 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,
diff --git a/docs/CONFIG.md b/docs/CONFIG.md
index 1b2fdfb69..069a9c233 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.
diff --git a/src/backend/wayland/state/data.rs b/src/backend/wayland/state/data.rs
index b4274e6ef..eb8e3f909 100644
--- a/src/backend/wayland/state/data.rs
+++ b/src/backend/wayland/state/data.rs
@@ -192,6 +192,7 @@ 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,
/// 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,
@@ -284,6 +285,7 @@ impl StateData {
prev_command_palette_damage: None,
prev_color_picker_damage: None,
prev_tool_preview_damage: None,
+ prev_shape_measure_badge_damage: None,
top_strip_fade: crate::ui::toolbar::snapshot::fade::TopStripFade::new(),
shortcut_coach: super::onboarding::ShortcutCoachSession::default(),
}
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 0b102ce7e..c3741ca02 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;
@@ -146,6 +147,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 +159,7 @@ impl WaylandState {
command_palette_active,
color_picker_active,
tool_preview_active,
+ shape_measure_badge_active,
width,
height,
);
diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs
index 38d31bfe1..62fe73df7 100644
--- a/src/backend/wayland/state/render/ui.rs
+++ b/src/backend/wayland/state/render/ui.rs
@@ -47,6 +47,9 @@ 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()
diff --git a/src/backend/wayland/state/render/ui_effect_damage.rs b/src/backend/wayland/state/render/ui_effect_damage.rs
index e0be51f7e..94e52633e 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,17 @@ 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;
+
regions
}
@@ -308,6 +320,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/config/tests/load.rs b/src/config/tests/load.rs
index f0779d28b..df083ab22 100644
--- a/src/config/tests/load.rs
+++ b/src/config/tests/load.rs
@@ -100,6 +100,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 6b6b1949d..50df335d5 100644
--- a/src/config/tests/schema.rs
+++ b/src/config/tests/schema.rs
@@ -54,6 +54,15 @@ fn region_capture_paths_exist_in_json_schema() {
}
}
+#[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();
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/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/ui.rs b/src/ui.rs
index 5b7939ec3..73344af15 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -9,6 +9,7 @@ mod context_menu;
mod eyedropper_loupe;
mod help_overlay;
mod input_hud;
+mod measure_badge;
mod onboarding_card;
mod precision_entry;
mod primitives;
@@ -36,11 +37,14 @@ 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};
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);
+ }
+}
From 4f62247bcc0390e53b7238b27247ac41b49bf997 Mon Sep 17 00:00:00 2001
From: devmobasa <4170275+devmobasa@users.noreply.github.com>
Date: Sat, 22 Aug 2026 22:10:13 +0200
Subject: [PATCH 05/13] feat(capture): add measurement and annotated
screenshots
---
README.md | 18 +
config.example.toml | 8 +
configurator/src/app/pages/capture.rs | 6 +
configurator/src/app/search/terms.rs | 5 +
configurator/src/app/search/tests.rs | 13 +
.../src/models/config/draft/from_config.rs | 1 +
configurator/src/models/config/draft/mod.rs | 1 +
configurator/src/models/config/setters.rs | 1 +
configurator/src/models/config/tests.rs | 13 +
.../src/models/config/to_config/capture.rs | 1 +
configurator/src/models/fields/toggles.rs | 1 +
.../models/keybindings/field/config/read.rs | 1 +
.../models/keybindings/field/config/write.rs | 1 +
.../src/models/keybindings/field/labels.rs | 1 +
.../src/models/keybindings/field/list.rs | 1 +
.../src/models/keybindings/field/mod.rs | 1 +
.../src/models/keybindings/field/tab.rs | 1 +
configurator/src/models/keybindings/tests.rs | 14 +
docs/CONFIG.md | 30 +
.../wayland/backend/event_loop/capture.rs | 1 +
src/backend/wayland/capture.rs | 1 +
src/backend/wayland/frozen/image.rs | 10 +-
src/backend/wayland/frozen/state.rs | 15 +-
src/backend/wayland/handlers/keyboard/mod.rs | 37 +-
src/backend/wayland/handlers/pointer/press.rs | 33 +-
src/backend/wayland/state/canvas_layer.rs | 2 +
src/backend/wayland/state/capture.rs | 56 +-
src/backend/wayland/state/core/overlay.rs | 6 +
src/backend/wayland/state/data.rs | 12 +
src/backend/wayland/state/ocr.rs | 24 +-
src/backend/wayland/state/region_capture.rs | 1934 +----------------
.../state/region_capture/active_state.rs | 318 +++
.../wayland/state/region_capture/board.rs | 69 +
.../wayland/state/region_capture/delivery.rs | 342 +++
.../wayland/state/region_capture/events.rs | 222 ++
.../wayland/state/region_capture/intent.rs | 11 +
.../wayland/state/region_capture/measure.rs | 71 +
.../wayland/state/region_capture/picker.rs | 503 +----
.../state/region_capture/review_state.rs | 142 ++
.../wayland/state/region_capture/runtime.rs | 366 ++++
.../state/region_capture/selection_state.rs | 94 +
.../state/region_capture/source_guard.rs | 43 +
.../region_capture/tests/capture_selection.rs | 120 +
.../tests/lifecycle_and_measure.rs | 382 ++++
.../wayland/state/region_capture/tests/mod.rs | 8 +
.../wayland/state/region_capture/tests/ocr.rs | 216 ++
.../state/region_capture/tests/picker.rs | 236 ++
.../state/region_capture/tests/review.rs | 448 ++++
.../wayland/state/render/canvas/background.rs | 2 +
.../wayland/state/render/canvas/mod.rs | 17 +-
src/backend/wayland/state/render/mod.rs | 2 +
src/backend/wayland/state/render/ui.rs | 49 +-
.../wayland/state/render/ui_effect_damage.rs | 16 +
src/backend/wayland/state/screen_image.rs | 29 +
src/backend/wayland/zoom/state.rs | 15 +-
src/canvas_export/mod.rs | 2 +
src/canvas_export/page.rs | 102 +
src/canvas_export/png.rs | 23 +-
src/canvas_export/region.rs | 358 +++
src/config/action_meta/entries/capture.rs | 11 +
src/config/action_meta/tests.rs | 14 +
src/config/keybindings/config/map/capture.rs | 1 +
src/config/keybindings/config/map/edit.rs | 1 +
.../config/types/bindings/capture.rs | 6 +
src/config/keybindings/defaults/capture.rs | 6 +
src/config/keybindings/tests.rs | 21 +
src/config/tests/load.rs | 14 +
src/config/tests/schema.rs | 9 +
src/config/types/capture.rs | 10 +
src/configurator_destination.rs | 1 +
src/domain/action.rs | 2 +
src/domain/tests.rs | 2 +
src/draw/render/blur.rs | 14 +-
src/draw/render/blur/style_tests.rs | 2 +
src/draw/render/types.rs | 4 +
.../state/actions/action_capture_zoom.rs | 52 +
src/input/state/core/base/types.rs | 1 +
src/input/state/core/modal.rs | 6 +-
src/input/state/core/region_select.rs | 115 +-
src/input/state/interaction/actions.rs | 1 +
src/screen_pixels.rs | 17 +
src/ui.rs | 2 +-
.../page_panel/thumbnail/content.rs | 2 +
.../help_overlay/sections/builder/sections.rs | 1 +
src/ui/help_overlay/sections/tests.rs | 15 +
src/ui/region_action_bar.rs | 131 +-
src/ui/region_capture_picker.rs | 182 +-
87 files changed, 4624 insertions(+), 2474 deletions(-)
create mode 100644 src/backend/wayland/state/region_capture/active_state.rs
create mode 100644 src/backend/wayland/state/region_capture/delivery.rs
create mode 100644 src/backend/wayland/state/region_capture/events.rs
create mode 100644 src/backend/wayland/state/region_capture/measure.rs
create mode 100644 src/backend/wayland/state/region_capture/review_state.rs
create mode 100644 src/backend/wayland/state/region_capture/runtime.rs
create mode 100644 src/backend/wayland/state/region_capture/selection_state.rs
create mode 100644 src/backend/wayland/state/region_capture/source_guard.rs
create mode 100644 src/backend/wayland/state/region_capture/tests/capture_selection.rs
create mode 100644 src/backend/wayland/state/region_capture/tests/lifecycle_and_measure.rs
create mode 100644 src/backend/wayland/state/region_capture/tests/mod.rs
create mode 100644 src/backend/wayland/state/region_capture/tests/ocr.rs
create mode 100644 src/backend/wayland/state/region_capture/tests/picker.rs
create mode 100644 src/backend/wayland/state/region_capture/tests/review.rs
create mode 100644 src/canvas_export/region.rs
diff --git a/README.md b/README.md
index cb8491d2c..500db17ae 100644
--- a/README.md
+++ b/README.md
@@ -744,6 +744,7 @@ Light passthrough (layer-shell compositors only) lets normal keyboard and pointe
| Ctrl+6 | Region → clipboard (explicit) |
| Ctrl+Alt+6 | Region → save PNG (explicit) |
| Unbound | Capture region interactively, then choose Copy, Save, Both, or Board (`capture_region_interactive`) |
+| 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
@@ -759,6 +760,23 @@ 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 Copy, Save, or Both action. Board always inserts the original
+crop so existing annotations are not baked into a duplicate image on the same
+board.
+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 fa29f5595..065071a52 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -278,6 +278,9 @@ capture_file_region = ["Ctrl+Alt+6"]
# Open the region review/action bar. Available from the command palette and
# unbound by default.
capture_region_interactive = []
+# 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 = []
@@ -1416,6 +1419,11 @@ format = "png"
# 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.
diff --git a/configurator/src/app/pages/capture.rs b/configurator/src/app/pages/capture.rs
index f2498ec4e..700b4ff11 100644
--- a/configurator/src/app/pages/capture.rs
+++ b/configurator/src/app/pages/capture.rs
@@ -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",
"",
diff --git a/configurator/src/app/search/terms.rs b/configurator/src/app/search/terms.rs
index 7de90341b..55398f314 100644
--- a/configurator/src/app/search/terms.rs
+++ b/configurator/src/app/search/terms.rs
@@ -379,6 +379,11 @@ 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",
];
diff --git a/configurator/src/app/search/tests.rs b/configurator/src/app/search/tests.rs
index 74cb1638e..5ebfa5984 100644
--- a/configurator/src/app/search/tests.rs
+++ b/configurator/src/app/search/tests.rs
@@ -646,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/models/config/draft/from_config.rs b/configurator/src/models/config/draft/from_config.rs
index e7fee6bf5..0f7944ad0 100644
--- a/configurator/src/models/config/draft/from_config.rs
+++ b/configurator/src/models/config/draft/from_config.rs
@@ -249,6 +249,7 @@ 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),
diff --git a/configurator/src/models/config/draft/mod.rs b/configurator/src/models/config/draft/mod.rs
index 3253988c6..3023c3d12 100644
--- a/configurator/src/models/config/draft/mod.rs
+++ b/configurator/src/models/config/draft/mod.rs
@@ -182,6 +182,7 @@ 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,
diff --git a/configurator/src/models/config/setters.rs b/configurator/src/models/config/setters.rs
index e55b740ba..6ad13bd9a 100644
--- a/configurator/src/models/config/setters.rs
+++ b/configurator/src/models/config/setters.rs
@@ -258,6 +258,7 @@ 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;
diff --git a/configurator/src/models/config/tests.rs b/configurator/src/models/config/tests.rs
index 46a868256..16b8176a6 100644
--- a/configurator/src/models/config/tests.rs
+++ b/configurator/src/models/config/tests.rs
@@ -96,6 +96,19 @@ fn config_draft_round_trips_region_capture_settings() {
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 2147a09bc..4f9125852 100644
--- a/configurator/src/models/config/to_config/capture.rs
+++ b/configurator/src/models/config/to_config/capture.rs
@@ -23,6 +23,7 @@ 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;
diff --git a/configurator/src/models/fields/toggles.rs b/configurator/src/models/fields/toggles.rs
index 60e01b8c6..c53a6d315 100644
--- a/configurator/src/models/fields/toggles.rs
+++ b/configurator/src/models/fields/toggles.rs
@@ -64,6 +64,7 @@ pub enum ToggleField {
BoardsPersistCustomizations,
CaptureEnabled,
CaptureCopyToClipboard,
+ CaptureIncludeDrawings,
CaptureExitAfter,
CaptureRegionShowSizeReadout,
CaptureRegionShowLoupe,
diff --git a/configurator/src/models/keybindings/field/config/read.rs b/configurator/src/models/keybindings/field/config/read.rs
index e9e511d38..75c803940 100644
--- a/configurator/src/models/keybindings/field/config/read.rs
+++ b/configurator/src/models/keybindings/field/config/read.rs
@@ -128,6 +128,7 @@ impl KeybindingField {
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 a430bde82..8d316c247 100644
--- a/configurator/src/models/keybindings/field/config/write.rs
+++ b/configurator/src/models/keybindings/field/config/write.rs
@@ -129,6 +129,7 @@ impl KeybindingField {
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 b78cd0529..5ebe9c270 100644
--- a/configurator/src/models/keybindings/field/labels.rs
+++ b/configurator/src/models/keybindings/field/labels.rs
@@ -138,6 +138,7 @@ impl KeybindingField {
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 a4cde3f1f..877bc6eaa 100644
--- a/configurator/src/models/keybindings/field/list.rs
+++ b/configurator/src/models/keybindings/field/list.rs
@@ -123,6 +123,7 @@ impl KeybindingField {
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 da7692ab8..b0dbcbdd1 100644
--- a/configurator/src/models/keybindings/field/mod.rs
+++ b/configurator/src/models/keybindings/field/mod.rs
@@ -108,6 +108,7 @@ pub enum KeybindingField {
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 ab82fa71d..faba941c0 100644
--- a/configurator/src/models/keybindings/field/tab.rs
+++ b/configurator/src/models/keybindings/field/tab.rs
@@ -124,6 +124,7 @@ impl KeybindingField {
| 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 beec64443..520edefab 100644
--- a/configurator/src/models/keybindings/tests.rs
+++ b/configurator/src/models/keybindings/tests.rs
@@ -185,6 +185,20 @@ fn interactive_region_capture_keybinding_is_visible_and_round_trips() {
);
}
+#[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/docs/CONFIG.md b/docs/CONFIG.md
index 069a9c233..e8924d026 100644
--- a/docs/CONFIG.md
+++ b/docs/CONFIG.md
@@ -1396,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.
@@ -1424,6 +1429,8 @@ 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.
@@ -1449,6 +1456,28 @@ 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`.
+`[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.
+
+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, and Both honour the current toggle. Board always inserts the
+original crop because compositing the current board first would permanently
+duplicate its existing annotations inside the new image.
+
+`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.
@@ -1973,6 +2002,7 @@ capture_file_region = ["Ctrl+Alt+6"]
# Open the region review/action bar. Available from the command palette and
# unbound by default.
capture_region_interactive = []
+measure_mode = []
export_canvas_file = []
export_canvas_clipboard = []
export_canvas_clipboard_and_file = []
diff --git a/src/backend/wayland/backend/event_loop/capture.rs b/src/backend/wayland/backend/event_loop/capture.rs
index 9cfab3e6c..5c7e2c6f6 100644
--- a/src/backend/wayland/backend/event_loop/capture.rs
+++ b/src/backend/wayland/backend/event_loop/capture.rs
@@ -152,6 +152,7 @@ pub(super) fn handle_pending_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);
diff --git a/src/backend/wayland/capture.rs b/src/backend/wayland/capture.rs
index e97840ff1..ccb3ed53c 100644
--- a/src/backend/wayland/capture.rs
+++ b/src/backend/wayland/capture.rs
@@ -361,6 +361,7 @@ mod tests {
None,
ExitAfterCaptureMode::Auto,
RegionPickerOptions::new(true, false, true),
+ true,
)
}
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 c2160fcb1..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();
}
@@ -667,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 323d8711e..b44232939 100644
--- a/src/backend/wayland/handlers/keyboard/mod.rs
+++ b/src/backend/wayland/handlers/keyboard/mod.rs
@@ -169,6 +169,16 @@ impl KeyboardHandler for WaylandState {
{
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)
@@ -217,16 +227,7 @@ impl KeyboardHandler for WaylandState {
// 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) {
- if self
- .input_state
- .region_state()
- .purpose()
- .is_some_and(crate::input::state::RegionPurposeTag::is_capture)
- {
- self.cancel_region_capture();
- } else {
- self.cancel_ocr();
- }
+ self.cancel_active_region_selector();
}
return;
}
@@ -238,6 +239,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();
}
@@ -582,6 +589,9 @@ fn region_review_key_action(key: Key, ctrl: bool, shift: bool) -> Option Some(RegionReviewKeyAction::Submit(
crate::ui::RegionAction::Board,
)),
+ Key::Char('d' | 'D') if !ctrl => Some(RegionReviewKeyAction::Submit(
+ crate::ui::RegionAction::ToggleIncludeDrawings,
+ )),
_ => None,
}
}
@@ -689,6 +699,13 @@ mod tests {
))
);
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]
diff --git a/src/backend/wayland/handlers/pointer/press.rs b/src/backend/wayland/handlers/pointer/press.rs
index 8aef5ec4a..60fdd89eb 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,
@@ -48,7 +52,13 @@ impl WaylandState {
BTN_LEFT => {
if let Some(action) = self.region_review_action_at(event.position) {
self.submit_region_review_action(action);
- self.set_suppress_next_release(true);
+ // 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.set_suppress_next_release(true);
+ }
return;
}
if self.region_review_bar_contains(event.position) {
@@ -392,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/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 0eb141510..598f4beda 100644
--- a/src/backend/wayland/state/capture.rs
+++ b/src/backend/wayland/state/capture.rs
@@ -21,6 +21,54 @@ pub(super) fn should_exit_after_capture(
}
}
+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,
@@ -204,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
diff --git a/src/backend/wayland/state/core/overlay.rs b/src/backend/wayland/state/core/overlay.rs
index ac8bc643e..5bb12a8cf 100644
--- a/src/backend/wayland/state/core/overlay.rs
+++ b/src/backend/wayland/state/core/overlay.rs
@@ -179,6 +179,12 @@ mod tests {
generation: 1,
}
));
+ assert!(!capture_picker_chrome_suppressed_for(
+ RegionSelectUiState::Armed {
+ purpose: RegionPurposeTag::Measure,
+ generation: 1,
+ }
+ ));
for purpose in [
RegionPurposeTag::CaptureDeliver,
RegionPurposeTag::CaptureInteractive,
diff --git a/src/backend/wayland/state/data.rs b/src/backend/wayland/state/data.rs
index eb8e3f909..5bc34f9a0 100644
--- a/src/backend/wayland/state/data.rs
+++ b/src/backend/wayland/state/data.rs
@@ -66,6 +66,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
}
@@ -193,6 +200,8 @@ pub struct StateData {
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,
@@ -286,6 +295,7 @@ impl StateData {
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(),
}
@@ -310,6 +320,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/ocr.rs b/src/backend/wayland/state/ocr.rs
index 018aa4fb6..72606c148 100644
--- a/src/backend/wayland/state/ocr.rs
+++ b/src/backend/wayland/state/ocr.rs
@@ -30,13 +30,17 @@ enum ActiveRegionCancelTarget {
None,
Ocr,
Capture,
+ Measure,
}
fn active_region_cancel_target(purpose: Option) -> ActiveRegionCancelTarget {
match purpose {
Some(RegionPurposeTag::Ocr) => ActiveRegionCancelTarget::Ocr,
- Some(purpose) if purpose.is_capture() => ActiveRegionCancelTarget::Capture,
- Some(_) | None => ActiveRegionCancelTarget::None,
+ Some(RegionPurposeTag::CaptureDeliver | RegionPurposeTag::CaptureInteractive) => {
+ ActiveRegionCancelTarget::Capture
+ }
+ Some(RegionPurposeTag::Measure) => ActiveRegionCancelTarget::Measure,
+ None => ActiveRegionCancelTarget::None,
}
}
@@ -223,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.
@@ -261,6 +265,7 @@ impl WaylandState {
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,
}
}
@@ -269,6 +274,7 @@ impl WaylandState {
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,
}
}
@@ -283,8 +289,9 @@ impl WaylandState {
) -> bool {
match self.region_owner_lost(source) {
RegionOwnerLoss::NotOwned => false,
- RegionOwnerLoss::RearmedCapture => true,
+ 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()
@@ -315,6 +322,7 @@ impl WaylandState {
RegionSelectionFinalize::NotOwned => return false,
RegionSelectionFinalize::Rearmed => return true,
RegionSelectionFinalize::Reviewed => return true,
+ RegionSelectionFinalize::Measured => return true,
RegionSelectionFinalize::Selected {
purpose: RegionPurposeTag::Ocr,
rect,
@@ -330,6 +338,10 @@ impl WaylandState {
purpose: RegionPurposeTag::CaptureInteractive,
..
} => return true,
+ RegionSelectionFinalize::Selected {
+ purpose: RegionPurposeTag::Measure,
+ ..
+ } => return true,
};
// The crop is taken while the capture is still held: releasing first
@@ -557,6 +569,10 @@ mod tests {
ActiveRegionCancelTarget::Capture
);
}
+ assert_eq!(
+ active_region_cancel_target(Some(RegionPurposeTag::Measure)),
+ ActiveRegionCancelTarget::Measure
+ );
assert_eq!(
active_region_cancel_target(None),
ActiveRegionCancelTarget::None
diff --git a/src/backend/wayland/state/region_capture.rs b/src/backend/wayland/state/region_capture.rs
index 6ecf5b76b..a4fc26507 100644
--- a/src/backend/wayland/state/region_capture.rs
+++ b/src/backend/wayland/state/region_capture.rs
@@ -2,1930 +2,38 @@ use crate::backend::wayland::acquisition::ScreenAcquisitionId;
use crate::input::state::{
RegionInputSource, RegionPurposeTag, RegionSelectUiState, RegionSelection, ScreenCaptureSource,
};
+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;
-
-use crate::screen_pixels::{ImagePixelRect, ImagePoint, clamp_edge};
-pub(in crate::backend::wayland) use board::world_rect_for_screen_rect;
+mod review_state;
+mod runtime;
+mod selection_state;
+mod source_guard;
+
+pub(super) use active_state::{ActiveScreenRegion, FreezeOwnership};
+pub(in crate::backend::wayland) use board::{
+ world_rect_for_image_rect_exact, world_rect_for_screen_rect,
+};
+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};
+pub(super) use source_guard::owned_generation_is_current;
+use source_guard::*;
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)>,
- square_modifier: bool,
- legend_dismissed: bool,
- },
-}
-
-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,
- }
- }
-
- pub const fn legend_dismissed(self) -> bool {
- matches!(
- self,
- Self::Ready {
- legend_dismissed: true,
- ..
- }
- )
- }
-
- 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()
- }
-
- 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,
- )
- }
-
- fn review_geometry(self) -> Option {
- let Self::Ready {
- purpose, source, ..
- } = self
- else {
- return None;
- };
- let image_rect = self.stored_review_rect()?;
- let display = super::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)),
- ),
- },
- ))
- }
-
- 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)
- }
-
- 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
- }
-
- 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
- }
-
- 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)?,
- })
- }
-
- fn picker_measurement(self, pointer: (f64, f64)) -> Option {
- 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,
- )
- }
-
- fn enter_review(&mut self, rect: ImagePixelRect) -> Option {
- let Self::Ready {
- purpose,
- anchor,
- raw_edge,
- logical_anchor,
- logical_edge,
- ..
- } = self
- else {
- return None;
- };
- if *purpose != RegionPurposeTag::CaptureInteractive {
- return 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())
- }
-
- 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,
- ..
- } = self
- else {
- return false;
- };
- if logical_anchor.is_some() {
- return false;
- }
- let display = super::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
- }
-
- 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
- }
-
- 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
- }
-
- fn reset_review_for_selection(&mut self) {
- if let Self::Ready {
- anchor,
- raw_edge,
- logical_anchor,
- ..
- } = self
- {
- *anchor = None;
- *raw_edge = None;
- *logical_anchor = None;
- }
- }
-
- 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,
- ..
- } = self
- else {
- return None;
- };
- if logical_anchor.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())
- }
-
- fn begin_selection(&mut self, logical: (f64, f64)) -> bool {
- 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
- }
-
- 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(in crate::backend::wayland) enum RegionSelectionFinalize {
- NotOwned,
- Rearmed,
- Reviewed,
- Selected {
- purpose: RegionPurposeTag,
- rect: ImagePixelRect,
- },
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub(in crate::backend::wayland) enum RegionOwnerLoss {
- NotOwned,
- RearmedCapture,
- Cancel(RegionPurposeTag),
-}
-
-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() {
- 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
- .selection_geometry()
- .map(RegionSelectionGeometry::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
-}
-
-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;
- }
- 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_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
- .selection_geometry()
- .map(RegionSelectionGeometry::display_selection)
- {
- input_state.update_region_selection(owner, preview.end);
- }
-}
-
-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
-}
-
-fn initial_square_modifier(purpose: RegionPurposeTag, shift: bool) -> bool {
- shift && purpose.selection_policy().allow_square()
-}
-
-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();
-}
-
-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;
- }
- 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(ActiveScreenRegion::finish_review_move);
- let finished_ui = input_state.finish_region_review_move(source);
- debug_assert_eq!(finished_backend, finished_ui);
- return RegionOwnerLoss::RearmedCapture;
- }
- rearm_region_selection_event(backend, input_state);
- RegionOwnerLoss::RearmedCapture
-}
-
-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;
- }
- 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(ActiveScreenRegion::finish_review_move);
- 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 }
-}
-
-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,
- square_modifier: initial_square_modifier(purpose, self.input_state.modifiers.shift),
- legend_dismissed: false,
- });
- 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(in crate::backend::wayland) fn region_selection_geometry(
- &self,
- ) -> Option {
- self.data
- .active_screen_region
- .and_then(ActiveScreenRegion::selection_geometry)
- }
-
- pub(super) fn region_picker_source_token(&self) -> Option {
- match self.data.active_screen_region {
- Some(ActiveScreenRegion::Ready { source, .. }) => Some(source),
- 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
- }
-
- 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 {
- 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(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_region) = {
- 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_region_source_changed(self.data.active_screen_region, &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(purpose) if purpose.is_capture() => {
- self.cancel_region_capture_for_source_change();
- }
- Some(_) | None => {}
- }
- }
- }
-}
-
-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_region_source_changed(
- region: Option,
- source_matches: &impl Fn(ScreenSourceToken) -> bool,
-) -> bool {
- matches!(
- region,
- Some(ActiveScreenRegion::Ready { 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,
- square_modifier: false,
- legend_dismissed: false,
- };
- 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,
- square_modifier: false,
- legend_dismissed: false,
- }
- }
-
- #[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), &|expected| {
- expected == source
- }));
- assert!(active_region_source_changed(Some(ready), &|expected| {
- expected == replacement
- }));
- assert!(active_region_source_changed(Some(ready), &|_| false));
- assert!(!active_region_source_changed(
- Some(ActiveScreenRegion::PendingZoom {
- purpose: RegionPurposeTag::Ocr,
- generation: 1,
- }),
- &|_| false,
- ));
-
- let capture = capture_region();
- assert!(active_region_source_changed(Some(capture), &|_| 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 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 { .. }
- ));
- }
-
- fn capture_region() -> ActiveScreenRegion {
- capture_region_at_scale(1.0)
- }
-
- fn interactive_region() -> ActiveScreenRegion {
- let mut region = capture_region();
- if let ActiveScreenRegion::Ready { purpose, .. } = &mut region {
- *purpose = RegionPurposeTag::CaptureInteractive;
- }
- region
- }
-
- 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,
- }
- }
-
- #[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())
- );
- }
-
- #[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::RearmedCapture
- );
- 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::RearmedCapture
- );
- 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);
- }
-}
+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..8dedee188
--- /dev/null
+++ b/src/backend/wayland/state/region_capture/active_state.rs
@@ -0,0 +1,318 @@
+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,
+ },
+}
+
+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
index 17200507f..17a15c699 100644
--- a/src/backend/wayland/state/region_capture/board.rs
+++ b/src/backend/wayland/state/region_capture/board.rs
@@ -1,4 +1,5 @@
use crate::util::Rect;
+use crate::{canvas_export::CanvasExportRect, screen_pixels::ImagePixelRect};
use super::super::screen_image::ScreenSourceToken;
@@ -35,6 +36,39 @@ pub(in crate::backend::wayland) fn world_rect_for_screen_rect(
Rect::from_min_max(left, top, right, bottom)
}
+/// 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::*;
@@ -82,4 +116,39 @@ mod tests {
Some(display)
);
}
+
+ #[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)
+ );
+ }
}
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..460458cd6
--- /dev/null
+++ b/src/backend/wayland/state/region_capture/delivery.rs
@@ -0,0 +1,342 @@
+use crate::canvas_export::{CanvasExportRect, CanvasRegionExportSnapshot, CanvasRegionSource};
+use crate::capture::{
+ CaptureDestination, ImageFormatMetadata, ImageOperationKind, RenderImageRequest,
+ RenderedImageDeliveryRequest,
+};
+use crate::input::state::{
+ BoardPasteTarget, RegionInputSource, 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),
+}
+
+pub(super) const fn include_drawings_for_submit(
+ include_drawings: bool,
+ submit: &RegionSubmit,
+) -> bool {
+ include_drawings && matches!(submit, RegionSubmit::Deliver(_))
+}
+
+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)
+ }
+
+ 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;
+ }
+ let Some(ActiveScreenRegion::Ready { source, .. }) = self.data.active_screen_region
+ else {
+ return false;
+ };
+ let display = super::super::screen_image::screen_rect_for_image_rect(&source, rect);
+ let Some(world_bounds) =
+ super::world_rect_for_screen_rect(display, self.board_view_offset(), source)
+ else {
+ return false;
+ };
+ 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;
+ };
+ match self.input_state.region_state().selection_owner() {
+ Some(RegionInputSource::Pointer | RegionInputSource::Touch) => {
+ self.set_suppress_next_release(true);
+ }
+ Some(RegionInputSource::Stylus) => self.retire_stylus_contact(),
+ None => {}
+ }
+ 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;
+ }
+ let include_drawings = include_drawings_for_submit(include_drawings, &submit);
+ // 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 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();
+ if include_drawings && drawing_snapshot.is_none() {
+ 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);
+ }
+
+ match submit {
+ RegionSubmit::Deliver(destination) => {
+ self.capture.set_exit_on_success(should_exit_after_capture(
+ intent.exit_mode(),
+ destination,
+ ));
+ let render: crate::capture::ImageRenderJob = match drawing_snapshot {
+ Some(snapshot) => {
+ Box::new(move || crate::canvas_export::render_canvas_region_png(snapshot))
+ }
+ None => {
+ let pixels = pixels.expect("raw delivery prepared its checked crop");
+ Box::new(move || crate::capture::png::encode_packed_argb32_png(&pixels))
+ }
+ };
+ 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 pixels = pixels.expect("board submission always uses the raw checked crop");
+ let render =
+ Box::new(move || crate::capture::png::encode_packed_argb32_png(&pixels));
+ 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..b1df7aee0
--- /dev/null
+++ b/src/backend/wayland/state/region_capture/events.rs
@@ -0,0 +1,222 @@
+use super::*;
+
+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() {
+ 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_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);
+ }
+}
+
+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,
+ ..
+ }) = backend.as_mut()
+ {
+ *anchor = None;
+ *raw_edge = None;
+ *logical_anchor = None;
+ *logical_edge = 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(ActiveScreenRegion::finish_review_move);
+ 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(ActiveScreenRegion::finish_review_move);
+ 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/intent.rs b/src/backend/wayland/state/region_capture/intent.rs
index 9504f36fe..b5e81a810 100644
--- a/src/backend/wayland/state/region_capture/intent.rs
+++ b/src/backend/wayland/state/region_capture/intent.rs
@@ -55,6 +55,7 @@ pub(in crate::backend::wayland) struct RegionCaptureIntent {
save_config: Option,
exit_mode: ExitAfterCaptureMode,
options: RegionPickerOptions,
+ include_drawings: bool,
}
impl RegionCaptureIntent {
@@ -65,6 +66,7 @@ impl RegionCaptureIntent {
save_config: Option,
exit_mode: ExitAfterCaptureMode,
options: RegionPickerOptions,
+ include_drawings: bool,
) -> Self {
Self {
action,
@@ -73,6 +75,7 @@ impl RegionCaptureIntent {
save_config,
exit_mode,
options,
+ include_drawings,
}
}
@@ -99,6 +102,10 @@ impl RegionCaptureIntent {
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)]
@@ -122,6 +129,7 @@ mod tests {
Some(save_config),
ExitAfterCaptureMode::Auto,
options,
+ true,
);
assert_eq!(intent.action(), Action::CaptureClipboardRegion);
@@ -138,6 +146,7 @@ mod tests {
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());
@@ -153,6 +162,7 @@ mod tests {
None,
ExitAfterCaptureMode::Never,
options,
+ false,
);
assert_eq!(intent.purpose(), RegionPurposeTag::CaptureInteractive);
@@ -160,5 +170,6 @@ mod tests {
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
index 390090e77..4322097fb 100644
--- a/src/backend/wayland/state/region_capture/picker.rs
+++ b/src/backend/wayland/state/region_capture/picker.rs
@@ -1,32 +1,21 @@
use crate::backend::wayland::acquisition::ScreenAcquisitionOwner;
use crate::backend::wayland::zoom::ZoomWaiterOwner;
use crate::capture::{
- CaptureDestination, CaptureType, ImageFormatMetadata, ImageOperationKind, RenderImageRequest,
- RenderedImageDeliveryRequest,
+ CaptureDestination, CaptureType,
file::{FileSaveConfig, expand_tilde},
};
use crate::config::{Action, RegionPicker};
-use crate::input::state::{
- BoardPasteTarget, RegionInputSource, RegionPurposeTag, ScreenCaptureSource, Toast,
- ToastPriority,
-};
-use crate::screen_pixels::{EmbeddedImageLimits, ImagePixelRect, PackedArgb32};
-use crate::ui::RegionAction;
+use crate::input::state::{RegionPurposeTag, ScreenCaptureSource, Toast, ToastPriority};
-use super::super::capture::should_exit_after_capture;
-use super::super::screen_image::{
- CropError, copy_image_rect, displayed_screen_image, screen_source_is,
-};
-use super::{
- ActiveScreenRegion, FreezeOwnership, RegionCaptureIntent, RegionPickerOptions,
- RegionSelectionFinalize,
-};
+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)]
-enum RegionPickerEntry {
+pub(super) enum RegionPickerEntry {
Activate,
WaitForZoom,
AutoFreeze,
@@ -35,7 +24,7 @@ enum RegionPickerEntry {
ZoomImageUnavailable,
}
-fn region_picker_entry(
+pub(super) fn region_picker_entry(
has_source: bool,
board_is_transparent: bool,
zoom_engaged: bool,
@@ -59,7 +48,7 @@ fn region_picker_entry(
}
}
-fn region_destination(
+pub(super) fn region_destination(
action: Action,
default_destination: CaptureDestination,
) -> Option {
@@ -76,45 +65,9 @@ fn region_destination(
}
}
-fn region_delivery_request(
- pixels: PackedArgb32,
+pub(super) fn legacy_region_request(
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
- });
- let render: crate::capture::ImageRenderJob =
- Box::new(move || crate::capture::png::encode_packed_argb32_png(&pixels));
- RenderedImageDeliveryRequest {
- render,
- destination,
- save_config,
- operation: ImageOperationKind::Screenshot,
- fallback_format_override: Some(ImageFormatMetadata::png()),
- }
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-enum RegionSubmit {
- Deliver(CaptureDestination),
- Board(BoardPasteTarget),
-}
-
-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 => None,
- }
-}
-
-fn legacy_region_request(intent: &RegionCaptureIntent) -> crate::capture::CaptureRequest {
+) -> crate::capture::CaptureRequest {
crate::capture::CaptureRequest {
capture_type: CaptureType::Selection {
x: 0,
@@ -128,100 +81,6 @@ fn legacy_region_request(intent: &RegionCaptureIntent) -> crate::capture::Captur
}
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)
- }
-
- 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 {
- 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;
- }
- let Some(ActiveScreenRegion::Ready { source, .. }) = self.data.active_screen_region
- else {
- return false;
- };
- let display = super::super::screen_image::screen_rect_for_image_rect(&source, rect);
- let Some(world_bounds) =
- super::world_rect_for_screen_rect(display, self.board_view_offset(), source)
- else {
- return false;
- };
- 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;
- };
- match self.input_state.region_state().selection_owner() {
- Some(RegionInputSource::Pointer | RegionInputSource::Touch) => {
- self.set_suppress_next_release(true);
- }
- Some(RegionInputSource::Stylus) => self.retire_stylus_contact(),
- None => {}
- }
- if purpose == RegionPurposeTag::CaptureInteractive {
- self.enter_region_review(rect);
- } else {
- self.submit_region_capture(rect);
- }
- }
-
pub(in crate::backend::wayland::state) fn begin_region_capture_action(
&mut self,
action: Action,
@@ -277,7 +136,9 @@ impl WaylandState {
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,
@@ -324,7 +185,12 @@ impl WaylandState {
self.frozen_enabled(),
) {
RegionPickerEntry::Activate => {
- if !self.activate_screen_region(purpose, generation, FreezeOwnership::PreExisting) {
+ if !self.activate_screen_region(
+ purpose,
+ generation,
+ FreezeOwnership::PreExisting,
+ include_drawings,
+ ) {
self.finish_region_activation_rejection();
}
}
@@ -424,7 +290,20 @@ impl WaylandState {
} else {
FreezeOwnership::PreExisting
};
- self.activate_screen_region(region.purpose(), region.generation(), ownership)
+ 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 {
@@ -486,7 +365,16 @@ impl WaylandState {
intent.exit_mode(),
intent.destination(),
));
- if !self.enter_overlay_suppression(super::super::OverlaySuppression::Capture) {
+ 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,
@@ -501,122 +389,6 @@ impl WaylandState {
);
}
- 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,
- ..
- }) = 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;
- }
- let pixels = match copy_image_rect(source.image, rect) {
- Ok(pixels) => 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 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);
- }
-
- match submit {
- RegionSubmit::Deliver(destination) => {
- self.capture.set_exit_on_success(should_exit_after_capture(
- intent.exit_mode(),
- destination,
- ));
- let request = region_delivery_request(pixels, &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 render =
- Box::new(move || crate::capture::png::encode_packed_argb32_png(&pixels));
- 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."),
- );
- }
- }
- }
- }
- }
-
pub(super) fn cancel_region_capture_for_source_change(&mut self) {
if self.cancel_region_capture() {
self.input_state.push_toast(
@@ -627,194 +399,3 @@ impl WaylandState {
}
}
}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use crate::backend::ExitAfterCaptureMode;
-
- #[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);
- }
-
- #[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::RegionCaptureConfig::default();
- let options =
- RegionPickerOptions::new(live.show_size_readout, live.show_loupe, live.show_legend);
- let intent = RegionCaptureIntent::new(
- Action::CaptureSelection,
- RegionPurposeTag::CaptureDeliver,
- CaptureDestination::ClipboardOnly,
- None,
- ExitAfterCaptureMode::Auto,
- options,
- );
- live.show_size_readout = false;
- live.show_loupe = true;
- live.show_legend = false;
- live.picker = RegionPicker::Slurp;
-
- assert_eq!(live.picker, RegionPicker::Slurp);
- assert!(!live.show_size_readout);
- assert!(live.show_loupe);
- assert!(!live.show_legend);
- 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),
- );
- let pixels = PackedArgb32::new(1, 1, 4, 0xff33_2211_u32.to_ne_bytes().to_vec())
- .expect("one native ARGB32 pixel");
-
- let request = region_delivery_request(pixels, &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),
- );
-
- 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/review_state.rs b/src/backend/wayland/state/region_capture/review_state.rs
new file mode 100644
index 000000000..576b90fba
--- /dev/null
+++ b/src/backend/wayland/state/region_capture/review_state.rs
@@ -0,0 +1,142 @@
+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,
+ ..
+ } = self
+ else {
+ return None;
+ };
+ if *purpose != RegionPurposeTag::CaptureInteractive {
+ return 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,
+ ..
+ } = self
+ else {
+ return false;
+ };
+ if logical_anchor.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(super) fn reset_review_for_selection(&mut self) {
+ if let Self::Ready {
+ anchor,
+ raw_edge,
+ logical_anchor,
+ ..
+ } = self
+ {
+ *anchor = None;
+ *raw_edge = None;
+ *logical_anchor = 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,
+ ..
+ } = self
+ else {
+ return None;
+ };
+ if logical_anchor.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())
+ }
+}
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..496fd1289
--- /dev/null
+++ b/src/backend/wayland/state/region_capture/runtime.rs
@@ -0,0 +1,366 @@
+use super::*;
+
+impl WaylandState {
+ 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,
+ });
+ self.input_state.activate_region(purpose, generation);
+ self.debug_assert_screen_region_invariant();
+ true
+ }
+
+ pub(in crate::backend::wayland::state) 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(in crate::backend::wayland) fn region_selection_geometry(
+ &self,
+ ) -> Option {
+ 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
+ }
+
+ 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 {
+ 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..3f7b8931c
--- /dev/null
+++ b/src/backend/wayland/state/region_capture/tests/capture_selection.rs
@@ -0,0 +1,120 @@
+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,
+ }
+}
+
+#[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..12464f982
--- /dev/null
+++ b/src/backend/wayland/state/region_capture/tests/lifecycle_and_measure.rs
@@ -0,0 +1,382 @@
+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,
+ };
+ 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,
+ }
+}
+
+#[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..1c2ac7185
--- /dev/null
+++ b/src/backend/wayland/state/region_capture/tests/picker.rs
@@ -0,0 +1,236 @@
+use super::*;
+use crate::backend::ExitAfterCaptureMode;
+use crate::backend::wayland::state::region_capture::delivery::{
+ RegionSubmit, include_drawings_for_submit, region_delivery_request, review_delivery_destination,
+};
+use crate::backend::wayland::state::region_capture::picker::{
+ RegionPickerEntry, legacy_region_request, region_destination, region_picker_entry,
+};
+use crate::capture::{
+ CaptureDestination, CaptureType, ImageFormatMetadata, ImageOperationKind, file::FileSaveConfig,
+};
+use crate::config::{Action, RegionPicker};
+use crate::input::state::{BoardPasteTarget, 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 include_drawings_applies_to_exports_but_board_keeps_the_raw_crop() {
+ assert!(include_drawings_for_submit(
+ true,
+ &RegionSubmit::Deliver(CaptureDestination::ClipboardAndFile)
+ ));
+ assert!(!include_drawings_for_submit(
+ false,
+ &RegionSubmit::Deliver(CaptureDestination::ClipboardAndFile)
+ ));
+ assert!(!include_drawings_for_submit(
+ true,
+ &RegionSubmit::Board(BoardPasteTarget {
+ board_id: "board".to_string(),
+ page_index: 0,
+ page_generation: 1,
+ world_bounds: crate::util::Rect::new(0, 0, 1, 1).unwrap(),
+ })
+ ));
+}
+
+#[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..290a2dada
--- /dev/null
+++ b/src/backend/wayland/state/region_capture/tests/review.rs
@@ -0,0 +1,448 @@
+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);
+}
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 5c547297a..cfc88ad52 100644
--- a/src/backend/wayland/state/render/canvas/mod.rs
+++ b/src/backend/wayland/state/render/canvas/mod.rs
@@ -16,6 +16,7 @@ impl WaylandState {
phys_height: u32,
now: Instant,
damage_world: &[crate::util::Rect],
+ render_transients: bool,
mut perf: Option<&mut PerfRenderBreakdown>,
) -> Result<()> {
let capture_picker_suppresses_canvas_chrome = self.capture_picker_chrome_suppressed();
@@ -218,11 +219,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();
@@ -235,6 +238,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) =
diff --git a/src/backend/wayland/state/render/mod.rs b/src/backend/wayland/state/render/mod.rs
index c3741ca02..655796d75 100644
--- a/src/backend/wayland/state/render/mod.rs
+++ b/src/backend/wayland/state/render/mod.rs
@@ -31,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
@@ -295,6 +296,7 @@ impl WaylandState {
phys_height,
now,
&damage_world,
+ render_canvas_transients,
render_breakdown.as_mut(),
)?;
}
diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs
index 62fe73df7..2058b390c 100644
--- a/src/backend/wayland/state/render/ui.rs
+++ b/src/backend/wayland/state/render/ui.rs
@@ -301,6 +301,15 @@ 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 !capture_picker {
if let Some(card) = self.first_run_onboarding_card() {
@@ -315,16 +324,17 @@ impl WaylandState {
}
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()
- || !self
- .input_state
- .region_state()
- .purpose()
- .is_some_and(|purpose| purpose.is_capture())
+ || !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())
@@ -334,18 +344,23 @@ impl WaylandState {
| 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 = 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 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| {
@@ -385,14 +400,16 @@ impl WaylandState {
width,
height,
crate::ui::RegionCapturePickerVisual {
- selection: geometry.map(|geometry| geometry.display_selection()),
+ selection,
pointer,
measurement: measurement.as_deref(),
+ show_scrim: !measure_mode,
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(),
},
|image_x, image_y| {
loupe_source.as_ref().and_then(|(source, _token)| {
diff --git a/src/backend/wayland/state/render/ui_effect_damage.rs b/src/backend/wayland/state/render/ui_effect_damage.rs
index 94e52633e..f703e0cbe 100644
--- a/src/backend/wayland/state/render/ui_effect_damage.rs
+++ b/src/backend/wayland/state/render/ui_effect_damage.rs
@@ -271,6 +271,22 @@ impl WaylandState {
);
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
}
diff --git a/src/backend/wayland/state/screen_image.rs b/src/backend/wayland/state/screen_image.rs
index 247121f28..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.
@@ -434,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/zoom/state.rs b/src/backend/wayland/zoom/state.rs
index f2287a266..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;
@@ -108,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,
@@ -213,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 {
@@ -230,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();
}
@@ -242,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();
}
@@ -258,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/config/action_meta/entries/capture.rs b/src/config/action_meta/entries/capture.rs
index 082a50737..cee5b5da3 100644
--- a/src/config/action_meta/entries/capture.rs
+++ b/src/config/action_meta/entries/capture.rs
@@ -102,6 +102,17 @@ pub const ENTRIES: &[ActionMeta] = &[
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 eea478055..86c50c62b 100644
--- a/src/config/action_meta/tests.rs
+++ b/src/config/action_meta/tests.rs
@@ -94,6 +94,7 @@ const HELP_ACTIONS: &[Action] = &[
Action::CaptureActiveWindow,
Action::CaptureSelection,
Action::CaptureRegionInteractive,
+ Action::MeasureMode,
Action::OpenCaptureFolder,
];
@@ -253,6 +254,7 @@ const EXPECTED_COMMAND_PALETTE_ACTIONS: &[Action] = &[
Action::SetColorBlack,
Action::PickScreenColor,
Action::CaptureRegionInteractive,
+ Action::MeasureMode,
Action::CaptureClipboardFull,
Action::CaptureFileFull,
Action::ExportCanvasFile,
@@ -495,3 +497,15 @@ fn interactive_region_capture_metadata_matches_its_public_surfaces() {
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/keybindings/config/map/capture.rs b/src/config/keybindings/config/map/capture.rs
index 942fd8642..5ac36dc38 100644
--- a/src/config/keybindings/config/map/capture.rs
+++ b/src/config/keybindings/config/map/capture.rs
@@ -35,6 +35,7 @@ impl KeybindingsConfig {
&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 1acc1ed86..ee94148c6 100644
--- a/src/config/keybindings/config/map/edit.rs
+++ b/src/config/keybindings/config/map/edit.rs
@@ -184,6 +184,7 @@ define_action_binding_accessors! {
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 bf0e679f2..396fc99c1 100644
--- a/src/config/keybindings/config/types/bindings/capture.rs
+++ b/src/config/keybindings/config/types/bindings/capture.rs
@@ -37,6 +37,11 @@ pub struct CaptureKeybindingsConfig {
#[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,
@@ -74,6 +79,7 @@ impl Default for CaptureKeybindingsConfig {
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 8dd707aa6..f7d737abc 100644
--- a/src/config/keybindings/defaults/capture.rs
+++ b/src/config/keybindings/defaults/capture.rs
@@ -40,6 +40,12 @@ pub(crate) fn default_capture_region_interactive() -> Vec {
Vec::new()
}
+/// 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 78e4ebcf3..388b5b792 100644
--- a/src/config/keybindings/tests.rs
+++ b/src/config/keybindings/tests.rs
@@ -525,6 +525,26 @@ fn interactive_region_capture_is_unbound_by_default_and_maps_when_configured() {
);
}
+#[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)]
@@ -797,6 +817,7 @@ const DEFAULT_BINDING_SNAPSHOT: &[(&str, &[&str])] = &[
("capture_clipboard_region", &["Ctrl+6"]),
("capture_file_region", &["Ctrl+Alt+6"]),
("capture_region_interactive", &[]),
+ ("measure_mode", &[]),
("export_canvas_file", &[]),
("export_canvas_clipboard", &[]),
("export_canvas_clipboard_and_file", &[]),
diff --git a/src/config/tests/load.rs b/src/config/tests/load.rs
index df083ab22..57f813d14 100644
--- a/src/config/tests/load.rs
+++ b/src/config/tests/load.rs
@@ -63,6 +63,20 @@ fn region_capture_defaults_and_explicit_values_round_trip() {
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")
diff --git a/src/config/tests/schema.rs b/src/config/tests/schema.rs
index 50df335d5..4a69aca85 100644
--- a/src/config/tests/schema.rs
+++ b/src/config/tests/schema.rs
@@ -54,6 +54,15 @@ fn region_capture_paths_exist_in_json_schema() {
}
}
+#[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();
diff --git a/src/config/types/capture.rs b/src/config/types/capture.rs
index 22d6c8bd4..b214c690d 100644
--- a/src/config/types/capture.rs
+++ b/src/config/types/capture.rs
@@ -64,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")]
@@ -88,6 +93,7 @@ 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(),
@@ -192,6 +198,10 @@ fn default_capture_clipboard() -> bool {
true
}
+fn default_capture_include_drawings() -> bool {
+ true
+}
+
fn default_capture_exit_after() -> bool {
false
}
diff --git a/src/configurator_destination.rs b/src/configurator_destination.rs
index 2b8d91ba5..ce2bbc661 100644
--- a/src/configurator_destination.rs
+++ b/src/configurator_destination.rs
@@ -206,6 +206,7 @@ pub fn keybindings_section_for_action(action: Action) -> Option() -> 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 50fee6aa9..4db260e99 100644
--- a/src/input/state/actions/action_capture_zoom.rs
+++ b/src/input/state/actions/action_capture_zoom.rs
@@ -6,6 +6,12 @@ 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;
}
@@ -140,6 +146,24 @@ impl InputState {
.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)]
@@ -203,4 +227,32 @@ mod tests {
);
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/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 4bc9fa50a..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)]
@@ -46,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,
+ },
}
}
}
@@ -77,7 +83,8 @@ pub struct RegionSelection {
pub end: (f64, f64),
}
-/// UI-facing lifecycle shared by OCR and native screen-region capture.
+/// 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
@@ -114,13 +121,23 @@ pub enum RegionSelectUiState {
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 { .. } | Self::Review { .. }
+ Self::Armed { .. }
+ | Self::Selecting { .. }
+ | Self::Review { .. }
+ | Self::Measured { .. }
)
}
@@ -140,6 +157,10 @@ impl RegionSelectUiState {
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 {
@@ -147,7 +168,7 @@ impl RegionSelectUiState {
start,
end: current,
}),
- Self::Review { display, .. } => Some(display),
+ Self::Review { display, .. } | Self::Measured { display, .. } => Some(display),
Self::Inactive | Self::PendingCapture { .. } | Self::Armed { .. } => None,
}
}
@@ -157,16 +178,21 @@ impl RegionSelectUiState {
match self {
Self::Selecting { owner, .. } => Some(owner),
Self::Review { move_owner, .. } => move_owner,
- Self::Inactive | Self::PendingCapture { .. } | Self::Armed { .. } => None,
+ 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 { .. } | Self::Review { .. } => {
- None
- }
+ Self::Inactive
+ | Self::Armed { .. }
+ | Self::Selecting { .. }
+ | Self::Review { .. }
+ | Self::Measured { .. } => None,
}
}
@@ -175,7 +201,8 @@ impl RegionSelectUiState {
Self::PendingCapture { purpose, .. }
| Self::Armed { purpose, .. }
| Self::Selecting { purpose, .. }
- | Self::Review { purpose, .. } => Some(purpose),
+ | Self::Review { purpose, .. }
+ | Self::Measured { purpose, .. } => Some(purpose),
Self::Inactive => None,
}
}
@@ -185,13 +212,18 @@ impl RegionSelectUiState {
Self::PendingCapture { generation, .. }
| Self::Armed { generation, .. }
| Self::Selecting { generation, .. }
- | Self::Review { generation, .. } => Some(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;
}
@@ -266,6 +298,11 @@ impl InputState {
purpose,
generation,
..
+ }
+ | RegionSelectUiState::Measured {
+ purpose,
+ generation,
+ ..
} => (purpose, generation),
_ => return false,
};
@@ -348,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.
@@ -375,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);
}
}
@@ -384,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;
}
}
@@ -501,6 +574,26 @@ mod tests {
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();
diff --git a/src/input/state/interaction/actions.rs b/src/input/state/interaction/actions.rs
index dc553b694..96c3d662c 100644
--- a/src/input/state/interaction/actions.rs
+++ b/src/input/state/interaction/actions.rs
@@ -136,6 +136,7 @@ pub(crate) fn classify_action(action: Action) -> ActionRoute {
| Action::CaptureClipboardRegion
| Action::CaptureFileRegion
| Action::CaptureRegionInteractive
+ | Action::MeasureMode
| Action::ExportCanvasFile
| Action::ExportCanvasClipboard
| Action::ExportCanvasClipboardAndFile
diff --git a/src/screen_pixels.rs b/src/screen_pixels.rs
index e9101a76f..af8d99ee6 100644
--- a/src/screen_pixels.rs
+++ b/src/screen_pixels.rs
@@ -5,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 }
@@ -274,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 73344af15..4257ca349 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -49,7 +49,7 @@ 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, capture_size_text,
+ RegionCaptureLoupeVisual, RegionCapturePickerVisual, capture_size_text, measure_picker_damage,
render_region_capture_picker,
};
pub use status::{
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 2ff74cd1f..623f57551 100644
--- a/src/ui/help_overlay/sections/builder/sections.rs
+++ b/src/ui/help_overlay/sections/builder/sections.rs
@@ -283,6 +283,7 @@ pub(super) fn build_main_sections(
]);
}
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 dc95edb5e..67aee4c40 100644
--- a/src/ui/help_overlay/sections/tests.rs
+++ b/src/ui/help_overlay/sections/tests.rs
@@ -75,3 +75,18 @@ fn interactive_region_capture_has_a_help_row_when_capture_is_enabled() {
&& 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/region_action_bar.rs b/src/ui/region_action_bar.rs
index b61dd4929..837bfe9bd 100644
--- a/src/ui/region_action_bar.rs
+++ b/src/ui/region_action_bar.rs
@@ -5,9 +5,10 @@ use super::primitives::{draw_rounded_rect, text_extents_for};
const SURFACE_MARGIN: f64 = 8.0;
const SELECTION_GAP: f64 = 10.0;
const BAR_WIDTH: f64 = 296.0;
-const BAR_HEIGHT: f64 = 44.0;
+const BAR_HEIGHT: f64 = 78.0;
const BAR_PADDING: f64 = 6.0;
const ITEM_GAP: f64 = 4.0;
+const ACTION_ROW_HEIGHT: f64 = 32.0;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RegionAction {
@@ -15,6 +16,7 @@ pub(crate) enum RegionAction {
Save,
Both,
Board,
+ ToggleIncludeDrawings,
}
impl RegionAction {
@@ -24,6 +26,7 @@ impl RegionAction {
Self::Save => "Save",
Self::Both => "Both",
Self::Board => "Board",
+ Self::ToggleIncludeDrawings => "Include drawings in exports",
}
}
@@ -33,6 +36,7 @@ impl RegionAction {
Self::Save => "Ctrl+S",
Self::Both => "Enter",
Self::Board => "B",
+ Self::ToggleIncludeDrawings => "D",
}
}
}
@@ -73,6 +77,7 @@ struct RegionActionItem {
pub(crate) struct RegionActionBar {
bounds: RegionActionRect,
items: [RegionActionItem; 4],
+ toggle: RegionActionItem,
}
impl RegionActionBar {
@@ -100,7 +105,7 @@ impl RegionActionBar {
(surface_height - height - SURFACE_MARGIN).max(SURFACE_MARGIN),
);
let bounds = RegionActionRect::new(x, y, width, height);
- let item_height = (height - BAR_PADDING * 2.0).max(0.0);
+ let item_height = ACTION_ROW_HEIGHT.min((height - BAR_PADDING * 2.0).max(0.0));
let item_width = ((width - BAR_PADDING * 2.0 - ITEM_GAP * 3.0) / 4.0).max(0.0);
let item = |index: usize, action| RegionActionItem {
action,
@@ -111,6 +116,16 @@ impl RegionActionBar {
item_height,
),
};
+ let toggle_y = y + BAR_PADDING + item_height + ITEM_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: [
@@ -119,6 +134,7 @@ impl RegionActionBar {
item(2, RegionAction::Both),
item(3, RegionAction::Board),
],
+ toggle,
}
}
@@ -132,6 +148,12 @@ impl RegionActionBar {
.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 {
@@ -143,6 +165,7 @@ pub(crate) fn render_region_action_bar(
ctx: &cairo::Context,
bar: RegionActionBar,
hovered: Option,
+ include_drawings: bool,
) {
let _ = ctx.save();
ctx.set_source_rgba(0.045, 0.05, 0.065, 0.96);
@@ -188,6 +211,94 @@ pub(crate) fn render_region_action_bar(
let _ = ctx.fill();
draw_action_text(ctx, item);
}
+ draw_toggle(ctx, bar.toggle, hovered, include_drawings);
+ 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();
+ if checked {
+ ctx.set_source_rgba(0.24, 0.48, 1.0, 0.42);
+ } else if hovered == Some(item.action) {
+ ctx.set_source_rgba(1.0, 1.0, 1.0, 0.14);
+ } else {
+ ctx.set_source_rgba(1.0, 1.0, 1.0, 0.07);
+ }
+ draw_rounded_rect(
+ ctx,
+ item.bounds.x,
+ item.bounds.y,
+ item.bounds.width,
+ item.bounds.height,
+ 6.0,
+ );
+ let _ = ctx.fill();
+
+ let marker_x = item.bounds.x + 10.0;
+ let marker_y = item.bounds.y + item.bounds.height / 2.0;
+ ctx.set_line_width(1.4);
+ ctx.set_source_rgba(1.0, 1.0, 1.0, 0.88);
+ ctx.rectangle(marker_x - 4.0, marker_y - 4.0, 8.0, 8.0);
+ if checked {
+ let _ = ctx.fill();
+ } else {
+ let _ = ctx.stroke();
+ }
+
+ let label = item.action.label();
+ let extents = text_extents_for(
+ ctx,
+ "Sans",
+ cairo::FontSlant::Normal,
+ cairo::FontWeight::Bold,
+ 10.5,
+ label,
+ );
+ ctx.set_source_rgba(1.0, 1.0, 1.0, 0.94);
+ ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Bold);
+ ctx.set_font_size(10.5);
+ ctx.move_to(
+ marker_x + 10.0 - extents.x_bearing(),
+ item.bounds.y + (item.bounds.height - extents.height()) / 2.0 - extents.y_bearing(),
+ );
+ let _ = ctx.show_text(label);
+
+ let shortcut = item.action.shortcut();
+ let shortcut_extents = text_extents_for(
+ ctx,
+ "Sans",
+ cairo::FontSlant::Normal,
+ cairo::FontWeight::Normal,
+ 8.0,
+ shortcut,
+ );
+ ctx.set_source_rgba(1.0, 1.0, 1.0, 0.58);
+ ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Normal);
+ ctx.set_font_size(8.0);
+ ctx.move_to(
+ item.bounds.x + item.bounds.width
+ - 10.0
+ - shortcut_extents.width()
+ - shortcut_extents.x_bearing(),
+ item.bounds.y + (item.bounds.height - shortcut_extents.height()) / 2.0
+ - shortcut_extents.y_bearing(),
+ );
+ let _ = ctx.show_text(shortcut);
let _ = ctx.restore();
}
@@ -254,7 +365,7 @@ mod tests {
);
assert_eq!(
centered.bounds(),
- RegionActionRect::new(52.0, 210.0, 296.0, 44.0)
+ RegionActionRect::new(52.0, 210.0, 296.0, 78.0)
);
let flipped = RegionActionBar::place(
@@ -266,7 +377,7 @@ mod tests {
);
assert_eq!(
flipped.bounds(),
- RegionActionRect::new(496.0, 506.0, 296.0, 44.0)
+ RegionActionRect::new(496.0, 472.0, 296.0, 78.0)
);
}
@@ -284,6 +395,10 @@ mod tests {
assert_eq!(bar.hit((164.0, 232.0)), Some(RegionAction::Save));
assert_eq!(bar.hit((236.0, 232.0)), Some(RegionAction::Both));
assert_eq!(bar.hit((308.0, 232.0)), Some(RegionAction::Board));
+ assert_eq!(
+ bar.hit((200.0, 267.0)),
+ Some(RegionAction::ToggleIncludeDrawings)
+ );
assert_eq!(bar.hit((128.0, 232.0)), None, "inter-item gap");
assert!(bar.contains((128.0, 232.0)), "bar gaps stay modal-owned");
assert_eq!(bar.hit((20.0, 20.0)), None, "outside the bar");
@@ -300,6 +415,11 @@ mod tests {
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]
@@ -313,7 +433,7 @@ mod tests {
);
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));
+ render_region_action_bar(&ctx, bar, Some(RegionAction::Both), true);
drop(ctx);
surface.flush();
let stride = surface.stride() as usize;
@@ -324,6 +444,7 @@ mod tests {
for x in [92, 164, 236, 308] {
assert!(alpha(x, 232) > 0, "control at x={x}");
}
+ assert!(alpha(200, 267) > 0, "checked drawings toggle");
assert_eq!(alpha(20, 20), 0, "outside remains untouched");
}
}
diff --git a/src/ui/region_capture_picker.rs b/src/ui/region_capture_picker.rs
index 6144ec234..147b7a321 100644
--- a/src/ui/region_capture_picker.rs
+++ b/src/ui/region_capture_picker.rs
@@ -1,4 +1,5 @@
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, render_region_action_bar};
@@ -20,10 +21,12 @@ pub(crate) struct RegionCapturePickerVisual<'a> {
pub selection: Option,
pub pointer: (f64, f64),
pub measurement: Option<&'a str>,
+ pub show_scrim: bool,
pub show_legend: bool,
pub loupe: Option,
pub action_bar: Option,
pub hovered_action: Option,
+ pub include_drawings: bool,
}
#[derive(Debug, Clone, Copy, PartialEq)]
@@ -57,6 +60,94 @@ 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),
@@ -78,15 +169,17 @@ pub(crate) fn render_region_capture_picker(
let height = f64::from(screen_height);
let _ = ctx.save();
- ctx.set_source_rgba(SCRIM.0, SCRIM.1, SCRIM.2, SCRIM.3);
- ctx.rectangle(0.0, 0.0, width, height);
- if let Some(selection) = visual.selection {
- let (x, y, w, h) = normalized_rect(selection);
- ctx.rectangle(x, y, w, h);
- ctx.set_fill_rule(cairo::FillRule::EvenOdd);
+ 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) = visual.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);
}
- let _ = ctx.fill();
- ctx.set_fill_rule(cairo::FillRule::Winding);
if let Some(selection) = visual.selection {
let (x, y, w, h) = normalized_rect(selection);
@@ -111,7 +204,12 @@ pub(crate) fn render_region_capture_picker(
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);
+ render_region_action_bar(
+ ctx,
+ action_bar,
+ visual.hovered_action,
+ visual.include_drawings,
+ );
}
let _ = ctx.restore();
}
@@ -341,10 +439,12 @@ mod tests {
}),
pointer: (30.0, 30.0),
measurement: None,
+ show_scrim: true,
show_legend: false,
loupe: None,
action_bar: None,
hovered_action: None,
+ include_drawings: false,
},
|_x, _y| None,
);
@@ -357,6 +457,66 @@ mod tests {
assert_eq!(alpha(20, 20), 0, "the selected pixels must remain clear");
}
+ #[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,
+ show_legend: false,
+ loupe: None,
+ action_bar: None,
+ hovered_action: None,
+ include_drawings: false,
+ },
+ |_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();
@@ -372,10 +532,12 @@ mod tests {
}),
pointer: (20.0, 20.0),
measurement: None,
+ show_scrim: true,
show_legend: false,
loupe: None,
action_bar: None,
hovered_action: None,
+ include_drawings: false,
},
|_x, _y| None,
);
@@ -406,10 +568,12 @@ mod tests {
selection: Some(selection),
pointer: (200.0, 150.0),
measurement: Some("200 × 100"),
+ show_scrim: true,
show_legend: false,
loupe: None,
action_bar: Some(bar),
hovered_action: Some(crate::ui::RegionAction::Both),
+ include_drawings: true,
},
|_x, _y| None,
);
From 21f168f3fbfb189d1765ee0b4077dad2f50ec773 Mon Sep 17 00:00:00 2001
From: devmobasa <4170275+devmobasa@users.noreply.github.com>
Date: Sat, 22 Aug 2026 23:55:14 +0200
Subject: [PATCH 06/13] feat(capture): add window snapping and live annotated
previews
---
CONTRIBUTING.md | 10 +
README.md | 13 +
config.example.toml | 4 +
docs/CONFIG.md | 24 +
.../wayland/backend/event_loop/capture.rs | 1 +
src/backend/wayland/handlers/keyboard/mod.rs | 47 ++
.../wayland/handlers/pointer/cursor.rs | 6 +-
src/backend/wayland/handlers/pointer/press.rs | 10 +-
.../wayland/handlers/pointer/release.rs | 7 +-
src/backend/wayland/handlers/touch.rs | 9 +-
src/backend/wayland/state.rs | 11 +
src/backend/wayland/state/core/init.rs | 3 +
src/backend/wayland/state/data.rs | 69 ++-
src/backend/wayland/state/region_capture.rs | 3 +
.../wayland/state/region_capture/delivery.rs | 13 +-
.../wayland/state/region_capture/geometry.rs | 9 +
.../wayland/state/region_capture/runtime.rs | 45 ++
.../state/region_capture/window_snap.rs | 451 ++++++++++++++++++
.../state/region_capture/window_snap/query.rs | 337 +++++++++++++
.../state/region_capture/window_snap/tests.rs | 214 +++++++++
.../wayland/state/render/canvas/mod.rs | 54 ++-
src/backend/wayland/state/render/ui.rs | 21 +
.../state/toolbar/visibility/access.rs | 23 +-
src/capture/mod.rs | 1 +
src/capture/sources/hyprland.rs | 4 +-
src/capture/tests/mod.rs | 1 +
src/capture/tests/window_geometry.rs | 331 +++++++++++++
src/capture/window_geometry/geometry.rs | 41 ++
src/capture/window_geometry/hyprland.rs | 207 ++++++++
src/capture/window_geometry/mod.rs | 65 +++
src/capture/window_geometry/query.rs | 79 +++
src/capture/window_geometry/sway.rs | 156 ++++++
src/capture/window_geometry/types.rs | 62 +++
src/process_broker/manifest.rs | 48 +-
src/process_broker/tests.rs | 134 ++++++
src/process_broker/wire.rs | 2 +
src/ui.rs | 4 +-
src/ui/region_capture_picker.rs | 264 +++++++++-
38 files changed, 2723 insertions(+), 60 deletions(-)
create mode 100644 src/backend/wayland/state/region_capture/window_snap.rs
create mode 100644 src/backend/wayland/state/region_capture/window_snap/query.rs
create mode 100644 src/backend/wayland/state/region_capture/window_snap/tests.rs
create mode 100644 src/capture/tests/window_geometry.rs
create mode 100644 src/capture/window_geometry/geometry.rs
create mode 100644 src/capture/window_geometry/hyprland.rs
create mode 100644 src/capture/window_geometry/mod.rs
create mode 100644 src/capture/window_geometry/query.rs
create mode 100644 src/capture/window_geometry/sway.rs
create mode 100644 src/capture/window_geometry/types.rs
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index b86389209..2d3195c73 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -154,6 +154,9 @@ 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.
@@ -169,6 +172,13 @@ following permission-gated evidence (these checks open or foreground the overlay
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
diff --git a/README.md b/README.md
index 500db17ae..d9ef75d94 100644
--- a/README.md
+++ b/README.md
@@ -754,6 +754,16 @@ 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` is available from the command palette and is
unbound by default; bind it in the configurator or under `[keybindings]`. It
always uses the native picker so Review cannot be bypassed by an external
@@ -768,6 +778,9 @@ exports** (or D) starts from the configured default and can override
it for that one Copy, Save, or Both action. Board always inserts the original
crop so existing annotations are not baked into a duplicate image on the same
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
diff --git a/config.example.toml b/config.example.toml
index 065071a52..5a2471ed6 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -1440,6 +1440,10 @@ ocr_languages = "eng"
# 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.
diff --git a/docs/CONFIG.md b/docs/CONFIG.md
index e8924d026..655c3885f 100644
--- a/docs/CONFIG.md
+++ b/docs/CONFIG.md
@@ -1447,6 +1447,27 @@ 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 is available from the command palette and has no default shortcut;
@@ -1463,6 +1484,9 @@ 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.
diff --git a/src/backend/wayland/backend/event_loop/capture.rs b/src/backend/wayland/backend/event_loop/capture.rs
index 5c7e2c6f6..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();
diff --git a/src/backend/wayland/handlers/keyboard/mod.rs b/src/backend/wayland/handlers/keyboard/mod.rs
index b44232939..ece5f3e36 100644
--- a/src/backend/wayland/handlers/keyboard/mod.rs
+++ b/src/backend/wayland/handlers/keyboard/mod.rs
@@ -161,6 +161,19 @@ 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 let Some(action) = action {
if self
@@ -566,6 +579,24 @@ fn region_capture_select_all_pressed(
&& 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),
@@ -670,6 +701,22 @@ mod tests {
));
}
+ #[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!(
diff --git a/src/backend/wayland/handlers/pointer/cursor.rs b/src/backend/wayland/handlers/pointer/cursor.rs
index d6254b7a4..3f0c93d35 100644
--- a/src/backend/wayland/handlers/pointer/cursor.rs
+++ b/src/backend/wayland/handlers/pointer/cursor.rs
@@ -39,7 +39,11 @@ impl WaylandState {
/// 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 if self.region_window_snap_active() {
+ CursorIcon::Pointer
+ } else {
+ CursorIcon::Crosshair
+ };
}
// Check color picker popup first (takes priority)
diff --git a/src/backend/wayland/handlers/pointer/press.rs b/src/backend/wayland/handlers/pointer/press.rs
index 60fdd89eb..2783d2091 100644
--- a/src/backend/wayland/handlers/pointer/press.rs
+++ b/src/backend/wayland/handlers/pointer/press.rs
@@ -57,7 +57,7 @@ impl WaylandState {
// generic post-modal latch here would swallow an
// unrelated canvas release after a keyboard exit.
if review_action_suppresses_next_release(action) {
- self.set_suppress_next_release(true);
+ self.suppress_next_release_from(RegionInputSource::Pointer);
}
return;
}
@@ -72,7 +72,7 @@ impl WaylandState {
}
BTN_RIGHT => {
self.cancel_active_region_selector();
- self.set_suppress_next_release(true);
+ self.suppress_next_release_from(RegionInputSource::Pointer);
}
_ => {}
}
@@ -87,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);
}
_ => {}
}
@@ -143,7 +143,7 @@ impl WaylandState {
screen_width,
screen_height,
) {
- self.set_suppress_next_release(true);
+ self.suppress_next_release_from(RegionInputSource::Pointer);
}
}
return;
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/touch.rs b/src/backend/wayland/handlers/touch.rs
index 730e99e16..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);
@@ -274,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;
}
@@ -456,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 {
@@ -464,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 46e5618f2..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},
@@ -270,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/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/data.rs b/src/backend/wayland/state/data.rs
index 5bc34f9a0..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]
@@ -152,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,
@@ -166,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