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(),
},