Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <kbd>X</kbd>) and drag across the selection; later cuts use the
already-collapsed output. **Undo** (<kbd>Ctrl+Z</kbd>), **Redo**
(<kbd>Ctrl+Y</kbd> or <kbd>Ctrl+Shift+Z</kbd>), 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
Expand Down
9 changes: 8 additions & 1 deletion docs/codebase-overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`). |
Expand All @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/backend/wayland/backend/event_loop/capture.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
40 changes: 40 additions & 0 deletions src/backend/wayland/handlers/keyboard/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -625,6 +628,18 @@ fn region_review_key_action(key: Key, ctrl: bool, shift: bool) -> Option<RegionR
Key::Char('d' | 'D') if !ctrl => 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,
}
}
Expand Down Expand Up @@ -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]
Expand Down
54 changes: 54 additions & 0 deletions src/backend/wayland/handlers/pointer/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ struct ScreenModalCursorContext {
over_selection: bool,
/// The grip being dragged, or hovered when nothing is being dragged.
resize_handle: Option<SelectionHandle>,
/// 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
Expand All @@ -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;
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -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(),
}
}

Expand Down Expand Up @@ -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
Expand Down
30 changes: 15 additions & 15 deletions src/backend/wayland/handlers/pointer/press.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -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 => {
Expand Down
16 changes: 8 additions & 8 deletions src/backend/wayland/handlers/tablet/tool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
}
Expand Down
27 changes: 15 additions & 12 deletions src/backend/wayland/handlers/touch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
17 changes: 17 additions & 0 deletions src/backend/wayland/runtime_operation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Mutex<Option<u64>>>,
Expand Down Expand Up @@ -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<dyn FnOnce() + Send>) -> std::io::Result<()>,
) -> Result<RuntimeOperationId, RuntimeOperationSubmitFailure<C>> {
self.try_submit_with_spawner(context, operation, spawn)
}

pub(in crate::backend::wayland) fn poll(&mut self) -> RuntimeOperationPoll<C, T> {
let Some(active) = self.active.take() else {
return RuntimeOperationPoll::Idle;
Expand Down
7 changes: 7 additions & 0 deletions src/backend/wayland/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions src/backend/wayland/state/core/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand Down Expand Up @@ -159,6 +161,7 @@ impl WaylandState {
clipboard_text_paste,
pending_text_paste: Default::default(),
window_query,
region_cut_preview,
ocr,
gtk_toolbar: None,
onboarding,
Expand Down
Loading
Loading