diff --git a/Cargo.lock b/Cargo.lock index d7c1c12d2..5136bdacf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1842,7 +1842,7 @@ dependencies = [ [[package]] name = "wayscriber" -version = "0.9.25" +version = "0.9.26" dependencies = [ "anyhow", "cairo-rs", @@ -1879,7 +1879,7 @@ dependencies = [ [[package]] name = "wayscriber-configurator" -version = "0.9.25" +version = "0.9.26" dependencies = [ "gtk4", "libadwaita", diff --git a/Cargo.toml b/Cargo.toml index 015052750..35f5580b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wayscriber" -version = "0.9.25" +version = "0.9.26" edition = "2024" rust-version = "1.98.1" description = "Screen annotation tool for Wayland compositors" diff --git a/README.md b/README.md index 9063e1d8b..9fd5916c1 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,7 @@ The v0.9.23+ prebuilt `wayscriber` packages require glibc 2.39 and GTK 4.12. See ### Drawing and editing - Freehand pen, highlighter, eraser (circle/rect) +- Shape pen: draw naturally and confident strokes preview and become editable lines, circles, ovals, or rectangles; other strokes remain freehand. Choose **Live Shaping Tool** in the command palette or the toolbar's Shapes picker. Adjust recognition with `[drawing] shape_recognition_sensitivity` (0-4) or the configurator's Drawing page. - Pen smoothing: finished pen and marker strokes are cleaned up on release, so the live line never lags the cursor (`[drawing] pen_smoothing`, 0-6, or the toolbar's **Smoothing** stepper); tablet pressure values are preserved, and the level is remembered with the session - Shapes: lines, rectangles, ellipses, polygons (with fill toggle) - Arrows in four styles - standard, pointy, curved (drag its handle to route around what is in the way), and double-ended - with optional auto-numbered labels; step markers for walkthroughs diff --git a/config.example.toml b/config.example.toml index b8782dce5..4d7b587ee 100644 --- a/config.example.toml +++ b/config.example.toml @@ -115,6 +115,7 @@ decrease_marker_opacity = ["Ctrl+Alt+ArrowDown"] # Tool selection shortcuts (optional; keep empty to rely on modifiers) select_selection_tool = ["V"] select_pen_tool = ["F"] +select_live_shape_tool = [] select_marker_tool = ["H"] select_step_marker_tool = [] select_eraser_tool = ["D"] @@ -696,6 +697,7 @@ shown = [] top_tools = [ "top.tool.select", "top.tool.pen", + "top.tool.live-shape", "top.tool.marker", "top.tool.step-marker", "top.tool.eraser", @@ -1116,6 +1118,11 @@ font_cycle = ["Sans", "Monospace", "Serif"] # A session remembers the level it was saved at; this is the starting value. pen_smoothing = 3 +# Shape Pen recognition sensitivity (0 - 4): 0 is precise, 2 is balanced, +# and 4 accepts rougher lines and closed shapes. Applies to lines, ellipses, +# and rectangles; it does not change the dedicated shape tools or grid spacing. +shape_recognition_sensitivity = 2 + # Default fill state for fill-capable shapes default_fill_enabled = false diff --git a/configurator/Cargo.toml b/configurator/Cargo.toml index d5009d88d..90543ede9 100644 --- a/configurator/Cargo.toml +++ b/configurator/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wayscriber-configurator" -version = "0.9.25" +version = "0.9.26" edition = "2024" rust-version = "1.98.1" diff --git a/configurator/src/app/pages/drawing/defaults.rs b/configurator/src/app/pages/drawing/defaults.rs index 654b612ae..60c379226 100644 --- a/configurator/src/app/pages/drawing/defaults.rs +++ b/configurator/src/app/pages/drawing/defaults.rs @@ -54,6 +54,18 @@ pub(super) fn build(page: &mut PageBuilder) { |value| Message::TextChanged(TextField::DrawingPenSmoothing, value), |app| validate_usize_range(&app.draft.drawing_pen_smoothing, 0, 6), ) + .entry_row_validated( + "Shape Pen sensitivity (0-4)", + |app| app.draft.drawing_shape_recognition_sensitivity.clone(), + |value| Message::TextChanged(TextField::DrawingShapeRecognitionSensitivity, value), + |app| { + validate_usize_range( + &app.draft.drawing_shape_recognition_sensitivity, + 0, + usize::from(wayscriber::config::MAX_SHAPE_RECOGNITION_SENSITIVITY), + ) + }, + ) .entry_row_validated( "Undo stack limit", |app| app.draft.drawing_undo_stack_limit.clone(), diff --git a/configurator/src/app/search/terms.rs b/configurator/src/app/search/terms.rs index 38bcfaaeb..de1fd7cfc 100644 --- a/configurator/src/app/search/terms.rs +++ b/configurator/src/app/search/terms.rs @@ -99,6 +99,9 @@ pub(super) const DRAWING_DEFAULT_TERMS: &[&str] = &[ "polygon", "polygon sides", "shape", + "shape pen sensitivity", + "shape recognition", + "recognition sensitivity", "marker", "marker opacity", "fill", diff --git a/configurator/src/app/search/tests.rs b/configurator/src/app/search/tests.rs index 8bd7887f2..1e4869573 100644 --- a/configurator/src/app/search/tests.rs +++ b/configurator/src/app/search/tests.rs @@ -115,6 +115,8 @@ fn exact_drawing_default_labels_match_defaults_section() { for query in [ "font size pt", "eraser size px", + "shape pen sensitivity", + "shape recognition", "enable text background", "enable text halo", ] { diff --git a/configurator/src/models/config/draft/from_config.rs b/configurator/src/models/config/draft/from_config.rs index 1f28920d6..9315f4ae6 100644 --- a/configurator/src/models/config/draft/from_config.rs +++ b/configurator/src/models/config/draft/from_config.rs @@ -74,6 +74,10 @@ impl ConfigDraft { drawing_polygon_sides: config.drawing.polygon_sides.to_string(), drawing_marker_opacity: format_float(config.drawing.marker_opacity), drawing_pen_smoothing: config.drawing.pen_smoothing.to_string(), + drawing_shape_recognition_sensitivity: config + .drawing + .shape_recognition_sensitivity + .to_string(), drawing_font_cycle: FontCycleDraft::from_entries(config.drawing.font_cycle.clone()), drawing_hit_test_tolerance: format_float(config.drawing.hit_test_tolerance), drawing_hit_test_linear_threshold: config.drawing.hit_test_linear_threshold.to_string(), diff --git a/configurator/src/models/config/draft/mod.rs b/configurator/src/models/config/draft/mod.rs index 2d8109cd2..b054ae53b 100644 --- a/configurator/src/models/config/draft/mod.rs +++ b/configurator/src/models/config/draft/mod.rs @@ -42,6 +42,7 @@ pub struct ConfigDraft { pub drawing_polygon_sides: String, pub drawing_marker_opacity: String, pub drawing_pen_smoothing: String, + pub drawing_shape_recognition_sensitivity: String, /// Families `Shift+T` steps through, in order. A list rather than a /// comma-separated line: a family name can contain a comma, and the row /// editor picks from what is installed instead of asking for exact text. diff --git a/configurator/src/models/config/setters.rs b/configurator/src/models/config/setters.rs index 12e3cad65..947fd252e 100644 --- a/configurator/src/models/config/setters.rs +++ b/configurator/src/models/config/setters.rs @@ -331,6 +331,9 @@ impl ConfigDraft { TextField::DrawingPolygonSides => self.drawing_polygon_sides = value, TextField::DrawingMarkerOpacity => self.drawing_marker_opacity = value, TextField::DrawingPenSmoothing => self.drawing_pen_smoothing = value, + TextField::DrawingShapeRecognitionSensitivity => { + self.drawing_shape_recognition_sensitivity = value + } TextField::DrawingFontFamily => self.drawing_font_family = value, TextField::DrawingFontWeight => { self.drawing_font_weight = value; diff --git a/configurator/src/models/config/tests.rs b/configurator/src/models/config/tests.rs index dc2108ac6..65a17dfde 100644 --- a/configurator/src/models/config/tests.rs +++ b/configurator/src/models/config/tests.rs @@ -1,5 +1,27 @@ use super::super::color::ColorInput; +#[test] +fn shape_pen_sensitivity_round_trips_and_rejects_invalid_drafts() { + let mut config = Config::default(); + config.drawing.shape_recognition_sensitivity = 3; + let mut draft = ConfigDraft::from_config(&config); + assert_eq!(draft.drawing_shape_recognition_sensitivity, "3"); + + draft.drawing_shape_recognition_sensitivity = "4".to_string(); + assert_eq!( + draft + .to_config(&config) + .expect("valid sensitivity") + .drawing + .shape_recognition_sensitivity, + 4 + ); + + draft.drawing_shape_recognition_sensitivity = "5".to_string(); + assert!(draft.to_config(&config).is_err()); + assert_eq!(draft.drawing_shape_recognition_sensitivity, "5"); +} + #[test] fn board_grid_draft_preserves_patterns_and_rejects_invalid_spacing() { let mut config = Config::default(); diff --git a/configurator/src/models/config/to_config/drawing.rs b/configurator/src/models/config/to_config/drawing.rs index de1645e7a..8267bedb3 100644 --- a/configurator/src/models/config/to_config/drawing.rs +++ b/configurator/src/models/config/to_config/drawing.rs @@ -4,6 +4,7 @@ use super::super::parse::{ }; use crate::models::error::FormError; use wayscriber::config::Config; +use wayscriber::config::MAX_SHAPE_RECOGNITION_SENSITIVITY; use wayscriber::domain::{DragBindableTool, DragTool}; use wayscriber::domain::{MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS}; use wayscriber::draw::{MAX_PEN_SMOOTHING, REGULAR_POLYGON_MAX_SIDES, REGULAR_POLYGON_MIN_SIDES}; @@ -59,6 +60,14 @@ impl ConfigDraft { errors, |value| config.drawing.pen_smoothing = value, ); + parse_u8_in_range( + &self.drawing_shape_recognition_sensitivity, + "drawing.shape_recognition_sensitivity", + 0, + MAX_SHAPE_RECOGNITION_SENSITIVITY, + errors, + |value| config.drawing.shape_recognition_sensitivity = value, + ); parse_field_in_range( &self.drawing_marker_opacity, "drawing.marker_opacity", diff --git a/configurator/src/models/fields/toggles.rs b/configurator/src/models/fields/toggles.rs index e61b0f9cb..085e5654f 100644 --- a/configurator/src/models/fields/toggles.rs +++ b/configurator/src/models/fields/toggles.rs @@ -109,6 +109,7 @@ pub enum TextField { DrawingPolygonSides, DrawingMarkerOpacity, DrawingPenSmoothing, + DrawingShapeRecognitionSensitivity, DrawingFontFamily, DrawingFontWeight, DrawingFontStyle, diff --git a/configurator/src/models/fields/tool.rs b/configurator/src/models/fields/tool.rs index c1e736626..55a2c77f8 100644 --- a/configurator/src/models/fields/tool.rs +++ b/configurator/src/models/fields/tool.rs @@ -43,6 +43,7 @@ impl DragToolField { pub enum ToolOption { Select, Pen, + LiveShape, Line, Rect, Ellipse, @@ -65,6 +66,7 @@ impl ToolOption { vec![ Self::Select, Self::Pen, + Self::LiveShape, Self::Line, Self::Rect, Self::Ellipse, @@ -87,6 +89,7 @@ impl ToolOption { match self { Self::Select => "Select", Self::Pen => "Pen", + Self::LiveShape => "Shape pen", Self::Line => "Line", Self::Rect => "Rectangle", Self::Ellipse => "Ellipse", @@ -109,6 +112,7 @@ impl ToolOption { match self { Self::Select => Tool::Select, Self::Pen => Tool::Pen, + Self::LiveShape => Tool::LiveShape, Self::Line => Tool::Line, Self::Rect => Tool::Rect, Self::Ellipse => Tool::Ellipse, @@ -131,6 +135,7 @@ impl ToolOption { match tool { Tool::Select => Self::Select, Tool::Pen => Self::Pen, + Tool::LiveShape => Self::LiveShape, Tool::Line => Self::Line, Tool::Rect => Self::Rect, Tool::Ellipse => Self::Ellipse, @@ -165,6 +170,7 @@ pub enum DragToolOption { Default, Select, Pen, + LiveShape, Line, Rect, Ellipse, @@ -186,6 +192,7 @@ impl DragToolOption { Self::Default, Self::Select, Self::Pen, + Self::LiveShape, Self::Line, Self::Rect, Self::Ellipse, @@ -215,6 +222,7 @@ impl DragToolOption { Self::Default => "Button default", Self::Select => "Select", Self::Pen => "Pen", + Self::LiveShape => "Shape pen", Self::Line => "Line", Self::Rect => "Rectangle", Self::Ellipse => "Ellipse", @@ -236,6 +244,7 @@ impl DragToolOption { Self::Default => DragTool::Default, Self::Select => DragTool::Select, Self::Pen => DragTool::Pen, + Self::LiveShape => DragTool::LiveShape, Self::Line => DragTool::Line, Self::Rect => DragTool::Rect, Self::Ellipse => DragTool::Ellipse, @@ -257,6 +266,7 @@ impl DragToolOption { DragTool::Default => Self::Default, DragTool::Select => Self::Select, DragTool::Pen => Self::Pen, + DragTool::LiveShape => Self::LiveShape, DragTool::Line => Self::Line, DragTool::Rect => Self::Rect, DragTool::Ellipse => Self::Ellipse, @@ -278,6 +288,7 @@ impl DragToolOption { Self::Default => None, Self::Select => Some(ToolOption::Select), Self::Pen => Some(ToolOption::Pen), + Self::LiveShape => Some(ToolOption::LiveShape), Self::Line => Some(ToolOption::Line), Self::Rect => Some(ToolOption::Rect), Self::Ellipse => Some(ToolOption::Ellipse), diff --git a/configurator/src/models/keybindings/field/list.rs b/configurator/src/models/keybindings/field/list.rs index 5a18491d7..779d386f2 100644 --- a/configurator/src/models/keybindings/field/list.rs +++ b/configurator/src/models/keybindings/field/list.rs @@ -37,6 +37,7 @@ const PREFERRED_ORDER: &[KeybindingField] = &[ KeybindingField::DecreaseMarkerOpacity, KeybindingField::SelectSelectionTool, KeybindingField::SelectPenTool, + KeybindingField::SelectLiveShapeTool, KeybindingField::SelectEraserTool, KeybindingField::ToggleEraserMode, KeybindingField::CycleFontFamily, diff --git a/docs/CONFIG.md b/docs/CONFIG.md index e121fe12d..6fba24550 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -244,6 +244,9 @@ font_cycle = ["Sans", "Monospace", "Serif"] # Smoothing applied to a finished freehand or marker stroke (0 - 6) pen_smoothing = 3 +# Shape Pen: 0 precise, 2 balanced (default), 4 more forgiving +shape_recognition_sensitivity = 2 + # Default fill state for fill-capable shape tools default_fill_enabled = false @@ -378,6 +381,7 @@ drag_tool = "default" - **Arrow style**: Run **Cycle Arrow Style** from the command palette to step through standard → pointy → curved → double (unbound by default; bind `cycle_arrow_style`). With arrows selected it restyles those in one undo step; with nothing selected it sets the style for the next arrow - **Marker opacity**: Use Ctrl+Alt + ↑/↓ - **Pen smoothing**: Run **Increase / Decrease Pen Smoothing** from the command palette, or bind `increase_pen_smoothing` / `decrease_pen_smoothing` (see [Pen smoothing](#pen-smoothing)) +- **Live shaping**: Choose **Shape pen** from the Shapes picker or **Live Shaping Tool** from the command palette; bind `select_live_shape_tool` for a shortcut. Confident lines, circles, ovals, and axis-aligned rectangles preview as shapes and commit as editable shapes. Set `[drawing] shape_recognition_sensitivity` from 0 (precise) to 4 (forgiving), with 2 as the default, or edit **Shape Pen sensitivity** on the configurator's Drawing page. Reopen the overlay after changing it. The setting affects recognition by Shape Pen, not the dedicated shape tools. Lines close to Cartesian or isometric board-paper lines snap to them; without a nearby grid line, nearly horizontal or vertical strokes align to that axis and skew lines keep their angle. Other ink stays freehand. - **Text font**: Shift+T steps through `font_cycle`; **Font Picker** in the command palette opens the full list (see [Font cycle](#font-cycle) and [Font picker](#font-picker)) - **Regular polygon sides**: Use the Shapes popover Sides control (range: 3-12) - **Font size**: Use Ctrl+Shift++/Ctrl+Shift+- or Shift + scroll (range: 8-72px) @@ -1364,6 +1368,7 @@ shown = [] top_tools = [ "top.tool.select", "top.tool.pen", + "top.tool.live-shape", "top.tool.marker", "top.tool.step-marker", "top.tool.eraser", @@ -2163,6 +2168,7 @@ decrease_marker_opacity = ["Ctrl+Alt+ArrowDown"] # Tool selection shortcuts (optional; keep empty to rely on modifiers) select_selection_tool = ["V"] select_pen_tool = ["F"] +select_live_shape_tool = [] # draw ink that snaps to lines or circles select_marker_tool = ["H"] select_step_marker_tool = [] select_eraser_tool = ["D"] diff --git a/packaging/.SRCINFO b/packaging/.SRCINFO index d9cab6ef4..44bd8d57a 100644 --- a/packaging/.SRCINFO +++ b/packaging/.SRCINFO @@ -1,6 +1,6 @@ pkgbase = wayscriber pkgdesc = Screen annotation tool for Wayland compositors - pkgver = 0.9.25 + pkgver = 0.9.26 pkgrel = 1 url = https://wayscriber.com arch = x86_64 @@ -21,7 +21,7 @@ pkgbase = wayscriber depends = slurp optdepends = tesseract: copy text from screen (OCR) optdepends = tesseract-data-eng: English language data for OCR - source = wayscriber-0.9.25.tar.gz::https://github.com/devmobasa/wayscriber/archive/refs/tags/v0.9.25.tar.gz + source = wayscriber-0.9.26.tar.gz::https://github.com/devmobasa/wayscriber/archive/refs/tags/v0.9.26.tar.gz sha256sums = SKIP pkgname = wayscriber diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index 811308931..5672b7357 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: wayscriber maintainers pkgname=wayscriber -pkgver=0.9.25 +pkgver=0.9.26 pkgrel=1 pkgdesc='Screen annotation tool for Wayland compositors' arch=('x86_64' 'aarch64') diff --git a/src/backend/wayland/state/render/tool_preview.rs b/src/backend/wayland/state/render/tool_preview.rs index 4c4484900..5411906d3 100644 --- a/src/backend/wayland/state/render/tool_preview.rs +++ b/src/backend/wayland/state/render/tool_preview.rs @@ -208,6 +208,7 @@ fn draw_semantic_tool_icon( 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::LiveShape => toolbar_icons::draw_icon_live_shape(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), diff --git a/src/config/action_meta/entries/tools.rs b/src/config/action_meta/entries/tools.rs index 21c03d514..b0d1df5ad 100644 --- a/src/config/action_meta/entries/tools.rs +++ b/src/config/action_meta/entries/tools.rs @@ -45,6 +45,16 @@ pub const ENTRIES: &[ActionMeta] = &[ true, icon: crate::toolbar_icons::draw_icon_pen ), + meta!( + SelectLiveShapeTool, + "Live Shaping Tool", + Some("Shape Pen"), + "Turn confident ink lines and circles into shapes", + Tools, + true, + true, + true + ), meta!( SelectLineTool, "Line Tool", diff --git a/src/config/action_meta/tests.rs b/src/config/action_meta/tests.rs index 3c2751c18..8d7fafb4d 100644 --- a/src/config/action_meta/tests.rs +++ b/src/config/action_meta/tests.rs @@ -41,6 +41,7 @@ const HELP_ACTIONS: &[Action] = &[ Action::SetColorBlack, Action::SelectSelectionTool, Action::SelectPenTool, + Action::SelectLiveShapeTool, Action::SelectLineTool, Action::SelectRectTool, Action::SelectEllipseTool, @@ -100,6 +101,7 @@ const HELP_ACTIONS: &[Action] = &[ const TOOLBAR_ACTIONS: &[Action] = &[ Action::SelectPenTool, + Action::SelectLiveShapeTool, Action::SelectLineTool, Action::SelectRectTool, Action::SelectEllipseTool, @@ -161,6 +163,7 @@ const EXPECTED_COMMAND_PALETTE_ACTIONS: &[Action] = &[ Action::Redo, Action::SelectSelectionTool, Action::SelectPenTool, + Action::SelectLiveShapeTool, Action::SelectLineTool, Action::SelectRectTool, Action::SelectEllipseTool, diff --git a/src/config/keybindings/config/map/edit.rs b/src/config/keybindings/config/map/edit.rs index e1b436607..ae130bf4f 100644 --- a/src/config/keybindings/config/map/edit.rs +++ b/src/config/keybindings/config/map/edit.rs @@ -168,6 +168,7 @@ define_action_binding_accessors! { CycleBlurStyle => tools.cycle_blur_style, CycleArrowStyle => tools.cycle_arrow_style, SelectPenTool => tools.select_pen_tool, + SelectLiveShapeTool => tools.select_live_shape_tool, SelectLineTool => tools.select_line_tool, SelectRectTool => tools.select_rect_tool, SelectEllipseTool => tools.select_ellipse_tool, diff --git a/src/config/keybindings/config/map/tools.rs b/src/config/keybindings/config/map/tools.rs index b41182f7a..c89d36d14 100644 --- a/src/config/keybindings/config/map/tools.rs +++ b/src/config/keybindings/config/map/tools.rs @@ -41,6 +41,10 @@ impl KeybindingsConfig { inserter.insert_all(&self.tools.cycle_blur_style, Action::CycleBlurStyle)?; inserter.insert_all(&self.tools.cycle_arrow_style, Action::CycleArrowStyle)?; inserter.insert_all(&self.tools.select_pen_tool, Action::SelectPenTool)?; + inserter.insert_all( + &self.tools.select_live_shape_tool, + Action::SelectLiveShapeTool, + )?; inserter.insert_all(&self.tools.select_line_tool, Action::SelectLineTool)?; inserter.insert_all(&self.tools.select_rect_tool, Action::SelectRectTool)?; inserter.insert_all(&self.tools.select_ellipse_tool, Action::SelectEllipseTool)?; diff --git a/src/config/keybindings/config/types/bindings/tools.rs b/src/config/keybindings/config/types/bindings/tools.rs index 097bb12f2..54ad586d2 100644 --- a/src/config/keybindings/config/types/bindings/tools.rs +++ b/src/config/keybindings/config/types/bindings/tools.rs @@ -57,6 +57,9 @@ pub struct ToolKeybindingsConfig { #[serde(default = "default_select_pen_tool")] pub select_pen_tool: Vec, + #[serde(default = "default_select_live_shape_tool")] + pub select_live_shape_tool: Vec, + #[serde(default = "default_select_line_tool")] pub select_line_tool: Vec, @@ -128,6 +131,7 @@ impl Default for ToolKeybindingsConfig { cycle_blur_style: default_cycle_blur_style(), cycle_arrow_style: default_cycle_arrow_style(), select_pen_tool: default_select_pen_tool(), + select_live_shape_tool: default_select_live_shape_tool(), select_line_tool: default_select_line_tool(), select_rect_tool: default_select_rect_tool(), select_ellipse_tool: default_select_ellipse_tool(), diff --git a/src/config/keybindings/defaults/tools.rs b/src/config/keybindings/defaults/tools.rs index 921af3ec5..69b07dd0d 100644 --- a/src/config/keybindings/defaults/tools.rs +++ b/src/config/keybindings/defaults/tools.rs @@ -72,6 +72,10 @@ pub(crate) fn default_select_pen_tool() -> Vec { vec!["F".to_string()] } +pub(crate) fn default_select_live_shape_tool() -> Vec { + Vec::new() +} + pub(crate) fn default_select_line_tool() -> Vec { Vec::new() } diff --git a/src/config/keybindings/tests.rs b/src/config/keybindings/tests.rs index 939f00c51..2e9f040c7 100644 --- a/src/config/keybindings/tests.rs +++ b/src/config/keybindings/tests.rs @@ -748,6 +748,7 @@ const DEFAULT_BINDING_SNAPSHOT: &[(&str, &[&str])] = &[ ("cycle_blur_style", &[]), ("cycle_arrow_style", &[]), ("select_pen_tool", &["F"]), + ("select_live_shape_tool", &[]), ("select_line_tool", &[]), ("select_rect_tool", &[]), ("select_ellipse_tool", &[]), diff --git a/src/config/mod.rs b/src/config/mod.rs index 1e623f5cf..e3d1b2d7b 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -63,9 +63,10 @@ pub use types::{ ARROW_ANGLE_MAX, ARROW_ANGLE_MIN, ARROW_LENGTH_MAX, ARROW_LENGTH_MIN, ArrowConfig, BoardBackgroundConfig, BoardColorConfig, BoardConfig, BoardGridConfig, BoardGridKindConfig, BoardItemConfig, BoardsConfig, CaptureConfig, ClickHighlightConfig, DEFAULT_OCR_LANGUAGES, - DEFAULT_PEN_SMOOTHING, DragButtonConfig, DrawingConfig, ExportConfig, HelpOverlayStyle, - HistoryConfig, InputHudConfig, InputHudMode, InputHudPosition, MouseDragToolsConfig, - PDF_LABEL_APP_BOARD, PDF_LABEL_APP_BOARDS, PDF_LABEL_BOARD_NAME, PDF_LABEL_DEFAULT_TEMPLATE, + DEFAULT_PEN_SMOOTHING, DEFAULT_SHAPE_RECOGNITION_SENSITIVITY, DragButtonConfig, DrawingConfig, + ExportConfig, HelpOverlayStyle, HistoryConfig, InputHudConfig, InputHudMode, InputHudPosition, + MAX_SHAPE_RECOGNITION_SENSITIVITY, MouseDragToolsConfig, PDF_LABEL_APP_BOARD, + PDF_LABEL_APP_BOARDS, PDF_LABEL_BOARD_NAME, PDF_LABEL_DEFAULT_TEMPLATE, PDF_LABEL_DOCUMENT_PAGE, PDF_LABEL_DOCUMENT_PAGES, PDF_LABEL_EXPORT_BOARD, PDF_LABEL_EXPORT_BOARDS, PDF_LABEL_PAGE, PDF_LABEL_PAGE_NAME, PDF_LABEL_PAGES, PDF_LABEL_PLACEHOLDERS, PRESET_SLOTS_MAX, PRESET_SLOTS_MIN, PdfExportConfig, PdfFitMode, diff --git a/src/config/tests/validate.rs b/src/config/tests/validate.rs index 768c835c5..566d97e07 100644 --- a/src/config/tests/validate.rs +++ b/src/config/tests/validate.rs @@ -85,6 +85,22 @@ fn drawing_polygon_sides_validation_keeps_supported_bounds() { assert_eq!(config.drawing.polygon_sides, 12); } +#[test] +fn shape_recognition_sensitivity_defaults_and_clamps() { + let mut config = config_from_toml("[drawing]\n"); + assert_eq!( + config.drawing.shape_recognition_sensitivity, + crate::config::DEFAULT_SHAPE_RECOGNITION_SENSITIVITY + ); + + config.drawing.shape_recognition_sensitivity = u8::MAX; + config.validate_and_clamp(); + assert_eq!( + config.drawing.shape_recognition_sensitivity, + crate::config::MAX_SHAPE_RECOGNITION_SENSITIVITY + ); +} + #[test] fn default_overlay_item_is_the_transparent_board() { let overlay = BoardsConfig::default_overlay_item(); diff --git a/src/config/types/drawing.rs b/src/config/types/drawing.rs index 4a33433f8..07c289316 100644 --- a/src/config/types/drawing.rs +++ b/src/config/types/drawing.rs @@ -12,10 +12,14 @@ pub(crate) const DEFAULT_HIT_TEST_TOLERANCE: f64 = 6.0; /// Default release-time smoothing level for freehand and marker strokes. pub const DEFAULT_PEN_SMOOTHING: u8 = 3; +/// Shape Pen recognition levels, from precise to forgiving. +pub const DEFAULT_SHAPE_RECOGNITION_SENSITIVITY: u8 = 2; +pub const MAX_SHAPE_RECOGNITION_SENSITIVITY: u8 = 4; + /// Drawing-related settings. /// /// Controls the default appearance of drawing tools when the overlay first opens. -/// Users can change these values at runtime using keybindings. +/// Some values can also be changed at runtime using keybindings. #[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DrawingConfig { @@ -68,6 +72,10 @@ pub struct DrawingConfig { #[serde(default = "default_pen_smoothing")] pub pen_smoothing: u8, + /// How readily Shape Pen turns ink into lines, ellipses, or rectangles (0 - 4). + #[serde(default = "default_shape_recognition_sensitivity")] + pub shape_recognition_sensitivity: u8, + /// Whether shapes start filled when applicable #[serde(default = "default_fill_enabled")] pub default_fill_enabled: bool, @@ -155,6 +163,7 @@ impl Default for DrawingConfig { marker_opacity: default_marker_opacity(), font_cycle: default_font_cycle(), pen_smoothing: default_pen_smoothing(), + shape_recognition_sensitivity: default_shape_recognition_sensitivity(), default_fill_enabled: default_fill_enabled(), polygon_sides: default_polygon_sides(), default_font_size: default_font_size(), @@ -914,6 +923,10 @@ fn default_pen_smoothing() -> u8 { DEFAULT_PEN_SMOOTHING } +fn default_shape_recognition_sensitivity() -> u8 { + DEFAULT_SHAPE_RECOGNITION_SENSITIVITY +} + fn default_fill_enabled() -> bool { false } diff --git a/src/config/types/mod.rs b/src/config/types/mod.rs index a3c3406d5..2eb4e607a 100644 --- a/src/config/types/mod.rs +++ b/src/config/types/mod.rs @@ -40,6 +40,7 @@ pub use click_highlight::ClickHighlightConfig; pub use context_menu::ContextMenuUiConfig; pub(crate) use drawing::DEFAULT_HIT_TEST_TOLERANCE; pub use drawing::DEFAULT_PEN_SMOOTHING; +pub use drawing::{DEFAULT_SHAPE_RECOGNITION_SENSITIVITY, MAX_SHAPE_RECOGNITION_SENSITIVITY}; pub use drawing::{ DragButtonConfig, DrawingConfig, MouseDragToolsConfig, QUICK_COLOR_RENDER_LIMIT, QuickColorConfig, QuickColorPalette, QuickColorPaletteEntry, QuickColorSlot, QuickColorWrite, diff --git a/src/config/types/toolbar/ids.rs b/src/config/types/toolbar/ids.rs index a330e0d55..97a12a1e2 100644 --- a/src/config/types/toolbar/ids.rs +++ b/src/config/types/toolbar/ids.rs @@ -8,6 +8,7 @@ pub const TOP_CHROME_CLOSE: ToolbarItemId = ToolbarItemId::from_known("top.chrom pub const TOP_TOOL_SELECT: ToolbarItemId = ToolbarItemId::from_known("top.tool.select"); pub const TOP_TOOL_PEN: ToolbarItemId = ToolbarItemId::from_known("top.tool.pen"); +pub const TOP_TOOL_LIVE_SHAPE: ToolbarItemId = ToolbarItemId::from_known("top.tool.live-shape"); pub const TOP_TOOL_MARKER: ToolbarItemId = ToolbarItemId::from_known("top.tool.marker"); pub const TOP_TOOL_STEP_MARKER: ToolbarItemId = ToolbarItemId::from_known("top.tool.step-marker"); pub const TOP_TOOL_ERASER: ToolbarItemId = ToolbarItemId::from_known("top.tool.eraser"); diff --git a/src/config/types/toolbar/items.rs b/src/config/types/toolbar/items.rs index 73afe9236..8a177d287 100644 --- a/src/config/types/toolbar/items.rs +++ b/src/config/types/toolbar/items.rs @@ -72,6 +72,7 @@ pub(crate) fn toolbar_item_hidden_by_baseline(id: ToolbarItemId) -> bool { const DEFAULT_TOP_TOOLS_ORDER: &[ToolbarItemId] = &[ ids::TOP_TOOL_SELECT, ids::TOP_TOOL_PEN, + ids::TOP_TOOL_LIVE_SHAPE, ids::TOP_TOOL_MARKER, ids::TOP_TOOL_STEP_MARKER, ids::TOP_TOOL_ERASER, diff --git a/src/config/types/toolbar/items/definitions.rs b/src/config/types/toolbar/items/definitions.rs index 3191b8d05..672103c62 100644 --- a/src/config/types/toolbar/items/definitions.rs +++ b/src/config/types/toolbar/items/definitions.rs @@ -18,6 +18,7 @@ const TOOLBAR_ITEM_DEFINITIONS: &[ToolbarItemDefinition] = &[ ), item(ids::TOP_TOOL_SELECT, "Select", Top, Tool, None), item(ids::TOP_TOOL_PEN, "Pen", Top, Tool, None), + item(ids::TOP_TOOL_LIVE_SHAPE, "Shape pen", Top, Tool, None), item(ids::TOP_TOOL_MARKER, "Marker", Top, Tool, None), item(ids::TOP_TOOL_STEP_MARKER, "Step marker", Top, Tool, None), item(ids::TOP_TOOL_ERASER, "Eraser", Top, Tool, None), diff --git a/src/config/validate/drawing.rs b/src/config/validate/drawing.rs index c31044ab9..b02271403 100644 --- a/src/config/validate/drawing.rs +++ b/src/config/validate/drawing.rs @@ -1,5 +1,5 @@ use super::Config; -use crate::config::types::DEFAULT_HIT_TEST_TOLERANCE; +use crate::config::types::{DEFAULT_HIT_TEST_TOLERANCE, MAX_SHAPE_RECOGNITION_SENSITIVITY}; use crate::domain::{MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS}; use crate::draw::shape::{MAX_PEN_SMOOTHING, REGULAR_POLYGON_MAX_SIDES, REGULAR_POLYGON_MIN_SIDES}; @@ -41,6 +41,14 @@ impl Config { self.drawing.pen_smoothing = MAX_PEN_SMOOTHING; } + if self.drawing.shape_recognition_sensitivity > MAX_SHAPE_RECOGNITION_SENSITIVITY { + log::warn!( + "Invalid shape_recognition_sensitivity {}, clamping to 0-{MAX_SHAPE_RECOGNITION_SENSITIVITY} range", + self.drawing.shape_recognition_sensitivity + ); + self.drawing.shape_recognition_sensitivity = MAX_SHAPE_RECOGNITION_SENSITIVITY; + } + if !(0.05..=0.9).contains(&self.drawing.marker_opacity) { log::warn!( "Invalid marker_opacity {:.2}, clamping to 0.05-0.90 range", diff --git a/src/configurator_destination.rs b/src/configurator_destination.rs index 3c0f26651..e4ded6343 100644 --- a/src/configurator_destination.rs +++ b/src/configurator_destination.rs @@ -108,6 +108,7 @@ pub fn keybindings_section_for_action(action: Action) -> Option KeybindingsSection::Drawing, Action::SelectSelectionTool | Action::SelectPenTool + | Action::SelectLiveShapeTool | Action::SelectEraserTool | Action::ToggleEraserMode | Action::IncreasePenSmoothing diff --git a/src/domain/action.rs b/src/domain/action.rs index dcd139b43..0f4049c28 100644 --- a/src/domain/action.rs +++ b/src/domain/action.rs @@ -53,6 +53,7 @@ pub enum Action { CycleBlurStyle, CycleArrowStyle, SelectPenTool, + SelectLiveShapeTool, SelectLineTool, SelectRectTool, SelectEllipseTool, diff --git a/src/domain/tests.rs b/src/domain/tests.rs index c09f8050b..b7d3167d4 100644 --- a/src/domain/tests.rs +++ b/src/domain/tests.rs @@ -92,6 +92,7 @@ fn action_serialization_matches_established_contract() { (Action::CycleBlurStyle, "cycle_blur_style"), (Action::CycleArrowStyle, "cycle_arrow_style"), (Action::SelectPenTool, "select_pen_tool"), + (Action::SelectLiveShapeTool, "select_live_shape_tool"), (Action::SelectLineTool, "select_line_tool"), (Action::SelectRectTool, "select_rect_tool"), (Action::SelectEllipseTool, "select_ellipse_tool"), diff --git a/src/domain/tool.rs b/src/domain/tool.rs index 2dd963906..7fc2a977c 100644 --- a/src/domain/tool.rs +++ b/src/domain/tool.rs @@ -12,6 +12,8 @@ pub enum Tool { Select, /// Freehand drawing - follows mouse path (default, no modifiers) Pen, + /// Freehand ink that resolves confident lines and circles into shapes. + LiveShape, /// Straight line - between start and end points (Shift) Line, /// Rectangle outline - from corner to corner (Ctrl) @@ -59,6 +61,7 @@ pub enum DragTool { Select, /// Freehand drawing. Pen, + LiveShape, /// Straight line. Line, /// Rectangle outline. @@ -106,6 +109,7 @@ impl DragTool { pub enum DragBindableTool { Select, Pen, + LiveShape, Line, Rect, Ellipse, @@ -126,6 +130,7 @@ impl DragBindableTool { match self { Self::Select => DragTool::Select, Self::Pen => DragTool::Pen, + Self::LiveShape => DragTool::LiveShape, Self::Line => DragTool::Line, Self::Rect => DragTool::Rect, Self::Ellipse => DragTool::Ellipse, @@ -146,6 +151,7 @@ impl DragBindableTool { match self { Self::Select => Tool::Select, Self::Pen => Tool::Pen, + Self::LiveShape => Tool::LiveShape, Self::Line => Tool::Line, Self::Rect => Tool::Rect, Self::Ellipse => Tool::Ellipse, @@ -166,6 +172,7 @@ impl DragBindableTool { match tool { Tool::Select => Some(Self::Select), Tool::Pen => Some(Self::Pen), + Tool::LiveShape => Some(Self::LiveShape), Tool::Line => Some(Self::Line), Tool::Rect => Some(Self::Rect), Tool::Ellipse => Some(Self::Ellipse), @@ -190,6 +197,7 @@ impl DragBindableTool { DragTool::Default => None, DragTool::Select => Some(Self::Select), DragTool::Pen => Some(Self::Pen), + DragTool::LiveShape => Some(Self::LiveShape), DragTool::Line => Some(Self::Line), DragTool::Rect => Some(Self::Rect), DragTool::Ellipse => Some(Self::Ellipse), diff --git a/src/input/state/core/dirty.rs b/src/input/state/core/dirty.rs index c837a7ffd..879c0a53e 100644 --- a/src/input/state/core/dirty.rs +++ b/src/input/state/core/dirty.rs @@ -119,6 +119,9 @@ impl InputState { else { return None; }; + if *tool == crate::input::Tool::LiveShape { + return None; + } let stroke_width = match tool.motion_behavior() { ToolMotionBehavior::NoPathAccumulation => return None, diff --git a/src/input/state/core/style.rs b/src/input/state/core/style.rs index ccf4868c5..a1dad7bea 100644 --- a/src/input/state/core/style.rs +++ b/src/input/state/core/style.rs @@ -31,6 +31,7 @@ pub(crate) struct DrawingStyle { pub(crate) eraser_mode: EraserMode, pub(crate) marker_opacity: f64, pub(crate) pen_smoothing: u8, + pub(crate) shape_recognition_sensitivity: u8, pub(crate) blur_style: BlurStyle, pub(crate) spotlight_dim_opacity: f64, pub(crate) spotlight_feather: f64, @@ -78,6 +79,7 @@ impl From<(&DrawingConfig, &ArrowConfig, &SpotlightConfig)> for DrawingStyle { eraser_mode: drawing.default_eraser_mode, marker_opacity: drawing.marker_opacity, pen_smoothing: crate::draw::shape::clamp_pen_smoothing(drawing.pen_smoothing), + shape_recognition_sensitivity: drawing.shape_recognition_sensitivity, blur_style: drawing.default_blur_style, spotlight_dim_opacity: spotlight.dim_opacity, spotlight_feather: spotlight.feather, diff --git a/src/input/state/interaction/actions.rs b/src/input/state/interaction/actions.rs index 45feb5740..868fb8e54 100644 --- a/src/input/state/interaction/actions.rs +++ b/src/input/state/interaction/actions.rs @@ -48,6 +48,7 @@ pub(crate) fn classify_action(action: Action) -> ActionRoute { | Action::CycleBlurStyle | Action::CycleArrowStyle | Action::SelectPenTool + | Action::SelectLiveShapeTool | Action::SelectLineTool | Action::SelectRectTool | Action::SelectEllipseTool diff --git a/src/input/state/mouse/release/drawing.rs b/src/input/state/mouse/release/drawing.rs index 0b19e3308..e11983f26 100644 --- a/src/input/state/mouse/release/drawing.rs +++ b/src/input/state/mouse/release/drawing.rs @@ -67,6 +67,8 @@ pub(super) fn finish_drawing( point_thicknesses: release.point_thicknesses, color: drawing_color, size: drawing_thickness, + grid: state.boards.active_board().spec.grid, + shape_recognition_sensitivity: state.style.shape_recognition_sensitivity, marker_opacity: state.style.marker_opacity, fill_enabled: state.style.fill_enabled, blur_style: state.style.blur_style, diff --git a/src/input/state/render.rs b/src/input/state/render.rs index cae55789c..c9a9b5c08 100644 --- a/src/input/state/render.rs +++ b/src/input/state/render.rs @@ -78,6 +78,8 @@ impl InputState { point_thicknesses, color: self.active_drag_color_or_current(), size: self.thickness_for_tool(*tool), + grid: self.boards.active_board().spec.grid, + shape_recognition_sensitivity: self.style.shape_recognition_sensitivity, eraser_size: self.style.eraser_size, marker_opacity: self.style.marker_opacity, fill_enabled: self.style.fill_enabled, diff --git a/src/input/state/tests/drawing.rs b/src/input/state/tests/drawing.rs index 15edc546d..0169be444 100644 --- a/src/input/state/tests/drawing.rs +++ b/src/input/state/tests/drawing.rs @@ -66,6 +66,326 @@ fn mouse_drag_creates_shapes_for_each_tool() { assert_eq!(state.boards.active_frame().shapes.len(), 5); } +#[test] +fn live_shape_strokes_preview_and_commit_lines_and_circles() { + use crate::input::tool::ProvisionalToolStroke; + + let mut state = create_test_input_state(); + assert!(state.set_tool_override(Some(Tool::LiveShape))); + + state.on_mouse_press(MouseButton::Left, 0, 0); + for point in [(20, 1), (40, -1), (60, 1), (80, 3)] { + state.on_mouse_motion(point.0, point.1); + } + assert!(matches!( + state.provisional_tool_stroke(80, 3), + ProvisionalToolStroke::Shape(Shape::Line { y2: 0, .. }) + )); + state.on_mouse_release(MouseButton::Left, 80, 3); + assert!(matches!( + state.boards.active_frame().shapes[0].shape, + Shape::Line { + x1: 0, + y1: 0, + x2: 80, + y2: 0, + .. + } + )); + + state.on_mouse_press(MouseButton::Left, 120, 80); + for index in 1..=32 { + let angle = std::f64::consts::TAU * f64::from(index) / 32.0; + state.on_mouse_motion( + 80 + (40.0 * angle.cos()).round() as i32, + 80 + (40.0 * angle.sin()).round() as i32, + ); + } + assert!(matches!( + state.provisional_tool_stroke(120, 80), + ProvisionalToolStroke::Shape(Shape::Ellipse { .. }) + )); + state.on_mouse_release(MouseButton::Left, 120, 80); + assert!(matches!( + state.boards.active_frame().shapes[1].shape, + Shape::Ellipse { + cx: 80, + cy: 80, + rx: 40, + ry: 40, + fill: false, + .. + } + )); + + state.on_mouse_press(MouseButton::Left, 0, 120); + for point in [(20, 140), (40, 160), (60, 180)] { + state.on_mouse_motion(point.0, point.1); + } + state.on_mouse_release(MouseButton::Left, 60, 180); + assert!(matches!( + state.boards.active_frame().shapes[2].shape, + Shape::Line { + x1: 0, + y1: 120, + x2: 60, + y2: 180, + .. + } + )); + assert_eq!(state.boards.active_frame().shapes.len(), 3); + assert_eq!(state.boards.active_frame().undo_stack_len(), 3); +} + +#[test] +fn live_shape_previews_and_commits_hand_drawn_rectangles_and_ovals() { + use crate::input::tool::ProvisionalToolStroke; + + let mut state = create_test_input_state(); + assert!(state.set_tool_override(Some(Tool::LiveShape))); + + state.on_mouse_press(MouseButton::Left, 10, 10); + for (x, y) in [ + (30, 8), + (55, 11), + (80, 9), + (110, 10), + (112, 30), + (109, 55), + (110, 90), + (85, 92), + (60, 89), + (35, 91), + (10, 90), + (8, 70), + (11, 45), + (9, 25), + (10, 10), + ] { + state.on_mouse_motion(x, y); + } + assert!(matches!( + state.provisional_tool_stroke(10, 10), + ProvisionalToolStroke::Shape(Shape::Rect { .. }) + )); + state.on_mouse_release(MouseButton::Left, 10, 10); + assert!(matches!( + state.boards.active_frame().shapes[0].shape, + Shape::Rect { + x: 8, + y: 8, + w: 104, + h: 84, + fill: false, + .. + } + )); + + state.style.shape_recognition_sensitivity = 0; + state.on_mouse_press(MouseButton::Left, 260, 80); + for index in 1..=32 { + let angle = std::f64::consts::TAU * f64::from(index) / 32.0; + state.on_mouse_motion( + 200 + (60.0 * angle.cos()).round() as i32, + 80 + (35.0 * angle.sin()).round() as i32, + ); + } + assert!(matches!( + state.provisional_tool_stroke(260, 80), + ProvisionalToolStroke::Shape(Shape::Ellipse { .. }) + )); + state.on_mouse_release(MouseButton::Left, 260, 80); + assert!(matches!( + state.boards.active_frame().shapes[1].shape, + Shape::Ellipse { + cx: 200, + cy: 80, + rx: 60, + ry: 35, + fill: false, + .. + } + )); +} + +#[test] +fn live_shape_sensitivity_recognizes_rough_lines_without_changing_precise_ink() { + for (sensitivity, becomes_line) in [(0, false), (4, true)] { + let mut state = create_test_input_state(); + state.style.shape_recognition_sensitivity = sensitivity; + assert!(state.set_tool_override(Some(Tool::LiveShape))); + + state.on_mouse_press(MouseButton::Left, 0, 0); + for (x, y) in [(20, 5), (40, -4), (60, 6), (80, 1)] { + state.on_mouse_motion(x, y); + } + state.on_mouse_release(MouseButton::Left, 80, 1); + + assert_eq!( + matches!( + state.boards.active_frame().shapes[0].shape, + Shape::Line { .. } + ), + becomes_line, + "sensitivity {sensitivity}" + ); + } +} + +#[test] +fn live_shape_sensitivity_controls_rough_closed_shapes() { + let rectangle = vec![ + (10, 10), + (30, 16), + (50, 18), + (70, 16), + (90, 10), + (84, 30), + (82, 50), + (84, 70), + (90, 90), + (70, 84), + (50, 82), + (30, 84), + (10, 90), + (16, 70), + (18, 50), + (16, 30), + (10, 10), + ]; + let oval: Vec<_> = (0..=32) + .map(|index| { + let angle = std::f64::consts::TAU * f64::from(index) / 32.0; + let wobble = 1.0 + 0.13 * (3.0 * angle).sin(); + ( + 200 + (50.0 * wobble * angle.cos()).round() as i32, + 80 + (40.0 * wobble * angle.sin()).round() as i32, + ) + }) + .collect(); + + for (path, expected_kind) in [(rectangle, "Rectangle"), (oval, "Ellipse")] { + for (sensitivity, recognized) in [(0, false), (4, true)] { + let mut state = create_test_input_state(); + state.style.shape_recognition_sensitivity = sensitivity; + assert!(state.set_tool_override(Some(Tool::LiveShape))); + + let first = path[0]; + state.on_mouse_press(MouseButton::Left, first.0, first.1); + for &(x, y) in &path[1..] { + state.on_mouse_motion(x, y); + } + let last = *path.last().unwrap(); + state.on_mouse_release(MouseButton::Left, last.0, last.1); + + let kind = state.boards.active_frame().shapes[0].shape.kind_name(); + assert_eq!( + kind, + if recognized { + expected_kind + } else { + "Freehand" + }, + "{expected_kind} at sensitivity {sensitivity}" + ); + } + } +} + +#[test] +fn live_shape_keeps_ambiguous_strokes_as_freehand() { + let mut state = create_test_input_state(); + assert!(state.set_tool_override(Some(Tool::LiveShape))); + + state.on_mouse_press(MouseButton::Left, 0, 0); + for point in [(30, 30), (0, 30), (30, 0)] { + state.on_mouse_motion(point.0, point.1); + } + state.on_mouse_release(MouseButton::Left, 30, 0); + + assert!(matches!( + state.boards.active_frame().shapes[0].shape, + Shape::Freehand { .. } + )); + + state.style.shape_recognition_sensitivity = 4; + state.on_mouse_press(MouseButton::Left, 40, 0); + for point in [ + (50, 10), + (60, 20), + (70, 30), + (80, 40), + (70, 50), + (60, 60), + (50, 70), + (40, 80), + (30, 70), + (20, 60), + (10, 50), + (0, 40), + (10, 30), + (20, 20), + (30, 10), + (40, 0), + ] { + state.on_mouse_motion(point.0, point.1); + } + state.on_mouse_release(MouseButton::Left, 40, 0); + assert!(matches!( + state.boards.active_frame().shapes[1].shape, + Shape::Freehand { .. } + )); +} + +#[test] +fn live_shape_snaps_lines_to_visible_board_grid() { + use crate::domain::{BoardGrid, BoardGridKind}; + use crate::input::BOARD_ID_WHITEBOARD; + use crate::input::tool::ProvisionalToolStroke; + + let mut state = create_test_input_state(); + state.switch_board(BOARD_ID_WHITEBOARD); + state.boards.active_board_mut().spec.grid = BoardGrid::new(BoardGridKind::Cartesian, 40); + assert!(state.set_tool_override(Some(Tool::LiveShape))); + + state.on_mouse_press(MouseButton::Left, 0, 44); + for point in [(30, 43), (60, 42), (100, 44)] { + state.on_mouse_motion(point.0, point.1); + } + assert!(matches!( + state.provisional_tool_stroke(100, 44), + ProvisionalToolStroke::Shape(Shape::Line { y1: 40, y2: 40, .. }) + )); + state.on_mouse_release(MouseButton::Left, 100, 44); + assert!(matches!( + state.boards.active_frame().shapes[0].shape, + Shape::Line { + x1: 0, + y1: 40, + x2: 100, + y2: 40, + .. + } + )); + + state.boards.active_board_mut().spec.grid = BoardGrid::new(BoardGridKind::Isometric, 40); + state.on_mouse_press(MouseButton::Left, 0, 1); + for point in [(40, 23), (80, 47), (120, 69)] { + state.on_mouse_motion(point.0, point.1); + } + state.on_mouse_release(MouseButton::Left, 120, 69); + assert!(matches!( + state.boards.active_frame().shapes[1].shape, + Shape::Line { + x1: 0, + y1: 0, + x2: 120, + y2: 69, + .. + } + )); +} + #[test] fn regular_polygon_drag_stores_concrete_points_and_side_metadata() { let mut state = create_test_input_state(); diff --git a/src/input/tool/catalog.rs b/src/input/tool/catalog.rs index f913f2139..e3a7443b2 100644 --- a/src/input/tool/catalog.rs +++ b/src/input/tool/catalog.rs @@ -43,6 +43,7 @@ pub(crate) enum ToolDrawingBehavior { kind: ToolPathKind, pressure: ToolPressureBehavior, }, + LiveShape, Line, Rect, Ellipse, @@ -82,7 +83,7 @@ const fn profile( } } -const DESCRIPTORS: [ToolDescriptor; 17] = [ +const DESCRIPTORS: [ToolDescriptor; 18] = [ ToolDescriptor { tool: Tool::Select, short_label: "Select", @@ -122,6 +123,26 @@ const DESCRIPTORS: [ToolDescriptor; 17] = [ pressure: ToolPressureBehavior::OptionalPressureStroke, }, }, + ToolDescriptor { + tool: Tool::LiveShape, + short_label: "Shape Pen", + display_label: "Live Shaping Tool", + action: Some(Action::SelectLiveShapeTool), + 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::LiveShape, + }, ToolDescriptor { tool: Tool::Line, short_label: "Line", @@ -402,9 +423,10 @@ const DESCRIPTORS: [ToolDescriptor; 17] = [ ]; impl Tool { - pub(crate) const ALL: [Self; 17] = [ + pub(crate) const ALL: [Self; 18] = [ Self::Select, Self::Pen, + Self::LiveShape, Self::Line, Self::Rect, Self::Ellipse, @@ -426,21 +448,22 @@ impl Tool { match self { Self::Select => &DESCRIPTORS[0], Self::Pen => &DESCRIPTORS[1], - Self::Line => &DESCRIPTORS[2], - Self::Rect => &DESCRIPTORS[3], - Self::Ellipse => &DESCRIPTORS[4], - Self::Triangle => &DESCRIPTORS[5], - Self::Parallelogram => &DESCRIPTORS[6], - Self::Rhombus => &DESCRIPTORS[7], - Self::RegularPolygon => &DESCRIPTORS[8], - Self::FreeformPolygon => &DESCRIPTORS[9], - Self::Arrow => &DESCRIPTORS[10], - Self::Blur => &DESCRIPTORS[11], - Self::Spotlight => &DESCRIPTORS[12], - Self::Marker => &DESCRIPTORS[13], - Self::Highlight => &DESCRIPTORS[14], - Self::StepMarker => &DESCRIPTORS[15], - Self::Eraser => &DESCRIPTORS[16], + Self::LiveShape => &DESCRIPTORS[2], + Self::Line => &DESCRIPTORS[3], + Self::Rect => &DESCRIPTORS[4], + Self::Ellipse => &DESCRIPTORS[5], + Self::Triangle => &DESCRIPTORS[6], + Self::Parallelogram => &DESCRIPTORS[7], + Self::Rhombus => &DESCRIPTORS[8], + Self::RegularPolygon => &DESCRIPTORS[9], + Self::FreeformPolygon => &DESCRIPTORS[10], + Self::Arrow => &DESCRIPTORS[11], + Self::Blur => &DESCRIPTORS[12], + Self::Spotlight => &DESCRIPTORS[13], + Self::Marker => &DESCRIPTORS[14], + Self::Highlight => &DESCRIPTORS[15], + Self::StepMarker => &DESCRIPTORS[16], + Self::Eraser => &DESCRIPTORS[17], } } @@ -487,7 +510,10 @@ impl Tool { /// Accumulated paths — freehand and marker — are the ones smoothed on /// release. pub(crate) fn smooths_strokes(self) -> bool { - matches!(self.drawing_behavior(), ToolDrawingBehavior::Path { .. }) + matches!( + self.drawing_behavior(), + ToolDrawingBehavior::Path { .. } | ToolDrawingBehavior::LiveShape + ) } pub(crate) fn settings_slot(self) -> ToolSettingsSlot { @@ -517,7 +543,7 @@ impl Tool { ToolDrawingBehavior::Path { pressure: ToolPressureBehavior::OptionalPressureStroke, .. - } + } | ToolDrawingBehavior::LiveShape ) } } diff --git a/src/input/tool/drawing.rs b/src/input/tool/drawing.rs index e44b4907c..dc416c4a6 100644 --- a/src/input/tool/drawing.rs +++ b/src/input/tool/drawing.rs @@ -39,6 +39,8 @@ pub(crate) struct ToolStrokeSnapshot { pub(crate) point_thicknesses: Vec, pub(crate) color: Color, pub(crate) size: f64, + pub(crate) grid: crate::domain::BoardGrid, + pub(crate) shape_recognition_sensitivity: u8, pub(crate) marker_opacity: f64, pub(crate) fill_enabled: bool, pub(crate) blur_style: BlurStyle, @@ -89,6 +91,8 @@ pub(crate) struct ProvisionalToolSnapshot<'a> { pub(crate) point_thicknesses: &'a [f32], pub(crate) color: Color, pub(crate) size: f64, + pub(crate) grid: crate::domain::BoardGrid, + pub(crate) shape_recognition_sensitivity: u8, pub(crate) eraser_size: f64, pub(crate) marker_opacity: f64, pub(crate) fill_enabled: bool, @@ -147,6 +151,37 @@ impl Tool { ToolDrawingBehavior::Path { kind, pressure } => { finish_path_stroke(snapshot, kind, pressure, usage) } + ToolDrawingBehavior::LiveShape => { + let recognized = if snapshot.points.last().copied() == Some(snapshot.end) { + super::live_shape::recognize( + &snapshot.points, + snapshot.color, + snapshot.size, + snapshot.grid, + snapshot.shape_recognition_sensitivity, + ) + } else { + let mut path = snapshot.points.clone(); + path.push(snapshot.end); + super::live_shape::recognize( + &path, + snapshot.color, + snapshot.size, + snapshot.grid, + snapshot.shape_recognition_sensitivity, + ) + }; + if let Some(shape) = recognized { + FinishedToolStroke::Shape { shape, usage } + } else { + finish_path_stroke( + snapshot, + ToolPathKind::Freehand, + ToolPressureBehavior::OptionalPressureStroke, + usage, + ) + } + } ToolDrawingBehavior::Line => finish_shape(snapshot, usage, |snapshot| Shape::Line { x1: snapshot.start.0, y1: snapshot.start.1, @@ -265,6 +300,31 @@ impl Tool { debug_assert_eq!(self, snapshot.tool); match self.drawing_behavior() { ToolDrawingBehavior::None => ProvisionalToolStroke::None, + ToolDrawingBehavior::LiveShape => { + if let Some(shape) = super::live_shape::recognize( + snapshot.points, + snapshot.color, + snapshot.size, + snapshot.grid, + snapshot.shape_recognition_sensitivity, + ) { + ProvisionalToolStroke::Shape(shape) + } else 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::OptionalPressureStroke, diff --git a/src/input/tool/live_shape.rs b/src/input/tool/live_shape.rs new file mode 100644 index 000000000..b012658e5 --- /dev/null +++ b/src/input/tool/live_shape.rs @@ -0,0 +1,358 @@ +//! Recognize lines and closed shapes from a pen path. Ambiguous ink stays ink. + +use crate::domain::{BoardGrid, BoardGridKind}; +use crate::draw::{Color, Shape}; + +pub(super) fn recognize( + points: &[(i32, i32)], + color: Color, + thick: f64, + grid: BoardGrid, + sensitivity: u8, +) -> Option { + let first = *points.first()?; + let last = *points.last()?; + let bounds = Bounds::for_points(points); + let chord = distance(first, last); + let length: f64 = points + .windows(2) + .map(|pair| distance(pair[0], pair[1])) + .sum(); + let sensitivity = sensitivity.min(crate::config::MAX_SHAPE_RECOGNITION_SENSITIVITY); + + if points.len() >= 8 + && bounds.width >= 24.0 + && bounds.height >= 24.0 + && chord <= bounds.diameter() * (0.12 + 0.05 * f64::from(sensitivity)) + && let Some(shape) = recognize_closed(points, bounds, length, color, thick, sensitivity) + { + return Some(shape); + } + + if chord < 16.0 || length > chord * (1.08 + 0.04 * f64::from(sensitivity)) { + return None; + } + + let deviation = points + .iter() + .map(|&(x, y)| { + (((f64::from(x) - f64::from(first.0)) * (f64::from(last.1) - f64::from(first.1)) + - (f64::from(y) - f64::from(first.1)) * (f64::from(last.0) - f64::from(first.0))) + / chord) + .abs() + }) + .fold(0.0_f64, f64::max); + if deviation > (chord * (0.04 + 0.02 * f64::from(sensitivity))).max(4.0) { + return None; + } + + let (start, end) = align_line(first, last, chord, grid); + + Some(Shape::Line { + x1: start.0, + y1: start.1, + x2: end.0, + y2: end.1, + color, + thick, + }) +} + +#[derive(Clone, Copy)] +struct Bounds { + min_x: i32, + max_x: i32, + min_y: i32, + max_y: i32, + width: f64, + height: f64, +} + +impl Bounds { + fn for_points(points: &[(i32, i32)]) -> Self { + let (min_x, max_x, min_y, max_y) = points.iter().fold( + (i32::MAX, i32::MIN, i32::MAX, i32::MIN), + |(min_x, max_x, min_y, max_y), &(x, y)| { + (min_x.min(x), max_x.max(x), min_y.min(y), max_y.max(y)) + }, + ); + Self { + min_x, + max_x, + min_y, + max_y, + width: f64::from(max_x) - f64::from(min_x), + height: f64::from(max_y) - f64::from(min_y), + } + } + + fn diameter(self) -> f64 { + self.width.max(self.height) + } + + fn center(self) -> (f64, f64) { + ( + (f64::from(self.min_x) + f64::from(self.max_x)) / 2.0, + (f64::from(self.min_y) + f64::from(self.max_y)) / 2.0, + ) + } +} + +struct ClosedFit { + shape: Shape, + error: f64, +} + +fn recognize_closed( + points: &[(i32, i32)], + bounds: Bounds, + length: f64, + color: Color, + thick: f64, + sensitivity: u8, +) -> Option { + if !winds_once(points, bounds) { + return None; + } + + let ellipse = fit_ellipse(points, bounds, length, color, thick, sensitivity); + let rectangle = fit_rectangle(points, bounds, length, color, thick, sensitivity); + match (ellipse, rectangle) { + (Some(ellipse), Some(rectangle)) if rectangle.error < ellipse.error => { + Some(rectangle.shape) + } + (Some(ellipse), _) => Some(ellipse.shape), + (_, Some(rectangle)) => Some(rectangle.shape), + _ => None, + } +} + +fn align_line( + first: (i32, i32), + last: (i32, i32), + chord: f64, + grid: BoardGrid, +) -> ((i32, i32), (i32, i32)) { + let dx = f64::from(last.0) - f64::from(first.0); + let dy = f64::from(last.1) - f64::from(first.1); + let spacing = f64::from(grid.spacing()); + + if dy.abs() <= chord * 0.12 { + let y = if grid.kind == BoardGridKind::Cartesian { + snap_to_grid(f64::from(first.1), f64::from(last.1), spacing).unwrap_or(first.1) + } else { + first.1 + }; + return ((first.0, y), (last.0, y)); + } + + if dx.abs() <= chord * 0.12 { + let vertical_spacing = match grid.kind { + BoardGridKind::Cartesian => Some(spacing), + BoardGridKind::Isometric => Some(3.0_f64.sqrt() * spacing / 2.0), + _ => None, + }; + let x = vertical_spacing + .and_then(|s| snap_to_grid(f64::from(first.0), f64::from(last.0), s)) + .unwrap_or(first.0); + return ((x, first.1), (x, last.1)); + } + + if grid.kind == BoardGridKind::Isometric { + for slope in [-1.0 / 3.0_f64.sqrt(), 1.0 / 3.0_f64.sqrt()] { + if (dy - slope * dx).abs() / (1.0 + slope * slope).sqrt() > chord * 0.12 { + continue; + } + let first_intercept = f64::from(first.1) - slope * f64::from(first.0); + let last_intercept = f64::from(last.1) - slope * f64::from(last.0); + if let Some(intercept) = snap_to_grid(first_intercept, last_intercept, spacing) { + let y1 = (slope * f64::from(first.0) + f64::from(intercept)).round() as i32; + let y2 = (slope * f64::from(last.0) + f64::from(intercept)).round() as i32; + return ((first.0, y1), (last.0, y2)); + } + } + } + + (first, last) +} + +fn snap_to_grid(first: f64, last: f64, spacing: f64) -> Option { + let coordinate = ((first + last) / (2.0 * spacing)).round() * spacing; + let margin = (spacing * 0.15).clamp(4.0, 8.0); + ((first - coordinate).abs() <= margin && (last - coordinate).abs() <= margin) + .then_some(coordinate.round() as i32) +} + +fn winds_once(points: &[(i32, i32)], bounds: Bounds) -> bool { + let (cx, cy) = bounds.center(); + let rx = bounds.width / 2.0; + let ry = bounds.height / 2.0; + let mut winding = 0.0_f64; + let mut travel = 0.0_f64; + let mut previous_angle: Option = None; + + for &(x, y) in points { + let angle = ((f64::from(y) - cy) / ry).atan2((f64::from(x) - cx) / rx); + if let Some(previous) = previous_angle { + let delta = (angle - previous + std::f64::consts::PI).rem_euclid(std::f64::consts::TAU) + - std::f64::consts::PI; + if delta.abs() > std::f64::consts::FRAC_PI_2 + 0.1 { + return false; + } + winding += delta; + travel += delta.abs(); + } + previous_angle = Some(angle); + } + + (std::f64::consts::TAU * 0.8..=std::f64::consts::TAU * 1.2).contains(&winding.abs()) + && travel <= winding.abs() * 1.5 +} + +fn fit_ellipse( + points: &[(i32, i32)], + bounds: Bounds, + length: f64, + color: Color, + thick: f64, + sensitivity: u8, +) -> Option { + let level = f64::from(sensitivity); + let rx = bounds.width / 2.0; + let ry = bounds.height / 2.0; + if rx.max(ry) / rx.min(ry) > 2.5 || has_polygon_corners(points, bounds) { + return None; + } + + let circumference = + std::f64::consts::PI * (3.0 * (rx + ry) - ((3.0 * rx + ry) * (rx + 3.0 * ry)).sqrt()); + if !(circumference * (0.74 - 0.03 * level)..=circumference * (1.3 + 0.05 * level)) + .contains(&length) + { + return None; + } + + let (cx, cy) = bounds.center(); + let mut error = 0.0_f64; + let mut worst = 0.0_f64; + for &(x, y) in points { + let radius = ((f64::from(x) - cx) / rx).hypot((f64::from(y) - cy) / ry); + let deviation = (radius - 1.0).abs(); + error += deviation; + worst = worst.max(deviation); + } + let error = error / points.len() as f64; + if error > 0.07 + 0.025 * level || worst > 0.2 + 0.05 * level { + return None; + } + + Some(ClosedFit { + shape: Shape::Ellipse { + cx: cx.round() as i32, + cy: cy.round() as i32, + rx: rx.round() as i32, + ry: ry.round() as i32, + fill: false, + color, + thick, + }, + error, + }) +} + +fn has_polygon_corners(points: &[(i32, i32)], bounds: Bounds) -> bool { + // One kink can be hand jitter; three clear turns describe a polygon. + let min_segment = (bounds.width.min(bounds.height) * 0.08).max(3.0); + let mut corners = 0; + for triplet in points.windows(3) { + let first = ( + f64::from(triplet[1].0) - f64::from(triplet[0].0), + f64::from(triplet[1].1) - f64::from(triplet[0].1), + ); + let second = ( + f64::from(triplet[2].0) - f64::from(triplet[1].0), + f64::from(triplet[2].1) - f64::from(triplet[1].1), + ); + let first_length = first.0.hypot(first.1); + let second_length = second.0.hypot(second.1); + if first_length.min(second_length) < min_segment { + continue; + } + if first.0 * second.0 + first.1 * second.1 < 0.25 * first_length * second_length { + corners += 1; + if corners >= 3 { + return true; + } + } + } + false +} + +fn fit_rectangle( + points: &[(i32, i32)], + bounds: Bounds, + length: f64, + color: Color, + thick: f64, + sensitivity: u8, +) -> Option { + let level = f64::from(sensitivity); + let short_side = bounds.width.min(bounds.height); + let perimeter = 2.0 * (bounds.width + bounds.height); + if bounds.diameter() / short_side > 5.0 + || !(perimeter * (0.8 - 0.04 * level)..=perimeter * (1.25 + 0.08 * level)).contains(&length) + { + return None; + } + + let mut error = 0.0_f64; + let mut worst = 0.0_f64; + for &(x, y) in points { + let x = f64::from(x); + let y = f64::from(y); + let distance = (x - f64::from(bounds.min_x)) + .abs() + .min((x - f64::from(bounds.max_x)).abs()) + .min((y - f64::from(bounds.min_y)).abs()) + .min((y - f64::from(bounds.max_y)).abs()) + / short_side; + error += distance; + worst = worst.max(distance); + } + let error = error / points.len() as f64; + if error > 0.035 + 0.02 * level || worst > 0.13 + 0.035 * level { + return None; + } + + for corner in [ + (bounds.min_x, bounds.min_y), + (bounds.max_x, bounds.min_y), + (bounds.max_x, bounds.max_y), + (bounds.min_x, bounds.max_y), + ] { + let nearest = points + .iter() + .map(|&point| distance(point, corner)) + .fold(f64::INFINITY, f64::min); + if nearest > short_side * (0.12 + 0.04 * level) { + return None; + } + } + + Some(ClosedFit { + shape: Shape::Rect { + x: bounds.min_x, + y: bounds.min_y, + w: bounds.width.round() as i32, + h: bounds.height.round() as i32, + fill: false, + color, + thick, + }, + error, + }) +} + +fn distance(a: (i32, i32), b: (i32, i32)) -> f64 { + (f64::from(a.0) - f64::from(b.0)).hypot(f64::from(a.1) - f64::from(b.1)) +} diff --git a/src/input/tool/mod.rs b/src/input/tool/mod.rs index 67e508d88..0c46139b3 100644 --- a/src/input/tool/mod.rs +++ b/src/input/tool/mod.rs @@ -2,6 +2,7 @@ mod catalog; mod drawing; +mod live_shape; mod profile; mod settings; diff --git a/src/input/tool/tests.rs b/src/input/tool/tests.rs index aaf1a5e80..592eff23e 100644 --- a/src/input/tool/tests.rs +++ b/src/input/tool/tests.rs @@ -163,11 +163,11 @@ fn descriptor_exposes_press_motion_and_drawing_behavior() { } #[test] -fn only_freehand_pen_supports_pressure_thickness() { +fn freehand_pens_support_pressure_thickness() { for tool in Tool::ALL { assert_eq!( tool.supports_pressure_thickness(), - tool == Tool::Pen, + matches!(tool, Tool::Pen | Tool::LiveShape), "unexpected pressure-thickness behavior for {tool:?}" ); } diff --git a/src/toolbar_icons/mod.rs b/src/toolbar_icons/mod.rs index 9582ca0a0..2400657ec 100644 --- a/src/toolbar_icons/mod.rs +++ b/src/toolbar_icons/mod.rs @@ -122,6 +122,7 @@ pub(crate) fn top_toolbar_icon_painter( I::LayoutAdvanced => draw_icon_layout_advanced, I::Tool(T::Select) => draw_icon_select, I::Tool(T::Pen) => draw_icon_pen, + I::Tool(T::LiveShape) => draw_icon_live_shape, I::Tool(T::Line) => draw_icon_line, I::Tool(T::Rect) => draw_icon_rect, I::Tool(T::Circle) => draw_icon_circle, diff --git a/src/toolbar_icons/tools.rs b/src/toolbar_icons/tools.rs index e2ef200ac..8c3f8ff75 100644 --- a/src/toolbar_icons/tools.rs +++ b/src/toolbar_icons/tools.rs @@ -11,6 +11,11 @@ pub fn draw_icon_pen(ctx: &Context, x: f64, y: f64, size: f64) { super::svg::render_pen(ctx, x, y, size); } +pub fn draw_icon_live_shape(ctx: &Context, x: f64, y: f64, size: f64) { + super::svg::render_pen(ctx, x, y, size); + super::svg::render_circle(ctx, x + size * 0.46, y + size * 0.46, size * 0.52); +} + pub fn draw_icon_line(ctx: &Context, x: f64, y: f64, size: f64) { super::svg::render_line(ctx, x, y, size); } diff --git a/src/ui/help_overlay/sections/builder/sections.rs b/src/ui/help_overlay/sections/builder/sections.rs index ddc7b66da..eb23a360e 100644 --- a/src/ui/help_overlay/sections/builder/sections.rs +++ b/src/ui/help_overlay/sections/builder/sections.rs @@ -104,6 +104,7 @@ pub(super) fn build_main_sections( title: "Drawing", rows: vec![ action_row(bindings, Action::SelectPenTool, NOT_BOUND_LABEL), + action_row(bindings, Action::SelectLiveShapeTool, NOT_BOUND_LABEL), action_row(bindings, Action::SelectLineTool, "Shift+Drag"), action_row(bindings, Action::SelectRectTool, "Ctrl+Drag"), action_row(bindings, Action::SelectEllipseTool, "Tab+Drag"), diff --git a/src/ui/toolbar/model/tools.rs b/src/ui/toolbar/model/tools.rs index 96cd76c49..a2b98acf0 100644 --- a/src/ui/toolbar/model/tools.rs +++ b/src/ui/toolbar/model/tools.rs @@ -13,6 +13,7 @@ use crate::ui::toolbar::ToolbarSnapshot; pub(crate) enum SemanticToolIcon { Select, Pen, + LiveShape, Line, Rect, Circle, @@ -49,7 +50,8 @@ const FULL_TOOL_BUTTONS: [Tool; 7] = [ ]; /// Full-mode shape picker: everything the strip no longer shows inline. -const FULL_SHAPE_PICKER_TOOLS: [Tool; 9] = [ +const FULL_SHAPE_PICKER_TOOLS: [Tool; 10] = [ + Tool::LiveShape, Tool::Rect, Tool::Ellipse, Tool::Blur, @@ -61,7 +63,8 @@ const FULL_SHAPE_PICKER_TOOLS: [Tool; 9] = [ Tool::FreeformPolygon, ]; -const SHAPE_TOOLS: [Tool; 11] = [ +const SHAPE_TOOLS: [Tool; 12] = [ + Tool::LiveShape, Tool::Line, Tool::Rect, Tool::Ellipse, @@ -161,6 +164,7 @@ pub(crate) fn toolbar_item_id_for_tool(tool: Tool) -> ToolbarItemId { match tool { Tool::Select => ids::TOP_TOOL_SELECT, Tool::Pen => ids::TOP_TOOL_PEN, + Tool::LiveShape => ids::TOP_TOOL_LIVE_SHAPE, Tool::Line => ids::TOP_TOOL_LINE, Tool::Rect => ids::TOP_TOOL_RECT, Tool::Ellipse => ids::TOP_TOOL_ELLIPSE, @@ -183,6 +187,7 @@ fn tool_for_toolbar_item_id(id: ToolbarItemId) -> Option { [ (ids::TOP_TOOL_SELECT, Tool::Select), (ids::TOP_TOOL_PEN, Tool::Pen), + (ids::TOP_TOOL_LIVE_SHAPE, Tool::LiveShape), (ids::TOP_TOOL_LINE, Tool::Line), (ids::TOP_TOOL_RECT, Tool::Rect), (ids::TOP_TOOL_ELLIPSE, Tool::Ellipse), @@ -332,9 +337,12 @@ pub(crate) enum TopToolGroup { pub(crate) fn top_tool_group(tool: Tool) -> TopToolGroup { match tool { - Tool::Select | Tool::Pen | Tool::Marker | Tool::StepMarker | Tool::Eraser => { - TopToolGroup::Pens - } + Tool::Select + | Tool::Pen + | Tool::LiveShape + | Tool::Marker + | Tool::StepMarker + | Tool::Eraser => TopToolGroup::Pens, _ => TopToolGroup::Shapes, } } @@ -385,6 +393,7 @@ pub(crate) fn semantic_icon_for_tool(tool: Tool) -> SemanticToolIcon { match tool { Tool::Select => SemanticToolIcon::Select, Tool::Pen => SemanticToolIcon::Pen, + Tool::LiveShape => SemanticToolIcon::LiveShape, Tool::Line => SemanticToolIcon::Line, Tool::Rect => SemanticToolIcon::Rect, Tool::Ellipse => SemanticToolIcon::Circle,