diff --git a/README.md b/README.md
index c574e5b4..d9bd6f23 100644
--- a/README.md
+++ b/README.md
@@ -791,6 +791,14 @@ the raw crop on the board.
The native region picker shows those committed drawings over its frozen desktop
while you select, so the visible preview matches the annotated export; toggling
drawings off in Review immediately returns the preview to raw desktop pixels.
+In Review you can also remove rows or columns from the captured pixels. Press
+**Cut** (or X) and drag across the selection; later cuts use the
+already-collapsed output. **Undo** (Ctrl+Z), **Redo**
+(Ctrl+Y or Ctrl+Shift+Z), and **Reset** apply only to
+this Review — they never change board drawings. After any cut the source crop
+is locked until you undo all cuts or Reset, and the loupe is hidden while Cut
+is armed or cuts exist. Copy, Save, Both, and Board all receive the collapsed
+result; Board keeps the original top-left and shrinks to the output size.
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/docs/codebase-overview.md b/docs/codebase-overview.md
index 0d4ec2f1..30cf48db 100644
--- a/docs/codebase-overview.md
+++ b/docs/codebase-overview.md
@@ -124,6 +124,7 @@ The result is a predictable pipeline: Wayland → handlers → `InputState` →
|-------------|---------|
| `mod.rs` | Public exports and shared submodules. |
| `manager.rs` | `CaptureManager` – unique owner of capacity-one request/completion channels, checked request IDs, status, and its Tokio worker task. |
+| `band_cut.rs` | Sequential row/column band cuts on tightly packed ARGB32 rasters. Interactive region review uses this engine; it does not know about Wayland, Cairo, or capture destinations. |
| `dependencies.rs` | Trait definitions (`CaptureSource`, `CaptureFileSaver`, `CaptureClipboard`) and default implementations. |
| `pipeline.rs` | `perform_capture`, `deliver_image`, `deliver_document`, and capture/delivery request definitions. |
| `sources/` | Strategies for acquiring image bytes: Hyprland fast-path (`hyprland.rs`), portal fallback (`portal.rs`), and URI reader/cleanup (`reader.rs`). |
@@ -138,7 +139,13 @@ The result is a predictable pipeline: Wayland → handlers → `InputState` →
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.
+ or Board can choose the terminal request. Interactive review can apply
+ sequential band cuts through `capture::band_cut` after flattening a
+ snapshot. Live cut previews run on `WaylandState.region_cut_preview`, a
+ capacity-one `RuntimeOperationController` independent of `CaptureManager`,
+ so a replaceable preview cannot occupy the capture reservation slot.
+ Flatten, cut, and PNG encode stay off the event loop; terminal Copy, Save,
+ Both, or Board still submit through `CaptureManager`.
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 d1f38750..caa0de99 100644
--- a/src/backend/wayland/backend/event_loop/capture.rs
+++ b/src/backend/wayland/backend/event_loop/capture.rs
@@ -108,6 +108,7 @@ pub(super) fn handle_pending_actions(
state.poll_text_copy_completion();
state.poll_text_paste_completion();
state.poll_region_window_query_completion();
+ state.poll_region_cut_preview_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 fc5ce358..d57cefbe 100644
--- a/src/backend/wayland/handlers/keyboard/mod.rs
+++ b/src/backend/wayland/handlers/keyboard/mod.rs
@@ -415,6 +415,9 @@ impl WaylandState {
return true;
}
if matches!(key, Key::Escape) {
+ if self.handle_region_cut_escape() {
+ return true;
+ }
self.cancel_active_region_selector();
}
true
@@ -625,6 +628,18 @@ fn region_review_key_action(key: Key, ctrl: bool, shift: bool) -> Option Some(RegionReviewKeyAction::Submit(
crate::ui::RegionAction::ToggleIncludeDrawings,
)),
+ Key::Char('x' | 'X') if !ctrl => Some(RegionReviewKeyAction::Submit(
+ crate::ui::RegionAction::CutBand,
+ )),
+ Key::Char('z' | 'Z') if ctrl && shift => Some(RegionReviewKeyAction::Submit(
+ crate::ui::RegionAction::RedoCut,
+ )),
+ Key::Char('z' | 'Z') if ctrl => Some(RegionReviewKeyAction::Submit(
+ crate::ui::RegionAction::UndoCut,
+ )),
+ Key::Char('y' | 'Y') if ctrl && !shift => Some(RegionReviewKeyAction::Submit(
+ crate::ui::RegionAction::RedoCut,
+ )),
_ => None,
}
}
@@ -764,6 +779,31 @@ mod tests {
))
);
assert_eq!(region_review_key_action(Key::Char('d'), true, false), None);
+ assert_eq!(
+ region_review_key_action(Key::Char('x'), false, false),
+ Some(RegionReviewKeyAction::Submit(
+ crate::ui::RegionAction::CutBand
+ ))
+ );
+ assert_eq!(region_review_key_action(Key::Char('x'), true, false), None);
+ assert_eq!(
+ region_review_key_action(Key::Char('z'), true, false),
+ Some(RegionReviewKeyAction::Submit(
+ crate::ui::RegionAction::UndoCut
+ ))
+ );
+ assert_eq!(
+ region_review_key_action(Key::Char('z'), true, true),
+ Some(RegionReviewKeyAction::Submit(
+ crate::ui::RegionAction::RedoCut
+ ))
+ );
+ assert_eq!(
+ region_review_key_action(Key::Char('y'), true, false),
+ Some(RegionReviewKeyAction::Submit(
+ crate::ui::RegionAction::RedoCut
+ ))
+ );
}
#[test]
diff --git a/src/backend/wayland/handlers/pointer/cursor.rs b/src/backend/wayland/handlers/pointer/cursor.rs
index 74638cda..d6fb7fd8 100644
--- a/src/backend/wayland/handlers/pointer/cursor.rs
+++ b/src/backend/wayland/handlers/pointer/cursor.rs
@@ -24,6 +24,10 @@ struct ScreenModalCursorContext {
over_selection: bool,
/// The grip being dragged, or hovered when nothing is being dragged.
resize_handle: Option,
+ /// Cut mode is armed. The pointer aims a band, not a crop move.
+ cut_armed: bool,
+ /// Cuts exist, so the source crop cannot move or resize.
+ crop_locked: bool,
}
/// Cursor for the screen-modal surfaces. Targeting keeps the crosshair; a
@@ -42,6 +46,9 @@ fn screen_modal_cursor(context: ScreenModalCursorContext) -> CursorIcon {
if let Some(handle) = context.resize_handle.filter(|_| context.review_dragging) {
return resize_cursor(handle);
}
+ if context.cut_armed && context.review_dragging {
+ return CursorIcon::Crosshair;
+ }
if context.review_dragging {
return CursorIcon::Grabbing;
}
@@ -54,6 +61,12 @@ fn screen_modal_cursor(context: ScreenModalCursorContext) -> CursorIcon {
if let Some(handle) = context.resize_handle {
return resize_cursor(handle);
}
+ if context.cut_armed && context.over_selection {
+ return CursorIcon::Crosshair;
+ }
+ if context.crop_locked {
+ return CursorIcon::Default;
+ }
if context.over_selection {
return CursorIcon::Grab;
}
@@ -146,6 +159,8 @@ impl WaylandState {
over_bar: self.region_review_bar_contains(point),
over_selection: self.region_review_selection_contains(point),
resize_handle,
+ cut_armed: self.region_cut_mode_armed(),
+ crop_locked: self.region_review_crop_locked(),
}
}
@@ -512,6 +527,45 @@ mod tests {
);
}
+ #[test]
+ fn cut_mode_uses_a_crosshair_and_locked_crops_drop_the_move_hand() {
+ assert_eq!(
+ screen_modal_cursor(review(ScreenModalCursorContext {
+ cut_armed: true,
+ over_selection: true,
+ ..ScreenModalCursorContext::default()
+ })),
+ CursorIcon::Crosshair
+ );
+ assert_eq!(
+ screen_modal_cursor(review(ScreenModalCursorContext {
+ cut_armed: true,
+ review_dragging: true,
+ over_selection: true,
+ ..ScreenModalCursorContext::default()
+ })),
+ CursorIcon::Crosshair
+ );
+ assert_eq!(
+ screen_modal_cursor(review(ScreenModalCursorContext {
+ crop_locked: true,
+ over_selection: true,
+ ..ScreenModalCursorContext::default()
+ })),
+ CursorIcon::Default
+ );
+ assert_eq!(
+ screen_modal_cursor(review(ScreenModalCursorContext {
+ cut_armed: true,
+ over_action: true,
+ over_bar: true,
+ over_selection: true,
+ ..ScreenModalCursorContext::default()
+ })),
+ CursorIcon::Pointer
+ );
+ }
+
#[test]
fn the_action_bar_outranks_a_grip_it_is_painted_over() {
// A bar clamped over the selection hides the grips underneath it, so
diff --git a/src/backend/wayland/handlers/pointer/press.rs b/src/backend/wayland/handlers/pointer/press.rs
index 2d44765a..9bde6428 100644
--- a/src/backend/wayland/handlers/pointer/press.rs
+++ b/src/backend/wayland/handlers/pointer/press.rs
@@ -2,7 +2,7 @@ use log::debug;
use smithay_client_toolkit::seat::pointer::{BTN_LEFT, BTN_MIDDLE, BTN_RIGHT, PointerEvent};
use wayland_client::QueueHandle;
-use crate::backend::wayland::state::drag_log;
+use crate::backend::wayland::state::{RegionReviewPress, drag_log};
use crate::backend::wayland::toolbar_intent::intent_to_event;
use crate::input::MouseButton;
use crate::input::state::HelpOverlayPressSource;
@@ -11,8 +11,9 @@ use crate::ui::toolbar::ToolbarEvent;
use super::*;
+#[cfg(test)]
fn review_action_suppresses_next_release(action: crate::ui::RegionAction) -> bool {
- action != crate::ui::RegionAction::ToggleIncludeDrawings
+ action.is_terminal()
}
impl WaylandState {
@@ -141,20 +142,19 @@ impl WaylandState {
}
match button {
BTN_LEFT => {
- if let Some(action) = self.region_review_action_at(event.position) {
- self.submit_region_review_action(action);
- // The toggle keeps Review active, so its release is consumed
- // by the active-region branch. Arming the generic post-modal
- // latch would swallow an unrelated canvas release later.
- if review_action_suppresses_next_release(action) {
- self.suppress_next_release_from(RegionInputSource::Pointer);
+ match self.consume_region_review_press(RegionInputSource::Pointer, event.position) {
+ RegionReviewPress::NotReview | RegionReviewPress::Fallthrough => {
+ self.begin_region_selection(
+ RegionInputSource::Pointer,
+ event.position.0,
+ event.position.1,
+ );
+ }
+ RegionReviewPress::Consumed { suppress_release } => {
+ if suppress_release {
+ self.suppress_next_release_from(RegionInputSource::Pointer);
+ }
}
- } else if !self.region_review_bar_contains(event.position) {
- self.begin_region_selection(
- RegionInputSource::Pointer,
- event.position.0,
- event.position.1,
- );
}
}
BTN_RIGHT => {
diff --git a/src/backend/wayland/handlers/tablet/tool.rs b/src/backend/wayland/handlers/tablet/tool.rs
index 93c9665f..95a9c145 100644
--- a/src/backend/wayland/handlers/tablet/tool.rs
+++ b/src/backend/wayland/handlers/tablet/tool.rs
@@ -8,7 +8,7 @@ use crate::{
util::Rect,
};
-use crate::backend::wayland::state::WaylandState;
+use crate::backend::wayland::state::{RegionReviewPress, WaylandState};
use crate::input::state::RegionInputSource;
const STYLUS_CURSOR_DAMAGE_RADIUS: i32 = 64;
@@ -253,13 +253,13 @@ impl WaylandState {
return false;
}
let (x, y) = self.current_or_pending_stylus_position();
- if let Some(action) = self.region_review_action_at((x, y)) {
- self.submit_region_review_action(action);
- self.retire_stylus_contact();
- } else if self.region_review_bar_contains((x, y)) {
- self.retire_stylus_contact();
- } else {
- self.begin_region_selection(RegionInputSource::Stylus, x, y);
+ match self.consume_region_review_press(RegionInputSource::Stylus, (x, y)) {
+ RegionReviewPress::NotReview | RegionReviewPress::Fallthrough => {
+ self.begin_region_selection(RegionInputSource::Stylus, x, y);
+ }
+ RegionReviewPress::Consumed { .. } => {
+ self.retire_stylus_contact();
+ }
}
true
}
diff --git a/src/backend/wayland/handlers/touch.rs b/src/backend/wayland/handlers/touch.rs
index a3105a38..3dad2cf8 100644
--- a/src/backend/wayland/handlers/touch.rs
+++ b/src/backend/wayland/handlers/touch.rs
@@ -6,7 +6,8 @@ use wayland_client::{
};
use crate::backend::wayland::state::{
- PerfInputSource, TouchTarget, WaylandState, debug_toolbar_drag_logging_enabled,
+ PerfInputSource, RegionReviewPress, TouchTarget, WaylandState,
+ debug_toolbar_drag_logging_enabled,
};
use crate::backend::wayland::toolbar_intent::intent_to_event;
use crate::input::MouseButton;
@@ -220,18 +221,20 @@ 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;
+ match self.consume_region_review_press(RegionInputSource::Touch, screen_position) {
+ RegionReviewPress::NotReview | RegionReviewPress::Fallthrough => {
+ self.begin_region_selection(
+ RegionInputSource::Touch,
+ screen_position.0,
+ screen_position.1,
+ );
+ }
+ RegionReviewPress::Consumed { suppress_release } => {
+ if suppress_release {
+ self.suppress_next_release_from(RegionInputSource::Touch);
+ }
+ }
}
- if self.region_review_bar_contains(screen_position) {
- return TouchTarget::Other;
- }
- self.begin_region_selection(
- RegionInputSource::Touch,
- screen_position.0,
- screen_position.1,
- );
// Unlike the one-shot eyedropper sample, an OCR region is a
// drag: report the real target so motion and release still
// resolve to screen coordinates and reach the selector.
diff --git a/src/backend/wayland/runtime_operation.rs b/src/backend/wayland/runtime_operation.rs
index 034b20bb..06051eac 100644
--- a/src/backend/wayland/runtime_operation.rs
+++ b/src/backend/wayland/runtime_operation.rs
@@ -16,6 +16,13 @@ impl fmt::Display for RuntimeOperationId {
}
}
+#[cfg(test)]
+impl RuntimeOperationId {
+ pub(in crate::backend::wayland) const fn from_test(value: u64) -> Self {
+ Self(value)
+ }
+}
+
#[derive(Clone)]
pub(in crate::backend::wayland) struct RuntimeOperationIdSource {
next: Arc>>,
@@ -215,6 +222,16 @@ where
Ok(id)
}
+ #[cfg(test)]
+ pub(in crate::backend::wayland) fn try_submit_with_spawner_for_test(
+ &mut self,
+ context: C,
+ operation: impl FnOnce() -> T + Send + 'static,
+ spawn: impl FnOnce(Box) -> std::io::Result<()>,
+ ) -> Result> {
+ self.try_submit_with_spawner(context, operation, spawn)
+ }
+
pub(in crate::backend::wayland) fn poll(&mut self) -> RuntimeOperationPoll {
let Some(active) = self.active.take() else {
return RuntimeOperationPoll::Idle;
diff --git a/src/backend/wayland/state.rs b/src/backend/wayland/state.rs
index 6d0cdc24..5494b66e 100644
--- a/src/backend/wayland/state.rs
+++ b/src/backend/wayland/state.rs
@@ -119,6 +119,7 @@ mod region_capture;
pub(in crate::backend::wayland) use region_capture::RegionCaptureIntent;
#[cfg(test)]
pub(in crate::backend::wayland) use region_capture::RegionPickerOptions;
+pub(in crate::backend::wayland) use region_capture::RegionReviewPress;
mod render;
mod screen_image;
mod text_clipboard;
@@ -300,6 +301,12 @@ pub(super) struct WaylandState {
crate::capture::window_geometry::WindowGeometryError,
>,
>,
+ /// Capacity-one Review cut preview. Independent of capture delivery so a
+ /// replaceable preview cannot occupy the capture reservation slot.
+ pub(super) region_cut_preview: RuntimeOperationController<
+ region_capture::CutPreviewKey,
+ region_capture::CutPreviewOutcome,
+ >,
/// 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 ce7dfb64..0d3dc735 100644
--- a/src/backend/wayland/state/core/init.rs
+++ b/src/backend/wayland/state/core/init.rs
@@ -117,6 +117,8 @@ impl WaylandState {
let clipboard_text_paste =
RuntimeOperationController::new(runtime_operation_ids.clone(), runtime_wake.clone());
let window_query =
+ RuntimeOperationController::new(runtime_operation_ids.clone(), runtime_wake.clone());
+ let region_cut_preview =
RuntimeOperationController::new(runtime_operation_ids, runtime_wake.clone());
let ocr = crate::ocr::OcrController::new(runtime_wake.clone());
@@ -159,6 +161,7 @@ impl WaylandState {
clipboard_text_paste,
pending_text_paste: Default::default(),
window_query,
+ region_cut_preview,
ocr,
gtk_toolbar: None,
onboarding,
diff --git a/src/backend/wayland/state/data.rs b/src/backend/wayland/state/data.rs
index 4364da4c..638faa79 100644
--- a/src/backend/wayland/state/data.rs
+++ b/src/backend/wayland/state/data.rs
@@ -190,6 +190,7 @@ pub struct StateData {
pub(super) active_eyedropper_source: Option,
pub(super) active_screen_region: Option,
pub(super) window_snap: Option,
+ pub(super) region_review_edits: Option,
pub(super) next_screen_region_generation: u64,
pub(super) frozen_enabled: bool,
pub(super) has_seen_surface_enter: bool,
@@ -304,6 +305,7 @@ impl StateData {
active_eyedropper_source: None,
active_screen_region: None,
window_snap: None,
+ region_review_edits: None,
next_screen_region_generation: 1,
frozen_enabled: false,
has_seen_surface_enter: false,
diff --git a/src/backend/wayland/state/ocr.rs b/src/backend/wayland/state/ocr.rs
index ef1d9e45..e9fb8b26 100644
--- a/src/backend/wayland/state/ocr.rs
+++ b/src/backend/wayland/state/ocr.rs
@@ -17,7 +17,7 @@ use super::WaylandState;
use super::acquisition::report_screen_source_activation_rejected_to;
use super::region_capture::{
ActiveScreenRegion, FreezeOwnership, RegionOwnerLoss, RegionSelectionFinalize,
- finalize_region_selection_event,
+ finalize_region_selection_with_review_edits,
};
use super::screen_image::{
CropError, DisplayedScreenImage, ScreenSourceEntry, copy_image_rect, displayed_screen_image,
@@ -314,9 +314,13 @@ impl WaylandState {
if was_engaged && !self.input_state.region_is_engaged() {
return true;
}
- let rect = match finalize_region_selection_event(
+ if self.finish_region_cut_drag(source, (x, y)) {
+ return true;
+ }
+ let rect = match finalize_region_selection_with_review_edits(
&mut self.data.active_screen_region,
&mut self.input_state,
+ &mut self.data.region_review_edits,
source,
(x, y),
) {
diff --git a/src/backend/wayland/state/region_capture.rs b/src/backend/wayland/state/region_capture.rs
index b8147239..79a12a0c 100644
--- a/src/backend/wayland/state/region_capture.rs
+++ b/src/backend/wayland/state/region_capture.rs
@@ -7,12 +7,15 @@ use crate::screen_pixels::{ImagePixelRect, ImagePoint, clamp_edge};
mod active_state;
mod board;
+mod cut_preview;
+mod cut_review;
mod delivery;
mod events;
mod geometry;
mod intent;
mod measure;
mod picker;
+mod render;
mod review_state;
mod runtime;
mod selection_state;
@@ -21,9 +24,14 @@ mod window_snap;
pub(super) use active_state::{ActiveScreenRegion, FreezeOwnership};
pub(in crate::backend::wayland) use board::{
- board_bounds_for_world_rect, world_rect_for_image_rect_exact,
+ board_bounds_for_world_rect, world_rect_for_composed_region, world_rect_for_image_rect_exact,
};
+pub(in crate::backend::wayland) use cut_preview::CutPreviewOutcome;
+pub(in crate::backend::wayland) use cut_review::RegionReviewPress;
+pub(in crate::backend::wayland) use cut_review::{CutPreviewKey, RegionReviewEdits};
+#[cfg(test)]
pub(super) use events::finalize_region_selection_event;
+pub(super) use events::finalize_region_selection_with_review_edits;
use events::*;
pub(in crate::backend::wayland) use geometry::{RegionPickerMeasurement, RegionSelectionGeometry};
pub(in crate::backend::wayland) use intent::{RegionCaptureIntent, RegionPickerOptions};
diff --git a/src/backend/wayland/state/region_capture/board.rs b/src/backend/wayland/state/region_capture/board.rs
index a818ba12..e68118c2 100644
--- a/src/backend/wayland/state/region_capture/board.rs
+++ b/src/backend/wayland/state/region_capture/board.rs
@@ -101,6 +101,31 @@ pub(in crate::backend::wayland) fn world_rect_for_image_rect_exact(
)
}
+/// Scale a source world rectangle to the composed native output size, keeping
+/// the original top-left. Width and height use output/source pixel ratios.
+pub(in crate::backend::wayland) fn world_rect_for_composed_region(
+ source_world: CanvasExportRect,
+ source_size: (u32, u32),
+ output_size: (u32, u32),
+) -> Option {
+ if source_size.0 == 0
+ || source_size.1 == 0
+ || output_size.0 == 0
+ || output_size.1 == 0
+ || !source_world.x.is_finite()
+ || !source_world.y.is_finite()
+ || !source_world.width.is_finite()
+ || !source_world.height.is_finite()
+ || source_world.width <= 0.0
+ || source_world.height <= 0.0
+ {
+ return None;
+ }
+ let width = source_world.width * f64::from(output_size.0) / f64::from(source_size.0);
+ let height = source_world.height * f64::from(output_size.1) / f64::from(source_size.1);
+ CanvasExportRect::new(source_world.x, source_world.y, width, height)
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/src/backend/wayland/state/region_capture/cut_preview.rs b/src/backend/wayland/state/region_capture/cut_preview.rs
new file mode 100644
index 00000000..3facc9a7
--- /dev/null
+++ b/src/backend/wayland/state/region_capture/cut_preview.rs
@@ -0,0 +1,1392 @@
+use std::sync::Arc;
+
+use crate::backend::wayland::runtime_operation::{
+ RuntimeOperationPoll, RuntimeOperationSubmitError,
+};
+use crate::backend::wayland::state::WaylandState;
+use crate::canvas_export::{CanvasExportRect, CanvasRegionExportSnapshot, CanvasRegionSource};
+use crate::capture::{CaptureError, CutBand};
+use crate::input::state::{Toast, ToastPriority};
+use crate::screen_pixels::{ImagePixelRect, PackedArgb32};
+
+use super::super::screen_image::{
+ displayed_screen_image, screen_source_is, shared_displayed_screen_image,
+};
+use super::ActiveScreenRegion;
+use super::cut_review::{
+ CutPreviewKey, PreviewApply, RegionAnnotatedRenderContext, RegionCutBase, RegionCutPreview,
+ RegionRenderFingerprint, RegionReviewCorrelation, RegionReviewEdits, native_extent_display,
+};
+use super::render::{RegionPixelSource, compose_shared_region_pixels, render_region_base_pixels};
+
+const TOAST_SOURCE: &str = "capture";
+
+pub(super) struct CutPreviewJob {
+ pub key: CutPreviewKey,
+ pub source: Option,
+ pub base: Option>,
+}
+
+pub(in crate::backend::wayland) enum CutPreviewOutcome {
+ Success {
+ key: CutPreviewKey,
+ base: Arc,
+ composed: Arc,
+ },
+ Failed {
+ key: CutPreviewKey,
+ message: String,
+ },
+}
+
+pub(super) struct RegionRenderSnapshot {
+ pub source: RegionPixelSource,
+ pub fingerprint: RegionRenderFingerprint,
+}
+
+pub(super) fn run_cut_preview(job: CutPreviewJob) -> CutPreviewOutcome {
+ let key = job.key;
+ let base = match job.base {
+ Some(base) => base,
+ None => {
+ let Some(source) = job.source else {
+ return CutPreviewOutcome::Failed {
+ key,
+ message: "Cut preview is missing its source image.".to_string(),
+ };
+ };
+ match render_region_base_pixels(source) {
+ Ok(pixels) => Arc::new(pixels),
+ Err(error) => {
+ return CutPreviewOutcome::Failed {
+ key,
+ message: error.to_string(),
+ };
+ }
+ }
+ }
+ };
+ match compose_shared_region_pixels(&base, &key.cuts) {
+ Ok(composed) => CutPreviewOutcome::Success {
+ key,
+ base,
+ composed,
+ },
+ Err(error) => CutPreviewOutcome::Failed {
+ key,
+ message: error.to_string(),
+ },
+ }
+}
+
+fn preview_matches_review(edits: &RegionReviewEdits, key: &CutPreviewKey) -> bool {
+ key.fingerprint.correlation() == &edits.correlation
+ && key.fingerprint.source_rect() == edits.source_rect
+}
+
+pub(super) fn apply_cut_preview_outcome(
+ edits: &mut Option,
+ outcome: CutPreviewOutcome,
+ display_for: impl FnOnce(
+ &RegionReviewEdits,
+ &[CutBand],
+ ) -> Option,
+) -> PreviewApply {
+ let Some(edits) = edits.as_mut() else {
+ return PreviewApply::Ignored;
+ };
+ match outcome {
+ CutPreviewOutcome::Failed { key, .. } => {
+ if !preview_matches_review(edits, &key) {
+ return PreviewApply::Ignored;
+ }
+ if edits.mark_preview_failed(&key) {
+ PreviewApply::Changed
+ } else {
+ PreviewApply::Ignored
+ }
+ }
+ CutPreviewOutcome::Success {
+ key,
+ base,
+ composed,
+ } => {
+ if !preview_matches_review(edits, &key) {
+ return PreviewApply::Ignored;
+ }
+ let mut changed = false;
+ if edits
+ .desired_preview
+ .as_ref()
+ .is_some_and(|desired| desired.fingerprint == key.fingerprint)
+ {
+ edits.base_cache = Some(RegionCutBase {
+ fingerprint: key.fingerprint.clone(),
+ pixels: Arc::clone(&base),
+ });
+ changed = true;
+ }
+ if edits.desired_preview.as_ref() == Some(&key) {
+ let Some(display) = display_for(edits, &key.cuts) else {
+ return if changed {
+ PreviewApply::Changed
+ } else {
+ PreviewApply::Ignored
+ };
+ };
+ if (composed.width(), composed.height())
+ != output_size_from_key(&key).unwrap_or((0, 0))
+ {
+ return if changed {
+ PreviewApply::Changed
+ } else {
+ PreviewApply::Ignored
+ };
+ }
+ edits.ready_preview = Some(RegionCutPreview {
+ key,
+ pixels: composed,
+ display,
+ });
+ edits.failed_revision = None;
+ changed = true;
+ }
+ if changed {
+ PreviewApply::Changed
+ } else {
+ PreviewApply::Ignored
+ }
+ }
+ }
+}
+
+fn output_size_from_key(key: &CutPreviewKey) -> Option<(u32, u32)> {
+ crate::capture::output_size(
+ (
+ key.fingerprint.source_rect().width(),
+ key.fingerprint.source_rect().height(),
+ ),
+ &key.cuts,
+ )
+ .ok()
+}
+
+fn desired_preview_to_schedule(
+ edits: Option<&RegionReviewEdits>,
+ worker_active: bool,
+) -> Option {
+ let edits = edits?;
+ let desired = edits.desired_preview.clone()?;
+ if edits.current_preview_failed()
+ || worker_active
+ || edits
+ .ready_preview
+ .as_ref()
+ .is_some_and(|ready| ready.key == desired)
+ {
+ return None;
+ }
+ Some(desired)
+}
+
+fn capture_ready_correlation(
+ region: Option,
+) -> Result {
+ match region {
+ Some(ActiveScreenRegion::Ready {
+ purpose,
+ generation,
+ source,
+ ..
+ }) if purpose.is_capture() => Ok(RegionReviewCorrelation { generation, source }),
+ _ => Err(CaptureError::ImageError(
+ "Region capture is not active.".to_string(),
+ )),
+ }
+}
+
+#[derive(Debug, PartialEq, Eq)]
+enum CutPreviewSnapshotClass {
+ Ready,
+ Cancelled,
+ Failed { message: String },
+}
+
+fn classify_cut_preview_snapshot(
+ desired: &RegionRenderFingerprint,
+ live: Result<&RegionRenderFingerprint, &CaptureError>,
+) -> CutPreviewSnapshotClass {
+ match live {
+ Ok(fingerprint) if fingerprint == desired => CutPreviewSnapshotClass::Ready,
+ Ok(fingerprint) if fingerprint.correlation().source != desired.correlation().source => {
+ CutPreviewSnapshotClass::Cancelled
+ }
+ Ok(_) => CutPreviewSnapshotClass::Failed {
+ message: "The capture source no longer matches the cut preview.".to_string(),
+ },
+ Err(CaptureError::Cancelled(_)) => CutPreviewSnapshotClass::Cancelled,
+ Err(error) => CutPreviewSnapshotClass::Failed {
+ message: error.to_string(),
+ },
+ }
+}
+
+fn cut_preview_from_poll(
+ poll: RuntimeOperationPoll,
+) -> Option {
+ match poll {
+ RuntimeOperationPoll::Idle | RuntimeOperationPoll::Pending { .. } => None,
+ RuntimeOperationPoll::Ready { outcome, .. } => Some(outcome),
+ RuntimeOperationPoll::ProducerFailed {
+ context, reason, ..
+ } => Some(CutPreviewOutcome::Failed {
+ key: context,
+ message: reason,
+ }),
+ RuntimeOperationPoll::Disconnected { context, .. } => Some(CutPreviewOutcome::Failed {
+ key: context,
+ message: "Cut preview worker disconnected.".to_string(),
+ }),
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct CutPreviewVisibleEffect {
+ toast_current_failure: bool,
+ dirty: bool,
+}
+
+fn visible_effect_for_cut_preview(
+ edits: &mut Option,
+ outcome: CutPreviewOutcome,
+ display_for: impl FnOnce(
+ &RegionReviewEdits,
+ &[CutBand],
+ ) -> Option,
+) -> CutPreviewVisibleEffect {
+ let desired = edits
+ .as_ref()
+ .and_then(|edits| edits.desired_preview.clone());
+ let toast_current_failure = matches!(
+ &outcome,
+ CutPreviewOutcome::Failed { key, .. } if desired.as_ref() == Some(key)
+ );
+ if let CutPreviewOutcome::Failed { message, .. } = &outcome
+ && !toast_current_failure
+ {
+ log::debug!("Ignoring stale cut preview failure: {message}");
+ }
+ let applied = apply_cut_preview_outcome(edits, outcome, display_for);
+ CutPreviewVisibleEffect {
+ toast_current_failure,
+ dirty: applied == PreviewApply::Changed,
+ }
+}
+
+fn present_cut_preview_effect(
+ input: &mut crate::input::InputState,
+ effect: CutPreviewVisibleEffect,
+) {
+ if effect.toast_current_failure {
+ input.push_toast(
+ ToastPriority::Info,
+ TOAST_SOURCE,
+ Toast::warning("Could not update the cut preview."),
+ );
+ }
+ if effect.dirty {
+ input.dirty_tracker.mark_full();
+ input.needs_redraw = true;
+ }
+}
+
+impl WaylandState {
+ fn annotated_render_context(&self) -> RegionAnnotatedRenderContext {
+ RegionAnnotatedRenderContext {
+ 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(),
+ canvas_content_generation: self.input_state.canvas_content_generation(),
+ board_view_offset: self.board_view_offset(),
+ text_halo_enabled: self.config.drawing.text_halo_enabled,
+ spotlight: crate::canvas_export::SpotlightPassSnapshot {
+ dim_opacity: self.input_state.spotlight_dim_opacity,
+ feather: self.input_state.spotlight_feather,
+ },
+ }
+ }
+
+ fn fingerprint_for_live_render(
+ &self,
+ correlation: RegionReviewCorrelation,
+ rect: ImagePixelRect,
+ include_drawings: bool,
+ ) -> RegionRenderFingerprint {
+ if include_drawings {
+ RegionRenderFingerprint::Annotated {
+ correlation,
+ source_rect: rect,
+ context: self.annotated_render_context(),
+ }
+ } else {
+ RegionRenderFingerprint::Raw {
+ correlation,
+ source_rect: rect,
+ }
+ }
+ }
+
+ fn retain_current_capture_image(
+ &self,
+ token: &crate::backend::wayland::state::screen_image::ScreenSourceToken,
+ ) -> Result, CaptureError> {
+ let Some(source) = displayed_screen_image(
+ &self.zoom,
+ &self.frozen,
+ self.input_state.board_is_transparent(),
+ ) else {
+ return Err(CaptureError::Cancelled(
+ "The captured screen image is no longer available.".to_string(),
+ ));
+ };
+ if !screen_source_is(
+ token,
+ &source,
+ &self.zoom,
+ &self.frozen,
+ (self.surface.width(), self.surface.height()),
+ ) {
+ return Err(CaptureError::Cancelled(
+ "The captured screen image changed.".to_string(),
+ ));
+ }
+ shared_displayed_screen_image(&self.zoom, &self.frozen, source.kind).ok_or_else(|| {
+ CaptureError::ImageError("Could not retain the captured screen image.".to_string())
+ })
+ }
+
+ fn region_pixel_source(
+ &self,
+ fingerprint: &RegionRenderFingerprint,
+ shared_image: std::sync::Arc,
+ ) -> Result {
+ match fingerprint {
+ RegionRenderFingerprint::Raw { source_rect, .. } => Ok(RegionPixelSource::Raw {
+ image: shared_image,
+ selection: *source_rect,
+ }),
+ RegionRenderFingerprint::Annotated {
+ source_rect,
+ context,
+ correlation,
+ } => {
+ let logical_bounds = CanvasExportRect::new(
+ context.board_view_offset.0,
+ context.board_view_offset.1,
+ f64::from(correlation.source.surface.0),
+ f64::from(correlation.source.surface.1),
+ )
+ .ok_or_else(|| {
+ CaptureError::ImageError("Could not map the selected drawings.".to_string())
+ })?;
+ Ok(RegionPixelSource::Annotated(Box::new(
+ CanvasRegionExportSnapshot {
+ source: CanvasRegionSource {
+ image: shared_image,
+ logical_bounds,
+ },
+ selection: *source_rect,
+ frame: self
+ .input_state
+ .boards
+ .active_frame()
+ .clone_without_history(),
+ text_halo_enabled: context.text_halo_enabled,
+ spotlight: context.spotlight,
+ },
+ )))
+ }
+ }
+ }
+
+ pub(super) fn snapshot_region_render(
+ &self,
+ rect: ImagePixelRect,
+ include_drawings: bool,
+ ) -> Result {
+ let correlation = capture_ready_correlation(self.data.active_screen_region)?;
+ let shared_image = self.retain_current_capture_image(&correlation.source)?;
+ let fingerprint = self.fingerprint_for_live_render(correlation, rect, include_drawings);
+ let source = self.region_pixel_source(&fingerprint, shared_image)?;
+ Ok(RegionRenderSnapshot {
+ source,
+ fingerprint,
+ })
+ }
+
+ pub(super) fn current_region_fingerprint(&self) -> Option {
+ let rect = self.region_review_rect()?;
+ let include_drawings = self.region_picker_include_drawings();
+ let correlation = capture_ready_correlation(self.data.active_screen_region).ok()?;
+ if self
+ .retain_current_capture_image(&correlation.source)
+ .is_err()
+ {
+ return None;
+ }
+ Some(self.fingerprint_for_live_render(correlation, rect, include_drawings))
+ }
+
+ pub(super) fn schedule_region_cut_preview(&mut self) {
+ let Some(desired) = desired_preview_to_schedule(
+ self.data.region_review_edits.as_ref(),
+ self.region_cut_preview.is_active(),
+ ) else {
+ return;
+ };
+ let Some(edits) = self.data.region_review_edits.as_ref() else {
+ return;
+ };
+ let cached_base = edits
+ .base_cache
+ .as_ref()
+ .filter(|cache| cache.fingerprint == desired.fingerprint)
+ .map(|cache| Arc::clone(&cache.pixels));
+ let source = if cached_base.is_some() {
+ None
+ } else {
+ match self.snapshot_region_render(
+ desired.fingerprint.source_rect(),
+ desired.fingerprint.include_drawings(),
+ ) {
+ Ok(snapshot) => match classify_cut_preview_snapshot(
+ &desired.fingerprint,
+ Ok(&snapshot.fingerprint),
+ ) {
+ CutPreviewSnapshotClass::Ready => Some(snapshot.source),
+ CutPreviewSnapshotClass::Cancelled => {
+ self.cancel_region_capture_for_source_change();
+ return;
+ }
+ CutPreviewSnapshotClass::Failed { message } => {
+ self.finish_cut_preview_poll(CutPreviewOutcome::Failed {
+ key: desired,
+ message,
+ });
+ return;
+ }
+ },
+ Err(error) => {
+ match classify_cut_preview_snapshot(&desired.fingerprint, Err(&error)) {
+ CutPreviewSnapshotClass::Cancelled => {
+ self.cancel_region_capture_for_source_change();
+ }
+ CutPreviewSnapshotClass::Failed { message } => {
+ self.finish_cut_preview_poll(CutPreviewOutcome::Failed {
+ key: desired,
+ message,
+ });
+ }
+ CutPreviewSnapshotClass::Ready => {
+ unreachable!("a snapshot error cannot match the desired fingerprint")
+ }
+ }
+ return;
+ }
+ }
+ };
+ let job = CutPreviewJob {
+ key: desired.clone(),
+ source,
+ base: cached_base,
+ };
+ if let Err(failure) = self.region_cut_preview.try_submit(
+ desired,
+ "wayscriber-region-cut-preview",
+ move || run_cut_preview(job),
+ ) {
+ let (error, key) = failure.into_parts();
+ if !matches!(error, RuntimeOperationSubmitError::Busy { .. }) {
+ log::debug!("Cut preview worker unavailable: {error}");
+ self.finish_cut_preview_poll(CutPreviewOutcome::Failed {
+ key,
+ message: error.to_string(),
+ });
+ }
+ }
+ }
+
+ pub(in crate::backend::wayland) fn poll_region_cut_preview_completion(&mut self) {
+ if let Some(outcome) = cut_preview_from_poll(self.region_cut_preview.poll()) {
+ self.finish_cut_preview_poll(outcome);
+ self.schedule_region_cut_preview();
+ }
+ }
+
+ fn finish_cut_preview_poll(&mut self, outcome: CutPreviewOutcome) {
+ let effect = visible_effect_for_cut_preview(
+ &mut self.data.region_review_edits,
+ outcome,
+ |edits, cuts| {
+ native_extent_display(
+ &edits.correlation.source,
+ edits.source_rect,
+ crate::capture::output_size(
+ (edits.source_rect.width(), edits.source_rect.height()),
+ cuts,
+ )
+ .ok()?,
+ )
+ },
+ );
+ present_cut_preview_effect(&mut self.input_state, effect);
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::backend::wayland::state::screen_image::ScreenImageKind;
+ use crate::capture::CutAxis;
+ use crate::input::state::RegionSelection;
+ use wayland_client::protocol::wl_output::Transform;
+
+ type ContextMutation = (&'static str, fn(&mut RegionAnnotatedRenderContext));
+
+ fn token() -> crate::backend::wayland::state::screen_image::ScreenSourceToken {
+ crate::backend::wayland::state::screen_image::ScreenSourceToken {
+ output_id: 1,
+ output_layout_generation: 1,
+ kind: ScreenImageKind::Frozen,
+ image_generation: 1,
+ image_size: (2, 1),
+ stride: 8,
+ surface: (2, 1),
+ output_scale: 1,
+ output_transform: Transform::Normal,
+ zoom_transformed: false,
+ zoom_scale: 1.0,
+ zoom_view_offset: (0.0, 0.0),
+ }
+ }
+
+ fn key(revision: u64, generation: u64) -> CutPreviewKey {
+ let rect = ImagePixelRect::new(0, 0, 2, 1, (2, 1)).unwrap();
+ CutPreviewKey {
+ fingerprint: RegionRenderFingerprint::Raw {
+ correlation: RegionReviewCorrelation {
+ generation,
+ source: token(),
+ },
+ source_rect: rect,
+ },
+ revision,
+ cuts: vec![CutBand::new(CutAxis::Columns, 1, 2).unwrap()],
+ }
+ }
+
+ fn annotated_key(revision: u64, generation: u64) -> CutPreviewKey {
+ let mut key = key(revision, generation);
+ key.fingerprint = RegionRenderFingerprint::Annotated {
+ correlation: key.fingerprint.correlation().clone(),
+ source_rect: key.fingerprint.source_rect(),
+ context: RegionAnnotatedRenderContext {
+ board_id: "board-a".to_string(),
+ page_index: 2,
+ page_generation: 3,
+ canvas_content_generation: 4,
+ board_view_offset: (5.0, 6.0),
+ text_halo_enabled: true,
+ spotlight: crate::canvas_export::SpotlightPassSnapshot {
+ dim_opacity: 0.7,
+ feather: 0.2,
+ },
+ },
+ };
+ key
+ }
+
+ fn pixels() -> Arc {
+ Arc::new(
+ PackedArgb32::new(
+ 2,
+ 1,
+ 8,
+ [0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88].to_vec(),
+ )
+ .unwrap(),
+ )
+ }
+
+ fn display_selection() -> RegionSelection {
+ RegionSelection {
+ start: (0.0, 0.0),
+ end: (1.0, 1.0),
+ }
+ }
+
+ fn display(_: &RegionReviewEdits, _: &[CutBand]) -> Option {
+ Some(display_selection())
+ }
+
+ #[test]
+ fn matching_completion_installs_base_and_preview() {
+ let rect = ImagePixelRect::new(0, 0, 2, 1, (2, 1)).unwrap();
+ let mut edits = Some(RegionReviewEdits::new(
+ RegionReviewCorrelation {
+ generation: 1,
+ source: token(),
+ },
+ rect,
+ ));
+ let desired = key(1, 1);
+ edits.as_mut().unwrap().cuts = desired.cuts.clone();
+ edits.as_mut().unwrap().revision = 1;
+ edits.as_mut().unwrap().desired_preview = Some(desired.clone());
+ let composed = Arc::new(PackedArgb32::new(1, 1, 4, vec![0x55, 0x66, 0x77, 0x88]).unwrap());
+ let applied = apply_cut_preview_outcome(
+ &mut edits,
+ CutPreviewOutcome::Success {
+ key: desired,
+ base: pixels(),
+ composed: Arc::clone(&composed),
+ },
+ display,
+ );
+ assert_eq!(applied, PreviewApply::Changed);
+ let edits = edits.unwrap();
+ assert!(edits.base_cache.is_some());
+ assert_eq!(
+ edits
+ .ready_preview
+ .as_ref()
+ .map(|preview| preview.pixels.as_ref()),
+ Some(composed.as_ref())
+ );
+ assert!(edits.preview_is_current());
+ }
+
+ #[test]
+ fn stale_revision_may_cache_the_base_only() {
+ let rect = ImagePixelRect::new(0, 0, 2, 1, (2, 1)).unwrap();
+ let mut edits = Some(RegionReviewEdits::new(
+ RegionReviewCorrelation {
+ generation: 1,
+ source: token(),
+ },
+ rect,
+ ));
+ let stale = key(1, 1);
+ let desired = key(2, 1);
+ edits.as_mut().unwrap().cuts = desired.cuts.clone();
+ edits.as_mut().unwrap().revision = 2;
+ edits.as_mut().unwrap().desired_preview = Some(desired);
+ let applied = apply_cut_preview_outcome(
+ &mut edits,
+ CutPreviewOutcome::Success {
+ key: stale,
+ base: pixels(),
+ composed: Arc::new(PackedArgb32::new(1, 1, 4, vec![0; 4]).unwrap()),
+ },
+ display,
+ );
+ assert_eq!(applied, PreviewApply::Changed);
+ let edits = edits.unwrap();
+ assert!(edits.base_cache.is_some());
+ assert!(edits.ready_preview.is_none());
+ }
+
+ #[test]
+ fn different_generation_rejects_all_output() {
+ let rect = ImagePixelRect::new(0, 0, 2, 1, (2, 1)).unwrap();
+ let mut edits = Some(RegionReviewEdits::new(
+ RegionReviewCorrelation {
+ generation: 2,
+ source: token(),
+ },
+ rect,
+ ));
+ let applied = apply_cut_preview_outcome(
+ &mut edits,
+ CutPreviewOutcome::Success {
+ key: key(1, 1),
+ base: pixels(),
+ composed: pixels(),
+ },
+ display,
+ );
+ assert_eq!(applied, PreviewApply::Ignored);
+ assert!(edits.unwrap().ready_preview.is_none());
+ }
+
+ #[test]
+ fn every_nonmatching_render_fingerprint_field_rejects_completed_output() {
+ let desired = annotated_key(1, 1);
+ let mut mismatches = Vec::new();
+
+ let mut different_source = desired.clone();
+ let RegionRenderFingerprint::Annotated { correlation, .. } =
+ &mut different_source.fingerprint
+ else {
+ unreachable!("annotated fixture stays annotated");
+ };
+ correlation.source.image_generation = 9;
+ mismatches.push(("source token", different_source));
+
+ let mut different_rect = desired.clone();
+ let RegionRenderFingerprint::Annotated { source_rect, .. } =
+ &mut different_rect.fingerprint
+ else {
+ unreachable!("annotated fixture stays annotated");
+ };
+ *source_rect = ImagePixelRect::new(0, 0, 1, 1, (2, 1)).unwrap();
+ mismatches.push(("source rectangle", different_rect));
+
+ let mut without_drawings = desired.clone();
+ without_drawings.fingerprint = RegionRenderFingerprint::Raw {
+ correlation: desired.fingerprint.correlation().clone(),
+ source_rect: desired.fingerprint.source_rect(),
+ };
+ mismatches.push(("include drawings", without_drawings));
+
+ let context_mutations: [ContextMutation; 8] = [
+ (
+ "board identity",
+ |context: &mut RegionAnnotatedRenderContext| {
+ context.board_id = "board-b".to_string()
+ },
+ ),
+ ("page identity", |context| context.page_index += 1),
+ ("page generation", |context| context.page_generation += 1),
+ ("canvas content generation", |context| {
+ context.canvas_content_generation += 1;
+ }),
+ ("board view offset", |context| {
+ context.board_view_offset.0 += 1.0;
+ }),
+ ("text halo", |context| {
+ context.text_halo_enabled = !context.text_halo_enabled;
+ }),
+ ("Spotlight opacity", |context| {
+ context.spotlight.dim_opacity += 0.1;
+ }),
+ ("Spotlight feather", |context| {
+ context.spotlight.feather += 0.1;
+ }),
+ ];
+ for (name, mutate) in context_mutations {
+ let mut mismatch = desired.clone();
+ let RegionRenderFingerprint::Annotated { context, .. } = &mut mismatch.fingerprint
+ else {
+ unreachable!("annotated fixture stays annotated");
+ };
+ mutate(context);
+ mismatches.push((name, mismatch));
+ }
+
+ for (name, mismatch) in mismatches {
+ let mut edits = review_edits(desired.clone());
+ let applied = apply_cut_preview_outcome(
+ &mut edits,
+ CutPreviewOutcome::Success {
+ key: mismatch,
+ base: pixels(),
+ composed: Arc::new(PackedArgb32::new(1, 1, 4, vec![0; 4]).unwrap()),
+ },
+ display,
+ );
+ let edits = edits.unwrap();
+ assert_eq!(applied, PreviewApply::Ignored, "{name}");
+ assert!(edits.base_cache.is_none(), "{name} donated a stale base");
+ assert!(
+ edits.ready_preview.is_none(),
+ "{name} installed a stale preview"
+ );
+ }
+ }
+
+ #[test]
+ fn current_failure_records_visible_state() {
+ let rect = ImagePixelRect::new(0, 0, 2, 1, (2, 1)).unwrap();
+ let mut edits = Some(RegionReviewEdits::new(
+ RegionReviewCorrelation {
+ generation: 1,
+ source: token(),
+ },
+ rect,
+ ));
+ let desired = key(3, 1);
+ edits.as_mut().unwrap().cuts = desired.cuts.clone();
+ edits.as_mut().unwrap().revision = 3;
+ edits.as_mut().unwrap().desired_preview = Some(desired.clone());
+ let applied = apply_cut_preview_outcome(
+ &mut edits,
+ CutPreviewOutcome::Failed {
+ key: desired,
+ message: "boom".to_string(),
+ },
+ display,
+ );
+ assert_eq!(applied, PreviewApply::Changed);
+ assert_eq!(edits.unwrap().failed_revision, Some(3));
+ }
+
+ #[test]
+ fn stale_failure_does_not_touch_the_new_review() {
+ let rect = ImagePixelRect::new(0, 0, 2, 1, (2, 1)).unwrap();
+ let mut edits = Some(RegionReviewEdits::new(
+ RegionReviewCorrelation {
+ generation: 2,
+ source: token(),
+ },
+ rect,
+ ));
+ let applied = apply_cut_preview_outcome(
+ &mut edits,
+ CutPreviewOutcome::Failed {
+ key: key(1, 1),
+ message: "old".to_string(),
+ },
+ display,
+ );
+ assert_eq!(applied, PreviewApply::Ignored);
+ assert!(edits.unwrap().failed_revision.is_none());
+ }
+
+ fn ready(purpose: crate::input::state::RegionPurposeTag) -> ActiveScreenRegion {
+ ActiveScreenRegion::Ready {
+ purpose,
+ generation: 7,
+ source: token(),
+ freeze_ownership: super::super::FreezeOwnership::PreExisting,
+ anchor: None,
+ raw_edge: None,
+ logical_anchor: None,
+ logical_edge: None,
+ square_modifier: false,
+ legend_dismissed: false,
+ include_drawings: false,
+ review_resize: None,
+ }
+ }
+
+ #[test]
+ fn render_snapshot_accepts_both_capture_purposes() {
+ use crate::input::state::RegionPurposeTag;
+ assert!(capture_ready_correlation(Some(ready(RegionPurposeTag::CaptureDeliver))).is_ok());
+ assert!(
+ capture_ready_correlation(Some(ready(RegionPurposeTag::CaptureInteractive))).is_ok()
+ );
+ assert!(capture_ready_correlation(Some(ready(RegionPurposeTag::Ocr))).is_err());
+ assert!(capture_ready_correlation(Some(ready(RegionPurposeTag::Measure))).is_err());
+ assert!(capture_ready_correlation(None).is_err());
+ let correlation =
+ capture_ready_correlation(Some(ready(RegionPurposeTag::CaptureDeliver))).unwrap();
+ assert_eq!(correlation.generation, 7);
+ let err = capture_ready_correlation(Some(ready(RegionPurposeTag::Ocr))).unwrap_err();
+ assert!(
+ !err.to_string().contains("review"),
+ "direct delivery must not require Review: {err}"
+ );
+ }
+
+ #[test]
+ fn snapshot_classification_routes_cancel_and_current_failures() {
+ let desired = key(1, 1);
+ assert_eq!(
+ classify_cut_preview_snapshot(&desired.fingerprint, Ok(&desired.fingerprint)),
+ CutPreviewSnapshotClass::Ready
+ );
+ let other = key(1, 2);
+ assert!(matches!(
+ classify_cut_preview_snapshot(&desired.fingerprint, Ok(&other.fingerprint)),
+ CutPreviewSnapshotClass::Failed { .. }
+ ));
+ assert_eq!(
+ classify_cut_preview_snapshot(
+ &desired.fingerprint,
+ Err(&CaptureError::Cancelled("changed".to_string()))
+ ),
+ CutPreviewSnapshotClass::Cancelled
+ );
+ assert!(matches!(
+ classify_cut_preview_snapshot(
+ &desired.fingerprint,
+ Err(&CaptureError::ImageError("nope".to_string()))
+ ),
+ CutPreviewSnapshotClass::Failed { .. }
+ ));
+ let mut other_token = token();
+ other_token.image_generation = 99;
+ let other_source = RegionRenderFingerprint::Raw {
+ correlation: RegionReviewCorrelation {
+ generation: 1,
+ source: other_token,
+ },
+ source_rect: desired.fingerprint.source_rect(),
+ };
+ assert_eq!(
+ classify_cut_preview_snapshot(&desired.fingerprint, Ok(&other_source)),
+ CutPreviewSnapshotClass::Cancelled
+ );
+ let drifted = RegionRenderFingerprint::Raw {
+ correlation: desired.fingerprint.correlation().clone(),
+ source_rect: ImagePixelRect::new(0, 0, 1, 1, (2, 1)).unwrap(),
+ };
+ assert!(matches!(
+ classify_cut_preview_snapshot(&desired.fingerprint, Ok(&drifted)),
+ CutPreviewSnapshotClass::Failed { .. }
+ ));
+ }
+
+ #[test]
+ fn preview_job_composes_from_the_key_cuts() {
+ let desired = key(1, 1);
+ match run_cut_preview(CutPreviewJob {
+ key: desired.clone(),
+ source: None,
+ base: Some(pixels()),
+ }) {
+ CutPreviewOutcome::Success { key, composed, .. } => {
+ assert_eq!(key.cuts, desired.cuts);
+ assert_eq!((composed.width(), composed.height()), (1, 1));
+ }
+ CutPreviewOutcome::Failed { message, .. } => panic!("{message}"),
+ }
+ }
+
+ #[test]
+ fn empty_cuts_reuse_the_cached_base_raster() {
+ let mut desired = key(1, 1);
+ desired.cuts.clear();
+ let base = pixels();
+ match run_cut_preview(CutPreviewJob {
+ key: desired,
+ source: None,
+ base: Some(Arc::clone(&base)),
+ }) {
+ CutPreviewOutcome::Success {
+ base: out_base,
+ composed,
+ ..
+ } => {
+ assert!(Arc::ptr_eq(&base, &out_base));
+ assert!(Arc::ptr_eq(&out_base, &composed));
+ assert_eq!((composed.width(), composed.height()), (2, 1));
+ }
+ CutPreviewOutcome::Failed { message, .. } => panic!("{message}"),
+ }
+ }
+
+ fn review_edits(desired: CutPreviewKey) -> Option {
+ let rect = desired.fingerprint.source_rect();
+ let mut edits = RegionReviewEdits::new(desired.fingerprint.correlation().clone(), rect);
+ edits.cuts = desired.cuts.clone();
+ edits.revision = desired.revision;
+ edits.desired_preview = Some(desired);
+ Some(edits)
+ }
+
+ fn poll_until_terminal(
+ controller: &mut crate::backend::wayland::runtime_operation::RuntimeOperationController<
+ CutPreviewKey,
+ CutPreviewOutcome,
+ >,
+ ) -> RuntimeOperationPoll {
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
+ loop {
+ match controller.poll() {
+ RuntimeOperationPoll::Pending { .. } => {
+ assert!(
+ std::time::Instant::now() < deadline,
+ "cut preview worker did not finish"
+ );
+ std::thread::yield_now();
+ }
+ poll => return poll,
+ }
+ }
+ }
+
+ fn apply_poll(
+ edits: &mut Option,
+ input: &mut crate::input::InputState,
+ poll: RuntimeOperationPoll,
+ ) {
+ if let Some(outcome) = cut_preview_from_poll(poll) {
+ let effect = visible_effect_for_cut_preview(edits, outcome, display);
+ present_cut_preview_effect(input, effect);
+ }
+ }
+
+ fn preview_controller()
+ -> crate::backend::wayland::runtime_operation::RuntimeOperationController<
+ CutPreviewKey,
+ CutPreviewOutcome,
+ > {
+ let wake = crate::backend::wayland::RuntimeWakeSource::new().unwrap();
+ crate::backend::wayland::runtime_operation::RuntimeOperationController::new(
+ crate::backend::wayland::runtime_operation::RuntimeOperationIdSource::new(),
+ wake.handle(),
+ )
+ }
+
+ #[test]
+ fn worker_error_toasts_once_and_marks_dirty_for_the_current_revision() {
+ let desired = key(3, 1);
+ let mut edits = review_edits(desired.clone());
+ let mut input = crate::input::state::test_support::make_test_input_state();
+ input.needs_redraw = false;
+ let _ = input.dirty_tracker.take_region_report(2, 1);
+
+ let mut controller = preview_controller();
+ let job_key = desired.clone();
+ controller
+ .try_submit(desired.clone(), "test-cut-preview-error", move || {
+ CutPreviewOutcome::Failed {
+ key: job_key,
+ message: "pixels".to_string(),
+ }
+ })
+ .unwrap();
+ apply_poll(&mut edits, &mut input, poll_until_terminal(&mut controller));
+
+ assert_eq!(edits.as_ref().unwrap().failed_revision, Some(3));
+ assert!(edits.as_ref().unwrap().current_preview_failed());
+ assert_eq!(input.test_toast_count(), 1);
+ assert_eq!(
+ input.test_active_toast_message(),
+ Some("Could not update the cut preview.")
+ );
+ assert_eq!(input.test_active_toast_key(), Some(TOAST_SOURCE));
+ assert!(input.needs_redraw);
+ assert_eq!(
+ input.dirty_tracker.take_region_report(2, 1).regions.len(),
+ 1
+ );
+
+ present_cut_preview_effect(
+ &mut input,
+ visible_effect_for_cut_preview(
+ &mut edits,
+ CutPreviewOutcome::Failed {
+ key: desired,
+ message: "pixels again".to_string(),
+ },
+ display,
+ ),
+ );
+ assert_eq!(
+ input.test_toast_count(),
+ 1,
+ "same capture key must not stack a second preview-failure toast"
+ );
+ }
+
+ #[test]
+ fn worker_panic_and_disconnect_fail_the_current_preview() {
+ let desired = key(4, 1);
+ let mut edits = review_edits(desired.clone());
+ let mut input = crate::input::state::test_support::make_test_input_state();
+ let mut controller = preview_controller();
+ controller
+ .try_submit(desired.clone(), "test-cut-preview-panic", || {
+ panic!("expected cut preview panic")
+ })
+ .unwrap();
+ apply_poll(&mut edits, &mut input, poll_until_terminal(&mut controller));
+ assert_eq!(edits.as_ref().unwrap().failed_revision, Some(4));
+ assert_eq!(input.test_toast_count(), 1);
+
+ let desired = key(5, 1);
+ let mut edits = review_edits(desired.clone());
+ let mut input = crate::input::state::test_support::make_test_input_state();
+ let mut controller = preview_controller();
+ controller
+ .try_submit_with_spawner_for_test(
+ desired.clone(),
+ || panic!("must not run"),
+ |job| {
+ drop(job);
+ Ok(())
+ },
+ )
+ .unwrap();
+ apply_poll(&mut edits, &mut input, controller.poll());
+ assert_eq!(edits.as_ref().unwrap().failed_revision, Some(5));
+ assert_eq!(input.test_toast_count(), 1);
+ }
+
+ #[test]
+ fn submit_failure_is_visible_and_busy_is_not() {
+ let desired = key(6, 1);
+ let mut edits = review_edits(desired.clone());
+ let mut input = crate::input::state::test_support::make_test_input_state();
+ let mut controller = preview_controller();
+ let failure = controller
+ .try_submit_with_spawner_for_test(
+ desired.clone(),
+ || panic!("must not run"),
+ |_job| Err(std::io::Error::other("injected spawn failure")),
+ )
+ .unwrap_err();
+ let (error, failed_key) = failure.into_parts();
+ assert!(matches!(
+ error,
+ crate::backend::wayland::runtime_operation::RuntimeOperationSubmitError::SpawnFailed { .. }
+ ));
+ present_cut_preview_effect(
+ &mut input,
+ visible_effect_for_cut_preview(
+ &mut edits,
+ CutPreviewOutcome::Failed {
+ key: failed_key,
+ message: error.to_string(),
+ },
+ display,
+ ),
+ );
+ assert_eq!(edits.as_ref().unwrap().failed_revision, Some(6));
+ assert_eq!(input.test_toast_count(), 1);
+
+ let desired = key(7, 1);
+ let edits = review_edits(desired.clone());
+ let input = crate::input::state::test_support::make_test_input_state();
+ let mut controller = preview_controller();
+ let (release_tx, release_rx) = std::sync::mpsc::channel();
+ let blocked_key = desired.clone();
+ controller
+ .try_submit(desired.clone(), "test-cut-preview-busy", move || {
+ release_rx.recv().unwrap();
+ CutPreviewOutcome::Failed {
+ key: blocked_key,
+ message: "late".to_string(),
+ }
+ })
+ .unwrap();
+ let busy = controller
+ .try_submit(desired.clone(), "test-cut-preview-busy-2", || {
+ panic!("must not run")
+ })
+ .unwrap_err()
+ .into_parts()
+ .0;
+ assert!(matches!(
+ busy,
+ crate::backend::wayland::runtime_operation::RuntimeOperationSubmitError::Busy { .. }
+ ));
+ assert!(edits.as_ref().unwrap().failed_revision.is_none());
+ assert_eq!(input.test_toast_count(), 0);
+ release_tx.send(()).unwrap();
+ let _ = poll_until_terminal(&mut controller);
+ }
+
+ #[test]
+ fn identity_mismatch_and_stale_failure_follow_current_or_silent_rules() {
+ let desired = key(8, 1);
+ let mut edits = review_edits(desired.clone());
+ let mut input = crate::input::state::test_support::make_test_input_state();
+ apply_poll(
+ &mut edits,
+ &mut input,
+ RuntimeOperationPoll::ProducerFailed {
+ id: crate::backend::wayland::runtime_operation::RuntimeOperationId::from_test(3),
+ context: desired.clone(),
+ reason: "runtime operation worker reported transport identity 4, expected 3"
+ .to_string(),
+ },
+ );
+ assert_eq!(edits.as_ref().unwrap().failed_revision, Some(8));
+ assert_eq!(input.test_toast_count(), 1);
+ assert!(input.needs_redraw);
+
+ let mut next = review_edits(key(9, 2));
+ let mut input = crate::input::state::test_support::make_test_input_state();
+ input.needs_redraw = false;
+ let _ = input.dirty_tracker.take_region_report(2, 1);
+ apply_poll(
+ &mut next,
+ &mut input,
+ RuntimeOperationPoll::ProducerFailed {
+ id: crate::backend::wayland::runtime_operation::RuntimeOperationId::from_test(3),
+ context: desired,
+ reason: "old picker".to_string(),
+ },
+ );
+ assert!(next.as_ref().unwrap().failed_revision.is_none());
+ assert_eq!(input.test_toast_count(), 0);
+ assert!(!input.needs_redraw);
+ assert!(
+ input
+ .dirty_tracker
+ .take_region_report(2, 1)
+ .regions
+ .is_empty()
+ );
+ }
+
+ #[test]
+ fn busy_controller_runs_only_the_newest_desired_key_after_terminal_poll() {
+ let first = key(10, 1);
+ let second = key(11, 1);
+ let newest = key(12, 1);
+ let mut edits = review_edits(first.clone());
+ let mut controller = preview_controller();
+ let (release_tx, release_rx) = std::sync::mpsc::channel();
+ let first_job_key = first.clone();
+ controller
+ .try_submit(first, "test-cut-preview-queued-first", move || {
+ release_rx.recv().unwrap();
+ CutPreviewOutcome::Failed {
+ key: first_job_key,
+ message: "stale".to_string(),
+ }
+ })
+ .unwrap();
+
+ edits.as_mut().unwrap().desired_preview = Some(second);
+ assert!(
+ desired_preview_to_schedule(edits.as_ref(), controller.is_active()).is_none(),
+ "busy preview work leaves the current desired key queued in Review state"
+ );
+ edits.as_mut().unwrap().desired_preview = Some(newest.clone());
+ assert!(
+ desired_preview_to_schedule(edits.as_ref(), controller.is_active()).is_none(),
+ "a later edit replaces the queued desired key instead of appending work"
+ );
+
+ release_tx.send(()).unwrap();
+ assert!(cut_preview_from_poll(poll_until_terminal(&mut controller)).is_some());
+ let scheduled = desired_preview_to_schedule(edits.as_ref(), controller.is_active())
+ .expect("terminal consumption schedules the newest desired key");
+ assert_eq!(scheduled, newest);
+
+ let scheduled_job_key = scheduled.clone();
+ controller
+ .try_submit(scheduled, "test-cut-preview-queued-newest", move || {
+ CutPreviewOutcome::Failed {
+ key: scheduled_job_key,
+ message: "newest".to_string(),
+ }
+ })
+ .unwrap();
+ assert!(matches!(
+ poll_until_terminal(&mut controller),
+ RuntimeOperationPoll::Ready {
+ context,
+ outcome: CutPreviewOutcome::Failed { key, .. },
+ ..
+ } if context == newest && key == newest
+ ));
+ }
+
+ #[test]
+ fn reset_while_worker_is_active_releases_buffers_and_ignores_completion() {
+ let active_key = key(1, 1);
+ let mut edits = review_edits(active_key.clone());
+ let cached_base = pixels();
+ let cached_base_weak = Arc::downgrade(&cached_base);
+ let cached_preview = Arc::new(PackedArgb32::new(1, 1, 4, vec![0; 4]).unwrap());
+ let cached_preview_weak = Arc::downgrade(&cached_preview);
+ edits.as_mut().unwrap().base_cache = Some(RegionCutBase {
+ fingerprint: active_key.fingerprint.clone(),
+ pixels: cached_base,
+ });
+ edits.as_mut().unwrap().ready_preview = Some(RegionCutPreview {
+ key: active_key.clone(),
+ pixels: cached_preview,
+ display: display_selection(),
+ });
+
+ let worker_base = pixels();
+ let worker_base_weak = Arc::downgrade(&worker_base);
+ let worker_preview = Arc::new(PackedArgb32::new(1, 1, 4, vec![1; 4]).unwrap());
+ let worker_preview_weak = Arc::downgrade(&worker_preview);
+ let (release_tx, release_rx) = std::sync::mpsc::channel();
+ let mut controller = preview_controller();
+ let worker_key = active_key.clone();
+ controller
+ .try_submit(active_key, "test-cut-preview-reset-active", move || {
+ release_rx.recv().unwrap();
+ CutPreviewOutcome::Success {
+ key: worker_key,
+ base: worker_base,
+ composed: worker_preview,
+ }
+ })
+ .unwrap();
+
+ assert!(edits.as_mut().unwrap().reset());
+ assert!(cached_base_weak.upgrade().is_none());
+ assert!(cached_preview_weak.upgrade().is_none());
+ release_tx.send(()).unwrap();
+ let outcome = cut_preview_from_poll(poll_until_terminal(&mut controller)).unwrap();
+ assert_eq!(
+ apply_cut_preview_outcome(&mut edits, outcome, display),
+ PreviewApply::Ignored
+ );
+ let edits = edits.unwrap();
+ assert!(edits.base_cache.is_none());
+ assert!(edits.ready_preview.is_none());
+ assert!(edits.desired_preview.is_none());
+ assert!(worker_base_weak.upgrade().is_none());
+ assert!(worker_preview_weak.upgrade().is_none());
+ }
+
+ #[test]
+ fn review_exit_and_reopen_releases_buffers_and_rejects_old_completion() {
+ let old_key = key(1, 1);
+ let mut edits = review_edits(old_key.clone());
+ let cached_base = pixels();
+ let cached_base_weak = Arc::downgrade(&cached_base);
+ let cached_preview = Arc::new(PackedArgb32::new(1, 1, 4, vec![0; 4]).unwrap());
+ let cached_preview_weak = Arc::downgrade(&cached_preview);
+ edits.as_mut().unwrap().base_cache = Some(RegionCutBase {
+ fingerprint: old_key.fingerprint.clone(),
+ pixels: cached_base,
+ });
+ edits.as_mut().unwrap().ready_preview = Some(RegionCutPreview {
+ key: old_key.clone(),
+ pixels: cached_preview,
+ display: display_selection(),
+ });
+
+ let worker_base = pixels();
+ let worker_base_weak = Arc::downgrade(&worker_base);
+ let worker_preview = Arc::new(PackedArgb32::new(1, 1, 4, vec![1; 4]).unwrap());
+ let worker_preview_weak = Arc::downgrade(&worker_preview);
+ let (release_tx, release_rx) = std::sync::mpsc::channel();
+ let mut controller = preview_controller();
+ let worker_key = old_key.clone();
+ controller
+ .try_submit(old_key, "test-cut-preview-reopen", move || {
+ release_rx.recv().unwrap();
+ CutPreviewOutcome::Success {
+ key: worker_key,
+ base: worker_base,
+ composed: worker_preview,
+ }
+ })
+ .unwrap();
+
+ edits = None;
+ assert!(
+ edits.is_none(),
+ "Review exit clears the transient edit state"
+ );
+ assert!(cached_base_weak.upgrade().is_none());
+ assert!(cached_preview_weak.upgrade().is_none());
+ edits = review_edits(key(1, 2));
+ release_tx.send(()).unwrap();
+ let outcome = cut_preview_from_poll(poll_until_terminal(&mut controller)).unwrap();
+ assert_eq!(
+ apply_cut_preview_outcome(&mut edits, outcome, display),
+ PreviewApply::Ignored
+ );
+ let edits = edits.unwrap();
+ assert!(edits.base_cache.is_none());
+ assert!(edits.ready_preview.is_none());
+ assert!(worker_base_weak.upgrade().is_none());
+ assert!(worker_preview_weak.upgrade().is_none());
+ }
+}
diff --git a/src/backend/wayland/state/region_capture/cut_review.rs b/src/backend/wayland/state/region_capture/cut_review.rs
new file mode 100644
index 00000000..ff4778f6
--- /dev/null
+++ b/src/backend/wayland/state/region_capture/cut_review.rs
@@ -0,0 +1,1409 @@
+use crate::capture::{CutAxis, CutBand, output_size};
+use crate::input::InputState;
+use crate::input::state::{RegionInputSource, RegionSelection};
+use crate::screen_pixels::{ImagePixelRect, ImagePoint, pixel_span};
+use crate::util::Rect;
+
+use super::super::screen_image::{ScreenSourceToken, screen_rect_for_native_extent};
+use super::*;
+use crate::ui::{RegionActionAvailability, RegionCutStatus};
+
+pub(super) const CUT_DRAG_THRESHOLD_PX: f64 = 4.0;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(super) enum PreviewApply {
+ Ignored,
+ Changed,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(in crate::backend::wayland) enum CutMode {
+ Idle,
+ Armed,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub(in crate::backend::wayland) struct CutDrag {
+ pub(super) owner: RegionInputSource,
+ pub(super) start: (f64, f64),
+ pub(super) current: (f64, f64),
+ pub(super) axis: Option,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub(in crate::backend::wayland) struct RegionReviewCorrelation {
+ pub(super) generation: u64,
+ pub(super) source: ScreenSourceToken,
+}
+
+/// Board and overlay facts that distinguish two annotated region renders.
+/// Fingerprint and snapshot construction share one of these so halo, Spotlight,
+/// and board identity cannot describe different frames.
+#[derive(Debug, Clone, PartialEq)]
+pub(super) struct RegionAnnotatedRenderContext {
+ pub(super) board_id: String,
+ pub(super) page_index: usize,
+ pub(super) page_generation: u64,
+ pub(super) canvas_content_generation: u64,
+ pub(super) board_view_offset: (f64, f64),
+ pub(super) text_halo_enabled: bool,
+ pub(super) spotlight: crate::canvas_export::SpotlightPassSnapshot,
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub(in crate::backend::wayland) enum RegionRenderFingerprint {
+ Raw {
+ correlation: RegionReviewCorrelation,
+ source_rect: ImagePixelRect,
+ },
+ Annotated {
+ correlation: RegionReviewCorrelation,
+ source_rect: ImagePixelRect,
+ context: RegionAnnotatedRenderContext,
+ },
+}
+
+impl RegionRenderFingerprint {
+ pub(super) fn correlation(&self) -> &RegionReviewCorrelation {
+ match self {
+ Self::Raw { correlation, .. } | Self::Annotated { correlation, .. } => correlation,
+ }
+ }
+
+ pub(super) fn source_rect(&self) -> ImagePixelRect {
+ match self {
+ Self::Raw { source_rect, .. } | Self::Annotated { source_rect, .. } => *source_rect,
+ }
+ }
+
+ pub(super) fn include_drawings(&self) -> bool {
+ matches!(self, Self::Annotated { .. })
+ }
+}
+
+#[derive(Debug, Clone, PartialEq)]
+pub(in crate::backend::wayland) struct CutPreviewKey {
+ pub(super) fingerprint: RegionRenderFingerprint,
+ pub(super) revision: u64,
+ pub(super) cuts: Vec,
+}
+
+#[derive(Debug, Clone)]
+pub(in crate::backend::wayland) struct RegionCutPreview {
+ pub(super) key: CutPreviewKey,
+ pub(super) pixels: std::sync::Arc,
+ pub(super) display: RegionSelection,
+}
+
+#[derive(Debug, Clone)]
+pub(in crate::backend::wayland) struct RegionCutBase {
+ pub(super) fingerprint: RegionRenderFingerprint,
+ pub(super) pixels: std::sync::Arc,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(super) enum CutCommit {
+ None,
+ Applied,
+ RejectedFullAxis,
+}
+
+#[derive(Debug)]
+pub(in crate::backend::wayland) struct RegionReviewEdits {
+ pub(super) correlation: RegionReviewCorrelation,
+ pub(super) source_rect: ImagePixelRect,
+ pub(super) mode: CutMode,
+ pub(super) drag: Option,
+ pub(super) cuts: Vec,
+ pub(super) redo: Vec,
+ pub(super) revision: u64,
+ pub(super) desired_preview: Option,
+ pub(super) ready_preview: Option,
+ pub(super) base_cache: Option,
+ pub(super) failed_revision: Option,
+}
+
+pub(super) fn review_edits_for_active_region(
+ region: Option,
+ rect: ImagePixelRect,
+) -> Option {
+ let Some(ActiveScreenRegion::Ready {
+ purpose: crate::input::state::RegionPurposeTag::CaptureInteractive,
+ generation,
+ source,
+ ..
+ }) = region
+ else {
+ return None;
+ };
+ Some(RegionReviewEdits::new(
+ RegionReviewCorrelation { generation, source },
+ rect,
+ ))
+}
+
+impl RegionReviewEdits {
+ pub(super) fn new(correlation: RegionReviewCorrelation, source_rect: ImagePixelRect) -> Self {
+ Self {
+ correlation,
+ source_rect,
+ mode: CutMode::Idle,
+ drag: None,
+ cuts: Vec::new(),
+ redo: Vec::new(),
+ revision: 0,
+ desired_preview: None,
+ ready_preview: None,
+ base_cache: None,
+ failed_revision: None,
+ }
+ }
+
+ pub(super) fn crop_locked(&self) -> bool {
+ !self.cuts.is_empty()
+ }
+
+ pub(super) fn loupe_suppressed(&self) -> bool {
+ self.mode == CutMode::Armed || self.crop_locked()
+ }
+
+ pub(super) fn preview_is_current(&self) -> bool {
+ match &self.desired_preview {
+ None => self.cuts.is_empty(),
+ Some(desired) => self
+ .ready_preview
+ .as_ref()
+ .is_some_and(|ready| ready.key == *desired),
+ }
+ }
+
+ pub(super) fn can_start_cut_drag(&self) -> bool {
+ self.mode == CutMode::Armed && self.preview_is_current() && self.drag.is_none()
+ }
+
+ /// A current failed preview must stay failed until undo, redo, or reset
+ /// changes the revision. Resubmitting the same revision churns workers.
+ pub(super) fn current_preview_failed(&self) -> bool {
+ self.desired_preview.as_ref().is_some_and(|desired| {
+ self.failed_revision
+ .is_some_and(|revision| revision == desired.revision)
+ })
+ }
+
+ pub(super) fn status(&self) -> Option {
+ if self.cuts.is_empty() {
+ return None;
+ }
+ if self
+ .failed_revision
+ .is_some_and(|revision| revision == self.revision)
+ {
+ return Some(RegionCutStatus::Failed);
+ }
+ if !self.preview_is_current() {
+ return Some(RegionCutStatus::Updating);
+ }
+ None
+ }
+
+ pub(super) fn availability(&self) -> RegionActionAvailability {
+ let preview_ok = self.preview_is_current();
+ RegionActionAvailability {
+ terminal: preview_ok,
+ cut: true,
+ undo: !self.cuts.is_empty(),
+ redo: !self.redo.is_empty(),
+ reset: self.crop_locked() || !self.redo.is_empty(),
+ }
+ }
+
+ /// Toggle Cut arming. Returns the abandoned drag owner, if any, so the
+ /// caller can retire the matching `InputState` review-move owner.
+ pub(super) fn toggle_mode(&mut self) -> Option {
+ let abandoned = if self.mode == CutMode::Armed {
+ self.drag.take().map(|drag| drag.owner)
+ } else {
+ None
+ };
+ self.mode = match self.mode {
+ CutMode::Idle => CutMode::Armed,
+ CutMode::Armed => CutMode::Idle,
+ };
+ abandoned
+ }
+
+ pub(super) fn disarm_mode(&mut self) -> bool {
+ if self.drag.is_some() {
+ self.drag = None;
+ return true;
+ }
+ if self.mode == CutMode::Armed {
+ self.mode = CutMode::Idle;
+ return true;
+ }
+ false
+ }
+
+ fn bump_revision(&mut self) -> bool {
+ match self.revision.checked_add(1) {
+ Some(next) => {
+ self.revision = next;
+ true
+ }
+ None => false,
+ }
+ }
+
+ pub(super) fn set_desired_from(&mut self, fingerprint: RegionRenderFingerprint) {
+ if self.cuts.is_empty() {
+ self.desired_preview = None;
+ self.ready_preview = None;
+ self.base_cache = None;
+ self.failed_revision = None;
+ return;
+ }
+ self.desired_preview = Some(CutPreviewKey {
+ fingerprint,
+ revision: self.revision,
+ cuts: self.cuts.clone(),
+ });
+ }
+
+ pub(super) fn output_size(&self) -> Option<(u32, u32)> {
+ output_size(
+ (self.source_rect.width(), self.source_rect.height()),
+ &self.cuts,
+ )
+ .ok()
+ }
+
+ pub(super) fn displayed_output_size(&self) -> Option<(u32, u32)> {
+ self.ready_preview
+ .as_ref()
+ .map(|preview| (preview.pixels.width(), preview.pixels.height()))
+ .or_else(|| {
+ if self.cuts.is_empty() {
+ Some((self.source_rect.width(), self.source_rect.height()))
+ } else {
+ None
+ }
+ })
+ }
+
+ pub(super) fn begin_drag(&mut self, owner: RegionInputSource, point: (f64, f64)) -> bool {
+ if !self.can_start_cut_drag() {
+ return false;
+ }
+ self.drag = Some(CutDrag {
+ owner,
+ start: point,
+ current: point,
+ axis: None,
+ });
+ true
+ }
+
+ pub(super) fn update_drag(&mut self, owner: RegionInputSource, point: (f64, f64)) -> bool {
+ let Some(drag) = self.drag.as_mut() else {
+ return false;
+ };
+ if drag.owner != owner {
+ return false;
+ }
+ drag.current = point;
+ if drag.axis.is_none() {
+ let dx = point.0 - drag.start.0;
+ let dy = point.1 - drag.start.1;
+ if dx.hypot(dy) >= CUT_DRAG_THRESHOLD_PX {
+ drag.axis = Some(dominant_cut_axis(dx, dy));
+ }
+ }
+ true
+ }
+
+ pub(super) fn finish_drag(
+ &mut self,
+ owner: RegionInputSource,
+ point: (f64, f64),
+ display: RegionSelection,
+ fingerprint: RegionRenderFingerprint,
+ ) -> CutCommit {
+ if !self.drag.as_ref().is_some_and(|drag| drag.owner == owner) {
+ return CutCommit::None;
+ }
+ let _ = self.update_drag(owner, point);
+ let Some(drag) = self.drag.take() else {
+ return CutCommit::None;
+ };
+ let Some(axis) = drag.axis else {
+ return CutCommit::None;
+ };
+ let Some(output) = self.output_size() else {
+ return CutCommit::None;
+ };
+ let Some(band) = quantized_cut(axis, display, output, drag.start, drag.current) else {
+ return CutCommit::None;
+ };
+ let mut next_cuts = self.cuts.clone();
+ next_cuts.push(band);
+ if output_size(
+ (self.source_rect.width(), self.source_rect.height()),
+ &next_cuts,
+ )
+ .is_err()
+ {
+ return CutCommit::RejectedFullAxis;
+ }
+ if !self.bump_revision() {
+ return CutCommit::None;
+ }
+ self.cuts.push(band);
+ self.redo.clear();
+ self.failed_revision = None;
+ self.set_desired_from(fingerprint);
+ CutCommit::Applied
+ }
+
+ pub(super) fn undo(&mut self, fingerprint: RegionRenderFingerprint) -> bool {
+ let Some(cut) = self.cuts.pop() else {
+ return false;
+ };
+ if !self.bump_revision() {
+ self.cuts.push(cut);
+ return false;
+ }
+ self.redo.push(cut);
+ self.drag = None;
+ self.failed_revision = None;
+ self.set_desired_from(fingerprint);
+ true
+ }
+
+ pub(super) fn redo(&mut self, fingerprint: RegionRenderFingerprint) -> bool {
+ let Some(cut) = self.redo.pop() else {
+ return false;
+ };
+ if !self.bump_revision() {
+ self.redo.push(cut);
+ return false;
+ }
+ self.cuts.push(cut);
+ self.drag = None;
+ self.failed_revision = None;
+ self.set_desired_from(fingerprint);
+ true
+ }
+
+ pub(super) fn reset(&mut self) -> bool {
+ if self.cuts.is_empty() && self.redo.is_empty() && self.drag.is_none() {
+ return false;
+ }
+ if !self.bump_revision() {
+ return false;
+ }
+ self.drag = None;
+ self.cuts.clear();
+ self.redo.clear();
+ self.failed_revision = None;
+ self.set_desired_from(RegionRenderFingerprint::Raw {
+ correlation: self.correlation.clone(),
+ source_rect: self.source_rect,
+ });
+ true
+ }
+
+ pub(super) fn set_source_rect(&mut self, source_rect: ImagePixelRect) -> bool {
+ if self.crop_locked() || self.source_rect == source_rect {
+ return false;
+ }
+ self.source_rect = source_rect;
+ self.redo.clear();
+ self.ready_preview = None;
+ self.base_cache = None;
+ self.desired_preview = None;
+ self.failed_revision = None;
+ true
+ }
+
+ pub(super) fn invalidate_base(&mut self, fingerprint: RegionRenderFingerprint) {
+ if !self.cuts.is_empty() && !self.bump_revision() {
+ return;
+ }
+ self.base_cache = None;
+ self.ready_preview = None;
+ self.failed_revision = None;
+ self.set_desired_from(fingerprint);
+ }
+
+ pub(super) fn mark_preview_failed(&mut self, key: &CutPreviewKey) -> bool {
+ if self.desired_preview.as_ref() != Some(key) {
+ return false;
+ }
+ self.failed_revision = Some(key.revision);
+ true
+ }
+}
+
+pub(super) fn dominant_cut_axis(dx: f64, dy: f64) -> CutAxis {
+ if dy.abs() > dx.abs() {
+ CutAxis::Rows
+ } else {
+ CutAxis::Columns
+ }
+}
+
+pub(super) fn output_display_for(
+ token: &ScreenSourceToken,
+ source_rect: ImagePixelRect,
+ cuts: &[CutBand],
+) -> Option {
+ let size = output_size((source_rect.width(), source_rect.height()), cuts).ok()?;
+ native_extent_display(token, source_rect, size)
+}
+
+pub(super) fn native_extent_display(
+ token: &ScreenSourceToken,
+ source_rect: ImagePixelRect,
+ size: (u32, u32),
+) -> Option {
+ let rect = screen_rect_for_native_extent(token, (source_rect.x(), source_rect.y()), size)?;
+ Some(region_selection_from_rect(rect))
+}
+
+pub(super) fn region_selection_from_rect(rect: Rect) -> RegionSelection {
+ RegionSelection {
+ start: (f64::from(rect.x), f64::from(rect.y)),
+ end: (
+ f64::from(rect.x.saturating_add(rect.width)),
+ f64::from(rect.y.saturating_add(rect.height)),
+ ),
+ }
+}
+
+pub(super) fn display_contains(display: RegionSelection, point: (f64, f64)) -> bool {
+ let left = display.start.0.min(display.end.0);
+ let right = display.start.0.max(display.end.0);
+ let top = display.start.1.min(display.end.1);
+ let bottom = display.start.1.max(display.end.1);
+ point.0 >= left && point.0 < right && point.1 >= top && point.1 < bottom
+}
+
+pub(super) fn logical_to_output_point(
+ display: RegionSelection,
+ output_size: (u32, u32),
+ point: (f64, f64),
+) -> Option {
+ if output_size.0 == 0 || output_size.1 == 0 || !point.0.is_finite() || !point.1.is_finite() {
+ return None;
+ }
+ let left = display.start.0.min(display.end.0);
+ let right = display.start.0.max(display.end.0);
+ let top = display.start.1.min(display.end.1);
+ let bottom = display.start.1.max(display.end.1);
+ let width = right - left;
+ let height = bottom - top;
+ if width <= 0.0 || height <= 0.0 || !width.is_finite() || !height.is_finite() {
+ return None;
+ }
+ let x = ((point.0 - left) / width) * f64::from(output_size.0);
+ let y = ((point.1 - top) / height) * f64::from(output_size.1);
+ if !x.is_finite() || !y.is_finite() {
+ return None;
+ }
+ Some(ImagePoint::new(
+ x.clamp(0.0, f64::from(output_size.0)),
+ y.clamp(0.0, f64::from(output_size.1)),
+ ))
+}
+
+fn quantized_cut(
+ axis: CutAxis,
+ display: RegionSelection,
+ output_size: (u32, u32),
+ start: (f64, f64),
+ current: (f64, f64),
+) -> Option {
+ let first = logical_to_output_point(display, output_size, start)?;
+ let second = logical_to_output_point(display, output_size, current)?;
+ let span = pixel_span(first, second, output_size)?;
+ match axis {
+ CutAxis::Columns => {
+ CutBand::from_unordered_edges(axis, span.x(), span.x().checked_add(span.width())?).ok()
+ }
+ CutAxis::Rows => {
+ CutBand::from_unordered_edges(axis, span.y(), span.y().checked_add(span.height())?).ok()
+ }
+ }
+}
+
+pub(super) fn cut_band_display(
+ display: RegionSelection,
+ output_size: (u32, u32),
+ axis: CutAxis,
+ start: u32,
+ end: u32,
+) -> Option {
+ if end <= start || output_size.0 == 0 || output_size.1 == 0 {
+ return None;
+ }
+ let left = display.start.0.min(display.end.0);
+ let right = display.start.0.max(display.end.0);
+ let top = display.start.1.min(display.end.1);
+ let bottom = display.start.1.max(display.end.1);
+ let width = right - left;
+ let height = bottom - top;
+ match axis {
+ CutAxis::Columns => {
+ let x0 = left + f64::from(start) * width / f64::from(output_size.0);
+ let x1 = left + f64::from(end) * width / f64::from(output_size.0);
+ Some(RegionSelection {
+ start: (x0, top),
+ end: (x1, bottom),
+ })
+ }
+ CutAxis::Rows => {
+ let y0 = top + f64::from(start) * height / f64::from(output_size.1);
+ let y1 = top + f64::from(end) * height / f64::from(output_size.1);
+ Some(RegionSelection {
+ start: (left, y0),
+ end: (right, y1),
+ })
+ }
+ }
+}
+
+fn retire_cut_drag_owner(input: &mut InputState, owner: Option) {
+ if let Some(owner) = owner {
+ let _ = input.finish_region_review_move(owner);
+ }
+}
+
+fn apply_cut_history_change(
+ edits: &mut Option,
+ input: &mut InputState,
+ change: impl FnOnce(&mut RegionReviewEdits) -> bool,
+) -> bool {
+ let owner = edits
+ .as_ref()
+ .and_then(|edits| edits.drag.map(|drag| drag.owner));
+ let Some(edits) = edits.as_mut() else {
+ return false;
+ };
+ if !change(edits) {
+ return false;
+ }
+ retire_cut_drag_owner(input, owner);
+ true
+}
+
+impl WaylandState {
+ pub(super) fn region_review_edits(&self) -> Option<&RegionReviewEdits> {
+ self.data.region_review_edits.as_ref()
+ }
+
+ pub(super) fn region_review_edits_mut(&mut self) -> Option<&mut RegionReviewEdits> {
+ self.data.region_review_edits.as_mut()
+ }
+
+ pub(in crate::backend::wayland) fn region_review_crop_locked(&self) -> bool {
+ self.region_review_edits()
+ .is_some_and(RegionReviewEdits::crop_locked)
+ }
+
+ pub(in crate::backend::wayland) fn region_review_loupe_suppressed(&self) -> bool {
+ self.region_review_edits()
+ .is_some_and(RegionReviewEdits::loupe_suppressed)
+ }
+
+ pub(in crate::backend::wayland) fn region_cut_displayed_selection(
+ &self,
+ ) -> Option {
+ let edits = self.region_review_edits()?;
+ if let Some(preview) = &edits.ready_preview {
+ return Some(preview.display);
+ }
+ let token = self.region_picker_source_token()?;
+ output_display_for(&token, edits.source_rect, &[])
+ }
+
+ pub(in crate::backend::wayland) fn region_cut_availability(&self) -> RegionActionAvailability {
+ self.region_review_edits()
+ .map(RegionReviewEdits::availability)
+ .unwrap_or_default()
+ }
+
+ pub(in crate::backend::wayland) fn region_cut_status(&self) -> Option {
+ self.region_review_edits()
+ .and_then(RegionReviewEdits::status)
+ }
+
+ pub(in crate::backend::wayland) fn region_cut_mode_armed(&self) -> bool {
+ self.region_review_edits()
+ .is_some_and(|edits| edits.mode == CutMode::Armed)
+ }
+
+ pub(super) fn create_region_review_edits(&mut self, rect: ImagePixelRect) {
+ self.data.region_review_edits =
+ review_edits_for_active_region(self.data.active_screen_region, rect);
+ }
+
+ pub(super) fn mark_region_cut_ui_dirty(&mut self) {
+ self.input_state.dirty_tracker.mark_full();
+ self.input_state.needs_redraw = true;
+ }
+
+ pub(in crate::backend::wayland) fn apply_region_review_edit(
+ &mut self,
+ action: crate::ui::RegionAction,
+ ) -> bool {
+ match action {
+ crate::ui::RegionAction::CutBand => self.toggle_region_cut_mode(),
+ crate::ui::RegionAction::UndoCut => self.undo_region_cut(),
+ crate::ui::RegionAction::RedoCut => self.redo_region_cut(),
+ crate::ui::RegionAction::ResetCuts => self.reset_region_cuts(),
+ crate::ui::RegionAction::ToggleIncludeDrawings => {
+ self.toggle_region_picker_include_drawings()
+ }
+ crate::ui::RegionAction::Copy
+ | crate::ui::RegionAction::Save
+ | crate::ui::RegionAction::Both
+ | crate::ui::RegionAction::Board => false,
+ }
+ }
+
+ fn retire_region_cut_drag_owner(&mut self, owner: Option) {
+ retire_cut_drag_owner(&mut self.input_state, owner);
+ }
+
+ fn toggle_region_cut_mode(&mut self) -> bool {
+ let Some(edits) = self.region_review_edits_mut() else {
+ return false;
+ };
+ let owner = edits.toggle_mode();
+ self.retire_region_cut_drag_owner(owner);
+ self.mark_region_cut_ui_dirty();
+ true
+ }
+
+ fn undo_region_cut(&mut self) -> bool {
+ let Some(fingerprint) = self.current_region_fingerprint() else {
+ return false;
+ };
+ if !apply_cut_history_change(
+ &mut self.data.region_review_edits,
+ &mut self.input_state,
+ |edits| edits.undo(fingerprint),
+ ) {
+ return false;
+ }
+ self.mark_region_cut_ui_dirty();
+ self.schedule_region_cut_preview();
+ true
+ }
+
+ fn redo_region_cut(&mut self) -> bool {
+ let Some(fingerprint) = self.current_region_fingerprint() else {
+ return false;
+ };
+ if !apply_cut_history_change(
+ &mut self.data.region_review_edits,
+ &mut self.input_state,
+ |edits| edits.redo(fingerprint),
+ ) {
+ return false;
+ }
+ self.mark_region_cut_ui_dirty();
+ self.schedule_region_cut_preview();
+ true
+ }
+
+ fn reset_region_cuts(&mut self) -> bool {
+ if !apply_cut_history_change(
+ &mut self.data.region_review_edits,
+ &mut self.input_state,
+ RegionReviewEdits::reset,
+ ) {
+ return false;
+ }
+ self.mark_region_cut_ui_dirty();
+ true
+ }
+
+ pub(in crate::backend::wayland) fn try_begin_region_cut_drag(
+ &mut self,
+ owner: RegionInputSource,
+ point: (f64, f64),
+ ) -> bool {
+ let Some(display) = self.region_cut_displayed_selection() else {
+ return false;
+ };
+ if !display_contains(display, point) {
+ return false;
+ }
+ let Some(edits) = self.region_review_edits_mut() else {
+ return false;
+ };
+ if !edits.begin_drag(owner, point) {
+ return false;
+ }
+ if !self.input_state.begin_region_review_move(owner) {
+ if let Some(edits) = self.region_review_edits_mut() {
+ edits.drag = None;
+ }
+ return false;
+ }
+ self.mark_region_cut_ui_dirty();
+ true
+ }
+
+ pub(in crate::backend::wayland) fn update_region_cut_drag(
+ &mut self,
+ owner: RegionInputSource,
+ point: (f64, f64),
+ ) -> bool {
+ let Some(edits) = self.region_review_edits_mut() else {
+ return false;
+ };
+ if !edits.update_drag(owner, point) {
+ return false;
+ }
+ self.mark_region_cut_ui_dirty();
+ true
+ }
+
+ pub(in crate::backend::wayland) fn finish_region_cut_drag(
+ &mut self,
+ owner: RegionInputSource,
+ point: (f64, f64),
+ ) -> bool {
+ if !self
+ .region_review_edits()
+ .and_then(|edits| edits.drag)
+ .is_some_and(|drag| drag.owner == owner)
+ {
+ return false;
+ }
+ let Some(display) = self.region_cut_displayed_selection() else {
+ self.abandon_region_cut_drag(owner);
+ return true;
+ };
+ let Some(fingerprint) = self.current_region_fingerprint() else {
+ self.abandon_region_cut_drag(owner);
+ return true;
+ };
+ let Some(edits) = self.region_review_edits_mut() else {
+ return false;
+ };
+ let commit = edits.finish_drag(owner, point, display, fingerprint);
+ let _ = self.input_state.finish_region_review_move(owner);
+ match commit {
+ CutCommit::Applied => {
+ self.mark_region_cut_ui_dirty();
+ self.schedule_region_cut_preview();
+ }
+ CutCommit::RejectedFullAxis => {
+ self.input_state.push_toast(
+ crate::input::state::ToastPriority::Info,
+ "capture",
+ crate::input::state::Toast::warning(
+ "That cut would remove the entire remaining image.",
+ ),
+ );
+ self.mark_region_cut_ui_dirty();
+ }
+ CutCommit::None => self.mark_region_cut_ui_dirty(),
+ }
+ true
+ }
+
+ pub(in crate::backend::wayland) fn abandon_region_cut_drag(
+ &mut self,
+ owner: RegionInputSource,
+ ) -> bool {
+ let Some(edits) = self.region_review_edits_mut() else {
+ return false;
+ };
+ let Some(drag) = edits.drag else {
+ return false;
+ };
+ if drag.owner != owner {
+ return false;
+ }
+ edits.drag = None;
+ let _ = self.input_state.finish_region_review_move(owner);
+ self.mark_region_cut_ui_dirty();
+ true
+ }
+
+ pub(in crate::backend::wayland) fn handle_region_cut_escape(&mut self) -> bool {
+ let owner = self
+ .region_review_edits()
+ .and_then(|edits| edits.drag.map(|drag| drag.owner));
+ let Some(edits) = self.region_review_edits_mut() else {
+ return false;
+ };
+ if !edits.disarm_mode() {
+ return false;
+ }
+ if let Some(owner) = owner {
+ let _ = self.input_state.finish_region_review_move(owner);
+ }
+ self.mark_region_cut_ui_dirty();
+ true
+ }
+
+ pub(super) fn sync_region_review_source_rect(&mut self) {
+ let Some(rect) = self.region_review_rect() else {
+ return;
+ };
+ let Some(edits) = self.region_review_edits_mut() else {
+ return;
+ };
+ if edits.set_source_rect(rect) {
+ self.mark_region_cut_ui_dirty();
+ }
+ }
+
+ pub(in crate::backend::wayland) fn region_cut_preview_pixels(
+ &self,
+ ) -> Option<&crate::screen_pixels::PackedArgb32> {
+ self.region_review_edits()
+ .and_then(|edits| edits.ready_preview.as_ref())
+ .map(|preview| preview.pixels.as_ref())
+ }
+
+ pub(in crate::backend::wayland) fn region_cut_drag_overlay(
+ &self,
+ ) -> Option<(CutAxis, RegionSelection)> {
+ let edits = self.region_review_edits()?;
+ let drag = edits.drag?;
+ let axis = drag.axis?;
+ let display = self.region_cut_displayed_selection()?;
+ let output = edits.output_size()?;
+ let band = quantized_cut(axis, display, output, drag.start, drag.current)?;
+ debug_assert_eq!(band.axis(), axis);
+ cut_band_display(display, output, axis, band.start(), band.end()).map(|band| (axis, band))
+ }
+
+ pub(in crate::backend::wayland) fn consume_region_review_press(
+ &mut self,
+ owner: RegionInputSource,
+ point: (f64, f64),
+ ) -> RegionReviewPress {
+ if !self.input_state.region_state().is_review() {
+ return RegionReviewPress::NotReview;
+ }
+ if self.region_review_bar_contains(point) {
+ let suppress_release = if let Some(action) = self.region_review_action_at(point) {
+ let terminal = action.is_terminal();
+ self.submit_region_review_action(action);
+ terminal
+ } else {
+ false
+ };
+ return RegionReviewPress::Consumed { suppress_release };
+ }
+ if self.try_begin_region_cut_drag(owner, point) {
+ return RegionReviewPress::Consumed {
+ suppress_release: false,
+ };
+ }
+ if self.region_review_crop_locked() || self.region_cut_mode_armed() {
+ return RegionReviewPress::Consumed {
+ suppress_release: false,
+ };
+ }
+ RegionReviewPress::Fallthrough
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(in crate::backend::wayland) enum RegionReviewPress {
+ NotReview,
+ Consumed { suppress_release: bool },
+ Fallthrough,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::backend::wayland::state::screen_image::ScreenImageKind;
+ use crate::capture::CutAxis;
+ use wayland_client::protocol::wl_output::Transform;
+
+ fn token() -> ScreenSourceToken {
+ ScreenSourceToken {
+ output_id: 1,
+ output_layout_generation: 1,
+ kind: ScreenImageKind::Frozen,
+ image_generation: 1,
+ image_size: (8, 8),
+ stride: 32,
+ surface: (8, 8),
+ output_scale: 1,
+ output_transform: Transform::Normal,
+ zoom_transformed: false,
+ zoom_scale: 1.0,
+ zoom_view_offset: (0.0, 0.0),
+ }
+ }
+
+ fn fingerprint(rect: ImagePixelRect) -> RegionRenderFingerprint {
+ RegionRenderFingerprint::Raw {
+ correlation: RegionReviewCorrelation {
+ generation: 1,
+ source: token(),
+ },
+ source_rect: rect,
+ }
+ }
+
+ fn edits() -> RegionReviewEdits {
+ let rect = ImagePixelRect::new(0, 0, 8, 8, (8, 8)).unwrap();
+ RegionReviewEdits::new(
+ RegionReviewCorrelation {
+ generation: 1,
+ source: token(),
+ },
+ rect,
+ )
+ }
+
+ fn display() -> RegionSelection {
+ RegionSelection {
+ start: (0.0, 0.0),
+ end: (8.0, 8.0),
+ }
+ }
+
+ #[test]
+ fn arming_does_not_change_history() {
+ let mut edits = edits();
+ edits.toggle_mode();
+ assert_eq!(edits.mode, CutMode::Armed);
+ assert!(edits.cuts.is_empty());
+ edits.toggle_mode();
+ assert_eq!(edits.mode, CutMode::Idle);
+ assert!(edits.cuts.is_empty());
+ }
+
+ #[test]
+ fn sub_threshold_drag_commits_nothing() {
+ let mut edits = edits();
+ edits.toggle_mode();
+ assert!(edits.begin_drag(RegionInputSource::Pointer, (1.0, 1.0)));
+ assert!(edits.update_drag(RegionInputSource::Pointer, (3.0, 1.0)));
+ assert_eq!(
+ edits.finish_drag(
+ RegionInputSource::Pointer,
+ (3.0, 1.0),
+ display(),
+ fingerprint(edits.source_rect)
+ ),
+ CutCommit::None
+ );
+ assert!(edits.cuts.is_empty());
+ }
+
+ #[test]
+ fn axis_locks_once_past_the_threshold() {
+ let mut edits = edits();
+ edits.toggle_mode();
+ assert!(edits.begin_drag(RegionInputSource::Pointer, (0.0, 0.0)));
+ assert!(edits.update_drag(RegionInputSource::Pointer, (6.0, 1.0)));
+ assert_eq!(edits.drag.unwrap().axis, Some(CutAxis::Columns));
+ assert!(edits.update_drag(RegionInputSource::Pointer, (6.0, 20.0)));
+ assert_eq!(edits.drag.unwrap().axis, Some(CutAxis::Columns));
+ }
+
+ #[test]
+ fn wrong_owner_cannot_update_or_finish_a_drag() {
+ let mut edits = edits();
+ edits.toggle_mode();
+ assert!(edits.begin_drag(RegionInputSource::Pointer, (0.0, 0.0)));
+ assert!(!edits.update_drag(RegionInputSource::Touch, (6.0, 0.0)));
+ assert_eq!(
+ edits.finish_drag(
+ RegionInputSource::Touch,
+ (6.0, 0.0),
+ display(),
+ fingerprint(edits.source_rect)
+ ),
+ CutCommit::None
+ );
+ assert!(edits.drag.is_some());
+ }
+
+ #[test]
+ fn valid_commit_appends_clears_redo_and_increments_revision() {
+ let mut edits = edits();
+ edits.toggle_mode();
+ assert!(edits.begin_drag(RegionInputSource::Pointer, (2.0, 0.0)));
+ assert!(edits.update_drag(RegionInputSource::Pointer, (7.0, 0.0)));
+ assert_eq!(
+ edits.finish_drag(
+ RegionInputSource::Pointer,
+ (7.0, 0.0),
+ display(),
+ fingerprint(edits.source_rect)
+ ),
+ CutCommit::Applied
+ );
+ assert_eq!(edits.cuts.len(), 1);
+ assert!(edits.redo.is_empty());
+ assert_eq!(edits.revision, 1);
+ assert!(!edits.preview_is_current());
+ }
+
+ #[test]
+ fn full_axis_commit_is_rejected_without_a_revision_change() {
+ let mut edits = edits();
+ edits.toggle_mode();
+ assert!(edits.begin_drag(RegionInputSource::Pointer, (0.0, 0.0)));
+ assert!(edits.update_drag(RegionInputSource::Pointer, (8.0, 0.0)));
+ assert_eq!(
+ edits.finish_drag(
+ RegionInputSource::Pointer,
+ (8.0, 0.0),
+ display(),
+ fingerprint(edits.source_rect)
+ ),
+ CutCommit::RejectedFullAxis
+ );
+ assert!(edits.cuts.is_empty());
+ assert_eq!(edits.revision, 0);
+ }
+
+ #[test]
+ fn undo_redo_and_new_commit_clear_redo() {
+ let mut edits = edits();
+ let fingerprint = fingerprint(edits.source_rect);
+ edits
+ .cuts
+ .push(CutBand::new(CutAxis::Columns, 1, 2).unwrap());
+ edits.revision = 1;
+ assert!(edits.undo(fingerprint.clone()));
+ assert!(edits.cuts.is_empty());
+ assert_eq!(edits.redo.len(), 1);
+ assert!(edits.redo(fingerprint.clone()));
+ assert_eq!(edits.cuts.len(), 1);
+ assert!(edits.undo(fingerprint.clone()));
+ edits.toggle_mode();
+ assert!(edits.begin_drag(RegionInputSource::Pointer, (2.0, 0.0)));
+ assert!(edits.update_drag(RegionInputSource::Pointer, (7.0, 0.0)));
+ assert_eq!(
+ edits.finish_drag(
+ RegionInputSource::Pointer,
+ (7.0, 0.0),
+ display(),
+ fingerprint
+ ),
+ CutCommit::Applied
+ );
+ assert!(edits.redo.is_empty());
+ }
+
+ #[test]
+ fn undo_and_redo_abandon_an_in_flight_drag() {
+ let mut edits = edits();
+ let fingerprint = fingerprint(edits.source_rect);
+ edits
+ .cuts
+ .push(CutBand::new(CutAxis::Columns, 1, 2).unwrap());
+ edits.revision = 1;
+ edits.set_desired_from(fingerprint.clone());
+ let desired = edits.desired_preview.clone().unwrap();
+ edits.ready_preview = Some(RegionCutPreview {
+ key: desired,
+ pixels: std::sync::Arc::new(
+ crate::screen_pixels::PackedArgb32::new(7, 8, 28, vec![0; 28 * 8]).unwrap(),
+ ),
+ display: display(),
+ });
+ edits.toggle_mode();
+ assert!(edits.begin_drag(RegionInputSource::Pointer, (1.0, 1.0)));
+ assert!(edits.undo(fingerprint.clone()));
+ assert!(edits.drag.is_none());
+ assert_eq!(
+ edits.finish_drag(
+ RegionInputSource::Pointer,
+ (7.0, 0.0),
+ display(),
+ fingerprint.clone()
+ ),
+ CutCommit::None
+ );
+
+ assert!(edits.begin_drag(RegionInputSource::Pointer, (1.0, 1.0)));
+ assert!(edits.redo(fingerprint.clone()));
+ assert!(edits.drag.is_none());
+ assert_eq!(
+ edits.finish_drag(
+ RegionInputSource::Pointer,
+ (7.0, 0.0),
+ display(),
+ fingerprint
+ ),
+ CutCommit::None
+ );
+ }
+
+ #[test]
+ fn undo_with_nothing_to_undo_leaves_an_in_flight_drag() {
+ let mut edits = edits();
+ edits.toggle_mode();
+ assert!(edits.begin_drag(RegionInputSource::Pointer, (1.0, 1.0)));
+ assert!(!edits.undo(fingerprint(edits.source_rect)));
+ assert!(edits.drag.is_some());
+ }
+
+ fn review_input() -> crate::input::InputState {
+ let mut input = crate::input::state::test_support::make_test_input_state();
+ input.activate_region_review(
+ crate::input::state::RegionPurposeTag::CaptureInteractive,
+ 1,
+ display(),
+ );
+ input
+ }
+
+ fn edits_with_current_preview() -> RegionReviewEdits {
+ let mut edits = edits();
+ let fingerprint = fingerprint(edits.source_rect);
+ edits
+ .cuts
+ .push(CutBand::new(CutAxis::Columns, 1, 2).unwrap());
+ edits.revision = 1;
+ edits.set_desired_from(fingerprint);
+ let desired = edits.desired_preview.clone().unwrap();
+ edits.ready_preview = Some(RegionCutPreview {
+ key: desired,
+ pixels: std::sync::Arc::new(
+ crate::screen_pixels::PackedArgb32::new(7, 8, 28, vec![0; 28 * 8]).unwrap(),
+ ),
+ display: display(),
+ });
+ edits
+ }
+
+ #[test]
+ fn undo_and_redo_retire_pointer_touch_and_tablet_owners_before_release() {
+ for owner in [
+ RegionInputSource::Pointer,
+ RegionInputSource::Touch,
+ RegionInputSource::Stylus,
+ ] {
+ let mut input = review_input();
+ let mut edits = Some(edits_with_current_preview());
+ edits.as_mut().unwrap().toggle_mode();
+ assert!(edits.as_mut().unwrap().begin_drag(owner, (1.0, 1.0)));
+ assert!(input.begin_region_review_move(owner));
+ assert!(input.region_selection_is_owned_by(owner));
+
+ let fingerprint = fingerprint(edits.as_ref().unwrap().source_rect);
+ assert!(apply_cut_history_change(&mut edits, &mut input, |edits| {
+ edits.undo(fingerprint.clone())
+ }));
+ assert!(edits.as_ref().unwrap().drag.is_none());
+ assert!(
+ !input.region_selection_is_owned_by(owner),
+ "{owner:?} must be retired before release"
+ );
+ assert_eq!(
+ edits.as_mut().unwrap().finish_drag(
+ owner,
+ (7.0, 0.0),
+ display(),
+ fingerprint.clone()
+ ),
+ CutCommit::None,
+ "{owner:?} release must not commit after undo"
+ );
+ assert!(!input.finish_region_review_move(owner));
+
+ assert!(edits.as_mut().unwrap().begin_drag(owner, (1.0, 1.0)));
+ assert!(input.begin_region_review_move(owner));
+ assert!(apply_cut_history_change(&mut edits, &mut input, |edits| {
+ edits.redo(fingerprint.clone())
+ }));
+ assert!(edits.as_ref().unwrap().drag.is_none());
+ assert!(!input.region_selection_is_owned_by(owner));
+ assert_eq!(
+ edits
+ .as_mut()
+ .unwrap()
+ .finish_drag(owner, (7.0, 0.0), display(), fingerprint),
+ CutCommit::None
+ );
+ }
+ }
+
+ #[test]
+ fn toggling_cut_mode_off_during_a_drag_returns_the_owner() {
+ let mut edits = edits();
+ edits.toggle_mode();
+ assert!(edits.begin_drag(RegionInputSource::Pointer, (1.0, 1.0)));
+ assert_eq!(edits.toggle_mode(), Some(RegionInputSource::Pointer));
+ assert_eq!(edits.mode, CutMode::Idle);
+ assert!(edits.drag.is_none());
+ }
+
+ #[test]
+ fn revision_exhaustion_leaves_reset_and_invalidate_untouched() {
+ let mut edits = edits();
+ let cut = CutBand::new(CutAxis::Columns, 1, 2).unwrap();
+ edits.cuts.push(cut);
+ edits.revision = u64::MAX;
+ edits.failed_revision = Some(u64::MAX);
+ assert!(!edits.reset());
+ assert_eq!(edits.cuts, [cut]);
+ assert_eq!(edits.failed_revision, Some(u64::MAX));
+
+ let fingerprint = fingerprint(edits.source_rect);
+ edits.invalidate_base(fingerprint);
+ assert_eq!(edits.cuts, [cut]);
+ assert_eq!(edits.failed_revision, Some(u64::MAX));
+ assert_eq!(edits.revision, u64::MAX);
+ }
+
+ #[test]
+ fn undoing_the_last_cut_unlocks_the_crop() {
+ let mut edits = edits();
+ let fingerprint = fingerprint(edits.source_rect);
+ edits
+ .cuts
+ .push(CutBand::new(CutAxis::Columns, 1, 2).unwrap());
+ edits.revision = 1;
+ assert!(edits.crop_locked());
+ assert!(edits.undo(fingerprint));
+ assert!(!edits.crop_locked());
+ }
+
+ #[test]
+ fn a_failed_current_preview_stays_failed_until_the_revision_changes() {
+ let mut edits = edits();
+ let desired = CutPreviewKey {
+ fingerprint: fingerprint(edits.source_rect),
+ revision: 3,
+ cuts: vec![CutBand::new(CutAxis::Columns, 1, 2).unwrap()],
+ };
+ edits.cuts = desired.cuts.clone();
+ edits.revision = 3;
+ edits.desired_preview = Some(desired.clone());
+ assert!(edits.mark_preview_failed(&desired));
+ assert!(edits.current_preview_failed());
+ edits.failed_revision = None;
+ edits.revision = 4;
+ edits.desired_preview = Some(CutPreviewKey {
+ revision: 4,
+ ..desired
+ });
+ assert!(!edits.current_preview_failed());
+ }
+
+ #[test]
+ fn reset_clears_history_and_unlocks_the_crop() {
+ let mut edits = edits();
+ edits.cuts.push(CutBand::new(CutAxis::Rows, 1, 2).unwrap());
+ edits
+ .redo
+ .push(CutBand::new(CutAxis::Columns, 1, 2).unwrap());
+ edits.mode = CutMode::Armed;
+ assert!(edits.reset());
+ assert!(edits.cuts.is_empty());
+ assert!(edits.redo.is_empty());
+ assert!(!edits.crop_locked());
+ assert!(edits.preview_is_current());
+ }
+
+ #[test]
+ fn source_rect_cannot_change_while_cuts_exist() {
+ let mut edits = edits();
+ let next = ImagePixelRect::new(1, 1, 4, 4, (8, 8)).unwrap();
+ assert!(edits.set_source_rect(next));
+ edits
+ .cuts
+ .push(CutBand::new(CutAxis::Columns, 1, 2).unwrap());
+ assert!(!edits.set_source_rect(ImagePixelRect::new(0, 0, 4, 4, (8, 8)).unwrap()));
+ assert_eq!(edits.source_rect, next);
+ }
+
+ #[test]
+ fn cut_start_is_rejected_while_preview_is_pending() {
+ let mut edits = edits();
+ edits.toggle_mode();
+ edits
+ .cuts
+ .push(CutBand::new(CutAxis::Columns, 1, 2).unwrap());
+ edits.revision = 1;
+ edits.set_desired_from(fingerprint(edits.source_rect));
+ assert!(!edits.preview_is_current());
+ assert!(!edits.can_start_cut_drag());
+ }
+
+ #[test]
+ fn loupe_is_suppressed_when_armed_or_cuts_exist() {
+ let mut edits = edits();
+ assert!(!edits.loupe_suppressed());
+ edits.toggle_mode();
+ assert!(edits.loupe_suppressed());
+ edits.toggle_mode();
+ edits
+ .cuts
+ .push(CutBand::new(CutAxis::Columns, 1, 2).unwrap());
+ assert!(edits.loupe_suppressed());
+ }
+
+ #[test]
+ fn column_cut_preserves_top_left_and_height() {
+ let token = token();
+ let rect = ImagePixelRect::new(0, 0, 8, 8, (8, 8)).unwrap();
+ let full = output_display_for(&token, rect, &[]).unwrap();
+ let cut = output_display_for(
+ &token,
+ rect,
+ &[CutBand::new(CutAxis::Columns, 2, 4).unwrap()],
+ )
+ .unwrap();
+ assert_eq!(cut.start, full.start);
+ assert_eq!(cut.end.1, full.end.1);
+ assert!(cut.end.0 < full.end.0);
+ }
+
+ #[test]
+ fn dominant_axis_ties_choose_columns() {
+ assert_eq!(dominant_cut_axis(4.0, 4.0), CutAxis::Columns);
+ assert_eq!(dominant_cut_axis(4.0, 5.0), CutAxis::Rows);
+ }
+
+ #[test]
+ fn pointer_edges_map_to_the_inclusive_pixel_edge_domain() {
+ let display = display();
+ assert_eq!(
+ logical_to_output_point(display, (8, 8), (0.0, 0.0)).map(|point| (point.x, point.y)),
+ Some((0.0, 0.0))
+ );
+ assert_eq!(
+ logical_to_output_point(display, (8, 8), (8.0, 8.0)).map(|point| (point.x, point.y)),
+ Some((8.0, 8.0))
+ );
+ let clamped = logical_to_output_point(display, (8, 8), (-2.0, 20.0)).unwrap();
+ assert_eq!((clamped.x, clamped.y), (0.0, 8.0));
+ }
+
+ #[test]
+ fn composed_board_origin_stays_put_while_size_contracts() {
+ let source = crate::canvas_export::CanvasExportRect::new(10.0, 20.0, 80.0, 40.0).unwrap();
+ let composed =
+ crate::backend::wayland::state::region_capture::world_rect_for_composed_region(
+ source,
+ (8, 8),
+ (6, 4),
+ )
+ .unwrap();
+ assert_eq!(composed.x, 10.0);
+ assert_eq!(composed.y, 20.0);
+ assert_eq!(composed.width, 60.0);
+ assert_eq!(composed.height, 20.0);
+ }
+}
diff --git a/src/backend/wayland/state/region_capture/delivery.rs b/src/backend/wayland/state/region_capture/delivery.rs
index 1b832f6f..df793c9d 100644
--- a/src/backend/wayland/state/region_capture/delivery.rs
+++ b/src/backend/wayland/state/region_capture/delivery.rs
@@ -1,17 +1,13 @@
-use crate::canvas_export::{CanvasExportRect, CanvasRegionExportSnapshot, CanvasRegionSource};
use crate::capture::{
CaptureDestination, ImageFormatMetadata, ImageOperationKind, RenderImageRequest,
- RenderedImageDeliveryRequest,
+ RenderedImageDeliveryRequest, output_size as band_cut_output_size,
};
use crate::input::state::{BoardPasteTarget, RegionPurposeTag, Toast, ToastPriority};
use crate::screen_pixels::{EmbeddedImageLimits, ImagePixelRect};
use crate::ui::RegionAction;
use super::super::capture::should_exit_after_capture;
-use super::super::screen_image::{
- CropError, copy_image_rect, displayed_screen_image, screen_source_is,
- shared_displayed_screen_image,
-};
+use super::render::{RegionRenderRequest, region_render_job};
use super::{ActiveScreenRegion, FreezeOwnership, RegionCaptureIntent, RegionSelectionFinalize};
use crate::backend::wayland::state::WaylandState;
@@ -38,35 +34,6 @@ pub(super) fn region_delivery_request(
}
}
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub(super) enum RegionSubmit {
- Deliver(CaptureDestination),
- Board(BoardPasteTarget),
-}
-
-/// What a submission renders from. One or the other, never both and never
-/// neither, so the render job cannot be handed an impossible pair.
-#[derive(Debug)]
-pub(super) enum RegionRenderSource {
- /// Committed board drawings composited over the crop, on the worker.
- Annotated(Box),
- /// The checked crop taken on the event loop.
- Raw(crate::screen_pixels::PackedArgb32),
-}
-
-/// The PNG job for a submission. Shared by every destination, so what Copy
-/// writes and what Board pastes can only differ by the Review toggle.
-pub(super) fn region_render_job(source: RegionRenderSource) -> crate::capture::ImageRenderJob {
- match source {
- RegionRenderSource::Annotated(snapshot) => {
- Box::new(move || crate::canvas_export::render_canvas_region_png(*snapshot))
- }
- RegionRenderSource::Raw(pixels) => {
- Box::new(move || crate::capture::png::encode_packed_argb32_png(&pixels))
- }
- }
-}
-
pub(super) const fn review_delivery_destination(
action: RegionAction,
) -> Option {
@@ -74,10 +41,21 @@ pub(super) const fn review_delivery_destination(
RegionAction::Copy => Some(CaptureDestination::ClipboardOnly),
RegionAction::Save => Some(CaptureDestination::FileOnly),
RegionAction::Both => Some(CaptureDestination::ClipboardAndFile),
- RegionAction::Board | RegionAction::ToggleIncludeDrawings => None,
+ RegionAction::Board
+ | RegionAction::CutBand
+ | RegionAction::UndoCut
+ | RegionAction::RedoCut
+ | RegionAction::ResetCuts
+ | RegionAction::ToggleIncludeDrawings => None,
}
}
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(super) enum RegionSubmit {
+ Deliver(CaptureDestination),
+ Board(BoardPasteTarget),
+}
+
impl WaylandState {
pub(in crate::backend::wayland) fn region_review_action_at(
&self,
@@ -88,7 +66,7 @@ impl WaylandState {
}
let selection = self.region_selection_geometry()?.display_selection();
crate::ui::RegionActionBar::place(selection, (self.surface.width(), self.surface.height()))
- .hit(point)
+ .enabled_hit(point, self.region_cut_availability())
}
/// Whether the pointer sits over the reviewed rectangle — the area a press
@@ -131,18 +109,35 @@ impl WaylandState {
&mut self,
action: RegionAction,
) -> bool {
- if action == RegionAction::ToggleIncludeDrawings {
- return self.toggle_region_picker_include_drawings();
+ if !action.is_terminal() {
+ return self.apply_region_review_edit(action);
+ }
+ if !self.region_cut_availability().allows(action) {
+ return true;
}
let Some(rect) = self.region_review_rect() else {
return false;
};
+ let cuts = self
+ .region_review_edits()
+ .map(|edits| edits.cuts.clone())
+ .unwrap_or_default();
+ if !self.preview_permits_submit(&cuts) {
+ return true;
+ }
+ let Ok(output) = band_cut_output_size((rect.width(), rect.height()), &cuts) else {
+ self.input_state.push_toast(
+ ToastPriority::Critical,
+ TOAST_SOURCE,
+ Toast::error("Could not apply the requested cut."),
+ );
+ return true;
+ };
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()) {
+ None => match self.board_submit_for_composed(rect, output) {
+ Ok(target) => RegionSubmit::Board(target),
+ Err(BoardSubmitError::TooLarge) => {
self.input_state.push_toast(
ToastPriority::Info,
TOAST_SOURCE,
@@ -150,28 +145,7 @@ impl WaylandState {
);
return true;
}
- // Placement maps the authoritative image rectangle, not the
- // picker's outward-rounded display rectangle: composition uses
- // the same exact world rectangle, so the pasted image lands
- // where the annotations were drawn instead of stretched by a
- // quantization the chrome introduced.
- //
- // A missing source or a rectangle that will not map is a dead
- // end for this capture, not a no-op: falling through silently
- // would leave Review painted over a reservation nothing can
- // ever complete, so it retires through the usual funnel.
- let placement = match self.data.active_screen_region {
- Some(ActiveScreenRegion::Ready { source, .. }) => {
- super::world_rect_for_image_rect_exact(
- rect,
- self.board_view_offset(),
- source,
- )
- .and_then(super::board_bounds_for_world_rect)
- }
- _ => None,
- };
- let Some(world_bounds) = placement else {
+ Err(BoardSubmitError::Unplaceable) => {
self.cancel_region_capture();
self.input_state.push_toast(
ToastPriority::Critical,
@@ -179,19 +153,57 @@ impl WaylandState {
Toast::error("Could not place that region on the board."),
);
return true;
- };
- RegionSubmit::Board(BoardPasteTarget {
- board_id: self.input_state.boards.active_board_id().to_string(),
- page_index: self.input_state.boards.active_page_index(),
- page_generation: self.input_state.boards.active_page_generation(),
- world_bounds,
- })
- }
+ }
+ },
};
- self.submit_region_capture_with(rect, submit);
+ self.submit_region_capture_with(rect, cuts, submit);
true
}
+ fn preview_permits_submit(&self, cuts: &[crate::capture::CutBand]) -> bool {
+ cuts.is_empty()
+ || self
+ .region_review_edits()
+ .is_some_and(super::cut_review::RegionReviewEdits::preview_is_current)
+ }
+
+ fn board_submit_for_composed(
+ &self,
+ rect: ImagePixelRect,
+ output: (u32, u32),
+ ) -> Result {
+ let limits = EmbeddedImageLimits::default();
+ if !limits.allows_pixels(output.0, output.1) {
+ return Err(BoardSubmitError::TooLarge);
+ }
+ let Some(ActiveScreenRegion::Ready { source, .. }) = self.data.active_screen_region else {
+ return Err(BoardSubmitError::Unplaceable);
+ };
+ let Some(source_world) =
+ super::world_rect_for_image_rect_exact(rect, self.board_view_offset(), source)
+ else {
+ return Err(BoardSubmitError::Unplaceable);
+ };
+ let composed = if output == (rect.width(), rect.height()) {
+ source_world
+ } else {
+ super::world_rect_for_composed_region(
+ source_world,
+ (rect.width(), rect.height()),
+ output,
+ )
+ .ok_or(BoardSubmitError::Unplaceable)?
+ };
+ let world_bounds =
+ super::board_bounds_for_world_rect(composed).ok_or(BoardSubmitError::Unplaceable)?;
+ Ok(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,
+ })
+ }
+
/// Submit the whole displayed image and retire any in-flight drag owner.
///
/// Keeping this transaction on WaylandState leaves the keyboard protocol
@@ -202,6 +214,12 @@ impl WaylandState {
else {
return;
};
+ if purpose == RegionPurposeTag::CaptureInteractive
+ && self.input_state.region_state().is_review()
+ && self.region_review_crop_locked()
+ {
+ return;
+ }
self.clear_region_window_snap();
self.retire_region_selection_owner(self.input_state.region_state().selection_owner());
match purpose {
@@ -222,12 +240,16 @@ impl WaylandState {
}
_ => return,
};
- self.submit_region_capture_with(rect, RegionSubmit::Deliver(destination));
+ self.submit_region_capture_with(rect, Vec::new(), RegionSubmit::Deliver(destination));
}
- fn submit_region_capture_with(&mut self, rect: ImagePixelRect, submit: RegionSubmit) {
+ fn submit_region_capture_with(
+ &mut self,
+ rect: ImagePixelRect,
+ cuts: Vec,
+ submit: RegionSubmit,
+ ) {
let Some(ActiveScreenRegion::Ready {
- source: token,
freeze_ownership,
purpose,
include_drawings,
@@ -240,102 +262,41 @@ impl WaylandState {
if !purpose.is_capture() {
return;
}
- let Some(source) = displayed_screen_image(
- &self.zoom,
- &self.frozen,
- self.input_state.board_is_transparent(),
- ) else {
- self.cancel_region_capture_for_source_change();
- return;
- };
- if !screen_source_is(
- &token,
- &source,
- &self.zoom,
- &self.frozen,
- (self.surface.width(), self.surface.height()),
- ) {
- self.cancel_region_capture_for_source_change();
- return;
- }
- // Every destination honours the Review toggle, Board included. Pasting a
- // composited crop onto the board it came from bakes a second, flattened
- // copy of those annotations over the live shapes; the toggle is the
- // control for that, so it is not overridden per destination here.
- //
- // The composed path validates and crops on the capture worker. The raw
- // path retains the existing checked event-loop crop, but never pays
- // that copy when the result would be discarded by composition.
- let raw_pixels = if include_drawings {
- None
- } else {
- match copy_image_rect(source.image, rect) {
- Ok(pixels) => Some(pixels),
- Err(error) => {
- let message = match error {
- CropError::Empty => "That selection has no screen pixels.",
- CropError::OutOfBounds => "Could not read that region of the screen image.",
- };
- self.cancel_region_capture();
- self.input_state.push_toast(
- ToastPriority::Critical,
- TOAST_SOURCE,
- Toast::error(message),
- );
- return;
- }
+ let snapshot = match self.snapshot_region_render(rect, include_drawings) {
+ Ok(snapshot) => snapshot,
+ Err(crate::capture::CaptureError::Cancelled(_)) => {
+ self.cancel_region_capture_for_source_change();
+ 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(),
- text_halo_enabled: self.config.drawing.text_halo_enabled,
- spotlight: crate::canvas_export::SpotlightPassSnapshot {
- dim_opacity: self.input_state.spotlight_dim_opacity,
- feather: self.input_state.spotlight_feather,
- },
- })
- })
- .flatten();
- let render_source = match (drawing_snapshot, raw_pixels) {
- (Some(snapshot), _) => RegionRenderSource::Annotated(Box::new(snapshot)),
- (None, Some(pixels)) => RegionRenderSource::Raw(pixels),
- (None, None) => {
- // The composed path skips the event-loop crop, so a snapshot
- // that could not be built leaves nothing at all to render.
+ Err(error) => {
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."),
+ Toast::error(error.to_string()),
);
return;
}
};
+ if !cuts.is_empty() {
+ let fingerprint_ok = self.region_review_edits().is_some_and(|edits| {
+ edits.ready_preview.as_ref().is_some_and(|preview| {
+ preview.key.fingerprint == snapshot.fingerprint && preview.key.cuts == cuts
+ })
+ });
+ if !fingerprint_ok {
+ if let Some(edits) = self.region_review_edits_mut() {
+ edits.invalidate_base(snapshot.fingerprint);
+ }
+ self.schedule_region_cut_preview();
+ self.mark_region_cut_ui_dirty();
+ return;
+ }
+ }
+ let request = RegionRenderRequest {
+ source: snapshot.source,
+ cuts,
+ };
let Some(intent) = self.capture.begin_region_submission() else {
self.cancel_region_capture();
@@ -351,7 +312,7 @@ impl WaylandState {
self.release_owned_frozen_generation(image_generation);
}
- let render = region_render_job(render_source);
+ let render = region_render_job(request);
match submit {
RegionSubmit::Deliver(destination) => {
self.capture.set_exit_on_success(should_exit_after_capture(
@@ -393,3 +354,8 @@ impl WaylandState {
}
}
}
+
+enum BoardSubmitError {
+ TooLarge,
+ Unplaceable,
+}
diff --git a/src/backend/wayland/state/region_capture/events.rs b/src/backend/wayland/state/region_capture/events.rs
index a18143fe..698c37fc 100644
--- a/src/backend/wayland/state/region_capture/events.rs
+++ b/src/backend/wayland/state/region_capture/events.rs
@@ -246,3 +246,21 @@ pub(in crate::backend::wayland::state) fn finalize_region_selection_event(
}
RegionSelectionFinalize::Selected { purpose, rect }
}
+
+pub(in crate::backend::wayland::state) fn finalize_region_selection_with_review_edits(
+ backend: &mut Option,
+ input_state: &mut crate::input::InputState,
+ review_edits: &mut Option,
+ owner: RegionInputSource,
+ logical: (f64, f64),
+) -> RegionSelectionFinalize {
+ let was_review = input_state.region_state().is_review();
+ let result = finalize_region_selection_event(backend, input_state, owner, logical);
+ if result == RegionSelectionFinalize::Reviewed
+ && (!was_review || review_edits.is_none())
+ && let Some(rect) = backend.and_then(ActiveScreenRegion::selection_rect)
+ {
+ *review_edits = super::cut_review::review_edits_for_active_region(*backend, rect);
+ }
+ result
+}
diff --git a/src/backend/wayland/state/region_capture/render.rs b/src/backend/wayland/state/region_capture/render.rs
new file mode 100644
index 00000000..79033791
--- /dev/null
+++ b/src/backend/wayland/state/region_capture/render.rs
@@ -0,0 +1,229 @@
+use std::sync::Arc;
+
+use crate::canvas_export::{CanvasRegionExportSnapshot, render_canvas_region_pixels};
+use crate::capture::{CaptureError, CutBand, apply_band_cuts, output_size as band_cut_output_size};
+use crate::screen_pixels::{ImagePixelRect, PackedArgb32, ScreenImage};
+
+/// Immutable pixels a region render job may flatten, then cut.
+#[derive(Debug, Clone)]
+pub(super) enum RegionPixelSource {
+ Raw {
+ image: Arc,
+ selection: ImagePixelRect,
+ },
+ Annotated(Box),
+}
+
+#[derive(Debug, Clone)]
+pub(super) struct RegionRenderRequest {
+ pub source: RegionPixelSource,
+ pub cuts: Vec,
+}
+
+impl RegionPixelSource {
+ pub(super) fn selection(&self) -> ImagePixelRect {
+ match self {
+ Self::Raw { selection, .. } => *selection,
+ Self::Annotated(snapshot) => snapshot.selection,
+ }
+ }
+}
+
+impl RegionRenderRequest {
+ pub(super) fn output_size(&self) -> Result<(u32, u32), CaptureError> {
+ let selection = self.source.selection();
+ band_cut_output_size((selection.width(), selection.height()), &self.cuts)
+ .map_err(band_cut_error)
+ }
+}
+
+pub(super) fn render_region_base_pixels(
+ source: RegionPixelSource,
+) -> Result {
+ match source {
+ RegionPixelSource::Raw { image, selection } => {
+ image.copy_rect(selection).ok_or_else(|| {
+ CaptureError::ImageError("Could not copy the selected screen pixels.".to_string())
+ })
+ }
+ RegionPixelSource::Annotated(snapshot) => render_canvas_region_pixels(*snapshot),
+ }
+}
+
+pub(super) fn compose_region_pixels(
+ base: PackedArgb32,
+ cuts: &[CutBand],
+) -> Result {
+ if cuts.is_empty() {
+ return Ok(base);
+ }
+ apply_band_cuts(&base, cuts).map_err(band_cut_error)
+}
+
+pub(super) fn compose_shared_region_pixels(
+ base: &Arc,
+ cuts: &[CutBand],
+) -> Result, CaptureError> {
+ if cuts.is_empty() {
+ return Ok(Arc::clone(base));
+ }
+ apply_band_cuts(base.as_ref(), cuts)
+ .map(Arc::new)
+ .map_err(band_cut_error)
+}
+
+pub(super) fn render_region_pixels(
+ request: RegionRenderRequest,
+) -> Result {
+ let _ = request.output_size()?;
+ compose_region_pixels(render_region_base_pixels(request.source)?, &request.cuts)
+}
+
+/// The PNG job for a submission. Shared by every destination, so what Copy
+/// writes and what Board pastes can only differ by the Review toggle and cuts.
+pub(super) fn region_render_job(request: RegionRenderRequest) -> crate::capture::ImageRenderJob {
+ Box::new(move || crate::capture::png::encode_packed_argb32_png(&render_region_pixels(request)?))
+}
+
+fn band_cut_error(error: crate::capture::BandCutError) -> CaptureError {
+ CaptureError::ImageError(format!("Could not apply the requested cut: {error}"))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::canvas_export::{CanvasExportRect, CanvasRegionSource, SpotlightPassSnapshot};
+ use crate::capture::CutAxis;
+ use crate::draw::{Frame, RED, Shape};
+
+ fn swatch() -> ([u32; 6], Vec) {
+ let ids = [
+ 0xFF11_2233,
+ 0xFF44_5566,
+ 0xFF77_8899,
+ 0xFFAA_BBCC,
+ 0xFFDD_EEFF,
+ 0xFF01_0203,
+ ];
+ let bytes = ids
+ .iter()
+ .copied()
+ .flat_map(u32::to_ne_bytes)
+ .collect::>();
+ (ids, bytes)
+ }
+
+ fn decode_png_pixels(bytes: &[u8]) -> Vec {
+ let png = bytes.to_vec();
+ let mut surface = cairo::ImageSurface::create_from_png(&mut png.as_slice()).expect("png");
+ surface.flush();
+ let width = surface.width() as usize;
+ let height = surface.height() as usize;
+ let stride = surface.stride() as usize;
+ let data = surface.data().expect("pixels");
+ let mut pixels = Vec::with_capacity(width * height);
+ for y in 0..height {
+ for x in 0..width {
+ let offset = y * stride + x * 4;
+ pixels.push(u32::from_ne_bytes(
+ data[offset..offset + 4].try_into().expect("pixel"),
+ ));
+ }
+ }
+ pixels
+ }
+
+ #[test]
+ fn raw_and_annotated_jobs_share_the_cut_compose_path() {
+ let (ids, bytes) = swatch();
+ let image = Arc::new(ScreenImage {
+ data: bytes.clone(),
+ width: 3,
+ height: 2,
+ stride: 12,
+ });
+ let selection = ImagePixelRect::new(0, 0, 3, 2, (3, 2)).unwrap();
+ let cut = CutBand::new(CutAxis::Columns, 1, 2).unwrap();
+ let rendered = region_render_job(RegionRenderRequest {
+ source: RegionPixelSource::Raw {
+ image: Arc::clone(&image),
+ selection,
+ },
+ cuts: vec![cut],
+ })()
+ .expect("raw cut");
+ assert_eq!((rendered.width, rendered.height), (2, 2));
+ assert_eq!(
+ decode_png_pixels(&rendered.bytes),
+ vec![ids[0], ids[2], ids[3], ids[5]]
+ );
+
+ let mut frame = Frame::new();
+ frame.add_shape(Shape::Rect {
+ x: 10,
+ y: 20,
+ w: 3,
+ h: 2,
+ fill: true,
+ color: RED,
+ thick: 1.0,
+ });
+ let composed = region_render_job(RegionRenderRequest {
+ source: RegionPixelSource::Annotated(Box::new(CanvasRegionExportSnapshot {
+ source: CanvasRegionSource {
+ image,
+ logical_bounds: CanvasExportRect::new(10.0, 20.0, 3.0, 2.0).unwrap(),
+ },
+ selection,
+ frame,
+ text_halo_enabled: true,
+ spotlight: SpotlightPassSnapshot {
+ dim_opacity: 0.0,
+ feather: 0.0,
+ },
+ })),
+ cuts: vec![cut],
+ })()
+ .expect("annotated cut");
+ assert_eq!((composed.width, composed.height), (2, 2));
+ assert_ne!(composed.bytes, rendered.bytes);
+ }
+
+ #[test]
+ fn empty_cuts_keep_the_source_crop() {
+ let (_, bytes) = swatch();
+ let request = RegionRenderRequest {
+ source: RegionPixelSource::Raw {
+ image: Arc::new(ScreenImage {
+ data: bytes,
+ width: 3,
+ height: 2,
+ stride: 12,
+ }),
+ selection: ImagePixelRect::new(0, 0, 3, 2, (3, 2)).unwrap(),
+ },
+ cuts: Vec::new(),
+ };
+ let rendered = region_render_job(request)().expect("raw");
+ assert_eq!((rendered.width, rendered.height), (3, 2));
+ }
+
+ #[test]
+ fn empty_cuts_return_the_owned_base_without_copying() {
+ let (_, bytes) = swatch();
+ let base = render_region_base_pixels(RegionPixelSource::Raw {
+ image: Arc::new(ScreenImage {
+ data: bytes,
+ width: 3,
+ height: 2,
+ stride: 12,
+ }),
+ selection: ImagePixelRect::new(0, 0, 3, 2, (3, 2)).unwrap(),
+ })
+ .expect("base");
+ let ptr = base.data().as_ptr();
+ let composed = compose_region_pixels(base, &[]).expect("empty cuts");
+ assert_eq!(composed.data().as_ptr(), ptr);
+ assert_eq!((composed.width(), composed.height()), (3, 2));
+ }
+}
diff --git a/src/backend/wayland/state/region_capture/runtime.rs b/src/backend/wayland/state/region_capture/runtime.rs
index 1efa9f89..19b949a7 100644
--- a/src/backend/wayland/state/region_capture/runtime.rs
+++ b/src/backend/wayland/state/region_capture/runtime.rs
@@ -82,8 +82,18 @@ impl WaylandState {
return false;
};
log::debug!("region picker include drawings: {checked}");
- self.input_state.dirty_tracker.mark_full();
- self.input_state.needs_redraw = true;
+ if self
+ .region_review_edits()
+ .is_some_and(|edits| !edits.cuts.is_empty())
+ {
+ if let Some(fingerprint) = self.current_region_fingerprint()
+ && let Some(edits) = self.region_review_edits_mut()
+ {
+ edits.invalidate_base(fingerprint);
+ }
+ self.schedule_region_cut_preview();
+ }
+ self.mark_region_cut_ui_dirty();
true
}
@@ -162,6 +172,7 @@ impl WaylandState {
pub(in crate::backend::wayland::state) fn clear_screen_region_ui_only(&mut self) {
self.clear_region_window_snap();
+ self.data.region_review_edits = None;
self.data.active_screen_region = None;
self.input_state.cancel_region_ui_only();
self.debug_assert_screen_region_invariant();
@@ -195,12 +206,18 @@ impl WaylandState {
if self.update_region_window_hover((x, y)) {
return;
}
+ if self.update_region_cut_drag(owner, (x, y)) {
+ return;
+ }
update_region_selection_event(
&mut self.data.active_screen_region,
&mut self.input_state,
owner,
(x, y),
);
+ if self.input_state.region_state().is_review() {
+ self.sync_region_review_source_rect();
+ }
}
pub(in crate::backend::wayland) fn region_selection_geometry(
@@ -227,6 +244,17 @@ impl WaylandState {
self.data
.active_screen_region
.and_then(ActiveScreenRegion::selection_geometry)
+ .and_then(|geometry| {
+ if !self.input_state.region_state().is_review() {
+ return Some(geometry);
+ }
+ let Some(display) = self.region_cut_displayed_selection() else {
+ return Some(geometry);
+ };
+ let purpose = self.data.active_screen_region?.purpose();
+ let rect = geometry.image_rect()?;
+ Some(RegionSelectionGeometry::review(purpose, rect, display))
+ })
}
pub(in crate::backend::wayland) fn region_measure_selection(&self) -> Option {
@@ -275,6 +303,7 @@ impl WaylandState {
};
self.input_state
.activate_region_review(purpose, generation, display);
+ self.create_region_review_edits(rect);
self.debug_assert_screen_region_invariant();
true
}
@@ -288,6 +317,9 @@ impl WaylandState {
if !self.input_state.region_state().is_review() {
return false;
}
+ if self.region_review_crop_locked() {
+ return true;
+ }
let Some(display) = self
.data
.active_screen_region
@@ -297,6 +329,7 @@ impl WaylandState {
return false;
};
self.input_state.update_region_review_display(display);
+ self.sync_region_review_source_rect();
true
}
@@ -321,6 +354,9 @@ impl WaylandState {
if !self.input_state.region_state().is_review() {
return None;
}
+ if self.region_review_crop_locked() || self.region_cut_mode_armed() {
+ return None;
+ }
self.data
.active_screen_region
.as_ref()
@@ -346,6 +382,16 @@ impl WaylandState {
height: rect.height(),
});
}
+ if self.input_state.region_state().is_review()
+ && let Some(size) = self
+ .region_review_edits()
+ .and_then(RegionReviewEdits::displayed_output_size)
+ {
+ return Some(RegionPickerMeasurement::Size {
+ width: size.0,
+ height: size.1,
+ });
+ }
self.data
.active_screen_region
.and_then(|region| region.picker_measurement(pointer))
@@ -368,6 +414,9 @@ impl WaylandState {
&mut self,
source: RegionInputSource,
) -> RegionOwnerLoss {
+ if self.abandon_region_cut_drag(source) {
+ return RegionOwnerLoss::Rearmed;
+ }
region_owner_lost_event(
&mut self.data.active_screen_region,
&mut self.input_state,
diff --git a/src/backend/wayland/state/region_capture/tests/capture_selection.rs b/src/backend/wayland/state/region_capture/tests/capture_selection.rs
index c3e9a43c..61ee5d4f 100644
--- a/src/backend/wayland/state/region_capture/tests/capture_selection.rs
+++ b/src/backend/wayland/state/region_capture/tests/capture_selection.rs
@@ -1,5 +1,8 @@
use super::lifecycle_and_measure::ocr_region;
use super::*;
+use crate::backend::wayland::state::region_capture::cut_review::{
+ CutCommit, CutMode, RegionRenderFingerprint,
+};
pub(super) fn capture_region() -> ActiveScreenRegion {
capture_region_at_scale(1.0)
@@ -84,9 +87,10 @@ fn capture_press_snaps_its_anchor_and_starts_with_a_zero_pixel_span() {
}
#[test]
-fn interactive_release_enters_integer_authoritative_review() {
+fn interactive_release_enters_review_ready_for_the_first_cut_drag() {
let mut backend = Some(interactive_region());
let mut input = make_test_input_state();
+ let mut review_edits = None;
input.activate_region(RegionPurposeTag::CaptureInteractive, 1);
assert!(begin_region_selection_event(
@@ -96,9 +100,10 @@ fn interactive_release_enters_integer_authoritative_review() {
(10.2, 20.7),
));
assert_eq!(
- finalize_region_selection_event(
+ finalize_region_selection_with_review_edits(
&mut backend,
&mut input,
+ &mut review_edits,
RegionInputSource::Pointer,
(30.1, 42.2),
),
@@ -118,4 +123,88 @@ fn interactive_release_enters_integer_authoritative_review() {
.and_then(ActiveScreenRegion::selection_geometry)
.map(|geometry| geometry.display_selection())
);
+
+ let display = input
+ .region_state()
+ .selection()
+ .expect("Review displays the selected capture");
+ let edits = review_edits
+ .as_mut()
+ .expect("normal interactive Review initializes cut state");
+ assert_eq!(edits.mode, CutMode::Idle);
+ assert_eq!(edits.toggle_mode(), None);
+ assert!(edits.begin_drag(RegionInputSource::Pointer, (12.0, 25.0)));
+ assert!(edits.update_drag(RegionInputSource::Pointer, (20.0, 25.0)));
+ let fingerprint = RegionRenderFingerprint::Raw {
+ correlation: edits.correlation.clone(),
+ source_rect: edits.source_rect,
+ };
+ assert_eq!(
+ edits.finish_drag(
+ RegionInputSource::Pointer,
+ (20.0, 25.0),
+ display,
+ fingerprint,
+ ),
+ CutCommit::Applied
+ );
+ assert_eq!(edits.cuts.len(), 1);
+}
+
+#[test]
+fn reselecting_before_a_cut_replaces_the_review_edit_geometry() {
+ let mut backend = Some(interactive_region());
+ let mut input = make_test_input_state();
+ let mut review_edits = None;
+ input.activate_region(RegionPurposeTag::CaptureInteractive, 1);
+
+ assert!(begin_region_selection_event(
+ &mut backend,
+ &mut input,
+ RegionInputSource::Pointer,
+ (10.0, 10.0),
+ ));
+ assert_eq!(
+ finalize_region_selection_with_review_edits(
+ &mut backend,
+ &mut input,
+ &mut review_edits,
+ RegionInputSource::Pointer,
+ (30.0, 30.0),
+ ),
+ RegionSelectionFinalize::Reviewed
+ );
+ let first_rect = review_edits
+ .as_ref()
+ .expect("first Review initializes edits")
+ .source_rect;
+
+ assert!(begin_region_selection_event(
+ &mut backend,
+ &mut input,
+ RegionInputSource::Pointer,
+ (50.0, 50.0),
+ ));
+ assert!(!input.region_state().is_review());
+ assert_eq!(
+ finalize_region_selection_with_review_edits(
+ &mut backend,
+ &mut input,
+ &mut review_edits,
+ RegionInputSource::Pointer,
+ (80.0, 90.0),
+ ),
+ RegionSelectionFinalize::Reviewed
+ );
+ let second_rect = backend
+ .and_then(ActiveScreenRegion::selection_rect)
+ .expect("replacement Review has geometry");
+ assert_ne!(second_rect, first_rect);
+ assert_eq!(
+ review_edits
+ .as_ref()
+ .expect("replacement Review refreshes edits")
+ .source_rect,
+ second_rect
+ );
}
diff --git a/src/backend/wayland/state/region_capture/tests/picker.rs b/src/backend/wayland/state/region_capture/tests/picker.rs
index 381a41d8..bdb872b8 100644
--- a/src/backend/wayland/state/region_capture/tests/picker.rs
+++ b/src/backend/wayland/state/region_capture/tests/picker.rs
@@ -1,11 +1,14 @@
use super::*;
use crate::backend::ExitAfterCaptureMode;
use crate::backend::wayland::state::region_capture::delivery::{
- RegionRenderSource, region_delivery_request, region_render_job, review_delivery_destination,
+ 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::backend::wayland::state::region_capture::render::{
+ RegionPixelSource, RegionRenderRequest, region_render_job,
+};
use crate::canvas_export::{
CanvasExportRect, CanvasRegionExportSnapshot, CanvasRegionSource, SpotlightPassSnapshot,
};
@@ -14,7 +17,7 @@ use crate::capture::{
};
use crate::config::{Action, RegionPicker};
use crate::input::state::RegionPurposeTag;
-use crate::screen_pixels::PackedArgb32;
+use crate::screen_pixels::{ImagePixelRect, PackedArgb32};
use crate::ui::RegionAction;
#[test]
@@ -66,6 +69,10 @@ fn review_destination_labels_match_the_delivery_they_request() {
review_delivery_destination(RegionAction::ToggleIncludeDrawings),
None
);
+ assert_eq!(review_delivery_destination(RegionAction::CutBand), None);
+ assert_eq!(review_delivery_destination(RegionAction::UndoCut), None);
+ assert_eq!(review_delivery_destination(RegionAction::RedoCut), None);
+ assert_eq!(review_delivery_destination(RegionAction::ResetCuts), None);
}
#[test]
@@ -88,9 +95,20 @@ fn the_render_job_composes_drawings_when_asked_and_stays_raw_otherwise() {
.iter()
.flat_map(|pixel| pixel.to_ne_bytes())
.collect();
- let raw = PackedArgb32::new(3, 2, 12, bytes.clone()).expect("a 3x2 crop");
- let rendered = region_render_job(RegionRenderSource::Raw(raw))().expect("raw render");
+ let rendered = region_render_job(RegionRenderRequest {
+ source: RegionPixelSource::Raw {
+ image: std::sync::Arc::new(crate::screen_pixels::ScreenImage {
+ data: bytes.clone(),
+ width: 3,
+ height: 2,
+ stride: 12,
+ }),
+ selection: ImagePixelRect::new(0, 0, 3, 2, (3, 2)).expect("selection"),
+ },
+ cuts: Vec::new(),
+ })()
+ .expect("raw render");
let direct = crate::capture::png::encode_packed_argb32_png(
&PackedArgb32::new(3, 2, 12, bytes).expect("a 3x2 crop"),
)
@@ -141,8 +159,11 @@ fn the_render_job_composes_drawings_when_asked_and_stays_raw_otherwise() {
feather: 0.0,
},
};
- let composed =
- region_render_job(RegionRenderSource::Annotated(Box::new(snapshot)))().expect("composed");
+ let composed = region_render_job(RegionRenderRequest {
+ source: RegionPixelSource::Annotated(Box::new(snapshot)),
+ cuts: Vec::new(),
+ })()
+ .expect("composed");
assert_eq!(
(composed.width, composed.height),
(3, 2),
@@ -155,6 +176,41 @@ fn the_render_job_composes_drawings_when_asked_and_stays_raw_otherwise() {
);
}
+#[test]
+fn the_render_job_cuts_after_flattening_and_keeps_composed_bytes() {
+ let swatch: [u32; 6] = [
+ 0xFF11_2233,
+ 0xFF44_5566,
+ 0xFF77_8899,
+ 0xFFAA_BBCC,
+ 0xFFDD_EEFF,
+ 0xFF01_0203,
+ ];
+ let bytes: Vec = swatch
+ .iter()
+ .flat_map(|pixel| pixel.to_ne_bytes())
+ .collect();
+ let cut = crate::capture::CutBand::new(crate::capture::CutAxis::Columns, 1, 2).unwrap();
+ let request = RegionRenderRequest {
+ source: RegionPixelSource::Raw {
+ image: std::sync::Arc::new(crate::screen_pixels::ScreenImage {
+ data: bytes,
+ width: 3,
+ height: 2,
+ stride: 12,
+ }),
+ selection: ImagePixelRect::new(0, 0, 3, 2, (3, 2)).expect("selection"),
+ },
+ cuts: vec![cut],
+ };
+ let rendered = region_render_job(request)().expect("cut render");
+ assert_eq!((rendered.width, rendered.height), (2, 2));
+ assert_eq!(
+ decode_png_pixels(&rendered.bytes),
+ vec![swatch[0], swatch[2], swatch[3], swatch[5]]
+ );
+}
+
fn decode_png_pixels(bytes: &[u8]) -> Vec {
let mut surface =
cairo::ImageSurface::create_from_png(&mut { bytes }).expect("the job produced a PNG");
diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs
index 7f7e0398..f5795f31 100644
--- a/src/backend/wayland/state/render/ui.rs
+++ b/src/backend/wayland/state/render/ui.rs
@@ -409,7 +409,10 @@ impl WaylandState {
// Grips belong to the reviewed rectangle, not the window-mode
// candidate, so they follow `region_selection_geometry` rather than
// the effective selection the scrim cuts out.
- let resize_handles = region_state.is_review().then(|| {
+ let resize_handles = (region_state.is_review()
+ && !self.region_review_crop_locked()
+ && !self.region_cut_mode_armed())
+ .then(|| {
geometry
.map(|geometry| crate::ui::RegionResizeHandles::place(geometry.display_selection()))
});
@@ -421,7 +424,8 @@ impl WaylandState {
.then(|| resize_handles.and_then(|handles| handles.hit(pointer)))
.flatten();
let loupe_enabled = options.is_some_and(|options| options.show_loupe())
- && (region_state.is_selecting() || region_state.is_review());
+ && (region_state.is_selecting() || region_state.is_review())
+ && !self.region_review_loupe_suppressed();
let loupe_source = if loupe_enabled {
self.region_picker_source_token().and_then(|token| {
let source = displayed_screen_image(
@@ -482,6 +486,20 @@ impl WaylandState {
action_bar,
hovered_action,
include_drawings: self.region_picker_include_drawings(),
+ cut: crate::ui::RegionCaptureCutVisual {
+ preview: self.region_cut_preview_pixels().and_then(|pixels| {
+ Some(crate::ui::RegionCutPreviewVisual {
+ pixels,
+ display: self.region_cut_displayed_selection()?,
+ })
+ }),
+ drag: self
+ .region_cut_drag_overlay()
+ .map(|(axis, band)| crate::ui::RegionCutDragVisual { axis, band }),
+ availability: self.region_cut_availability(),
+ cut_armed: self.region_cut_mode_armed(),
+ status: self.region_cut_status(),
+ },
window,
},
|image_x, image_y| {
diff --git a/src/backend/wayland/state/screen_image.rs b/src/backend/wayland/state/screen_image.rs
index 4a3ee5a1..3fee52fa 100644
--- a/src/backend/wayland/state/screen_image.rs
+++ b/src/backend/wayland/state/screen_image.rs
@@ -263,6 +263,43 @@ 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")
}
+/// Map a native-pixel extent whose origin is a source-image point into logical
+/// screen space. The extent is not a second source crop; it only reuses the
+/// source token's image-to-logical scale, including fractional output scale
+/// and Zoom transforms.
+pub(super) fn screen_rect_for_native_extent(
+ token: &ScreenSourceToken,
+ origin: (u32, u32),
+ native_size: (u32, u32),
+) -> Option {
+ if native_size.0 == 0
+ || native_size.1 == 0
+ || token.image_size.0 == 0
+ || token.image_size.1 == 0
+ {
+ return None;
+ }
+ let right = origin.0.checked_add(native_size.0)?;
+ let bottom = origin.1.checked_add(native_size.1)?;
+ let first = screen_point_for_image_point(
+ token,
+ ImagePoint::new(f64::from(origin.0), f64::from(origin.1)),
+ );
+ let second =
+ screen_point_for_image_point(token, ImagePoint::new(f64::from(right), f64::from(bottom)));
+ if ![first.0, first.1, second.0, second.1]
+ .iter()
+ .all(|value| value.is_finite())
+ {
+ return None;
+ }
+ 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)
+}
+
#[allow(dead_code)] // The empty-span contract is exercised by mapping tests.
pub(super) fn screen_rect_for_pixel_span(
token: &ScreenSourceToken,
@@ -321,49 +358,7 @@ pub(super) fn copy_image_rect(
if source.width == 0 || source.height == 0 {
return Err(CropError::Empty);
}
- if rect
- .x()
- .checked_add(rect.width())
- .ok_or(CropError::OutOfBounds)?
- > source.width
- || rect
- .y()
- .checked_add(rect.height())
- .ok_or(CropError::OutOfBounds)?
- > source.height
- {
- return Err(CropError::OutOfBounds);
- }
- let source_stride = usize::try_from(source.stride).map_err(|_| CropError::OutOfBounds)?;
- let row_bytes = usize::try_from(rect.width())
- .ok()
- .and_then(|width| width.checked_mul(4))
- .ok_or(CropError::OutOfBounds)?;
- let total_bytes = usize::try_from(rect.height())
- .ok()
- .and_then(|height| height.checked_mul(row_bytes))
- .ok_or(CropError::OutOfBounds)?;
- let stride = i32::try_from(row_bytes).map_err(|_| CropError::OutOfBounds)?;
-
- let mut data = Vec::with_capacity(total_bytes);
- let start_column = usize::try_from(rect.x())
- .ok()
- .and_then(|x| x.checked_mul(4))
- .ok_or(CropError::OutOfBounds)?;
- for row in 0..rect.height() {
- let source_row = usize::try_from(rect.y().saturating_add(row))
- .ok()
- .and_then(|row| row.checked_mul(source_stride))
- .ok_or(CropError::OutOfBounds)?;
- let start = source_row
- .checked_add(start_column)
- .ok_or(CropError::OutOfBounds)?;
- let end = start.checked_add(row_bytes).ok_or(CropError::OutOfBounds)?;
- data.extend_from_slice(source.data.get(start..end).ok_or(CropError::OutOfBounds)?);
- }
-
- crate::screen_pixels::PackedArgb32::new(rect.width(), rect.height(), stride, data)
- .ok_or(CropError::OutOfBounds)
+ source.copy_rect(rect).ok_or(CropError::OutOfBounds)
}
#[cfg(test)]
diff --git a/src/canvas_export/mod.rs b/src/canvas_export/mod.rs
index 429836a0..0d9df223 100644
--- a/src/canvas_export/mod.rs
+++ b/src/canvas_export/mod.rs
@@ -13,7 +13,9 @@ 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};
+pub(crate) use region::{
+ CanvasRegionExportSnapshot, CanvasRegionSource, render_canvas_region_pixels,
+};
#[cfg(test)]
mod tests {
diff --git a/src/canvas_export/page.rs b/src/canvas_export/page.rs
index 6b8bbdff..8140ab7f 100644
--- a/src/canvas_export/page.rs
+++ b/src/canvas_export/page.rs
@@ -24,7 +24,7 @@ pub struct CanvasPageExportSnapshot {
}
/// Spotlight appearance carried into an export.
-#[derive(Debug, Clone, Copy)]
+#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SpotlightPassSnapshot {
pub dim_opacity: f64,
pub feather: f64,
diff --git a/src/canvas_export/region.rs b/src/canvas_export/region.rs
index e2715a70..4343525d 100644
--- a/src/canvas_export/region.rs
+++ b/src/canvas_export/region.rs
@@ -4,8 +4,9 @@ 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::capture::CaptureError;
+#[cfg(test)]
+use crate::capture::RenderedImage;
use crate::draw::Frame;
use crate::screen_pixels::{ImagePixelRect, PackedArgb32, ScreenImage};
@@ -43,37 +44,9 @@ impl CanvasRegionSource {
}
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()))
+ self.image
+ .copy_rect(selection)
+ .ok_or_else(|| CaptureError::ImageError("Region leaves the captured image".to_string()))
}
fn magnifier_working_selection(
@@ -135,9 +108,58 @@ impl CanvasRegionSource {
}
}
-pub(crate) fn render_canvas_region_png(
+fn packed_argb32_from_surface(
+ surface: &mut cairo::ImageSurface,
+ subject: &str,
+) -> Result {
+ surface.flush();
+ let width = u32::try_from(surface.width())
+ .map_err(|_| CaptureError::ImageError(format!("{subject} width is too large")))?;
+ let height = u32::try_from(surface.height())
+ .map_err(|_| CaptureError::ImageError(format!("{subject} height is too large")))?;
+ let stride = surface.stride();
+ let data = surface.data().map_err(|err| {
+ CaptureError::ImageError(format!("Failed to read {subject} pixels: {err}"))
+ })?;
+ let row_bytes = usize::try_from(width)
+ .ok()
+ .and_then(|width| width.checked_mul(4))
+ .ok_or_else(|| CaptureError::ImageError(format!("{subject} row size overflow")))?;
+ let tight_stride = i32::try_from(row_bytes)
+ .map_err(|_| CaptureError::ImageError(format!("{subject} stride is too large")))?;
+ let source_stride = usize::try_from(stride)
+ .map_err(|_| CaptureError::ImageError(format!("{subject} stride is invalid")))?;
+ let packed = if source_stride == row_bytes {
+ PackedArgb32::new(width, height, tight_stride, data.to_vec())
+ } else {
+ let height_usize = usize::try_from(height)
+ .map_err(|_| CaptureError::ImageError(format!("{subject} height is too large")))?;
+ let length = row_bytes
+ .checked_mul(height_usize)
+ .ok_or_else(|| CaptureError::ImageError(format!("{subject} buffer size overflow")))?;
+ let mut tight = Vec::new();
+ tight
+ .try_reserve(length)
+ .map_err(|_| CaptureError::ImageError(format!("{subject} buffer size overflow")))?;
+ for row in 0..height_usize {
+ let start = row
+ .checked_mul(source_stride)
+ .ok_or_else(|| CaptureError::ImageError(format!("{subject} offset overflow")))?;
+ let end = start
+ .checked_add(row_bytes)
+ .ok_or_else(|| CaptureError::ImageError(format!("{subject} offset overflow")))?;
+ tight.extend_from_slice(data.get(start..end).ok_or_else(|| {
+ CaptureError::ImageError(format!("{subject} pixels are truncated"))
+ })?);
+ }
+ PackedArgb32::new(width, height, tight_stride, tight)
+ };
+ packed.ok_or_else(|| CaptureError::ImageError(format!("{subject} pixels are invalid")))
+}
+
+pub(crate) fn render_canvas_region_pixels(
snapshot: CanvasRegionExportSnapshot,
-) -> Result {
+) -> Result {
let working_selection = snapshot
.source
.magnifier_working_selection(snapshot.selection, &snapshot.frame)
@@ -154,7 +176,7 @@ pub(crate) fn render_canvas_region_png(
let height = i32::try_from(working_height)
.map_err(|_| CaptureError::ImageError("Region height is too large".to_string()))?;
let stride = pixels.stride();
- let surface = cairo::ImageSurface::create_for_data(
+ let mut surface = cairo::ImageSurface::create_for_data(
pixels.into_data(),
cairo::Format::ARgb32,
width,
@@ -198,12 +220,12 @@ pub(crate) fn render_canvas_region_png(
drop(ctx);
if working_selection == snapshot.selection {
- return encode_surface_png(&surface, "region");
+ return packed_argb32_from_surface(&mut surface, "region");
}
let output_width = snapshot.selection.width();
let output_height = snapshot.selection.height();
- let output = cairo::ImageSurface::create(
+ let mut output = cairo::ImageSurface::create(
cairo::Format::ARgb32,
i32::try_from(output_width)
.map_err(|_| CaptureError::ImageError("Region width is too large".to_string()))?,
@@ -224,7 +246,14 @@ pub(crate) fn render_canvas_region_png(
.map_err(|err| CaptureError::ImageError(format!("Failed to paint region crop: {err}")))?;
drop(crop);
- encode_surface_png(&output, "region")
+ packed_argb32_from_surface(&mut output, "region")
+}
+
+#[cfg(test)]
+pub(crate) fn render_canvas_region_png(
+ snapshot: CanvasRegionExportSnapshot,
+) -> Result {
+ crate::capture::png::encode_packed_argb32_png(&render_canvas_region_pixels(snapshot)?)
}
#[cfg(test)]
@@ -605,4 +634,93 @@ mod tests {
}
}
}
+
+ fn pixel_at(pixels: &PackedArgb32, x: u32, y: u32) -> u32 {
+ let offset = y as usize * pixels.stride() as usize + x as usize * 4;
+ u32::from_ne_bytes(pixels.data()[offset..offset + 4].try_into().expect("pixel"))
+ }
+
+ #[test]
+ fn no_cut_png_bytes_match_the_extracted_pixels() {
+ 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 snapshot = CanvasRegionExportSnapshot {
+ source: solid_source(8, 8, 0xFF20_3040),
+ selection: ImagePixelRect::new(0, 0, 8, 8, (8, 8)).unwrap(),
+ frame,
+ text_halo_enabled: true,
+ spotlight: SpotlightPassSnapshot::default(),
+ };
+ let pixels = render_canvas_region_pixels(snapshot.clone()).expect("pixels");
+ let rendered = render_canvas_region_png(snapshot).expect("png");
+ assert_eq!(
+ (pixels.width(), pixels.height()),
+ (rendered.width, rendered.height)
+ );
+ for y in 0..pixels.height() as i32 {
+ for x in 0..pixels.width() as i32 {
+ assert_eq!(
+ pixel_at(&pixels, x as u32, y as u32),
+ decoded_pixel(&rendered, x, y),
+ "pixel ({x}, {y})"
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn a_drawing_crossing_a_removed_band_is_flattened_then_joined() {
+ let mut frame = Frame::new();
+ frame.add_shape(Shape::Rect {
+ x: 10,
+ y: 20,
+ w: 8,
+ h: 1,
+ fill: true,
+ color: RED,
+ thick: 1.0,
+ });
+ let snapshot = CanvasRegionExportSnapshot {
+ source: solid_source(8, 8, 0xFF20_3040),
+ selection: ImagePixelRect::new(0, 0, 8, 8, (8, 8)).unwrap(),
+ frame,
+ text_halo_enabled: true,
+ spotlight: SpotlightPassSnapshot::default(),
+ };
+ let base = render_canvas_region_pixels(snapshot).expect("flattened");
+ let cut = crate::capture::CutBand::new(crate::capture::CutAxis::Columns, 3, 5).unwrap();
+ let composed = crate::capture::apply_band_cuts(&base, &[cut]).expect("cut");
+ assert_eq!((composed.width(), composed.height()), (6, 8));
+ assert_eq!(pixel_at(&composed, 2, 0), 0xFFFF_0000);
+ assert_eq!(pixel_at(&composed, 3, 0), 0xFFFF_0000);
+ assert_eq!(pixel_at(&base, 2, 0), 0xFFFF_0000);
+ assert_eq!(pixel_at(&base, 3, 0), 0xFFFF_0000);
+ assert_eq!(pixel_at(&base, 5, 0), 0xFFFF_0000);
+ }
+
+ #[test]
+ fn packed_extraction_copies_non_tight_cairo_rows() {
+ let width = 3i32;
+ let height = 2i32;
+ let mut surface =
+ cairo::ImageSurface::create(cairo::Format::ARgb32, width, height).expect("surface");
+ {
+ let ctx = cairo::Context::new(&surface).expect("context");
+ ctx.set_source_rgba(1.0, 0.0, 0.0, 1.0);
+ ctx.paint().expect("paint");
+ }
+ let pixels = packed_argb32_from_surface(&mut surface, "stride-test").expect("packed");
+ assert_eq!(pixels.stride(), width * 4);
+ assert_eq!(pixels.data().len(), (width * height * 4) as usize);
+ assert_eq!(pixel_at(&pixels, 0, 0), 0xFFFF_0000);
+ assert_eq!(pixel_at(&pixels, 2, 1), 0xFFFF_0000);
+ }
}
diff --git a/src/capture/band_cut.rs b/src/capture/band_cut.rs
new file mode 100644
index 00000000..2f247f06
--- /dev/null
+++ b/src/capture/band_cut.rs
@@ -0,0 +1,661 @@
+//! Sequential row/column band cuts on tightly packed ARGB32 rasters.
+//!
+//! Cuts are expressed in the coordinate system of the raster produced by every
+//! preceding cut. The engine is backend-neutral: it does not know about Wayland,
+//! Cairo, source tokens, or capture destinations.
+
+use crate::screen_pixels::PackedArgb32;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum CutAxis {
+ Columns,
+ Rows,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) struct CutBand {
+ axis: CutAxis,
+ start: u32,
+ end: u32,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub(crate) enum BandCutError {
+ EmptyRange,
+ OutOfBounds,
+ EmptyOutput,
+ SizeOverflow,
+ InvalidPixels,
+}
+
+impl std::fmt::Display for BandCutError {
+ fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::EmptyRange => formatter.write_str("cut range is empty"),
+ Self::OutOfBounds => formatter.write_str("cut range leaves the current output"),
+ Self::EmptyOutput => formatter.write_str("cut would remove the entire remaining axis"),
+ Self::SizeOverflow => formatter.write_str("cut output size overflows"),
+ Self::InvalidPixels => formatter.write_str("cut pixels are invalid"),
+ }
+ }
+}
+
+impl CutBand {
+ pub(crate) fn new(axis: CutAxis, start: u32, end: u32) -> Result {
+ if start >= end {
+ return Err(BandCutError::EmptyRange);
+ }
+ Ok(Self { axis, start, end })
+ }
+
+ /// Normalize a pair of pixel edges so backward drags match forward drags.
+ pub(crate) fn from_unordered_edges(
+ axis: CutAxis,
+ first: u32,
+ second: u32,
+ ) -> Result {
+ Self::new(axis, first.min(second), first.max(second))
+ }
+
+ pub(crate) const fn axis(self) -> CutAxis {
+ self.axis
+ }
+
+ pub(crate) const fn start(self) -> u32 {
+ self.start
+ }
+
+ pub(crate) const fn end(self) -> u32 {
+ self.end
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+struct AxisSpan {
+ start: u32,
+ end: u32,
+}
+
+impl AxisSpan {
+ fn len(self) -> Result {
+ self.end
+ .checked_sub(self.start)
+ .ok_or(BandCutError::SizeOverflow)
+ }
+}
+
+fn initial_spans(extent: u32) -> Vec {
+ if extent == 0 {
+ Vec::new()
+ } else {
+ vec![AxisSpan {
+ start: 0,
+ end: extent,
+ }]
+ }
+}
+
+fn axis_extent(spans: &[AxisSpan]) -> Result {
+ spans.iter().try_fold(0u32, |acc, span| {
+ acc.checked_add(span.len()?)
+ .ok_or(BandCutError::SizeOverflow)
+ })
+}
+
+fn tight_row(width: u32) -> Result<(i32, usize), BandCutError> {
+ let row_bytes_u32 = width.checked_mul(4).ok_or(BandCutError::SizeOverflow)?;
+ let stride = i32::try_from(row_bytes_u32).map_err(|_| BandCutError::SizeOverflow)?;
+ let row_bytes = usize::try_from(row_bytes_u32).map_err(|_| BandCutError::SizeOverflow)?;
+ Ok((stride, row_bytes))
+}
+
+fn checked_buffer_len(row_bytes: usize, height: u32) -> Result {
+ usize::try_from(height)
+ .ok()
+ .and_then(|height| row_bytes.checked_mul(height))
+ .ok_or(BandCutError::SizeOverflow)
+}
+
+fn checked_row_offset(row: u32, stride: usize) -> Result {
+ usize::try_from(row)
+ .ok()
+ .and_then(|row| row.checked_mul(stride))
+ .ok_or(BandCutError::SizeOverflow)
+}
+
+fn checked_pixel_bytes(offset: u32) -> Result {
+ usize::try_from(offset)
+ .ok()
+ .and_then(|offset| offset.checked_mul(4))
+ .ok_or(BandCutError::SizeOverflow)
+}
+
+fn remove_logical_range(
+ spans: &mut Vec,
+ start: u32,
+ end: u32,
+) -> Result<(), BandCutError> {
+ if start >= end {
+ return Err(BandCutError::EmptyRange);
+ }
+ let extent = axis_extent(spans)?;
+ if end > extent {
+ return Err(BandCutError::OutOfBounds);
+ }
+
+ let mut kept = Vec::new();
+ let mut logical = 0u32;
+ for span in spans.iter().copied() {
+ let span_len = span.len()?;
+ let logical_end = logical
+ .checked_add(span_len)
+ .ok_or(BandCutError::SizeOverflow)?;
+ if start > logical {
+ let keep_end = start.min(logical_end);
+ if keep_end > logical {
+ let keep_len = keep_end - logical;
+ kept.push(AxisSpan {
+ start: span.start,
+ end: span
+ .start
+ .checked_add(keep_len)
+ .ok_or(BandCutError::SizeOverflow)?,
+ });
+ }
+ }
+ if end < logical_end {
+ let keep_start = end.max(logical);
+ if keep_start < logical_end {
+ let skip = keep_start - logical;
+ kept.push(AxisSpan {
+ start: span
+ .start
+ .checked_add(skip)
+ .ok_or(BandCutError::SizeOverflow)?,
+ end: span.end,
+ });
+ }
+ }
+ logical = logical_end;
+ }
+ if axis_extent(&kept)? == 0 {
+ return Err(BandCutError::EmptyOutput);
+ }
+ *spans = kept;
+ Ok(())
+}
+
+fn retained_axes(
+ source: (u32, u32),
+ cuts: &[CutBand],
+) -> Result<(Vec, Vec), BandCutError> {
+ let mut columns = initial_spans(source.0);
+ let mut rows = initial_spans(source.1);
+ for cut in cuts {
+ match cut.axis {
+ CutAxis::Columns => remove_logical_range(&mut columns, cut.start, cut.end)?,
+ CutAxis::Rows => remove_logical_range(&mut rows, cut.start, cut.end)?,
+ }
+ }
+ Ok((columns, rows))
+}
+
+/// Replay `cuts` against `source` and return the remaining native size.
+pub(crate) fn output_size(
+ source: (u32, u32),
+ cuts: &[CutBand],
+) -> Result<(u32, u32), BandCutError> {
+ let (columns, rows) = retained_axes(source, cuts)?;
+ let width = axis_extent(&columns)?;
+ let height = axis_extent(&rows)?;
+ if width == 0 || height == 0 {
+ return Err(BandCutError::EmptyOutput);
+ }
+ Ok((width, height))
+}
+
+fn copy_retained(
+ source: &PackedArgb32,
+ columns: &[AxisSpan],
+ rows: &[AxisSpan],
+) -> Result {
+ let output_width = axis_extent(columns)?;
+ let output_height = axis_extent(rows)?;
+ if output_width == 0 || output_height == 0 {
+ return Err(BandCutError::EmptyOutput);
+ }
+ let (stride, row_bytes) = tight_row(output_width)?;
+ let length = checked_buffer_len(row_bytes, output_height)?;
+ let source_stride = usize::try_from(source.stride()).map_err(|_| BandCutError::SizeOverflow)?;
+ let source_data = source.data();
+ let mut output = vec![0u8; length];
+ let mut dest_row = 0usize;
+ for row_span in rows {
+ let mut source_y = row_span.start;
+ while source_y < row_span.end {
+ let source_row = checked_row_offset(source_y, source_stride)?;
+ let dest_row_offset = dest_row
+ .checked_mul(row_bytes)
+ .ok_or(BandCutError::SizeOverflow)?;
+ let mut dest_bytes = 0usize;
+ for column_span in columns {
+ let span_bytes = checked_pixel_bytes(column_span.len()?)?;
+ let source_start = source_row
+ .checked_add(checked_pixel_bytes(column_span.start)?)
+ .ok_or(BandCutError::SizeOverflow)?;
+ let source_end = source_start
+ .checked_add(span_bytes)
+ .ok_or(BandCutError::SizeOverflow)?;
+ let dest_start = dest_row_offset
+ .checked_add(dest_bytes)
+ .ok_or(BandCutError::SizeOverflow)?;
+ let dest_end = dest_start
+ .checked_add(span_bytes)
+ .ok_or(BandCutError::SizeOverflow)?;
+ let source_run = source_data
+ .get(source_start..source_end)
+ .ok_or(BandCutError::InvalidPixels)?;
+ let dest_run = output
+ .get_mut(dest_start..dest_end)
+ .ok_or(BandCutError::SizeOverflow)?;
+ dest_run.copy_from_slice(source_run);
+ dest_bytes = dest_bytes
+ .checked_add(span_bytes)
+ .ok_or(BandCutError::SizeOverflow)?;
+ }
+ dest_row = dest_row.checked_add(1).ok_or(BandCutError::SizeOverflow)?;
+ source_y = source_y.checked_add(1).ok_or(BandCutError::SizeOverflow)?;
+ }
+ }
+ PackedArgb32::new(output_width, output_height, stride, output)
+ .ok_or(BandCutError::InvalidPixels)
+}
+
+/// Apply ordered band cuts to a tightly packed raster, allocating one output.
+pub(crate) fn apply_band_cuts(
+ source: &PackedArgb32,
+ cuts: &[CutBand],
+) -> Result {
+ if cuts.is_empty() {
+ return Ok(source.clone());
+ }
+ let (columns, rows) = retained_axes((source.width(), source.height()), cuts)?;
+ copy_retained(source, &columns, &rows)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn pixel(bytes: [u8; 4]) -> u32 {
+ u32::from_ne_bytes(bytes)
+ }
+
+ fn unique_raster(width: u32, height: u32) -> PackedArgb32 {
+ let mut data = Vec::with_capacity((width * height * 4) as usize);
+ for y in 0..height {
+ for x in 0..width {
+ data.extend_from_slice(&[
+ x as u8,
+ y as u8,
+ (y.wrapping_mul(width).wrapping_add(x)) as u8,
+ 0x80 | ((x + y) as u8 & 0x3F),
+ ]);
+ }
+ }
+ PackedArgb32::new(width, height, (width * 4) as i32, data).expect("unique raster")
+ }
+
+ fn raster_from_ids(width: u32, height: u32, ids: &[u32]) -> PackedArgb32 {
+ assert_eq!(ids.len(), (width * height) as usize);
+ let data = ids
+ .iter()
+ .flat_map(|id| id.to_ne_bytes())
+ .collect::>();
+ PackedArgb32::new(width, height, (width * 4) as i32, data).expect("id raster")
+ }
+
+ fn ids_of(pixels: &PackedArgb32) -> Vec {
+ pixels
+ .data()
+ .chunks_exact(4)
+ .map(|chunk| u32::from_ne_bytes(chunk.try_into().expect("pixel")))
+ .collect()
+ }
+
+ fn cut(axis: CutAxis, start: u32, end: u32) -> CutBand {
+ CutBand::new(axis, start, end).expect("valid cut")
+ }
+
+ fn reference_one(pixels: &PackedArgb32, cut: CutBand) -> Result {
+ let width = pixels.width();
+ let height = pixels.height();
+ let source = pixels.data();
+ let source_stride = usize::try_from(pixels.stride()).unwrap();
+ match cut.axis {
+ CutAxis::Columns => {
+ if cut.start >= cut.end {
+ return Err(BandCutError::EmptyRange);
+ }
+ if cut.end > width {
+ return Err(BandCutError::OutOfBounds);
+ }
+ let removed = cut.end - cut.start;
+ let output_width = width
+ .checked_sub(removed)
+ .ok_or(BandCutError::SizeOverflow)?;
+ if output_width == 0 {
+ return Err(BandCutError::EmptyOutput);
+ }
+ let (stride, row_bytes) = tight_row(output_width)?;
+ let mut data = Vec::with_capacity(checked_buffer_len(row_bytes, height)?);
+ for y in 0..height {
+ let row = checked_row_offset(y, source_stride)?;
+ for x in 0..width {
+ if x >= cut.start && x < cut.end {
+ continue;
+ }
+ let start = row + checked_pixel_bytes(x)?;
+ data.extend_from_slice(&source[start..start + 4]);
+ }
+ }
+ PackedArgb32::new(output_width, height, stride, data)
+ .ok_or(BandCutError::InvalidPixels)
+ }
+ CutAxis::Rows => {
+ if cut.start >= cut.end {
+ return Err(BandCutError::EmptyRange);
+ }
+ if cut.end > height {
+ return Err(BandCutError::OutOfBounds);
+ }
+ let removed = cut.end - cut.start;
+ let output_height = height
+ .checked_sub(removed)
+ .ok_or(BandCutError::SizeOverflow)?;
+ if output_height == 0 {
+ return Err(BandCutError::EmptyOutput);
+ }
+ let (stride, row_bytes) = tight_row(width)?;
+ let mut data = Vec::with_capacity(checked_buffer_len(row_bytes, output_height)?);
+ for y in 0..height {
+ if y >= cut.start && y < cut.end {
+ continue;
+ }
+ let row = checked_row_offset(y, source_stride)?;
+ data.extend_from_slice(&source[row..row + row_bytes]);
+ }
+ PackedArgb32::new(width, output_height, stride, data)
+ .ok_or(BandCutError::InvalidPixels)
+ }
+ }
+ }
+
+ fn reference_apply(
+ pixels: &PackedArgb32,
+ cuts: &[CutBand],
+ ) -> Result {
+ let mut current = pixels.clone();
+ for cut in cuts {
+ current = reference_one(¤t, *cut)?;
+ }
+ Ok(current)
+ }
+
+ #[test]
+ fn no_cuts_returns_an_equal_raster() {
+ let source = unique_raster(4, 3);
+ let output = apply_band_cuts(&source, &[]).expect("no cuts");
+ assert_eq!(output, source);
+ assert_eq!(output.stride(), 16);
+ assert_eq!(output_size((4, 3), &[]), Ok((4, 3)));
+ }
+
+ #[test]
+ fn removing_a_middle_column_joins_the_neighbours() {
+ let source = raster_from_ids(5, 1, &[1, 2, 3, 4, 5]);
+ let output = apply_band_cuts(&source, &[cut(CutAxis::Columns, 2, 3)]).unwrap();
+ assert_eq!(ids_of(&output), vec![1, 2, 4, 5]);
+ assert_eq!(output.stride(), 16);
+ }
+
+ #[test]
+ fn first_and_last_column_bands_are_removed() {
+ let source = raster_from_ids(6, 1, &[1, 2, 3, 4, 5, 6]);
+ let output = apply_band_cuts(
+ &source,
+ &[cut(CutAxis::Columns, 0, 1), cut(CutAxis::Columns, 4, 5)],
+ )
+ .unwrap();
+ // After removing the first column: 2 3 4 5 6. The second cut uses that
+ // output, so [4,5) removes 6.
+ assert_eq!(ids_of(&output), vec![2, 3, 4, 5]);
+ }
+
+ #[test]
+ fn removing_a_middle_row_slides_content_up() {
+ let source = raster_from_ids(2, 4, &[1, 2, 3, 4, 5, 6, 7, 8]);
+ let output = apply_band_cuts(&source, &[cut(CutAxis::Rows, 1, 2)]).unwrap();
+ assert_eq!(ids_of(&output), vec![1, 2, 5, 6, 7, 8]);
+ assert_eq!((output.width(), output.height()), (2, 3));
+ }
+
+ #[test]
+ fn first_and_last_row_bands_are_removed() {
+ let source = raster_from_ids(1, 5, &[1, 2, 3, 4, 5]);
+ let output = apply_band_cuts(
+ &source,
+ &[cut(CutAxis::Rows, 0, 1), cut(CutAxis::Rows, 3, 4)],
+ )
+ .unwrap();
+ assert_eq!(ids_of(&output), vec![2, 3, 4]);
+ }
+
+ #[test]
+ fn backward_edges_normalize_to_the_same_band() {
+ assert_eq!(
+ CutBand::from_unordered_edges(CutAxis::Columns, 5, 2).unwrap(),
+ CutBand::from_unordered_edges(CutAxis::Columns, 2, 5).unwrap()
+ );
+ }
+
+ #[test]
+ fn sequential_same_axis_cuts_use_current_output_coordinates() {
+ // A B C D E F G H, cut [2,4), then [3,5) → A B E H
+ let labels = [10, 20, 30, 40, 50, 60, 70, 80];
+ let source = raster_from_ids(8, 1, &labels);
+ let output = apply_band_cuts(
+ &source,
+ &[cut(CutAxis::Columns, 2, 4), cut(CutAxis::Columns, 3, 5)],
+ )
+ .unwrap();
+ assert_eq!(ids_of(&output), vec![10, 20, 50, 80]);
+ }
+
+ #[test]
+ fn mixed_row_and_column_cuts_preserve_source_pixels() {
+ let mut ids = Vec::new();
+ for y in 0..4u32 {
+ for x in 0..4u32 {
+ ids.push(y * 10 + x);
+ }
+ }
+ let source = raster_from_ids(4, 4, &ids);
+ let output = apply_band_cuts(
+ &source,
+ &[cut(CutAxis::Columns, 1, 2), cut(CutAxis::Rows, 2, 3)],
+ )
+ .unwrap();
+ assert_eq!(ids_of(&output), vec![0, 2, 3, 10, 12, 13, 30, 32, 33]);
+ assert_eq!((output.width(), output.height()), (3, 3));
+ }
+
+ #[test]
+ fn undo_last_then_redo_reproduces_identical_bytes() {
+ let source = unique_raster(6, 5);
+ let first = cut(CutAxis::Columns, 1, 3);
+ let second = cut(CutAxis::Rows, 2, 4);
+ let both = apply_band_cuts(&source, &[first, second]).unwrap();
+ let undone = apply_band_cuts(&source, &[first]).unwrap();
+ let redone = apply_band_cuts(&source, &[first, second]).unwrap();
+ assert_eq!(redone, both);
+ assert_ne!(undone, both);
+ }
+
+ #[test]
+ fn all_four_native_order_bytes_are_copied_unchanged() {
+ let bytes = [0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88];
+ let source = PackedArgb32::new(2, 1, 8, bytes.to_vec()).unwrap();
+ let output = apply_band_cuts(&source, &[cut(CutAxis::Columns, 0, 1)]).unwrap();
+ assert_eq!(output.data(), &bytes[4..]);
+ assert_eq!(
+ pixel(output.data().try_into().unwrap()),
+ pixel([0x55, 0x66, 0x77, 0x88])
+ );
+ }
+
+ #[test]
+ fn one_pixel_wide_or_high_output_is_valid() {
+ let source = unique_raster(4, 3);
+ let column = apply_band_cuts(&source, &[cut(CutAxis::Columns, 1, 4)]).unwrap();
+ assert_eq!((column.width(), column.height()), (1, 3));
+ let row = apply_band_cuts(&source, &[cut(CutAxis::Rows, 0, 2)]).unwrap();
+ assert_eq!((row.width(), row.height()), (4, 1));
+ }
+
+ #[test]
+ fn empty_half_open_range_is_rejected() {
+ assert_eq!(
+ CutBand::new(CutAxis::Columns, 3, 3),
+ Err(BandCutError::EmptyRange)
+ );
+ assert_eq!(
+ CutBand::from_unordered_edges(CutAxis::Rows, 4, 4),
+ Err(BandCutError::EmptyRange)
+ );
+ }
+
+ #[test]
+ fn end_beyond_current_extent_is_rejected() {
+ let source = unique_raster(3, 2);
+ assert_eq!(
+ apply_band_cuts(&source, &[cut(CutAxis::Columns, 1, 4)]),
+ Err(BandCutError::OutOfBounds)
+ );
+ assert_eq!(
+ output_size((3, 2), &[cut(CutAxis::Rows, 0, 3)]),
+ Err(BandCutError::OutOfBounds)
+ );
+ }
+
+ #[test]
+ fn a_cut_that_clears_an_axis_is_rejected() {
+ let source = unique_raster(3, 2);
+ assert_eq!(
+ apply_band_cuts(&source, &[cut(CutAxis::Columns, 0, 3)]),
+ Err(BandCutError::EmptyOutput)
+ );
+ assert_eq!(
+ apply_band_cuts(&source, &[cut(CutAxis::Rows, 0, 2)]),
+ Err(BandCutError::EmptyOutput)
+ );
+ }
+
+ #[test]
+ fn a_later_cut_invalid_under_the_earlier_size_is_rejected() {
+ let source = unique_raster(4, 3);
+ let cuts = [cut(CutAxis::Columns, 1, 3), cut(CutAxis::Columns, 2, 3)];
+ // After the first cut the width is 2, so [2,3) is out of bounds.
+ assert_eq!(
+ apply_band_cuts(&source, &cuts),
+ Err(BandCutError::OutOfBounds)
+ );
+ assert_eq!(output_size((4, 3), &cuts), Err(BandCutError::OutOfBounds));
+ }
+
+ #[test]
+ fn width_times_four_and_allocation_overflow_fail_without_panic() {
+ assert_eq!(tight_row(u32::MAX), Err(BandCutError::SizeOverflow));
+ assert_eq!(
+ tight_row((i32::MAX as u32 / 4) + 1),
+ Err(BandCutError::SizeOverflow)
+ );
+ assert_eq!(
+ checked_buffer_len(usize::MAX, 2),
+ Err(BandCutError::SizeOverflow)
+ );
+ assert_eq!(
+ checked_row_offset(2, usize::MAX),
+ Err(BandCutError::SizeOverflow)
+ );
+ // A u32 pixel index times four always fits in 64-bit usize; the
+ // helper must still refuse to panic if a later 32-bit target is used.
+ if usize::BITS <= 32 {
+ assert_eq!(
+ checked_pixel_bytes(u32::MAX / 2),
+ Err(BandCutError::SizeOverflow)
+ );
+ } else {
+ assert_eq!(checked_pixel_bytes(u32::MAX / 2), Ok(8_589_934_588));
+ }
+ }
+
+ #[test]
+ fn malformed_packed_pixels_cannot_be_constructed() {
+ assert_eq!(PackedArgb32::new(2, 2, 12, vec![0; 24]), None);
+ assert_eq!(PackedArgb32::new(2, 2, 8, vec![0; 15]), None);
+ assert_eq!(PackedArgb32::new(u32::MAX, 1, -4, Vec::new()), None);
+ }
+
+ #[test]
+ fn output_stride_equals_width_times_four() {
+ let source = unique_raster(7, 5);
+ let output = apply_band_cuts(&source, &[cut(CutAxis::Columns, 2, 4)]).unwrap();
+ assert_eq!(output.stride(), (output.width() * 4) as i32);
+ assert_eq!(
+ output.data().len(),
+ output.stride() as usize * output.height() as usize
+ );
+ }
+
+ #[test]
+ fn retained_interval_engine_matches_sequential_reference() {
+ for width in 1..=6 {
+ for height in 1..=6 {
+ let source = unique_raster(width, height);
+ assert_eq!(
+ apply_band_cuts(&source, &[]).unwrap(),
+ reference_apply(&source, &[]).unwrap()
+ );
+ for axis in [CutAxis::Columns, CutAxis::Rows] {
+ let extent = if axis == CutAxis::Columns {
+ width
+ } else {
+ height
+ };
+ for start in 0..extent {
+ for end in (start + 1)..=extent {
+ let Ok(band) = CutBand::new(axis, start, end) else {
+ continue;
+ };
+ let optimized = apply_band_cuts(&source, &[band]);
+ let reference = reference_apply(&source, &[band]);
+ assert_eq!(optimized, reference, "{width}x{height} {band:?}");
+ }
+ }
+ }
+ if width >= 4 && height >= 3 {
+ let cuts = [
+ cut(CutAxis::Columns, 1, 2),
+ cut(CutAxis::Rows, 0, 1),
+ cut(CutAxis::Columns, 1, 2),
+ ];
+ assert_eq!(
+ apply_band_cuts(&source, &cuts),
+ reference_apply(&source, &cuts)
+ );
+ }
+ }
+ }
+ }
+}
diff --git a/src/capture/mod.rs b/src/capture/mod.rs
index 7f8497eb..1a3ee332 100644
--- a/src/capture/mod.rs
+++ b/src/capture/mod.rs
@@ -7,6 +7,7 @@
//! - Clipboard integration
//! - File saving with configurable formats
+pub(crate) mod band_cut;
pub mod clipboard;
pub mod file;
pub(crate) mod png;
@@ -23,6 +24,7 @@ mod pipeline;
#[cfg(test)]
mod tests;
+pub(crate) use band_cut::{BandCutError, CutAxis, CutBand, apply_band_cuts, output_size};
pub use manager::{CaptureManager, CapturePoll, CaptureRequestId, CaptureSubmitError};
#[allow(unused_imports)]
pub(crate) use pipeline::CaptureRequest;
diff --git a/src/screen_pixels.rs b/src/screen_pixels.rs
index af8d99ee..a28dea5d 100644
--- a/src/screen_pixels.rs
+++ b/src/screen_pixels.rs
@@ -293,6 +293,38 @@ impl PackedArgb32 {
}
}
+impl ScreenImage {
+ /// Copy `rect` into a tightly packed ARGB snapshot. The source may carry
+ /// row padding; the copy never does.
+ pub(crate) fn copy_rect(&self, rect: ImagePixelRect) -> Option {
+ if self.width == 0 || self.height == 0 {
+ return None;
+ }
+ if rect.x().checked_add(rect.width())? > self.width
+ || rect.y().checked_add(rect.height())? > self.height
+ {
+ return None;
+ }
+ let source_stride = usize::try_from(self.stride).ok()?;
+ let row_bytes = usize::try_from(rect.width()).ok()?.checked_mul(4)?;
+ let total_bytes = usize::try_from(rect.height())
+ .ok()?
+ .checked_mul(row_bytes)?;
+ let stride = i32::try_from(row_bytes).ok()?;
+ let start_column = usize::try_from(rect.x()).ok()?.checked_mul(4)?;
+ let mut data = Vec::with_capacity(total_bytes);
+ for row in 0..rect.height() {
+ let start = usize::try_from(rect.y().saturating_add(row))
+ .ok()
+ .and_then(|row| row.checked_mul(source_stride))
+ .and_then(|row| row.checked_add(start_column))?;
+ let end = start.checked_add(row_bytes)?;
+ data.extend_from_slice(self.data.get(start..end)?);
+ }
+ PackedArgb32::new(rect.width(), rect.height(), stride, data)
+ }
+}
+
impl std::fmt::Debug for PackedArgb32 {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
diff --git a/src/ui.rs b/src/ui.rs
index f0152415..0bfd7e4a 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -59,11 +59,13 @@ pub(crate) use primitives::ellipsize_to_fit;
pub(crate) use primitives::{checkerboard_behind, draw_pill};
pub use properties_panel::render_properties_panel;
pub use radial_menu::render_radial_menu;
-pub(crate) use region_action_bar::{RegionAction, RegionActionBar};
+pub(crate) use region_action_bar::{
+ RegionAction, RegionActionAvailability, RegionActionBar, RegionCutStatus,
+};
pub(crate) use region_capture_picker::{
- OCR_LEGEND_TEXT, RegionCaptureLoupeVisual, RegionCapturePickerVisual,
- RegionCaptureWindowVisual, capture_size_text, measure_picker_damage,
- render_region_capture_picker, render_region_legend,
+ OCR_LEGEND_TEXT, RegionCaptureCutVisual, RegionCaptureLoupeVisual, RegionCapturePickerVisual,
+ RegionCaptureWindowVisual, RegionCutDragVisual, RegionCutPreviewVisual, capture_size_text,
+ measure_picker_damage, render_region_capture_picker, render_region_legend,
};
pub(crate) use region_resize_handles::RegionResizeHandles;
pub(crate) use spotlight_control::render_spotlight_magnification_control;
diff --git a/src/ui/region_action_bar.rs b/src/ui/region_action_bar.rs
index aafbbaa8..5ef16a28 100644
--- a/src/ui/region_action_bar.rs
+++ b/src/ui/region_action_bar.rs
@@ -12,8 +12,17 @@ const ITEM_GAP: f64 = 6.0;
/// divider is centred in it.
const ROW_GAP: f64 = 8.0;
const ACTION_ROW_HEIGHT: f64 = 38.0;
+const EDIT_ROW_HEIGHT: f64 = 28.0;
const TOGGLE_ROW_HEIGHT: f64 = 26.0;
-const BAR_HEIGHT: f64 = BAR_PADDING * 2.0 + ACTION_ROW_HEIGHT + ROW_GAP + TOGGLE_ROW_HEIGHT;
+const STATUS_ROW_HEIGHT: f64 = 16.0;
+const BAR_HEIGHT: f64 = BAR_PADDING * 2.0
+ + ACTION_ROW_HEIGHT
+ + ROW_GAP
+ + EDIT_ROW_HEIGHT
+ + ROW_GAP
+ + TOGGLE_ROW_HEIGHT
+ + ROW_GAP
+ + STATUS_ROW_HEIGHT;
/// Resting width of one action control. The bar sizes itself from this instead
/// of stretching controls across an arbitrary fixed width.
const ACTION_ITEM_WIDTH: f64 = 74.0;
@@ -58,6 +67,10 @@ pub(crate) enum RegionAction {
Save,
Both,
Board,
+ CutBand,
+ UndoCut,
+ RedoCut,
+ ResetCuts,
ToggleIncludeDrawings,
}
@@ -68,6 +81,10 @@ impl RegionAction {
Self::Save => "Save",
Self::Both => "Both",
Self::Board => "Board",
+ Self::CutBand => "Cut",
+ Self::UndoCut => "Undo",
+ Self::RedoCut => "Redo",
+ Self::ResetCuts => "Reset",
Self::ToggleIncludeDrawings => "Include drawings in exports",
}
}
@@ -78,16 +95,101 @@ impl RegionAction {
Self::Save => "Ctrl+S",
Self::Both => "Enter",
Self::Board => "B",
+ Self::CutBand => "X",
+ Self::UndoCut => "Ctrl+Z",
+ Self::RedoCut => "Ctrl+Y",
+ Self::ResetCuts => "",
Self::ToggleIncludeDrawings => "D",
}
}
+ /// Destinations that leave Review. Edit controls stay in the picker.
+ pub(crate) const fn is_terminal(self) -> bool {
+ matches!(self, Self::Copy | Self::Save | Self::Both | Self::Board)
+ }
+
/// The accented default action: the one `Enter` submits.
const fn is_primary(self) -> bool {
matches!(self, Self::Both)
}
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) struct RegionActionAvailability {
+ pub terminal: bool,
+ pub cut: bool,
+ pub undo: bool,
+ pub redo: bool,
+ pub reset: bool,
+}
+
+impl RegionActionAvailability {
+ /// Resting Review bar: terminals and Cut enabled, history empty.
+ pub(crate) const DEFAULT: Self = Self {
+ terminal: true,
+ cut: true,
+ undo: false,
+ redo: false,
+ reset: false,
+ };
+
+ pub(crate) const fn allows(self, action: RegionAction) -> bool {
+ match action {
+ RegionAction::Copy | RegionAction::Save | RegionAction::Both | RegionAction::Board => {
+ self.terminal
+ }
+ RegionAction::CutBand => self.cut,
+ RegionAction::UndoCut => self.undo,
+ RegionAction::RedoCut => self.redo,
+ RegionAction::ResetCuts => self.reset,
+ RegionAction::ToggleIncludeDrawings => true,
+ }
+ }
+}
+
+impl Default for RegionActionAvailability {
+ fn default() -> Self {
+ Self::DEFAULT
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum RegionCutStatus {
+ Updating,
+ Failed,
+}
+
+impl RegionCutStatus {
+ const fn message(self) -> &'static str {
+ match self {
+ Self::Updating => "Updating cut preview…",
+ Self::Failed => "Cut preview failed",
+ }
+ }
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) struct RegionActionBarVisual {
+ pub hovered: Option,
+ pub include_drawings: bool,
+ pub availability: RegionActionAvailability,
+ pub cut_armed: bool,
+ pub status: Option,
+}
+
+#[cfg(test)]
+impl RegionActionBarVisual {
+ pub(crate) const fn simple(hovered: Option, include_drawings: bool) -> Self {
+ Self {
+ hovered,
+ include_drawings,
+ availability: RegionActionAvailability::DEFAULT,
+ cut_armed: false,
+ status: None,
+ }
+ }
+}
+
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct RegionActionRect {
pub x: f64,
@@ -124,6 +226,7 @@ struct RegionActionItem {
pub(crate) struct RegionActionBar {
bounds: RegionActionRect,
items: [RegionActionItem; 4],
+ edit: [RegionActionItem; 4],
toggle: RegionActionItem,
}
@@ -152,36 +255,60 @@ impl RegionActionBar {
(surface_height - height - SURFACE_MARGIN).max(SURFACE_MARGIN),
);
let bounds = RegionActionRect::new(x, y, width, height);
- let item_height = ACTION_ROW_HEIGHT.min((height - BAR_PADDING * 2.0).max(0.0));
+ let width_scale = if BAR_WIDTH > 0.0 {
+ (width / BAR_WIDTH).clamp(0.0, 1.0)
+ } else {
+ 0.0
+ };
+ let height_scale = if BAR_HEIGHT > 0.0 {
+ (height / BAR_HEIGHT).clamp(0.0, 1.0)
+ } else {
+ 0.0
+ };
+ let pad_x = BAR_PADDING * width_scale;
+ let pad_y = BAR_PADDING * height_scale;
+ let item_gap = ITEM_GAP * width_scale;
+ let row_gap = ROW_GAP * height_scale;
+ let action_height = ACTION_ROW_HEIGHT * height_scale;
+ let edit_height = EDIT_ROW_HEIGHT * height_scale;
+ let toggle_height = TOGGLE_ROW_HEIGHT * height_scale;
let item_count = 4.0;
let item_width =
- ((width - BAR_PADDING * 2.0 - ITEM_GAP * (item_count - 1.0)) / item_count).max(0.0);
- let item = |index: usize, action| RegionActionItem {
+ ((width - pad_x * 2.0 - item_gap * (item_count - 1.0)) / item_count).max(0.0);
+ let row_item = |index: usize, action, row_y, row_height| RegionActionItem {
action,
bounds: RegionActionRect::new(
- x + BAR_PADDING + index as f64 * (item_width + ITEM_GAP),
- y + BAR_PADDING,
+ x + pad_x + index as f64 * (item_width + item_gap),
+ row_y,
item_width,
- item_height,
+ row_height,
),
};
- let toggle_y = y + BAR_PADDING + item_height + ROW_GAP;
+ let action_y = y + pad_y;
+ let edit_y = action_y + action_height + row_gap;
+ let toggle_y = edit_y + edit_height + row_gap;
let toggle = RegionActionItem {
action: RegionAction::ToggleIncludeDrawings,
bounds: RegionActionRect::new(
- x + BAR_PADDING,
+ x + pad_x,
toggle_y,
- (width - BAR_PADDING * 2.0).max(0.0),
- (y + height - BAR_PADDING - toggle_y).max(0.0),
+ (width - pad_x * 2.0).max(0.0),
+ toggle_height,
),
};
Self {
bounds,
items: [
- item(0, RegionAction::Copy),
- item(1, RegionAction::Save),
- item(2, RegionAction::Both),
- item(3, RegionAction::Board),
+ row_item(0, RegionAction::Copy, action_y, action_height),
+ row_item(1, RegionAction::Save, action_y, action_height),
+ row_item(2, RegionAction::Both, action_y, action_height),
+ row_item(3, RegionAction::Board, action_y, action_height),
+ ],
+ edit: [
+ row_item(0, RegionAction::CutBand, edit_y, edit_height),
+ row_item(1, RegionAction::UndoCut, edit_y, edit_height),
+ row_item(2, RegionAction::RedoCut, edit_y, edit_height),
+ row_item(3, RegionAction::ResetCuts, edit_y, edit_height),
],
toggle,
}
@@ -196,6 +323,7 @@ impl RegionActionBar {
pub(crate) fn hit(self, point: (f64, f64)) -> Option {
self.items
.iter()
+ .chain(self.edit.iter())
.find(|item| item.bounds.contains(point))
.map(|item| item.action)
.or_else(|| {
@@ -206,25 +334,73 @@ impl RegionActionBar {
})
}
+ pub(crate) fn enabled_hit(
+ self,
+ point: (f64, f64),
+ availability: RegionActionAvailability,
+ ) -> Option {
+ self.hit(point)
+ .filter(|&action| availability.allows(action))
+ }
+
pub(crate) fn contains(self, point: (f64, f64)) -> bool {
self.bounds.contains(point)
}
+
+ fn status_bounds(self) -> Option {
+ let toggle = self.toggle.bounds;
+ if toggle.width <= 0.0 {
+ return None;
+ }
+ let pad_y = (self.items[0].bounds.y - self.bounds.y).max(0.0);
+ let row_gap =
+ (self.edit[0].bounds.y - self.items[0].bounds.y - self.items[0].bounds.height).max(0.0);
+ let y = toggle.y + toggle.height + row_gap;
+ let height = (self.bounds.y + self.bounds.height - pad_y - y).max(0.0);
+ (height > 0.0).then(|| RegionActionRect::new(toggle.x, y, toggle.width, height))
+ }
}
pub(crate) fn render_region_action_bar(
ctx: &cairo::Context,
bar: RegionActionBar,
- hovered: Option,
- include_drawings: bool,
+ visual: RegionActionBarVisual,
) {
let _ = ctx.save();
draw_bar_frame(ctx, bar.bounds);
for item in bar.items {
- draw_action(ctx, item, hovered == Some(item.action));
+ draw_action(
+ ctx,
+ item,
+ visual.hovered == Some(item.action),
+ visual.availability.allows(item.action),
+ false,
+ );
}
- draw_row_divider(ctx, bar);
- draw_toggle(ctx, bar.toggle, hovered, include_drawings);
+ draw_row_divider(
+ ctx,
+ bar.items[0].bounds,
+ bar.edit[0].bounds.y,
+ bar.toggle.bounds.width,
+ );
+ for item in bar.edit {
+ draw_action(
+ ctx,
+ item,
+ visual.hovered == Some(item.action),
+ visual.availability.allows(item.action),
+ visual.cut_armed && item.action == RegionAction::CutBand,
+ );
+ }
+ draw_row_divider(
+ ctx,
+ bar.edit[0].bounds,
+ bar.toggle.bounds.y,
+ bar.toggle.bounds.width,
+ );
+ draw_toggle(ctx, bar.toggle, visual.hovered, visual.include_drawings);
+ draw_status(ctx, bar, visual.status);
let _ = ctx.restore();
}
@@ -272,32 +448,49 @@ fn draw_bar_frame(ctx: &cairo::Context, bounds: RegionActionRect) {
let _ = ctx.stroke();
}
-/// Hairline between the action row and the toggle row, inset from the bar's
-/// padding so it reads as a grouping rule rather than a border.
-fn draw_row_divider(ctx: &cairo::Context, bar: RegionActionBar) {
- let toggle = bar.toggle.bounds;
- if toggle.width <= 0.0 || toggle.height <= 0.0 {
+/// Hairline between stacked rows, inset from the bar's padding so it reads as
+/// a grouping rule rather than a border.
+fn draw_row_divider(ctx: &cairo::Context, row: RegionActionRect, next_y: f64, width: f64) {
+ if width <= 0.0 || row.height <= 0.0 {
return;
}
- let y = (toggle.y - ROW_GAP / 2.0).floor() + 0.5;
+ let gap = (next_y - row.y - row.height).max(0.0);
+ if gap <= 0.0 {
+ return;
+ }
+ let y = (row.y + row.height + gap / 2.0).floor() + 0.5;
theme::set_color(ctx, overlay::DIVIDER_LIGHT);
ctx.set_line_width(1.0);
- ctx.move_to(toggle.x, y);
- ctx.line_to(toggle.x + toggle.width, y);
+ ctx.move_to(row.x, y);
+ ctx.line_to(row.x + width, y);
let _ = ctx.stroke();
}
-fn draw_action(ctx: &cairo::Context, item: RegionActionItem, hovered: bool) {
+fn draw_action(
+ ctx: &cairo::Context,
+ item: RegionActionItem,
+ hovered: bool,
+ enabled: bool,
+ selected: bool,
+) {
if item.bounds.width <= 0.0 || item.bounds.height <= 0.0 {
return;
}
let primary = item.action.is_primary();
- let (fill, border) = match (primary, hovered) {
+ let (mut fill, mut border) = match (primary, hovered || selected) {
(true, false) => (PRIMARY_BG, PRIMARY_BORDER),
(true, true) => (PRIMARY_BG_HOVER, ITEM_BORDER_HOVER),
(false, false) => (ITEM_BG, ITEM_BORDER),
(false, true) => (ITEM_BG_HOVER, ITEM_BORDER_HOVER),
};
+ if selected && !primary {
+ fill = theme::rgba(theme::ACCENT_RGB, 0.35);
+ border = ITEM_BORDER_HOVER;
+ }
+ if !enabled {
+ fill.3 *= 0.45;
+ border.3 *= 0.45;
+ }
theme::set_color(ctx, fill);
draw_rounded_rect(
@@ -322,12 +515,12 @@ fn draw_action(ctx: &cairo::Context, item: RegionActionItem, hovered: bool) {
);
let _ = ctx.stroke();
- draw_action_content(ctx, item, primary);
+ draw_action_content(ctx, item, primary, enabled);
}
/// Label over keycap, the pair centred as one block so every control's text
/// sits on the same optical line regardless of ascenders or descenders.
-fn draw_action_content(ctx: &cairo::Context, item: RegionActionItem, primary: bool) {
+fn draw_action_content(ctx: &cairo::Context, item: RegionActionItem, primary: bool, enabled: bool) {
let _ = ctx.save();
ctx.rectangle(
item.bounds.x,
@@ -341,35 +534,53 @@ fn draw_action_content(ctx: &cairo::Context, item: RegionActionItem, primary: bo
let label = item.action.label();
let layout = text_layout(ctx, label_style(), label, None);
let label_extents = layout.ink_extents();
- let (keycap_width, keycap_height) = keycap_size(ctx, item.action.shortcut(), KEYCAP_FONT_SIZE);
+ let shortcut = item.action.shortcut();
+ let (keycap_width, keycap_height) = if shortcut.is_empty() {
+ (0.0, 0.0)
+ } else {
+ keycap_size(ctx, shortcut, KEYCAP_FONT_SIZE)
+ };
- let stack_height = label_extents.height() + LABEL_KEYCAP_GAP + keycap_height;
+ let stack_height = if shortcut.is_empty() {
+ label_extents.height()
+ } else {
+ label_extents.height() + LABEL_KEYCAP_GAP + keycap_height
+ };
let stack_top = item.bounds.y + (item.bounds.height - stack_height) / 2.0;
+ let text_color = if !enabled {
+ overlay::TEXT_TERTIARY
+ } else if primary {
+ overlay::TEXT_WHITE
+ } else {
+ overlay::TEXT_PRIMARY
+ };
- theme::set_color(ctx, overlay::TEXT_PRIMARY);
+ theme::set_color(ctx, text_color);
layout.show_at_baseline(
ctx,
center_x - label_extents.width() / 2.0 - label_extents.x_bearing(),
stack_top - label_extents.y_bearing(),
);
- draw_keycap(
- ctx,
- center_x - keycap_width / 2.0,
- stack_top + label_extents.height() + LABEL_KEYCAP_GAP,
- item.action.shortcut(),
- KEYCAP_FONT_SIZE,
- if primary {
- KEYCAP_BG_ON_ACCENT
- } else {
- KEYCAP_BG
- },
- if primary {
- overlay::TEXT_WHITE
- } else {
- overlay::TEXT_HINT
- },
- );
+ if !shortcut.is_empty() {
+ draw_keycap(
+ ctx,
+ center_x - keycap_width / 2.0,
+ stack_top + label_extents.height() + LABEL_KEYCAP_GAP,
+ shortcut,
+ KEYCAP_FONT_SIZE,
+ if primary {
+ KEYCAP_BG_ON_ACCENT
+ } else {
+ KEYCAP_BG
+ },
+ if primary {
+ overlay::TEXT_WHITE
+ } else {
+ overlay::TEXT_HINT
+ },
+ );
+ }
let _ = ctx.restore();
}
@@ -488,6 +699,37 @@ fn draw_checkbox(ctx: &cairo::Context, x: f64, y: f64, size: f64, checked: bool)
let _ = ctx.stroke();
}
+fn draw_status(ctx: &cairo::Context, bar: RegionActionBar, status: Option) {
+ let Some(status) = status else {
+ return;
+ };
+ let Some(row) = bar.status_bounds() else {
+ return;
+ };
+ let font_size = (TOGGLE_FONT_SIZE * (row.height / STATUS_ROW_HEIGHT).min(1.0)).max(0.0);
+ if font_size < 1.0 {
+ return;
+ }
+ let _ = ctx.save();
+ ctx.rectangle(row.x, row.y, row.width, row.height);
+ ctx.clip();
+ let layout = text_layout(ctx, status_label_style(font_size), status.message(), None);
+ let extents = layout.ink_extents();
+ theme::set_color(
+ ctx,
+ match status {
+ RegionCutStatus::Updating => overlay::TEXT_HINT,
+ RegionCutStatus::Failed => overlay::TEXT_PRIMARY,
+ },
+ );
+ layout.show_at_baseline(
+ ctx,
+ row.x + (row.width - extents.width()) / 2.0 - extents.x_bearing(),
+ row.y + (row.height - extents.height()) / 2.0 - extents.y_bearing(),
+ );
+ let _ = ctx.restore();
+}
+
fn label_style() -> UiTextStyle<'static> {
UiTextStyle {
family: "Sans",
@@ -498,11 +740,15 @@ fn label_style() -> UiTextStyle<'static> {
}
fn toggle_label_style() -> UiTextStyle<'static> {
+ status_label_style(TOGGLE_FONT_SIZE)
+}
+
+fn status_label_style(size: f64) -> UiTextStyle<'static> {
UiTextStyle {
family: "Sans",
slant: cairo::FontSlant::Normal,
weight: cairo::FontWeight::Normal,
- size: TOGGLE_FONT_SIZE,
+ size,
}
}
@@ -510,18 +756,65 @@ fn toggle_label_style() -> UiTextStyle<'static> {
mod tests {
use super::*;
- #[test]
- fn action_bar_prefers_below_then_flips_above_and_clamps_to_the_surface() {
- let centered = RegionActionBar::place(
+ fn sample_bar() -> RegionActionBar {
+ RegionActionBar::place(
RegionSelection {
start: (100.0, 100.0),
end: (300.0, 200.0),
},
(800, 600),
+ )
+ }
+
+ fn rect_inside(inner: RegionActionRect, outer: RegionActionRect) -> bool {
+ inner.x + f64::EPSILON >= outer.x
+ && inner.y + f64::EPSILON >= outer.y
+ && inner.x + inner.width <= outer.x + outer.width + f64::EPSILON
+ && inner.y + inner.height <= outer.y + outer.height + f64::EPSILON
+ }
+
+ fn assert_controls_stay_inside_bar(bar: &RegionActionBar) {
+ let bounds = bar.bounds();
+ for item in bar.items.iter().chain(bar.edit.iter()) {
+ assert!(
+ rect_inside(item.bounds, bounds),
+ "{:?} at ({}, {}) {}x{} leaves bar {bounds:?}",
+ item.action,
+ item.bounds.x,
+ item.bounds.y,
+ item.bounds.width,
+ item.bounds.height
+ );
+ }
+ assert!(
+ rect_inside(bar.toggle.bounds, bounds),
+ "toggle leaves bar {bounds:?}"
);
+ for row in [&bar.items[..], &bar.edit[..]] {
+ for pair in row.windows(2) {
+ assert!(
+ pair[0].bounds.x + pair[0].bounds.width <= pair[1].bounds.x + f64::EPSILON,
+ "{:?} overlaps {:?}",
+ pair[0].action,
+ pair[1].action
+ );
+ }
+ }
+ assert!(
+ bar.items[0].bounds.y + bar.items[0].bounds.height
+ <= bar.edit[0].bounds.y + f64::EPSILON
+ );
+ assert!(
+ bar.edit[0].bounds.y + bar.edit[0].bounds.height <= bar.toggle.bounds.y + f64::EPSILON
+ );
+ }
+
+ #[test]
+ fn action_bar_prefers_below_then_flips_above_and_clamps_to_the_surface() {
+ let centered = sample_bar();
assert_eq!(
centered.bounds(),
- RegionActionRect::new(35.0, 212.0, 330.0, 88.0)
+ RegionActionRect::new(35.0, 212.0, 330.0, BAR_HEIGHT)
);
let flipped = RegionActionBar::place(
@@ -533,56 +826,107 @@ mod tests {
);
assert_eq!(
flipped.bounds(),
- RegionActionRect::new(462.0, 460.0, 330.0, 88.0)
+ RegionActionRect::new(462.0, 560.0 - SELECTION_GAP - BAR_HEIGHT, 330.0, BAR_HEIGHT)
);
}
#[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),
- );
+ let bar = sample_bar();
+ let action_y = bar.items[0].bounds.y + bar.items[0].bounds.height / 2.0;
+ let edit_y = bar.edit[0].bounds.y + bar.edit[0].bounds.height / 2.0;
+ let toggle_y = bar.toggle.bounds.y + bar.toggle.bounds.height / 2.0;
- assert_eq!(bar.hit((80.0, 239.0)), Some(RegionAction::Copy));
- assert_eq!(bar.hit((160.0, 239.0)), Some(RegionAction::Save));
- assert_eq!(bar.hit((240.0, 239.0)), Some(RegionAction::Both));
- assert_eq!(bar.hit((320.0, 239.0)), Some(RegionAction::Board));
+ assert_eq!(bar.hit((80.0, action_y)), Some(RegionAction::Copy));
+ assert_eq!(bar.hit((160.0, action_y)), Some(RegionAction::Save));
+ assert_eq!(bar.hit((240.0, action_y)), Some(RegionAction::Both));
+ assert_eq!(bar.hit((320.0, action_y)), Some(RegionAction::Board));
+ assert_eq!(bar.hit((80.0, edit_y)), Some(RegionAction::CutBand));
+ assert_eq!(bar.hit((160.0, edit_y)), Some(RegionAction::UndoCut));
+ assert_eq!(bar.hit((240.0, edit_y)), Some(RegionAction::RedoCut));
+ assert_eq!(bar.hit((320.0, edit_y)), Some(RegionAction::ResetCuts));
assert_eq!(
- bar.hit((200.0, 279.0)),
+ bar.hit((200.0, toggle_y)),
Some(RegionAction::ToggleIncludeDrawings)
);
- assert_eq!(bar.hit((119.0, 239.0)), None, "inter-item gap");
- assert!(bar.contains((119.0, 239.0)), "bar gaps stay modal-owned");
+ assert_eq!(bar.hit((119.0, action_y)), None, "inter-item gap");
+ assert!(bar.contains((119.0, action_y)), "bar gaps stay modal-owned");
assert_eq!(bar.hit((20.0, 20.0)), None, "outside the bar");
assert!(!bar.contains((20.0, 20.0)));
}
#[test]
- fn action_bar_rows_never_overlap_and_stay_inside_the_padded_frame() {
- let bar = RegionActionBar::place(
- RegionSelection {
- start: (100.0, 100.0),
- end: (300.0, 200.0),
- },
- (800, 600),
+ fn disabled_controls_still_consume_the_bar_but_return_no_enabled_action() {
+ let bar = sample_bar();
+ let availability = RegionActionAvailability {
+ terminal: false,
+ cut: true,
+ undo: false,
+ redo: false,
+ reset: false,
+ };
+ let action_y = bar.items[0].bounds.y + bar.items[0].bounds.height / 2.0;
+ assert_eq!(bar.hit((80.0, action_y)), Some(RegionAction::Copy));
+ assert_eq!(bar.enabled_hit((80.0, action_y), availability), None);
+ assert!(bar.contains((80.0, action_y)));
+ assert_eq!(
+ bar.enabled_hit(
+ (
+ bar.edit[0].bounds.x + bar.edit[0].bounds.width / 2.0,
+ bar.edit[0].bounds.y + bar.edit[0].bounds.height / 2.0
+ ),
+ availability
+ ),
+ Some(RegionAction::CutBand)
);
+ }
+
+ #[test]
+ fn action_bar_rows_never_overlap_and_stay_inside_the_padded_frame() {
+ let bar = sample_bar();
let bounds = bar.bounds();
let toggle = bar.toggle.bounds;
for item in bar.items {
assert!(item.bounds.y >= bounds.y + BAR_PADDING);
- assert!(item.bounds.y + item.bounds.height <= toggle.y - ROW_GAP + f64::EPSILON);
+ assert!(
+ item.bounds.y + item.bounds.height <= bar.edit[0].bounds.y - ROW_GAP + f64::EPSILON
+ );
assert!(item.bounds.x >= bounds.x + BAR_PADDING);
assert!(item.bounds.x + item.bounds.width <= bounds.x + bounds.width - BAR_PADDING);
assert_eq!(item.bounds.width, ACTION_ITEM_WIDTH);
}
+ for item in bar.edit {
+ assert!(item.bounds.y >= bar.items[0].bounds.y + bar.items[0].bounds.height);
+ assert!(item.bounds.y + item.bounds.height <= toggle.y - ROW_GAP + f64::EPSILON);
+ assert_eq!(item.bounds.width, ACTION_ITEM_WIDTH);
+ }
assert!(toggle.y + toggle.height <= bounds.y + bounds.height - BAR_PADDING);
}
+ #[test]
+ fn narrow_and_short_surfaces_keep_controls_inside_the_bar() {
+ let selection = RegionSelection {
+ start: (10.0, 10.0),
+ end: (40.0, 30.0),
+ };
+ for surface in [(200, 80), (80, 40), (40, 600), (800, 36)] {
+ let bar = RegionActionBar::place(selection, surface);
+ assert_controls_stay_inside_bar(&bar);
+ let action = bar.items[0].bounds;
+ if action.width > 1.0 && action.height > 1.0 {
+ assert_eq!(
+ bar.hit((
+ action.x + action.width / 2.0,
+ action.y + action.height / 2.0
+ )),
+ Some(RegionAction::Copy),
+ "typed hit on {surface:?}"
+ );
+ }
+ }
+ }
+
#[test]
fn action_bar_exposes_the_requested_labels_and_shortcuts() {
assert_eq!(RegionAction::Copy.label(), "Copy");
@@ -593,11 +937,18 @@ mod tests {
assert_eq!(RegionAction::Both.shortcut(), "Enter");
assert_eq!(RegionAction::Board.label(), "Board");
assert_eq!(RegionAction::Board.shortcut(), "B");
+ assert_eq!(RegionAction::CutBand.label(), "Cut");
+ assert_eq!(RegionAction::CutBand.shortcut(), "X");
+ assert_eq!(RegionAction::UndoCut.shortcut(), "Ctrl+Z");
+ assert_eq!(RegionAction::RedoCut.shortcut(), "Ctrl+Y");
assert_eq!(
RegionAction::ToggleIncludeDrawings.label(),
"Include drawings in exports"
);
assert_eq!(RegionAction::ToggleIncludeDrawings.shortcut(), "D");
+ assert!(RegionAction::Copy.is_terminal());
+ assert!(!RegionAction::CutBand.is_terminal());
+ assert!(!RegionAction::ToggleIncludeDrawings.is_terminal());
}
#[test]
@@ -607,6 +958,10 @@ mod tests {
RegionAction::Copy,
RegionAction::Save,
RegionAction::Board,
+ RegionAction::CutBand,
+ RegionAction::UndoCut,
+ RegionAction::RedoCut,
+ RegionAction::ResetCuts,
RegionAction::ToggleIncludeDrawings,
] {
assert!(!action.is_primary(), "{action:?} must stay neutral");
@@ -615,52 +970,45 @@ mod tests {
#[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 bar = sample_bar();
let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 800, 600).unwrap();
let ctx = cairo::Context::new(&surface).unwrap();
- render_region_action_bar(&ctx, bar, Some(RegionAction::Both), true);
+ render_region_action_bar(
+ &ctx,
+ bar,
+ RegionActionBarVisual::simple(Some(RegionAction::Both), true),
+ );
drop(ctx);
surface.flush();
let stride = surface.stride() as usize;
let data = surface.data().unwrap();
let alpha = |x: usize, y: usize| data[y * stride + x * 4 + 3];
+ let action_y = (bar.items[0].bounds.y + bar.items[0].bounds.height / 2.0) as usize;
+ let toggle_y = (bar.toggle.bounds.y + bar.toggle.bounds.height / 2.0) as usize;
- assert!(alpha(40, 250) > 0, "bar surface");
+ assert!(alpha(40, action_y) > 0, "bar surface");
for x in [80, 160, 240, 320] {
- assert!(alpha(x, 239) > 0, "control at x={x}");
+ assert!(alpha(x, action_y) > 0, "control at x={x}");
}
- assert!(alpha(56, 279) > 0, "checked drawings checkbox");
+ assert!(alpha(56, toggle_y) > 0, "checked drawings checkbox");
assert_eq!(alpha(20, 20), 0, "outside remains untouched");
}
#[test]
fn the_drawings_checkbox_carries_the_state_instead_of_a_full_width_slab() {
- let bar = RegionActionBar::place(
- RegionSelection {
- start: (100.0, 100.0),
- end: (300.0, 200.0),
- },
- (800, 600),
- );
+ let bar = sample_bar();
+ let toggle_y = (bar.toggle.bounds.y + bar.toggle.bounds.height / 2.0) as usize;
let row_alpha = |checked: bool| {
let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 800, 600).unwrap();
let ctx = cairo::Context::new(&surface).unwrap();
- render_region_action_bar(&ctx, bar, None, checked);
+ render_region_action_bar(&ctx, bar, RegionActionBarVisual::simple(None, checked));
drop(ctx);
surface.flush();
let stride = surface.stride() as usize;
let data = surface.data().unwrap();
- // Far right of the toggle row, clear of the checkbox, the label and
- // the keycap chip: unfilled in both states.
(
- u32::from(data[279 * stride + 300 * 4 + 3]),
- u32::from(data[279 * stride + 56 * 4 + 3]),
+ u32::from(data[toggle_y * stride + 300 * 4 + 3]),
+ u32::from(data[toggle_y * stride + 56 * 4 + 3]),
)
};
@@ -669,4 +1017,104 @@ mod tests {
assert_eq!(off_row, on_row, "the row background must not change");
assert!(on_box > 0 && off_box > 0, "the box is drawn either way");
}
+
+ #[test]
+ fn updating_and_failed_preview_states_paint_status_text() {
+ let bar = sample_bar();
+ 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,
+ RegionActionBarVisual {
+ hovered: None,
+ include_drawings: false,
+ availability: RegionActionAvailability {
+ terminal: false,
+ cut: true,
+ undo: true,
+ redo: false,
+ reset: true,
+ },
+ cut_armed: true,
+ status: Some(RegionCutStatus::Updating),
+ },
+ );
+ drop(ctx);
+ surface.flush();
+ let stride = surface.stride() as usize;
+ let data = surface.data().unwrap();
+ let status = bar.status_bounds().unwrap();
+ let status_y = (status.y + status.height / 2.0) as usize;
+ let alpha = data[status_y * stride + 200 * 4 + 3];
+ assert!(alpha > 0, "status caption is visible");
+ }
+
+ fn paint_bar(
+ width: i32,
+ height: i32,
+ bar: RegionActionBar,
+ status: Option,
+ ) -> (usize, Vec) {
+ let mut surface =
+ cairo::ImageSurface::create(cairo::Format::ARgb32, width, height).unwrap();
+ let ctx = cairo::Context::new(&surface).unwrap();
+ render_region_action_bar(
+ &ctx,
+ bar,
+ RegionActionBarVisual {
+ hovered: None,
+ include_drawings: false,
+ availability: RegionActionAvailability {
+ terminal: false,
+ cut: true,
+ undo: true,
+ redo: false,
+ reset: true,
+ },
+ cut_armed: false,
+ status,
+ },
+ );
+ drop(ctx);
+ surface.flush();
+ let stride = surface.stride() as usize;
+ let pixels = surface.data().unwrap().to_vec();
+ (stride, pixels)
+ }
+
+ #[test]
+ fn short_surface_status_paint_stays_inside_its_row() {
+ let selection = RegionSelection {
+ start: (10.0, 10.0),
+ end: (40.0, 30.0),
+ };
+ for surface in [(800, 36), (800, 100), (200, 80)] {
+ let bar = RegionActionBar::place(selection, surface);
+ let width = i32::try_from(surface.0).unwrap();
+ let height = i32::try_from(surface.1).unwrap();
+ let (stride, without) = paint_bar(width, height, bar, None);
+ let (_, with_status) = paint_bar(width, height, bar, Some(RegionCutStatus::Failed));
+ let row = bar.status_bounds();
+ for y in 0..surface.1 as usize {
+ for x in 0..surface.0 as usize {
+ let offset = y * stride + x * 4;
+ if without[offset..offset + 4] == with_status[offset..offset + 4] {
+ continue;
+ }
+ let Some(row) = row else {
+ panic!("status painted with no status row on {surface:?}");
+ };
+ assert!(
+ row.contains((x as f64 + 0.5, y as f64 + 0.5)),
+ "status paint at ({x}, {y}) left the {row:?} row on {surface:?}"
+ );
+ assert!(
+ bar.bounds.contains((x as f64 + 0.5, y as f64 + 0.5)),
+ "status paint at ({x}, {y}) left the bar on {surface:?}"
+ );
+ }
+ }
+ }
+ }
}
diff --git a/src/ui/region_capture_picker.rs b/src/ui/region_capture_picker.rs
index 9c57a3f6..75785f77 100644
--- a/src/ui/region_capture_picker.rs
+++ b/src/ui/region_capture_picker.rs
@@ -1,10 +1,13 @@
+use crate::capture::CutAxis;
use crate::input::SelectionHandle;
use crate::input::state::RegionSelection;
+use crate::screen_pixels::PackedArgb32;
use crate::util::Rect;
use super::primitives::{draw_rounded_rect, text_extents_for};
use super::region_action_bar::{
- RegionAction, RegionActionBar, RegionActionRect, render_region_action_bar,
+ RegionAction, RegionActionAvailability, RegionActionBar, RegionActionBarVisual,
+ RegionActionRect, RegionCutStatus, render_region_action_bar,
};
use super::region_resize_handles::{RegionResizeHandles, render_region_resize_handles};
@@ -92,9 +95,31 @@ pub(crate) struct RegionCapturePickerVisual<'a> {
pub action_bar: Option,
pub hovered_action: Option,
pub include_drawings: bool,
+ pub cut: RegionCaptureCutVisual<'a>,
pub window: RegionCaptureWindowVisual<'a>,
}
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub(crate) struct RegionCutPreviewVisual<'a> {
+ pub pixels: &'a PackedArgb32,
+ pub display: RegionSelection,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq)]
+pub(crate) struct RegionCutDragVisual {
+ pub axis: CutAxis,
+ pub band: RegionSelection,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Default)]
+pub(crate) struct RegionCaptureCutVisual<'a> {
+ pub preview: Option>,
+ pub drag: Option,
+ pub availability: RegionActionAvailability,
+ pub cut_armed: bool,
+ pub status: Option,
+}
+
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) struct RegionCaptureLoupeVisual {
pub pointer: (f64, f64),
@@ -242,6 +267,9 @@ pub(crate) fn render_region_capture_picker(
};
let _ = ctx.save();
+ if let Some(preview) = visual.cut.preview {
+ paint_cut_preview(ctx, preview);
+ }
if visual.show_scrim {
ctx.set_source_rgba(SCRIM.0, SCRIM.1, SCRIM.2, SCRIM.3);
ctx.rectangle(0.0, 0.0, width, height);
@@ -266,6 +294,9 @@ pub(crate) fn render_region_capture_picker(
if let Some(handles) = visual.resize_handles {
render_region_resize_handles(ctx, handles, visual.hovered_handle);
}
+ if let Some(drag) = visual.cut.drag {
+ draw_cut_drag(ctx, drag);
+ }
if !visual.window.active && !visual.review {
draw_crosshair(ctx, visual.pointer, (width, height));
}
@@ -299,13 +330,96 @@ pub(crate) fn render_region_capture_picker(
render_region_action_bar(
ctx,
action_bar,
- visual.hovered_action,
- visual.include_drawings,
+ RegionActionBarVisual {
+ hovered: visual.hovered_action,
+ include_drawings: visual.include_drawings,
+ availability: visual.cut.availability,
+ cut_armed: visual.cut.cut_armed,
+ status: visual.cut.status,
+ },
);
}
let _ = ctx.restore();
}
+fn paint_cut_preview(ctx: &cairo::Context, preview: RegionCutPreviewVisual<'_>) {
+ let pixels = preview.pixels;
+ let Ok(width) = i32::try_from(pixels.width()) else {
+ return;
+ };
+ let Ok(height) = i32::try_from(pixels.height()) else {
+ return;
+ };
+ if width <= 0 || height <= 0 {
+ return;
+ }
+ let (x, y, display_width, display_height) = normalized_rect(preview.display);
+ if display_width <= 0.0 || display_height <= 0.0 {
+ return;
+ }
+ // SAFETY: Cairo borrows `pixels.data` for this surface. The buffer is
+ // owned by the Review preview and stays alive until the surface is
+ // dropped at the end of this function. The API wants `*mut u8` even
+ // though this path only reads pixels; we never write through the
+ // pointer, and no other alias mutates the buffer while Cairo holds it.
+ let surface = unsafe {
+ cairo::ImageSurface::create_for_data_unsafe(
+ pixels.data().as_ptr() as *mut u8,
+ cairo::Format::ARgb32,
+ width,
+ height,
+ pixels.stride(),
+ )
+ };
+ let Ok(surface) = surface else {
+ return;
+ };
+ let _ = ctx.save();
+ ctx.rectangle(x, y, display_width, display_height);
+ ctx.clip();
+ ctx.translate(x, y);
+ ctx.scale(
+ display_width / f64::from(pixels.width()),
+ display_height / f64::from(pixels.height()),
+ );
+ // Place the surface in the translated/scaled user space, matching the
+ // frozen-backdrop path: the CTM maps one source pixel onto one displayed
+ // output pixel, and nearest-neighbor keeps cut seams crisp.
+ if ctx.set_source_surface(&surface, 0.0, 0.0).is_ok() {
+ ctx.source().set_filter(cairo::Filter::Nearest);
+ ctx.source().set_extend(cairo::Extend::None);
+ let _ = ctx.paint();
+ }
+ let _ = ctx.restore();
+}
+
+fn draw_cut_drag(ctx: &cairo::Context, drag: RegionCutDragVisual) {
+ let (x, y, width, height) = normalized_rect(drag.band);
+ if width <= 0.0 || height <= 0.0 {
+ return;
+ }
+ ctx.set_source_rgba(0.05, 0.08, 0.14, 0.48);
+ ctx.rectangle(x, y, width, height);
+ let _ = ctx.fill();
+ ctx.set_source_rgba(1.0, 1.0, 1.0, 0.92);
+ ctx.set_line_width(1.0);
+ match drag.axis {
+ CutAxis::Columns => {
+ ctx.move_to(x + 0.5, y);
+ ctx.line_to(x + 0.5, y + height);
+ ctx.move_to(x + width - 0.5, y);
+ ctx.line_to(x + width - 0.5, y + height);
+ }
+ CutAxis::Rows => {
+ ctx.move_to(x, y + 0.5);
+ ctx.line_to(x + width, y + 0.5);
+ ctx.move_to(x, y + height - 0.5);
+ ctx.line_to(x + width, y + height - 0.5);
+ }
+ }
+ let _ = ctx.stroke();
+}
+
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);
@@ -696,6 +810,7 @@ mod tests {
action_bar: None,
hovered_action: None,
include_drawings: false,
+ cut: Default::default(),
window: RegionCaptureWindowVisual::disabled(),
},
|_x, _y| None,
@@ -740,6 +855,7 @@ mod tests {
action_bar: None,
hovered_action: None,
include_drawings: false,
+ cut: Default::default(),
window: RegionCaptureWindowVisual {
available: true,
active: true,
@@ -780,6 +896,7 @@ mod tests {
action_bar: None,
hovered_action: None,
include_drawings: false,
+ cut: Default::default(),
window: RegionCaptureWindowVisual {
available: true,
active: true,
@@ -827,6 +944,7 @@ mod tests {
action_bar: None,
hovered_action: None,
include_drawings: false,
+ cut: Default::default(),
window: RegionCaptureWindowVisual {
available: true,
active: true,
@@ -873,6 +991,7 @@ mod tests {
action_bar: None,
hovered_action: None,
include_drawings: false,
+ cut: Default::default(),
window: RegionCaptureWindowVisual::disabled(),
},
|_x, _y| None,
@@ -940,6 +1059,7 @@ mod tests {
action_bar: None,
hovered_action: None,
include_drawings: false,
+ cut: Default::default(),
window: RegionCaptureWindowVisual::disabled(),
},
|_x, _y| None,
@@ -980,6 +1100,7 @@ mod tests {
action_bar: Some(bar),
hovered_action: Some(crate::ui::RegionAction::Both),
include_drawings: true,
+ cut: Default::default(),
window: RegionCaptureWindowVisual::disabled(),
},
|_x, _y| None,
@@ -1090,6 +1211,7 @@ mod tests {
action_bar: Some(bar),
hovered_action: None,
include_drawings: false,
+ cut: Default::default(),
window: RegionCaptureWindowVisual::disabled(),
},
|_x, _y| None,
@@ -1191,6 +1313,7 @@ mod tests {
action_bar: None,
hovered_action: None,
include_drawings: false,
+ cut: Default::default(),
window: RegionCaptureWindowVisual::disabled(),
},
|_x, _y| None,
@@ -1245,4 +1368,84 @@ mod tests {
assert!(data[98 * stride + 88 * 4 + 3] > 0, "loupe center pixel");
assert_eq!(data[5 * stride + 5 * 4 + 3], 0, "outside untouched");
}
+
+ #[test]
+ fn accepted_cut_preview_paints_before_the_scrim_hole() {
+ let pixels = PackedArgb32::new(
+ 2,
+ 1,
+ 8,
+ [0x33, 0x22, 0x11, 0xFF, 0xCC, 0xBB, 0xAA, 0xFF].to_vec(),
+ )
+ .unwrap();
+ let created = unsafe {
+ cairo::ImageSurface::create_for_data_unsafe(
+ pixels.data().as_ptr() as *mut u8,
+ cairo::Format::ARgb32,
+ 2,
+ 1,
+ 8,
+ )
+ };
+ assert!(
+ created.is_ok(),
+ "preview pixels must be a valid Cairo source: {created:?}"
+ );
+ drop(created);
+ // Large enough that the 4–20px corner arms cannot cover the samples,
+ // and far enough from the 1px frame. 2×1 source scales 16× onto this
+ // 32×16 display: (18, 18) is inside the first source pixel, (34, 18)
+ // inside the second.
+ let display = RegionSelection {
+ start: (10.0, 10.0),
+ end: (42.0, 26.0),
+ };
+ let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 60, 40).unwrap();
+ let ctx = cairo::Context::new(&surface).unwrap();
+ render_region_capture_picker(
+ &ctx,
+ 60,
+ 40,
+ RegionCapturePickerVisual {
+ selection: Some(display),
+ pointer: (4.0, 4.0),
+ measurement: None,
+ show_scrim: true,
+ review: true,
+ resize_handles: None,
+ hovered_handle: None,
+ show_legend: false,
+ loupe: None,
+ action_bar: None,
+ hovered_action: None,
+ include_drawings: false,
+ cut: RegionCaptureCutVisual {
+ preview: Some(RegionCutPreviewVisual {
+ pixels: &pixels,
+ display,
+ }),
+ ..Default::default()
+ },
+ window: RegionCaptureWindowVisual::disabled(),
+ },
+ |_x, _y| None,
+ );
+ drop(ctx);
+ surface.flush();
+ let stride = surface.stride() as usize;
+ let data = surface.data().unwrap();
+ let alpha = |x: usize, y: usize| data[y * stride + x * 4 + 3];
+ assert!(alpha(18, 18) > 0, "preview occupies the displayed output");
+ assert!(alpha(2, 2) > 0, "vacated source is dimmed by the scrim");
+ assert_eq!(
+ &data[18 * stride + 18 * 4..18 * stride + 18 * 4 + 4],
+ &[0x33, 0x22, 0x11, 0xFF],
+ "first source pixel fills the left half of the displayed output"
+ );
+ assert_eq!(
+ &data[18 * stride + 34 * 4..18 * stride + 34 * 4 + 4],
+ &[0xCC, 0xBB, 0xAA, 0xFF],
+ "second source pixel fills the right half of the displayed output"
+ );
+ }
}