From 238113c9d9f9cee58d90dd0844c1384f327e007f Mon Sep 17 00:00:00 2001 From: robertwidfen <126524724+robertwidfen@users.noreply.github.com> Date: Sat, 2 May 2026 01:04:32 +0200 Subject: [PATCH 01/13] feat: select/delete/move/resize annotations Select annotations items with pointer tool. Delete annotation with Delete. Nudge selected annotation by cursor keys. Pointer tool click raises annotation to top. Double-click to edit text annotation. Alt+Click selects overlapping annotations, but does not raise the annotation. If (Alt+)Click without Ctrl hits an annotation starts temporary pointer tool. --- src/femtovg_area/imp.rs | 70 +++++- src/femtovg_area/mod.rs | 56 +++++ src/math.rs | 102 ++++++++- src/sketch_board.rs | 304 ++++++++++++++++++++++-- src/tools/arrow.rs | 42 +++- src/tools/blur.rs | 26 +++ src/tools/brush.rs | 70 +++++- src/tools/drag_box.rs | 4 +- src/tools/ellipse.rs | 45 +++- src/tools/highlight.rs | 127 +++++++++- src/tools/line.rs | 42 +++- src/tools/marker.rs | 35 ++- src/tools/mod.rs | 77 ++++++- src/tools/pointer.rs | 497 +++++++++++++++++++++++++++++++++++++++- src/tools/rectangle.rs | 30 ++- src/tools/text.rs | 76 +++++- 16 files changed, 1538 insertions(+), 65 deletions(-) diff --git a/src/femtovg_area/imp.rs b/src/femtovg_area/imp.rs index f147174a..a4d77a29 100644 --- a/src/femtovg_area/imp.rs +++ b/src/femtovg_area/imp.rs @@ -59,6 +59,7 @@ pub struct FemtoVgAreaMut { drag_offset: Vec2D, is_drag: bool, is_reset: bool, + hidden_drawable_index: Option, } enum HistoryEntry { @@ -187,6 +188,7 @@ impl FemtoVGArea { last_scale: initial_scale, is_drag: false, is_reset: false, + hidden_drawable_index: None, }); self.sender.borrow_mut().replace(sender); } @@ -334,6 +336,58 @@ impl FemtoVgAreaMut { self.redo_stack.clear(); } + // Hit-test all drawables and return all indices whose bounds hit `pos`, in order from topmost to bottommost. + pub fn hit_test(&self, pos: Vec2D) -> Vec { + let mut results = Vec::new(); + // A small tolerance is applied to make thin shapes (lines, arrows) easier to click. + const HIT_TOLERANCE: f32 = 5.0; + for (i, d) in self.drawables.iter().enumerate().rev() { + if d.hit_test(pos, HIT_TOLERANCE) { + results.push(i); + } + } + results + } + + pub fn get_drawable_bounds(&self, index: usize) -> Option<(Vec2D, Vec2D)> { + self.drawables.get(index).and_then(|d| d.bounds()) + } + + pub fn get_drawable_clone(&self, index: usize) -> Option> { + self.drawables.get(index).map(|d| d.clone_box()) + } + + pub fn replace_drawable(&mut self, index: usize, drawable: Box) { + if index < self.drawables.len() { + self.drawables[index] = drawable; + } + } + + pub fn move_drawable_to_end(&mut self, index: usize) -> Option { + if index >= self.drawables.len() { + return None; + } + + if index + 1 == self.drawables.len() { + return Some(index); + } + + let drawable = self.drawables.remove(index); + self.drawables.push(drawable); + Some(self.drawables.len() - 1) + } + + pub fn remove_drawable(&mut self, index: usize) { + if index < self.drawables.len() { + self.drawables.remove(index); + } + } + + // Set (or clear) the drawable index to skip during rendering (used while drag-previewing). + pub fn set_hidden_drawable_index(&mut self, index: Option) { + self.hidden_drawable_index = index; + } + pub fn undo(&mut self) -> bool { match self.undo_stack.pop() { Some(HistoryEntry::Drawable(history_drawable)) => { @@ -514,13 +568,23 @@ impl FemtoVgAreaMut { self.background_image.height() as f32, ), ); + let mut active_tool_drawn_in_stack = false; + // render the whole stack - for d in &mut self.drawables { + for (i, d) in self.drawables.iter().enumerate() { + if self.hidden_drawable_index == Some(i) { + // Draw the active tool preview in the original z position. + if let Some(preview) = self.active_tool.borrow().get_drawable() { + preview.draw(canvas, font, bounds)?; + active_tool_drawn_in_stack = true; + } + continue; + } d.draw(canvas, font, bounds)?; } - // render active tool - if let Some(d) = self.active_tool.borrow().get_drawable() { + // render active tool (default: on top) when not already drawn in stack order + if !active_tool_drawn_in_stack && let Some(d) = self.active_tool.borrow().get_drawable() { d.draw(canvas, font, bounds)?; } diff --git a/src/femtovg_area/mod.rs b/src/femtovg_area/mod.rs index cafda09f..1363056f 100644 --- a/src/femtovg_area/mod.rs +++ b/src/femtovg_area/mod.rs @@ -177,4 +177,60 @@ impl FemtoVGArea { pub fn resize(&self, width: i32, height: i32) { self.imp().resize(width, height); } + + pub fn hit_test(&self, pos: Vec2D) -> Vec { + self.imp() + .inner() + .as_ref() + .expect("Did you call init before using FemtoVgArea?") + .hit_test(pos) + } + + pub fn get_drawable_bounds(&self, index: usize) -> Option<(Vec2D, Vec2D)> { + self.imp() + .inner() + .as_ref() + .expect("Did you call init before using FemtoVgArea?") + .get_drawable_bounds(index) + } + + pub fn get_drawable_clone(&self, index: usize) -> Option> { + self.imp() + .inner() + .as_ref() + .expect("Did you call init before using FemtoVgArea?") + .get_drawable_clone(index) + } + + pub fn replace_drawable(&mut self, index: usize, drawable: Box) { + self.imp() + .inner() + .as_mut() + .expect("Did you call init before using FemtoVgArea?") + .replace_drawable(index, drawable); + } + + pub fn move_drawable_to_end(&mut self, index: usize) -> Option { + self.imp() + .inner() + .as_mut() + .expect("Did you call init before using FemtoVgArea?") + .move_drawable_to_end(index) + } + + pub fn remove_drawable(&mut self, index: usize) { + self.imp() + .inner() + .as_mut() + .expect("Did you call init before using FemtoVgArea?") + .remove_drawable(index); + } + + pub fn set_hidden_drawable_index(&mut self, index: Option) { + self.imp() + .inner() + .as_mut() + .expect("Did you call init before using FemtoVgArea?") + .set_hidden_drawable_index(index); + } } diff --git a/src/math.rs b/src/math.rs index 3a313630..50054d5f 100644 --- a/src/math.rs +++ b/src/math.rs @@ -1,7 +1,7 @@ use std::{ f32::consts::PI, fmt::Display, - ops::{Add, AddAssign, Mul, Sub, SubAssign}, + ops::{Add, AddAssign, Div, Mul, Sub, SubAssign}, }; #[derive(Default, Debug, Copy, Clone, PartialEq)] @@ -59,6 +59,34 @@ impl Vec2D { self.x * self.x + self.y * self.y } + pub fn abs(&self) -> Self { + Self { + x: self.x.abs(), + y: self.y.abs(), + } + } + + pub fn round(&self) -> Self { + Self { + x: self.x.round(), + y: self.y.round(), + } + } + + pub fn min(self, other: Self) -> Self { + Self { + x: self.x.min(other.x), + y: self.y.min(other.y), + } + } + + pub fn max(self, other: Self) -> Self { + Self { + x: self.x.max(other.x), + y: self.y.max(other.y), + } + } + /** * Get the angle of the vector. * Angle of 0 is the positive x-axis. @@ -114,6 +142,17 @@ impl Vec2D { let dy = self.y - other.y; (dx * dx + dy * dy).sqrt() } + + pub fn distance_to_segment(&self, a: Vec2D, b: Vec2D) -> f32 { + let ab = b - a; + if ab.is_zero() { + return self.distance_to(&a); + } + let ap = *self - a; + let factor = (ap * ab / ab.norm2()).clamp(0.0, 1.0); + let projected_point = a + ab * factor; + self.distance_to(&projected_point) + } } impl Add for Vec2D { @@ -127,6 +166,17 @@ impl Add for Vec2D { } } +impl Add for Vec2D { + type Output = Vec2D; + + fn add(self, rhs: f32) -> Self::Output { + Self::Output { + x: self.x + rhs, + y: self.y + rhs, + } + } +} + impl AddAssign for Vec2D { fn add_assign(&mut self, rhs: Self) { *self = *self + rhs @@ -144,6 +194,17 @@ impl Sub for Vec2D { } } +impl Sub for Vec2D { + type Output = Vec2D; + + fn sub(self, rhs: f32) -> Self::Output { + Self::Output { + x: self.x - rhs, + y: self.y - rhs, + } + } +} + impl SubAssign for Vec2D { fn sub_assign(&mut self, rhs: Self) { *self = *self - rhs; @@ -158,6 +219,30 @@ impl Mul for Vec2D { } } +impl Mul for Vec2D { + type Output = f32; + + fn mul(self, rhs: Vec2D) -> Self::Output { + self.x * rhs.x + self.y * rhs.y + } +} + +impl Div for Vec2D { + type Output = Vec2D; + + fn div(self, rhs: f32) -> Self::Output { + Vec2D::new(self.x / rhs, self.y / rhs) + } +} + +impl Div for Vec2D { + type Output = Vec2D; + + fn div(self, rhs: Vec2D) -> Self::Output { + Vec2D::new(self.x / rhs.x, self.y / rhs.y) + } +} + impl Display for Vec2D { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "({},{})", self.x, self.y) @@ -204,13 +289,12 @@ pub fn rect_ensure_in_bounds(rect: (Vec2D, Vec2D), bounds: (Vec2D, Vec2D)) -> (V (pos, size) } -pub fn rect_round(rect: (Vec2D, Vec2D)) -> (Vec2D, Vec2D) { - let (mut pos, mut size) = rect; - - pos.x = pos.x.round(); - pos.y = pos.y.round(); - size.x = size.x.round(); - size.y = size.y.round(); +// Return the bounding box tl, br for two points +pub fn ensure_bounding_box(a: Vec2D, b: Vec2D) -> (Vec2D, Vec2D) { + (a.min(b), a.max(b)) +} - (pos, size) +pub fn rect_round(rect: (Vec2D, Vec2D)) -> (Vec2D, Vec2D) { + let (pos, size) = rect; + (pos.round(), size.round()) } diff --git a/src/sketch_board.rs b/src/sketch_board.rs index 33d98617..806ccf42 100644 --- a/src/sketch_board.rs +++ b/src/sketch_board.rs @@ -24,7 +24,7 @@ use crate::keybindings::{ActionTrigger, ShortcutCommand, ShortcutRegistry}; use crate::math::Vec2D; use crate::notification::{log_result, log_result_with_pixbuf}; use crate::style::{Color, Size, Style}; -use crate::tools::{Tool, ToolEvent, ToolUpdateResult, Tools, ToolsManager}; +use crate::tools::{PointerTool, TextTool, Tool, ToolEvent, ToolUpdateResult, Tools, ToolsManager}; use crate::ui::toolbars::ToolbarEvent; use xdg::BaseDirectories; @@ -38,6 +38,7 @@ pub enum SketchBoardInput { PinchStart, PinchScale(f32), PinchEnd, + NudgeSelection(Vec2D), ToolbarEvent(ToolbarEvent), RenderResult(RenderedImage, Vec), RenderResultFollowup(Option, Vec, Option), @@ -281,6 +282,10 @@ pub struct SketchBoard { tool_edit_mode: bool, tools: ToolsManager, pinch_last_scale: f32, + pointer_tool: Rc>, + text_tool: Rc>, + return_to_pointer_after_text_commit: bool, + temporary_pointer_previous_tool: Option, style: Style, im_context: gtk::IMMulticontext, last_saved_filepath: RefCell>, @@ -730,6 +735,8 @@ impl SketchBoard { if self.active_tool.borrow().active() { self.active_tool.borrow_mut().handle_undo() } else if self.renderer.undo() { + self.renderer.set_hidden_drawable_index(None); + self.pointer_tool.borrow_mut().deselect(); ToolUpdateResult::Redraw } else { ToolUpdateResult::Unmodified @@ -740,6 +747,8 @@ impl SketchBoard { if self.active_tool.borrow().active() { self.active_tool.borrow_mut().handle_redo() } else if self.renderer.redo() { + self.renderer.set_hidden_drawable_index(None); + self.pointer_tool.borrow_mut().deselect(); ToolUpdateResult::Redraw } else { ToolUpdateResult::Unmodified @@ -749,6 +758,8 @@ impl SketchBoard { fn handle_clear_all(&mut self) -> ToolUpdateResult { // can't use lazy || here if self.deactivate_active_tool() | self.renderer.clear_all() { + self.renderer.set_hidden_drawable_index(None); + self.pointer_tool.borrow_mut().deselect(); ToolUpdateResult::Redraw } else { ToolUpdateResult::Unmodified @@ -773,6 +784,200 @@ impl SketchBoard { ToolUpdateResult::Unmodified } + fn handle_pointer_tool_click( + &mut self, + me: &MouseEventMsg, + sender: &ComponentSender, + ) -> ToolUpdateResult { + if self.active_tool_type() == Tools::Pointer { + if me.type_ == MouseEventType::Click && me.n_pressed == 2 { + self.handle_pointer_tool_double_click(me.pos, sender) + .unwrap_or_else(|| ToolUpdateResult::Unmodified) + } else if me.type_ == MouseEventType::Click + && me.n_pressed == 1 + && let Some(previous_tool) = self.temporary_pointer_previous_tool + && previous_tool != Tools::Pointer + && self.renderer.hit_test(me.pos).is_empty() + && self + .pointer_tool + .borrow() + .hit_test_handles(me.pos) + .is_none() + { + // switch back to previous tool + self.temporary_pointer_previous_tool = None; + self.handle_toolbar_event( + ToolbarEvent::ToolSelected(previous_tool), + sender.clone(), + ); + sender + .output_sender() + .emit(SketchBoardOutput::ToolSwitchShortcut(previous_tool)); + self.active_tool + .borrow_mut() + .handle_event(ToolEvent::Input(InputEvent::Mouse(*me))) + } else { + let result = if me.type_ == MouseEventType::BeginDrag { + self.handle_pointer_tool_begin_drag(me) + .unwrap_or(ToolUpdateResult::Unmodified) + } else if !matches!( + me.type_, + MouseEventType::PointerPos | MouseEventType::UpdateDrag + ) { + self.renderer.set_hidden_drawable_index(None); + ToolUpdateResult::Redraw + } else { + ToolUpdateResult::Unmodified + }; + let tool_result = self + .active_tool + .borrow_mut() + .handle_event(ToolEvent::Input(InputEvent::Mouse(*me))); + match tool_result { + ToolUpdateResult::Unmodified => result, + _ => tool_result, + } + } + } else if me.type_ == MouseEventType::Click && me.n_pressed == 1 { + if !me.modifier.contains(ModifierType::CONTROL_MASK) + && !self.renderer.hit_test(me.pos).is_empty() + { + // temporarily switch to pointer tool + let previous_tool = self.active_tool_type(); + self.handle_toolbar_event( + ToolbarEvent::ToolSelected(Tools::Pointer), + sender.clone(), + ); + sender + .output_sender() + .emit(SketchBoardOutput::ToolSwitchShortcut(Tools::Pointer)); + self.temporary_pointer_previous_tool = Some(previous_tool); + ToolUpdateResult::Redraw + } else { + // otherwise pass to tool + self.active_tool + .borrow_mut() + .handle_event(ToolEvent::Input(InputEvent::Mouse(*me))) + } + } else { + // otherwise pass to tool + self.active_tool + .borrow_mut() + .handle_event(ToolEvent::Input(InputEvent::Mouse(*me))) + } + } + + // Pre-processes `BeginDrag` events when the Pointer tool is active. + // Returns `Some(result)` to short-circuit normal tool dispatch, or `None` to fall through. + fn handle_pointer_tool_begin_drag( + &mut self, + me: &crate::sketch_board::MouseEventMsg, + ) -> Option { + // Check resize handle first (only when something is already selected) + let handle_hit = self.pointer_tool.borrow().hit_test_handles(me.pos); + if let Some(handle) = handle_hit { + let sel_idx = self.pointer_tool.borrow().selected_index(); + if let Some(idx) = sel_idx + && let (Some(drawable), Some(bounds)) = ( + self.renderer.get_drawable_clone(idx), + self.renderer.get_drawable_bounds(idx), + ) + { + self.renderer.set_hidden_drawable_index(Some(idx)); + self.pointer_tool + .borrow_mut() + .begin_resize(idx, drawable, handle, bounds); + return Some(ToolUpdateResult::Redraw); + } + } + + let is_alt_click = me.modifier.contains(ModifierType::ALT_MASK); + let selected_idx = self.pointer_tool.borrow().selected_index(); + // If a drawable is already selected and the click still hits it, use it + if !is_alt_click + && let Some(selected_idx) = selected_idx + && let Some(drawable) = self.renderer.get_drawable_clone(selected_idx) + && drawable.hit_test(me.pos, 5.0) + && let Some((tl, br)) = self.renderer.get_drawable_bounds(selected_idx) + { + // self.sync_toolbar_style_from_drawable(drawable.as_ref(), sender); + self.renderer.set_hidden_drawable_index(Some(selected_idx)); + self.pointer_tool + .borrow_mut() + .begin_move(selected_idx, drawable, (tl, br)); + return Some(ToolUpdateResult::Redraw); + } + + // Check for another body hit when alt is pressed, otherwise just select topmost hit + let idx_to_select = if is_alt_click { + // Alt+Click: cycle through all overlapping objects + let all_hits = self.renderer.hit_test(me.pos); + if !all_hits.is_empty() { + // Get the next index in the cycle + self.pointer_tool + .borrow_mut() + .cycle_to_next_object(me.pos, all_hits) + } else { + None + } + } else { + // Normal click: select topmost object + self.renderer.hit_test(me.pos).first().copied() + }; + + if let Some(idx) = idx_to_select { + let sel_idx = if is_alt_click { + // Alt+Click: don't move to end, just use the index as-is + idx + } else { + // Normal click: move to end and use new index + self.renderer.move_drawable_to_end(idx).unwrap_or(idx) + }; + + if let (Some(drawable), Some(bounds)) = ( + self.renderer.get_drawable_clone(sel_idx), + self.renderer.get_drawable_bounds(sel_idx), + ) { + self.renderer.set_hidden_drawable_index(Some(sel_idx)); + self.pointer_tool + .borrow_mut() + .begin_move(sel_idx, drawable, bounds); + return Some(ToolUpdateResult::Redraw); + } + } + + // Clicked on empty space: deselect + self.renderer.set_hidden_drawable_index(None); + self.pointer_tool.borrow_mut().deselect(); + Some(ToolUpdateResult::Redraw) + } + + // If the hit drawable is a Text, removes it from the canvas and re-opens it in the text tool. + fn handle_pointer_tool_double_click( + &mut self, + pos: Vec2D, + sender: &ComponentSender, + ) -> Option { + let idx = self.renderer.hit_test(pos).first().copied()?; + let drawable = self.renderer.get_drawable_clone(idx)?; + let (text_pos, content, style) = drawable.edit_info()?; + + // Remove the committed drawable and clear selection + self.pointer_tool.borrow_mut().deselect(); + self.renderer.set_hidden_drawable_index(None); + self.renderer.remove_drawable(idx); + + // Pre-populate the text tool and switch to it + self.text_tool + .borrow_mut() + .load_for_editing(text_pos, &content, style); + self.return_to_pointer_after_text_commit = true; + Some(self.handle_toolbar_event( + crate::ui::toolbars::ToolbarEvent::ToolSelected(Tools::Text), + sender.clone(), + )) + } + fn handle_toolbar_event( &mut self, toolbar_event: ToolbarEvent, @@ -784,6 +989,9 @@ impl SketchBoard { ToolUpdateResult::Unmodified } ToolbarEvent::ToolSelected(tool) => { + self.temporary_pointer_previous_tool = None; + self.return_to_pointer_after_text_commit = false; + // deactivate old tool and save drawable, if any let old_tool = self.active_tool.clone(); let mut deactivate_result = @@ -791,6 +999,9 @@ impl SketchBoard { old_tool.borrow_mut().set_im_context(None); + // If we were in the pointer tool, ensure the hidden drawable is restored + self.renderer.set_hidden_drawable_index(None); + if let ToolUpdateResult::Commit(d) = deactivate_result { self.renderer.commit(d); if APP_CONFIG.read().auto_copy() { @@ -1043,8 +1254,15 @@ impl SketchBoard { } ShortcutCommand::Scale(scale) => self.handle_scale(scale), ShortcutCommand::DeleteSelection => { - // Placeholder for future delete selection implementation - ToolUpdateResult::Unmodified + let pointer_selection = self.pointer_tool.borrow().selected_index(); + if let Some(idx) = pointer_selection { + self.pointer_tool.borrow_mut().deselect(); + self.renderer.set_hidden_drawable_index(None); + self.renderer.remove_drawable(idx); + ToolUpdateResult::Redraw + } else { + ToolUpdateResult::Unmodified + } } ShortcutCommand::ClearAll => self.handle_clear_all(), ShortcutCommand::RunConfiguredActions(trigger) => { @@ -1227,10 +1445,19 @@ impl Component for SketchBoard { self.im_context.focus_out(); } + let sender_for_post_commit = sender.clone(); + // handle resize ourselves, pass everything else to tool let sender_clone = sender.clone(); let result = match msg { SketchBoardInput::InputEvent(mut ie) => { + if matches!(ie, InputEvent::Mouse(_)) { + // changes pos to local coords + ie.handle_event_mouse_input(&self.renderer); + // handle right click and other things + ie.handle_mouse_event(&self.renderer); + } + if let InputEvent::Key(ke) = ie { let active_tool_result = self .active_tool @@ -1255,6 +1482,8 @@ impl Component for SketchBoard { } _ => active_tool_result, } + } else if let InputEvent::Mouse(me) = ie { + self.handle_pointer_tool_click(&me, &sender.clone()) } else { if let InputEvent::Mouse(me) = &ie && matches!(me.type_, MouseEventType::Click | MouseEventType::BeginDrag) @@ -1263,24 +1492,11 @@ impl Component for SketchBoard { } ie.handle_event_mouse_input(&self.renderer); - let active_tool_result = self - .active_tool - .borrow_mut() - .handle_event(ToolEvent::Input(ie.clone())); - // eprintln!("active_tool_result={:?}", active_tool_result); - - match active_tool_result { - ToolUpdateResult::StopPropagation - | ToolUpdateResult::RedrawAndStopPropagation => active_tool_result, - _ => { - if let Some(result) = ie.handle_mouse_event(&self.renderer) { - result - } else { - active_tool_result - } - } - } + // other things like input for text tool + self.active_tool + .borrow_mut() + .handle_event(ToolEvent::Input(ie.clone())) } } SketchBoardInput::PinchStart => { @@ -1303,6 +1519,25 @@ impl Component for SketchBoard { self.pinch_last_scale = 1.0; ToolUpdateResult::Unmodified } + SketchBoardInput::NudgeSelection(delta) => { + let selected_index = self.pointer_tool.borrow().selected_index(); + if let Some(index) = selected_index { + if let Some(mut drawable) = self.renderer.get_drawable_clone(index) { + drawable.translate(delta); + self.renderer.replace_drawable(index, drawable); + if let Some(new_bounds) = self.renderer.get_drawable_bounds(index) { + self.pointer_tool + .borrow_mut() + .set_selection(index, new_bounds); + } + ToolUpdateResult::Redraw + } else { + ToolUpdateResult::Unmodified + } + } else { + ToolUpdateResult::Unmodified + } + } SketchBoardInput::ToolbarEvent(toolbar_event) => { self.handle_toolbar_event(toolbar_event, sender) } @@ -1350,6 +1585,27 @@ impl Component for SketchBoard { if APP_CONFIG.read().auto_copy() { self.renderer.request_render(&[Action::SaveToClipboard]); } + + if self.return_to_pointer_after_text_commit + && self.active_tool_type() == Tools::Text + { + self.return_to_pointer_after_text_commit = false; + let _ = self.handle_toolbar_event( + ToolbarEvent::ToolSelected(Tools::Pointer), + sender_for_post_commit, + ); + } + + self.refresh_screen(); + } + ToolUpdateResult::ReplaceDrawable(index, drawable) => { + self.renderer.replace_drawable(index, drawable); + // Update the selection overlay bounds to reflect the new position/size. + if let Some(new_bounds) = self.renderer.get_drawable_bounds(index) { + self.pointer_tool + .borrow_mut() + .set_selection(index, new_bounds); + } self.refresh_screen(); } ToolUpdateResult::Unmodified | ToolUpdateResult::StopPropagation => (), @@ -1374,6 +1630,10 @@ impl Component for SketchBoard { let initial_ime_enabled = config.initial_tool() == Tools::Text && text_tool.borrow().input_enabled(); + let pointer_tool = tools.get_pointer_tool(); + + let text_tool = tools.get_text_tool(); + let mut model = Self { renderer: FemtoVGArea::default(), ime_enabled: Rc::new(Cell::new(initial_ime_enabled)), @@ -1382,6 +1642,10 @@ impl Component for SketchBoard { tool_edit_mode: false, style: Style::default(), pinch_last_scale: 1.0, + pointer_tool, + text_tool, + return_to_pointer_after_text_commit: false, + temporary_pointer_previous_tool: None, tools, im_context, last_saved_filepath: RefCell::new(None), diff --git a/src/tools/arrow.rs b/src/tools/arrow.rs index c09a2e8f..a65792f7 100644 --- a/src/tools/arrow.rs +++ b/src/tools/arrow.rs @@ -6,7 +6,7 @@ use relm4::{ }; use crate::{ - math::{Angle, Vec2D}, + math::{self, Angle, Vec2D}, sketch_board::{MouseButton, MouseEventMsg, MouseEventType, SketchBoardInput}, style::Style, }; @@ -137,6 +137,46 @@ impl Tool for ArrowTool { } impl Drawable for Arrow { + fn bounds(&self) -> Option<(Vec2D, Vec2D)> { + let end = self.end?; + Some(math::ensure_bounding_box(self.start, end)) + } + + fn hit_test(&self, pos: Vec2D, tolerance: f32) -> bool { + let Some(end) = self.end else { + return false; + }; + pos.distance_to_segment(self.start, end) <= tolerance + } + + fn translate(&mut self, delta: Vec2D) { + self.start += delta; + if let Some(e) = &mut self.end { + *e += delta; + } + } + + fn resize_bounds(&mut self, tl: Vec2D, br: Vec2D) { + // Preserve the arrow direction by remembering which corner each endpoint was in. + // bounds() always returns (min, max), so we detect which corners start/end occupy + // and map them into the new bounds accordingly. + if let Some(end) = self.end { + let start_is_left = self.start.x <= end.x; + let start_is_top = self.start.y <= end.y; + self.start = Vec2D::new( + if start_is_left { tl.x } else { br.x }, + if start_is_top { tl.y } else { br.y }, + ); + self.end = Some(Vec2D::new( + if start_is_left { br.x } else { tl.x }, + if start_is_top { br.y } else { tl.y }, + )); + } else { + self.start = tl; + self.end = Some(br); + } + } + fn draw( &self, canvas: &mut femtovg::Canvas, diff --git a/src/tools/blur.rs b/src/tools/blur.rs index edfc3e85..06471543 100644 --- a/src/tools/blur.rs +++ b/src/tools/blur.rs @@ -13,6 +13,7 @@ use crate::{ math::{self, Vec2D}, sketch_board::{MouseButton, MouseEventMsg, MouseEventType, SketchBoardInput}, style::Style, + tools::hit_test_rectangle, }; use super::{ @@ -80,6 +81,31 @@ impl Blur { } impl Drawable for Blur { + fn bounds(&self) -> Option<(Vec2D, Vec2D)> { + let size = self.size?; + Some(math::ensure_bounding_box( + self.top_left, + self.top_left + size, + )) + } + + fn hit_test(&self, pos: Vec2D, tolerance: f32) -> bool { + hit_test_rectangle(pos, self.top_left, self.size, tolerance, true) + } + + fn translate(&mut self, delta: Vec2D) { + self.top_left += delta; + // invalidate cached blur image since position changed + *self.cached_image.borrow_mut() = None; + } + + fn resize_bounds(&mut self, tl: Vec2D, br: Vec2D) { + let (tl, br) = math::ensure_bounding_box(tl, br); + self.top_left = tl; + self.size = Some(br - tl); + *self.cached_image.borrow_mut() = None; + } + fn draw( &self, canvas: &mut femtovg::Canvas, diff --git a/src/tools/brush.rs b/src/tools/brush.rs index a9c2cf9d..8b5cd3b7 100644 --- a/src/tools/brush.rs +++ b/src/tools/brush.rs @@ -4,9 +4,10 @@ use femtovg::{FontId, LineCap, LineJoin, Paint, Path}; use crate::{ configuration::APP_CONFIG, - math::Vec2D, + math::{self, Vec2D}, sketch_board::{MouseButton, MouseEventMsg, MouseEventType, SketchBoardInput}, style::Style, + tools::hit_test_rectangle, }; use super::{Drawable, DrawableClone, Tool, ToolUpdateResult, Tools}; @@ -37,6 +38,73 @@ impl BrushDrawable { } impl Drawable for BrushDrawable { + fn bounds(&self) -> Option<(Vec2D, Vec2D)> { + let start = self.start_point?; + let mut tl = start; + let mut br = start; + for p in self.points.iter().skip(1) { + let abs = start + *p; + tl = tl.min(abs); + br = br.max(abs); + } + Some((tl, br)) + } + + fn hit_test(&self, pos: Vec2D, tolerance: f32) -> bool { + let (tl, br) = match self.bounds() { + Some(bounds) => bounds, + None => return false, + }; + hit_test_rectangle(pos, tl, Some(br - tl), tolerance, true) + } + + fn translate(&mut self, delta: Vec2D) { + if let Some(ref mut sp) = self.start_point { + *sp += delta; + } + } + + fn resize_bounds(&mut self, tl: Vec2D, br: Vec2D) { + let (tl, br) = math::ensure_bounding_box(tl, br); + // Get current bounds + if let Some((current_tl, current_br)) = self.bounds() { + let current_size = current_br - current_tl; + let new_size = br - tl; + + // Calculate scale factors + let scale_x = if current_size.x.abs() > 0.001 { + new_size.x / current_size.x + } else { + 1.0 + }; + let scale_y = if current_size.y.abs() > 0.001 { + new_size.y / current_size.y + } else { + 1.0 + }; + + // Scale all points relative to their current start point + if let Some(start) = self.start_point { + for p in &mut self.points { + // Convert to absolute coordinates + let abs_p = Vec2D { + x: start.x + p.x, + y: start.y + p.y, + }; + // Translate to origin (relative to old top-left) + let rel_p = abs_p - current_tl; + // Scale and update point + *p = Vec2D { + x: rel_p.x * scale_x, + y: rel_p.y * scale_y, + }; + } + // Update start point to new top-left + self.start_point = Some(tl); + } + } + } + fn draw( &self, canvas: &mut femtovg::Canvas, diff --git a/src/tools/drag_box.rs b/src/tools/drag_box.rs index 52364fd0..119653bb 100644 --- a/src/tools/drag_box.rs +++ b/src/tools/drag_box.rs @@ -25,12 +25,12 @@ impl DragBox { let top_left = if centered { origin - size * 0.5 } else { - origin + origin.min(origin + size) }; Self { top_left, - size, + size: size.abs(), centered, } } diff --git a/src/tools/ellipse.rs b/src/tools/ellipse.rs index 48a3fe94..b3351e65 100644 --- a/src/tools/ellipse.rs +++ b/src/tools/ellipse.rs @@ -3,7 +3,7 @@ use femtovg::{FontId, Path}; use relm4::{Sender, gtk::gdk::Key}; use crate::{ - math::Vec2D, + math::{self, Vec2D}, sketch_board::{MouseButton, MouseEventMsg, MouseEventType, SketchBoardInput}, style::Style, }; @@ -24,6 +24,47 @@ pub struct Ellipse { } impl Drawable for Ellipse { + fn bounds(&self) -> Option<(Vec2D, Vec2D)> { + let radii = self.radii?.abs(); + Some((self.middle - radii, self.middle + radii)) + } + + fn hit_test(&self, pos: Vec2D, tolerance: f32) -> bool { + let Some(radii) = self.radii else { + return false; + }; + + let d = (pos - self.middle) / (radii + tolerance); + if d * d > 1.0 { + // outside the outer tolerance + return false; + } + + // if filled, only check the outer tolerance + if self.style.fill { + return true; + } + + // outside the inner tolerance + let inner_d = (pos - self.middle) / (radii - tolerance); + inner_d * inner_d > 1.0 + } + + fn translate(&mut self, delta: Vec2D) { + self.middle += delta; + self.origin += delta; + } + + fn resize_bounds(&mut self, tl: Vec2D, br: Vec2D) { + let (tl, br) = math::ensure_bounding_box(tl, br); + let center = (tl + br) / 2.0; + self.middle = center; + self.origin = center; + self.radii = Some((br - tl).abs() / 2.0); + self.centered = false; + self.finishing = true; + } + fn draw( &self, canvas: &mut femtovg::Canvas, @@ -59,7 +100,7 @@ impl Ellipse { let drag_box = DragBox::from_origin_delta(self.origin, event.pos, event.modifier); self.centered = drag_box.centered; self.middle = drag_box.middle(); - self.radii = Some(drag_box.size * 0.5); + self.radii = Some(drag_box.size.abs() * 0.5); } } diff --git a/src/tools/highlight.rs b/src/tools/highlight.rs index 6bbbd8f5..4ad2cb82 100644 --- a/src/tools/highlight.rs +++ b/src/tools/highlight.rs @@ -14,7 +14,7 @@ use crate::{ math::{self, Vec2D}, sketch_board::{MouseButton, MouseEventMsg, MouseEventType, SketchBoardInput}, style::Style, - tools::DrawableClone, + tools::{DrawableClone, hit_test_rectangle}, }; use satty_cli::command_line; @@ -161,6 +161,131 @@ pub struct HighlightTool { } impl Drawable for HighlightKind { + fn bounds(&self) -> Option<(Vec2D, Vec2D)> { + match self { + HighlightKind::Block(h) => { + let size = h.data.size?; + Some(math::ensure_bounding_box( + h.data.top_left, + h.data.top_left + size, + )) + } + HighlightKind::Freehand(h) => { + let mut min_x = f32::MAX; + let mut min_y = f32::MAX; + let mut max_x = f32::MIN; + let mut max_y = f32::MIN; + let first = h.data.points.first()?; + for (i, p) in h.data.points.iter().enumerate() { + // First point is absolute, subsequent points are stored as offsets. + let abs = if i == 0 { *p } else { *first + *p }; + min_x = min_x.min(abs.x); + min_y = min_y.min(abs.y); + max_x = max_x.max(abs.x); + max_y = max_y.max(abs.y); + } + let stroke_width = h + .style + .size + .to_highlight_width(h.style.annotation_size_factor); + Some(( + Vec2D::new(min_x, min_y) - stroke_width, + Vec2D::new(max_x, max_y) + stroke_width, + )) + } + } + } + + fn hit_test(&self, pos: Vec2D, tolerance: f32) -> bool { + let (tl, br) = match self.bounds() { + Some(bounds) => bounds, + None => return false, + }; + hit_test_rectangle(pos, tl, Some(br - tl), tolerance, true) + } + + fn translate(&mut self, delta: Vec2D) { + match self { + HighlightKind::Block(h) => { + h.data.top_left += delta; + } + HighlightKind::Freehand(h) => { + if let Some(first) = h.data.points.first_mut() { + *first += delta; + } + } + } + } + + fn resize_bounds(&mut self, tl: Vec2D, br: Vec2D) { + let (tl, br) = math::ensure_bounding_box(tl, br); + match self { + HighlightKind::Block(h) => { + h.data.top_left = tl; + h.data.size = Some(br - tl); + } + HighlightKind::Freehand(h) => { + // Resize freehand by scaling all points from current bounds to new bounds. + if h.data.points.is_empty() { + return; + } + + let first_abs = h.data.points[0]; + let mut min_x = f32::MAX; + let mut min_y = f32::MAX; + let mut max_x = f32::MIN; + let mut max_y = f32::MIN; + for (i, point) in h.data.points.iter().enumerate() { + let abs = if i == 0 { *point } else { first_abs + *point }; + min_x = min_x.min(abs.x); + min_y = min_y.min(abs.y); + max_x = max_x.max(abs.x); + max_y = max_y.max(abs.y); + } + + let current_tl = Vec2D::new(min_x, min_y); + let current_br = Vec2D::new(max_x, max_y); + + let current_size = current_br - current_tl; + let new_size = br - tl; + + let scale_x = if current_size.x.abs() > f32::EPSILON { + new_size.x / current_size.x + } else { + 1.0 + }; + let scale_y = if current_size.y.abs() > f32::EPSILON { + new_size.y / current_size.y + } else { + 1.0 + }; + + let mut transformed_abs_points = Vec::with_capacity(h.data.points.len()); + + for (i, point) in h.data.points.iter().enumerate() { + let abs = if i == 0 { *point } else { first_abs + *point }; + let relative = abs - current_tl; + transformed_abs_points.push(Vec2D::new( + tl.x + relative.x * scale_x, + tl.y + relative.y * scale_y, + )); + } + + let new_first_abs = transformed_abs_points[0]; + h.data.points[0] = new_first_abs; + for (target, abs) in h + .data + .points + .iter_mut() + .skip(1) + .zip(transformed_abs_points.iter().skip(1)) + { + *target = *abs - new_first_abs; + } + } + } + } + fn draw( &self, canvas: &mut femtovg::Canvas, diff --git a/src/tools/line.rs b/src/tools/line.rs index 38a373d8..bc89941c 100644 --- a/src/tools/line.rs +++ b/src/tools/line.rs @@ -6,7 +6,7 @@ use relm4::{ }; use crate::{ - math::Vec2D, + math::{self, Vec2D}, sketch_board::{MouseButton, MouseEventMsg, MouseEventType, SketchBoardInput}, style::Style, }; @@ -29,6 +29,46 @@ pub struct Line { } impl Drawable for Line { + fn bounds(&self) -> Option<(Vec2D, Vec2D)> { + let dir = self.direction?; + let end = self.start + dir; + Some(math::ensure_bounding_box(self.start, end)) + } + + fn hit_test(&self, pos: Vec2D, tolerance: f32) -> bool { + let Some(dir) = self.direction else { + return false; + }; + let end = self.start + dir; + pos.distance_to_segment(self.start, end) <= tolerance + } + + fn translate(&mut self, delta: Vec2D) { + self.start += delta; + } + + fn resize_bounds(&mut self, tl: Vec2D, br: Vec2D) { + if let Some(direction) = self.direction { + let end = self.start + direction; + let start_is_left = self.start.x <= end.x; + let start_is_top = self.start.y <= end.y; + let new_start = Vec2D::new( + if start_is_left { tl.x } else { br.x }, + if start_is_top { tl.y } else { br.y }, + ); + let new_end = Vec2D::new( + if start_is_left { br.x } else { tl.x }, + if start_is_top { br.y } else { tl.y }, + ); + + self.start = new_start; + self.direction = Some(new_end - new_start); + } else { + self.start = tl; + self.direction = Some(br - tl); + } + } + fn draw( &self, canvas: &mut femtovg::Canvas, diff --git a/src/tools/marker.rs b/src/tools/marker.rs index 74940ad2..d90bb1d5 100644 --- a/src/tools/marker.rs +++ b/src/tools/marker.rs @@ -1,4 +1,4 @@ -use std::cell::RefCell; +use std::cell::{Cell, RefCell}; use std::f64::consts::PI; use std::rc::Rc; @@ -27,6 +27,8 @@ pub struct Marker { number: u16, extra_ring: bool, style: Style, + // for bounding box cache circle radius from the last draw + radius: Cell, tool_next_number: Rc>, } @@ -39,6 +41,22 @@ impl Marker { } impl Drawable for Marker { + fn bounds(&self) -> Option<(Vec2D, Vec2D)> { + let r = self.radius.get() + self.get_line_width() * if self.extra_ring { 2.0 } else { 0.0 }; + let r = Vec2D::new(r, r); + Some((self.pos - r, self.pos + r)) + } + + fn hit_test(&self, pos: Vec2D, tolerance: f32) -> bool { + let r = self.radius.get() + self.get_line_width() * if self.extra_ring { 2.0 } else { 0.0 }; + let d = (pos - self.pos) / (r + tolerance); + d * d <= 1.0 + } + + fn translate(&mut self, delta: Vec2D) { + self.pos += delta; + } + fn draw( &self, canvas: &mut femtovg::Canvas, @@ -87,6 +105,9 @@ impl Drawable for Marker { let circle_paint = Paint::color(marker_color).with_line_width(line_width); + self.radius + .set(circle_radius + self.style.annotation_size_factor); + canvas.save(); canvas.fill_path(&inner_circle_path, &circle_paint); @@ -165,6 +186,12 @@ impl Tool for MarkerTool { if event.button != MouseButton::Primary { return ToolUpdateResult::Unmodified; } + let font_size = self + .style + .size + .to_text_size(self.style.annotation_size_factor) as f32; + let extra_ring = event.modifier.contains(ModifierType::ALT_MASK); + match event.type_ { MouseEventType::Click => { self.origin = event.pos; @@ -172,8 +199,9 @@ impl Tool for MarkerTool { pos: event.pos, number: *self.next_number.borrow(), style: self.style, + radius: Cell::new(font_size), tool_next_number: self.next_number.clone(), - extra_ring: event.modifier.contains(ModifierType::ALT_MASK), + extra_ring, }); ToolUpdateResult::Redraw } @@ -186,10 +214,11 @@ impl Tool for MarkerTool { } } MouseEventType::Release => { - *self.next_number.borrow_mut() += 1; if let Some(marker) = &mut self.marker.take() { let result = ToolUpdateResult::Commit(marker.clone_box()); self.marker = None; + // increment for next + *self.next_number.borrow_mut() += 1; result } else { ToolUpdateResult::Unmodified diff --git a/src/tools/mod.rs b/src/tools/mod.rs index abfda68c..c59baf52 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -17,7 +17,7 @@ use relm4::{ use serde_derive::Deserialize; use crate::{ - math::Vec2D, + math::{Vec2D, ensure_bounding_box}, sketch_board::{InputEvent, KeyEventMsg, MouseEventMsg, SketchBoardInput, TextEventMsg}, style::Style, }; @@ -160,11 +160,64 @@ pub trait Drawable: DrawableClone + Debug { -> Result<()>; fn handle_undo(&mut self) {} fn handle_redo(&mut self) {} + fn bounds(&self) -> Option<(Vec2D, Vec2D)> { + None + } + fn hit_test(&self, pos: Vec2D, tolerance: f32) -> bool { + let _ = (pos, tolerance); + false + } + fn translate(&mut self, delta: Vec2D) { + let _ = delta; + } + fn resize_bounds(&mut self, tl: Vec2D, br: Vec2D) { + let _ = (tl, br); + } + // Returns position, text content and style if this drawable is an editable text, for + // re-opening it in the text tool. Returns None for all other drawable types. + fn edit_info(&self) -> Option<(Vec2D, String, crate::style::Style)> { + None + } +} + +pub fn hit_test_rectangle( + pos: Vec2D, + top_left: Vec2D, + size: Option, + tolerance: f32, + filled: bool, +) -> bool { + let Some(size) = size else { + return false; + }; + + // ensure a valid bounding box - dragging br to the left/up of tl is possible + // and then the hit test should still work as expected + let (tl, br) = ensure_bounding_box(top_left, top_left + size); + + if pos.x < tl.x - tolerance + || pos.x > br.x + tolerance + || pos.y < tl.y - tolerance + || pos.y > br.y + tolerance + { + return false; + } + + // Allow hit also inside + if filled { + return true; + } + + let tl_inner = tl + tolerance; + let br_inner = br - tolerance; + + pos.x < tl_inner.x || pos.x > br_inner.x || pos.y < tl_inner.y || pos.y > br_inner.y } #[derive(Debug)] pub enum ToolUpdateResult { Commit(Box), + ReplaceDrawable(usize, Box), Redraw, Unmodified, StopPropagation, @@ -177,10 +230,11 @@ pub use crop::CropTool; pub use ellipse::EllipseTool; pub use highlight::{HighlightTool, Highlighters}; pub use line::LineTool; +pub use pointer::PointerTool; pub use rectangle::RectangleTool; pub use text::TextTool; -use self::{brush::BrushTool, marker::MarkerTool, pointer::PointerTool}; +use self::{brush::BrushTool, marker::MarkerTool}; #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash, Deserialize)] #[serde(rename_all = "lowercase")] @@ -245,6 +299,7 @@ impl FromStr for Tools { pub struct ToolsManager { tools: HashMap>>, crop_tool: Rc>, + pointer_tool: Rc>, text_tool: Rc>, } @@ -252,10 +307,6 @@ impl ToolsManager { pub fn new() -> Self { let mut tools: HashMap>> = HashMap::new(); //tools.insert(Tools::Crop, Rc::new(RefCell::new(CropTool::default()))); - tools.insert( - Tools::Pointer, - Rc::new(RefCell::new(PointerTool::default())), - ); tools.insert(Tools::Line, Rc::new(RefCell::new(LineTool::default()))); tools.insert(Tools::Arrow, Rc::new(RefCell::new(ArrowTool::default()))); tools.insert( @@ -266,7 +317,8 @@ impl ToolsManager { Tools::Ellipse, Rc::new(RefCell::new(EllipseTool::default())), ); - tools.insert(Tools::Text, Rc::new(RefCell::new(TextTool::default()))); + let text_tool = Rc::new(RefCell::new(TextTool::default())); + tools.insert(Tools::Text, text_tool.clone()); tools.insert(Tools::Blur, Rc::new(RefCell::new(BlurTool::default()))); tools.insert( Tools::Highlight, @@ -277,10 +329,12 @@ impl ToolsManager { let crop_tool = Rc::new(RefCell::new(CropTool::default())); let text_tool = Rc::new(RefCell::new(TextTool::default())); + let pointer_tool = Rc::new(RefCell::new(PointerTool::default())); Self { tools, - crop_tool, text_tool, + crop_tool, + pointer_tool, } } @@ -288,11 +342,12 @@ impl ToolsManager { match tool { Tools::Crop => self.crop_tool.clone(), Tools::Text => self.text_tool.clone(), + Tools::Pointer => self.pointer_tool.clone(), _ => self .tools .get(tool) .unwrap_or_else(|| { - panic!("Did you add the requested too {tool:#?} to the tools HashMap?") + panic!("Did you add the requested to {tool:#?} to the tools HashMap?") }) .clone(), } @@ -302,6 +357,10 @@ impl ToolsManager { self.crop_tool.clone() } + pub fn get_pointer_tool(&self) -> Rc> { + self.pointer_tool.clone() + } + pub fn get_text_tool(&self) -> Rc> { self.text_tool.clone() } diff --git a/src/tools/pointer.rs b/src/tools/pointer.rs index 47209885..6c9c9c79 100644 --- a/src/tools/pointer.rs +++ b/src/tools/pointer.rs @@ -1,20 +1,377 @@ -use super::{Tool, Tools}; -use crate::sketch_board::SketchBoardInput; -use relm4::Sender; +use anyhow::Result; +use femtovg::{Color, FontId, Paint, Path}; +use relm4::{Sender, gtk::gdk::ModifierType}; +use std::cell::Cell; + +use crate::{ + configuration::APP_CONFIG, + math::{Vec2D, ensure_bounding_box}, + sketch_board::{KeyEventMsg, MouseButton, MouseEventMsg, MouseEventType, SketchBoardInput}, +}; + +use super::{Drawable, Tool, ToolUpdateResult, Tools}; + +// Desired on-screen size (in device pixels) for each resize handle. +const HANDLE_SIZE: f32 = 11.0; +const HANDLE_HALF: f32 = HANDLE_SIZE / 2.0; +const SELECTION_BORDER_OUTSET: f32 = 4.0; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ResizeHandle { + TopLeft, + TopCenter, + TopRight, + MiddleLeft, + MiddleRight, + BottomLeft, + BottomCenter, + BottomRight, +} + +impl ResizeHandle { + pub fn all() -> [ResizeHandle; 8] { + [ + ResizeHandle::TopLeft, + ResizeHandle::TopCenter, + ResizeHandle::TopRight, + ResizeHandle::MiddleLeft, + ResizeHandle::MiddleRight, + ResizeHandle::BottomLeft, + ResizeHandle::BottomCenter, + ResizeHandle::BottomRight, + ] + } + + pub fn center(&self, tl: Vec2D, br: Vec2D) -> Vec2D { + let mx = (tl.x + br.x) / 2.0; + let my = (tl.y + br.y) / 2.0; + match self { + ResizeHandle::TopLeft => tl, + ResizeHandle::TopCenter => Vec2D::new(mx, tl.y), + ResizeHandle::TopRight => Vec2D::new(br.x, tl.y), + ResizeHandle::MiddleLeft => Vec2D::new(tl.x, my), + ResizeHandle::MiddleRight => Vec2D::new(br.x, my), + ResizeHandle::BottomLeft => Vec2D::new(tl.x, br.y), + ResizeHandle::BottomCenter => Vec2D::new(mx, br.y), + ResizeHandle::BottomRight => br, + } + } + + // Compute new (tl, br) after dragging this handle by `delta`. + // + // This intentionally preserves axis inversion (tl may become > br) so tools like + // line/arrow can keep endpoint intent when crossing over an axis. + pub fn apply_delta(&self, tl: Vec2D, br: Vec2D, delta: Vec2D) -> (Vec2D, Vec2D) { + let mut new_tl = tl; + let mut new_br = br; + match self { + ResizeHandle::TopLeft => { + new_tl += delta; + } + ResizeHandle::TopCenter => { + new_tl.y += delta.y; + } + ResizeHandle::TopRight => { + new_tl.y += delta.y; + new_br.x += delta.x; + } + ResizeHandle::MiddleLeft => { + new_tl.x += delta.x; + } + ResizeHandle::MiddleRight => { + new_br.x += delta.x; + } + ResizeHandle::BottomLeft => { + new_tl.x += delta.x; + new_br.y += delta.y; + } + ResizeHandle::BottomCenter => { + new_br.y += delta.y; + } + ResizeHandle::BottomRight => { + new_br += delta; + } + } + + (new_tl, new_br) + } +} + +// Returns the handle under `pos`, if any, given bounds `(tl, br)`. +pub fn hit_handle( + scaled_handle_size: f32, + pos: Vec2D, + tl: Vec2D, + br: Vec2D, +) -> Option { + for h in ResizeHandle::all() { + let handle_half = scaled_handle_size / 2.0; + + let c = h.center(tl, br); + if (pos.x - c.x).abs() <= handle_half && (pos.y - c.y).abs() <= handle_half { + return Some(h); + } + } + None +} + +// Draws a selection rectangle with 8 resize handles on top of the selected drawable. +#[derive(Clone, Debug)] +struct SelectionOverlay { + tl: Vec2D, + br: Vec2D, + scaled_handle_size: Cell, +} + +impl Drawable for SelectionOverlay { + fn draw( + &self, + canvas: &mut femtovg::Canvas, + _font: FontId, + _bounds: (Vec2D, Vec2D), + ) -> Result<()> { + canvas.save(); + + // draw handles in inverse zoom scale so the visual size stays constant on screen. + let scale = canvas.transform().average_scale().max(f32::EPSILON); + + // Selection rectangle + let stroke_width = 1.5 / scale; + let mut rect = Path::new(); + rect.rect( + self.tl.x, + self.tl.y, + self.br.x - self.tl.x, + self.br.y - self.tl.y, + ); + canvas.stroke_path( + &rect, + &Paint::color(Color::rgba(70, 130, 180, 220)).with_line_width(stroke_width), + ); + + // Resize handles + let handle_half = HANDLE_HALF / scale; + let handle_size = HANDLE_SIZE / scale; + self.scaled_handle_size.set(handle_size); + for handle in ResizeHandle::all() { + let c = handle.center(self.tl, self.br); + let mut hpath = Path::new(); + hpath.rect( + c.x - handle_half, + c.y - handle_half, + handle_size, + handle_size, + ); + canvas.fill_path(&hpath, &Paint::color(Color::rgba(255, 255, 255, 255))); + canvas.stroke_path( + &hpath, + &Paint::color(Color::rgba(70, 130, 180, 255)).with_line_width(stroke_width), + ); + } + + canvas.restore(); + Ok(()) + } +} + +enum DragState { + None, + Moving { + index: usize, + original: Box, + orig_bounds: (Vec2D, Vec2D), + }, + Resizing { + index: usize, + original: Box, + handle: ResizeHandle, + orig_bounds: (Vec2D, Vec2D), + }, +} -#[derive(Default)] pub struct PointerTool { input_enabled: bool, sender: Option>, + selected_index: Option, + selected_bounds: Option<(Vec2D, Vec2D)>, + drag_state: DragState, + // Shown as the active-tool drawable: either a moved/resized preview, or a selection overlay. + preview: Option>, + selection_overlay: Option, + // For cycling through overlapping objects: last click position + last_click_pos: Option, + // For cycling through overlapping objects: all hit objects at last click position + hit_objects_at_pos: Vec, + // For cycling through overlapping objects: current index in hit_objects list + current_cycle_index: usize, +} + +impl Default for PointerTool { + fn default() -> Self { + Self { + input_enabled: false, + sender: None, + selected_index: None, + selected_bounds: None, + drag_state: DragState::None, + preview: None, + selection_overlay: None, + last_click_pos: None, + hit_objects_at_pos: Vec::new(), + current_cycle_index: 0, + } + } +} + +impl PointerTool { + pub fn selected_index(&self) -> Option { + self.selected_index + } + + // Returns the handle under `pos` given the current selection bounds. + pub fn hit_test_handles(&self, pos: Vec2D) -> Option { + let overlay = self.selection_overlay.as_ref()?; + let scaled_handle_size = overlay.scaled_handle_size.get(); + hit_handle(scaled_handle_size, pos, overlay.tl, overlay.br) + } + + // Called by SketchBoard before delivering a BeginDrag event: sets up a move drag. + pub fn begin_move( + &mut self, + index: usize, + drawable: Box, + orig_bounds: (Vec2D, Vec2D), + ) { + self.selected_index = Some(index); + self.selected_bounds = Some(orig_bounds); + self.selection_overlay = None; + self.preview = Some(drawable.clone_box()); + self.drag_state = DragState::Moving { + index, + original: drawable, + orig_bounds, + }; + } + + // Called by SketchBoard before delivering a BeginDrag event: sets up a resize drag. + pub fn begin_resize( + &mut self, + index: usize, + drawable: Box, + handle: ResizeHandle, + orig_bounds: (Vec2D, Vec2D), + ) { + self.selected_index = Some(index); + self.selected_bounds = Some(orig_bounds); + self.selection_overlay = None; + self.preview = Some(drawable.clone_box()); + self.drag_state = DragState::Resizing { + index, + original: drawable, + handle, + orig_bounds, + }; + } + + fn update_selection_bounds(&mut self, tl: Vec2D, br: Vec2D) { + let (tl, br) = ensure_bounding_box(tl, br); + self.selected_bounds = Some((tl, br)); + + let handle_size = if let Some(overlay) = &self.selection_overlay { + overlay.scaled_handle_size.get() + } else { + HANDLE_SIZE + }; + + // Add extra outset to selection overlay if the drawable is small to reduce handle overlapping + let w = br.x - tl.x + SELECTION_BORDER_OUTSET * 2.0; + let h = br.y - tl.y + SELECTION_BORDER_OUTSET * 2.0; + let border_outset_x = if w < 3.0 * handle_size { + HANDLE_SIZE + } else { + SELECTION_BORDER_OUTSET + }; + let border_outset_y = if h < 3.0 * handle_size { + HANDLE_SIZE + } else { + SELECTION_BORDER_OUTSET + }; + + self.selection_overlay = Some(SelectionOverlay { + tl: tl - Vec2D::new(border_outset_x, border_outset_y), + br: br + Vec2D::new(border_outset_x, border_outset_y), + // is updated in draw() to maintain constant on-screen size regardless of zoom level + scaled_handle_size: Cell::new(HANDLE_SIZE), + }); + } + + // Select a drawable without starting a drag (e.g. after a commit/replace). + pub fn set_selection(&mut self, index: usize, bounds: (Vec2D, Vec2D)) { + self.selected_index = Some(index); + self.update_selection_bounds(bounds.0, bounds.1); + self.drag_state = DragState::None; + self.preview = None; + } + + pub fn deselect(&mut self) { + self.selected_index = None; + self.selected_bounds = None; + self.selection_overlay = None; + self.drag_state = DragState::None; + self.preview = None; + // Reset cycling state when deselecting + self.last_click_pos = None; + self.hit_objects_at_pos.clear(); + self.current_cycle_index = 0; + } + + // Cycle through overlapping objects at the same position. + // When Alt+Click is used, this method determines which object to select next. + // Returns the next object index to cycle through, or None if no objects are at the position. + pub fn cycle_to_next_object( + &mut self, + click_pos: Vec2D, + hit_indices: Vec, + ) -> Option { + if hit_indices.is_empty() { + return None; + } + + // Check if this is the same position as last click + if let Some(last_pos) = self.last_click_pos { + if (last_pos.x - click_pos.x).abs() < 0.1 && (last_pos.y - click_pos.y).abs() < 0.1 { + // Same position: advance to next object in cycle + self.current_cycle_index = (self.current_cycle_index + 1) % hit_indices.len(); + } else { + // Different position: reset cycle + self.current_cycle_index = 0; + } + } else { + // First time: reset cycle + self.current_cycle_index = 0; + } + + // Store position for next cycle check + self.last_click_pos = Some(click_pos); + + // Return the object at current cycle index + hit_indices.get(self.current_cycle_index).copied() + } } impl Tool for PointerTool { - fn get_tool_type(&self) -> super::Tools { + fn get_tool_type(&self) -> Tools { Tools::Pointer } - fn get_drawable(&self) -> Option<&dyn super::Drawable> { - None + fn get_drawable(&self) -> Option<&dyn Drawable> { + if let Some(p) = &self.preview { + Some(p.as_ref()) + } else if let Some(s) = &self.selection_overlay { + Some(s) + } else { + None + } } fn input_enabled(&self) -> bool { @@ -25,6 +382,132 @@ impl Tool for PointerTool { self.input_enabled = value; } + fn handle_deactivated(&mut self) -> ToolUpdateResult { + self.deselect(); + ToolUpdateResult::Redraw + } + + fn handle_key_event(&mut self, event: KeyEventMsg) -> ToolUpdateResult { + if self.selected_index.is_none() + || event + .modifier + .intersects(ModifierType::CONTROL_MASK | ModifierType::ALT_MASK) + { + return ToolUpdateResult::Unmodified; + } + + let step = if event.modifier.contains(ModifierType::SHIFT_MASK) { + APP_CONFIG.read().text_move_length() + } else { + 1.0 + }; + + let delta = match event.key { + relm4::gtk::gdk::Key::Left => Vec2D::new(-step, 0.0), + relm4::gtk::gdk::Key::Right => Vec2D::new(step, 0.0), + relm4::gtk::gdk::Key::Up => Vec2D::new(0.0, -step), + relm4::gtk::gdk::Key::Down => Vec2D::new(0.0, step), + _ => return ToolUpdateResult::Unmodified, + }; + + if let Some(sender) = &self.sender { + sender.emit(SketchBoardInput::NudgeSelection(delta)); + ToolUpdateResult::StopPropagation + } else { + ToolUpdateResult::Unmodified + } + } + + fn handle_mouse_event(&mut self, event: MouseEventMsg) -> ToolUpdateResult { + if event.button == MouseButton::Middle { + return ToolUpdateResult::Unmodified; + } + + // For EndDrag/UpdateDrag, event.pos is the cumulative delta since BeginDrag. + match event.type_ { + MouseEventType::UpdateDrag => match &self.drag_state { + DragState::Moving { + original, + orig_bounds, + .. + } => { + let delta = event.pos; + let mut preview = original.clone_box(); + preview.translate(delta); + let (tl, br) = *orig_bounds; + self.update_selection_bounds(tl + delta, br + delta); + self.preview = Some(preview); + ToolUpdateResult::Redraw + } + DragState::Resizing { + original, + handle, + orig_bounds, + .. + } => { + let delta = event.pos; + let (new_tl, new_br) = handle.apply_delta(orig_bounds.0, orig_bounds.1, delta); + let mut preview = original.clone_box(); + preview.resize_bounds(new_tl, new_br); + self.update_selection_bounds(new_tl, new_br); + self.preview = Some(preview); + ToolUpdateResult::Redraw + } + DragState::None => ToolUpdateResult::Unmodified, + }, + + MouseEventType::EndDrag => { + match std::mem::replace(&mut self.drag_state, DragState::None) { + DragState::Moving { + index, + original, + orig_bounds, + } => { + let delta = event.pos; + if delta.is_zero() { + // Click with no movement: just show selection overlay + self.update_selection_bounds(orig_bounds.0, orig_bounds.1); + self.preview = None; + ToolUpdateResult::Redraw + } else { + let mut final_drawable = original; + final_drawable.translate(delta); + let (tl, br) = orig_bounds; + let new_bounds = (tl + delta, br + delta); + self.update_selection_bounds(new_bounds.0, new_bounds.1); + self.preview = None; + ToolUpdateResult::ReplaceDrawable(index, final_drawable) + } + } + DragState::Resizing { + index, + original, + handle, + orig_bounds, + } => { + let delta = event.pos; + if delta.is_zero() { + self.update_selection_bounds(orig_bounds.0, orig_bounds.1); + self.preview = None; + ToolUpdateResult::Redraw + } else { + let (new_tl, new_br) = + handle.apply_delta(orig_bounds.0, orig_bounds.1, delta); + let mut final_drawable = original; + final_drawable.resize_bounds(new_tl, new_br); + self.update_selection_bounds(new_tl, new_br); + self.preview = None; + ToolUpdateResult::ReplaceDrawable(index, final_drawable) + } + } + DragState::None => ToolUpdateResult::Unmodified, + } + } + + _ => ToolUpdateResult::Unmodified, + } + } + fn set_sender(&mut self, sender: Sender) { self.sender = Some(sender); } diff --git a/src/tools/rectangle.rs b/src/tools/rectangle.rs index 44e4ab19..b8bca06e 100644 --- a/src/tools/rectangle.rs +++ b/src/tools/rectangle.rs @@ -4,9 +4,10 @@ use relm4::{Sender, gtk::gdk::Key}; use crate::{ configuration::APP_CONFIG, - math::Vec2D, + math::{self, Vec2D}, sketch_board::{MouseButton, MouseEventMsg, MouseEventType, SketchBoardInput}, style::Style, + tools::hit_test_rectangle, }; use super::{ @@ -25,6 +26,32 @@ pub struct Rectangle { } impl Drawable for Rectangle { + fn bounds(&self) -> Option<(Vec2D, Vec2D)> { + let size = self.size?; + Some(math::ensure_bounding_box( + self.top_left, + self.top_left + size, + )) + } + + fn hit_test(&self, pos: Vec2D, tolerance: f32) -> bool { + hit_test_rectangle(pos, self.top_left, self.size, tolerance, self.style.fill) + } + + fn translate(&mut self, delta: Vec2D) { + self.top_left += delta; + self.origin += delta; + } + + fn resize_bounds(&mut self, tl: Vec2D, br: Vec2D) { + let (tl, br) = math::ensure_bounding_box(tl, br); + self.top_left = tl; + self.size = Some(br - tl); + self.origin = tl; + self.centered = false; + self.finishing = true; + } + fn draw( &self, canvas: &mut femtovg::Canvas, @@ -118,7 +145,6 @@ impl Tool for RectangleTool { rectangle.finishing = true; if event.pos == Vec2D::zero() { self.rectangle = None; - ToolUpdateResult::Redraw } else { rectangle.calculate_shape(&event); diff --git a/src/tools/text.rs b/src/tools/text.rs index 07208e9f..fa66e370 100644 --- a/src/tools/text.rs +++ b/src/tools/text.rs @@ -10,6 +10,7 @@ use std::{borrow::Cow, ops::Range}; use relm4::gtk::prelude::*; +use crate::tools::hit_test_rectangle; use crate::{ configuration::APP_CONFIG, femtovg_area, @@ -224,6 +225,49 @@ impl Text { } impl Drawable for Text { + fn bounds(&self) -> Option<(Vec2D, Vec2D)> { + let rect = self.rect.borrow(); + if rect.width() == 0 && rect.height() == 0 { + // Not yet drawn; use pos as a small point region + return Some((self.pos, self.pos + Vec2D::new(10.0, 10.0))); + } + Some(( + Vec2D::new(rect.x() as f32, rect.y() as f32), + Vec2D::new( + (rect.x() + rect.width()) as f32, + (rect.y() + rect.height()) as f32, + ), + )) + } + + fn hit_test(&self, pos: Vec2D, tolerance: f32) -> bool { + let (tl, br) = match self.bounds() { + Some(bounds) => bounds, + None => return false, + }; + hit_test_rectangle(pos, tl, Some(br - tl), tolerance, true) + } + + fn translate(&mut self, delta: Vec2D) { + self.pos += delta; + let old = *self.rect.borrow(); + *self.rect.borrow_mut() = Rectangle::new( + old.x() + delta.x as i32, + old.y() + delta.y as i32, + old.width(), + old.height(), + ); + } + + fn edit_info(&self) -> Option<(Vec2D, String, crate::style::Style)> { + let content = self.text_buffer.text( + &self.text_buffer.start_iter(), + &self.text_buffer.end_iter(), + false, + ); + Some((self.pos, content.to_string(), self.style)) + } + fn draw( &self, canvas: &mut femtovg::Canvas, @@ -803,6 +847,7 @@ pub struct TextTool { drag_start_pos: Vec2D, dragged: Rc>, alt_tap: bool, + editing_existing: bool, } impl Tool for TextTool { @@ -1259,6 +1304,8 @@ impl Tool for TextTool { } } + let editing_existing = self.editing_existing; + // create commit message if necessary let return_value = match &mut self.text { Some(l) => { @@ -1280,10 +1327,17 @@ impl Tool for TextTool { None => ToolUpdateResult::Redraw, }; - // create a new Text - self.text = Some(Text::new(event.pos, self.style, self.im_context.clone())); - - self.set_input_enabled(true); + if editing_existing { + // Pointer-initiated edit: finish editing and let SketchBoard switch tool. + self.text = None; + self.set_input_enabled(false); + self.editing_existing = false; + } else { + // Native text-tool behavior: commit current text and start a new one. + self.text = + Some(Text::new(event.pos, self.style, self.im_context.clone())); + self.set_input_enabled(true); + } return_value } @@ -1406,6 +1460,7 @@ impl Tool for TextTool { fn handle_deactivated(&mut self) -> ToolUpdateResult { self.input_enabled = false; + self.editing_existing = false; if let Some(t) = &mut self.text { let content = t.get_text(); if content.is_empty() { @@ -1852,4 +1907,17 @@ impl TextTool { } } } + + // Pre-populate the tool with an existing text drawable so the user can edit it. + // Call this before switching to the Text tool. + pub fn load_for_editing(&mut self, pos: Vec2D, content: &str, style: Style) { + let t = Text::new(pos, style, self.im_context.clone()); + t.text_buffer.insert_at_cursor(content); + // Move cursor to end + t.text_buffer.place_cursor(&t.text_buffer.end_iter()); + self.text = Some(t); + self.style = style; + self.set_input_enabled(true); + self.editing_existing = true; + } } From 1beaf2ef05aa56bfed7f05537b6dda8c96b2b82d Mon Sep 17 00:00:00 2001 From: robertwidfen <126524724+robertwidfen@users.noreply.github.com> Date: Sat, 8 Aug 2026 03:05:43 +0200 Subject: [PATCH 02/13] refactor: crop uses pointer tool --- src/femtovg_area/imp.rs | 84 ++++++--- src/femtovg_area/mod.rs | 22 ++- src/sketch_board.rs | 29 ++- src/tools/crop.rs | 401 ++++++---------------------------------- src/tools/mod.rs | 28 ++- src/tools/pointer.rs | 19 +- 6 files changed, 199 insertions(+), 384 deletions(-) diff --git a/src/femtovg_area/imp.rs b/src/femtovg_area/imp.rs index a4d77a29..5b021766 100644 --- a/src/femtovg_area/imp.rs +++ b/src/femtovg_area/imp.rs @@ -25,7 +25,7 @@ use crate::{ configuration::Action, math::{Vec2D, rect_ensure_in_bounds, rect_round}, sketch_board::SketchBoardInput, - tools::{CropTool, Drawable, Tool}, + tools::{Drawable, Tool, Tools}, }; use super::{font_stack, set_font_stack}; @@ -46,7 +46,6 @@ pub struct FemtoVgAreaMut { background_image_id: Option, transparent_background_id: Option, active_tool: Rc>, - crop_tool: Rc>, scale_factor: f32, offset: Vec2D, drawables: Vec>, @@ -165,7 +164,6 @@ impl FemtoVGArea { pub fn init( &self, sender: Sender, - crop_tool: Rc>, active_tool: Rc>, background_image: Pixbuf, ) { @@ -175,7 +173,6 @@ impl FemtoVGArea { background_image_id: None, transparent_background_id: None, active_tool, - crop_tool, scale_factor: 1.0, offset: Vec2D::zero(), drawables: Vec::new(), @@ -330,13 +327,29 @@ impl FemtoVGArea { impl FemtoVgAreaMut { pub fn commit(&mut self, drawable: Box) { + // Keep at most one crop drawable and pin it to index 0. + if drawable.is_crop() { + self.drawables.retain(|d| !d.is_crop()); + self.drawables.insert(0, drawable.clone_box()); + self.undo_stack.push(HistoryEntry::Drawable(drawable)); + self.redo_stack.clear(); + return; + } self.undo_stack .push(HistoryEntry::Drawable(drawable.clone_box())); self.drawables.push(drawable); self.redo_stack.clear(); } - // Hit-test all drawables and return all indices whose bounds hit `pos`, in order from topmost to bottommost. + pub fn last_drawable_index(&self) -> Option { + self.drawables.len().checked_sub(1) + } + + pub fn crop_drawable_index(&self) -> Option { + self.drawables.iter().position(|d| d.is_crop()) + } + + // Hit-test all drawables and return all indices whose bounds contain `pos`, in order from topmost to bottommost. pub fn hit_test(&self, pos: Vec2D) -> Vec { let mut results = Vec::new(); // A small tolerance is applied to make thin shapes (lines, arrows) easier to click. @@ -368,6 +381,10 @@ impl FemtoVgAreaMut { return None; } + if self.drawables[index].is_crop() { + return Some(0); + } + if index + 1 == self.drawables.len() { return Some(index); } @@ -391,7 +408,15 @@ impl FemtoVgAreaMut { pub fn undo(&mut self) -> bool { match self.undo_stack.pop() { Some(HistoryEntry::Drawable(history_drawable)) => { - let mut drawable = self.drawables.pop().unwrap_or(history_drawable); + let mut drawable = if history_drawable.is_crop() { + if let Some(index) = self.crop_drawable_index() { + self.drawables.remove(index) + } else { + history_drawable + } + } else { + self.drawables.pop().unwrap_or(history_drawable) + }; drawable.handle_undo(); self.redo_stack.push(HistoryEntry::Drawable(drawable)); true @@ -408,6 +433,16 @@ impl FemtoVgAreaMut { match self.redo_stack.pop() { Some(HistoryEntry::Drawable(mut drawable)) => { drawable.handle_redo(); + + // Keep at most one crop drawable and pin it to index 0. + if drawable.is_crop() { + self.drawables.retain(|d| !d.is_crop()); + self.undo_stack + .push(HistoryEntry::Drawable(drawable.clone_box())); + self.drawables.insert(0, drawable); + return true; + } + self.undo_stack .push(HistoryEntry::Drawable(drawable.clone_box())); self.drawables.push(drawable); @@ -476,15 +511,17 @@ impl FemtoVgAreaMut { self.background_image.height() as f32, ), ); - // get offset and size of the area in question + // get offset and size of the crop if there is one let (pos, size) = self - .crop_tool - .borrow() - .get_crop() - .map(|c| c.get_rectangle()) - .map(|rect| rect_ensure_in_bounds(rect, bounds)) - .map(rect_round) - .filter(|(_, size)| !size.is_zero()) + .drawables + .iter() + .find(|d| d.is_crop()) + .and_then(|d| { + d.bounds().map(|(tl, br)| { + let rect = (tl, br - tl); + rect_ensure_in_bounds(rect_round(rect), bounds) + }) + }) .unwrap_or(bounds); // create render-target @@ -505,7 +542,6 @@ impl FemtoVgAreaMut { self.render( canvas, font, - false, femtovg::Color::rgbaf(0.0, 0.0, 0.0, 0.0), false, )?; @@ -538,7 +574,6 @@ impl FemtoVgAreaMut { self.render( canvas, font, - true, femtovg::Color::rgbaf(0.0, 0.0, 0.0, 0.0), true, )?; @@ -550,7 +585,6 @@ impl FemtoVgAreaMut { &mut self, canvas: &mut femtovg::Canvas, font: FontId, - render_crop: bool, outside_bg_color: femtovg::Color, onscreen: bool, ) -> Result<()> { @@ -568,13 +602,17 @@ impl FemtoVgAreaMut { self.background_image.height() as f32, ), ); + // Offscreen export should not include pointer selection handles. + let draw_active_tool = + onscreen || self.active_tool.borrow().get_tool_type() != Tools::Pointer; let mut active_tool_drawn_in_stack = false; // render the whole stack for (i, d) in self.drawables.iter().enumerate() { if self.hidden_drawable_index == Some(i) { // Draw the active tool preview in the original z position. - if let Some(preview) = self.active_tool.borrow().get_drawable() { + if draw_active_tool && let Some(preview) = self.active_tool.borrow().get_drawable() + { preview.draw(canvas, font, bounds)?; active_tool_drawn_in_stack = true; } @@ -584,15 +622,13 @@ impl FemtoVgAreaMut { } // render active tool (default: on top) when not already drawn in stack order - if !active_tool_drawn_in_stack && let Some(d) = self.active_tool.borrow().get_drawable() { + if draw_active_tool + && !active_tool_drawn_in_stack + && let Some(d) = self.active_tool.borrow().get_drawable() + { d.draw(canvas, font, bounds)?; } - // render crop tool - if render_crop && let Some(c) = self.crop_tool.borrow().get_crop() { - c.draw(canvas, font, bounds)?; - } - canvas.flush(); Ok(()) } diff --git a/src/femtovg_area/mod.rs b/src/femtovg_area/mod.rs index 1363056f..f506ec26 100644 --- a/src/femtovg_area/mod.rs +++ b/src/femtovg_area/mod.rs @@ -14,7 +14,7 @@ use crate::{ configuration::Action, math::Vec2D, sketch_board::SketchBoardInput, - tools::{CropTool, Drawable, Tool}, + tools::{Drawable, Tool}, }; static FONT_STACK: OnceLock> = OnceLock::new(); @@ -99,12 +99,10 @@ impl FemtoVGArea { pub fn init( &mut self, sender: Sender, - crop_tool: Rc>, active_tool: Rc>, background_image: Pixbuf, ) { - self.imp() - .init(sender, crop_tool, active_tool, background_image); + self.imp().init(sender, active_tool, background_image); } pub fn set_zoom_scale(&self, factor: f32) { @@ -194,6 +192,22 @@ impl FemtoVGArea { .get_drawable_bounds(index) } + pub fn last_drawable_index(&self) -> Option { + self.imp() + .inner() + .as_ref() + .expect("Did you call init before using FemtoVgArea?") + .last_drawable_index() + } + + pub fn crop_drawable_index(&self) -> Option { + self.imp() + .inner() + .as_ref() + .expect("Did you call init before using FemtoVgArea?") + .crop_drawable_index() + } + pub fn get_drawable_clone(&self, index: usize) -> Option> { self.imp() .inner() diff --git a/src/sketch_board.rs b/src/sketch_board.rs index 806ccf42..54198698 100644 --- a/src/sketch_board.rs +++ b/src/sketch_board.rs @@ -1581,7 +1581,35 @@ impl Component for SketchBoard { match result { ToolUpdateResult::Commit(drawable) => { + let committed_is_crop = drawable.is_crop(); self.renderer.commit(drawable); + + let committed_index = if committed_is_crop { + self.renderer.crop_drawable_index() + } else { + self.renderer.last_drawable_index() + }; + + if let Some(index) = committed_index + && let Some(new_bounds) = self.renderer.get_drawable_bounds(index) + { + self.pointer_tool + .borrow_mut() + .set_selection(index, new_bounds); + } + + if self.active_tool_type() != Tools::Pointer { + let previous_tool = self.active_tool_type(); + let _ = self.handle_toolbar_event( + ToolbarEvent::ToolSelected(Tools::Pointer), + sender_for_post_commit.clone(), + ); + sender_for_post_commit + .output_sender() + .emit(SketchBoardOutput::ToolSwitchShortcut(Tools::Pointer)); + self.temporary_pointer_previous_tool = Some(previous_tool); + } + if APP_CONFIG.read().auto_copy() { self.renderer.request_render(&[Action::SaveToClipboard]); } @@ -1654,7 +1682,6 @@ impl Component for SketchBoard { let area = &mut model.renderer; area.init( sender.input_sender().clone(), - model.tools.get_crop_tool(), model.active_tool.clone(), image, ); diff --git a/src/tools/crop.rs b/src/tools/crop.rs index 9ff493b9..d8bd43f0 100644 --- a/src/tools/crop.rs +++ b/src/tools/crop.rs @@ -1,19 +1,20 @@ -use std::f32::consts::PI; - -use super::{Drawable, Tool, ToolUpdateResult, Tools}; +use super::{Drawable, DrawableClone, Tool, ToolUpdateResult, Tools}; use crate::{ math::{self, Vec2D}, sketch_board::{ KeyEventMsg, MouseButton, MouseEventMsg, MouseEventType, SketchBoardInput, SketchBoardOutput, }, + tools::hit_test_rectangle, }; use anyhow::Result; use femtovg::{Color, Paint, Path}; -use relm4::adw::gdk::ModifierType; -use relm4::{Sender, gtk::gdk::Key}; +use relm4::{ + Sender, + gtk::gdk::{Key, ModifierType}, +}; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Copy)] pub struct Crop { pos: Vec2D, size: Vec2D, @@ -23,15 +24,12 @@ pub struct Crop { #[derive(Default)] pub struct CropTool { crop: Option, - action: Option, + dragging: bool, input_enabled: bool, sender: Option>, } impl Crop { - const HANDLE_RADIUS: f32 = 5.0; - const HANDLE_BORDER: f32 = 2.0; - fn new(pos: Vec2D) -> Self { Self { pos, @@ -40,73 +38,34 @@ impl Crop { } } - fn draw_single_handle( - canvas: &mut femtovg::Canvas, - center: Vec2D, - scale: f32, - ) { - let mut path = Path::new(); - path.arc( - center.x, - center.y, - Crop::HANDLE_RADIUS / scale, - 0.0, - 2.0 * PI, - femtovg::Solidity::Solid, - ); - - let border_paint = - Paint::color(Color::rgbf(0.9, 0.9, 0.9)).with_line_width(Crop::HANDLE_BORDER / scale); - let fill_paint = Paint::color(Color::rgbaf(0.0, 0.0, 0.0, 0.4)); + pub fn get_rectangle(&self) -> (Vec2D, Vec2D) { + math::rect_ensure_positive_size(self.pos, self.size) + } +} - canvas.fill_path(&path, &fill_paint); - canvas.stroke_path(&path, &border_paint); +impl Drawable for Crop { + fn is_crop(&self) -> bool { + true } - pub fn get_rectangle(&self) -> (Vec2D, Vec2D) { - math::rect_ensure_positive_size(self.pos, self.size) + fn bounds(&self) -> Option<(Vec2D, Vec2D)> { + Some(math::ensure_bounding_box(self.pos, self.pos + self.size)) } - fn get_handle_pos(crop_pos: Vec2D, crop_size: Vec2D, handle: CropHandle) -> Vec2D { - match handle { - CropHandle::TopLeftCorner => crop_pos, - CropHandle::TopEdge => crop_pos + Vec2D::new(crop_size.x / 2.0, 0.0), - CropHandle::TopRightCorner => crop_pos + Vec2D::new(crop_size.x, 0.0), - CropHandle::RightEdge => crop_pos + Vec2D::new(crop_size.x, crop_size.y / 2.0), - CropHandle::BottomRightCorner => crop_pos + Vec2D::new(crop_size.x, crop_size.y), - CropHandle::BottomEdge => crop_pos + Vec2D::new(crop_size.x / 2.0, crop_size.y), - CropHandle::BottomLeftCorner => crop_pos + Vec2D::new(0.0, crop_size.y), - CropHandle::LeftEdge => crop_pos + Vec2D::new(0.0, crop_size.y / 2.0), - } + fn hit_test(&self, pos: Vec2D, tolerance: f32) -> bool { + hit_test_rectangle(pos, self.pos, Some(self.size), tolerance, false) } - fn get_closest_handle(&self, mouse_pos: Vec2D) -> (CropHandle, f32) { - let mut min_distance_squared = f32::MAX; - let mut closest_handle = CropHandle::TopLeftCorner; - for h in CropHandle::all() { - let handle_pos = Self::get_handle_pos(self.pos, self.size, h); - let distance_squared = (handle_pos - mouse_pos).norm2(); - if distance_squared < min_distance_squared { - min_distance_squared = distance_squared; - closest_handle = h; - } - } - (closest_handle, min_distance_squared) + + fn translate(&mut self, delta: Vec2D) { + self.pos += delta; } - fn test_handle_hit(&self, mouse_pos: Vec2D, margin2: f32) -> Option { - const HANDLE_SIZE: f32 = Crop::HANDLE_RADIUS + Crop::HANDLE_BORDER; - const HANDLE_SIZE2: f32 = HANDLE_SIZE * HANDLE_SIZE; - let allowed_distance2 = HANDLE_SIZE2 + margin2; - let (handle, distance2) = self.get_closest_handle(mouse_pos); - if distance2 < allowed_distance2 { - Some(handle) - } else { - None - } + fn resize_bounds(&mut self, tl: Vec2D, br: Vec2D) { + let (tl, br) = math::ensure_bounding_box(tl, br); + self.pos = tl; + self.size = br - tl; } -} -impl Drawable for Crop { fn draw( &self, canvas: &mut femtovg::Canvas, @@ -114,7 +73,6 @@ impl Drawable for Crop { bounds: (Vec2D, Vec2D), ) -> Result<()> { let size = self.size; - let scale = canvas.transform().average_scale(); let shadow_paint = Paint::color(Color::rgbaf(0.0, 0.0, 0.0, 0.5)) .with_fill_rule(femtovg::FillRule::EvenOdd); @@ -132,144 +90,12 @@ impl Drawable for Crop { canvas.fill_path(&shadow_path, &shadow_paint); canvas.stroke_path(&border_path, &border_paint); - if self.active { - Self::draw_single_handle(canvas, self.pos, scale); - Self::draw_single_handle(canvas, self.pos + Vec2D::new(size.x / 2.0, 0.0), scale); - Self::draw_single_handle(canvas, self.pos + Vec2D::new(size.x, 0.0), scale); - Self::draw_single_handle(canvas, self.pos + Vec2D::new(0.0, size.y / 2.0), scale); - Self::draw_single_handle(canvas, self.pos + Vec2D::new(0.0, size.y), scale); - Self::draw_single_handle(canvas, self.pos + Vec2D::new(size.x / 2.0, size.y), scale); - Self::draw_single_handle(canvas, self.pos + Vec2D::new(size.x, size.y), scale); - Self::draw_single_handle(canvas, self.pos + Vec2D::new(size.x, size.y / 2.0), scale); - } - canvas.restore(); Ok(()) } } -#[derive(Clone, Copy)] -enum CropHandle { - TopLeftCorner, - TopEdge, - TopRightCorner, - RightEdge, - BottomRightCorner, - BottomEdge, - BottomLeftCorner, - LeftEdge, -} - -enum CropToolAction { - NewCrop, - DragHandle(DragHandleState), - Move(MoveState), -} - -struct DragHandleState { - handle: CropHandle, - top_left_start: Vec2D, - bottom_right_start: Vec2D, -} - -struct MoveState { - start: Vec2D, -} - impl CropTool { - pub fn get_crop(&self) -> Option<&Crop> { - match &self.crop { - Some(c) => Some(c), - None => None, - } - } -} - -impl CropHandle { - fn all() -> [CropHandle; 8] { - [ - CropHandle::TopLeftCorner, - CropHandle::TopEdge, - CropHandle::TopRightCorner, - CropHandle::RightEdge, - CropHandle::BottomRightCorner, - CropHandle::BottomEdge, - CropHandle::BottomLeftCorner, - CropHandle::LeftEdge, - ] - } -} - -impl CropTool { - const HANDLE_MARGIN_IN_2: f32 = 15.0 * 15.0; - const HANDLE_MARGIN_OUT: f32 = 40.0; - - fn test_inside_crop(&self, mouse_pos: Vec2D, margin: f32) -> bool { - let crop = match &self.crop { - Some(c) => c, - None => return false, - }; - - let (mut min_x, mut max_x) = (crop.pos.x, crop.pos.x + crop.size.x); - if min_x > max_x { - (min_x, max_x) = (max_x, min_x); - } - min_x -= margin; - max_x += margin; - - let (mut min_y, mut max_y) = (crop.pos.y, crop.pos.y + crop.size.y); - if min_y > max_y { - (min_y, max_y) = (max_y, min_y); - } - min_y -= margin; - max_y += margin; - - min_x < mouse_pos.x && mouse_pos.x < max_x && min_y < mouse_pos.y && mouse_pos.y < max_y - } - - fn apply_drag_handle_transformation( - crop: &mut Crop, - state: &DragHandleState, - direction: Vec2D, - ) { - let mut tl = state.top_left_start; - let mut br = state.bottom_right_start; - - // apply transformation - match state.handle { - CropHandle::TopLeftCorner => { - tl += direction; - } - CropHandle::TopEdge => { - tl += Vec2D::new(0.0, direction.y); - } - CropHandle::TopRightCorner => { - tl += Vec2D::new(0.0, direction.y); - br += Vec2D::new(direction.x, 0.0); - } - CropHandle::RightEdge => { - br += Vec2D::new(direction.x, 0.0); - } - CropHandle::BottomRightCorner => { - br += direction; - } - CropHandle::BottomEdge => { - br += Vec2D::new(0.0, direction.y); - } - CropHandle::BottomLeftCorner => { - tl += Vec2D::new(direction.x, 0.0); - br += Vec2D::new(0.0, direction.y); - } - CropHandle::LeftEdge => { - tl += Vec2D::new(direction.x, 0.0); - } - } - - // convert back and save - crop.pos = tl; - crop.size = br - tl; - } - fn emit_crop_dimensions_update(&self) { if let (Some(crop), Some(sender)) = (&self.crop, &self.sender) { let (_pos, size) = crop.get_rectangle(); @@ -282,111 +108,6 @@ impl CropTool { .ok(); } } - - fn begin_drag(&mut self, pos: Vec2D) -> ToolUpdateResult { - let mut activate = false; - match &self.crop { - None => { - // No crop exists, create a new one - self.crop = Some(Crop::new(pos)); - self.action = Some(CropToolAction::NewCrop); - } - Some(c) => { - if !c.active { - activate = true; - } - if let Some(handle) = c.test_handle_hit(pos, CropTool::HANDLE_MARGIN_IN_2) { - // Crop exists and we are near a handle, drag it - self.action = Some(CropToolAction::DragHandle(DragHandleState { - handle, - top_left_start: c.pos, - bottom_right_start: c.pos + c.size, - })); - } else if self.test_inside_crop(pos, 0.0) { - // Crop exists and we are inside it, move it - self.action = Some(CropToolAction::Move(MoveState { start: c.pos })); - } else if self.test_inside_crop(pos, CropTool::HANDLE_MARGIN_OUT) { - // Crop exists and we are near the edge, drag from the closest handle - let (handle, _) = c.get_closest_handle(pos); - self.action = Some(CropToolAction::DragHandle(DragHandleState { - handle, - top_left_start: c.pos, - bottom_right_start: c.pos + c.size, - })); - } else { - // Crop exists, but we far outside from it, create a new one - self.crop = Some(Crop::new(pos)); - self.action = Some(CropToolAction::NewCrop); - } - } - } - if activate && let Some(c) = &mut self.crop { - c.active = true; - } - ToolUpdateResult::Redraw - } - - fn update_drag(&mut self, direction: Vec2D) -> ToolUpdateResult { - let crop = match &mut self.crop { - Some(c) => c, - None => return ToolUpdateResult::Unmodified, - }; - - let action = match &self.action { - Some(a) => a, - None => return ToolUpdateResult::Unmodified, - }; - - match action { - CropToolAction::NewCrop => { - crop.size = direction; - self.emit_crop_dimensions_update(); - ToolUpdateResult::Redraw - } - CropToolAction::DragHandle(state) => { - Self::apply_drag_handle_transformation(crop, state, direction); - self.emit_crop_dimensions_update(); - ToolUpdateResult::Redraw - } - CropToolAction::Move(state) => { - crop.pos = state.start + direction; - ToolUpdateResult::Redraw - } - } - } - - fn end_drag(&mut self, direction: Vec2D) -> ToolUpdateResult { - let Some(crop) = &mut self.crop else { - return ToolUpdateResult::Unmodified; - }; - - let Some(action) = &self.action else { - return ToolUpdateResult::Unmodified; - }; - - match action { - // crop never returns "commit" because nothing gets - // committed to the drawables stack - CropToolAction::NewCrop => { - crop.size = direction; - self.action = None; - self.emit_crop_dimensions_update(); - ToolUpdateResult::Redraw - } - CropToolAction::DragHandle(state) => { - Self::apply_drag_handle_transformation(crop, state, direction); - self.action = None; - self.emit_crop_dimensions_update(); - ToolUpdateResult::Redraw - } - CropToolAction::Move(state) => { - crop.pos = state.start + direction; - self.action = None; - self.emit_crop_dimensions_update(); - ToolUpdateResult::Redraw - } - } - } } impl Tool for CropTool { @@ -412,17 +133,15 @@ impl Tool for CropTool { fn handle_key_event(&mut self, event: KeyEventMsg) -> ToolUpdateResult { match event.key { - //FIXME: use if let guards as soon as they're stabilized (1.95) - Key::Escape if self.crop.is_some() => { - if self.crop.as_mut().unwrap().active { + Key::Escape if let Some(crop) = &self.crop => { + if crop.active { self.handle_dismissed() } else { ToolUpdateResult::Unmodified } } - //FIXME: use if let guards as soon as they're stabilized (1.95) - Key::Return if self.crop.is_some() => { - if self.crop.as_mut().unwrap().active { + Key::Return if let Some(crop) = &self.crop => { + if crop.active { self.handle_deactivated() } else { ToolUpdateResult::Unmodified @@ -447,53 +166,39 @@ impl Tool for CropTool { self.handle_dismissed() } MouseEventType::BeginDrag if event.button == MouseButton::Primary && !ctrl_pressed => { - self.begin_drag(event.pos) + self.dragging = true; + self.crop = Some(Crop::new(event.pos)); + ToolUpdateResult::Redraw } MouseEventType::EndDrag if event.button == MouseButton::Primary && !ctrl_pressed => { - self.end_drag(event.pos) + self.dragging = false; + let Some(crop) = &mut self.crop else { + return ToolUpdateResult::Unmodified; + }; + + ToolUpdateResult::Commit(crop.clone_box()) } MouseEventType::UpdateDrag if event.button == MouseButton::Primary && !ctrl_pressed => { - self.update_drag(event.pos) + if event.pos == Vec2D::zero() { + return ToolUpdateResult::Unmodified; + } + let Some(crop) = &mut self.crop else { + return ToolUpdateResult::Unmodified; + }; + crop.size = event.pos; + self.emit_crop_dimensions_update(); + ToolUpdateResult::Redraw } _ => ToolUpdateResult::Unmodified, } } - fn handle_activated(&mut self) -> ToolUpdateResult { - if let Some(c) = &mut self.crop { - c.active = true; - return ToolUpdateResult::Redraw; - } - ToolUpdateResult::Unmodified - } - - fn handle_deactivated(&mut self) -> ToolUpdateResult { - if let Some(c) = &mut self.crop { - c.active = false; - } - self.action = None; - ToolUpdateResult::Redraw - } - - fn handle_dismissed(&mut self) -> ToolUpdateResult { - self.crop = None; - self.action = None; - - if let Some(sender) = &self.sender { - sender - .send(SketchBoardInput::Output( - SketchBoardOutput::DimensionsUpdate(None), - )) - .ok(); - } - ToolUpdateResult::RedrawAndStopPropagation - } - fn get_drawable(&self) -> Option<&dyn Drawable> { - // the reason we always return None is because we dont want this tool - // to show up with the standard rendering mechanism. Instead it will always - // be drawn separately by using `get_crop(&self)` - None + if self.dragging { + self.crop.as_ref().map(|crop| crop as &dyn Drawable) + } else { + None + } } fn set_sender(&mut self, sender: Sender) { diff --git a/src/tools/mod.rs b/src/tools/mod.rs index c59baf52..3f73227b 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -1,6 +1,12 @@ use std::fmt; use std::str::FromStr; -use std::{borrow::Cow, cell::RefCell, collections::HashMap, fmt::Debug, rc::Rc}; +use std::{ + borrow::Cow, + cell::{OnceCell, RefCell}, + collections::HashMap, + fmt::Debug, + rc::Rc, +}; use anyhow::Result; use femtovg::{Canvas, FontId, renderer::OpenGl}; @@ -160,6 +166,9 @@ pub trait Drawable: DrawableClone + Debug { -> Result<()>; fn handle_undo(&mut self) {} fn handle_redo(&mut self) {} + fn is_crop(&self) -> bool { + false + } fn bounds(&self) -> Option<(Vec2D, Vec2D)> { None } @@ -236,6 +245,17 @@ pub use text::TextTool; use self::{brush::BrushTool, marker::MarkerTool}; +thread_local! { + static CROP_TOOL_SINGLETON: OnceCell>> = const { OnceCell::new() }; +} + +fn shared_crop_tool() -> Rc> { + CROP_TOOL_SINGLETON.with(|cell| { + cell.get_or_init(|| Rc::new(RefCell::new(CropTool::default()))) + .clone() + }) +} + #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash, Deserialize)] #[serde(rename_all = "lowercase")] pub enum Tools { @@ -327,7 +347,7 @@ impl ToolsManager { tools.insert(Tools::Marker, Rc::new(RefCell::new(MarkerTool::default()))); tools.insert(Tools::Brush, Rc::new(RefCell::new(BrushTool::default()))); - let crop_tool = Rc::new(RefCell::new(CropTool::default())); + let crop_tool = shared_crop_tool(); let text_tool = Rc::new(RefCell::new(TextTool::default())); let pointer_tool = Rc::new(RefCell::new(PointerTool::default())); Self { @@ -353,10 +373,6 @@ impl ToolsManager { } } - pub fn get_crop_tool(&self) -> Rc> { - self.crop_tool.clone() - } - pub fn get_pointer_tool(&self) -> Rc> { self.pointer_tool.clone() } diff --git a/src/tools/pointer.rs b/src/tools/pointer.rs index 6c9c9c79..a0d55e12 100644 --- a/src/tools/pointer.rs +++ b/src/tools/pointer.rs @@ -6,7 +6,10 @@ use std::cell::Cell; use crate::{ configuration::APP_CONFIG, math::{Vec2D, ensure_bounding_box}, - sketch_board::{KeyEventMsg, MouseButton, MouseEventMsg, MouseEventType, SketchBoardInput}, + sketch_board::{ + KeyEventMsg, MouseButton, MouseEventMsg, MouseEventType, SketchBoardInput, + SketchBoardOutput, + }, }; use super::{Drawable, Tool, ToolUpdateResult, Tools}; @@ -449,6 +452,20 @@ impl Tool for PointerTool { let (new_tl, new_br) = handle.apply_delta(orig_bounds.0, orig_bounds.1, delta); let mut preview = original.clone_box(); preview.resize_bounds(new_tl, new_br); + if preview.is_crop() + && let Some(sender) = &self.sender + { + let size = new_br - new_tl; + sender + .send(SketchBoardInput::Output( + SketchBoardOutput::DimensionsUpdate(Some(( + size.x.round() as i32, + size.y.round() as i32, + ))), + )) + .ok(); + } + self.update_selection_bounds(new_tl, new_br); self.preview = Some(preview); ToolUpdateResult::Redraw From c95853841209e28c0643e896a6a917f72180ec9d Mon Sep 17 00:00:00 2001 From: robertwidfen <126524724+robertwidfen@users.noreply.github.com> Date: Sat, 6 Jun 2026 15:38:46 +0200 Subject: [PATCH 03/13] feat: marker resize changes number resp. extra ring --- src/tools/marker.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/tools/marker.rs b/src/tools/marker.rs index d90bb1d5..7b73f73f 100644 --- a/src/tools/marker.rs +++ b/src/tools/marker.rs @@ -57,6 +57,38 @@ impl Drawable for Marker { self.pos += delta; } + fn resize_bounds(&mut self, tl: Vec2D, br: Vec2D) { + let Some((old_tl, old_br)) = self.bounds() else { + return; + }; + + // Marker resize handles are semantic controls rather than geometric resize: + // left/right adjust number, vertical drag toggles extra ring. + let delta_left = tl.x - old_tl.x; + let delta_right = br.x - old_br.x; + + const NUMBER_PX_THRESHOLD: f32 = 11.0; + let left_steps = (delta_left / NUMBER_PX_THRESHOLD).floor(); + let right_steps = (delta_right / NUMBER_PX_THRESHOLD).floor(); + let delta_steps = if left_steps.abs() < right_steps.abs() { + right_steps + } else { + left_steps + }; + let new_number = self.number.saturating_add_signed(delta_steps as i16).max(1); + self.number = new_number; + + let delta_top = tl.y - old_tl.y; + let delta_bottom = br.y - old_br.y; + let ring_offset = self.get_line_width(); + + if delta_top <= -ring_offset || delta_bottom >= ring_offset { + self.extra_ring = true; + } else if delta_top > ring_offset || delta_bottom < -ring_offset { + self.extra_ring = false; + } + } + fn draw( &self, canvas: &mut femtovg::Canvas, From 0aa53f7f2ce393332189a1efb73412c7abd8a708 Mon Sep 17 00:00:00 2001 From: robertwidfen <126524724+robertwidfen@users.noreply.github.com> Date: Mon, 25 May 2026 01:04:47 +0200 Subject: [PATCH 04/13] feat: Add Alt and Shift for Resize --- src/tools/pointer.rs | 89 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 72 insertions(+), 17 deletions(-) diff --git a/src/tools/pointer.rs b/src/tools/pointer.rs index a0d55e12..ddcbff70 100644 --- a/src/tools/pointer.rs +++ b/src/tools/pointer.rs @@ -64,35 +64,91 @@ impl ResizeHandle { // // This intentionally preserves axis inversion (tl may become > br) so tools like // line/arrow can keep endpoint intent when crossing over an axis. - pub fn apply_delta(&self, tl: Vec2D, br: Vec2D, delta: Vec2D) -> (Vec2D, Vec2D) { + pub fn resize(&self, event: MouseEventMsg, tl: Vec2D, br: Vec2D) -> (Vec2D, Vec2D) { + let mut delta = event.pos; + + if event.modifier & ModifierType::SHIFT_MASK != ModifierType::empty() { + (delta.x, delta.y) = match self { + ResizeHandle::TopRight => { + let x = delta.x.max(-delta.y); + (x, -x) + } + ResizeHandle::BottomRight => { + let x = delta.x.max(delta.y); + (x, x) + } + ResizeHandle::BottomLeft => { + let x = delta.x.min(-delta.y); + (x, -x) + } + ResizeHandle::TopLeft => { + let x = delta.x.min(delta.y); + (x, x) + } + _ => (delta.x, delta.y), + }; + } + + let is_centered = event.modifier & ModifierType::ALT_MASK != ModifierType::empty(); + if is_centered { + delta = delta / 2.0; + } + let mut new_tl = tl; let mut new_br = br; + match self { - ResizeHandle::TopLeft => { - new_tl += delta; - } - ResizeHandle::TopCenter => { - new_tl.y += delta.y; - } ResizeHandle::TopRight => { new_tl.y += delta.y; new_br.x += delta.x; - } - ResizeHandle::MiddleLeft => { - new_tl.x += delta.x; + if is_centered { + new_tl.x -= delta.x; + new_br.y -= delta.y; + } } ResizeHandle::MiddleRight => { new_br.x += delta.x; + if is_centered { + new_tl.x -= delta.x; + } + } + ResizeHandle::BottomRight => { + new_br += delta; + if is_centered { + new_tl -= delta; + } + } + ResizeHandle::BottomCenter => { + new_br.y += delta.y; + if is_centered { + new_tl.y -= delta.y; + } } ResizeHandle::BottomLeft => { new_tl.x += delta.x; new_br.y += delta.y; + if is_centered { + new_tl.y -= delta.y; + new_br.x -= delta.x; + } } - ResizeHandle::BottomCenter => { - new_br.y += delta.y; + ResizeHandle::MiddleLeft => { + new_tl.x += delta.x; + if is_centered { + new_br.x -= delta.x; + } } - ResizeHandle::BottomRight => { - new_br += delta; + ResizeHandle::TopCenter => { + new_tl.y += delta.y; + if is_centered { + new_br.y -= delta.y; + } + } + ResizeHandle::TopLeft => { + new_tl += delta; + if is_centered { + new_br -= delta; + } } } @@ -448,8 +504,7 @@ impl Tool for PointerTool { orig_bounds, .. } => { - let delta = event.pos; - let (new_tl, new_br) = handle.apply_delta(orig_bounds.0, orig_bounds.1, delta); + let (new_tl, new_br) = handle.resize(event, orig_bounds.0, orig_bounds.1); let mut preview = original.clone_box(); preview.resize_bounds(new_tl, new_br); if preview.is_crop() @@ -509,7 +564,7 @@ impl Tool for PointerTool { ToolUpdateResult::Redraw } else { let (new_tl, new_br) = - handle.apply_delta(orig_bounds.0, orig_bounds.1, delta); + handle.resize(event, orig_bounds.0, orig_bounds.1); let mut final_drawable = original; final_drawable.resize_bounds(new_tl, new_br); self.update_selection_bounds(new_tl, new_br); From 8bf48288548649f023da4417c89105c481b71ba2 Mon Sep 17 00:00:00 2001 From: robertwidfen <126524724+robertwidfen@users.noreply.github.com> Date: Sat, 2 May 2026 16:28:58 +0200 Subject: [PATCH 05/13] fix: on reset also reset the marker count Relying on undo is not sufficient when markers have been deleted. --- src/sketch_board.rs | 12 +++++++++--- src/tools/marker.rs | 4 ++++ src/tools/mod.rs | 4 ++++ 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/sketch_board.rs b/src/sketch_board.rs index 54198698..5e8eb900 100644 --- a/src/sketch_board.rs +++ b/src/sketch_board.rs @@ -757,9 +757,15 @@ impl SketchBoard { fn handle_clear_all(&mut self) -> ToolUpdateResult { // can't use lazy || here - if self.deactivate_active_tool() | self.renderer.clear_all() { - self.renderer.set_hidden_drawable_index(None); - self.pointer_tool.borrow_mut().deselect(); + let did_reset = self.deactivate_active_tool() | self.renderer.clear_all(); + + self.renderer.set_hidden_drawable_index(None); + self.pointer_tool.borrow_mut().deselect(); + + // Reset marker numbering after drawable undo hooks ran. + self.tools.get(&Tools::Marker).borrow_mut().handle_reset(); + + if did_reset { ToolUpdateResult::Redraw } else { ToolUpdateResult::Unmodified diff --git a/src/tools/marker.rs b/src/tools/marker.rs index 7b73f73f..64403f7e 100644 --- a/src/tools/marker.rs +++ b/src/tools/marker.rs @@ -214,6 +214,10 @@ impl Tool for MarkerTool { ToolUpdateResult::Unmodified } + fn handle_reset(&mut self) { + *self.next_number.borrow_mut() = 1; + } + fn handle_mouse_event(&mut self, event: MouseEventMsg) -> ToolUpdateResult { if event.button != MouseButton::Primary { return ToolUpdateResult::Unmodified; diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 3f73227b..53166f66 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -124,6 +124,10 @@ pub trait Tool { ToolUpdateResult::Unmodified } + fn handle_reset(&mut self) { + // override if your tool needs to reset a internal state, e.g. the next marker number for the marker tool + } + fn set_im_context(&mut self, _context: Option) {} fn get_drawable(&self) -> Option<&dyn Drawable>; From 8b721477aff323c39b2162d7a6d2e27d8ee91621 Mon Sep 17 00:00:00 2001 From: robertwidfen <126524724+robertwidfen@users.noreply.github.com> Date: Sat, 2 May 2026 02:18:20 +0200 Subject: [PATCH 06/13] feat: re-style selected annotation Updates toolbar styles based on slected annotation and update annotation with toolbar style changes. Other changes: - Always draw stroke, otherwise with fill-only the shape of rectangle and ellipse will shrink. - Update selection bounds after redraw as with text we do not know the new size until redraw. --- src/femtovg_area/imp.rs | 17 +++++ src/femtovg_area/mod.rs | 5 ++ src/main.rs | 13 +++- src/sketch_board.rs | 147 +++++++++++++++++++++++++++++----------- src/tools/arrow.rs | 8 +++ src/tools/blur.rs | 8 +++ src/tools/brush.rs | 8 +++ src/tools/ellipse.rs | 11 ++- src/tools/highlight.rs | 14 ++++ src/tools/line.rs | 8 +++ src/tools/marker.rs | 12 ++++ src/tools/mod.rs | 11 +++ src/tools/pointer.rs | 4 ++ src/tools/rectangle.rs | 11 ++- src/tools/text.rs | 12 ++++ src/ui/toolbars.rs | 10 ++- 16 files changed, 249 insertions(+), 50 deletions(-) diff --git a/src/femtovg_area/imp.rs b/src/femtovg_area/imp.rs index 5b021766..e973583b 100644 --- a/src/femtovg_area/imp.rs +++ b/src/femtovg_area/imp.rs @@ -38,6 +38,7 @@ pub struct FemtoVGArea { font: RefCell>, inner: RefCell>, request_render: RefCell>>, + post_render_refresh_selection: RefCell>, sender: RefCell>>, } @@ -157,6 +158,14 @@ impl GLAreaImpl for FemtoVGArea { { eprintln!("Error rendering to framebuffer: {e}"); } + + if let Some(index) = self.post_render_refresh_selection.borrow_mut().take() { + self.sender + .borrow() + .as_ref() + .expect("Did you call init before using FemtoVgArea?") + .emit(SketchBoardInput::RefreshSelectionBounds(index)); + } glib::Propagation::Stop } } @@ -320,6 +329,14 @@ impl FemtoVGArea { self.request_render.borrow_mut().replace(actions.into()); self.obj().queue_render(); } + + pub fn schedule_refresh_selection_after_render(&self, index: usize) { + self.post_render_refresh_selection + .borrow_mut() + .replace(index); + self.obj().queue_render(); + } + pub fn set_parent_sender(&self, sender: Sender) { self.sender.borrow_mut().replace(sender); } diff --git a/src/femtovg_area/mod.rs b/src/femtovg_area/mod.rs index f506ec26..4a733801 100644 --- a/src/femtovg_area/mod.rs +++ b/src/femtovg_area/mod.rs @@ -72,6 +72,11 @@ impl FemtoVGArea { pub fn request_render(&self, actions: &[Action]) { self.imp().request_render(actions); } + + pub fn schedule_refresh_selection_after_render(&self, index: usize) { + self.imp().schedule_refresh_selection_after_render(index); + } + pub fn clear_all(&mut self) -> bool { self.imp() .inner() diff --git a/src/main.rs b/src/main.rs index 04ce98e0..b6653337 100644 --- a/src/main.rs +++ b/src/main.rs @@ -81,11 +81,12 @@ enum AppInput { ToolSwitchShortcut(Tools), ColorSwitchShortcut(u64), SetColor(Color), - SetFill(bool), - SetRoundCaps(bool), SetSize(Size), + SetAnnotationSizeFactor(f32), FocusAnnotationSizeFactorShortcut, ScaleFactorChanged, + SetFill(bool), + SetRoundCaps(bool), FullscreenChanged(bool), DimensionsUpdate(Option<(i32, i32)>), ToolEditingChanged(bool), @@ -313,6 +314,11 @@ impl Component for App { .sender() .emit(StyleToolbarInput::SetSize(size)); } + AppInput::SetAnnotationSizeFactor(factor) => { + self.style_toolbar + .sender() + .emit(StyleToolbarInput::SetAnnotationSizeFactor(factor)); + } AppInput::FocusAnnotationSizeFactorShortcut => { self.style_toolbar .sender() @@ -395,6 +401,9 @@ impl Component for App { SketchBoardOutput::FocusAnnotationSizeFactorShortcut => { AppInput::FocusAnnotationSizeFactorShortcut } + SketchBoardOutput::SetAnnotationSizeFactor(factor) => { + AppInput::SetAnnotationSizeFactor(factor) + } SketchBoardOutput::DimensionsUpdate(dimensions) => { AppInput::DimensionsUpdate(dimensions) } diff --git a/src/sketch_board.rs b/src/sketch_board.rs index 5e8eb900..93d6fdee 100644 --- a/src/sketch_board.rs +++ b/src/sketch_board.rs @@ -39,6 +39,7 @@ pub enum SketchBoardInput { PinchScale(f32), PinchEnd, NudgeSelection(Vec2D), + RefreshSelectionBounds(usize), ToolbarEvent(ToolbarEvent), RenderResult(RenderedImage, Vec), RenderResultFollowup(Option, Vec, Option), @@ -56,6 +57,7 @@ pub enum SketchBoardOutput { ColorSwitchShortcut(u64), SetColor(Color), SetSize(Size), + SetAnnotationSizeFactor(f32), FocusAnnotationSizeFactorShortcut, SetFill(bool), SetRoundCaps(bool), @@ -292,6 +294,65 @@ pub struct SketchBoard { } impl SketchBoard { + fn sync_toolbar_style_from_drawable( + &mut self, + drawable: &dyn crate::tools::Drawable, + sender: &ComponentSender, + ) { + let Some(style) = drawable.get_style() else { + return; + }; + + let old_style = self.style; + + if old_style.color != style.color { + sender + .output_sender() + .emit(SketchBoardOutput::SetColor(style.color)); + } + if old_style.size != style.size { + sender + .output_sender() + .emit(SketchBoardOutput::SetSize(style.size)); + } + if old_style.annotation_size_factor != style.annotation_size_factor { + sender + .output_sender() + .emit(SketchBoardOutput::SetAnnotationSizeFactor( + style.annotation_size_factor, + )); + } + if old_style.fill != style.fill { + sender + .output_sender() + .emit(SketchBoardOutput::SetFill(style.fill)); + } + if old_style.round_caps != style.round_caps { + sender + .output_sender() + .emit(SketchBoardOutput::SetRoundCaps(style.round_caps)); + } + self.style = *style; + } + + fn set_drawable_style_from_toolbar_style(&mut self) -> ToolUpdateResult { + if self.active_tool_type() == Tools::Pointer { + let selected_index = self.pointer_tool.borrow().selected_index(); + if let Some(index) = selected_index + && let Some(mut drawable) = self.renderer.get_drawable_clone(index) + && let Some(style) = drawable.get_style_mut() + { + *style = self.style; + self.renderer.replace_drawable(index, drawable); + self.update_pointer_tool_selection(index, true); + } + } + self.active_tool + .borrow_mut() + .handle_event(ToolEvent::StyleChanged(self.style)); + ToolUpdateResult::Redraw + } + fn refresh_screen(&mut self) { self.renderer.queue_render(); } @@ -824,7 +885,7 @@ impl SketchBoard { .handle_event(ToolEvent::Input(InputEvent::Mouse(*me))) } else { let result = if me.type_ == MouseEventType::BeginDrag { - self.handle_pointer_tool_begin_drag(me) + self.handle_pointer_tool_begin_drag(me, sender) .unwrap_or(ToolUpdateResult::Unmodified) } else if !matches!( me.type_, @@ -878,6 +939,7 @@ impl SketchBoard { fn handle_pointer_tool_begin_drag( &mut self, me: &crate::sketch_board::MouseEventMsg, + sender: &ComponentSender, ) -> Option { // Check resize handle first (only when something is already selected) let handle_hit = self.pointer_tool.borrow().hit_test_handles(me.pos); @@ -889,6 +951,7 @@ impl SketchBoard { self.renderer.get_drawable_bounds(idx), ) { + self.sync_toolbar_style_from_drawable(drawable.as_ref(), sender); self.renderer.set_hidden_drawable_index(Some(idx)); self.pointer_tool .borrow_mut() @@ -906,7 +969,6 @@ impl SketchBoard { && drawable.hit_test(me.pos, 5.0) && let Some((tl, br)) = self.renderer.get_drawable_bounds(selected_idx) { - // self.sync_toolbar_style_from_drawable(drawable.as_ref(), sender); self.renderer.set_hidden_drawable_index(Some(selected_idx)); self.pointer_tool .borrow_mut() @@ -944,6 +1006,7 @@ impl SketchBoard { self.renderer.get_drawable_clone(sel_idx), self.renderer.get_drawable_bounds(sel_idx), ) { + self.sync_toolbar_style_from_drawable(drawable.as_ref(), sender); self.renderer.set_hidden_drawable_index(Some(sel_idx)); self.pointer_tool .borrow_mut() @@ -1051,18 +1114,11 @@ impl SketchBoard { } ToolbarEvent::ColorSelected(color) => { self.style.color = color; - self.active_tool - .borrow_mut() - .handle_event(ToolEvent::StyleChanged(self.style)) + self.set_drawable_style_from_toolbar_style() } ToolbarEvent::SizeSelected(size) => { self.style.size = size; - sender - .output_sender() - .emit(SketchBoardOutput::SetSize(self.style.size)); - self.active_tool - .borrow_mut() - .handle_event(ToolEvent::StyleChanged(self.style)) + self.set_drawable_style_from_toolbar_style() } ToolbarEvent::SaveFile => self.handle_action(&[Action::SaveToFile]), ToolbarEvent::CopyClipboard => self.handle_action(&[Action::SaveToClipboard]), @@ -1071,34 +1127,19 @@ impl SketchBoard { ToolbarEvent::ClearAll => self.handle_clear_all(), ToolbarEvent::ToggleFill => { self.style.fill = !self.style.fill; - sender - .output_sender() - .emit(SketchBoardOutput::SetFill(self.style.fill)); - self.active_tool - .borrow_mut() - .handle_event(ToolEvent::StyleChanged(self.style)) + self.set_drawable_style_from_toolbar_style() } ToolbarEvent::ToggleRoundCaps => { self.style.round_caps = !self.style.round_caps; - sender - .output_sender() - .emit(SketchBoardOutput::SetRoundCaps(self.style.round_caps)); - self.active_tool - .borrow_mut() - .handle_event(ToolEvent::StyleChanged(self.style)) + self.set_drawable_style_from_toolbar_style() } ToolbarEvent::AnnotationSizeFactorChanged(value) => { self.style.annotation_size_factor = value; - self.active_tool - .borrow_mut() - .handle_event(ToolEvent::StyleChanged(self.style)) + self.set_drawable_style_from_toolbar_style() } ToolbarEvent::SetFill(fill_enabled) => { self.style.fill = fill_enabled; - self.active_tool - .borrow_mut() - .handle_event(ToolEvent::StyleChanged(self.style)); - ToolUpdateResult::Redraw + self.set_drawable_style_from_toolbar_style() } ToolbarEvent::SaveFileAs => self.handle_action(&[Action::SaveToFileAs]), ToolbarEvent::ScaleFitToWindow => self.handle_scale(0), @@ -1283,6 +1324,36 @@ impl SketchBoard { } } } + + fn update_pointer_tool_selection(&mut self, index: usize, refresh: bool) -> ToolUpdateResult { + let Some(drawable) = self.renderer.get_drawable_clone(index) else { + return ToolUpdateResult::Unmodified; + }; + + // Text/Marker bounds can depend on the latest draw pass. Schedule a post-render refresh + // so the pointer selection can snap to the final geometry. + if refresh && drawable.bounds_only_valid_after_redraw() { + self.renderer.schedule_refresh_selection_after_render(index); + return ToolUpdateResult::Unmodified; + } + + let Some(bounds) = self.renderer.get_drawable_bounds(index) else { + return ToolUpdateResult::Unmodified; + }; + + let needs_update = { + let pointer_tool = self.pointer_tool.borrow(); + pointer_tool.selected_index() != Some(index) + || pointer_tool.selected_bounds() != Some(bounds) + }; + + if !needs_update { + return ToolUpdateResult::Unmodified; + } + + self.pointer_tool.borrow_mut().set_selection(index, bounds); + ToolUpdateResult::Redraw + } } #[relm4::component(pub)] @@ -1531,11 +1602,7 @@ impl Component for SketchBoard { if let Some(mut drawable) = self.renderer.get_drawable_clone(index) { drawable.translate(delta); self.renderer.replace_drawable(index, drawable); - if let Some(new_bounds) = self.renderer.get_drawable_bounds(index) { - self.pointer_tool - .borrow_mut() - .set_selection(index, new_bounds); - } + self.update_pointer_tool_selection(index, false); ToolUpdateResult::Redraw } else { ToolUpdateResult::Unmodified @@ -1544,6 +1611,9 @@ impl Component for SketchBoard { ToolUpdateResult::Unmodified } } + SketchBoardInput::RefreshSelectionBounds(index) => { + self.update_pointer_tool_selection(index, false) + } SketchBoardInput::ToolbarEvent(toolbar_event) => { self.handle_toolbar_event(toolbar_event, sender) } @@ -1634,12 +1704,7 @@ impl Component for SketchBoard { } ToolUpdateResult::ReplaceDrawable(index, drawable) => { self.renderer.replace_drawable(index, drawable); - // Update the selection overlay bounds to reflect the new position/size. - if let Some(new_bounds) = self.renderer.get_drawable_bounds(index) { - self.pointer_tool - .borrow_mut() - .set_selection(index, new_bounds); - } + self.update_pointer_tool_selection(index, true); self.refresh_screen(); } ToolUpdateResult::Unmodified | ToolUpdateResult::StopPropagation => (), diff --git a/src/tools/arrow.rs b/src/tools/arrow.rs index a65792f7..2527902b 100644 --- a/src/tools/arrow.rs +++ b/src/tools/arrow.rs @@ -177,6 +177,14 @@ impl Drawable for Arrow { } } + fn get_style(&self) -> Option<&Style> { + Some(&self.style) + } + + fn get_style_mut(&mut self) -> Option<&mut Style> { + Some(&mut self.style) + } + fn draw( &self, canvas: &mut femtovg::Canvas, diff --git a/src/tools/blur.rs b/src/tools/blur.rs index 06471543..6a2e6292 100644 --- a/src/tools/blur.rs +++ b/src/tools/blur.rs @@ -106,6 +106,14 @@ impl Drawable for Blur { *self.cached_image.borrow_mut() = None; } + fn get_style(&self) -> Option<&Style> { + Some(&self.style) + } + + fn get_style_mut(&mut self) -> Option<&mut Style> { + Some(&mut self.style) + } + fn draw( &self, canvas: &mut femtovg::Canvas, diff --git a/src/tools/brush.rs b/src/tools/brush.rs index 8b5cd3b7..a0a51580 100644 --- a/src/tools/brush.rs +++ b/src/tools/brush.rs @@ -105,6 +105,14 @@ impl Drawable for BrushDrawable { } } + fn get_style(&self) -> Option<&Style> { + Some(&self.style) + } + + fn get_style_mut(&mut self) -> Option<&mut Style> { + Some(&mut self.style) + } + fn draw( &self, canvas: &mut femtovg::Canvas, diff --git a/src/tools/ellipse.rs b/src/tools/ellipse.rs index b3351e65..fb9d7581 100644 --- a/src/tools/ellipse.rs +++ b/src/tools/ellipse.rs @@ -65,6 +65,14 @@ impl Drawable for Ellipse { self.finishing = true; } + fn get_style(&self) -> Option<&Style> { + Some(&self.style) + } + + fn get_style_mut(&mut self) -> Option<&mut Style> { + Some(&mut self.style) + } + fn draw( &self, canvas: &mut femtovg::Canvas, @@ -86,9 +94,8 @@ impl Drawable for Ellipse { if self.style.fill { canvas.fill_path(&path, &self.style.into()); - } else { - canvas.stroke_path(&path, &self.style.into()); } + canvas.stroke_path(&path, &self.style.into()); canvas.restore(); Ok(()) diff --git a/src/tools/highlight.rs b/src/tools/highlight.rs index 4ad2cb82..a08f3c3e 100644 --- a/src/tools/highlight.rs +++ b/src/tools/highlight.rs @@ -286,6 +286,20 @@ impl Drawable for HighlightKind { } } + fn get_style(&self) -> Option<&Style> { + match self { + HighlightKind::Block(highlighter) => Some(&highlighter.style), + HighlightKind::Freehand(highlighter) => Some(&highlighter.style), + } + } + + fn get_style_mut(&mut self) -> Option<&mut Style> { + match self { + HighlightKind::Block(highlighter) => Some(&mut highlighter.style), + HighlightKind::Freehand(highlighter) => Some(&mut highlighter.style), + } + } + fn draw( &self, canvas: &mut femtovg::Canvas, diff --git a/src/tools/line.rs b/src/tools/line.rs index bc89941c..8dce3e55 100644 --- a/src/tools/line.rs +++ b/src/tools/line.rs @@ -69,6 +69,14 @@ impl Drawable for Line { } } + fn get_style(&self) -> Option<&Style> { + Some(&self.style) + } + + fn get_style_mut(&mut self) -> Option<&mut Style> { + Some(&mut self.style) + } + fn draw( &self, canvas: &mut femtovg::Canvas, diff --git a/src/tools/marker.rs b/src/tools/marker.rs index 64403f7e..1ff1c217 100644 --- a/src/tools/marker.rs +++ b/src/tools/marker.rs @@ -41,6 +41,10 @@ impl Marker { } impl Drawable for Marker { + fn bounds_only_valid_after_redraw(&self) -> bool { + true + } + fn bounds(&self) -> Option<(Vec2D, Vec2D)> { let r = self.radius.get() + self.get_line_width() * if self.extra_ring { 2.0 } else { 0.0 }; let r = Vec2D::new(r, r); @@ -57,6 +61,14 @@ impl Drawable for Marker { self.pos += delta; } + fn get_style(&self) -> Option<&Style> { + Some(&self.style) + } + + fn get_style_mut(&mut self) -> Option<&mut Style> { + Some(&mut self.style) + } + fn resize_bounds(&mut self, tl: Vec2D, br: Vec2D) { let Some((old_tl, old_br)) = self.bounds() else { return; diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 53166f66..6558536e 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -173,6 +173,9 @@ pub trait Drawable: DrawableClone + Debug { fn is_crop(&self) -> bool { false } + fn bounds_only_valid_after_redraw(&self) -> bool { + false + } fn bounds(&self) -> Option<(Vec2D, Vec2D)> { None } @@ -191,6 +194,14 @@ pub trait Drawable: DrawableClone + Debug { fn edit_info(&self) -> Option<(Vec2D, String, crate::style::Style)> { None } + + fn get_style(&self) -> Option<&Style> { + None + } + + fn get_style_mut(&mut self) -> Option<&mut Style> { + None + } } pub fn hit_test_rectangle( diff --git a/src/tools/pointer.rs b/src/tools/pointer.rs index ddcbff70..98e339f8 100644 --- a/src/tools/pointer.rs +++ b/src/tools/pointer.rs @@ -287,6 +287,10 @@ impl PointerTool { self.selected_index } + pub fn selected_bounds(&self) -> Option<(Vec2D, Vec2D)> { + self.selected_bounds + } + // Returns the handle under `pos` given the current selection bounds. pub fn hit_test_handles(&self, pos: Vec2D) -> Option { let overlay = self.selection_overlay.as_ref()?; diff --git a/src/tools/rectangle.rs b/src/tools/rectangle.rs index b8bca06e..fba56d1a 100644 --- a/src/tools/rectangle.rs +++ b/src/tools/rectangle.rs @@ -52,6 +52,14 @@ impl Drawable for Rectangle { self.finishing = true; } + fn get_style(&self) -> Option<&Style> { + Some(&self.style) + } + + fn get_style_mut(&mut self) -> Option<&mut Style> { + Some(&mut self.style) + } + fn draw( &self, canvas: &mut femtovg::Canvas, @@ -79,9 +87,8 @@ impl Drawable for Rectangle { if self.style.fill { canvas.fill_path(&path, &self.style.into()); - } else { - canvas.stroke_path(&path, &self.style.into()); } + canvas.stroke_path(&path, &self.style.into()); canvas.restore(); Ok(()) diff --git a/src/tools/text.rs b/src/tools/text.rs index fa66e370..d4a8ae27 100644 --- a/src/tools/text.rs +++ b/src/tools/text.rs @@ -225,6 +225,10 @@ impl Text { } impl Drawable for Text { + fn bounds_only_valid_after_redraw(&self) -> bool { + true + } + fn bounds(&self) -> Option<(Vec2D, Vec2D)> { let rect = self.rect.borrow(); if rect.width() == 0 && rect.height() == 0 { @@ -268,6 +272,14 @@ impl Drawable for Text { Some((self.pos, content.to_string(), self.style)) } + fn get_style(&self) -> Option<&Style> { + Some(&self.style) + } + + fn get_style_mut(&mut self) -> Option<&mut Style> { + Some(&mut self.style) + } + fn draw( &self, canvas: &mut femtovg::Canvas, diff --git a/src/ui/toolbars.rs b/src/ui/toolbars.rs index 339cfa80..0c3e17fa 100644 --- a/src/ui/toolbars.rs +++ b/src/ui/toolbars.rs @@ -49,13 +49,13 @@ pub enum ToolbarEvent { ColorSelected(Color), SetFill(bool), SizeSelected(Size), + AnnotationSizeFactorChanged(f32), Redo, Undo, SaveFile, CopyClipboard, ToggleFill, ToggleRoundCaps, - AnnotationSizeFactorChanged(f32), ClearAll, SaveFileAs, ScaleFitToWindow, @@ -79,6 +79,7 @@ pub enum StyleToolbarInput { SetFill(bool), SetRoundCaps(bool), SetSize(Size), + SetAnnotationSizeFactor(f32), ShowColorDialog, ColorDialogFinished(Option), SetVisibility(bool), @@ -523,8 +524,8 @@ impl Component for StyleToolbar { set_alignment: 1.0, connect_value_changed[sender] => move |spin_button| { - let new_value = spin_button.value(); - sender.output_sender().emit(ToolbarEvent::AnnotationSizeFactorChanged(new_value as f32)); + let new_value = spin_button.value() as f32; + sender.output_sender().emit(ToolbarEvent::AnnotationSizeFactorChanged(new_value)); }, add_controller = gtk::EventControllerKey { @@ -674,6 +675,9 @@ impl Component for StyleToolbar { StyleToolbarInput::SetSize(size) => { self.size_action.change_state(&size.to_variant()); } + StyleToolbarInput::SetAnnotationSizeFactor(value) => { + self.size_spin_button.set_value(value as f64); + } StyleToolbarInput::SetVisibility(visible) => self.visible = visible, StyleToolbarInput::ToggleVisibility => { self.visible = !self.visible; From 0a27d62164fd7d8a175b1ef69ae8c5433a606586 Mon Sep 17 00:00:00 2001 From: robertwidfen <126524724+robertwidfen@users.noreply.github.com> Date: Fri, 14 Aug 2026 00:02:57 +0200 Subject: [PATCH 07/13] fix: update toolbar on toggle-fill/round-cap/cycle size Got lost in the restyle commit. --- src/sketch_board.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/sketch_board.rs b/src/sketch_board.rs index 93d6fdee..09e5448f 100644 --- a/src/sketch_board.rs +++ b/src/sketch_board.rs @@ -1118,6 +1118,7 @@ impl SketchBoard { } ToolbarEvent::SizeSelected(size) => { self.style.size = size; + sender.output_sender().emit(SketchBoardOutput::SetSize(size)); self.set_drawable_style_from_toolbar_style() } ToolbarEvent::SaveFile => self.handle_action(&[Action::SaveToFile]), @@ -1127,10 +1128,16 @@ impl SketchBoard { ToolbarEvent::ClearAll => self.handle_clear_all(), ToolbarEvent::ToggleFill => { self.style.fill = !self.style.fill; + sender + .output_sender() + .emit(SketchBoardOutput::SetFill(self.style.fill)); self.set_drawable_style_from_toolbar_style() } ToolbarEvent::ToggleRoundCaps => { self.style.round_caps = !self.style.round_caps; + sender + .output_sender() + .emit(SketchBoardOutput::SetRoundCaps(self.style.round_caps)); self.set_drawable_style_from_toolbar_style() } ToolbarEvent::AnnotationSizeFactorChanged(value) => { From 66e86f9e3dd2a72fcfc6cbd09c529e915144e316 Mon Sep 17 00:00:00 2001 From: robertwidfen <126524724+robertwidfen@users.noreply.github.com> Date: Wed, 12 Aug 2026 09:46:28 +0200 Subject: [PATCH 08/13] feat: change mouse cursor to pointer tool operation --- src/femtovg_area/imp.rs | 4 +- src/sketch_board.rs | 39 +++++++++++--- src/tools/mod.rs | 2 + src/tools/pointer.rs | 110 +++++++++++++++++++++++++++++++++++++--- 4 files changed, 139 insertions(+), 16 deletions(-) diff --git a/src/femtovg_area/imp.rs b/src/femtovg_area/imp.rs index e973583b..73afb2e1 100644 --- a/src/femtovg_area/imp.rs +++ b/src/femtovg_area/imp.rs @@ -369,10 +369,8 @@ impl FemtoVgAreaMut { // Hit-test all drawables and return all indices whose bounds contain `pos`, in order from topmost to bottommost. pub fn hit_test(&self, pos: Vec2D) -> Vec { let mut results = Vec::new(); - // A small tolerance is applied to make thin shapes (lines, arrows) easier to click. - const HIT_TOLERANCE: f32 = 5.0; for (i, d) in self.drawables.iter().enumerate().rev() { - if d.hit_test(pos, HIT_TOLERANCE) { + if d.hit_test(pos, crate::tools::HIT_BORDER_TOLERANCE) { results.push(i); } } diff --git a/src/sketch_board.rs b/src/sketch_board.rs index 09e5448f..6cb88397 100644 --- a/src/sketch_board.rs +++ b/src/sketch_board.rs @@ -40,6 +40,7 @@ pub enum SketchBoardInput { PinchEnd, NudgeSelection(Vec2D), RefreshSelectionBounds(usize), + RefreshMouseCursor(Vec2D), ToolbarEvent(ToolbarEvent), RenderResult(RenderedImage, Vec), RenderResultFollowup(Option, Vec, Option), @@ -205,6 +206,10 @@ impl InputEvent { me.pos = renderer.abs_canvas_to_image_coordinates(me.pos); None } + MouseEventType::PointerPos => { + me.pos = renderer.abs_canvas_to_image_coordinates(me.pos); + None + } MouseEventType::EndDrag | MouseEventType::UpdateDrag => { me.pos = renderer.rel_canvas_to_image_coordinates(me.pos); None @@ -265,7 +270,7 @@ impl InputEvent { None } MouseEventType::PointerPos => { - renderer.set_pointer_offset(me.pos); + renderer.set_pointer_offset(me.screen_pos); None } _ => None, @@ -955,7 +960,7 @@ impl SketchBoard { self.renderer.set_hidden_drawable_index(Some(idx)); self.pointer_tool .borrow_mut() - .begin_resize(idx, drawable, handle, bounds); + .begin_resize(idx, drawable, handle, bounds, me.pos); return Some(ToolUpdateResult::Redraw); } } @@ -966,13 +971,13 @@ impl SketchBoard { if !is_alt_click && let Some(selected_idx) = selected_idx && let Some(drawable) = self.renderer.get_drawable_clone(selected_idx) - && drawable.hit_test(me.pos, 5.0) + && drawable.hit_test(me.pos, crate::tools::HIT_BORDER_TOLERANCE) && let Some((tl, br)) = self.renderer.get_drawable_bounds(selected_idx) { self.renderer.set_hidden_drawable_index(Some(selected_idx)); self.pointer_tool .borrow_mut() - .begin_move(selected_idx, drawable, (tl, br)); + .begin_move(selected_idx, drawable, (tl, br), me.pos); return Some(ToolUpdateResult::Redraw); } @@ -1010,7 +1015,7 @@ impl SketchBoard { self.renderer.set_hidden_drawable_index(Some(sel_idx)); self.pointer_tool .borrow_mut() - .begin_move(sel_idx, drawable, bounds); + .begin_move(sel_idx, drawable, bounds, me.pos); return Some(ToolUpdateResult::Redraw); } } @@ -1118,7 +1123,9 @@ impl SketchBoard { } ToolbarEvent::SizeSelected(size) => { self.style.size = size; - sender.output_sender().emit(SketchBoardOutput::SetSize(size)); + sender + .output_sender() + .emit(SketchBoardOutput::SetSize(size)); self.set_drawable_style_from_toolbar_style() } ToolbarEvent::SaveFile => self.handle_action(&[Action::SaveToFile]), @@ -1361,6 +1368,15 @@ impl SketchBoard { self.pointer_tool.borrow_mut().set_selection(index, bounds); ToolUpdateResult::Redraw } + + fn update_mouse_cursor(&self, pos: Vec2D) { + if !self.renderer.hit_test(pos).is_empty() { + let cursor = self.pointer_tool.borrow().get_cursor("grab"); + self.renderer.set_cursor(cursor.as_ref()); + } else { + self.renderer.set_cursor(None); + } + } } #[relm4::component(pub)] @@ -1540,6 +1556,13 @@ impl Component for SketchBoard { ie.handle_event_mouse_input(&self.renderer); // handle right click and other things ie.handle_mouse_event(&self.renderer); + // change cursor if we are hovering over a drawable + if let InputEvent::Mouse(me) = ie + && let MouseEventType::PointerPos = me.type_ + && me.modifier == ModifierType::empty() + { + self.update_mouse_cursor(me.pos); + } } if let InputEvent::Key(ke) = ie { @@ -1621,6 +1644,10 @@ impl Component for SketchBoard { SketchBoardInput::RefreshSelectionBounds(index) => { self.update_pointer_tool_selection(index, false) } + SketchBoardInput::RefreshMouseCursor(pos) => { + self.update_mouse_cursor(pos); + ToolUpdateResult::Unmodified + } SketchBoardInput::ToolbarEvent(toolbar_event) => { self.handle_toolbar_event(toolbar_event, sender) } diff --git a/src/tools/mod.rs b/src/tools/mod.rs index 6558536e..c6f1bac3 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -43,6 +43,8 @@ mod pointer; mod rectangle; mod text; +pub const HIT_BORDER_TOLERANCE: f32 = 7.0; + pub enum ToolEvent { Activated, Deactivated, diff --git a/src/tools/pointer.rs b/src/tools/pointer.rs index 98e339f8..50ff93a2 100644 --- a/src/tools/pointer.rs +++ b/src/tools/pointer.rs @@ -1,6 +1,9 @@ use anyhow::Result; use femtovg::{Color, FontId, Paint, Path}; -use relm4::{Sender, gtk::gdk::ModifierType}; +use relm4::{ + Sender, + gtk::{self, gdk::ModifierType, prelude::WidgetExt}, +}; use std::cell::Cell; use crate::{ @@ -12,7 +15,7 @@ use crate::{ }, }; -use super::{Drawable, Tool, ToolUpdateResult, Tools}; +use super::{Drawable, InputContext, Tool, ToolUpdateResult, Tools}; // Desired on-screen size (in device pixels) for each resize handle. const HANDLE_SIZE: f32 = 11.0; @@ -233,6 +236,7 @@ impl Drawable for SelectionOverlay { } } +#[derive(Debug)] enum DragState { None, Moving { @@ -251,9 +255,11 @@ enum DragState { pub struct PointerTool { input_enabled: bool, sender: Option>, + cursor_widget: Option, selected_index: Option, selected_bounds: Option<(Vec2D, Vec2D)>, drag_state: DragState, + last_drag_state: bool, // Shown as the active-tool drawable: either a moved/resized preview, or a selection overlay. preview: Option>, selection_overlay: Option, @@ -263,6 +269,8 @@ pub struct PointerTool { hit_objects_at_pos: Vec, // For cycling through overlapping objects: current index in hit_objects list current_cycle_index: usize, + // Absolute pointer position (image coordinates) at drag start. + drag_start_pos: Option, } impl Default for PointerTool { @@ -270,19 +278,76 @@ impl Default for PointerTool { Self { input_enabled: false, sender: None, + cursor_widget: None, selected_index: None, selected_bounds: None, drag_state: DragState::None, + last_drag_state: false, preview: None, selection_overlay: None, last_click_pos: None, hit_objects_at_pos: Vec::new(), current_cycle_index: 0, + drag_start_pos: None, } } } impl PointerTool { + pub fn get_cursor(&self, name: &str) -> Option { + let cursor_candidates = match name { + "grabbing" => Some(&["grabbing", "all-resize"]), + "grab" => Some(&["grab", "all-scroll"]), + "nwse-resize" => Some(&["nwse-resize", "top-left-corner"]), + "nesw-resize" => Some(&["nesw-resize", "top-right-corner"]), + "ns-resize" => Some(&["ns-resize", "top-center"]), + "ew-resize" => Some(&["ew-resize", "middle-left"]), + _ => None, + }; + cursor_candidates.and_then(|candidates| { + candidates + .iter() + .find_map(|candidate| gtk::gdk::Cursor::from_name(candidate, None)) + }) + } + + fn set_hover_cursor(&mut self, pos: Vec2D) { + let Some(widget) = &self.cursor_widget else { + return; + }; + + let cursor = if let DragState::Moving { .. } = self.drag_state { + self.last_drag_state = true; + self.get_cursor("grabbing") + } else if matches!(self.drag_state, DragState::None) && self.last_drag_state { + if let Some(sender) = &self.sender { + sender.emit(SketchBoardInput::RefreshMouseCursor(pos)); + } + self.last_drag_state = false; + None + } else if let Some(handle) = self.hit_test_handles(pos) { + type RH = ResizeHandle; + match handle { + RH::TopLeft | RH::BottomRight => self.get_cursor("nwse-resize"), + RH::TopRight | RH::BottomLeft => self.get_cursor("nesw-resize"), + RH::TopCenter | RH::BottomCenter => self.get_cursor("ns-resize"), + RH::MiddleLeft | RH::MiddleRight => self.get_cursor("ew-resize"), + } + } else { + None + }; + + if cursor.is_some() { + widget.set_cursor(cursor.as_ref()); + } + } + + fn clear_hover_cursor(&self) { + if let Some(widget) = &self.cursor_widget { + widget.set_cursor(None); + } + } + pub fn selected_index(&self) -> Option { self.selected_index } @@ -304,6 +369,7 @@ impl PointerTool { index: usize, drawable: Box, orig_bounds: (Vec2D, Vec2D), + start_pos: Vec2D, ) { self.selected_index = Some(index); self.selected_bounds = Some(orig_bounds); @@ -314,6 +380,8 @@ impl PointerTool { original: drawable, orig_bounds, }; + self.drag_start_pos = Some(start_pos); + self.set_hover_cursor(orig_bounds.0); } // Called by SketchBoard before delivering a BeginDrag event: sets up a resize drag. @@ -323,6 +391,7 @@ impl PointerTool { drawable: Box, handle: ResizeHandle, orig_bounds: (Vec2D, Vec2D), + start_pos: Vec2D, ) { self.selected_index = Some(index); self.selected_bounds = Some(orig_bounds); @@ -334,6 +403,11 @@ impl PointerTool { handle, orig_bounds, }; + self.drag_start_pos = Some(start_pos); + } + + fn current_drag_pos(&self, delta: Vec2D) -> Vec2D { + self.drag_start_pos.map_or(delta, |start| start + delta) } fn update_selection_bounds(&mut self, tl: Vec2D, br: Vec2D) { @@ -386,6 +460,7 @@ impl PointerTool { self.last_click_pos = None; self.hit_objects_at_pos.clear(); self.current_cycle_index = 0; + self.drag_start_pos = None; } // Cycle through overlapping objects at the same position. @@ -446,6 +521,7 @@ impl Tool for PointerTool { } fn handle_deactivated(&mut self) -> ToolUpdateResult { + self.clear_hover_cursor(); self.deselect(); ToolUpdateResult::Redraw } @@ -488,6 +564,11 @@ impl Tool for PointerTool { // For EndDrag/UpdateDrag, event.pos is the cumulative delta since BeginDrag. match event.type_ { + MouseEventType::PointerPos | MouseEventType::Release => { + self.set_hover_cursor(event.pos); + ToolUpdateResult::Unmodified + } + MouseEventType::UpdateDrag => match &self.drag_state { DragState::Moving { original, @@ -533,6 +614,7 @@ impl Tool for PointerTool { }, MouseEventType::EndDrag => { + let current_pos = self.current_drag_pos(event.pos); match std::mem::replace(&mut self.drag_state, DragState::None) { DragState::Moving { index, @@ -540,7 +622,7 @@ impl Tool for PointerTool { orig_bounds, } => { let delta = event.pos; - if delta.is_zero() { + let result = if delta.is_zero() { // Click with no movement: just show selection overlay self.update_selection_bounds(orig_bounds.0, orig_bounds.1); self.preview = None; @@ -553,7 +635,10 @@ impl Tool for PointerTool { self.update_selection_bounds(new_bounds.0, new_bounds.1); self.preview = None; ToolUpdateResult::ReplaceDrawable(index, final_drawable) - } + }; + self.drag_start_pos = None; + self.set_hover_cursor(current_pos); + result } DragState::Resizing { index, @@ -562,7 +647,7 @@ impl Tool for PointerTool { orig_bounds, } => { let delta = event.pos; - if delta.is_zero() { + let result = if delta.is_zero() { self.update_selection_bounds(orig_bounds.0, orig_bounds.1); self.preview = None; ToolUpdateResult::Redraw @@ -574,9 +659,15 @@ impl Tool for PointerTool { self.update_selection_bounds(new_tl, new_br); self.preview = None; ToolUpdateResult::ReplaceDrawable(index, final_drawable) - } + }; + self.drag_start_pos = None; + self.set_hover_cursor(current_pos); + result + } + DragState::None => { + self.drag_start_pos = None; + ToolUpdateResult::Unmodified } - DragState::None => ToolUpdateResult::Unmodified, } } @@ -587,4 +678,9 @@ impl Tool for PointerTool { fn set_sender(&mut self, sender: Sender) { self.sender = Some(sender); } + + fn set_im_context(&mut self, context: Option) { + self.cursor_widget = context.map(|ctx| ctx.widget); + self.clear_hover_cursor(); + } } From 3470ee0c12135e8c1a8edbf4cb1d5aafe520556e Mon Sep 17 00:00:00 2001 From: robertwidfen <126524724+robertwidfen@users.noreply.github.com> Date: Thu, 13 Aug 2026 20:12:17 +0200 Subject: [PATCH 09/13] refactor: draw crop on top --- src/femtovg_area/imp.rs | 44 +++++++++++++++++------------------------ 1 file changed, 18 insertions(+), 26 deletions(-) diff --git a/src/femtovg_area/imp.rs b/src/femtovg_area/imp.rs index 73afb2e1..8dc5b0e0 100644 --- a/src/femtovg_area/imp.rs +++ b/src/femtovg_area/imp.rs @@ -344,13 +344,9 @@ impl FemtoVGArea { impl FemtoVgAreaMut { pub fn commit(&mut self, drawable: Box) { - // Keep at most one crop drawable and pin it to index 0. + // Keep at most one crop drawable if drawable.is_crop() { self.drawables.retain(|d| !d.is_crop()); - self.drawables.insert(0, drawable.clone_box()); - self.undo_stack.push(HistoryEntry::Drawable(drawable)); - self.redo_stack.clear(); - return; } self.undo_stack .push(HistoryEntry::Drawable(drawable.clone_box())); @@ -396,10 +392,6 @@ impl FemtoVgAreaMut { return None; } - if self.drawables[index].is_crop() { - return Some(0); - } - if index + 1 == self.drawables.len() { return Some(index); } @@ -423,15 +415,7 @@ impl FemtoVgAreaMut { pub fn undo(&mut self) -> bool { match self.undo_stack.pop() { Some(HistoryEntry::Drawable(history_drawable)) => { - let mut drawable = if history_drawable.is_crop() { - if let Some(index) = self.crop_drawable_index() { - self.drawables.remove(index) - } else { - history_drawable - } - } else { - self.drawables.pop().unwrap_or(history_drawable) - }; + let mut drawable = self.drawables.pop().unwrap_or(history_drawable); drawable.handle_undo(); self.redo_stack.push(HistoryEntry::Drawable(drawable)); true @@ -449,13 +433,9 @@ impl FemtoVgAreaMut { Some(HistoryEntry::Drawable(mut drawable)) => { drawable.handle_redo(); - // Keep at most one crop drawable and pin it to index 0. + // Keep at most one crop drawable if drawable.is_crop() { self.drawables.retain(|d| !d.is_crop()); - self.undo_stack - .push(HistoryEntry::Drawable(drawable.clone_box())); - self.drawables.insert(0, drawable); - return true; } self.undo_stack @@ -526,6 +506,7 @@ impl FemtoVgAreaMut { self.background_image.height() as f32, ), ); + // get offset and size of the crop if there is one let (pos, size) = self .drawables @@ -626,17 +607,28 @@ impl FemtoVgAreaMut { for (i, d) in self.drawables.iter().enumerate() { if self.hidden_drawable_index == Some(i) { // Draw the active tool preview in the original z position. - if draw_active_tool && let Some(preview) = self.active_tool.borrow().get_drawable() + if draw_active_tool + && let Some(preview) = self.active_tool.borrow().get_drawable() + && !preview.is_crop() { preview.draw(canvas, font, bounds)?; active_tool_drawn_in_stack = true; } continue; } - d.draw(canvas, font, bounds)?; + if !d.is_crop() { + d.draw(canvas, font, bounds)?; + } + } + + // draw crop bounds on top + for (i, d) in self.drawables.iter().enumerate() { + if self.hidden_drawable_index != Some(i) && d.is_crop() { + d.draw(canvas, font, bounds)?; + } } - // render active tool (default: on top) when not already drawn in stack order + // render active (pointer) tool (default: on top) when not already drawn in stack order if draw_active_tool && !active_tool_drawn_in_stack && let Some(d) = self.active_tool.borrow().get_drawable() From 693a2408cd77e9db5eaee863af8a7424c815bd1f Mon Sep 17 00:00:00 2001 From: robertwidfen <126524724+robertwidfen@users.noreply.github.com> Date: Sat, 15 Aug 2026 05:09:49 +0200 Subject: [PATCH 10/13] refactor: remove crop special mouse and key binding --- README.md | 5 ----- src/tools/crop.rs | 46 +++++----------------------------------------- 2 files changed, 5 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index 4347b06f..360c83f6 100644 --- a/README.md +++ b/README.md @@ -101,11 +101,6 @@ Default single-key shortcuts: ### Tool Modifiers and Keys -Crop: -- Press Esc or Ctrl+right mouse0.22.0 experimental button while editing to reset crop altogether 0.21.0. -- Press Enter or Ctrl+left mouse0.22.0 experimental while editing to finish editing crop and keep the crop area active 0.21.0. -- Left click crop area when tool is active but not editing to resume editing0.21.0. - Arrow and line: - Shift to make tool snap to 15° steps. diff --git a/src/tools/crop.rs b/src/tools/crop.rs index d8bd43f0..c80bf4ef 100644 --- a/src/tools/crop.rs +++ b/src/tools/crop.rs @@ -2,17 +2,13 @@ use super::{Drawable, DrawableClone, Tool, ToolUpdateResult, Tools}; use crate::{ math::{self, Vec2D}, sketch_board::{ - KeyEventMsg, MouseButton, MouseEventMsg, MouseEventType, SketchBoardInput, - SketchBoardOutput, + MouseButton, MouseEventMsg, MouseEventType, SketchBoardInput, SketchBoardOutput, }, tools::hit_test_rectangle, }; use anyhow::Result; use femtovg::{Color, Paint, Path}; -use relm4::{ - Sender, - gtk::gdk::{Key, ModifierType}, -}; +use relm4::Sender; #[derive(Debug, Clone, Copy)] pub struct Crop { @@ -131,46 +127,14 @@ impl Tool for CropTool { Tools::Crop } - fn handle_key_event(&mut self, event: KeyEventMsg) -> ToolUpdateResult { - match event.key { - Key::Escape if let Some(crop) = &self.crop => { - if crop.active { - self.handle_dismissed() - } else { - ToolUpdateResult::Unmodified - } - } - Key::Return if let Some(crop) = &self.crop => { - if crop.active { - self.handle_deactivated() - } else { - ToolUpdateResult::Unmodified - } - } - _ => ToolUpdateResult::Unmodified, - } - } - fn handle_mouse_event(&mut self, event: MouseEventMsg) -> ToolUpdateResult { - let ctrl_pressed = event.modifier.intersects(ModifierType::CONTROL_MASK); match event.type_ { - MouseEventType::Click if event.button == MouseButton::Primary && ctrl_pressed => { - self.handle_deactivated() - } - MouseEventType::Click - if event.button == MouseButton::Secondary - && ctrl_pressed - && let Some(crop) = &self.crop - && crop.active => - { - self.handle_dismissed() - } - MouseEventType::BeginDrag if event.button == MouseButton::Primary && !ctrl_pressed => { + MouseEventType::BeginDrag if event.button == MouseButton::Primary => { self.dragging = true; self.crop = Some(Crop::new(event.pos)); ToolUpdateResult::Redraw } - MouseEventType::EndDrag if event.button == MouseButton::Primary && !ctrl_pressed => { + MouseEventType::EndDrag if event.button == MouseButton::Primary => { self.dragging = false; let Some(crop) = &mut self.crop else { return ToolUpdateResult::Unmodified; @@ -178,7 +142,7 @@ impl Tool for CropTool { ToolUpdateResult::Commit(crop.clone_box()) } - MouseEventType::UpdateDrag if event.button == MouseButton::Primary && !ctrl_pressed => { + MouseEventType::UpdateDrag if event.button == MouseButton::Primary => { if event.pos == Vec2D::zero() { return ToolUpdateResult::Unmodified; } From aa3a3827d3ad0e122e0fbdc6749f7a5a9daddb4e Mon Sep 17 00:00:00 2001 From: robertwidfen <126524724+robertwidfen@users.noreply.github.com> Date: Fri, 14 Aug 2026 01:57:33 +0200 Subject: [PATCH 11/13] feat: scroll on selected annotation changes layer Scrol up to raise and down to lower. --- src/femtovg_area/imp.rs | 16 +++---- src/femtovg_area/mod.rs | 4 +- src/sketch_board.rs | 97 +++++++++++++++++++++++++++++++++-------- 3 files changed, 88 insertions(+), 29 deletions(-) diff --git a/src/femtovg_area/imp.rs b/src/femtovg_area/imp.rs index 8dc5b0e0..047df001 100644 --- a/src/femtovg_area/imp.rs +++ b/src/femtovg_area/imp.rs @@ -116,6 +116,7 @@ impl GLAreaImpl for FemtoVGArea { .expect("Did you call init before using FemtoVgArea?") .update_transformation(canvas); } + fn render(&self, _context: >k::gdk::GLContext) -> glib::Propagation { self.ensure_canvas(); @@ -169,6 +170,7 @@ impl GLAreaImpl for FemtoVGArea { glib::Propagation::Stop } } + impl FemtoVGArea { pub fn init( &self, @@ -198,6 +200,7 @@ impl FemtoVGArea { }); self.sender.borrow_mut().replace(sender); } + fn ensure_canvas(&self) { if self.canvas.borrow().is_none() { let c = self @@ -387,18 +390,15 @@ impl FemtoVgAreaMut { } } - pub fn move_drawable_to_end(&mut self, index: usize) -> Option { + pub fn move_drawable_index(&mut self, index: usize, offset: isize) -> Option { if index >= self.drawables.len() { return None; } - - if index + 1 == self.drawables.len() { - return Some(index); - } - + let new_index = + (index as isize + offset).clamp(0, self.drawables.len() as isize - 1) as usize; let drawable = self.drawables.remove(index); - self.drawables.push(drawable); - Some(self.drawables.len() - 1) + self.drawables.insert(new_index, drawable); + Some(new_index) } pub fn remove_drawable(&mut self, index: usize) { diff --git a/src/femtovg_area/mod.rs b/src/femtovg_area/mod.rs index 4a733801..c85cfe21 100644 --- a/src/femtovg_area/mod.rs +++ b/src/femtovg_area/mod.rs @@ -229,12 +229,12 @@ impl FemtoVGArea { .replace_drawable(index, drawable); } - pub fn move_drawable_to_end(&mut self, index: usize) -> Option { + pub fn move_drawable_index(&mut self, index: usize, delta: isize) -> Option { self.imp() .inner() .as_mut() .expect("Did you call init before using FemtoVgArea?") - .move_drawable_to_end(index) + .move_drawable_index(index, delta) } pub fn remove_drawable(&mut self, index: usize) { diff --git a/src/sketch_board.rs b/src/sketch_board.rs index 6cb88397..cca98048 100644 --- a/src/sketch_board.rs +++ b/src/sketch_board.rs @@ -294,6 +294,7 @@ pub struct SketchBoard { return_to_pointer_after_text_commit: bool, temporary_pointer_previous_tool: Option, style: Style, + pointer_layer_scroll_accumulator: f32, im_context: gtk::IMMulticontext, last_saved_filepath: RefCell>, } @@ -856,12 +857,65 @@ impl SketchBoard { ToolUpdateResult::Unmodified } + // accumulates deltas for easier touchpad use + fn pointer_layer_scroll_offset(&mut self, me: &MouseEventMsg) -> isize { + let delta = me.pos.y; + + if !me.is_touchpad { + return if delta > 0.0 { + -1 + } else if delta < 0.0 { + 1 + } else { + 0 + }; + } + + // reset on direction changes so the response feels "immediate". + let mut delta_acumulated = self.pointer_layer_scroll_accumulator; + if delta_acumulated != 0.0 && delta_acumulated.signum() != delta.signum() { + delta_acumulated = 0.0; + } + + delta_acumulated += delta; + + const MAX_DELTA: f32 = 7.0; + if delta_acumulated >= MAX_DELTA { + self.pointer_layer_scroll_accumulator = 0.0; + 1 + } else if delta_acumulated <= -MAX_DELTA { + self.pointer_layer_scroll_accumulator = 0.0; + -1 + } else { + self.pointer_layer_scroll_accumulator = delta_acumulated; + 0 + } + } + fn handle_pointer_tool_click( &mut self, me: &MouseEventMsg, sender: &ComponentSender, ) -> ToolUpdateResult { if self.active_tool_type() == Tools::Pointer { + if me.type_ == MouseEventType::Scroll + && !me.modifier.contains(ModifierType::CONTROL_MASK) + { + let selected_idx = self.pointer_tool.borrow().selected_index(); + if let Some(selected_idx) = selected_idx { + let offset = self.pointer_layer_scroll_offset(me); + + if offset != 0 + && let Some(new_idx) = + self.renderer.move_drawable_index(selected_idx, offset) + { + self.update_pointer_tool_selection(new_idx, false); + return ToolUpdateResult::Redraw; + } + } + return ToolUpdateResult::Unmodified; + } + if me.type_ == MouseEventType::Click && me.n_pressed == 2 { self.handle_pointer_tool_double_click(me.pos, sender) .unwrap_or_else(|| ToolUpdateResult::Unmodified) @@ -998,26 +1052,18 @@ impl SketchBoard { self.renderer.hit_test(me.pos).first().copied() }; - if let Some(idx) = idx_to_select { - let sel_idx = if is_alt_click { - // Alt+Click: don't move to end, just use the index as-is - idx - } else { - // Normal click: move to end and use new index - self.renderer.move_drawable_to_end(idx).unwrap_or(idx) - }; - - if let (Some(drawable), Some(bounds)) = ( + if let Some(sel_idx) = idx_to_select + && let (Some(drawable), Some(bounds)) = ( self.renderer.get_drawable_clone(sel_idx), self.renderer.get_drawable_bounds(sel_idx), - ) { - self.sync_toolbar_style_from_drawable(drawable.as_ref(), sender); - self.renderer.set_hidden_drawable_index(Some(sel_idx)); - self.pointer_tool - .borrow_mut() - .begin_move(sel_idx, drawable, bounds, me.pos); - return Some(ToolUpdateResult::Redraw); - } + ) + { + self.sync_toolbar_style_from_drawable(drawable.as_ref(), sender); + self.renderer.set_hidden_drawable_index(Some(sel_idx)); + self.pointer_tool + .borrow_mut() + .begin_move(sel_idx, drawable, bounds, me.pos); + return Some(ToolUpdateResult::Redraw); } // Clicked on empty space: deselect @@ -1554,8 +1600,20 @@ impl Component for SketchBoard { if matches!(ie, InputEvent::Mouse(_)) { // changes pos to local coords ie.handle_event_mouse_input(&self.renderer); + + let skip_default_mouse_handling = if let InputEvent::Mouse(me) = &ie { + me.type_ == MouseEventType::Scroll + && self.active_tool_type() == Tools::Pointer + && self.pointer_tool.borrow().selected_index().is_some() + && !me.modifier.contains(ModifierType::CONTROL_MASK) + } else { + false + }; + // handle right click and other things - ie.handle_mouse_event(&self.renderer); + if !skip_default_mouse_handling { + ie.handle_mouse_event(&self.renderer); + } // change cursor if we are hovering over a drawable if let InputEvent::Mouse(me) = ie && let MouseEventType::PointerPos = me.type_ @@ -1774,6 +1832,7 @@ impl Component for SketchBoard { active_tool: tools.get(&config.initial_tool()), tool_edit_mode: false, style: Style::default(), + pointer_layer_scroll_accumulator: 0.0, pinch_last_scale: 1.0, pointer_tool, text_tool, From 829ababbb1db9c282938d66ce35f10431b3ceb64 Mon Sep 17 00:00:00 2001 From: robertwidfen <126524724+robertwidfen@users.noreply.github.com> Date: Sat, 15 Aug 2026 02:10:48 +0200 Subject: [PATCH 12/13] feat: add `auto-select-new` config option --- README.md | 2 ++ config.toml | 2 ++ src/configuration.rs | 10 ++++++++++ src/sketch_board.rs | 39 ++++++++++++++++++++++----------------- 4 files changed, 36 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index 360c83f6..0f92ed9a 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,8 @@ resize = { mode = "smart" } floating-hack = true # Change to true to automatically copy to clipboard after every annotation change (0.21.0) auto-copy = false +# Change to true to automatically select newly created annotation (NEXTRELEASE) +auto-select-new = false # Exit directly after copy/save action. 0.21.0: change to list of triggers # Note that exit-early-save-as was removed with 0.21.0. early-exit = ["all"] diff --git a/config.toml b/config.toml index 68d74bf9..1d6a8a0b 100644 --- a/config.toml +++ b/config.toml @@ -12,6 +12,8 @@ resize = { mode = "smart" } floating-hack = true # Change to true to automatically copy to clipboard after every annotation change (0.21.0) auto-copy = false +# Change to true to automatically select newly created annotation (NEXTRELEASE) +auto-select-new = false # Exit directly after copy/save action. 0.21.0: change to list of triggers # Note that exit-early-save-as was removed with 0.21.0. early-exit = ["all"] diff --git a/src/configuration.rs b/src/configuration.rs index daa92d9d..f2472101 100644 --- a/src/configuration.rs +++ b/src/configuration.rs @@ -53,6 +53,7 @@ pub struct Configuration { annotation_size_factor: f32, save_after_copy: bool, auto_copy: bool, + auto_select: bool, actions_on_enter: Vec, actions_on_escape: Vec, actions_on_right_click: Vec, @@ -305,6 +306,9 @@ impl Configuration { if let Some(v) = general.auto_copy { self.auto_copy = v; } + if let Some(v) = general.auto_select_new { + self.auto_select = v; + } if let Some(v) = general.actions_on_enter { self.actions_on_enter = v; } @@ -577,6 +581,10 @@ impl Configuration { self.auto_copy } + pub fn auto_select(&self) -> bool { + self.auto_select + } + pub fn actions_on_enter(&self) -> Vec { self.actions_on_enter.clone() } @@ -683,6 +691,7 @@ impl Default for Configuration { annotation_size_factor: 1.0, save_after_copy: false, auto_copy: false, + auto_select: false, actions_on_enter: vec![], actions_on_escape: vec![Action::Exit], actions_on_right_click: vec![], @@ -756,6 +765,7 @@ struct ConfigurationFileGeneral { annotation_size_factor: Option, save_after_copy: Option, auto_copy: Option, + auto_select_new: Option, output_filename: Option, actions_on_enter: Option>, actions_on_escape: Option>, diff --git a/src/sketch_board.rs b/src/sketch_board.rs index cca98048..91d24b60 100644 --- a/src/sketch_board.rs +++ b/src/sketch_board.rs @@ -1751,6 +1751,7 @@ impl Component for SketchBoard { ToolUpdateResult::Commit(drawable) => { let committed_is_crop = drawable.is_crop(); self.renderer.commit(drawable); + let auto_select = APP_CONFIG.read().auto_select(); let committed_index = if committed_is_crop { self.renderer.crop_drawable_index() @@ -1758,24 +1759,28 @@ impl Component for SketchBoard { self.renderer.last_drawable_index() }; - if let Some(index) = committed_index - && let Some(new_bounds) = self.renderer.get_drawable_bounds(index) - { - self.pointer_tool - .borrow_mut() - .set_selection(index, new_bounds); - } + if auto_select { + if let Some(index) = committed_index + && let Some(new_bounds) = self.renderer.get_drawable_bounds(index) + { + self.pointer_tool + .borrow_mut() + .set_selection(index, new_bounds); + } - if self.active_tool_type() != Tools::Pointer { - let previous_tool = self.active_tool_type(); - let _ = self.handle_toolbar_event( - ToolbarEvent::ToolSelected(Tools::Pointer), - sender_for_post_commit.clone(), - ); - sender_for_post_commit - .output_sender() - .emit(SketchBoardOutput::ToolSwitchShortcut(Tools::Pointer)); - self.temporary_pointer_previous_tool = Some(previous_tool); + if self.active_tool_type() != Tools::Pointer { + let previous_tool = self.active_tool_type(); + let _ = self.handle_toolbar_event( + ToolbarEvent::ToolSelected(Tools::Pointer), + sender_for_post_commit.clone(), + ); + sender_for_post_commit + .output_sender() + .emit(SketchBoardOutput::ToolSwitchShortcut(Tools::Pointer)); + self.temporary_pointer_previous_tool = Some(previous_tool); + } + } else { + self.pointer_tool.borrow_mut().deselect(); } if APP_CONFIG.read().auto_copy() { From a30f0868a2171ae086eaea9ea38c4efca84a4364 Mon Sep 17 00:00:00 2001 From: robertwidfen <126524724+robertwidfen@users.noreply.github.com> Date: Fri, 14 Aug 2026 03:22:50 +0200 Subject: [PATCH 13/13] update README.md for annotation editing. Got lost somewhere during rebasing. --- README.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/README.md b/README.md index 0f92ed9a..6758c249 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,21 @@ Default single-key shortcuts: - u: Blur tool - g: Highlight tool +### Pointer Tool NEXTRELEASE + +Annotations can be selected by click. +Newly created annotation will be autoselected. +It will update the toolbar to the annotation style and allows style changes. + +- Hold Alt to select between overlapping annotations. +- Delete deletes the selected annotation. +- Drag the resize handles to change the size. +- Grab at the selection border to move. +- Scroll up/down to raise or lower the annotation. +- Nudge by cursor keys. +- Double-click to edit text annotation. +- Horizontal resize on marker chages its number and vertical the extra ring. + ### Tool Modifiers and Keys Arrow and line: