diff --git a/README.md b/README.md index aa34a11f..e33b1223 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,7 @@ journalctl --user -u wayscriber.service -f | Ctrl+Shift+C | Select region → clipboard | | Ctrl+Shift+S | Select region → save PNG | | Ctrl+Shift+O | Capture active window | +| Ctrl+Alt+O | Open last capture folder | Requires `wl-clipboard`, `grim`, `slurp`. Falls back to xdg-desktop-portal if missing. @@ -354,6 +355,9 @@ Press F1 or F10 at any time for the in-app cheat sheet. |--------|-----| | Undo | Ctrl+Z | | Redo | Ctrl+Shift+Z / Ctrl+Y | +| Copy selection | Ctrl+Alt+C | +| Paste selection | Ctrl+Alt+V | +| Select all | Ctrl+A | | Eraser | D | | Toggle eraser mode | Ctrl+Shift+E | | Clear all | E | diff --git a/config.example.toml b/config.example.toml index fb1667db..a72a4c35 100644 --- a/config.example.toml +++ b/config.example.toml @@ -504,6 +504,13 @@ redo = ["Ctrl+Shift+Z", "Ctrl+Y"] # Duplicate currently selected annotations duplicate_selection = ["Ctrl+D"] +# Copy/paste selected annotations +copy_selection = ["Ctrl+Alt+C"] +paste_selection = ["Ctrl+Alt+V"] + +# Select all annotations +select_all = ["Ctrl+A"] + # Reorder selected annotations within the stack move_selection_to_front = ["]"] move_selection_to_back = ["["] @@ -518,7 +525,7 @@ nudge_selection_right = ["ArrowRight", "Shift+PageDown"] nudge_selection_up_large = ["PageUp"] nudge_selection_down_large = ["PageDown"] -# Move selection to edges (left/right by default; top/bottom after a vertical nudge) +# Move selection to horizontal edges (left/right) move_selection_to_start = ["Home"] move_selection_to_end = ["End"] @@ -596,6 +603,9 @@ capture_file_selection = ["Ctrl+Shift+S"] capture_clipboard_region = ["Ctrl+6"] capture_file_region = ["Ctrl+Shift+6"] +# Open the most recent capture folder +open_capture_folder = ["Ctrl+Alt+O"] + # Toggle frozen mode toggle_frozen_mode = ["Ctrl+Shift+F"] diff --git a/configurator/src/models/keybindings.rs b/configurator/src/models/keybindings.rs index 6e62472a..73d27c96 100644 --- a/configurator/src/models/keybindings.rs +++ b/configurator/src/models/keybindings.rs @@ -20,6 +20,9 @@ pub enum KeybindingField { EnterStickyNoteMode, ClearCanvas, Undo, + CopySelection, + PasteSelection, + SelectAll, IncreaseThickness, DecreaseThickness, SelectPenTool, @@ -50,6 +53,7 @@ pub enum KeybindingField { CaptureFileSelection, CaptureClipboardRegion, CaptureFileRegion, + OpenCaptureFolder, ToggleFrozenMode, ZoomIn, ZoomOut, @@ -128,6 +132,9 @@ impl KeybindingField { Self::EnterStickyNoteMode, Self::ClearCanvas, Self::Undo, + Self::CopySelection, + Self::PasteSelection, + Self::SelectAll, Self::IncreaseThickness, Self::DecreaseThickness, Self::SelectPenTool, @@ -158,6 +165,7 @@ impl KeybindingField { Self::CaptureFileSelection, Self::CaptureClipboardRegion, Self::CaptureFileRegion, + Self::OpenCaptureFolder, Self::ToggleFrozenMode, Self::ZoomIn, Self::ZoomOut, @@ -189,6 +197,9 @@ impl KeybindingField { Self::EnterStickyNoteMode => "Enter sticky note mode", Self::ClearCanvas => "Clear canvas", Self::Undo => "Undo", + Self::CopySelection => "Copy selection", + Self::PasteSelection => "Paste selection", + Self::SelectAll => "Select all", Self::IncreaseThickness => "Increase thickness", Self::DecreaseThickness => "Decrease thickness", Self::SelectPenTool => "Select pen tool", @@ -219,6 +230,7 @@ impl KeybindingField { Self::CaptureFileSelection => "File selection", Self::CaptureClipboardRegion => "Clipboard region", Self::CaptureFileRegion => "File region", + Self::OpenCaptureFolder => "Open capture folder", Self::ToggleFrozenMode => "Toggle freeze", Self::ZoomIn => "Zoom in", Self::ZoomOut => "Zoom out", @@ -250,6 +262,9 @@ impl KeybindingField { Self::EnterStickyNoteMode => "enter_sticky_note_mode", Self::ClearCanvas => "clear_canvas", Self::Undo => "undo", + Self::CopySelection => "copy_selection", + Self::PasteSelection => "paste_selection", + Self::SelectAll => "select_all", Self::IncreaseThickness => "increase_thickness", Self::DecreaseThickness => "decrease_thickness", Self::SelectPenTool => "select_pen_tool", @@ -280,6 +295,7 @@ impl KeybindingField { Self::CaptureFileSelection => "capture_file_selection", Self::CaptureClipboardRegion => "capture_clipboard_region", Self::CaptureFileRegion => "capture_file_region", + Self::OpenCaptureFolder => "open_capture_folder", Self::ToggleFrozenMode => "toggle_frozen_mode", Self::ZoomIn => "zoom_in", Self::ZoomOut => "zoom_out", @@ -311,6 +327,9 @@ impl KeybindingField { Self::EnterStickyNoteMode => &config.enter_sticky_note_mode, Self::ClearCanvas => &config.clear_canvas, Self::Undo => &config.undo, + Self::CopySelection => &config.copy_selection, + Self::PasteSelection => &config.paste_selection, + Self::SelectAll => &config.select_all, Self::IncreaseThickness => &config.increase_thickness, Self::DecreaseThickness => &config.decrease_thickness, Self::SelectPenTool => &config.select_pen_tool, @@ -341,6 +360,7 @@ impl KeybindingField { Self::CaptureFileSelection => &config.capture_file_selection, Self::CaptureClipboardRegion => &config.capture_clipboard_region, Self::CaptureFileRegion => &config.capture_file_region, + Self::OpenCaptureFolder => &config.open_capture_folder, Self::ToggleFrozenMode => &config.toggle_frozen_mode, Self::ZoomIn => &config.zoom_in, Self::ZoomOut => &config.zoom_out, @@ -372,6 +392,9 @@ impl KeybindingField { Self::EnterStickyNoteMode => config.enter_sticky_note_mode = value, Self::ClearCanvas => config.clear_canvas = value, Self::Undo => config.undo = value, + Self::CopySelection => config.copy_selection = value, + Self::PasteSelection => config.paste_selection = value, + Self::SelectAll => config.select_all = value, Self::IncreaseThickness => config.increase_thickness = value, Self::DecreaseThickness => config.decrease_thickness = value, Self::SelectPenTool => config.select_pen_tool = value, @@ -402,6 +425,7 @@ impl KeybindingField { Self::CaptureFileSelection => config.capture_file_selection = value, Self::CaptureClipboardRegion => config.capture_clipboard_region = value, Self::CaptureFileRegion => config.capture_file_region = value, + Self::OpenCaptureFolder => config.open_capture_folder = value, Self::ToggleFrozenMode => config.toggle_frozen_mode = value, Self::ZoomIn => config.zoom_in = value, Self::ZoomOut => config.zoom_out = value, diff --git a/docs/CONFIG.md b/docs/CONFIG.md index ccea88d4..590403c3 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -509,6 +509,13 @@ redo = ["Ctrl+Shift+Z", "Ctrl+Y"] # Duplicate current selection duplicate_selection = ["Ctrl+D"] +# Copy/paste selection +copy_selection = ["Ctrl+Alt+C"] +paste_selection = ["Ctrl+Alt+V"] + +# Select all annotations +select_all = ["Ctrl+A"] + # Nudge selection (hold Shift for a larger step) nudge_selection_up = ["ArrowUp"] nudge_selection_down = ["ArrowDown"] @@ -519,7 +526,7 @@ nudge_selection_right = ["ArrowRight", "Shift+PageDown"] nudge_selection_up_large = ["PageUp"] nudge_selection_down_large = ["PageDown"] -# Move selection to edges (left/right by default; top/bottom after a vertical nudge) +# Move selection to horizontal edges (left/right) move_selection_to_start = ["Home"] move_selection_to_end = ["End"] @@ -584,6 +591,9 @@ capture_file_selection = ["Ctrl+Shift+S"] capture_clipboard_region = ["Ctrl+6"] capture_file_region = ["Ctrl+Shift+6"] +# Open the most recent capture folder +open_capture_folder = ["Ctrl+Alt+O"] + # Toggle frozen mode toggle_frozen_mode = ["Ctrl+Shift+F"] @@ -673,7 +683,9 @@ clear_canvas = ["X"] - Duplicate keybindings across actions will be detected and reported at startup **Defaults:** -All defaults match the original hardcoded keybindings to maintain compatibility. +Defaults match the original hardcoded keybindings where possible. Copy/paste selection uses +Ctrl+Alt+C/Ctrl+Alt+V, so the clipboard-selection capture shortcut +defaults to Ctrl+Shift+C to avoid conflicts. ## Creating Your Configuration diff --git a/src/backend/wayland/backend.rs b/src/backend/wayland/backend.rs index c6d7cf68..b3ce89ba 100644 --- a/src/backend/wayland/backend.rs +++ b/src/backend/wayland/backend.rs @@ -922,6 +922,18 @@ impl WaylandBackend { message_parts.join(" • ") }; + let open_folder_binding = state + .config + .keybindings + .open_capture_folder + .first() + .map(|binding| binding.as_str()); + state.input_state.set_capture_feedback( + result.saved_path.as_deref(), + result.copied_to_clipboard, + open_folder_binding, + ); + notification::send_notification_async( &state.tokio_handle, "Screenshot Captured".to_string(), diff --git a/src/config/keybindings.rs b/src/config/keybindings.rs index f878bd02..a2c46b9a 100644 --- a/src/config/keybindings.rs +++ b/src/config/keybindings.rs @@ -25,6 +25,9 @@ pub enum Action { UndoAllDelayed, RedoAllDelayed, DuplicateSelection, + CopySelection, + PasteSelection, + SelectAll, MoveSelectionToFront, MoveSelectionToBack, NudgeSelectionUp, @@ -93,6 +96,7 @@ pub enum Action { CaptureFileSelection, CaptureClipboardRegion, CaptureFileRegion, + OpenCaptureFolder, ToggleFrozenMode, ZoomIn, ZoomOut, @@ -246,6 +250,15 @@ pub struct KeybindingsConfig { #[serde(default = "default_duplicate_selection")] pub duplicate_selection: Vec, + #[serde(default = "default_copy_selection")] + pub copy_selection: Vec, + + #[serde(default = "default_paste_selection")] + pub paste_selection: Vec, + + #[serde(default = "default_select_all")] + pub select_all: Vec, + #[serde(default = "default_move_selection_to_front")] pub move_selection_to_front: Vec, @@ -411,6 +424,9 @@ pub struct KeybindingsConfig { #[serde(default = "default_capture_file_region")] pub capture_file_region: Vec, + #[serde(default = "default_open_capture_folder")] + pub open_capture_folder: Vec, + #[serde(default = "default_toggle_frozen_mode")] pub toggle_frozen_mode: Vec, @@ -475,6 +491,9 @@ impl Default for KeybindingsConfig { undo_all_delayed: Vec::new(), redo_all_delayed: Vec::new(), duplicate_selection: default_duplicate_selection(), + copy_selection: default_copy_selection(), + paste_selection: default_paste_selection(), + select_all: default_select_all(), move_selection_to_front: default_move_selection_to_front(), move_selection_to_back: default_move_selection_to_back(), nudge_selection_up: default_nudge_selection_up(), @@ -531,6 +550,7 @@ impl Default for KeybindingsConfig { capture_file_selection: default_capture_file_selection(), capture_clipboard_region: default_capture_clipboard_region(), capture_file_region: default_capture_file_region(), + open_capture_folder: default_open_capture_folder(), toggle_frozen_mode: default_toggle_frozen_mode(), zoom_in: default_zoom_in(), zoom_out: default_zoom_out(), @@ -617,6 +637,18 @@ impl KeybindingsConfig { insert_binding(binding_str, Action::DuplicateSelection)?; } + for binding_str in &self.copy_selection { + insert_binding(binding_str, Action::CopySelection)?; + } + + for binding_str in &self.paste_selection { + insert_binding(binding_str, Action::PasteSelection)?; + } + + for binding_str in &self.select_all { + insert_binding(binding_str, Action::SelectAll)?; + } + for binding_str in &self.move_selection_to_front { insert_binding(binding_str, Action::MoveSelectionToFront)?; } @@ -863,6 +895,10 @@ impl KeybindingsConfig { insert_binding(binding_str, Action::CaptureFileRegion)?; } + for binding_str in &self.open_capture_folder { + insert_binding(binding_str, Action::OpenCaptureFolder)?; + } + for binding_str in &self.toggle_frozen_mode { insert_binding(binding_str, Action::ToggleFrozenMode)?; } @@ -969,6 +1005,18 @@ fn default_duplicate_selection() -> Vec { vec!["Ctrl+D".to_string()] } +fn default_copy_selection() -> Vec { + vec!["Ctrl+Alt+C".to_string()] +} + +fn default_paste_selection() -> Vec { + vec!["Ctrl+Alt+V".to_string()] +} + +fn default_select_all() -> Vec { + vec!["Ctrl+A".to_string()] +} + fn default_move_selection_to_front() -> Vec { vec!["]".to_string()] } @@ -1193,6 +1241,10 @@ fn default_capture_file_region() -> Vec { vec!["Ctrl+Shift+6".to_string()] } +fn default_open_capture_folder() -> Vec { + vec!["Ctrl+Alt+O".to_string()] +} + fn default_toggle_frozen_mode() -> Vec { vec!["Ctrl+Shift+F".to_string()] } @@ -1391,6 +1443,18 @@ mod tests { let move_back = KeyBinding::parse("[").unwrap(); assert_eq!(map.get(&move_back), Some(&Action::MoveSelectionToBack)); + let copy_selection = KeyBinding::parse("Ctrl+Alt+C").unwrap(); + assert_eq!(map.get(©_selection), Some(&Action::CopySelection)); + + let capture_selection = KeyBinding::parse("Ctrl+Shift+C").unwrap(); + assert_eq!( + map.get(&capture_selection), + Some(&Action::CaptureClipboardSelection) + ); + + let select_all = KeyBinding::parse("Ctrl+A").unwrap(); + assert_eq!(map.get(&select_all), Some(&Action::SelectAll)); + let toggle_highlight = KeyBinding::parse("Ctrl+Shift+H").unwrap(); assert_eq!( map.get(&toggle_highlight), diff --git a/src/input/state/actions.rs b/src/input/state/actions.rs index 4f9555a7..8dc8bec1 100644 --- a/src/input/state/actions.rs +++ b/src/input/state/actions.rs @@ -68,6 +68,17 @@ impl InputState { } } + if matches!(key, Key::Escape) + && matches!(self.state, DrawingState::Idle) + && self.has_selection() + { + let bounds = self.selection_bounding_box(self.selected_shape_ids()); + self.clear_selection(); + self.mark_selection_dirty_region(bounds); + self.needs_redraw = true; + return; + } + // In text input mode, only check actions if modifiers are pressed or it's a special key // This allows plain letters to be typed without triggering color/tool actions if matches!(&self.state, DrawingState::TextInput { .. }) { @@ -354,6 +365,12 @@ impl InputState { self.restore_selection_from_snapshots(snapshots.clone()); self.state = DrawingState::Idle; } + DrawingState::Selecting { .. } => { + self.clear_provisional_dirty(); + self.last_provisional_bounds = None; + self.state = DrawingState::Idle; + self.needs_redraw = true; + } DrawingState::ResizingText { shape_id, snapshot, .. } => { @@ -450,6 +467,49 @@ impl InputState { Action::RedoAllDelayed => { self.start_redo_all_delayed(self.redo_all_delay_ms); } + Action::CopySelection => { + let copied = self.copy_selection(); + if copied > 0 { + info!("Copied selection ({} shape(s))", copied); + } else if self.has_selection() { + self.set_ui_toast( + UiToastKind::Warning, + "No unlocked shapes to copy; clipboard unchanged.", + ); + } else { + self.set_ui_toast( + UiToastKind::Warning, + "No selection to copy; clipboard unchanged.", + ); + } + } + Action::PasteSelection => { + let pasted = self.paste_selection(); + if pasted > 0 { + info!("Pasted selection ({} shape(s))", pasted); + } else if self.selection_clipboard_is_empty() { + self.set_ui_toast(UiToastKind::Warning, "Clipboard is empty."); + } + } + Action::SelectAll => { + let previous_bounds = self.selection_bounding_box(self.selected_shape_ids()); + let ids: Vec<_> = self + .canvas_set + .active_frame() + .shapes + .iter() + .map(|shape| shape.id) + .collect(); + if ids.is_empty() { + self.set_ui_toast(UiToastKind::Warning, "No shapes to select."); + } else { + self.set_selection(ids); + self.mark_selection_dirty_region(previous_bounds); + let new_bounds = self.selection_bounding_box(self.selected_shape_ids()); + self.mark_selection_dirty_region(new_bounds); + self.needs_redraw = true; + } + } Action::DuplicateSelection => { if self.duplicate_selection() { info!("Duplicated selection"); @@ -534,26 +594,12 @@ impl InputState { } } Action::MoveSelectionToStart => { - let axis = self - .last_selection_axis - .unwrap_or(SelectionAxis::Horizontal); - let moved = match axis { - SelectionAxis::Horizontal => self.move_selection_to_horizontal_edge(true), - SelectionAxis::Vertical => self.move_selection_to_vertical_edge(true), - }; - if moved { + if self.move_selection_to_horizontal_edge(true) { info!("Moved selection to start"); } } Action::MoveSelectionToEnd => { - let axis = self - .last_selection_axis - .unwrap_or(SelectionAxis::Horizontal); - let moved = match axis { - SelectionAxis::Horizontal => self.move_selection_to_horizontal_edge(false), - SelectionAxis::Vertical => self.move_selection_to_vertical_edge(false), - }; - if moved { + if self.move_selection_to_horizontal_edge(false) { info!("Moved selection to end"); } } @@ -712,6 +758,9 @@ impl InputState { Action::OpenConfigurator => { self.launch_configurator(); } + Action::OpenCaptureFolder => { + self.open_capture_folder(); + } Action::SetColorRed => { let _ = self.set_color(util::key_to_color('r').unwrap()); } diff --git a/src/input/state/core/base.rs b/src/input/state/core/base.rs index e4d42825..0a423e6e 100644 --- a/src/input/state/core/base.rs +++ b/src/input/state/core/base.rs @@ -14,7 +14,7 @@ use super::{ }; use crate::config::{Action, BoardConfig, KeyBinding, PRESET_SLOTS_MAX, ToolPresetConfig}; use crate::draw::frame::ShapeSnapshot; -use crate::draw::{CanvasSet, Color, DirtyTracker, EraserKind, FontDescriptor, ShapeId}; +use crate::draw::{CanvasSet, Color, DirtyTracker, EraserKind, FontDescriptor, Shape, ShapeId}; use crate::input::state::highlight::{ClickHighlightSettings, ClickHighlightState}; use crate::input::{ modifiers::Modifiers, @@ -22,6 +22,7 @@ use crate::input::{ }; use crate::util::Rect; use std::collections::HashMap; +use std::path::PathBuf; use std::time::Instant; /// Current drawing mode state machine. /// @@ -73,6 +74,15 @@ pub enum DrawingState { /// Whether any translation has been applied moved: bool, }, + /// Selection box mode - user is dragging a rectangle to select shapes + Selecting { + /// Starting X coordinate + start_x: i32, + /// Starting Y coordinate + start_y: i32, + /// Whether the selection should be additive + additive: bool, + }, /// Resize text/note wrap width by dragging a handle ResizingText { /// Shape id being resized @@ -128,6 +138,7 @@ pub enum PresetFeedbackKind { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum UiToastKind { + Info, Warning, Error, } @@ -282,6 +293,12 @@ pub struct InputState { pub show_tool_preview: bool, /// Pending UI toast (errors/warnings/info) pub(crate) ui_toast: Option, + /// Copied selection shapes for paste operations + pub(super) selection_clipboard: Option>, + /// Offset applied to successive paste operations + pub(super) clipboard_paste_offset: i32, + /// Last capture path (for quick open-folder action) + pub(super) last_capture_path: Option, /// Last text/note click used for double-click detection pub(crate) last_text_click: Option, /// Tracks an in-progress text edit target (existing shape to replace) @@ -461,6 +478,9 @@ impl InputState { show_preset_toasts: true, show_tool_preview: false, ui_toast: None, + selection_clipboard: None, + clipboard_paste_offset: 0, + last_capture_path: None, last_text_click: None, text_edit_target: None, pending_history: None, diff --git a/src/input/state/core/dirty.rs b/src/input/state/core/dirty.rs index 9c3045ff..514fbbee 100644 --- a/src/input/state/core/dirty.rs +++ b/src/input/state/core/dirty.rs @@ -35,14 +35,13 @@ impl InputState { } fn compute_provisional_bounds(&self, current_x: i32, current_y: i32) -> Option { - if let DrawingState::Drawing { - tool, - start_x, - start_y, - points, - } = &self.state - { - match tool { + match &self.state { + DrawingState::Drawing { + tool, + start_x, + start_y, + points, + } => match tool { Tool::Pen => bounding_box_for_points(points, self.current_thickness), Tool::Marker => { let inflated = @@ -87,9 +86,12 @@ impl InputState { ), Tool::Highlight => None, Tool::Select => None, - } - } else { - None + }, + DrawingState::Selecting { + start_x, start_y, .. + } => Self::selection_rect_from_points(*start_x, *start_y, current_x, current_y) + .and_then(|rect| rect.inflated(2)), + _ => None, } } diff --git a/src/input/state/core/selection_actions.rs b/src/input/state/core/selection_actions.rs index a28f0f76..dba0c83b 100644 --- a/src/input/state/core/selection_actions.rs +++ b/src/input/state/core/selection_actions.rs @@ -1,4 +1,4 @@ -use super::base::{DrawingState, InputState, TextInputMode}; +use super::base::{DrawingState, InputState, TextInputMode, UiToastKind}; use crate::draw::frame::{ShapeSnapshot, UndoAction}; use crate::draw::{Shape, ShapeId}; use crate::util::Rect; @@ -8,8 +8,43 @@ const SELECTION_HALO_PADDING: i32 = 6; const TEXT_RESIZE_HANDLE_SIZE: i32 = 10; const TEXT_RESIZE_HANDLE_OFFSET: i32 = 6; const TEXT_WRAP_MIN_WIDTH: i32 = 40; +const COPY_PASTE_OFFSET: i32 = 12; + +fn selection_rect(start_x: i32, start_y: i32, end_x: i32, end_y: i32) -> Option { + let min_x = start_x.min(end_x); + let min_y = start_y.min(end_y); + let max_x = start_x.max(end_x); + let max_y = start_y.max(end_y); + Rect::from_min_max(min_x, min_y, max_x, max_y) +} + +fn rects_intersect(a: Rect, b: Rect) -> bool { + a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y +} impl InputState { + pub(crate) fn selection_rect_from_points( + start_x: i32, + start_y: i32, + end_x: i32, + end_y: i32, + ) -> Option { + selection_rect(start_x, start_y, end_x, end_y) + } + + pub(crate) fn shape_ids_in_rect(&self, rect: Rect) -> Vec { + let frame = self.canvas_set.active_frame(); + frame + .shapes + .iter() + .filter_map(|shape| { + shape + .shape + .bounding_box() + .and_then(|bounds| rects_intersect(rect, bounds).then_some(shape.id)) + }) + .collect() + } pub(crate) fn delete_selection(&mut self) -> bool { let ids: Vec = self.selected_shape_ids().to_vec(); self.delete_shapes_by_ids(&ids) @@ -135,7 +170,7 @@ impl InputState { } let mut cloned_shape = shape.shape.clone(); - Self::translate_shape(&mut cloned_shape, 12, 12); + Self::translate_shape(&mut cloned_shape, COPY_PASTE_OFFSET, COPY_PASTE_OFFSET); let new_id = { let frame = self.canvas_set.active_frame_mut(); frame.add_shape(cloned_shape) @@ -167,6 +202,105 @@ impl InputState { true } + pub(crate) fn copy_selection(&mut self) -> usize { + let ids: Vec = self.selected_shape_ids().to_vec(); + if ids.is_empty() { + return 0; + } + + let frame = self.canvas_set.active_frame(); + let mut copied = Vec::new(); + for id in ids { + if let Some(shape) = frame.shape(id) { + if shape.locked { + continue; + } + copied.push(shape.shape.clone()); + } + } + + if copied.is_empty() { + return 0; + } + + let count = copied.len(); + self.selection_clipboard = Some(copied); + self.clipboard_paste_offset = 0; + count + } + + pub(crate) fn selection_clipboard_is_empty(&self) -> bool { + self.selection_clipboard + .as_ref() + .is_none_or(|clipboard| clipboard.is_empty()) + } + + pub(crate) fn paste_selection(&mut self) -> usize { + let Some(shapes) = self.selection_clipboard.clone() else { + return 0; + }; + if shapes.is_empty() { + return 0; + } + + let total = shapes.len(); + let offset = self + .clipboard_paste_offset + .saturating_add(COPY_PASTE_OFFSET); + let mut created = Vec::new(); + let mut new_ids = Vec::new(); + let mut limit_hit = false; + + for shape in shapes { + let mut cloned_shape = shape; + Self::translate_shape(&mut cloned_shape, offset, offset); + let new_id = { + let frame = self.canvas_set.active_frame_mut(); + frame.try_add_shape_with_id(cloned_shape, self.max_shapes_per_frame) + }; + + let Some(new_id) = new_id else { + limit_hit = true; + break; + }; + + if let Some((index, stored)) = { + let frame = self.canvas_set.active_frame(); + frame + .find_index(new_id) + .and_then(|idx| frame.shape(new_id).map(|s| (idx, s.clone()))) + } { + self.mark_selection_dirty_region(stored.shape.bounding_box()); + self.invalidate_hit_cache_for(new_id); + created.push((index, stored)); + new_ids.push(new_id); + } + } + + if created.is_empty() { + if limit_hit { + self.set_ui_toast(UiToastKind::Warning, "Shape limit reached; nothing pasted."); + } + return 0; + } + + let created_len = created.len(); + self.canvas_set.active_frame_mut().push_undo_action( + UndoAction::Create { shapes: created }, + self.undo_stack_limit, + ); + self.needs_redraw = true; + self.set_selection(new_ids); + self.clipboard_paste_offset = offset; + if limit_hit { + self.set_ui_toast( + UiToastKind::Warning, + format!("Shape limit reached; pasted {created_len} of {total}."), + ); + } + created_len + } + pub(crate) fn move_selection_to_front(&mut self) -> bool { self.reorder_selection(true) } diff --git a/src/input/state/core/utility.rs b/src/input/state/core/utility.rs index 3a68ee58..f71a6e36 100644 --- a/src/input/state/core/utility.rs +++ b/src/input/state/core/utility.rs @@ -6,6 +6,7 @@ use crate::config::Action; use crate::config::Config; use crate::util::Rect; use std::io::ErrorKind; +use std::path::Path; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; @@ -201,6 +202,37 @@ impl InputState { self.needs_redraw = true; } + #[allow(dead_code)] + pub(crate) fn set_capture_feedback( + &mut self, + saved_path: Option<&Path>, + copied_to_clipboard: bool, + open_folder_binding: Option<&str>, + ) { + let mut parts = Vec::new(); + self.last_capture_path = saved_path.map(|path| path.to_path_buf()); + if let Some(path) = saved_path { + let mut saved = format!("Saved to {}", path.display()); + if let Some(binding) = open_folder_binding { + saved.push_str(&format!(" ({binding} opens folder)")); + } + parts.push(saved); + } + + if copied_to_clipboard { + if saved_path.is_none() { + parts.push("Clipboard only (no file saved)".to_string()); + } + parts.push("Copied to clipboard".to_string()); + } + + if parts.is_empty() { + parts.push("Screenshot captured".to_string()); + } + + self.set_ui_toast(UiToastKind::Info, parts.join(" | ")); + } + pub fn advance_ui_toast(&mut self, now: Instant) -> bool { let duration = Duration::from_millis(UI_TOAST_DURATION_MS); let Some(toast) = &self.ui_toast else { @@ -213,6 +245,57 @@ impl InputState { true } + /// Opens the most recent capture directory using the desktop default application. + pub(crate) fn open_capture_folder(&mut self) { + let Some(path) = self.last_capture_path.clone() else { + self.set_ui_toast(UiToastKind::Warning, "No saved capture to open."); + return; + }; + + let folder = if path.is_dir() { + path + } else if let Some(parent) = path.parent() { + parent.to_path_buf() + } else { + self.set_ui_toast(UiToastKind::Warning, "Capture folder is unavailable."); + return; + }; + + let opener = if cfg!(target_os = "macos") { + "open" + } else if cfg!(target_os = "windows") { + "cmd" + } else { + "xdg-open" + }; + + let mut cmd = Command::new(opener); + if cfg!(target_os = "windows") { + cmd.args(["/C", "start", ""]).arg(&folder); + } else { + cmd.arg(&folder); + } + + match cmd.spawn() { + Ok(child) => { + log::info!( + "Opened capture folder at {} (pid {})", + folder.display(), + child.id() + ); + self.should_exit = true; + } + Err(err) => { + log::error!( + "Failed to open capture folder at {}: {}", + folder.display(), + err + ); + self.set_ui_toast(UiToastKind::Error, "Failed to open capture folder."); + } + } + } + /// Opens the primary config file using the desktop default application. pub(crate) fn open_config_file_default(&mut self) { let path = match Config::get_config_path() { diff --git a/src/input/state/mouse.rs b/src/input/state/mouse.rs index b6021075..28714cc5 100644 --- a/src/input/state/mouse.rs +++ b/src/input/state/mouse.rs @@ -12,6 +12,7 @@ use super::{ContextMenuKind, DrawingState, InputState}; const TEXT_CLICK_DRAG_THRESHOLD: i32 = 4; const TEXT_DOUBLE_CLICK_MS: u64 = 400; const TEXT_DOUBLE_CLICK_DISTANCE: i32 = 6; +const SELECTION_DRAG_THRESHOLD: i32 = 4; impl InputState { fn handle_right_click(&mut self, x: i32, y: i32) { @@ -26,6 +27,12 @@ impl InputState { self.restore_selection_from_snapshots(snapshots.clone()); self.state = DrawingState::Idle; } + DrawingState::Selecting { .. } => { + self.clear_provisional_dirty(); + self.last_provisional_bounds = None; + self.state = DrawingState::Idle; + self.needs_redraw = true; + } DrawingState::ResizingText { shape_id, snapshot, .. } => { @@ -129,7 +136,8 @@ impl InputState { match &mut self.state { DrawingState::Idle => { - let selection_click = self.modifiers.alt; + let selection_click = + self.modifiers.alt || self.active_tool() == Tool::Select; if let Some(shape_id) = self.hit_text_resize_handle(x, y) { let snapshot = { let frame = self.canvas_set.active_frame(); @@ -179,23 +187,35 @@ impl InputState { } } self.last_text_click = None; - if selection_click && let Some(hit_id) = self.hit_test_at(x, y) { - if !self.selected_shape_ids().contains(&hit_id) { - if self.modifiers.shift { - self.extend_selection([hit_id]); - } else { - self.set_selection(vec![hit_id]); + if selection_click { + if let Some(hit_id) = self.hit_test_at(x, y) { + if !self.selected_shape_ids().contains(&hit_id) { + if self.modifiers.shift { + self.extend_selection([hit_id]); + } else { + self.set_selection(vec![hit_id]); + } } - } - let snapshots = self.capture_movable_selection_snapshots(); - if !snapshots.is_empty() { - self.state = DrawingState::MovingSelection { - last_x: x, - last_y: y, - snapshots, - moved: false, + let snapshots = self.capture_movable_selection_snapshots(); + if !snapshots.is_empty() { + self.state = DrawingState::MovingSelection { + last_x: x, + last_y: y, + snapshots, + moved: false, + }; + return; + } + } else { + self.state = DrawingState::Selecting { + start_x: x, + start_y: y, + additive: self.modifiers.shift, }; + self.last_provisional_bounds = None; + self.update_provisional_dirty(x, y); + self.needs_redraw = true; return; } } @@ -221,6 +241,7 @@ impl InputState { } DrawingState::Drawing { .. } | DrawingState::MovingSelection { .. } + | DrawingState::Selecting { .. } | DrawingState::PendingTextClick { .. } | DrawingState::ResizingText { .. } => {} } @@ -303,6 +324,12 @@ impl InputState { return; } + if matches!(self.state, DrawingState::Selecting { .. }) { + self.update_provisional_dirty(x, y); + self.needs_redraw = true; + return; + } + if self.is_context_menu_open() { self.update_context_menu_hover_from_pointer(x, y); return; @@ -375,6 +402,34 @@ impl InputState { self.push_translation_undo(snapshots); } } + DrawingState::Selecting { + start_x, + start_y, + additive, + } => { + self.clear_provisional_dirty(); + let dx = (x - start_x).abs(); + let dy = (y - start_y).abs(); + if dx < SELECTION_DRAG_THRESHOLD && dy < SELECTION_DRAG_THRESHOLD { + if !additive { + let bounds = self.selection_bounding_box(self.selected_shape_ids()); + self.clear_selection(); + self.mark_selection_dirty_region(bounds); + self.needs_redraw = true; + } + return; + } + + if let Some(rect) = Self::selection_rect_from_points(start_x, start_y, x, y) { + let ids = self.shape_ids_in_rect(rect); + if additive { + self.extend_selection(ids); + } else { + self.set_selection(ids); + } + self.needs_redraw = true; + } + } DrawingState::ResizingText { shape_id, snapshot, .. } => { diff --git a/src/input/state/render.rs b/src/input/state/render.rs index 0480bc1e..bae82b79 100644 --- a/src/input/state/render.rs +++ b/src/input/state/render.rs @@ -125,14 +125,13 @@ impl InputState { current_x: i32, current_y: i32, ) -> bool { - if let DrawingState::Drawing { - tool, - start_x: _, - start_y: _, - points, - } = &self.state - { - match tool { + match &self.state { + DrawingState::Drawing { + tool, + start_x: _, + start_y: _, + points, + } => match tool { Tool::Pen => { // Render freehand without cloning - just borrow the points render_freehand_borrowed( @@ -173,9 +172,38 @@ impl InputState { false } } + }, + DrawingState::Selecting { + start_x, + start_y, + additive, + } => { + let Some(rect) = + Self::selection_rect_from_points(*start_x, *start_y, current_x, current_y) + else { + return false; + }; + let _ = ctx.save(); + ctx.rectangle( + rect.x as f64, + rect.y as f64, + rect.width as f64, + rect.height as f64, + ); + ctx.set_source_rgba(0.2, 0.45, 1.0, 0.12); + let _ = ctx.fill_preserve(); + if *additive { + ctx.set_source_rgba(0.2, 0.75, 0.45, 0.9); + } else { + ctx.set_source_rgba(0.2, 0.45, 1.0, 0.9); + } + ctx.set_line_width(1.5); + ctx.set_dash(&[6.0, 4.0], 0.0); + let _ = ctx.stroke(); + let _ = ctx.restore(); + true } - } else { - false + _ => false, } } diff --git a/src/input/state/tests.rs b/src/input/state/tests.rs index 475b6e01..6a45bd56 100644 --- a/src/input/state/tests.rs +++ b/src/input/state/tests.rs @@ -257,6 +257,92 @@ fn duplicate_selection_via_action_creates_offset_shape() { } } +#[test] +fn copy_paste_selection_creates_offset_shape() { + let mut state = create_test_input_state(); + let original_id = state.canvas_set.active_frame_mut().add_shape(Shape::Rect { + x: 10, + y: 20, + w: 100, + h: 80, + fill: false, + color: state.current_color, + thick: state.current_thickness, + }); + + state.set_selection(vec![original_id]); + state.handle_action(Action::CopySelection); + state.handle_action(Action::PasteSelection); + + let frame = state.canvas_set.active_frame(); + assert_eq!(frame.shapes.len(), 2); + + let new_id = frame + .shapes + .iter() + .map(|shape| shape.id) + .find(|id| *id != original_id) + .expect("pasted shape id"); + let original = frame.shape(original_id).unwrap(); + let pasted = frame.shape(new_id).unwrap(); + + match (&original.shape, &pasted.shape) { + (Shape::Rect { x: ox, y: oy, .. }, Shape::Rect { x: px, y: py, .. }) => { + assert_eq!(*px, ox + 12); + assert_eq!(*py, oy + 12); + } + _ => panic!("Expected rectangles"), + } +} + +#[test] +fn select_all_action_selects_shapes() { + let mut state = create_test_input_state(); + let first = state.canvas_set.active_frame_mut().add_shape(Shape::Rect { + x: 10, + y: 10, + w: 10, + h: 10, + fill: false, + color: state.current_color, + thick: state.current_thickness, + }); + let second = state.canvas_set.active_frame_mut().add_shape(Shape::Rect { + x: 30, + y: 30, + w: 10, + h: 10, + fill: false, + color: state.current_color, + thick: state.current_thickness, + }); + + state.handle_action(Action::SelectAll); + let selected = state.selected_shape_ids(); + assert_eq!(selected.len(), 2); + assert!(selected.contains(&first)); + assert!(selected.contains(&second)); +} + +#[test] +fn escape_clears_selection_before_exit() { + let mut state = create_test_input_state(); + let shape_id = state.canvas_set.active_frame_mut().add_shape(Shape::Rect { + x: 0, + y: 0, + w: 10, + h: 10, + fill: false, + color: state.current_color, + thick: state.current_thickness, + }); + + state.set_selection(vec![shape_id]); + state.on_key_press(Key::Escape); + assert!(!state.has_selection()); + assert!(!state.should_exit); +} + #[test] fn duplicate_selection_skips_locked_shapes() { let mut state = create_test_input_state(); @@ -294,6 +380,38 @@ fn duplicate_selection_skips_locked_shapes() { ); } +#[test] +fn select_tool_drag_selects_shapes_in_rect() { + let mut state = create_test_input_state(); + let inside = state.canvas_set.active_frame_mut().add_shape(Shape::Rect { + x: 12, + y: 12, + w: 8, + h: 8, + fill: false, + color: state.current_color, + thick: state.current_thickness, + }); + let outside = state.canvas_set.active_frame_mut().add_shape(Shape::Rect { + x: 80, + y: 80, + w: 10, + h: 10, + fill: false, + color: state.current_color, + thick: state.current_thickness, + }); + + state.set_tool_override(Some(Tool::Select)); + state.on_mouse_press(MouseButton::Left, 0, 0); + state.on_mouse_motion(40, 40); + state.on_mouse_release(MouseButton::Left, 40, 40); + + let selected = state.selected_shape_ids(); + assert!(selected.contains(&inside)); + assert!(!selected.contains(&outside)); +} + #[test] fn delete_shapes_by_ids_ignores_missing_ids() { let mut state = create_test_input_state(); @@ -497,7 +615,7 @@ fn move_selection_to_horizontal_edges_uses_screen_bounds() { } #[test] -fn move_selection_to_vertical_edges_uses_screen_bounds() { +fn move_selection_to_horizontal_edges_ignores_last_axis() { let mut state = create_test_input_state(); state.update_screen_dimensions(200, 100); let shape_id = state.canvas_set.active_frame_mut().add_shape(Shape::Rect { @@ -518,7 +636,7 @@ fn move_selection_to_vertical_edges_uses_screen_bounds() { let frame = state.canvas_set.active_frame(); let shape = frame.shape(shape_id).unwrap(); let bounds = shape.shape.bounding_box().expect("rect should have bounds"); - assert_eq!(bounds.y, 0); + assert_eq!(bounds.x, 0); } state.handle_action(Action::MoveSelectionToEnd); @@ -527,7 +645,7 @@ fn move_selection_to_vertical_edges_uses_screen_bounds() { let frame = state.canvas_set.active_frame(); let shape = frame.shape(shape_id).unwrap(); let bounds = shape.shape.bounding_box().expect("rect should have bounds"); - assert_eq!(bounds.y + bounds.height, 100); + assert_eq!(bounds.x + bounds.width, 200); } } diff --git a/src/ui.rs b/src/ui.rs index 897ff187..54cd244e 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -105,6 +105,7 @@ pub fn render_status_bar( Tool::Eraser => "Eraser", }, DrawingState::MovingSelection { .. } => "Move", + DrawingState::Selecting { .. } => "Select", DrawingState::ResizingText { .. } => "Resize", DrawingState::PendingTextClick { .. } | DrawingState::Idle => match tool { Tool::Select => "Select", @@ -444,6 +445,7 @@ pub fn render_ui_toast( let fade = (1.0 - progress as f64).clamp(0.0, 1.0); let (r, g, b) = match toast.kind { + UiToastKind::Info => (0.25, 0.7, 0.9), UiToastKind::Warning => (0.92, 0.62, 0.18), UiToastKind::Error => (0.9, 0.3, 0.3), }; @@ -562,6 +564,10 @@ pub fn render_help_overlay( key: "Shift+Alt+Click", action: "Add to selection", }, + Row { + key: "Alt+Drag", + action: "Box select", + }, Row { key: "Delete", action: "Delete selection", @@ -570,6 +576,18 @@ pub fn render_help_overlay( key: "Ctrl+D", action: "Duplicate selection", }, + Row { + key: "Ctrl+Alt+C", + action: "Copy selection", + }, + Row { + key: "Ctrl+Alt+V", + action: "Paste selection", + }, + Row { + key: "Ctrl+A", + action: "Select all", + }, ], badges: Vec::new(), }, @@ -743,6 +761,10 @@ pub fn render_help_overlay( key: "Ctrl+Shift+I", action: "Selection (capture defaults)", }, + Row { + key: "Ctrl+Alt+O", + action: "Open capture folder", + }, ], badges: Vec::new(), },