diff --git a/README.md b/README.md index 4347b06f..6758c249 100644 --- a/README.md +++ b/README.md @@ -99,12 +99,22 @@ Default single-key shortcuts: - u: Blur tool - g: Highlight tool -### Tool Modifiers and Keys +### 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. -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. +- 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: - Shift to make tool snap to 15° steps. @@ -170,6 +180,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/femtovg_area/imp.rs b/src/femtovg_area/imp.rs index f147174a..047df001 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}; @@ -38,6 +38,7 @@ pub struct FemtoVGArea { font: RefCell>, inner: RefCell>, request_render: RefCell>>, + post_render_refresh_selection: RefCell>, sender: RefCell>>, } @@ -46,7 +47,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>, @@ -59,6 +59,7 @@ pub struct FemtoVgAreaMut { drag_offset: Vec2D, is_drag: bool, is_reset: bool, + hidden_drawable_index: Option, } enum HistoryEntry { @@ -115,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(); @@ -157,14 +159,22 @@ 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 } } + impl FemtoVGArea { pub fn init( &self, sender: Sender, - crop_tool: Rc>, active_tool: Rc>, background_image: Pixbuf, ) { @@ -174,7 +184,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(), @@ -187,9 +196,11 @@ impl FemtoVGArea { last_scale: initial_scale, is_drag: false, is_reset: false, + hidden_drawable_index: None, }); self.sender.borrow_mut().replace(sender); } + fn ensure_canvas(&self) { if self.canvas.borrow().is_none() { let c = self @@ -321,6 +332,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); } @@ -328,12 +347,71 @@ impl FemtoVGArea { impl FemtoVgAreaMut { pub fn commit(&mut self, drawable: Box) { + // 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.push(drawable); self.redo_stack.clear(); } + 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(); + for (i, d) in self.drawables.iter().enumerate().rev() { + if d.hit_test(pos, crate::tools::HIT_BORDER_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_index(&mut self, index: usize, offset: isize) -> Option { + if index >= self.drawables.len() { + return None; + } + 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.insert(new_index, drawable); + Some(new_index) + } + + 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)) => { @@ -354,6 +432,12 @@ impl FemtoVgAreaMut { match self.redo_stack.pop() { Some(HistoryEntry::Drawable(mut drawable)) => { drawable.handle_redo(); + + // 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.push(drawable); @@ -422,15 +506,18 @@ 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 @@ -451,7 +538,6 @@ impl FemtoVgAreaMut { self.render( canvas, font, - false, femtovg::Color::rgbaf(0.0, 0.0, 0.0, 0.0), false, )?; @@ -484,7 +570,6 @@ impl FemtoVgAreaMut { self.render( canvas, font, - true, femtovg::Color::rgbaf(0.0, 0.0, 0.0, 0.0), true, )?; @@ -496,7 +581,6 @@ impl FemtoVgAreaMut { &mut self, canvas: &mut femtovg::Canvas, font: FontId, - render_crop: bool, outside_bg_color: femtovg::Color, onscreen: bool, ) -> Result<()> { @@ -514,19 +598,42 @@ 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 d in &mut self.drawables { - d.draw(canvas, font, bounds)?; + 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() + && !preview.is_crop() + { + preview.draw(canvas, font, bounds)?; + active_tool_drawn_in_stack = true; + } + continue; + } + if !d.is_crop() { + d.draw(canvas, font, bounds)?; + } } - // render active tool - if let Some(d) = self.active_tool.borrow().get_drawable() { - 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 crop tool - if render_crop && let Some(c) = self.crop_tool.borrow().get_crop() { - c.draw(canvas, font, bounds)?; + // 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() + { + d.draw(canvas, font, bounds)?; } canvas.flush(); diff --git a/src/femtovg_area/mod.rs b/src/femtovg_area/mod.rs index cafda09f..c85cfe21 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(); @@ -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() @@ -99,12 +104,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) { @@ -177,4 +180,76 @@ 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 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() + .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_index(&mut self, index: usize, delta: isize) -> Option { + self.imp() + .inner() + .as_mut() + .expect("Did you call init before using FemtoVgArea?") + .move_drawable_index(index, delta) + } + + 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/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/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..91d24b60 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,9 @@ pub enum SketchBoardInput { PinchStart, PinchScale(f32), PinchEnd, + NudgeSelection(Vec2D), + RefreshSelectionBounds(usize), + RefreshMouseCursor(Vec2D), ToolbarEvent(ToolbarEvent), RenderResult(RenderedImage, Vec), RenderResultFollowup(Option, Vec, Option), @@ -55,6 +58,7 @@ pub enum SketchBoardOutput { ColorSwitchShortcut(u64), SetColor(Color), SetSize(Size), + SetAnnotationSizeFactor(f32), FocusAnnotationSizeFactorShortcut, SetFill(bool), SetRoundCaps(bool), @@ -202,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 @@ -262,7 +270,7 @@ impl InputEvent { None } MouseEventType::PointerPos => { - renderer.set_pointer_offset(me.pos); + renderer.set_pointer_offset(me.screen_pos); None } _ => None, @@ -281,12 +289,76 @@ 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, + pointer_layer_scroll_accumulator: f32, im_context: gtk::IMMulticontext, last_saved_filepath: RefCell>, } 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(); } @@ -730,6 +802,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 +814,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 @@ -748,7 +824,15 @@ impl SketchBoard { fn handle_clear_all(&mut self) -> ToolUpdateResult { // can't use lazy || here - if self.deactivate_active_tool() | self.renderer.clear_all() { + 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 @@ -773,6 +857,247 @@ 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) + } 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, sender) + .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, + 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); + 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.sync_toolbar_style_from_drawable(drawable.as_ref(), sender); + self.renderer.set_hidden_drawable_index(Some(idx)); + self.pointer_tool + .borrow_mut() + .begin_resize(idx, drawable, handle, bounds, me.pos); + 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, 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), me.pos); + 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(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); + } + + // 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 +1109,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 +1119,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() { @@ -834,18 +1165,14 @@ 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)) + .emit(SketchBoardOutput::SetSize(size)); + self.set_drawable_style_from_toolbar_style() } ToolbarEvent::SaveFile => self.handle_action(&[Action::SaveToFile]), ToolbarEvent::CopyClipboard => self.handle_action(&[Action::SaveToClipboard]), @@ -857,31 +1184,22 @@ impl SketchBoard { 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), @@ -1043,8 +1361,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) => { @@ -1059,6 +1384,45 @@ 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 + } + + 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)] @@ -1227,10 +1591,38 @@ 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); + + 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 + 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_ + && me.modifier == ModifierType::empty() + { + self.update_mouse_cursor(me.pos); + } + } + if let InputEvent::Key(ke) = ie { let active_tool_result = self .active_tool @@ -1255,6 +1647,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 +1657,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 +1684,28 @@ 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); + self.update_pointer_tool_selection(index, false); + ToolUpdateResult::Redraw + } else { + ToolUpdateResult::Unmodified + } + } else { + ToolUpdateResult::Unmodified + } + } + 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) } @@ -1346,10 +1749,59 @@ impl Component for SketchBoard { match result { 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() + } else { + self.renderer.last_drawable_index() + }; + + 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); + } + } else { + self.pointer_tool.borrow_mut().deselect(); + } + 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); + self.update_pointer_tool_selection(index, true); self.refresh_screen(); } ToolUpdateResult::Unmodified | ToolUpdateResult::StopPropagation => (), @@ -1374,6 +1826,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)), @@ -1381,7 +1837,12 @@ 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, + return_to_pointer_after_text_commit: false, + temporary_pointer_previous_tool: None, tools, im_context, last_saved_filepath: RefCell::new(None), @@ -1390,7 +1851,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/arrow.rs b/src/tools/arrow.rs index c09a2e8f..2527902b 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,54 @@ 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 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 edfc3e85..6a2e6292 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,39 @@ 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 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 a9c2cf9d..a0a51580 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,81 @@ 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 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/crop.rs b/src/tools/crop.rs index 9ff493b9..c80bf4ef 100644 --- a/src/tools/crop.rs +++ b/src/tools/crop.rs @@ -1,19 +1,16 @@ -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, + 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; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Copy)] pub struct Crop { pos: Vec2D, size: Vec2D, @@ -23,15 +20,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 +34,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 +69,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 +86,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 +104,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 { @@ -410,90 +127,42 @@ impl Tool for CropTool { Tools::Crop } - 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 { - 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 { - 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 => { - self.begin_drag(event.pos) + 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 => { - self.end_drag(event.pos) + MouseEventType::EndDrag if event.button == MouseButton::Primary => { + 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) + MouseEventType::UpdateDrag if event.button == MouseButton::Primary => { + 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/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..fb9d7581 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,55 @@ 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 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, @@ -45,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(()) @@ -59,7 +107,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..a08f3c3e 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,145 @@ 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 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 38a373d8..8dce3e55 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,54 @@ 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 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 74940ad2..1ff1c217 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,66 @@ 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); + 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 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; + }; + + // 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, @@ -87,6 +149,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); @@ -161,10 +226,20 @@ 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; } + 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 +247,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 +262,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..c6f1bac3 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}; @@ -17,7 +23,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, }; @@ -37,6 +43,8 @@ mod pointer; mod rectangle; mod text; +pub const HIT_BORDER_TOLERANCE: f32 = 7.0; + pub enum ToolEvent { Activated, Deactivated, @@ -118,6 +126,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>; @@ -160,11 +172,78 @@ pub trait Drawable: DrawableClone + Debug { -> Result<()>; fn handle_undo(&mut self) {} fn handle_redo(&mut self) {} + fn is_crop(&self) -> bool { + false + } + fn bounds_only_valid_after_redraw(&self) -> bool { + false + } + 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 + } + + fn get_style(&self) -> Option<&Style> { + None + } + + fn get_style_mut(&mut self) -> Option<&mut 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 +256,22 @@ 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}; + +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")] @@ -245,6 +336,7 @@ impl FromStr for Tools { pub struct ToolsManager { tools: HashMap>>, crop_tool: Rc>, + pointer_tool: Rc>, text_tool: Rc>, } @@ -252,10 +344,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 +354,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, @@ -275,12 +364,14 @@ 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 { tools, - crop_tool, text_tool, + crop_tool, + pointer_tool, } } @@ -288,18 +379,19 @@ 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(), } } - pub fn get_crop_tool(&self) -> Rc> { - self.crop_tool.clone() + pub fn get_pointer_tool(&self) -> Rc> { + self.pointer_tool.clone() } pub fn get_text_tool(&self) -> Rc> { diff --git a/src/tools/pointer.rs b/src/tools/pointer.rs index 47209885..50ff93a2 100644 --- a/src/tools/pointer.rs +++ b/src/tools/pointer.rs @@ -1,20 +1,515 @@ -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::{self, gdk::ModifierType, prelude::WidgetExt}, +}; +use std::cell::Cell; + +use crate::{ + configuration::APP_CONFIG, + math::{Vec2D, ensure_bounding_box}, + sketch_board::{ + KeyEventMsg, MouseButton, MouseEventMsg, MouseEventType, SketchBoardInput, + SketchBoardOutput, + }, +}; + +use super::{Drawable, InputContext, 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 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::TopRight => { + new_tl.y += delta.y; + new_br.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::MiddleLeft => { + new_tl.x += delta.x; + if is_centered { + new_br.x -= delta.x; + } + } + 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; + } + } + } + + (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(()) + } +} + +#[derive(Debug)] +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>, + 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, + // 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, + // Absolute pointer position (image coordinates) at drag start. + drag_start_pos: Option, +} + +impl Default for PointerTool { + fn default() -> Self { + 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 + } + + 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()?; + 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), + start_pos: 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, + }; + 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. + pub fn begin_resize( + &mut self, + index: usize, + drawable: Box, + handle: ResizeHandle, + orig_bounds: (Vec2D, Vec2D), + start_pos: 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, + }; + 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) { + 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; + self.drag_start_pos = None; + } + + // 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,7 +520,167 @@ impl Tool for PointerTool { self.input_enabled = value; } + fn handle_deactivated(&mut self) -> ToolUpdateResult { + self.clear_hover_cursor(); + 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::PointerPos | MouseEventType::Release => { + self.set_hover_cursor(event.pos); + ToolUpdateResult::Unmodified + } + + 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 (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() + && 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 + } + DragState::None => ToolUpdateResult::Unmodified, + }, + + MouseEventType::EndDrag => { + let current_pos = self.current_drag_pos(event.pos); + match std::mem::replace(&mut self.drag_state, DragState::None) { + DragState::Moving { + index, + original, + orig_bounds, + } => { + let delta = event.pos; + 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; + 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) + }; + self.drag_start_pos = None; + self.set_hover_cursor(current_pos); + result + } + DragState::Resizing { + index, + original, + handle, + orig_bounds, + } => { + let delta = event.pos; + let result = 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.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); + 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 + } + } + } + + _ => ToolUpdateResult::Unmodified, + } + } + 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(); + } } diff --git a/src/tools/rectangle.rs b/src/tools/rectangle.rs index 44e4ab19..fba56d1a 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,40 @@ 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 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, @@ -52,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(()) @@ -118,7 +152,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..d4a8ae27 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,61 @@ 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 { + // 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 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, @@ -803,6 +859,7 @@ pub struct TextTool { drag_start_pos: Vec2D, dragged: Rc>, alt_tap: bool, + editing_existing: bool, } impl Tool for TextTool { @@ -1259,6 +1316,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 +1339,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 +1472,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 +1919,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; + } } 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;