From 71e0878eca4d4de06da8836aaa077d257c62478f Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Tue, 19 May 2026 18:49:25 +0200 Subject: [PATCH 1/2] refactor: catalog drawing tools --- .../wayland/state/render/canvas/mod.rs | 43 +- .../wayland/state/render/tool_preview.rs | 43 +- src/backend/wayland/state/toolbar/events.rs | 1 + .../wayland/toolbar/layout/spec/top.rs | 13 +- .../wayland/toolbar/layout/top/icons.rs | 6 +- src/backend/wayland/toolbar/layout/top/mod.rs | 39 +- .../side_palette/presets/slot/content.rs | 25 +- .../render/top_strip/icons/shape_picker.rs | 23 +- .../render/top_strip/icons/tool_row.rs | 83 ++-- .../wayland/toolbar/render/top_strip/mod.rs | 32 +- .../wayland/toolbar/render/top_strip/text.rs | 34 +- src/draw/shape/mod.rs | 9 +- src/input/state/actions/action_tools.rs | 42 +- src/input/state/core/dirty.rs | 103 +--- .../state/core/tool_controls/settings.rs | 6 - .../interaction/adapters/active_motion.rs | 34 +- src/input/state/mouse/press.rs | 43 +- src/input/state/mouse/release/drawing.rs | 192 ++------ src/input/state/render.rs | 264 ++++------- src/input/state/tests/drawing.rs | 34 ++ src/input/tool.rs | 446 ------------------ src/input/tool/catalog.rs | 387 +++++++++++++++ src/input/tool/drag.rs | 53 +++ src/input/tool/drawing.rs | 432 +++++++++++++++++ src/input/tool/kind.rs | 34 ++ src/input/tool/mod.rs | 33 ++ src/input/tool/profile.rs | 95 ++++ src/input/tool/settings.rs | 104 ++++ src/input/tool/tests.rs | 131 +++++ src/ui/toolbar/apply/tools.rs | 12 +- src/ui/toolbar/bindings.rs | 13 +- src/ui/toolbar/events.rs | 4 - src/ui/toolbar/model/event_policy.rs | 20 +- src/ui/toolbar/model/mod.rs | 6 + src/ui/toolbar/model/tools.rs | 105 +++++ 35 files changed, 1752 insertions(+), 1192 deletions(-) delete mode 100644 src/input/tool.rs create mode 100644 src/input/tool/catalog.rs create mode 100644 src/input/tool/drag.rs create mode 100644 src/input/tool/drawing.rs create mode 100644 src/input/tool/kind.rs create mode 100644 src/input/tool/mod.rs create mode 100644 src/input/tool/profile.rs create mode 100644 src/input/tool/settings.rs create mode 100644 src/input/tool/tests.rs create mode 100644 src/ui/toolbar/model/tools.rs diff --git a/src/backend/wayland/state/render/canvas/mod.rs b/src/backend/wayland/state/render/canvas/mod.rs index a7eafca2..6b29edb0 100644 --- a/src/backend/wayland/state/render/canvas/mod.rs +++ b/src/backend/wayland/state/render/canvas/mod.rs @@ -168,42 +168,13 @@ impl WaylandState { self.render_eraser_hover_halos(ctx, hover_mx, hover_my); - // Render provisional shape if actively drawing. - let rendered_provisional = if let crate::input::DrawingState::Drawing { - tool: crate::input::Tool::Blur, - start_x, - start_y, - .. - } = &self.input_state.state - { - let (x, w) = if mx >= *start_x { - (*start_x, mx - start_x) - } else { - (mx, start_x - mx) - }; - let (y, h) = if my >= *start_y { - (*start_y, my - start_y) - } else { - (my, start_y - my) - }; - crate::draw::render_blur_rect( - ctx, - crate::draw::BlurRectParams { - x, - y, - w, - h, - strength: self - .input_state - .thickness_for_tool(crate::input::Tool::Blur), - cacheable: false, - }, - &replay_ctx, - ); - true - } else { - // Use optimized method that avoids cloning for freehand - self.input_state.render_provisional_shape(ctx, mx, my) + let provisional = self.input_state.provisional_tool_stroke(mx, my); + let rendered_provisional = match provisional { + crate::input::tool::ProvisionalToolStroke::BlurReplayPreview(params) => { + crate::draw::render_blur_rect(ctx, params, &replay_ctx); + true + } + _ => self.input_state.render_provisional_shape(ctx, mx, my), }; if rendered_provisional { debug!("Rendered provisional shape"); diff --git a/src/backend/wayland/state/render/tool_preview.rs b/src/backend/wayland/state/render/tool_preview.rs index a84f73a4..0cd5357c 100644 --- a/src/backend/wayland/state/render/tool_preview.rs +++ b/src/backend/wayland/state/render/tool_preview.rs @@ -1,6 +1,7 @@ use crate::draw::Color; use crate::input::Tool; use crate::toolbar_icons; +use crate::ui::toolbar::model::{self, SemanticToolIcon}; pub(super) fn draw_tool_preview( ctx: &cairo::Context, @@ -47,22 +48,38 @@ pub(super) fn draw_tool_preview( ctx.set_source_rgba(r, g, b, a); let icon_x = bx + pad; let icon_y = by + pad; - match tool { - Tool::Select => toolbar_icons::draw_icon_select(ctx, icon_x, icon_y, icon_size), - Tool::Pen => toolbar_icons::draw_icon_pen(ctx, icon_x, icon_y, icon_size), - Tool::Line => toolbar_icons::draw_icon_line(ctx, icon_x, icon_y, icon_size), - Tool::Rect => toolbar_icons::draw_icon_rect(ctx, icon_x, icon_y, icon_size), - Tool::Ellipse => toolbar_icons::draw_icon_circle(ctx, icon_x, icon_y, icon_size), - Tool::Arrow => toolbar_icons::draw_icon_arrow(ctx, icon_x, icon_y, icon_size), - Tool::Blur => toolbar_icons::draw_icon_blur(ctx, icon_x, icon_y, icon_size), - Tool::Marker => toolbar_icons::draw_icon_marker(ctx, icon_x, icon_y, icon_size), - Tool::StepMarker => toolbar_icons::draw_icon_step_marker(ctx, icon_x, icon_y, icon_size), - Tool::Highlight => toolbar_icons::draw_icon_highlight(ctx, icon_x, icon_y, icon_size), - Tool::Eraser => toolbar_icons::draw_icon_eraser(ctx, icon_x, icon_y, icon_size), - } + draw_semantic_tool_icon( + ctx, + model::semantic_icon_for_tool(tool), + icon_x, + icon_y, + icon_size, + ); let _ = ctx.restore(); } +fn draw_semantic_tool_icon( + ctx: &cairo::Context, + icon: SemanticToolIcon, + x: f64, + y: f64, + size: f64, +) { + match icon { + SemanticToolIcon::Select => toolbar_icons::draw_icon_select(ctx, x, y, size), + SemanticToolIcon::Pen => toolbar_icons::draw_icon_pen(ctx, x, y, size), + SemanticToolIcon::Line => toolbar_icons::draw_icon_line(ctx, x, y, size), + SemanticToolIcon::Rect => toolbar_icons::draw_icon_rect(ctx, x, y, size), + SemanticToolIcon::Circle => toolbar_icons::draw_icon_circle(ctx, x, y, size), + SemanticToolIcon::Arrow => toolbar_icons::draw_icon_arrow(ctx, x, y, size), + SemanticToolIcon::Blur => toolbar_icons::draw_icon_blur(ctx, x, y, size), + SemanticToolIcon::Marker => toolbar_icons::draw_icon_marker(ctx, x, y, size), + SemanticToolIcon::Highlight => toolbar_icons::draw_icon_highlight(ctx, x, y, size), + SemanticToolIcon::StepMarker => toolbar_icons::draw_icon_step_marker(ctx, x, y, size), + SemanticToolIcon::Eraser => toolbar_icons::draw_icon_eraser(ctx, x, y, size), + } +} + pub(super) fn draw_stylus_hover_cursor( ctx: &cairo::Context, tool: Tool, diff --git a/src/backend/wayland/state/toolbar/events.rs b/src/backend/wayland/state/toolbar/events.rs index 6082dacf..43c360a5 100644 --- a/src/backend/wayland/state/toolbar/events.rs +++ b/src/backend/wayland/state/toolbar/events.rs @@ -411,6 +411,7 @@ mod tests { fn click_highlight_toolbar_events_are_explicit_config_exceptions() { let events = vec![ ToolbarEvent::ToggleAllHighlight(true), + ToolbarEvent::SelectTool(Tool::Highlight), ToolbarEvent::ToggleHighlightToolRing(true), ]; diff --git a/src/backend/wayland/toolbar/layout/spec/top.rs b/src/backend/wayland/toolbar/layout/spec/top.rs index 78725299..96d2a891 100644 --- a/src/backend/wayland/toolbar/layout/spec/top.rs +++ b/src/backend/wayland/toolbar/layout/spec/top.rs @@ -1,6 +1,6 @@ use crate::config::ToolbarLayoutMode; -use crate::input::Tool; use crate::ui::toolbar::ToolbarSnapshot; +use crate::ui::toolbar::model; use super::ToolbarLayoutSpec; @@ -52,18 +52,15 @@ impl ToolbarLayoutSpec { } else { Self::TOP_TEXT_BUTTON_W }; - let tool_count = if self.layout_mode == ToolbarLayoutMode::Simple { - 5 - } else { - 10 - }; + let tool_count = + model::top_tool_buttons(self.layout_mode == ToolbarLayoutMode::Simple).len(); let mut x = Self::TOP_START_X + Self::TOP_HANDLE_SIZE + gap; x += tool_count as f64 * (btn_w + gap); if self.layout_mode == ToolbarLayoutMode::Simple { x += btn_w + gap; } - let fill_tool_active = matches!(snapshot.tool_override, Some(Tool::Rect | Tool::Ellipse)) - || matches!(snapshot.active_tool, Tool::Rect | Tool::Ellipse); + let fill_tool_active = + model::fill_tool_active(snapshot.active_tool, snapshot.tool_override); let fill_visible = !self.use_icons && fill_tool_active && !(self.layout_mode == ToolbarLayoutMode::Simple && self.shape_picker_open); diff --git a/src/backend/wayland/toolbar/layout/top/icons.rs b/src/backend/wayland/toolbar/layout/top/icons.rs index 679b0a92..335e2a2b 100644 --- a/src/backend/wayland/toolbar/layout/top/icons.rs +++ b/src/backend/wayland/toolbar/layout/top/icons.rs @@ -5,8 +5,8 @@ use super::super::spec::ToolbarLayoutSpec; use super::shape_buttons; use super::tool_buttons; use crate::config::{Action, action_label}; -use crate::input::Tool; use crate::ui::toolbar::bindings::tool_tooltip_label; +use crate::ui::toolbar::model; use crate::ui::toolbar::{ToolbarEvent, ToolbarSnapshot}; pub(super) fn build_hits( @@ -28,10 +28,10 @@ pub(super) fn build_hits( let mut rect_x = None; let mut circle_end_x = None; for tool in tool_buttons { - if *tool == Tool::Rect { + if model::is_fill_tool(*tool) && rect_x.is_none() { rect_x = Some(x); } - if *tool == Tool::Ellipse { + if model::is_fill_tool(*tool) { circle_end_x = Some(x + btn_size); } hits.push(HitRegion { diff --git a/src/backend/wayland/toolbar/layout/top/mod.rs b/src/backend/wayland/toolbar/layout/top/mod.rs index daebfbd8..cece60db 100644 --- a/src/backend/wayland/toolbar/layout/top/mod.rs +++ b/src/backend/wayland/toolbar/layout/top/mod.rs @@ -5,38 +5,12 @@ use super::super::hit::HitRegion; use super::spec::ToolbarLayoutSpec; use crate::config::ToolbarLayoutMode; use crate::input::Tool; +use crate::ui::toolbar::model; use crate::ui::toolbar::{ToolbarEvent, ToolbarSnapshot}; mod icons; mod text; -const TOOL_BUTTONS_SIMPLE: &[Tool] = &[ - Tool::Select, - Tool::Pen, - Tool::Marker, - Tool::StepMarker, - Tool::Eraser, -]; -const TOOL_BUTTONS_FULL: &[Tool] = &[ - Tool::Select, - Tool::Pen, - Tool::Marker, - Tool::StepMarker, - Tool::Eraser, - Tool::Line, - Tool::Rect, - Tool::Ellipse, - Tool::Arrow, - Tool::Blur, -]; -const SHAPE_BUTTONS: &[Tool] = &[ - Tool::Line, - Tool::Rect, - Tool::Ellipse, - Tool::Arrow, - Tool::Blur, -]; - pub fn build_top_hits( width: f64, height: f64, @@ -45,8 +19,7 @@ pub fn build_top_hits( ) { let spec = ToolbarLayoutSpec::new(snapshot); let is_simple = snapshot.layout_mode == ToolbarLayoutMode::Simple; - let fill_tool_active = matches!(snapshot.tool_override, Some(Tool::Rect | Tool::Ellipse)) - || matches!(snapshot.active_tool, Tool::Rect | Tool::Ellipse); + let fill_tool_active = model::fill_tool_active(snapshot.active_tool, snapshot.tool_override); if spec.use_icons() { icons::build_hits(height, snapshot, &spec, is_simple, fill_tool_active, hits); @@ -79,13 +52,9 @@ pub fn build_top_hits( } fn tool_buttons(is_simple: bool) -> &'static [Tool] { - if is_simple { - TOOL_BUTTONS_SIMPLE - } else { - TOOL_BUTTONS_FULL - } + model::top_tool_buttons(is_simple) } fn shape_buttons() -> &'static [Tool] { - SHAPE_BUTTONS + model::shape_tools() } diff --git a/src/backend/wayland/toolbar/render/side_palette/presets/slot/content.rs b/src/backend/wayland/toolbar/render/side_palette/presets/slot/content.rs index 6fe294f4..c713a512 100644 --- a/src/backend/wayland/toolbar/render/side_palette/presets/slot/content.rs +++ b/src/backend/wayland/toolbar/render/side_palette/presets/slot/content.rs @@ -3,6 +3,7 @@ use crate::backend::wayland::toolbar::hit::HitRegion; use crate::draw::Color; use crate::input::Tool; use crate::toolbar_icons; +use crate::ui::toolbar::model::{self, SemanticToolIcon}; use crate::ui::toolbar::{PresetSlotSnapshot, ToolbarEvent, ToolbarSnapshot}; use super::super::super::super::widgets::{draw_round_rect, draw_swatch}; @@ -114,17 +115,17 @@ pub(super) fn draw_preset_content( } fn draw_preset_icon(ctx: &cairo::Context, tool: Tool, x: f64, y: f64, size: f64) { - match tool { - Tool::Select => toolbar_icons::draw_icon_select(ctx, x, y, size), - Tool::Pen => toolbar_icons::draw_icon_pen(ctx, x, y, size), - Tool::Line => toolbar_icons::draw_icon_line(ctx, x, y, size), - Tool::Rect => toolbar_icons::draw_icon_rect(ctx, x, y, size), - Tool::Ellipse => toolbar_icons::draw_icon_circle(ctx, x, y, size), - Tool::Arrow => toolbar_icons::draw_icon_arrow(ctx, x, y, size), - Tool::Blur => toolbar_icons::draw_icon_blur(ctx, x, y, size), - Tool::Marker => toolbar_icons::draw_icon_marker(ctx, x, y, size), - Tool::StepMarker => toolbar_icons::draw_icon_step_marker(ctx, x, y, size), - Tool::Highlight => toolbar_icons::draw_icon_highlight(ctx, x, y, size), - Tool::Eraser => toolbar_icons::draw_icon_eraser(ctx, x, y, size), + match model::semantic_icon_for_tool(tool) { + SemanticToolIcon::Select => toolbar_icons::draw_icon_select(ctx, x, y, size), + SemanticToolIcon::Pen => toolbar_icons::draw_icon_pen(ctx, x, y, size), + SemanticToolIcon::Line => toolbar_icons::draw_icon_line(ctx, x, y, size), + SemanticToolIcon::Rect => toolbar_icons::draw_icon_rect(ctx, x, y, size), + SemanticToolIcon::Circle => toolbar_icons::draw_icon_circle(ctx, x, y, size), + SemanticToolIcon::Arrow => toolbar_icons::draw_icon_arrow(ctx, x, y, size), + SemanticToolIcon::Blur => toolbar_icons::draw_icon_blur(ctx, x, y, size), + SemanticToolIcon::Marker => toolbar_icons::draw_icon_marker(ctx, x, y, size), + SemanticToolIcon::StepMarker => toolbar_icons::draw_icon_step_marker(ctx, x, y, size), + SemanticToolIcon::Highlight => toolbar_icons::draw_icon_highlight(ctx, x, y, size), + SemanticToolIcon::Eraser => toolbar_icons::draw_icon_eraser(ctx, x, y, size), } } diff --git a/src/backend/wayland/toolbar/render/top_strip/icons/shape_picker.rs b/src/backend/wayland/toolbar/render/top_strip/icons/shape_picker.rs index 3e3a5ede..07c7ea79 100644 --- a/src/backend/wayland/toolbar/render/top_strip/icons/shape_picker.rs +++ b/src/backend/wayland/toolbar/render/top_strip/icons/shape_picker.rs @@ -1,15 +1,13 @@ use crate::backend::wayland::toolbar::events::HitKind; use crate::backend::wayland::toolbar::hit::HitRegion; use crate::backend::wayland::toolbar::layout::ToolbarLayoutSpec; -use crate::input::Tool; -use crate::toolbar_icons; use crate::ui::toolbar::ToolbarEvent; use crate::ui::toolbar::bindings::tool_tooltip_label; +use crate::ui::toolbar::model; use super::super::super::widgets::*; use super::TopStripLayout; - -type IconFn = fn(&cairo::Context, f64, f64, f64); +use super::tool_row::draw_semantic_tool_icon; pub(super) fn draw_shape_picker_row( layout: &mut TopStripLayout, @@ -20,14 +18,7 @@ pub(super) fn draw_shape_picker_row( ) { let shape_y = y + btn_size + ToolbarLayoutSpec::TOP_SHAPE_ROW_GAP; let mut shape_x = ToolbarLayoutSpec::TOP_START_X + handle_w + layout.gap; - let shapes: &[(Tool, IconFn)] = &[ - (Tool::Line, toolbar_icons::draw_icon_line), - (Tool::Rect, toolbar_icons::draw_icon_rect), - (Tool::Ellipse, toolbar_icons::draw_icon_circle), - (Tool::Arrow, toolbar_icons::draw_icon_arrow), - (Tool::Blur, toolbar_icons::draw_icon_blur), - ]; - for (tool, icon_fn) in shapes { + for tool in model::shape_tools() { let is_active = layout.snapshot.active_tool == *tool || layout.snapshot.tool_override == Some(*tool); let is_hover = layout @@ -40,7 +31,13 @@ pub(super) fn draw_shape_picker_row( set_icon_color(layout.ctx, is_hover); let icon_x = shape_x + (btn_size - icon_size) / 2.0; let icon_y = shape_y + (btn_size - icon_size) / 2.0; - icon_fn(layout.ctx, icon_x, icon_y, icon_size); + draw_semantic_tool_icon( + layout.ctx, + model::semantic_icon_for_tool(*tool), + icon_x, + icon_y, + icon_size, + ); let tooltip = layout.tool_tooltip(*tool, tool_tooltip_label(*tool)); layout.hits.push(HitRegion { rect: (shape_x, shape_y, btn_size, btn_size), diff --git a/src/backend/wayland/toolbar/render/top_strip/icons/tool_row.rs b/src/backend/wayland/toolbar/render/top_strip/icons/tool_row.rs index fe9fe031..03d5800a 100644 --- a/src/backend/wayland/toolbar/render/top_strip/icons/tool_row.rs +++ b/src/backend/wayland/toolbar/render/top_strip/icons/tool_row.rs @@ -4,6 +4,7 @@ use crate::input::Tool; use crate::toolbar_icons; use crate::ui::toolbar::ToolbarEvent; use crate::ui::toolbar::bindings::tool_tooltip_label; +use crate::ui::toolbar::model::{self, SemanticToolIcon}; use super::super::super::widgets::*; use super::TopStripLayout; @@ -27,45 +28,14 @@ pub(super) fn draw_tool_row( ) -> ToolRowResult { let snapshot = layout.snapshot; - type IconFn = fn(&cairo::Context, f64, f64, f64); - - let tool_buttons: &[(Tool, IconFn)] = if is_simple { - &[ - (Tool::Select, toolbar_icons::draw_icon_select as IconFn), - (Tool::Pen, toolbar_icons::draw_icon_pen as IconFn), - (Tool::Marker, toolbar_icons::draw_icon_marker as IconFn), - ( - Tool::StepMarker, - toolbar_icons::draw_icon_step_marker as IconFn, - ), - (Tool::Eraser, toolbar_icons::draw_icon_eraser as IconFn), - ] - } else { - &[ - (Tool::Select, toolbar_icons::draw_icon_select as IconFn), - (Tool::Pen, toolbar_icons::draw_icon_pen as IconFn), - (Tool::Marker, toolbar_icons::draw_icon_marker as IconFn), - ( - Tool::StepMarker, - toolbar_icons::draw_icon_step_marker as IconFn, - ), - (Tool::Eraser, toolbar_icons::draw_icon_eraser as IconFn), - (Tool::Line, toolbar_icons::draw_icon_line as IconFn), - (Tool::Rect, toolbar_icons::draw_icon_rect as IconFn), - (Tool::Ellipse, toolbar_icons::draw_icon_circle as IconFn), - (Tool::Arrow, toolbar_icons::draw_icon_arrow as IconFn), - (Tool::Blur, toolbar_icons::draw_icon_blur as IconFn), - ] - }; - let mut fill_anchor: Option<(f64, f64)> = None; let mut rect_x = None; let mut circle_end_x = None; - for (tool, icon_fn) in tool_buttons { - if *tool == Tool::Rect { + for tool in model::top_tool_buttons(is_simple) { + if model::is_fill_tool(*tool) && rect_x.is_none() { rect_x = Some(x); } - if *tool == Tool::Ellipse { + if model::is_fill_tool(*tool) { circle_end_x = Some(x + btn_size); } @@ -79,7 +49,13 @@ pub(super) fn draw_tool_row( set_icon_color(layout.ctx, is_hover); let icon_x = x + (btn_size - icon_size) / 2.0; let icon_y = y + (btn_size - icon_size) / 2.0; - icon_fn(layout.ctx, icon_x, icon_y, icon_size); + draw_semantic_tool_icon( + layout.ctx, + model::semantic_icon_for_tool(*tool), + icon_x, + icon_y, + icon_size, + ); let tooltip = layout.tool_tooltip(*tool, tool_tooltip_label(*tool)); layout.hits.push(HitRegion { @@ -109,14 +85,13 @@ pub(super) fn draw_tool_row( set_icon_color(layout.ctx, shapes_hover); let icon_x = x + (btn_size - icon_size) / 2.0; let icon_y = y + (btn_size - icon_size) / 2.0; - match shape_icon_tool { - Tool::Line => toolbar_icons::draw_icon_line(layout.ctx, icon_x, icon_y, icon_size), - Tool::Rect => toolbar_icons::draw_icon_rect(layout.ctx, icon_x, icon_y, icon_size), - Tool::Ellipse => toolbar_icons::draw_icon_circle(layout.ctx, icon_x, icon_y, icon_size), - Tool::Arrow => toolbar_icons::draw_icon_arrow(layout.ctx, icon_x, icon_y, icon_size), - Tool::Blur => toolbar_icons::draw_icon_blur(layout.ctx, icon_x, icon_y, icon_size), - _ => toolbar_icons::draw_icon_rect(layout.ctx, icon_x, icon_y, icon_size), - } + draw_semantic_tool_icon( + layout.ctx, + model::semantic_icon_for_tool(shape_icon_tool), + icon_x, + icon_y, + icon_size, + ); layout.hits.push(HitRegion { rect: (x, y, btn_size, btn_size), event: ToolbarEvent::ToggleShapePicker(!snapshot.shape_picker_open), @@ -134,3 +109,25 @@ pub(super) fn draw_tool_row( fill_anchor, } } + +pub(crate) fn draw_semantic_tool_icon( + ctx: &cairo::Context, + icon: SemanticToolIcon, + x: f64, + y: f64, + size: f64, +) { + match icon { + SemanticToolIcon::Select => toolbar_icons::draw_icon_select(ctx, x, y, size), + SemanticToolIcon::Pen => toolbar_icons::draw_icon_pen(ctx, x, y, size), + SemanticToolIcon::Line => toolbar_icons::draw_icon_line(ctx, x, y, size), + SemanticToolIcon::Rect => toolbar_icons::draw_icon_rect(ctx, x, y, size), + SemanticToolIcon::Circle => toolbar_icons::draw_icon_circle(ctx, x, y, size), + SemanticToolIcon::Arrow => toolbar_icons::draw_icon_arrow(ctx, x, y, size), + SemanticToolIcon::Blur => toolbar_icons::draw_icon_blur(ctx, x, y, size), + SemanticToolIcon::Marker => toolbar_icons::draw_icon_marker(ctx, x, y, size), + SemanticToolIcon::Highlight => toolbar_icons::draw_icon_highlight(ctx, x, y, size), + SemanticToolIcon::StepMarker => toolbar_icons::draw_icon_step_marker(ctx, x, y, size), + SemanticToolIcon::Eraser => toolbar_icons::draw_icon_eraser(ctx, x, y, size), + } +} diff --git a/src/backend/wayland/toolbar/render/top_strip/mod.rs b/src/backend/wayland/toolbar/render/top_strip/mod.rs index 436bea9e..ea7991e4 100644 --- a/src/backend/wayland/toolbar/render/top_strip/mod.rs +++ b/src/backend/wayland/toolbar/render/top_strip/mod.rs @@ -16,7 +16,7 @@ use super::widgets::{ draw_close_button, draw_drag_handle, draw_panel_background, draw_pin_button, draw_tooltip_with_delay, point_in_rect, }; -use crate::ui::toolbar::ToolbarEvent; +use crate::ui::toolbar::{ToolbarEvent, model}; pub(super) const TOP_LABEL_FONT_SIZE: f64 = 14.0; pub(super) const ICON_TOGGLE_FONT_SIZE: f64 = 12.0; @@ -53,13 +53,7 @@ impl<'a> TopStripLayout<'a> { } pub(super) fn tool_tooltip(&self, tool: Tool, label: &str) -> String { - let default_hint = match tool { - Tool::Line => Some("Shift+Drag"), - Tool::Rect => Some("Ctrl+Drag"), - Tool::Ellipse => Some("Tab+Drag"), - Tool::Arrow => Some("Ctrl+Shift+Drag"), - _ => None, - }; + let default_hint = model::default_drag_hint(tool); let binding = match (self.snapshot.binding_hints.for_tool(tool), default_hint) { (Some(binding), Some(fallback)) => Some(format!("{}, {}", binding, fallback)), (Some(binding), None) => Some(binding.to_string()), @@ -101,24 +95,10 @@ pub fn render_top_strip( x += handle_w + layout.gap; let is_simple = snapshot.layout_mode == crate::config::ToolbarLayoutMode::Simple; - let current_shape_tool = match snapshot.tool_override { - Some(Tool::Line) => Some(Tool::Line), - Some(Tool::Rect) => Some(Tool::Rect), - Some(Tool::Ellipse) => Some(Tool::Ellipse), - Some(Tool::Arrow) => Some(Tool::Arrow), - Some(Tool::Blur) => Some(Tool::Blur), - _ => match snapshot.active_tool { - Tool::Line => Some(Tool::Line), - Tool::Rect => Some(Tool::Rect), - Tool::Ellipse => Some(Tool::Ellipse), - Tool::Arrow => Some(Tool::Arrow), - Tool::Blur => Some(Tool::Blur), - _ => None, - }, - }; - let shape_icon_tool = current_shape_tool.unwrap_or(Tool::Rect); - let fill_tool_active = matches!(snapshot.tool_override, Some(Tool::Rect | Tool::Ellipse)) - || matches!(snapshot.active_tool, Tool::Rect | Tool::Ellipse); + let current_shape_tool = + model::current_shape_tool(snapshot.active_tool, snapshot.tool_override); + let shape_icon_tool = current_shape_tool.unwrap_or_else(model::default_shape_tool); + let fill_tool_active = model::fill_tool_active(snapshot.active_tool, snapshot.tool_override); if snapshot.use_icons { icons::draw_icon_strip( diff --git a/src/backend/wayland/toolbar/render/top_strip/text.rs b/src/backend/wayland/toolbar/render/top_strip/text.rs index b48c08db..5e9d3781 100644 --- a/src/backend/wayland/toolbar/render/top_strip/text.rs +++ b/src/backend/wayland/toolbar/render/top_strip/text.rs @@ -5,8 +5,8 @@ use crate::backend::wayland::toolbar::hit::HitRegion; use crate::backend::wayland::toolbar::layout::ToolbarLayoutSpec; use crate::config::{Action, action_label, action_short_label}; use crate::input::Tool; -use crate::ui::toolbar::ToolbarEvent; use crate::ui::toolbar::bindings::{tool_label, tool_tooltip_label}; +use crate::ui::toolbar::{ToolbarEvent, model}; use crate::ui_text::UiTextStyle; use super::super::widgets::constants::FONT_FAMILY_DEFAULT; @@ -40,28 +40,7 @@ pub(super) fn draw_text_strip( size: ICON_TOGGLE_FONT_SIZE, }; - let tool_buttons: &[Tool] = if is_simple { - &[ - Tool::Select, - Tool::Pen, - Tool::Marker, - Tool::StepMarker, - Tool::Eraser, - ] - } else { - &[ - Tool::Select, - Tool::Pen, - Tool::Marker, - Tool::StepMarker, - Tool::Eraser, - Tool::Line, - Tool::Rect, - Tool::Ellipse, - Tool::Arrow, - Tool::Blur, - ] - }; + let tool_buttons = model::top_tool_buttons(is_simple); for tool in tool_buttons { let label = tool_label(*tool); @@ -246,14 +225,7 @@ pub(super) fn draw_text_strip( if is_simple && snapshot.shape_picker_open { let shape_y = y + btn_h + ToolbarLayoutSpec::TOP_SHAPE_ROW_GAP; let mut shape_x = ToolbarLayoutSpec::TOP_START_X + handle_w + gap; - let shapes: &[Tool] = &[ - Tool::Line, - Tool::Rect, - Tool::Ellipse, - Tool::Arrow, - Tool::Blur, - ]; - for tool in shapes { + for tool in model::shape_tools() { let label = tool_label(*tool); let tooltip_label = tool_tooltip_label(*tool); let is_active = snapshot.active_tool == *tool || snapshot.tool_override == Some(*tool); diff --git a/src/draw/shape/mod.rs b/src/draw/shape/mod.rs index 9ba69cd9..006eafe9 100644 --- a/src/draw/shape/mod.rs +++ b/src/draw/shape/mod.rs @@ -11,13 +11,8 @@ pub use text_cache::invalidate_text_cache; pub use types::{ArrowLabel, EmbeddedImage, EraserBrush, EraserKind, Shape, StepMarkerLabel}; pub(crate) use arrow_label::{ARROW_LABEL_BACKGROUND, arrow_label_layout}; -pub(crate) use bounds::{ - bounding_box_for_arrow, bounding_box_for_blur, bounding_box_for_ellipse, - bounding_box_for_eraser, bounding_box_for_line, bounding_box_for_points, bounding_box_for_rect, -}; -pub(crate) use step_marker::{ - step_marker_bounds, step_marker_outline_thickness, step_marker_radius, -}; +pub(crate) use bounds::{bounding_box_for_blur, bounding_box_for_eraser, bounding_box_for_points}; +pub(crate) use step_marker::{step_marker_outline_thickness, step_marker_radius}; pub(crate) use text::{ bounding_box_for_sticky_note, bounding_box_for_text, sticky_note_layout, sticky_note_text_layout, diff --git a/src/input/state/actions/action_tools.rs b/src/input/state/actions/action_tools.rs index a875f4aa..6630b2f9 100644 --- a/src/input/state/actions/action_tools.rs +++ b/src/input/state/actions/action_tools.rs @@ -6,6 +6,14 @@ use super::super::InputState; impl InputState { pub(in crate::input::state) fn handle_tool_action(&mut self, action: Action) -> bool { + if let Some(tool) = Tool::from_select_action(action) { + if tool == Tool::Highlight { + self.set_highlight_tool(true); + } + self.set_tool_override(Some(tool)); + return true; + } + match action { Action::IncreaseThickness => { self.nudge_thickness_for_active_tool(1.0); @@ -19,45 +27,11 @@ impl InputState { Action::DecreaseMarkerOpacity => { self.set_marker_opacity(self.marker_opacity - 0.05); } - Action::SelectSelectionTool => { - self.set_tool_override(Some(Tool::Select)); - } - Action::SelectMarkerTool => { - self.set_tool_override(Some(Tool::Marker)); - } - Action::SelectStepMarkerTool => { - self.set_tool_override(Some(Tool::StepMarker)); - } - Action::SelectEraserTool => { - self.set_tool_override(Some(Tool::Eraser)); - } Action::ToggleEraserMode => { if self.toggle_eraser_mode() { info!("Eraser mode set to {:?}", self.eraser_mode); } } - Action::SelectPenTool => { - self.set_tool_override(Some(Tool::Pen)); - } - Action::SelectLineTool => { - self.set_tool_override(Some(Tool::Line)); - } - Action::SelectRectTool => { - self.set_tool_override(Some(Tool::Rect)); - } - Action::SelectEllipseTool => { - self.set_tool_override(Some(Tool::Ellipse)); - } - Action::SelectArrowTool => { - self.set_tool_override(Some(Tool::Arrow)); - } - Action::SelectBlurTool => { - self.set_tool_override(Some(Tool::Blur)); - } - Action::SelectHighlightTool => { - self.set_highlight_tool(true); - self.set_tool_override(Some(Tool::Highlight)); - } Action::IncreaseFontSize => { self.adjust_font_size(2.0); } diff --git a/src/input/state/core/dirty.rs b/src/input/state/core/dirty.rs index 716dd6a3..d1f71a2a 100644 --- a/src/input/state/core/dirty.rs +++ b/src/input/state/core/dirty.rs @@ -1,11 +1,6 @@ use super::base::{DrawingState, InputState, TextInputMode}; -use crate::draw::shape::{ - bounding_box_for_arrow, bounding_box_for_blur, bounding_box_for_ellipse, - bounding_box_for_eraser, bounding_box_for_line, bounding_box_for_points, bounding_box_for_rect, - bounding_box_for_sticky_note, bounding_box_for_text, step_marker_bounds, -}; -use crate::input::tool::Tool; -use crate::util::{self, Rect}; +use crate::draw::shape::{bounding_box_for_sticky_note, bounding_box_for_text}; +use crate::util::Rect; impl InputState { /// Clears any cached provisional shape bounds and marks their damage region. @@ -36,97 +31,9 @@ impl InputState { fn compute_provisional_bounds(&self, current_x: i32, current_y: i32) -> Option { match &self.state { - DrawingState::Drawing { - tool, - start_x, - start_y, - points, - point_thicknesses, - } => match tool { - Tool::Pen => { - // Approximate bounding box for variable width - // If we have pressure data, we should ideally use it, but for dirty tracking - // utilizing the max possible width (current_thickness) is safe and fast. - // Or we could scan the point_thicknesses vector. - if !point_thicknesses.is_empty() { - let max_thick = - point_thicknesses.iter().fold(0.0f32, |a, &b| a.max(b)) as f64; - bounding_box_for_points(points, max_thick) - } else { - bounding_box_for_points(points, self.thickness_for_tool(*tool)) - } - } - Tool::Marker => { - let thickness = self.thickness_for_tool(*tool); - let inflated = (thickness * 1.35).max(thickness + 1.0); - bounding_box_for_points(points, inflated) - } - Tool::Eraser => bounding_box_for_eraser(points, self.eraser_size), - Tool::Line => bounding_box_for_line( - *start_x, - *start_y, - current_x, - current_y, - self.thickness_for_tool(*tool), - ), - Tool::Rect => { - let (x, w) = if current_x >= *start_x { - (*start_x, current_x - start_x) - } else { - (current_x, start_x - current_x) - }; - let (y, h) = if current_y >= *start_y { - (*start_y, current_y - start_y) - } else { - (current_y, start_y - current_y) - }; - bounding_box_for_rect(x, y, w, h, self.thickness_for_tool(*tool)) - } - Tool::Ellipse => { - let (cx, cy, rx, ry) = - util::ellipse_bounds(*start_x, *start_y, current_x, current_y); - bounding_box_for_ellipse(cx, cy, rx, ry, self.thickness_for_tool(*tool)) - } - Tool::Arrow => { - let label = self.next_arrow_label(); - bounding_box_for_arrow( - *start_x, - *start_y, - current_x, - current_y, - self.thickness_for_tool(*tool), - self.arrow_length, - self.arrow_angle, - self.arrow_head_at_end, - label.as_ref(), - ) - } - Tool::Blur => { - let (x, w) = if current_x >= *start_x { - (*start_x, current_x - start_x) - } else { - (current_x, start_x - current_x) - }; - let (y, h) = if current_y >= *start_y { - (*start_y, current_y - start_y) - } else { - (current_y, start_y - current_y) - }; - bounding_box_for_blur(x, y, w, h) - } - Tool::StepMarker => { - let label = self.next_step_marker_label(); - step_marker_bounds( - current_x, - current_y, - label.value, - label.size, - &label.font_descriptor, - ) - } - Tool::Highlight => None, - Tool::Select => None, - }, + DrawingState::Drawing { .. } => { + self.provisional_tool_stroke(current_x, current_y).bounds() + } DrawingState::Selecting { start_x, start_y, .. } => Self::selection_rect_from_points(*start_x, *start_y, current_x, current_y) diff --git a/src/input/state/core/tool_controls/settings.rs b/src/input/state/core/tool_controls/settings.rs index b2141c8b..9ff5323b 100644 --- a/src/input/state/core/tool_controls/settings.rs +++ b/src/input/state/core/tool_controls/settings.rs @@ -177,12 +177,6 @@ impl InputState { .unwrap_or_else(|| self.color_for_tool(tool)) } - pub(crate) fn marker_color_for(&self, color: Color) -> Color { - // Keep a minimum alpha so the marker remains visible even if a fully transparent color was set. - let alpha = (color.a * self.marker_opacity).clamp(0.05, 0.9); - Color { a: alpha, ..color } - } - pub(crate) fn begin_pointer_drag(&mut self, button: MouseButton, color: Option) { self.active_drag_button = Some(button); self.active_drag_color = color; diff --git a/src/input/state/interaction/adapters/active_motion.rs b/src/input/state/interaction/adapters/active_motion.rs index d1ae1bbb..69e85225 100644 --- a/src/input/state/interaction/adapters/active_motion.rs +++ b/src/input/state/interaction/adapters/active_motion.rs @@ -4,6 +4,7 @@ use super::super::outcome::{ }; use crate::input::state::mouse::TEXT_CLICK_DRAG_THRESHOLD; use crate::input::state::{DrawingState, InputState}; +use crate::input::tool::{ToolMotionBehavior, ToolMotionSizeSource, ToolPressBehavior}; use crate::input::{EraserMode, MouseButton, Tool}; use std::sync::Arc; @@ -37,13 +38,16 @@ pub(crate) fn handle_active_motion( let dy = canvas.y() - *start_y; if dx.abs() >= TEXT_CLICK_DRAG_THRESHOLD || dy.abs() >= TEXT_CLICK_DRAG_THRESHOLD { let tool = *tool; - if tool != Tool::Highlight && tool != Tool::Select { + if matches!( + tool.press_behavior(), + ToolPressBehavior::StartDrawing { .. } + ) { let drawing_thickness = state.thickness_for_tool(tool); let mut points = vec![(*start_x, *start_y)]; let mut point_thicknesses = vec![drawing_thickness as f32]; - if tool == Tool::Pen || tool == Tool::Marker || tool == Tool::Eraser { + if let Some(sample_size) = motion_sample_size(state, tool) { points.push((canvas.x(), canvas.y())); - point_thicknesses.push(drawing_thickness as f32); + point_thicknesses.push(sample_size as f32); } state.state = DrawingState::Drawing { tool, @@ -121,19 +125,19 @@ pub(crate) fn handle_drawing_or_idle_motion( ) -> RoutingOutcome { let canvas = points.canvas(); let mut drawing = false; + let sample_size = if let DrawingState::Drawing { tool, .. } = &state.state { + motion_sample_size(state, *tool) + } else { + None + }; if let DrawingState::Drawing { - tool, points, point_thicknesses, .. } = &mut state.state { - if *tool == Tool::Pen || *tool == Tool::Marker || *tool == Tool::Eraser { + if let Some(thickness) = sample_size { points.push((canvas.x(), canvas.y())); - let thickness = match *tool { - Tool::Eraser => state.eraser_size, - _ => state.tool_settings.get(*tool).thickness, - }; point_thicknesses.push(thickness as f32); } drawing = true; @@ -175,3 +179,15 @@ pub(crate) fn has_active_drag(state: &InputState) -> bool { pub(crate) fn release_button_matches_active_drag(state: &InputState, button: MouseButton) -> bool { state.pointer_drag_button_matches(button) } + +fn motion_sample_size(state: &InputState, tool: Tool) -> Option { + match tool.motion_behavior() { + ToolMotionBehavior::NoPathAccumulation => None, + ToolMotionBehavior::AccumulatePath { + size_source: ToolMotionSizeSource::ToolSize, + } => Some(state.tool_settings.get(tool).thickness), + ToolMotionBehavior::AccumulatePath { + size_source: ToolMotionSizeSource::EraserSize, + } => Some(state.eraser_size), + } +} diff --git a/src/input/state/mouse/press.rs b/src/input/state/mouse/press.rs index c9383af8..35c2ad89 100644 --- a/src/input/state/mouse/press.rs +++ b/src/input/state/mouse/press.rs @@ -1,5 +1,6 @@ use crate::draw::Shape; use crate::draw::frame::ShapeSnapshot; +use crate::input::tool::ToolPressBehavior; use crate::input::{DragTool, Tool, events::MouseButton}; use std::sync::Arc; @@ -241,7 +242,8 @@ impl InputState { x: i32, y: i32, ) { - let selection_click = self.modifiers.alt || tool == Tool::Select; + let selection_click = + self.modifiers.alt || matches!(tool.press_behavior(), ToolPressBehavior::Selection); let hit_id = self.hit_test_at(x, y); if let Some(shape_id) = self.hit_text_resize_handle(x, y) { @@ -346,23 +348,28 @@ impl InputState { } } - if tool == Tool::Blur && !self.frozen_active() && !self.pending_frozen_toggle() { - self.request_frozen_toggle(); - } - if tool != Tool::Highlight && tool != Tool::Select { - self.sync_current_settings_for_tool(tool); - let drawing_thickness = self.thickness_for_tool(tool); - self.begin_pointer_drag(button, color); - self.state = DrawingState::Drawing { - tool, - start_x: x, - start_y: y, - points: vec![(x, y)], - point_thicknesses: vec![drawing_thickness as f32], - }; - self.last_provisional_bounds = None; - self.update_provisional_dirty(x, y); - self.needs_redraw = true; + match tool.press_behavior() { + ToolPressBehavior::Selection | ToolPressBehavior::HighlightNoop => {} + ToolPressBehavior::StartDrawing { + request_blur_capture, + } => { + if request_blur_capture && !self.frozen_active() && !self.pending_frozen_toggle() { + self.request_frozen_toggle(); + } + self.sync_current_settings_for_tool(tool); + let drawing_thickness = self.thickness_for_tool(tool); + self.begin_pointer_drag(button, color); + self.state = DrawingState::Drawing { + tool, + start_x: x, + start_y: y, + points: vec![(x, y)], + point_thicknesses: vec![drawing_thickness as f32], + }; + self.last_provisional_bounds = None; + self.update_provisional_dirty(x, y); + self.needs_redraw = true; + } } } diff --git a/src/input/state/mouse/release/drawing.rs b/src/input/state/mouse/release/drawing.rs index dc43404e..ac32d776 100644 --- a/src/input/state/mouse/release/drawing.rs +++ b/src/input/state/mouse/release/drawing.rs @@ -1,10 +1,8 @@ use log::warn; -use crate::draw::Shape; use crate::draw::frame::UndoAction; -use crate::draw::shape::EraserBrush; -use crate::input::{EraserMode, InputState, Tool}; -use crate::util; +use crate::input::tool::{FinishedToolStroke, ToolStrokeSnapshot}; +use crate::input::{InputState, Tool}; pub(super) struct DrawingRelease { pub(super) start: (i32, i32), @@ -16,167 +14,37 @@ pub(super) struct DrawingRelease { pub(super) fn finish_drawing(state: &mut InputState, tool: Tool, release: DrawingRelease) { let drawing_color = state.active_drag_color_or_tool(tool); let drawing_thickness = state.thickness_for_tool(tool); - let (start_x, start_y) = release.start; - let (end_x, end_y) = release.end; - let DrawingRelease { - points, - point_thicknesses, - .. - } = release; - let label = if matches!(tool, Tool::Arrow) { - state.next_arrow_label() - } else { - None + let snapshot = ToolStrokeSnapshot { + tool, + start: release.start, + end: release.end, + points: release.points, + point_thicknesses: release.point_thicknesses, + color: drawing_color, + size: drawing_thickness, + marker_opacity: state.marker_opacity, + fill_enabled: state.fill_enabled, + arrow_length: state.arrow_length, + arrow_angle: state.arrow_angle, + arrow_head_at_end: state.arrow_head_at_end, + arrow_label: state.next_arrow_label(), + step_marker_label: state.next_step_marker_label(), + eraser_mode: state.eraser_mode, + eraser_size: state.eraser_size, + eraser_kind: state.eraser_kind, + pressure_variation_threshold: state.pressure_variation_threshold, }; - let used_arrow_label = label.is_some(); - let step_label = if matches!(tool, Tool::StepMarker) { - Some(state.next_step_marker_label()) - } else { - None - }; - let used_step_marker = step_label.is_some(); - let shape = match tool { - Tool::Pen => { - // Check if we have pressure data and if it varies enough to matter - let use_pressure = if point_thicknesses.len() == points.len() { - let min_t = point_thicknesses - .iter() - .fold(f32::INFINITY, |a, &b| a.min(b)); - let max_t = point_thicknesses - .iter() - .fold(f32::NEG_INFINITY, |a, &b| a.max(b)); - (max_t - min_t).abs() > state.pressure_variation_threshold as f32 - } else { - false - }; - - if use_pressure { - let points_with_pressure: Vec<(i32, i32, f32)> = points - .iter() - .zip(point_thicknesses.iter()) - .map(|(&(x, y), &t)| (x, y, t)) - .collect(); - Shape::FreehandPressure { - points: points_with_pressure, - color: drawing_color, - } - } else { - Shape::Freehand { - points, - color: drawing_color, - thick: drawing_thickness, - } - } - } - Tool::Line => Shape::Line { - x1: start_x, - y1: start_y, - x2: end_x, - y2: end_y, - color: drawing_color, - thick: drawing_thickness, - }, - Tool::Rect => { - let (left, width) = if end_x >= start_x { - (start_x, end_x - start_x) - } else { - (end_x, start_x - end_x) - }; - let (top, height) = if end_y >= start_y { - (start_y, end_y - start_y) - } else { - (end_y, start_y - end_y) - }; - Shape::Rect { - x: left, - y: top, - w: width, - h: height, - fill: state.fill_enabled, - color: drawing_color, - thick: drawing_thickness, - } - } - Tool::Ellipse => { - let (cx, cy, rx, ry) = util::ellipse_bounds(start_x, start_y, end_x, end_y); - Shape::Ellipse { - cx, - cy, - rx, - ry, - fill: state.fill_enabled, - color: drawing_color, - thick: drawing_thickness, - } - } - Tool::Arrow => Shape::Arrow { - x1: start_x, - y1: start_y, - x2: end_x, - y2: end_y, - color: drawing_color, - thick: drawing_thickness, - arrow_length: state.arrow_length, - arrow_angle: state.arrow_angle, - head_at_end: state.arrow_head_at_end, - label, - }, - Tool::Blur => { - let (left, width) = if end_x >= start_x { - (start_x, end_x - start_x) - } else { - (end_x, start_x - end_x) - }; - let (top, height) = if end_y >= start_y { - (start_y, end_y - start_y) - } else { - (end_y, start_y - end_y) - }; - Shape::BlurRect { - x: left, - y: top, - w: width, - h: height, - strength: drawing_thickness, - } - } - Tool::Marker => Shape::MarkerStroke { - points, - color: state.marker_color_for(drawing_color), - thick: drawing_thickness, - }, - Tool::StepMarker => Shape::StepMarker { - x: end_x, - y: end_y, - color: drawing_color, - label: step_label.unwrap_or_else(|| state.next_step_marker_label()), - }, - Tool::Eraser => { - if state.eraser_mode == EraserMode::Stroke { - state.clear_provisional_dirty(); - let mut path = points; - if path.last().copied() != Some((end_x, end_y)) { - path.push((end_x, end_y)); - } - if state.erase_strokes_by_points(&path) { - state.mark_session_dirty(); - } - return; - } - Shape::EraserStroke { - points, - brush: EraserBrush { - size: state.eraser_size, - kind: state.eraser_kind, - }, - } - } - Tool::Highlight => { + let (shape, usage) = match tool.finish_stroke(snapshot) { + FinishedToolStroke::Shape { shape, usage } => (shape, usage), + FinishedToolStroke::EraseStroke { path } => { state.clear_provisional_dirty(); + if state.erase_strokes_by_points(&path) { + state.mark_session_dirty(); + } return; } - Tool::Select => { + FinishedToolStroke::Noop => { state.clear_provisional_dirty(); return; } @@ -227,10 +95,10 @@ pub(super) fn finish_drawing(state: &mut InputState, tool: Tool, release: Drawin state.dirty_tracker.mark_full(); state.pending_onboarding_usage.first_stroke_done = true; } - if used_arrow_label { + if usage.bump_arrow_label { state.bump_arrow_label(); } - if used_step_marker { + if usage.bump_step_marker { state.bump_step_marker(); } } else if limit_reached { diff --git a/src/input/state/render.rs b/src/input/state/render.rs index 93196e1f..749376b0 100644 --- a/src/input/state/render.rs +++ b/src/input/state/render.rs @@ -1,115 +1,111 @@ +use crate::draw::render::render_freehand_pressure_borrowed; use crate::draw::{ Color, Shape, render_freehand_borrowed, render_marker_stroke_borrowed, render_shape, }; -use crate::input::tool::Tool; -use crate::util; +use crate::input::Tool; +use crate::input::tool::{ProvisionalToolSnapshot, ProvisionalToolStroke}; use super::{DrawingState, InputState}; impl InputState { - /// Returns the shape currently being drawn for non-freehand tools. - /// - /// # Note - /// This is only for Line, Rect, Ellipse, Arrow tools which don't require - /// cloning. Pen/Marker/Eraser are handled directly in `render_provisional_shape()` - /// using borrowed rendering to avoid per-frame allocations. - fn get_provisional_shape(&self, current_x: i32, current_y: i32) -> Option { - if let DrawingState::Drawing { + pub(crate) fn provisional_tool_stroke( + &self, + current_x: i32, + current_y: i32, + ) -> ProvisionalToolStroke<'_> { + let DrawingState::Drawing { tool, start_x, start_y, - .. + points, + point_thicknesses, } = &self.state - { - let drawing_color = self.active_drag_color_or_current(); - let drawing_thickness = self.thickness_for_tool(*tool); - match tool { - // Pen, Marker, Eraser are handled by render_provisional_shape() directly - // with borrowed rendering - never call this method for those tools. - Tool::Pen | Tool::Marker | Tool::Eraser | Tool::Highlight | Tool::Select => None, - Tool::Line => Some(Shape::Line { - x1: *start_x, - y1: *start_y, - x2: current_x, - y2: current_y, - color: drawing_color, - thick: drawing_thickness, - }), - Tool::Rect => { - // Normalize rectangle to handle dragging in any direction - let (x, w) = if current_x >= *start_x { - (*start_x, current_x - start_x) - } else { - (current_x, start_x - current_x) - }; - let (y, h) = if current_y >= *start_y { - (*start_y, current_y - start_y) - } else { - (current_y, start_y - current_y) - }; - Some(Shape::Rect { - x, - y, - w, - h, - fill: self.fill_enabled, - color: drawing_color, - thick: drawing_thickness, - }) - } - Tool::Ellipse => { - let (cx, cy, rx, ry) = - util::ellipse_bounds(*start_x, *start_y, current_x, current_y); - Some(Shape::Ellipse { - cx, - cy, - rx, - ry, - fill: self.fill_enabled, - color: drawing_color, - thick: drawing_thickness, - }) - } - Tool::Arrow => Some(Shape::Arrow { - x1: *start_x, - y1: *start_y, - x2: current_x, - y2: current_y, - color: drawing_color, - thick: drawing_thickness, - arrow_length: self.arrow_length, - arrow_angle: self.arrow_angle, - head_at_end: self.arrow_head_at_end, - label: self.next_arrow_label(), - }), - Tool::Blur => { - let (x, w) = if current_x >= *start_x { - (*start_x, current_x - start_x) - } else { - (current_x, start_x - current_x) - }; - let (y, h) = if current_y >= *start_y { - (*start_y, current_y - start_y) - } else { - (current_y, start_y - current_y) - }; - Some(Shape::BlurRect { - x, - y, - w, - h, - strength: drawing_thickness, - }) - } - Tool::StepMarker => Some(Shape::StepMarker { - x: current_x, - y: current_y, - color: drawing_color, - label: self.next_step_marker_label(), - }), + else { + return ProvisionalToolStroke::None; + }; + + let snapshot = ProvisionalToolSnapshot { + tool: *tool, + start: (*start_x, *start_y), + current: (current_x, current_y), + points, + point_thicknesses, + color: self.active_drag_color_or_current(), + size: self.thickness_for_tool(*tool), + eraser_size: self.eraser_size, + marker_opacity: self.marker_opacity, + fill_enabled: self.fill_enabled, + arrow_length: self.arrow_length, + arrow_angle: self.arrow_angle, + arrow_head_at_end: self.arrow_head_at_end, + arrow_label: if *tool == Tool::Arrow { + self.next_arrow_label() + } else { + None + }, + step_marker_label: (*tool == Tool::StepMarker).then(|| self.next_step_marker_label()), + }; + tool.provisional_stroke(snapshot) + } + + pub(crate) fn render_provisional_tool_stroke( + &self, + ctx: &cairo::Context, + stroke: ProvisionalToolStroke<'_>, + ) -> bool { + match stroke { + ProvisionalToolStroke::BorrowedFreehand { + points, + color, + size, + } => { + render_freehand_borrowed(ctx, points, color, size); + true + } + ProvisionalToolStroke::BorrowedPressureFreehand { + points, + point_thicknesses, + color, + } => { + render_freehand_pressure_borrowed(ctx, points, point_thicknesses, color); + true } - } else { - None + ProvisionalToolStroke::BorrowedMarker { + points, + color, + size, + } => { + render_marker_stroke_borrowed(ctx, points, color, size); + true + } + ProvisionalToolStroke::EraserPreview { points, size } => { + let preview_color = Color { + r: 1.0, + g: 1.0, + b: 1.0, + a: 0.35, + }; + render_freehand_borrowed(ctx, points, preview_color, size); + true + } + ProvisionalToolStroke::Shape(shape) => { + render_shape(ctx, &shape); + true + } + ProvisionalToolStroke::BlurReplayPreview(params) => { + render_shape( + ctx, + &Shape::BlurRect { + x: params.x, + y: params.y, + w: params.w, + h: params.h, + strength: params.strength, + }, + ); + true + } + ProvisionalToolStroke::None => false, } } @@ -132,70 +128,10 @@ impl InputState { current_y: i32, ) -> bool { match &self.state { - DrawingState::Drawing { - tool, - start_x: _, - start_y: _, - points, - point_thicknesses, - } => match tool { - Tool::Pen => { - let drawing_color = self.active_drag_color_or_current(); - // Render freehand without cloning - just borrow the points - // Check if we have pressure data available for this stroke - let use_pressure = - !point_thicknesses.is_empty() && point_thicknesses.len() == points.len(); - - if use_pressure { - // Pass separate slices to avoid allocation - use crate::draw::render::render_freehand_pressure_borrowed; - render_freehand_pressure_borrowed( - ctx, - points, - point_thicknesses, - drawing_color, - ); - } else { - render_freehand_borrowed( - ctx, - points, - drawing_color, - self.thickness_for_tool(*tool), - ); - } - true - } - Tool::Highlight => false, - Tool::Marker => { - render_marker_stroke_borrowed( - ctx, - points, - self.marker_color_for(self.active_drag_color_or_current()), - self.thickness_for_tool(*tool), - ); - true - } - Tool::Eraser => { - // Visual preview without actually clearing - let preview_color = Color { - r: 1.0, - g: 1.0, - b: 1.0, - a: 0.35, - }; - render_freehand_borrowed(ctx, points, preview_color, self.eraser_size); - true - } - _ => { - // For other tools, use the normal path (no clone needed) - if let Some(shape) = self.get_provisional_shape(current_x, current_y) { - render_shape(ctx, &shape); - true - } else { - false - } - } - }, + DrawingState::Drawing { .. } => { + let stroke = self.provisional_tool_stroke(current_x, current_y); + self.render_provisional_tool_stroke(ctx, stroke) + } DrawingState::Selecting { start_x, start_y, diff --git a/src/input/state/tests/drawing.rs b/src/input/state/tests/drawing.rs index 3ee414c1..5b1ad399 100644 --- a/src/input/state/tests/drawing.rs +++ b/src/input/state/tests/drawing.rs @@ -1,5 +1,6 @@ use super::*; use crate::input::{DragBinding, DragButtonBindings, DragToolBindings}; +use crate::ui::toolbar::ToolbarEvent; fn left_drag_bindings( drag: Tool, @@ -213,6 +214,39 @@ fn toggle_click_highlight_action_changes_state() { assert!(state.needs_redraw); } +#[test] +fn toolbar_select_highlight_syncs_click_highlight_state() { + let mut state = create_test_input_state(); + assert!(!state.highlight_tool_active()); + assert!(!state.click_highlight_enabled()); + + assert!(state.apply_toolbar_event(ToolbarEvent::SelectTool(Tool::Highlight))); + + assert_eq!(state.tool_override(), Some(Tool::Highlight)); + assert!(state.highlight_tool_active()); + assert!(state.click_highlight_enabled()); +} + +#[test] +fn toolbar_select_highlight_sticks_when_highlight_is_active_via_modifier() { + let mut state = create_test_input_state(); + let mut bindings = DragToolBindings::default(); + bindings.left.shift_drag = DragBinding::from_tool(Tool::Highlight); + assert!(state.set_drag_tool_bindings(bindings)); + assert!(state.set_tool_override(Some(Tool::Pen))); + + state.on_key_press(Key::Shift); + assert_eq!(state.active_tool(), Tool::Highlight); + assert_eq!(state.tool_override(), Some(Tool::Pen)); + + assert!(state.apply_toolbar_event(ToolbarEvent::SelectTool(Tool::Highlight))); + state.on_key_release(Key::Shift); + + assert_eq!(state.tool_override(), Some(Tool::Highlight)); + assert_eq!(state.active_tool(), Tool::Highlight); + assert!(state.click_highlight_enabled()); +} + #[test] fn highlight_tool_prevents_drawing() { let mut state = create_test_input_state(); diff --git a/src/input/tool.rs b/src/input/tool.rs deleted file mode 100644 index fc55df86..00000000 --- a/src/input/tool.rs +++ /dev/null @@ -1,446 +0,0 @@ -//! Drawing tool selection. - -use crate::draw::Color; -use serde::{Deserialize, Serialize}; - -/// Drawing tool selection. -/// -/// The active tool determines what shape is created when the user drags the mouse. -/// Drag modifier mappings are configurable via `[drawing]` drag-tool fields. -#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum Tool { - /// Select/cursor tool - interact with UI without drawing - Select, - /// Freehand drawing - follows mouse path (default, no modifiers) - Pen, - /// Straight line - between start and end points (Shift) - Line, - /// Rectangle outline - from corner to corner (Ctrl) - Rect, - /// Ellipse/circle outline - from center outward (Tab) - Ellipse, - /// Arrow with directional head (Ctrl+Shift) - Arrow, - /// Privacy blur rectangle over the captured background - Blur, - /// Semi-transparent marker stroke for highlighting text - Marker, - /// Highlight-only tool (no drawing, emits click highlight) - Highlight, - /// Numbered step marker tool (places auto-incrementing bubbles) - StepMarker, - /// Eraser brush that removes content within its stroke - Eraser, - // Note: Text mode uses DrawingState::TextInput instead of Tool::Text -} - -/// The stored color/thickness slot used by a tool. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ToolSettingsSlot { - Pen, - Line, - Rect, - Ellipse, - Arrow, - Blur, - Marker, - StepMarker, -} - -impl ToolSettingsSlot { - pub(crate) const ALL: [Self; 8] = [ - Self::Pen, - Self::Line, - Self::Rect, - Self::Ellipse, - Self::Arrow, - Self::Blur, - Self::Marker, - Self::StepMarker, - ]; - - pub(crate) fn representative_tool(self) -> Tool { - match self { - Self::Pen => Tool::Pen, - Self::Line => Tool::Line, - Self::Rect => Tool::Rect, - Self::Ellipse => Tool::Ellipse, - Self::Arrow => Tool::Arrow, - Self::Blur => Tool::Blur, - Self::Marker => Tool::Marker, - Self::StepMarker => Tool::StepMarker, - } - } -} - -/// Where a tool's visible size value is stored. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ToolSizeSource { - DrawingThickness, - EraserSize, -} - -/// Side-toolbar control family exposed by a tool. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ToolControlGroup { - None, - Stroke, - Marker, - Eraser, - Shape, - Arrow, - StepMarker, -} - -/// Catalog entry describing the settings and controls for one drawing tool. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) struct ToolProfile { - pub(crate) settings_slot: ToolSettingsSlot, - pub(crate) size_source: ToolSizeSource, - pub(crate) control_group: ToolControlGroup, - pub(crate) needs_color: bool, - pub(crate) thickness_label: &'static str, -} - -impl ToolProfile { - pub(crate) fn needs_thickness_control(self) -> bool { - !matches!(self.control_group, ToolControlGroup::None) - } - - pub(crate) fn show_fill_toggle(self) -> bool { - matches!(self.control_group, ToolControlGroup::Shape) - } - - pub(crate) fn show_arrow_labels(self) -> bool { - matches!(self.control_group, ToolControlGroup::Arrow) - } - - pub(crate) fn show_step_counter(self) -> bool { - matches!(self.control_group, ToolControlGroup::StepMarker) - } - - pub(crate) fn show_eraser_mode(self) -> bool { - matches!(self.control_group, ToolControlGroup::Eraser) - } - - pub(crate) fn show_marker_opacity(self) -> bool { - matches!(self.control_group, ToolControlGroup::Marker) - } -} - -impl Tool { - pub(crate) fn profile(self) -> ToolProfile { - match self { - Self::Select | Self::Highlight => ToolProfile { - settings_slot: ToolSettingsSlot::Pen, - size_source: ToolSizeSource::DrawingThickness, - control_group: ToolControlGroup::None, - needs_color: false, - thickness_label: "", - }, - Self::Pen | Self::Line => ToolProfile { - settings_slot: if self == Self::Pen { - ToolSettingsSlot::Pen - } else { - ToolSettingsSlot::Line - }, - size_source: ToolSizeSource::DrawingThickness, - control_group: ToolControlGroup::Stroke, - needs_color: true, - thickness_label: "Thickness", - }, - Self::Blur => ToolProfile { - settings_slot: ToolSettingsSlot::Blur, - size_source: ToolSizeSource::DrawingThickness, - control_group: ToolControlGroup::Stroke, - needs_color: false, - thickness_label: "Blur", - }, - Self::Marker => ToolProfile { - settings_slot: ToolSettingsSlot::Marker, - size_source: ToolSizeSource::DrawingThickness, - control_group: ToolControlGroup::Marker, - needs_color: true, - thickness_label: "Thickness", - }, - Self::Eraser => ToolProfile { - settings_slot: ToolSettingsSlot::Pen, - size_source: ToolSizeSource::EraserSize, - control_group: ToolControlGroup::Eraser, - needs_color: false, - thickness_label: "Eraser Size", - }, - Self::Rect | Self::Ellipse => ToolProfile { - settings_slot: if self == Self::Rect { - ToolSettingsSlot::Rect - } else { - ToolSettingsSlot::Ellipse - }, - size_source: ToolSizeSource::DrawingThickness, - control_group: ToolControlGroup::Shape, - needs_color: true, - thickness_label: "Thickness", - }, - Self::Arrow => ToolProfile { - settings_slot: ToolSettingsSlot::Arrow, - size_source: ToolSizeSource::DrawingThickness, - control_group: ToolControlGroup::Arrow, - needs_color: true, - thickness_label: "Thickness", - }, - Self::StepMarker => ToolProfile { - settings_slot: ToolSettingsSlot::StepMarker, - size_source: ToolSizeSource::DrawingThickness, - control_group: ToolControlGroup::StepMarker, - needs_color: true, - thickness_label: "Size", - }, - } - } - - pub(crate) fn settings_slot(self) -> ToolSettingsSlot { - self.profile().settings_slot - } - - pub(crate) fn settings_tool(self) -> Tool { - self.settings_slot().representative_tool() - } - - pub(crate) fn uses_eraser_size(self) -> bool { - matches!(self.profile().size_source, ToolSizeSource::EraserSize) - } - - pub(crate) fn uses_drawing_thickness(self) -> bool { - matches!(self.profile().size_source, ToolSizeSource::DrawingThickness) - } - - pub(crate) fn uses_marker_opacity(self) -> bool { - self.profile().show_marker_opacity() - } -} - -/// Color and thickness stored independently for a drawing tool. -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] -pub struct ToolDrawingSettings { - pub color: Color, - pub thickness: f64, -} - -impl ToolDrawingSettings { - pub fn new(color: Color, thickness: f64) -> Self { - Self { color, thickness } - } -} - -/// Independent color/thickness settings for tools that draw with them. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct PerToolDrawingSettings { - pub pen: ToolDrawingSettings, - pub line: ToolDrawingSettings, - pub rect: ToolDrawingSettings, - pub ellipse: ToolDrawingSettings, - pub arrow: ToolDrawingSettings, - pub blur: ToolDrawingSettings, - pub marker: ToolDrawingSettings, - pub step_marker: ToolDrawingSettings, -} - -impl PerToolDrawingSettings { - pub fn new(color: Color, thickness: f64) -> Self { - let settings = ToolDrawingSettings::new(color, thickness); - Self { - pen: settings, - line: settings, - rect: settings, - ellipse: settings, - arrow: settings, - blur: settings, - marker: settings, - step_marker: settings, - } - } - - pub fn settings_tool(tool: Tool) -> Tool { - tool.settings_tool() - } - - pub fn get(&self, tool: Tool) -> &ToolDrawingSettings { - self.get_slot(tool.settings_slot()) - } - - pub fn get_mut(&mut self, tool: Tool) -> &mut ToolDrawingSettings { - self.get_slot_mut(tool.settings_slot()) - } - - pub(crate) fn get_slot(&self, slot: ToolSettingsSlot) -> &ToolDrawingSettings { - match slot { - ToolSettingsSlot::Pen => &self.pen, - ToolSettingsSlot::Line => &self.line, - ToolSettingsSlot::Rect => &self.rect, - ToolSettingsSlot::Ellipse => &self.ellipse, - ToolSettingsSlot::Arrow => &self.arrow, - ToolSettingsSlot::Blur => &self.blur, - ToolSettingsSlot::Marker => &self.marker, - ToolSettingsSlot::StepMarker => &self.step_marker, - } - } - - pub(crate) fn get_slot_mut(&mut self, slot: ToolSettingsSlot) -> &mut ToolDrawingSettings { - match slot { - ToolSettingsSlot::Pen => &mut self.pen, - ToolSettingsSlot::Line => &mut self.line, - ToolSettingsSlot::Rect => &mut self.rect, - ToolSettingsSlot::Ellipse => &mut self.ellipse, - ToolSettingsSlot::Arrow => &mut self.arrow, - ToolSettingsSlot::Blur => &mut self.blur, - ToolSettingsSlot::Marker => &mut self.marker, - ToolSettingsSlot::StepMarker => &mut self.step_marker, - } - } - - pub fn clamp_thicknesses(mut self, min: f64, max: f64) -> Self { - for slot in ToolSettingsSlot::ALL { - let settings = self.get_slot_mut(slot); - settings.thickness = settings.thickness.clamp(min, max); - } - self - } -} - -/// Tool/action selected by a drag binding. -/// -/// `Default` preserves a mouse button's built-in behavior, such as right-click -/// context menus or middle-click radial menu toggles. -#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "kebab-case")] -pub enum DragTool { - /// Preserve the button's built-in behavior. - Default, - /// Select/cursor tool. - Select, - /// Freehand drawing. - Pen, - /// Straight line. - Line, - /// Rectangle outline. - Rect, - /// Ellipse/circle outline. - Ellipse, - /// Arrow with directional head. - Arrow, - /// Privacy blur rectangle. - Blur, - /// Semi-transparent marker stroke. - Marker, - /// Highlight-only tool. - Highlight, - /// Numbered step marker tool. - StepMarker, - /// Eraser brush. - Eraser, -} - -impl DragTool { - pub fn from_tool(tool: Tool) -> Self { - match tool { - Tool::Select => Self::Select, - Tool::Pen => Self::Pen, - Tool::Line => Self::Line, - Tool::Rect => Self::Rect, - Tool::Ellipse => Self::Ellipse, - Tool::Arrow => Self::Arrow, - Tool::Blur => Self::Blur, - Tool::Marker => Self::Marker, - Tool::Highlight => Self::Highlight, - Tool::StepMarker => Self::StepMarker, - Tool::Eraser => Self::Eraser, - } - } - - pub fn as_tool(self) -> Option { - match self { - Self::Default => None, - Self::Select => Some(Tool::Select), - Self::Pen => Some(Tool::Pen), - Self::Line => Some(Tool::Line), - Self::Rect => Some(Tool::Rect), - Self::Ellipse => Some(Tool::Ellipse), - Self::Arrow => Some(Tool::Arrow), - Self::Blur => Some(Tool::Blur), - Self::Marker => Some(Tool::Marker), - Self::Highlight => Some(Tool::Highlight), - Self::StepMarker => Some(Tool::StepMarker), - Self::Eraser => Some(Tool::Eraser), - } - } -} - -/// Eraser behavior mode. -#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] -#[serde(rename_all = "kebab-case")] -pub enum EraserMode { - /// Brush-style eraser that clears pixels along its stroke. - #[default] - Brush, - /// Stroke eraser that deletes any shape it touches. - Stroke, -} - -#[cfg(test)] -mod tests { - use super::*; - - fn color(r: f64) -> Color { - Color { - r, - g: 0.0, - b: 0.0, - a: 1.0, - } - } - - #[test] - fn tool_profile_maps_compatibility_tools_to_pen_settings() { - assert_eq!(Tool::Select.settings_slot(), ToolSettingsSlot::Pen); - assert_eq!(Tool::Highlight.settings_slot(), ToolSettingsSlot::Pen); - assert_eq!(Tool::Eraser.settings_slot(), ToolSettingsSlot::Pen); - assert_eq!( - Tool::Eraser.profile().size_source, - ToolSizeSource::EraserSize - ); - - for slot in ToolSettingsSlot::ALL { - assert_eq!(slot.representative_tool().settings_slot(), slot); - } - } - - #[test] - fn tool_profile_describes_toolbar_control_groups() { - assert!(!Tool::Select.profile().needs_thickness_control()); - assert_eq!(Tool::Blur.profile().thickness_label, "Blur"); - assert!(Tool::Marker.profile().show_marker_opacity()); - assert!(Tool::Eraser.profile().show_eraser_mode()); - assert!(Tool::Rect.profile().show_fill_toggle()); - assert!(Tool::Arrow.profile().show_arrow_labels()); - assert!(Tool::StepMarker.profile().show_step_counter()); - } - - #[test] - fn per_tool_settings_read_and_write_through_catalog_slot() { - let mut settings = PerToolDrawingSettings::new(color(1.0), 4.0); - settings.marker = ToolDrawingSettings::new(color(0.5), 12.0); - - assert_eq!(settings.get(Tool::Eraser), &settings.pen); - assert_eq!(settings.get(Tool::Marker), &settings.marker); - - settings.get_mut(Tool::Highlight).thickness = 8.0; - settings.get_mut(Tool::Marker).thickness = 16.0; - - assert_eq!(settings.pen.thickness, 8.0); - assert_eq!(settings.marker.thickness, 16.0); - } -} diff --git a/src/input/tool/catalog.rs b/src/input/tool/catalog.rs new file mode 100644 index 00000000..64db6faa --- /dev/null +++ b/src/input/tool/catalog.rs @@ -0,0 +1,387 @@ +use crate::config::Action; + +use super::{DragTool, Tool, ToolControlGroup, ToolProfile, ToolSettingsSlot, ToolSizeSource}; + +/// Static catalog facts for one built-in drawing tool. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ToolDescriptor { + pub(crate) tool: Tool, + pub(crate) short_label: &'static str, + pub(crate) display_label: &'static str, + pub(crate) action: Option, + pub(crate) drag_tool: DragTool, + pub(crate) profile: ToolProfile, + pub(crate) press: ToolPressBehavior, + pub(crate) motion: ToolMotionBehavior, + pub(crate) drawing: ToolDrawingBehavior, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ToolPressBehavior { + Selection, + HighlightNoop, + StartDrawing { request_blur_capture: bool }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ToolMotionBehavior { + NoPathAccumulation, + AccumulatePath { size_source: ToolMotionSizeSource }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ToolMotionSizeSource { + ToolSize, + EraserSize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ToolDrawingBehavior { + None, + Path { + kind: ToolPathKind, + pressure: ToolPressureBehavior, + }, + Line, + Rect, + Ellipse, + Arrow, + BlurRect, + StepMarker, + Eraser, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ToolPathKind { + Freehand, + Marker, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ToolPressureBehavior { + None, + OptionalPressureStroke, +} + +const fn profile( + settings_slot: ToolSettingsSlot, + size_source: ToolSizeSource, + control_group: ToolControlGroup, + needs_color: bool, + thickness_label: &'static str, +) -> ToolProfile { + ToolProfile { + settings_slot, + size_source, + control_group, + needs_color, + thickness_label, + } +} + +const DESCRIPTORS: [ToolDescriptor; 11] = [ + ToolDescriptor { + tool: Tool::Select, + short_label: "Select", + display_label: "Selection Tool", + action: Some(Action::SelectSelectionTool), + drag_tool: DragTool::Select, + profile: profile( + ToolSettingsSlot::Pen, + ToolSizeSource::DrawingThickness, + ToolControlGroup::None, + false, + "", + ), + press: ToolPressBehavior::Selection, + motion: ToolMotionBehavior::NoPathAccumulation, + drawing: ToolDrawingBehavior::None, + }, + ToolDescriptor { + tool: Tool::Pen, + short_label: "Pen", + display_label: "Pen Tool", + action: Some(Action::SelectPenTool), + drag_tool: DragTool::Pen, + profile: profile( + ToolSettingsSlot::Pen, + ToolSizeSource::DrawingThickness, + ToolControlGroup::Stroke, + true, + "Thickness", + ), + press: ToolPressBehavior::StartDrawing { + request_blur_capture: false, + }, + motion: ToolMotionBehavior::AccumulatePath { + size_source: ToolMotionSizeSource::ToolSize, + }, + drawing: ToolDrawingBehavior::Path { + kind: ToolPathKind::Freehand, + pressure: ToolPressureBehavior::OptionalPressureStroke, + }, + }, + ToolDescriptor { + tool: Tool::Line, + short_label: "Line", + display_label: "Line Tool", + action: Some(Action::SelectLineTool), + drag_tool: DragTool::Line, + profile: profile( + ToolSettingsSlot::Line, + ToolSizeSource::DrawingThickness, + ToolControlGroup::Stroke, + true, + "Thickness", + ), + press: ToolPressBehavior::StartDrawing { + request_blur_capture: false, + }, + motion: ToolMotionBehavior::NoPathAccumulation, + drawing: ToolDrawingBehavior::Line, + }, + ToolDescriptor { + tool: Tool::Rect, + short_label: "Rect", + display_label: "Rectangle Tool", + action: Some(Action::SelectRectTool), + drag_tool: DragTool::Rect, + profile: profile( + ToolSettingsSlot::Rect, + ToolSizeSource::DrawingThickness, + ToolControlGroup::Shape, + true, + "Thickness", + ), + press: ToolPressBehavior::StartDrawing { + request_blur_capture: false, + }, + motion: ToolMotionBehavior::NoPathAccumulation, + drawing: ToolDrawingBehavior::Rect, + }, + ToolDescriptor { + tool: Tool::Ellipse, + short_label: "Circle", + display_label: "Ellipse Tool", + action: Some(Action::SelectEllipseTool), + drag_tool: DragTool::Ellipse, + profile: profile( + ToolSettingsSlot::Ellipse, + ToolSizeSource::DrawingThickness, + ToolControlGroup::Shape, + true, + "Thickness", + ), + press: ToolPressBehavior::StartDrawing { + request_blur_capture: false, + }, + motion: ToolMotionBehavior::NoPathAccumulation, + drawing: ToolDrawingBehavior::Ellipse, + }, + ToolDescriptor { + tool: Tool::Arrow, + short_label: "Arrow", + display_label: "Arrow Tool", + action: Some(Action::SelectArrowTool), + drag_tool: DragTool::Arrow, + profile: profile( + ToolSettingsSlot::Arrow, + ToolSizeSource::DrawingThickness, + ToolControlGroup::Arrow, + true, + "Thickness", + ), + press: ToolPressBehavior::StartDrawing { + request_blur_capture: false, + }, + motion: ToolMotionBehavior::NoPathAccumulation, + drawing: ToolDrawingBehavior::Arrow, + }, + ToolDescriptor { + tool: Tool::Blur, + short_label: "Blur", + display_label: "Blur Tool", + action: Some(Action::SelectBlurTool), + drag_tool: DragTool::Blur, + profile: profile( + ToolSettingsSlot::Blur, + ToolSizeSource::DrawingThickness, + ToolControlGroup::Stroke, + false, + "Blur", + ), + press: ToolPressBehavior::StartDrawing { + request_blur_capture: true, + }, + motion: ToolMotionBehavior::NoPathAccumulation, + drawing: ToolDrawingBehavior::BlurRect, + }, + ToolDescriptor { + tool: Tool::Marker, + short_label: "Marker", + display_label: "Marker Tool", + action: Some(Action::SelectMarkerTool), + drag_tool: DragTool::Marker, + profile: profile( + ToolSettingsSlot::Marker, + ToolSizeSource::DrawingThickness, + ToolControlGroup::Marker, + true, + "Thickness", + ), + press: ToolPressBehavior::StartDrawing { + request_blur_capture: false, + }, + motion: ToolMotionBehavior::AccumulatePath { + size_source: ToolMotionSizeSource::ToolSize, + }, + drawing: ToolDrawingBehavior::Path { + kind: ToolPathKind::Marker, + pressure: ToolPressureBehavior::None, + }, + }, + ToolDescriptor { + tool: Tool::Highlight, + short_label: "Highlight", + display_label: "Highlight Tool", + action: Some(Action::SelectHighlightTool), + drag_tool: DragTool::Highlight, + profile: profile( + ToolSettingsSlot::Pen, + ToolSizeSource::DrawingThickness, + ToolControlGroup::None, + false, + "", + ), + press: ToolPressBehavior::HighlightNoop, + motion: ToolMotionBehavior::NoPathAccumulation, + drawing: ToolDrawingBehavior::None, + }, + ToolDescriptor { + tool: Tool::StepMarker, + short_label: "Steps", + display_label: "Step Marker Tool", + action: Some(Action::SelectStepMarkerTool), + drag_tool: DragTool::StepMarker, + profile: profile( + ToolSettingsSlot::StepMarker, + ToolSizeSource::DrawingThickness, + ToolControlGroup::StepMarker, + true, + "Size", + ), + press: ToolPressBehavior::StartDrawing { + request_blur_capture: false, + }, + motion: ToolMotionBehavior::NoPathAccumulation, + drawing: ToolDrawingBehavior::StepMarker, + }, + ToolDescriptor { + tool: Tool::Eraser, + short_label: "Eraser", + display_label: "Eraser Tool", + action: Some(Action::SelectEraserTool), + drag_tool: DragTool::Eraser, + profile: profile( + ToolSettingsSlot::Pen, + ToolSizeSource::EraserSize, + ToolControlGroup::Eraser, + false, + "Eraser Size", + ), + press: ToolPressBehavior::StartDrawing { + request_blur_capture: false, + }, + motion: ToolMotionBehavior::AccumulatePath { + size_source: ToolMotionSizeSource::EraserSize, + }, + drawing: ToolDrawingBehavior::Eraser, + }, +]; + +impl Tool { + pub(crate) const ALL: [Self; 11] = [ + Self::Select, + Self::Pen, + Self::Line, + Self::Rect, + Self::Ellipse, + Self::Arrow, + Self::Blur, + Self::Marker, + Self::Highlight, + Self::StepMarker, + Self::Eraser, + ]; + + pub(crate) fn descriptor(self) -> &'static ToolDescriptor { + match self { + Self::Select => &DESCRIPTORS[0], + Self::Pen => &DESCRIPTORS[1], + Self::Line => &DESCRIPTORS[2], + Self::Rect => &DESCRIPTORS[3], + Self::Ellipse => &DESCRIPTORS[4], + Self::Arrow => &DESCRIPTORS[5], + Self::Blur => &DESCRIPTORS[6], + Self::Marker => &DESCRIPTORS[7], + Self::Highlight => &DESCRIPTORS[8], + Self::StepMarker => &DESCRIPTORS[9], + Self::Eraser => &DESCRIPTORS[10], + } + } + + pub(crate) fn profile(self) -> ToolProfile { + self.descriptor().profile + } + + pub(crate) fn action(self) -> Option { + self.descriptor().action + } + + pub(crate) fn from_select_action(action: Action) -> Option { + Self::ALL + .iter() + .copied() + .find(|tool| tool.action() == Some(action)) + } + + pub(crate) fn short_label(self) -> &'static str { + self.descriptor().short_label + } + + pub(crate) fn display_label(self) -> &'static str { + self.descriptor().display_label + } + + pub(crate) fn press_behavior(self) -> ToolPressBehavior { + self.descriptor().press + } + + pub(crate) fn motion_behavior(self) -> ToolMotionBehavior { + self.descriptor().motion + } + + pub(crate) fn drawing_behavior(self) -> ToolDrawingBehavior { + self.descriptor().drawing + } + + pub(crate) fn settings_slot(self) -> ToolSettingsSlot { + self.profile().settings_slot + } + + pub(crate) fn settings_tool(self) -> Tool { + self.settings_slot().representative_tool() + } + + pub(crate) fn uses_eraser_size(self) -> bool { + matches!(self.profile().size_source, ToolSizeSource::EraserSize) + } + + pub(crate) fn uses_drawing_thickness(self) -> bool { + matches!(self.profile().size_source, ToolSizeSource::DrawingThickness) + } + + pub(crate) fn uses_marker_opacity(self) -> bool { + self.profile().show_marker_opacity() + } +} diff --git a/src/input/tool/drag.rs b/src/input/tool/drag.rs new file mode 100644 index 00000000..3217900a --- /dev/null +++ b/src/input/tool/drag.rs @@ -0,0 +1,53 @@ +use serde::{Deserialize, Serialize}; + +use super::Tool; + +/// Tool/action selected by a drag binding. +/// +/// `Default` preserves a mouse button's built-in behavior, such as right-click +/// context menus or middle-click radial menu toggles. +#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum DragTool { + /// Preserve the button's built-in behavior. + Default, + /// Select/cursor tool. + Select, + /// Freehand drawing. + Pen, + /// Straight line. + Line, + /// Rectangle outline. + Rect, + /// Ellipse/circle outline. + Ellipse, + /// Arrow with directional head. + Arrow, + /// Privacy blur rectangle. + Blur, + /// Semi-transparent marker stroke. + Marker, + /// Highlight-only tool. + Highlight, + /// Numbered step marker tool. + StepMarker, + /// Eraser brush. + Eraser, +} + +impl DragTool { + pub fn from_tool(tool: Tool) -> Self { + tool.descriptor().drag_tool + } + + pub fn as_tool(self) -> Option { + if self == Self::Default { + return None; + } + Tool::ALL + .iter() + .copied() + .find(|tool| tool.descriptor().drag_tool == self) + } +} diff --git a/src/input/tool/drawing.rs b/src/input/tool/drawing.rs new file mode 100644 index 00000000..69481489 --- /dev/null +++ b/src/input/tool/drawing.rs @@ -0,0 +1,432 @@ +use crate::draw::shape::{bounding_box_for_blur, bounding_box_for_eraser, bounding_box_for_points}; +use crate::draw::{ArrowLabel, BlurRectParams, Color, EraserBrush, EraserKind, Shape}; +use crate::input::tool::{ + EraserMode, Tool, ToolDrawingBehavior, ToolPathKind, ToolPressureBehavior, +}; +use crate::util::{self, Rect}; + +/// Immutable inputs needed to turn one completed drag into an app-level outcome. +pub(crate) struct ToolStrokeSnapshot { + pub(crate) tool: Tool, + pub(crate) start: (i32, i32), + pub(crate) end: (i32, i32), + pub(crate) points: Vec<(i32, i32)>, + pub(crate) point_thicknesses: Vec, + pub(crate) color: Color, + pub(crate) size: f64, + pub(crate) marker_opacity: f64, + pub(crate) fill_enabled: bool, + pub(crate) arrow_length: f64, + pub(crate) arrow_angle: f64, + pub(crate) arrow_head_at_end: bool, + pub(crate) arrow_label: Option, + pub(crate) step_marker_label: crate::draw::StepMarkerLabel, + pub(crate) eraser_mode: EraserMode, + pub(crate) eraser_size: f64, + pub(crate) eraser_kind: EraserKind, + pub(crate) pressure_variation_threshold: f64, +} + +pub(crate) enum FinishedToolStroke { + Shape { shape: Shape, usage: ToolUsage }, + EraseStroke { path: Vec<(i32, i32)> }, + Noop, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) struct ToolUsage { + pub(crate) bump_arrow_label: bool, + pub(crate) bump_step_marker: bool, +} + +/// Borrowed inputs needed to classify and render the current live preview. +pub(crate) struct ProvisionalToolSnapshot<'a> { + pub(crate) tool: Tool, + pub(crate) start: (i32, i32), + pub(crate) current: (i32, i32), + pub(crate) points: &'a [(i32, i32)], + pub(crate) point_thicknesses: &'a [f32], + pub(crate) color: Color, + pub(crate) size: f64, + pub(crate) eraser_size: f64, + pub(crate) marker_opacity: f64, + pub(crate) fill_enabled: bool, + pub(crate) arrow_length: f64, + pub(crate) arrow_angle: f64, + pub(crate) arrow_head_at_end: bool, + pub(crate) arrow_label: Option, + pub(crate) step_marker_label: Option, +} + +pub(crate) enum ProvisionalToolStroke<'a> { + BorrowedFreehand { + points: &'a [(i32, i32)], + color: Color, + size: f64, + }, + BorrowedPressureFreehand { + points: &'a [(i32, i32)], + point_thicknesses: &'a [f32], + color: Color, + }, + BorrowedMarker { + points: &'a [(i32, i32)], + color: Color, + size: f64, + }, + EraserPreview { + points: &'a [(i32, i32)], + size: f64, + }, + Shape(Shape), + BlurReplayPreview(BlurRectParams), + None, +} + +impl Tool { + pub(crate) fn finish_stroke(self, snapshot: ToolStrokeSnapshot) -> FinishedToolStroke { + debug_assert_eq!(self, snapshot.tool); + let usage = ToolUsage::default(); + match self.drawing_behavior() { + ToolDrawingBehavior::None => FinishedToolStroke::Noop, + ToolDrawingBehavior::Path { kind, pressure } => { + finish_path_stroke(snapshot, kind, pressure, usage) + } + ToolDrawingBehavior::Line => finish_shape(snapshot, usage, |snapshot| Shape::Line { + x1: snapshot.start.0, + y1: snapshot.start.1, + x2: snapshot.end.0, + y2: snapshot.end.1, + color: snapshot.color, + thick: snapshot.size, + }), + ToolDrawingBehavior::Rect => finish_shape(snapshot, usage, |snapshot| { + let (x, w) = normalized_axis(snapshot.start.0, snapshot.end.0); + let (y, h) = normalized_axis(snapshot.start.1, snapshot.end.1); + Shape::Rect { + x, + y, + w, + h, + fill: snapshot.fill_enabled, + color: snapshot.color, + thick: snapshot.size, + } + }), + ToolDrawingBehavior::Ellipse => finish_shape(snapshot, usage, |snapshot| { + let (cx, cy, rx, ry) = util::ellipse_bounds( + snapshot.start.0, + snapshot.start.1, + snapshot.end.0, + snapshot.end.1, + ); + Shape::Ellipse { + cx, + cy, + rx, + ry, + fill: snapshot.fill_enabled, + color: snapshot.color, + thick: snapshot.size, + } + }), + ToolDrawingBehavior::Arrow => { + let usage = ToolUsage { + bump_arrow_label: snapshot.arrow_label.is_some(), + ..usage + }; + finish_shape(snapshot, usage, |snapshot| Shape::Arrow { + x1: snapshot.start.0, + y1: snapshot.start.1, + x2: snapshot.end.0, + y2: snapshot.end.1, + color: snapshot.color, + thick: snapshot.size, + arrow_length: snapshot.arrow_length, + arrow_angle: snapshot.arrow_angle, + head_at_end: snapshot.arrow_head_at_end, + label: snapshot.arrow_label, + }) + } + ToolDrawingBehavior::BlurRect => finish_shape(snapshot, usage, |snapshot| { + let (x, w) = normalized_axis(snapshot.start.0, snapshot.end.0); + let (y, h) = normalized_axis(snapshot.start.1, snapshot.end.1); + Shape::BlurRect { + x, + y, + w, + h, + strength: snapshot.size, + } + }), + ToolDrawingBehavior::StepMarker => { + let usage = ToolUsage { + bump_step_marker: true, + ..usage + }; + finish_shape(snapshot, usage, |snapshot| Shape::StepMarker { + x: snapshot.end.0, + y: snapshot.end.1, + color: snapshot.color, + label: snapshot.step_marker_label, + }) + } + ToolDrawingBehavior::Eraser => finish_eraser(snapshot), + } + } + + pub(crate) fn provisional_stroke<'a>( + self, + snapshot: ProvisionalToolSnapshot<'a>, + ) -> ProvisionalToolStroke<'a> { + debug_assert_eq!(self, snapshot.tool); + match self.drawing_behavior() { + ToolDrawingBehavior::None => ProvisionalToolStroke::None, + ToolDrawingBehavior::Path { + kind: ToolPathKind::Freehand, + pressure: ToolPressureBehavior::OptionalPressureStroke, + } => { + if !snapshot.point_thicknesses.is_empty() + && snapshot.point_thicknesses.len() == snapshot.points.len() + { + ProvisionalToolStroke::BorrowedPressureFreehand { + points: snapshot.points, + point_thicknesses: snapshot.point_thicknesses, + color: snapshot.color, + } + } else { + ProvisionalToolStroke::BorrowedFreehand { + points: snapshot.points, + color: snapshot.color, + size: snapshot.size, + } + } + } + ToolDrawingBehavior::Path { + kind: ToolPathKind::Freehand, + pressure: ToolPressureBehavior::None, + } => ProvisionalToolStroke::BorrowedFreehand { + points: snapshot.points, + color: snapshot.color, + size: snapshot.size, + }, + ToolDrawingBehavior::Path { + kind: ToolPathKind::Marker, + .. + } => ProvisionalToolStroke::BorrowedMarker { + points: snapshot.points, + color: marker_color_with_opacity(snapshot.color, snapshot.marker_opacity), + size: snapshot.size, + }, + ToolDrawingBehavior::Line => ProvisionalToolStroke::Shape(Shape::Line { + x1: snapshot.start.0, + y1: snapshot.start.1, + x2: snapshot.current.0, + y2: snapshot.current.1, + color: snapshot.color, + thick: snapshot.size, + }), + ToolDrawingBehavior::Rect => { + let (x, w) = normalized_axis(snapshot.start.0, snapshot.current.0); + let (y, h) = normalized_axis(snapshot.start.1, snapshot.current.1); + ProvisionalToolStroke::Shape(Shape::Rect { + x, + y, + w, + h, + fill: snapshot.fill_enabled, + color: snapshot.color, + thick: snapshot.size, + }) + } + ToolDrawingBehavior::Ellipse => { + let (cx, cy, rx, ry) = util::ellipse_bounds( + snapshot.start.0, + snapshot.start.1, + snapshot.current.0, + snapshot.current.1, + ); + ProvisionalToolStroke::Shape(Shape::Ellipse { + cx, + cy, + rx, + ry, + fill: snapshot.fill_enabled, + color: snapshot.color, + thick: snapshot.size, + }) + } + ToolDrawingBehavior::Arrow => ProvisionalToolStroke::Shape(Shape::Arrow { + x1: snapshot.start.0, + y1: snapshot.start.1, + x2: snapshot.current.0, + y2: snapshot.current.1, + color: snapshot.color, + thick: snapshot.size, + arrow_length: snapshot.arrow_length, + arrow_angle: snapshot.arrow_angle, + head_at_end: snapshot.arrow_head_at_end, + label: snapshot.arrow_label, + }), + ToolDrawingBehavior::BlurRect => { + let (x, w) = normalized_axis(snapshot.start.0, snapshot.current.0); + let (y, h) = normalized_axis(snapshot.start.1, snapshot.current.1); + ProvisionalToolStroke::BlurReplayPreview(BlurRectParams { + x, + y, + w, + h, + strength: snapshot.size, + cacheable: false, + }) + } + ToolDrawingBehavior::StepMarker => ProvisionalToolStroke::Shape(Shape::StepMarker { + x: snapshot.current.0, + y: snapshot.current.1, + color: snapshot.color, + label: match snapshot.step_marker_label { + Some(label) => label, + None => return ProvisionalToolStroke::None, + }, + }), + ToolDrawingBehavior::Eraser => ProvisionalToolStroke::EraserPreview { + points: snapshot.points, + size: snapshot.eraser_size, + }, + } + } +} + +impl<'a> ProvisionalToolStroke<'a> { + pub(crate) fn bounds(&self) -> Option { + match self { + Self::BorrowedFreehand { points, size, .. } => bounding_box_for_points(points, *size), + Self::BorrowedPressureFreehand { + points, + point_thicknesses, + .. + } => { + let max_thick = point_thicknesses.iter().fold(0.0f32, |a, &b| a.max(b)) as f64; + bounding_box_for_points(points, max_thick) + } + Self::BorrowedMarker { points, size, .. } => { + let inflated = (*size * 1.35).max(*size + 1.0); + bounding_box_for_points(points, inflated) + } + Self::EraserPreview { points, size } => bounding_box_for_eraser(points, *size), + Self::Shape(shape) => shape.bounding_box(), + Self::BlurReplayPreview(params) => { + bounding_box_for_blur(params.x, params.y, params.w, params.h) + } + Self::None => None, + } + } +} + +pub(crate) fn marker_color_with_opacity(color: Color, marker_opacity: f64) -> Color { + let alpha = (color.a * marker_opacity).clamp(0.05, 0.9); + Color { a: alpha, ..color } +} + +fn finish_path_stroke( + snapshot: ToolStrokeSnapshot, + kind: ToolPathKind, + pressure: ToolPressureBehavior, + usage: ToolUsage, +) -> FinishedToolStroke { + match kind { + ToolPathKind::Freehand => { + if matches!(pressure, ToolPressureBehavior::OptionalPressureStroke) + && pressure_data_varies( + &snapshot.point_thicknesses, + snapshot.points.len(), + snapshot.pressure_variation_threshold, + ) + { + let points = snapshot + .points + .into_iter() + .zip(snapshot.point_thicknesses) + .map(|((x, y), t)| (x, y, t)) + .collect(); + return FinishedToolStroke::Shape { + shape: Shape::FreehandPressure { + points, + color: snapshot.color, + }, + usage, + }; + } + + FinishedToolStroke::Shape { + shape: Shape::Freehand { + points: snapshot.points, + color: snapshot.color, + thick: snapshot.size, + }, + usage, + } + } + ToolPathKind::Marker => FinishedToolStroke::Shape { + shape: Shape::MarkerStroke { + points: snapshot.points, + color: marker_color_with_opacity(snapshot.color, snapshot.marker_opacity), + thick: snapshot.size, + }, + usage, + }, + } +} + +fn finish_eraser(snapshot: ToolStrokeSnapshot) -> FinishedToolStroke { + if snapshot.eraser_mode == EraserMode::Stroke { + let mut path = snapshot.points; + if path.last().copied() != Some(snapshot.end) { + path.push(snapshot.end); + } + return FinishedToolStroke::EraseStroke { path }; + } + + FinishedToolStroke::Shape { + shape: Shape::EraserStroke { + points: snapshot.points, + brush: EraserBrush { + size: snapshot.eraser_size, + kind: snapshot.eraser_kind, + }, + }, + usage: ToolUsage::default(), + } +} + +fn finish_shape( + snapshot: ToolStrokeSnapshot, + usage: ToolUsage, + shape_builder: impl FnOnce(ToolStrokeSnapshot) -> Shape, +) -> FinishedToolStroke { + FinishedToolStroke::Shape { + shape: shape_builder(snapshot), + usage, + } +} + +fn pressure_data_varies(point_thicknesses: &[f32], point_count: usize, threshold: f64) -> bool { + if point_thicknesses.len() != point_count { + return false; + } + let min_t = point_thicknesses + .iter() + .fold(f32::INFINITY, |a, &b| a.min(b)); + let max_t = point_thicknesses + .iter() + .fold(f32::NEG_INFINITY, |a, &b| a.max(b)); + (max_t - min_t).abs() > threshold as f32 +} + +fn normalized_axis(start: i32, end: i32) -> (i32, i32) { + if end >= start { + (start, end - start) + } else { + (end, start - end) + } +} diff --git a/src/input/tool/kind.rs b/src/input/tool/kind.rs new file mode 100644 index 00000000..1a98284b --- /dev/null +++ b/src/input/tool/kind.rs @@ -0,0 +1,34 @@ +use serde::{Deserialize, Serialize}; + +/// Drawing tool selection. +/// +/// The active tool determines what shape is created when the user drags the mouse. +/// Drag modifier mappings are configurable via `[drawing]` drag-tool fields. +#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum Tool { + /// Select/cursor tool - interact with UI without drawing + Select, + /// Freehand drawing - follows mouse path (default, no modifiers) + Pen, + /// Straight line - between start and end points (Shift) + Line, + /// Rectangle outline - from corner to corner (Ctrl) + Rect, + /// Ellipse/circle outline - from center outward (Tab) + Ellipse, + /// Arrow with directional head (Ctrl+Shift) + Arrow, + /// Privacy blur rectangle over the captured background + Blur, + /// Semi-transparent marker stroke for highlighting text + Marker, + /// Highlight-only tool (no drawing, emits click highlight) + Highlight, + /// Numbered step marker tool (places auto-incrementing bubbles) + StepMarker, + /// Eraser brush that removes content within its stroke + Eraser, + // Note: Text mode uses DrawingState::TextInput instead of Tool::Text +} diff --git a/src/input/tool/mod.rs b/src/input/tool/mod.rs new file mode 100644 index 00000000..753f73a1 --- /dev/null +++ b/src/input/tool/mod.rs @@ -0,0 +1,33 @@ +//! Drawing tool selection and catalog metadata. + +mod catalog; +mod drag; +mod drawing; +mod kind; +mod profile; +mod settings; + +#[cfg(test)] +mod tests; + +#[expect( + unused_imports, + reason = "Tool::descriptor exposes this crate-visible catalog interface" +)] +pub(crate) use catalog::ToolDescriptor; +pub(crate) use catalog::{ + ToolDrawingBehavior, ToolMotionBehavior, ToolMotionSizeSource, ToolPathKind, ToolPressBehavior, + ToolPressureBehavior, +}; +pub use drag::DragTool; +#[expect( + unused_imports, + reason = "FinishedToolStroke exposes usage metadata to crate callers" +)] +pub(crate) use drawing::ToolUsage; +pub(crate) use drawing::{ + FinishedToolStroke, ProvisionalToolSnapshot, ProvisionalToolStroke, ToolStrokeSnapshot, +}; +pub use kind::Tool; +pub(crate) use profile::{ToolControlGroup, ToolProfile, ToolSettingsSlot, ToolSizeSource}; +pub use settings::{EraserMode, PerToolDrawingSettings, ToolDrawingSettings}; diff --git a/src/input/tool/profile.rs b/src/input/tool/profile.rs new file mode 100644 index 00000000..5b19bc81 --- /dev/null +++ b/src/input/tool/profile.rs @@ -0,0 +1,95 @@ +use super::Tool; + +/// The stored color/thickness slot used by a tool. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ToolSettingsSlot { + Pen, + Line, + Rect, + Ellipse, + Arrow, + Blur, + Marker, + StepMarker, +} + +impl ToolSettingsSlot { + pub(crate) const ALL: [Self; 8] = [ + Self::Pen, + Self::Line, + Self::Rect, + Self::Ellipse, + Self::Arrow, + Self::Blur, + Self::Marker, + Self::StepMarker, + ]; + + pub(crate) fn representative_tool(self) -> Tool { + match self { + Self::Pen => Tool::Pen, + Self::Line => Tool::Line, + Self::Rect => Tool::Rect, + Self::Ellipse => Tool::Ellipse, + Self::Arrow => Tool::Arrow, + Self::Blur => Tool::Blur, + Self::Marker => Tool::Marker, + Self::StepMarker => Tool::StepMarker, + } + } +} + +/// Where a tool's visible size value is stored. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ToolSizeSource { + DrawingThickness, + EraserSize, +} + +/// Side-toolbar control family exposed by a tool. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ToolControlGroup { + None, + Stroke, + Marker, + Eraser, + Shape, + Arrow, + StepMarker, +} + +/// Catalog entry describing the settings and controls for one drawing tool. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) struct ToolProfile { + pub(crate) settings_slot: ToolSettingsSlot, + pub(crate) size_source: ToolSizeSource, + pub(crate) control_group: ToolControlGroup, + pub(crate) needs_color: bool, + pub(crate) thickness_label: &'static str, +} + +impl ToolProfile { + pub(crate) fn needs_thickness_control(self) -> bool { + !matches!(self.control_group, ToolControlGroup::None) + } + + pub(crate) fn show_fill_toggle(self) -> bool { + matches!(self.control_group, ToolControlGroup::Shape) + } + + pub(crate) fn show_arrow_labels(self) -> bool { + matches!(self.control_group, ToolControlGroup::Arrow) + } + + pub(crate) fn show_step_counter(self) -> bool { + matches!(self.control_group, ToolControlGroup::StepMarker) + } + + pub(crate) fn show_eraser_mode(self) -> bool { + matches!(self.control_group, ToolControlGroup::Eraser) + } + + pub(crate) fn show_marker_opacity(self) -> bool { + matches!(self.control_group, ToolControlGroup::Marker) + } +} diff --git a/src/input/tool/settings.rs b/src/input/tool/settings.rs new file mode 100644 index 00000000..0d85d029 --- /dev/null +++ b/src/input/tool/settings.rs @@ -0,0 +1,104 @@ +use crate::draw::Color; +use serde::{Deserialize, Serialize}; + +use super::{Tool, ToolSettingsSlot}; + +/// Color and thickness stored independently for a drawing tool. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] +pub struct ToolDrawingSettings { + pub color: Color, + pub thickness: f64, +} + +impl ToolDrawingSettings { + pub fn new(color: Color, thickness: f64) -> Self { + Self { color, thickness } + } +} + +/// Independent color/thickness settings for tools that draw with them. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PerToolDrawingSettings { + pub pen: ToolDrawingSettings, + pub line: ToolDrawingSettings, + pub rect: ToolDrawingSettings, + pub ellipse: ToolDrawingSettings, + pub arrow: ToolDrawingSettings, + pub blur: ToolDrawingSettings, + pub marker: ToolDrawingSettings, + pub step_marker: ToolDrawingSettings, +} + +impl PerToolDrawingSettings { + pub fn new(color: Color, thickness: f64) -> Self { + let settings = ToolDrawingSettings::new(color, thickness); + Self { + pen: settings, + line: settings, + rect: settings, + ellipse: settings, + arrow: settings, + blur: settings, + marker: settings, + step_marker: settings, + } + } + + pub fn settings_tool(tool: Tool) -> Tool { + tool.settings_tool() + } + + pub fn get(&self, tool: Tool) -> &ToolDrawingSettings { + self.get_slot(tool.settings_slot()) + } + + pub fn get_mut(&mut self, tool: Tool) -> &mut ToolDrawingSettings { + self.get_slot_mut(tool.settings_slot()) + } + + pub(crate) fn get_slot(&self, slot: ToolSettingsSlot) -> &ToolDrawingSettings { + match slot { + ToolSettingsSlot::Pen => &self.pen, + ToolSettingsSlot::Line => &self.line, + ToolSettingsSlot::Rect => &self.rect, + ToolSettingsSlot::Ellipse => &self.ellipse, + ToolSettingsSlot::Arrow => &self.arrow, + ToolSettingsSlot::Blur => &self.blur, + ToolSettingsSlot::Marker => &self.marker, + ToolSettingsSlot::StepMarker => &self.step_marker, + } + } + + pub(crate) fn get_slot_mut(&mut self, slot: ToolSettingsSlot) -> &mut ToolDrawingSettings { + match slot { + ToolSettingsSlot::Pen => &mut self.pen, + ToolSettingsSlot::Line => &mut self.line, + ToolSettingsSlot::Rect => &mut self.rect, + ToolSettingsSlot::Ellipse => &mut self.ellipse, + ToolSettingsSlot::Arrow => &mut self.arrow, + ToolSettingsSlot::Blur => &mut self.blur, + ToolSettingsSlot::Marker => &mut self.marker, + ToolSettingsSlot::StepMarker => &mut self.step_marker, + } + } + + pub fn clamp_thicknesses(mut self, min: f64, max: f64) -> Self { + for slot in ToolSettingsSlot::ALL { + let settings = self.get_slot_mut(slot); + settings.thickness = settings.thickness.clamp(min, max); + } + self + } +} + +/// Eraser behavior mode. +#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "kebab-case")] +pub enum EraserMode { + /// Brush-style eraser that clears pixels along its stroke. + #[default] + Brush, + /// Stroke eraser that deletes any shape it touches. + Stroke, +} diff --git a/src/input/tool/tests.rs b/src/input/tool/tests.rs new file mode 100644 index 00000000..65479969 --- /dev/null +++ b/src/input/tool/tests.rs @@ -0,0 +1,131 @@ +use super::drawing::marker_color_with_opacity; +use super::*; +use crate::config::Action; +use crate::draw::Color; +use std::collections::HashSet; + +fn color(r: f64) -> Color { + Color { + r, + g: 0.0, + b: 0.0, + a: 1.0, + } +} + +#[test] +fn descriptor_table_covers_every_tool_once() { + let mut seen = HashSet::new(); + for tool in Tool::ALL { + assert_eq!(tool.descriptor().tool, tool); + assert!(seen.insert(tool)); + } + assert_eq!(seen.len(), Tool::ALL.len()); +} + +#[test] +fn tool_profile_maps_compatibility_tools_to_pen_settings() { + assert_eq!(Tool::Select.settings_slot(), ToolSettingsSlot::Pen); + assert_eq!(Tool::Highlight.settings_slot(), ToolSettingsSlot::Pen); + assert_eq!(Tool::Eraser.settings_slot(), ToolSettingsSlot::Pen); + assert_eq!( + Tool::Eraser.profile().size_source, + ToolSizeSource::EraserSize + ); + + for slot in ToolSettingsSlot::ALL { + assert_eq!(slot.representative_tool().settings_slot(), slot); + } +} + +#[test] +fn tool_profile_describes_toolbar_control_groups() { + assert!(!Tool::Select.profile().needs_thickness_control()); + assert_eq!(Tool::Blur.profile().thickness_label, "Blur"); + assert!(Tool::Marker.profile().show_marker_opacity()); + assert!(Tool::Eraser.profile().show_eraser_mode()); + assert!(Tool::Rect.profile().show_fill_toggle()); + assert!(Tool::Arrow.profile().show_arrow_labels()); + assert!(Tool::StepMarker.profile().show_step_counter()); +} + +#[test] +fn per_tool_settings_read_and_write_through_catalog_slot() { + let mut settings = PerToolDrawingSettings::new(color(1.0), 4.0); + settings.marker = ToolDrawingSettings::new(color(0.5), 12.0); + + assert_eq!(settings.get(Tool::Eraser), &settings.pen); + assert_eq!(settings.get(Tool::Marker), &settings.marker); + + settings.get_mut(Tool::Highlight).thickness = 8.0; + settings.get_mut(Tool::Marker).thickness = 16.0; + + assert_eq!(settings.pen.thickness, 8.0); + assert_eq!(settings.marker.thickness, 16.0); +} + +#[test] +fn selectable_tools_expose_actions_from_catalog() { + assert_eq!(Tool::Select.action(), Some(Action::SelectSelectionTool)); + assert_eq!(Tool::Pen.action(), Some(Action::SelectPenTool)); + assert_eq!(Tool::Highlight.action(), Some(Action::SelectHighlightTool)); + assert_eq!( + Tool::from_select_action(Action::SelectEraserTool), + Some(Tool::Eraser) + ); + assert_eq!(Tool::from_select_action(Action::ToggleEraserMode), None); +} + +#[test] +fn drag_tools_round_trip_through_descriptor_table() { + for tool in Tool::ALL { + let drag_tool = DragTool::from_tool(tool); + assert_ne!(drag_tool, DragTool::Default); + assert_eq!(drag_tool.as_tool(), Some(tool)); + } + assert_eq!(DragTool::Default.as_tool(), None); +} + +#[test] +fn descriptor_exposes_press_motion_and_drawing_behavior() { + assert_eq!(Tool::Select.press_behavior(), ToolPressBehavior::Selection); + assert_eq!( + Tool::Highlight.press_behavior(), + ToolPressBehavior::HighlightNoop + ); + assert_eq!( + Tool::Blur.press_behavior(), + ToolPressBehavior::StartDrawing { + request_blur_capture: true + } + ); + assert!(matches!( + Tool::Pen.motion_behavior(), + ToolMotionBehavior::AccumulatePath { + size_source: ToolMotionSizeSource::ToolSize + } + )); + assert!(matches!( + Tool::Eraser.motion_behavior(), + ToolMotionBehavior::AccumulatePath { + size_source: ToolMotionSizeSource::EraserSize + } + )); + assert_eq!( + Tool::Line.motion_behavior(), + ToolMotionBehavior::NoPathAccumulation + ); + assert!(matches!( + Tool::Marker.drawing_behavior(), + ToolDrawingBehavior::Path { + kind: ToolPathKind::Marker, + pressure: ToolPressureBehavior::None + } + )); +} + +#[test] +fn marker_opacity_helper_preserves_current_alpha_clamp() { + assert_eq!(marker_color_with_opacity(color(1.0), 0.0).a, 0.05); + assert_eq!(marker_color_with_opacity(color(1.0), 2.0).a, 0.9); +} diff --git a/src/ui/toolbar/apply/tools.rs b/src/ui/toolbar/apply/tools.rs index 397023b2..1391680e 100644 --- a/src/ui/toolbar/apply/tools.rs +++ b/src/ui/toolbar/apply/tools.rs @@ -7,7 +7,17 @@ impl InputState { if matches!(self.state, DrawingState::TextInput { .. }) { self.cancel_text_input(); } - let mut changed = self.set_tool_override(Some(tool)); + let mut changed = if tool == Tool::Highlight { + let was_highlight_active = self.highlight_tool_active(); + let was_click_highlight_enabled = self.click_highlight_enabled(); + self.set_highlight_tool(true); + let override_changed = self.set_tool_override(Some(tool)); + override_changed + || was_highlight_active != self.highlight_tool_active() + || was_click_highlight_enabled != self.click_highlight_enabled() + } else { + self.set_tool_override(Some(tool)) + }; if self.toolbar_layout_mode == ToolbarLayoutMode::Simple && self.toolbar_shapes_expanded { self.toolbar_shapes_expanded = false; changed = true; diff --git a/src/ui/toolbar/bindings.rs b/src/ui/toolbar/bindings.rs index 520f7e16..41b07c2a 100644 --- a/src/ui/toolbar/bindings.rs +++ b/src/ui/toolbar/bindings.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use crate::config::{Action, action_label, action_meta_iter, action_short_label}; +use crate::config::{Action, action_meta_iter}; use crate::input::{InputState, Tool}; use crate::label_format::join_binding_labels; @@ -8,7 +8,6 @@ use super::events::{ ToolbarEvent, action_for_apply_preset as event_action_for_apply_preset, action_for_clear_preset as event_action_for_clear_preset, action_for_save_preset as event_action_for_save_preset, - action_for_tool as event_action_for_tool, }; #[derive(Debug, Clone, PartialEq, Default)] @@ -54,19 +53,17 @@ impl ToolbarBindingHints { } pub(crate) fn action_for_tool(tool: Tool) -> Option { - event_action_for_tool(tool) + tool.action() } #[allow(dead_code)] pub(crate) fn tool_label(tool: Tool) -> &'static str { - action_for_tool(tool) - .map(action_short_label) - .unwrap_or("Select") + tool.short_label() } #[allow(dead_code)] pub(crate) fn tool_tooltip_label(tool: Tool) -> &'static str { - action_for_tool(tool).map(action_label).unwrap_or("Select") + tool.display_label() } pub(crate) fn action_for_event(event: &ToolbarEvent) -> Option { @@ -210,6 +207,8 @@ mod tests { #[test] fn tool_label_and_tooltip_label_use_action_metadata() { + use crate::config::{action_label, action_short_label}; + assert_eq!( tool_label(Tool::Ellipse), action_short_label(Action::SelectEllipseTool) diff --git a/src/ui/toolbar/events.rs b/src/ui/toolbar/events.rs index 977279b3..a1d1d8d6 100644 --- a/src/ui/toolbar/events.rs +++ b/src/ui/toolbar/events.rs @@ -166,10 +166,6 @@ impl ToolbarEvent { } } -pub(crate) fn action_for_tool(tool: Tool) -> Option { - super::model::action_for_tool(tool) -} - pub(crate) fn action_for_apply_preset(slot: usize) -> Option { super::model::action_for_apply_preset(slot) } diff --git a/src/ui/toolbar/model/event_policy.rs b/src/ui/toolbar/model/event_policy.rs index e471ef72..08c0a2b1 100644 --- a/src/ui/toolbar/model/event_policy.rs +++ b/src/ui/toolbar/model/event_policy.rs @@ -129,19 +129,7 @@ pub(crate) fn tooltip_label_for_event( } pub(crate) fn action_for_tool(tool: Tool) -> Option { - match tool { - Tool::Select => Some(Action::SelectSelectionTool), - Tool::Pen => Some(Action::SelectPenTool), - Tool::Line => Some(Action::SelectLineTool), - Tool::Rect => Some(Action::SelectRectTool), - Tool::Ellipse => Some(Action::SelectEllipseTool), - Tool::Arrow => Some(Action::SelectArrowTool), - Tool::Blur => Some(Action::SelectBlurTool), - Tool::Marker => Some(Action::SelectMarkerTool), - Tool::StepMarker => Some(Action::SelectStepMarkerTool), - Tool::Highlight => Some(Action::SelectHighlightTool), - Tool::Eraser => Some(Action::SelectEraserTool), - } + tool.action() } pub(crate) fn action_for_apply_preset(slot: usize) -> Option { @@ -207,9 +195,9 @@ fn persistence_for_event(event: &ToolbarEvent) -> ToolbarPersistence { ToolbarEvent::ToggleFloatingBadgeAlways(_) => { ToolbarPersistence::Persist(Ui(FloatingBadgeAlways)) } - ToolbarEvent::ToggleAllHighlight(_) | ToolbarEvent::ToggleHighlightToolRing(_) => { - ToolbarPersistence::Persist(ClickHighlight) - } + ToolbarEvent::SelectTool(Tool::Highlight) + | ToolbarEvent::ToggleAllHighlight(_) + | ToolbarEvent::ToggleHighlightToolRing(_) => ToolbarPersistence::Persist(ClickHighlight), _ => ToolbarPersistence::RuntimeOnly, } } diff --git a/src/ui/toolbar/model/mod.rs b/src/ui/toolbar/model/mod.rs index a00fc9af..1c09209d 100644 --- a/src/ui/toolbar/model/mod.rs +++ b/src/ui/toolbar/model/mod.rs @@ -4,6 +4,7 @@ pub(crate) mod control; pub(crate) mod event_policy; pub(crate) mod header; pub(crate) mod settings; +pub(crate) mod tools; #[allow(unused_imports)] pub(crate) use actions::{ @@ -32,6 +33,11 @@ pub(crate) use event_policy::{ pub(crate) use header::{SideHeaderModel, board_chip_label}; #[allow(unused_imports)] pub(crate) use settings::{ToolbarSettingsButton, ToolbarSettingsModel, ToolbarSettingsToggle}; +#[allow(unused_imports)] +pub(crate) use tools::{ + SemanticToolIcon, current_shape_tool, default_drag_hint, default_shape_tool, fill_tool_active, + is_fill_tool, semantic_icon_for_tool, shape_tools, top_tool_buttons, +}; #[cfg(test)] mod tests { diff --git a/src/ui/toolbar/model/tools.rs b/src/ui/toolbar/model/tools.rs new file mode 100644 index 00000000..a4223c96 --- /dev/null +++ b/src/ui/toolbar/model/tools.rs @@ -0,0 +1,105 @@ +use crate::input::Tool; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SemanticToolIcon { + Select, + Pen, + Line, + Rect, + Circle, + Arrow, + Blur, + Marker, + Highlight, + StepMarker, + Eraser, +} + +const SIMPLE_TOOL_BUTTONS: [Tool; 5] = [ + Tool::Select, + Tool::Pen, + Tool::Marker, + Tool::StepMarker, + Tool::Eraser, +]; + +const FULL_TOOL_BUTTONS: [Tool; 10] = [ + Tool::Select, + Tool::Pen, + Tool::Marker, + Tool::StepMarker, + Tool::Eraser, + Tool::Line, + Tool::Rect, + Tool::Ellipse, + Tool::Arrow, + Tool::Blur, +]; + +const SHAPE_TOOLS: [Tool; 5] = [ + Tool::Line, + Tool::Rect, + Tool::Ellipse, + Tool::Arrow, + Tool::Blur, +]; + +pub(crate) fn top_tool_buttons(simple: bool) -> &'static [Tool] { + if simple { + &SIMPLE_TOOL_BUTTONS + } else { + &FULL_TOOL_BUTTONS + } +} + +pub(crate) fn shape_tools() -> &'static [Tool] { + &SHAPE_TOOLS +} + +pub(crate) fn semantic_icon_for_tool(tool: Tool) -> SemanticToolIcon { + match tool { + Tool::Select => SemanticToolIcon::Select, + Tool::Pen => SemanticToolIcon::Pen, + Tool::Line => SemanticToolIcon::Line, + Tool::Rect => SemanticToolIcon::Rect, + Tool::Ellipse => SemanticToolIcon::Circle, + Tool::Arrow => SemanticToolIcon::Arrow, + Tool::Blur => SemanticToolIcon::Blur, + Tool::Marker => SemanticToolIcon::Marker, + Tool::Highlight => SemanticToolIcon::Highlight, + Tool::StepMarker => SemanticToolIcon::StepMarker, + Tool::Eraser => SemanticToolIcon::Eraser, + } +} + +pub(crate) fn default_drag_hint(tool: Tool) -> Option<&'static str> { + match tool { + Tool::Line => Some("Shift+Drag"), + Tool::Rect => Some("Ctrl+Drag"), + Tool::Ellipse => Some("Tab+Drag"), + Tool::Arrow => Some("Ctrl+Shift+Drag"), + _ => None, + } +} + +pub(crate) fn is_shape_tool(tool: Tool) -> bool { + shape_tools().contains(&tool) +} + +pub(crate) fn is_fill_tool(tool: Tool) -> bool { + matches!(tool, Tool::Rect | Tool::Ellipse) +} + +pub(crate) fn fill_tool_active(active_tool: Tool, tool_override: Option) -> bool { + tool_override.is_some_and(is_fill_tool) || is_fill_tool(active_tool) +} + +pub(crate) fn current_shape_tool(active_tool: Tool, tool_override: Option) -> Option { + tool_override + .filter(|tool| is_shape_tool(*tool)) + .or_else(|| is_shape_tool(active_tool).then_some(active_tool)) +} + +pub(crate) fn default_shape_tool() -> Tool { + Tool::Rect +} From 4fdf194babf916b5035e5e2b5bbbd8b63df26b2a Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Tue, 19 May 2026 19:49:30 +0200 Subject: [PATCH 2/2] fix: isolate ellipse render paths --- src/draw/render/primitives.rs | 44 +++++++++++++++++++++++++++++++++++ tests/ui.rs | 44 ++++++++++++++++++++++++++++++++++- 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/src/draw/render/primitives.rs b/src/draw/render/primitives.rs index e00200cb..eb5d64b9 100644 --- a/src/draw/render/primitives.rs +++ b/src/draw/render/primitives.rs @@ -81,6 +81,7 @@ pub(super) fn render_ellipse( ctx.save().ok(); ctx.translate(cx as f64, cy as f64); ctx.scale(rx as f64, ry as f64); + ctx.new_sub_path(); ctx.arc(0.0, 0.0, 1.0, 0.0, 2.0 * std::f64::consts::PI); if fill { let _ = ctx.save(); @@ -154,3 +155,46 @@ pub(super) fn render_arrow( let _ = ctx.fill(); ctx.restore().ok(); } + +#[cfg(test)] +mod tests { + use super::*; + use cairo::{Context, ImageSurface}; + + fn surface_with_context(width: i32, height: i32) -> (ImageSurface, Context) { + let surface = ImageSurface::create(cairo::Format::ARgb32, width, height).unwrap(); + let ctx = Context::new(&surface).unwrap(); + (surface, ctx) + } + + fn alpha_at(surface: &mut ImageSurface, x: i32, y: i32) -> u8 { + let stride = surface.stride() as usize; + let offset = y as usize * stride + x as usize * 4 + 3; + surface.data().unwrap()[offset] + } + + #[test] + fn ellipse_does_not_connect_to_existing_current_path() { + let (mut surface, ctx) = surface_with_context(120, 120); + let magenta = Color { + r: 1.0, + g: 0.0, + b: 1.0, + a: 1.0, + }; + + ctx.move_to(10.0, 90.0); + render_ellipse(&ctx, 80, 20, 20, 10, false, magenta, 6.0); + + drop(ctx); + assert_eq!( + alpha_at(&mut surface, 48, 60), + 0, + "ellipse rendering must not stroke a line from a prior current point" + ); + assert!( + alpha_at(&mut surface, 100, 20) > 0, + "ellipse stroke should still render" + ); + } +} diff --git a/tests/ui.rs b/tests/ui.rs index 64a7ce65..51647649 100644 --- a/tests/ui.rs +++ b/tests/ui.rs @@ -2,7 +2,7 @@ use cairo::{Context, ImageSurface}; use wayscriber::config::{ HelpOverlayStyle, KeybindingsConfig, PresenterModeConfig, StatusBarStyle, StatusPosition, }; -use wayscriber::draw::Color; +use wayscriber::draw::{Color, Shape}; use wayscriber::input::{ BOARD_ID_BLACKBOARD, BOARD_ID_WHITEBOARD, ClickHighlightSettings, EraserMode, InputState, }; @@ -60,6 +60,12 @@ fn surface_has_pixels(surface: &mut ImageSurface) -> bool { .unwrap_or(false) } +fn alpha_at(surface: &mut ImageSurface, x: i32, y: i32) -> u8 { + let stride = surface.stride() as usize; + let offset = y as usize * stride + x as usize * 4 + 3; + surface.data().unwrap()[offset] +} + #[test] fn render_status_bar_draws_for_all_positions() { let mut input = make_input_state(); @@ -144,6 +150,42 @@ fn render_frozen_badge_draws_pixels() { assert!(surface_has_pixels(&mut surface)); } +#[test] +fn render_shape_ellipse_does_not_connect_to_existing_current_path() { + let (mut surface, ctx) = surface_with_context(120, 120); + let magenta = Color { + r: 1.0, + g: 0.0, + b: 1.0, + a: 1.0, + }; + + ctx.move_to(10.0, 90.0); + wayscriber::draw::render_shape( + &ctx, + &Shape::Ellipse { + cx: 80, + cy: 20, + rx: 20, + ry: 10, + fill: false, + color: magenta, + thick: 6.0, + }, + ); + + drop(ctx); + assert_eq!( + alpha_at(&mut surface, 48, 60), + 0, + "ellipse rendering must not connect to a path left by prior drawing" + ); + assert!( + alpha_at(&mut surface, 100, 20) > 0, + "ellipse stroke should still render" + ); +} + #[test] fn render_onboarding_card_tiny_surface_does_not_panic() { let (mut surface, ctx) = surface_with_context(200, 40);