From e6f1ca09971eff1c2d7e8df9ae1b9773db9bce79 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:19:28 +0200 Subject: [PATCH 01/11] fix: apply the Fill toggle to Shape Pen shapes Recognized ellipses, rectangles, and triangles were always unfilled even though the Shapes popover offers Fill while Shape Pen is active. They now follow the toggle like the dedicated shape tools, and Shape Pen's style pill shows the Fill control. --- docs/CONFIG.md | 2 +- src/input/state/tests/mod.rs | 1 + src/input/state/tests/shape_pen.rs | 76 ++++++++++++++++++++++++++++++ src/input/tool/catalog.rs | 2 +- src/input/tool/drawing.rs | 3 ++ src/input/tool/live_shape.rs | 10 +++- src/input/tool/live_shape/tests.rs | 2 +- 7 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 src/input/state/tests/shape_pen.rs diff --git a/docs/CONFIG.md b/docs/CONFIG.md index d77aab33..ae3681c4 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -381,7 +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, axis-aligned rectangles, and triangles 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. Nearly horizontal or vertical triangle sides align the same way. Other ink stays freehand. +- **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, axis-aligned rectangles, and triangles preview as shapes and commit as editable shapes. Recognized ovals, rectangles, and triangles follow the Fill toggle, like the dedicated shape tools. 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. Nearly horizontal or vertical triangle sides align the same way. 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) diff --git a/src/input/state/tests/mod.rs b/src/input/state/tests/mod.rs index 9b79a87e..be734650 100644 --- a/src/input/state/tests/mod.rs +++ b/src/input/state/tests/mod.rs @@ -31,6 +31,7 @@ mod properties_panel; mod radial_menu; mod selection; mod session_preflight; +mod shape_pen; mod shape_pen_triangles; mod spotlight; mod status_hud; diff --git a/src/input/state/tests/shape_pen.rs b/src/input/state/tests/shape_pen.rs new file mode 100644 index 00000000..8d194a56 --- /dev/null +++ b/src/input/state/tests/shape_pen.rs @@ -0,0 +1,76 @@ +use super::*; +use crate::input::tool::ProvisionalToolStroke; +use crate::ui::toolbar::{ToolContext, ToolbarSnapshot}; + +/// A hand-drawn rectangle from the top-left corner, clockwise. +const RECTANGLE: [(i32, i32); 16] = [ + (10, 10), + (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), +]; + +fn shape_pen_state() -> InputState { + let mut state = create_test_input_state(); + assert!(state.set_tool_override(Some(Tool::LiveShape))); + state +} + +fn draw_path(state: &mut InputState, path: &[(i32, i32)]) { + let (first, rest) = path.split_first().expect("path has points"); + state.on_mouse_press(MouseButton::Left, first.0, first.1); + for &(x, y) in rest { + state.on_mouse_motion(x, y); + } +} + +fn release_at_end(state: &mut InputState, path: &[(i32, i32)]) { + let last = path.last().expect("path has points"); + state.on_mouse_release(MouseButton::Left, last.0, last.1); +} + +#[test] +fn recognized_closed_shapes_follow_the_fill_toggle() { + for fill_enabled in [false, true] { + let mut state = shape_pen_state(); + state.style.fill_enabled = fill_enabled; + + draw_path(&mut state, &RECTANGLE); + assert!(matches!( + state.provisional_tool_stroke(10, 10), + ProvisionalToolStroke::Shape(Shape::Rect { fill, .. }) if fill == fill_enabled + )); + release_at_end(&mut state, &RECTANGLE); + + assert!( + matches!( + state.boards.active_frame().shapes[0].shape, + Shape::Rect { fill, .. } if fill == fill_enabled + ), + "fill {fill_enabled}" + ); + } +} + +#[test] +fn shape_pen_style_pill_offers_fill_and_smoothing() { + let state = shape_pen_state(); + + let context = ToolContext::from_snapshot(&ToolbarSnapshot::from_input(&state)); + + assert!(context.show_fill_toggle); + assert!(context.show_pen_smoothing); + assert_eq!(context.thickness_label, "Thickness"); +} diff --git a/src/input/tool/catalog.rs b/src/input/tool/catalog.rs index e3a7443b..5e33511f 100644 --- a/src/input/tool/catalog.rs +++ b/src/input/tool/catalog.rs @@ -131,7 +131,7 @@ const DESCRIPTORS: [ToolDescriptor; 18] = [ profile: profile( ToolSettingsSlot::Pen, ToolSizeSource::DrawingThickness, - ToolControlGroup::Stroke, + ToolControlGroup::Shape, true, "Thickness", ), diff --git a/src/input/tool/drawing.rs b/src/input/tool/drawing.rs index dc416c4a..8cd4dadd 100644 --- a/src/input/tool/drawing.rs +++ b/src/input/tool/drawing.rs @@ -157,6 +157,7 @@ impl Tool { &snapshot.points, snapshot.color, snapshot.size, + snapshot.fill_enabled, snapshot.grid, snapshot.shape_recognition_sensitivity, ) @@ -167,6 +168,7 @@ impl Tool { &path, snapshot.color, snapshot.size, + snapshot.fill_enabled, snapshot.grid, snapshot.shape_recognition_sensitivity, ) @@ -305,6 +307,7 @@ impl Tool { snapshot.points, snapshot.color, snapshot.size, + snapshot.fill_enabled, snapshot.grid, snapshot.shape_recognition_sensitivity, ) { diff --git a/src/input/tool/live_shape.rs b/src/input/tool/live_shape.rs index ae4bc705..b446d7ae 100644 --- a/src/input/tool/live_shape.rs +++ b/src/input/tool/live_shape.rs @@ -11,6 +11,7 @@ pub(super) fn recognize( points: &[(i32, i32)], color: Color, thick: f64, + fill: bool, grid: BoardGrid, sensitivity: u8, ) -> Option { @@ -28,8 +29,15 @@ pub(super) fn recognize( && 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) + && let Some(mut shape) = recognize_closed(points, bounds, length, color, thick, sensitivity) { + // Closed shapes follow the Fill toggle, like the dedicated shape tools. + if let Shape::Ellipse { fill: filled, .. } + | Shape::Rect { fill: filled, .. } + | Shape::Polygon { fill: filled, .. } = &mut shape + { + *filled = fill; + } return Some(shape); } diff --git a/src/input/tool/live_shape/tests.rs b/src/input/tool/live_shape/tests.rs index 04e139fe..6f920a9c 100644 --- a/src/input/tool/live_shape/tests.rs +++ b/src/input/tool/live_shape/tests.rs @@ -2,7 +2,7 @@ use crate::domain::BoardGrid; use crate::draw::{BLACK, PolygonKind, Shape}; fn recognize(points: &[(i32, i32)], sensitivity: u8) -> Option { - super::recognize(points, BLACK, 3.0, BoardGrid::default(), sensitivity) + super::recognize(points, BLACK, 3.0, false, BoardGrid::default(), sensitivity) } fn triangle_points(shape: Option) -> Option> { From 44d128cfa1edae89dbee0818b2588525f7036d36 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:22:10 +0200 Subject: [PATCH 02/11] fix: call the Shape Pen tool by one name The palette, tooltip, and help called it "Live Shaping Tool" while the toolbar said "Shape Pen" and the configurator "Shape pen". Use "Shape Pen" everywhere, with "Shape Pen Tool" where other tools read "Pen Tool" or "Line Tool". Also cover its toolbar icon in the painter test. --- README.md | 2 +- configurator/src/models/fields/tool.rs | 4 ++-- docs/CONFIG.md | 2 +- src/config/action_meta/entries/tools.rs | 2 +- src/config/types/toolbar/items/definitions.rs | 2 +- src/input/tool/catalog.rs | 2 +- src/toolbar_icons/mod.rs | 3 ++- 7 files changed, 9 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index c83b1216..06db4eca 100644 --- a/README.md +++ b/README.md @@ -117,7 +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, rectangles, or triangles; 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. +- Shape Pen: draw naturally and confident strokes preview and become editable lines, circles, ovals, rectangles, or triangles; other strokes remain freehand. Choose **Shape Pen 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/configurator/src/models/fields/tool.rs b/configurator/src/models/fields/tool.rs index 55a2c77f..1e1bf6ec 100644 --- a/configurator/src/models/fields/tool.rs +++ b/configurator/src/models/fields/tool.rs @@ -89,7 +89,7 @@ impl ToolOption { match self { Self::Select => "Select", Self::Pen => "Pen", - Self::LiveShape => "Shape pen", + Self::LiveShape => "Shape Pen", Self::Line => "Line", Self::Rect => "Rectangle", Self::Ellipse => "Ellipse", @@ -222,7 +222,7 @@ impl DragToolOption { Self::Default => "Button default", Self::Select => "Select", Self::Pen => "Pen", - Self::LiveShape => "Shape pen", + Self::LiveShape => "Shape Pen", Self::Line => "Line", Self::Rect => "Rectangle", Self::Ellipse => "Ellipse", diff --git a/docs/CONFIG.md b/docs/CONFIG.md index ae3681c4..35c60672 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -381,7 +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, axis-aligned rectangles, and triangles preview as shapes and commit as editable shapes. Recognized ovals, rectangles, and triangles follow the Fill toggle, like the dedicated shape tools. 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. Nearly horizontal or vertical triangle sides align the same way. Other ink stays freehand. +- **Shape Pen**: Choose **Shape Pen** from the Shapes picker or **Shape Pen Tool** from the command palette; bind `select_live_shape_tool` for a shortcut. Confident lines, circles, ovals, axis-aligned rectangles, and triangles preview as shapes and commit as editable shapes. Recognized ovals, rectangles, and triangles follow the Fill toggle, like the dedicated shape tools. 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. Nearly horizontal or vertical triangle sides align the same way. 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) diff --git a/src/config/action_meta/entries/tools.rs b/src/config/action_meta/entries/tools.rs index b626025f..73b045ae 100644 --- a/src/config/action_meta/entries/tools.rs +++ b/src/config/action_meta/entries/tools.rs @@ -47,7 +47,7 @@ pub const ENTRIES: &[ActionMeta] = &[ ), meta!( SelectLiveShapeTool, - "Live Shaping Tool", + "Shape Pen Tool", Some("Shape Pen"), "Turn confident ink into lines, ellipses, rectangles, and triangles", Tools, diff --git a/src/config/types/toolbar/items/definitions.rs b/src/config/types/toolbar/items/definitions.rs index 672103c6..1f5104c3 100644 --- a/src/config/types/toolbar/items/definitions.rs +++ b/src/config/types/toolbar/items/definitions.rs @@ -18,7 +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_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/input/tool/catalog.rs b/src/input/tool/catalog.rs index 5e33511f..bf4688a8 100644 --- a/src/input/tool/catalog.rs +++ b/src/input/tool/catalog.rs @@ -126,7 +126,7 @@ const DESCRIPTORS: [ToolDescriptor; 18] = [ ToolDescriptor { tool: Tool::LiveShape, short_label: "Shape Pen", - display_label: "Live Shaping Tool", + display_label: "Shape Pen Tool", action: Some(Action::SelectLiveShapeTool), profile: profile( ToolSettingsSlot::Pen, diff --git a/src/toolbar_icons/mod.rs b/src/toolbar_icons/mod.rs index 2400657e..dfd54ec2 100644 --- a/src/toolbar_icons/mod.rs +++ b/src/toolbar_icons/mod.rs @@ -155,7 +155,7 @@ mod painter_tests { /// Every public painter. `svg.rs` covers the newer family through its own /// `render_*` entry points; this covers the shipped surface callers use, /// including the older proportional-style painters that had no coverage. - const PAINTERS: [(&str, IconPainter); 64] = [ + const PAINTERS: [(&str, IconPainter); 65] = [ ("arrow", draw_icon_arrow), ("blur", draw_icon_blur), ("board", draw_icon_board), @@ -183,6 +183,7 @@ mod painter_tests { ("layout_regular", draw_icon_layout_regular), ("layout_simple", draw_icon_layout_simple), ("line", draw_icon_line), + ("live_shape", draw_icon_live_shape), ("lock", draw_icon_lock), ("marker", draw_icon_marker), ("minimize", draw_icon_minimize), From 46d251a737f4864072cb588fd56e43b75ebf72ef Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:26:16 +0200 Subject: [PATCH 03/11] feat: name the recognized shape in Shape Pen's live readout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit While drawing with Shape Pen, the shape readout badge now says what release will commit, such as "Triangle 120 × 90" or "Line 140", and stays hidden while the stroke is still ink. The rectangle and ellipse tools keep their plain width and height readout. --- config.example.toml | 3 +- configurator/src/app/pages/ui/general.rs | 2 +- docs/CONFIG.md | 5 +- .../wayland/state/render/measure_badge.rs | 6 +- .../wayland/state/render/ui_effect_damage.rs | 4 +- src/config/types/ui.rs | 3 +- src/input/state/render.rs | 53 ++++++++--- src/input/state/tests/drawing.rs | 25 +++-- src/input/state/tests/shape_pen.rs | 31 ++++++ src/ui.rs | 3 +- src/ui/measure_badge.rs | 95 ++++++++++++++++--- 11 files changed, 186 insertions(+), 44 deletions(-) diff --git a/config.example.toml b/config.example.toml index 9682c6ae..6f58320c 100644 --- a/config.example.toml +++ b/config.example.toml @@ -479,7 +479,8 @@ show_capabilities_warning = true # The guided tour remains available manually when this is false. show_onboarding_hints = true -# Show rectangle and ellipse preview dimensions in logical board pixels. +# Show rectangle and ellipse preview dimensions in logical board pixels, and +# the shape Shape Pen has recognized with its size (a line shows its length). # This is separate from capture.region.show_size_readout. show_shape_size_readout = true diff --git a/configurator/src/app/pages/ui/general.rs b/configurator/src/app/pages/ui/general.rs index a08c0d2e..36a8716d 100644 --- a/configurator/src/app/pages/ui/general.rs +++ b/configurator/src/app/pages/ui/general.rs @@ -67,7 +67,7 @@ pub(super) fn build(sender: &ComponentSender) -> BuiltPage { ) .switch_row( "Show shape size readout", - "Shows rectangle and ellipse preview dimensions in logical board pixels. Separate from the region-capture size readout.", + "Shows rectangle and ellipse preview dimensions in logical board pixels, and the shape Shape Pen has recognized. Separate from the region-capture size readout.", |app| app.draft.ui_show_shape_size_readout, |value| Message::ToggleChanged(ToggleField::UiShowShapeSizeReadout, value), ) diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 35c60672..fba2fb15 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -925,7 +925,8 @@ show_capabilities_warning = true # appearances. The guided tour remains available manually when this is false. show_onboarding_hints = true -# Show rectangle and ellipse preview dimensions in logical board pixels. +# Show rectangle and ellipse preview dimensions in logical board pixels, and +# the shape Shape Pen has recognized with its size (a line shows its length). # This is separate from capture.region.show_size_readout. show_shape_size_readout = true @@ -1401,7 +1402,7 @@ top_controls = [ - **Context-aware UI**: `context_aware_ui` shows/hides tool-specific controls (colors, thickness, arrow labels, etc.) based on the active tool; disable to always show all controls. - **Preset toasts**: `show_preset_toasts` enables toast confirmations for preset apply/save/clear. - **Automatic guidance**: `show_onboarding_hints` controls first-run cards, discovery tips, and shortcut coaching. Discovery and coaching tips offer **Got it** (permanently acknowledge that tip) and **Tip settings…** (acknowledge it, then open the Configurator at this setting); the toolbar-hidden recovery tip keeps **Show** as its primary control and offers the same settings route. Using the board picker, bottom-right zoom controls, or Canvas popover also acknowledges the matching tip. Clicking the message body dismisses a tip only for the current run; an unattended tip stops after three appearances. Set this option to `false` to disable all automatic tutorials on later overlay launches; the running overlay does not live-reload this Configurator change. The guided tour remains available manually, and capability, safety, and configuration warnings are unaffected. Completed profiles migrated from onboarding versions before v6 are not enrolled in the later status-bar, Canvas, and zoom tip series. If onboarding progress cannot be saved, automatic guidance is disabled for that run and an actionable persistence warning is shown. -- **Shape size readout**: `show_shape_size_readout` controls the live rectangle and ellipse preview dimensions, measured in logical board pixels. Ellipse values match the diameter that will be committed, so an odd drag span rounds down to the nearest even diameter. It defaults to `true` and is separate from `capture.region.show_size_readout`, which describes a region-capture selection. +- **Shape size readout**: `show_shape_size_readout` controls the live rectangle and ellipse preview dimensions, measured in logical board pixels. With Shape Pen it names the shape that release will commit, such as "Triangle 120 × 90" or "Line 140", and stays hidden while the stroke is still ink. Ellipse values match the diameter that will be committed, so an odd drag span rounds down to the nearest even diameter. It defaults to `true` and is separate from `capture.region.show_size_readout`, which describes a region-capture selection. - **Capability warnings**: `show_capabilities_warning` independently controls compositor limitation warnings; disabling tutorials does not hide safety, configuration, or capability diagnostics. - **Tool preview**: `show_tool_preview` toggles the cursor bubble. - **Offsets**: `top_offset` and `top_offset_y` are the authored default top-toolbar position. Dragging the strip saves its position as a runtime preference in `runtime-ui.toml` and leaves these untouched; editing one here again takes over from the saved drag. diff --git a/src/backend/wayland/state/render/measure_badge.rs b/src/backend/wayland/state/render/measure_badge.rs index 58e596ef..f4a26f21 100644 --- a/src/backend/wayland/state/render/measure_badge.rs +++ b/src/backend/wayland/state/render/measure_badge.rs @@ -9,11 +9,13 @@ impl WaylandState { ) -> Option { let pointer = self.pointer.position(); let world = self.canvas_world_coords(pointer.0 as f64, pointer.1 as f64); - let size = self.input_state.provisional_shape_size(world.0, world.1)?; + let readout = self + .input_state + .provisional_shape_readout(world.0, world.1)?; crate::ui::measure_shape_badge( self.render.ui_text(), self.config.ui.show_shape_size_readout, - size, + readout, (pointer.0 as f64, pointer.1 as f64), width, height, diff --git a/src/backend/wayland/state/render/ui_effect_damage.rs b/src/backend/wayland/state/render/ui_effect_damage.rs index 2ec38d83..ae72acb9 100644 --- a/src/backend/wayland/state/render/ui_effect_damage.rs +++ b/src/backend/wayland/state/render/ui_effect_damage.rs @@ -400,7 +400,7 @@ mod tests { let first = crate::ui::measure_shape_badge( &crate::ui_text::UiTextEngine::default(), true, - (20, 30), + crate::ui::ShapeReadout::size(20, 30), (100.0, 100.0), 800, 600, @@ -409,7 +409,7 @@ mod tests { let second = crate::ui::measure_shape_badge( &crate::ui_text::UiTextEngine::default(), true, - (200, 300), + crate::ui::ShapeReadout::size(200, 300), (300.0, 250.0), 800, 600, diff --git a/src/config/types/ui.rs b/src/config/types/ui.rs index daf314b6..f1576926 100644 --- a/src/config/types/ui.rs +++ b/src/config/types/ui.rs @@ -121,7 +121,8 @@ pub struct UiConfig { #[serde(default = "default_show_onboarding_hints")] pub show_onboarding_hints: bool, - /// Show rectangle and ellipse preview dimensions in logical board pixels. + /// Show rectangle and ellipse preview dimensions in logical board pixels, + /// and the shape Shape Pen has recognized. #[serde(default = "default_show_shape_size_readout")] pub show_shape_size_readout: bool, diff --git a/src/input/state/render.rs b/src/input/state/render.rs index c9a9b5c0..155bf87f 100644 --- a/src/input/state/render.rs +++ b/src/input/state/render.rs @@ -7,38 +7,61 @@ use crate::input::Tool; use crate::input::tool::{ PolygonProvisionalSnapshot, ProvisionalToolSnapshot, ProvisionalToolStroke, }; +use crate::ui::{ShapeExtent, ShapeReadout}; use crate::util::Rect; use std::ops::Range; use super::{DrawingState, InputState}; impl InputState { - /// Logical canvas dimensions for the in-progress shape-size badge. + /// What the in-progress shape badge reports, in logical canvas pixels. /// - /// Only the rectangle and ellipse tools expose a width/height readout; - /// path-like and line-like tools keep their existing uncluttered preview. - pub(crate) fn provisional_shape_size( + /// The rectangle and ellipse tools show their width and height. Shape Pen + /// names the shape it has recognized, so the badge says what release will + /// commit, and shows nothing while the stroke is still ink. Other path-like + /// and line-like tools keep their uncluttered preview. + pub(crate) fn provisional_shape_readout( &self, current_x: i32, current_y: i32, - ) -> Option<(u32, u32)> { + ) -> Option { let DrawingState::Drawing { tool, .. } = &self.state else { return None; }; - if !matches!(tool, Tool::Rect | Tool::Ellipse) { + let names_shape = match tool { + Tool::Rect | Tool::Ellipse => false, + Tool::LiveShape => true, + _ => return None, + }; + let ProvisionalToolStroke::Shape(shape) = + self.provisional_tool_stroke(current_x, current_y) + else { return None; - } + }; - match self.provisional_tool_stroke(current_x, current_y) { - ProvisionalToolStroke::Shape(Shape::Rect { w, h, .. }) => { - Some((w.unsigned_abs(), h.unsigned_abs())) - } - ProvisionalToolStroke::Shape(Shape::Ellipse { rx, ry, .. }) => Some(( + let extent = match &shape { + Shape::Rect { w, h, .. } => ShapeExtent::Size(w.unsigned_abs(), h.unsigned_abs()), + Shape::Ellipse { rx, ry, .. } => ShapeExtent::Size( rx.unsigned_abs().saturating_mul(2), ry.unsigned_abs().saturating_mul(2), - )), - _ => None, - } + ), + Shape::Polygon { points, .. } => { + let span = |axis: fn(&(i32, i32)) -> i32| { + let low = points.iter().map(axis).min().unwrap_or(0); + let high = points.iter().map(axis).max().unwrap_or(0); + low.abs_diff(high) + }; + ShapeExtent::Size(span(|point| point.0), span(|point| point.1)) + } + Shape::Line { x1, y1, x2, y2, .. } => { + ShapeExtent::Length((f64::from(x2 - x1).hypot(f64::from(y2 - y1))).round() as u32) + } + _ => return None, + }; + Some(ShapeReadout { + kind: names_shape.then(|| shape.kind_name()), + extent, + }) } pub(crate) fn provisional_tool_stroke( diff --git a/src/input/state/tests/drawing.rs b/src/input/state/tests/drawing.rs index 92ac4b3e..7e500087 100644 --- a/src/input/state/tests/drawing.rs +++ b/src/input/state/tests/drawing.rs @@ -1,6 +1,7 @@ use super::*; use crate::draw::BlurStyle; use crate::input::{DragBinding, DragButtonBindings, DragToolBindings}; +use crate::ui::ShapeReadout; use crate::ui::toolbar::ToolbarEvent; fn left_drag_bindings( @@ -1175,7 +1176,7 @@ fn sync_highlight_color_marks_dirty_when_pen_color_changes() { } #[test] -fn provisional_shape_size_reports_rect_and_ellipse_logical_extents() { +fn provisional_shape_readout_reports_rect_and_ellipse_logical_extents() { let mut state = create_test_input_state(); for tool in [Tool::Rect, Tool::Ellipse] { @@ -1187,7 +1188,10 @@ fn provisional_shape_size_reports_rect_and_ellipse_logical_extents() { point_thicknesses: Vec::new(), }; - assert_eq!(state.provisional_shape_size(20, 175), Some((120, 80))); + assert_eq!( + state.provisional_shape_readout(20, 175), + Some(ShapeReadout::size(120, 80)) + ); } state.state = DrawingState::Drawing { @@ -1197,7 +1201,10 @@ fn provisional_shape_size_reports_rect_and_ellipse_logical_extents() { points: Vec::new(), point_thicknesses: Vec::new(), }; - assert_eq!(state.provisional_shape_size(0, 0), Some((10, 14))); + assert_eq!( + state.provisional_shape_readout(0, 0), + Some(ShapeReadout::size(10, 14)) + ); state.state = DrawingState::Drawing { tool: Tool::Rect, @@ -1206,13 +1213,17 @@ fn provisional_shape_size_reports_rect_and_ellipse_logical_extents() { points: Vec::new(), point_thicknesses: Vec::new(), }; - assert_eq!(state.provisional_shape_size(7, 9), Some((0, 0))); + assert_eq!( + state.provisional_shape_readout(7, 9), + Some(ShapeReadout::size(0, 0)) + ); } #[test] -fn provisional_shape_size_excludes_freehand_and_non_size_badge_tools() { +fn provisional_shape_readout_excludes_freehand_and_non_size_badge_tools() { let mut state = create_test_input_state(); + // A single Shape Pen point is still ink, so it has no readout either. for tool in Tool::ALL .into_iter() .filter(|tool| !matches!(tool, Tool::Rect | Tool::Ellipse)) @@ -1225,11 +1236,11 @@ fn provisional_shape_size_excludes_freehand_and_non_size_badge_tools() { point_thicknesses: vec![2.0], }; - assert_eq!(state.provisional_shape_size(40, 70), None, "{tool:?}"); + assert_eq!(state.provisional_shape_readout(40, 70), None, "{tool:?}"); } state.state = DrawingState::Idle; - assert_eq!(state.provisional_shape_size(40, 70), None); + assert_eq!(state.provisional_shape_readout(40, 70), None); } fn test_rects_intersect(a: crate::util::Rect, b: crate::util::Rect) -> bool { diff --git a/src/input/state/tests/shape_pen.rs b/src/input/state/tests/shape_pen.rs index 8d194a56..51f568fc 100644 --- a/src/input/state/tests/shape_pen.rs +++ b/src/input/state/tests/shape_pen.rs @@ -1,6 +1,7 @@ use super::*; use crate::input::tool::ProvisionalToolStroke; use crate::ui::toolbar::{ToolContext, ToolbarSnapshot}; +use crate::ui::{ShapeExtent, ShapeReadout}; /// A hand-drawn rectangle from the top-left corner, clockwise. const RECTANGLE: [(i32, i32); 16] = [ @@ -74,3 +75,33 @@ fn shape_pen_style_pill_offers_fill_and_smoothing() { assert!(context.show_pen_smoothing); assert_eq!(context.thickness_label, "Thickness"); } + +#[test] +fn readout_names_the_recognized_shape_and_stays_quiet_for_ink() { + let mut state = shape_pen_state(); + + // Two sides of the rectangle are neither a line nor a closed shape yet. + draw_path(&mut state, &RECTANGLE[..8]); + assert_eq!(state.provisional_shape_readout(110, 90), None); + + for &(x, y) in &RECTANGLE[8..] { + state.on_mouse_motion(x, y); + } + assert_eq!( + state.provisional_shape_readout(10, 10), + Some(ShapeReadout { + kind: Some("Rectangle"), + extent: ShapeExtent::Size(104, 84), + }) + ); + release_at_end(&mut state, &RECTANGLE); + + draw_path(&mut state, &[(0, 200), (40, 201), (80, 199), (120, 200)]); + assert_eq!( + state.provisional_shape_readout(120, 200), + Some(ShapeReadout { + kind: Some("Line"), + extent: ShapeExtent::Length(120), + }) + ); +} diff --git a/src/ui.rs b/src/ui.rs index 3dc53dce..9fd8a0e8 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -58,7 +58,8 @@ pub use help_overlay::{ pub use input_hud::{input_hud_geometry, render_input_hud}; pub(crate) use input_hud::{input_hud_geometry_with_engine, render_input_hud_with_engine}; pub(crate) use measure_badge::{ - ShapeMeasureBadge, measure_shape_badge, shape_measure_badge_text_style, + ShapeExtent, ShapeMeasureBadge, ShapeReadout, measure_shape_badge, + shape_measure_badge_text_style, }; pub(crate) use ocr_scan::{ ocr_scan_geometry, render_ocr_scan_result, render_ocr_scan_still, render_ocr_scan_sweep, diff --git a/src/ui/measure_badge.rs b/src/ui/measure_badge.rs index bc86a05f..e69794b5 100644 --- a/src/ui/measure_badge.rs +++ b/src/ui/measure_badge.rs @@ -1,4 +1,5 @@ -//! Pure layout for the live rectangle/ellipse size readout. +//! Pure layout for the live shape readout: rectangle and ellipse sizes, and +//! the shape Shape Pen has recognized. use crate::ui_text::{UiTextEngine, UiTextStyle}; @@ -15,6 +16,42 @@ pub(crate) struct ShapeMeasureBadge { pub baseline: (f64, f64), } +/// What the live shape readout reports, in logical board pixels. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct ShapeReadout { + /// Recognized shape name. Shape Pen names what release will commit; the + /// dedicated shape tools already say what they draw. + pub kind: Option<&'static str>, + pub extent: ShapeExtent, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum ShapeExtent { + Size(u32, u32), + Length(u32), +} + +impl ShapeReadout { + #[cfg(test)] + pub(crate) fn size(width: u32, height: u32) -> Self { + Self { + kind: None, + extent: ShapeExtent::Size(width, height), + } + } + + fn text(self) -> String { + let extent = match self.extent { + ShapeExtent::Size(width, height) => format!("{width} × {height}"), + ShapeExtent::Length(length) => length.to_string(), + }; + match self.kind { + Some(kind) => format!("{kind} {extent}"), + None => extent, + } + } +} + pub(crate) fn shape_measure_badge_text_style() -> UiTextStyle<'static> { UiTextStyle { family: "monospace", @@ -30,7 +67,7 @@ pub(crate) fn shape_measure_badge_text_style() -> UiTextStyle<'static> { pub(crate) fn measure_shape_badge( engine: &UiTextEngine, enabled: bool, - size: (u32, u32), + readout: ShapeReadout, pointer: (f64, f64), screen_width: u32, screen_height: u32, @@ -38,7 +75,7 @@ pub(crate) fn measure_shape_badge( if !enabled { return None; } - let text = format!("{} × {}", size.0, size.1); + let text = readout.text(); let extents = engine.measure(shape_measure_badge_text_style(), &text, None)?; let width = (extents.width() + PADDING_X * 2.0) .min((screen_width as f64 - SCREEN_MARGIN * 2.0).max(0.0)); @@ -79,7 +116,7 @@ mod tests { let badge = measure_shape_badge( &UiTextEngine::default(), true, - (120, 80), + ShapeReadout::size(120, 80), (100.0, 100.0), 1920, 1080, @@ -88,7 +125,7 @@ mod tests { let zero = measure_shape_badge( &UiTextEngine::default(), true, - (0, 0), + ShapeReadout::size(0, 0), (100.0, 100.0), 1920, 1080, @@ -99,13 +136,38 @@ mod tests { assert_eq!(zero.text, "0 × 0"); } + #[test] + fn recognized_shapes_are_named_before_their_extent() { + let text = |kind, extent| { + measure_shape_badge( + &UiTextEngine::default(), + true, + ShapeReadout { + kind: Some(kind), + extent, + }, + (100.0, 100.0), + 1920, + 1080, + ) + .expect("text measurement") + .text + }; + + assert_eq!( + text("Triangle", ShapeExtent::Size(120, 90)), + "Triangle 120 × 90" + ); + assert_eq!(text("Line", ShapeExtent::Length(140)), "Line 140"); + } + #[test] fn disabled_badge_has_no_visual() { assert!( measure_shape_badge( &UiTextEngine::default(), false, - (120, 80), + ShapeReadout::size(120, 80), (100.0, 100.0), 1920, 1080 @@ -117,9 +179,16 @@ mod tests { #[test] fn badge_handles_every_horizontal_and_vertical_flip_combination() { let layout = |pointer| { - measure_shape_badge(&UiTextEngine::default(), true, (120, 80), pointer, 400, 300) - .expect("text measurement") - .bounds + measure_shape_badge( + &UiTextEngine::default(), + true, + ShapeReadout::size(120, 80), + pointer, + 400, + 300, + ) + .expect("text measurement") + .bounds }; let below_right = layout((20.0, 30.0)); assert!(below_right.0 > 20.0 && below_right.1 > 30.0); @@ -139,7 +208,7 @@ mod tests { let (x, y, width, height) = measure_shape_badge( &UiTextEngine::default(), true, - (3840, 2160), + ShapeReadout::size(3840, 2160), (2.0, 2.0), 80, 20, @@ -156,7 +225,9 @@ mod tests { fn retained_measurement_owner_keeps_geometry_after_scaled_label_paint() { let engine = UiTextEngine::default(); for size in [(120, 80), (3840, 2160), (120, 80)] { - let badge = measure_shape_badge(&engine, true, size, (100.0, 100.0), 800, 600).unwrap(); + let readout = ShapeReadout::size(size.0, size.1); + let badge = + measure_shape_badge(&engine, true, readout, (100.0, 100.0), 800, 600).unwrap(); for density in [1, 2, 1] { let paint = |owner: &UiTextEngine| { let mut surface = cairo::ImageSurface::create( @@ -182,7 +253,7 @@ mod tests { ); assert!(retained.iter().any(|byte| *byte != 0)); let repeated = - measure_shape_badge(&engine, true, size, (100.0, 100.0), 800, 600).unwrap(); + measure_shape_badge(&engine, true, readout, (100.0, 100.0), 800, 600).unwrap(); assert_eq!(repeated.bounds, badge.bounds); assert_eq!(repeated.baseline, badge.baseline); assert_eq!(repeated.text, badge.text); From b838eee3c263e635095b0107c7b61a8fa41de47a Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:35:20 +0200 Subject: [PATCH 04/11] feat: adjust Shape Pen sensitivity while drawing Shape Pen sensitivity could only be set in the config file or the configurator, and took effect after reopening the overlay. Add a Sensitivity stepper to Shape Pen's style pill, unbound Increase and Decrease Shape Pen Sensitivity actions with a toast, and remember the level with the session like pen smoothing. The config value is the starting level. --- README.md | 2 +- config.example.toml | 6 ++ .../src/models/keybindings/field/list.rs | 2 + docs/CONFIG.md | 4 +- src/backend/wayland/session/tests.rs | 1 + src/backend/wayland/toolbar/view/top/build.rs | 1 + src/config/action_meta/entries/tools.rs | 20 +++++++ src/config/action_meta/tests.rs | 2 + src/config/keybindings/config/map/edit.rs | 2 + src/config/keybindings/config/map/tools.rs | 8 +++ .../config/types/bindings/tools.rs | 12 ++++ src/config/keybindings/defaults/tools.rs | 10 ++++ src/config/keybindings/tests.rs | 2 + src/configurator_destination.rs | 2 + src/domain/action.rs | 2 + src/domain/tests.rs | 8 +++ src/input/state/actions/action_tools.rs | 22 ++++++++ src/input/state/core/style.rs | 26 ++++++++- .../state/core/tool_controls/settings.rs | 19 +++++++ src/input/state/core/toolbar/apply/mod.rs | 3 + src/input/state/interaction/actions.rs | 2 + src/input/state/tests/shape_pen.rs | 55 ++++++++++++++++++- src/session/snapshot/tests.rs | 1 + src/session/snapshot/types.rs | 5 ++ src/session/storage/tests.rs | 1 + src/session/tests/limits.rs | 1 + src/session/tests/snapshot.rs | 25 +++++++++ src/toolbar_gtk/view/top_bar/style_pill.rs | 1 + .../help_overlay/sections/builder/sections.rs | 11 ++++ src/ui/toolbar/events.rs | 2 + src/ui/toolbar/model/event_policy.rs | 1 + src/ui/toolbar/model/style_pill.rs | 6 ++ src/ui/toolbar/model/style_pill/control.rs | 44 ++++++++++++++- .../model/style_pill/tests/tool_states.rs | 45 +++++++++++++++ src/ui/toolbar/snapshot/build.rs | 1 + src/ui/toolbar/snapshot/types.rs | 8 +++ tests/cli.rs | 1 + 37 files changed, 358 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 06db4eca..aa9d9924 100644 --- a/README.md +++ b/README.md @@ -117,7 +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, rectangles, or triangles; other strokes remain freehand. Choose **Shape Pen 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. +- Shape Pen: draw naturally and confident strokes preview and become editable lines, circles, ovals, rectangles, or triangles; other strokes remain freehand. Choose **Shape Pen Tool** in the command palette or the toolbar's Shapes picker. Adjust recognition with the toolbar's **Sensitivity** stepper, `[drawing] shape_recognition_sensitivity` (0-4), or the configurator's Drawing page; the level is remembered with the session. - 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 6f58320c..c6cc0ae8 100644 --- a/config.example.toml +++ b/config.example.toml @@ -129,6 +129,9 @@ open_font_picker = [] # Step stroke smoothing up and down (unbound by default) increase_pen_smoothing = [] decrease_pen_smoothing = [] +# Step Shape Pen recognition sensitivity up and down (unbound by default) +increase_shape_recognition_sensitivity = [] +decrease_shape_recognition_sensitivity = [] # Select the spotlight tool (unbound by default) select_spotlight_tool = [] # Step the blur tool through blur/pixelate/secure/black-out (unbound by default) @@ -1123,6 +1126,9 @@ pen_smoothing = 3 # and 4 accepts rougher lines and closed shapes. Applies to lines, ellipses, # rectangles, and triangles; it does not change the dedicated shape tools or # grid spacing. +# +# Also on the toolbar as the "Sensitivity" stepper while Shape Pen is up. +# A session remembers the level it was saved at; this is the starting value. shape_recognition_sensitivity = 2 # Default fill state for fill-capable shapes diff --git a/configurator/src/models/keybindings/field/list.rs b/configurator/src/models/keybindings/field/list.rs index 779d386f..1b00a9b5 100644 --- a/configurator/src/models/keybindings/field/list.rs +++ b/configurator/src/models/keybindings/field/list.rs @@ -44,6 +44,8 @@ const PREFERRED_ORDER: &[KeybindingField] = &[ KeybindingField::OpenFontPicker, KeybindingField::IncreasePenSmoothing, KeybindingField::DecreasePenSmoothing, + KeybindingField::IncreaseShapeRecognitionSensitivity, + KeybindingField::DecreaseShapeRecognitionSensitivity, KeybindingField::SelectMarkerTool, KeybindingField::SelectStepMarkerTool, KeybindingField::SelectLineTool, diff --git a/docs/CONFIG.md b/docs/CONFIG.md index fba2fb15..4daa427b 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -381,7 +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)) -- **Shape Pen**: Choose **Shape Pen** from the Shapes picker or **Shape Pen Tool** from the command palette; bind `select_live_shape_tool` for a shortcut. Confident lines, circles, ovals, axis-aligned rectangles, and triangles preview as shapes and commit as editable shapes. Recognized ovals, rectangles, and triangles follow the Fill toggle, like the dedicated shape tools. 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. Nearly horizontal or vertical triangle sides align the same way. Other ink stays freehand. +- **Shape Pen**: Choose **Shape Pen** from the Shapes picker or **Shape Pen Tool** from the command palette; bind `select_live_shape_tool` for a shortcut. Confident lines, circles, ovals, axis-aligned rectangles, and triangles preview as shapes and commit as editable shapes. Recognized ovals, rectangles, and triangles follow the Fill toggle, like the dedicated shape tools. 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. While Shape Pen is active, the toolbar's **Sensitivity** stepper changes it at once; so do **Increase / Decrease Shape Pen Sensitivity** in the command palette, or bind `increase_shape_recognition_sensitivity` / `decrease_shape_recognition_sensitivity`. A session remembers the level it was saved at; the config value is the starting level. 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. Nearly horizontal or vertical triangle sides align the same way. 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) @@ -2178,6 +2178,8 @@ cycle_font_family = ["Shift+T"] # step the text font through drawing.font_cyc open_font_picker = [] # pick from every installed family increase_pen_smoothing = [] # clean up finished strokes more decrease_pen_smoothing = [] # keep more of the drawn path +increase_shape_recognition_sensitivity = [] # Shape Pen: recognize rougher strokes +decrease_shape_recognition_sensitivity = [] # Shape Pen: keep more strokes as ink cycle_blur_style = [] # blur -> pixelate -> secure -> black out cycle_arrow_style = [] # standard -> pointy -> curved -> double select_spotlight_tool = [] # dim everything except a region diff --git a/src/backend/wayland/session/tests.rs b/src/backend/wayland/session/tests.rs index 0f2107a0..9f2813c9 100644 --- a/src/backend/wayland/session/tests.rs +++ b/src/backend/wayland/session/tests.rs @@ -192,6 +192,7 @@ fn sample_tool_state() -> stored_session::ToolStateSnapshot { blur_style: Default::default(), recent_colors: Vec::new(), pen_smoothing: None, + shape_recognition_sensitivity: None, marker_opacity: Some(0.32), spotlight_magnification: None, fill_enabled: Some(false), diff --git a/src/backend/wayland/toolbar/view/top/build.rs b/src/backend/wayland/toolbar/view/top/build.rs index b37db685..efe0e18c 100644 --- a/src/backend/wayland/toolbar/view/top/build.rs +++ b/src/backend/wayland/toolbar/view/top/build.rs @@ -905,6 +905,7 @@ fn push_style_pill( x += ToolbarLayoutSpec::TOP_STYLE_SEL_VALUE_W + gap; } model::StylePillControl::PenSmoothingStepper + | model::StylePillControl::ShapeSensitivityStepper | model::StylePillControl::SelectionStepper(_) => { let enabled = control.enabled(snapshot); let steps = control.required_steps(snapshot); diff --git a/src/config/action_meta/entries/tools.rs b/src/config/action_meta/entries/tools.rs index 73b045ae..9e7870dd 100644 --- a/src/config/action_meta/entries/tools.rs +++ b/src/config/action_meta/entries/tools.rs @@ -274,6 +274,26 @@ pub const ENTRIES: &[ActionMeta] = &[ true, true ), + meta!( + IncreaseShapeRecognitionSensitivity, + "Increase Shape Pen Sensitivity", + None, + "Recognize rougher strokes as shapes", + Tools, + true, + true, + true + ), + meta!( + DecreaseShapeRecognitionSensitivity, + "Decrease Shape Pen Sensitivity", + None, + "Keep more strokes as ink", + Tools, + true, + true, + true + ), meta!( CycleBlurStyle, "Cycle Blur Style", diff --git a/src/config/action_meta/tests.rs b/src/config/action_meta/tests.rs index 8d7fafb4..be0d3a3a 100644 --- a/src/config/action_meta/tests.rs +++ b/src/config/action_meta/tests.rs @@ -183,6 +183,8 @@ const EXPECTED_COMMAND_PALETTE_ACTIONS: &[Action] = &[ Action::OpenFontPicker, Action::IncreasePenSmoothing, Action::DecreasePenSmoothing, + Action::IncreaseShapeRecognitionSensitivity, + Action::DecreaseShapeRecognitionSensitivity, Action::SelectSpotlightTool, Action::CycleBlurStyle, Action::CycleArrowStyle, diff --git a/src/config/keybindings/config/map/edit.rs b/src/config/keybindings/config/map/edit.rs index ae130bf4..7313dab2 100644 --- a/src/config/keybindings/config/map/edit.rs +++ b/src/config/keybindings/config/map/edit.rs @@ -163,6 +163,8 @@ define_action_binding_accessors! { ToggleEraserMode => tools.toggle_eraser_mode, IncreasePenSmoothing => tools.increase_pen_smoothing, DecreasePenSmoothing => tools.decrease_pen_smoothing, + IncreaseShapeRecognitionSensitivity => tools.increase_shape_recognition_sensitivity, + DecreaseShapeRecognitionSensitivity => tools.decrease_shape_recognition_sensitivity, CycleFontFamily => tools.cycle_font_family, OpenFontPicker => tools.open_font_picker, CycleBlurStyle => tools.cycle_blur_style, diff --git a/src/config/keybindings/config/map/tools.rs b/src/config/keybindings/config/map/tools.rs index c89d36d1..06312eaf 100644 --- a/src/config/keybindings/config/map/tools.rs +++ b/src/config/keybindings/config/map/tools.rs @@ -36,6 +36,14 @@ impl KeybindingsConfig { &self.tools.decrease_pen_smoothing, Action::DecreasePenSmoothing, )?; + inserter.insert_all( + &self.tools.increase_shape_recognition_sensitivity, + Action::IncreaseShapeRecognitionSensitivity, + )?; + inserter.insert_all( + &self.tools.decrease_shape_recognition_sensitivity, + Action::DecreaseShapeRecognitionSensitivity, + )?; inserter.insert_all(&self.tools.cycle_font_family, Action::CycleFontFamily)?; inserter.insert_all(&self.tools.open_font_picker, Action::OpenFontPicker)?; inserter.insert_all(&self.tools.cycle_blur_style, Action::CycleBlurStyle)?; diff --git a/src/config/keybindings/config/types/bindings/tools.rs b/src/config/keybindings/config/types/bindings/tools.rs index 54ad586d..fa756441 100644 --- a/src/config/keybindings/config/types/bindings/tools.rs +++ b/src/config/keybindings/config/types/bindings/tools.rs @@ -40,6 +40,14 @@ pub struct ToolKeybindingsConfig { #[serde(default = "default_decrease_pen_smoothing")] pub decrease_pen_smoothing: Vec, + /// Let Shape Pen turn rougher strokes into shapes. + #[serde(default = "default_increase_shape_recognition_sensitivity")] + pub increase_shape_recognition_sensitivity: Vec, + + /// Make Shape Pen keep more strokes as ink. + #[serde(default = "default_decrease_shape_recognition_sensitivity")] + pub decrease_shape_recognition_sensitivity: Vec, + /// Step the text font through `drawing.font_cycle`. #[serde(default = "default_cycle_font_family")] pub cycle_font_family: Vec, @@ -126,6 +134,10 @@ impl Default for ToolKeybindingsConfig { toggle_eraser_mode: default_toggle_eraser_mode(), increase_pen_smoothing: default_increase_pen_smoothing(), decrease_pen_smoothing: default_decrease_pen_smoothing(), + increase_shape_recognition_sensitivity: default_increase_shape_recognition_sensitivity( + ), + decrease_shape_recognition_sensitivity: default_decrease_shape_recognition_sensitivity( + ), cycle_font_family: default_cycle_font_family(), open_font_picker: default_open_font_picker(), cycle_blur_style: default_cycle_blur_style(), diff --git a/src/config/keybindings/defaults/tools.rs b/src/config/keybindings/defaults/tools.rs index 69b07dd0..7f0185d2 100644 --- a/src/config/keybindings/defaults/tools.rs +++ b/src/config/keybindings/defaults/tools.rs @@ -46,6 +46,16 @@ pub(crate) fn default_decrease_pen_smoothing() -> Vec { Vec::new() } +/// Unbound like smoothing: sensitivity is tuned once, and the Shape Pen style +/// pill has a stepper for it. +pub(crate) fn default_increase_shape_recognition_sensitivity() -> Vec { + Vec::new() +} + +pub(crate) fn default_decrease_shape_recognition_sensitivity() -> Vec { + Vec::new() +} + /// `T` enters text mode, so `Shift+T` is the natural neighbour for the font /// that text will be written in. Omasnap uses the same chord for the same job. pub(crate) fn default_cycle_font_family() -> Vec { diff --git a/src/config/keybindings/tests.rs b/src/config/keybindings/tests.rs index 2e9f040c..c1ce6427 100644 --- a/src/config/keybindings/tests.rs +++ b/src/config/keybindings/tests.rs @@ -745,6 +745,8 @@ const DEFAULT_BINDING_SNAPSHOT: &[(&str, &[&str])] = &[ ("open_font_picker", &[]), ("increase_pen_smoothing", &[]), ("decrease_pen_smoothing", &[]), + ("increase_shape_recognition_sensitivity", &[]), + ("decrease_shape_recognition_sensitivity", &[]), ("cycle_blur_style", &[]), ("cycle_arrow_style", &[]), ("select_pen_tool", &["F"]), diff --git a/src/configurator_destination.rs b/src/configurator_destination.rs index e4ded634..ae67a95e 100644 --- a/src/configurator_destination.rs +++ b/src/configurator_destination.rs @@ -113,6 +113,8 @@ pub fn keybindings_section_for_action(action: Action) -> Option self.announce_pen_smoothing(1), Action::DecreasePenSmoothing => self.announce_pen_smoothing(-1), + Action::IncreaseShapeRecognitionSensitivity => { + self.announce_shape_recognition_sensitivity(1); + } + Action::DecreaseShapeRecognitionSensitivity => { + self.announce_shape_recognition_sensitivity(-1); + } Action::ToggleEraserMode => { if self.toggle_eraser_mode() { info!("Eraser mode set to {:?}", self.style.eraser_mode); diff --git a/src/input/state/core/style.rs b/src/input/state/core/style.rs index a1dad7be..3018041e 100644 --- a/src/input/state/core/style.rs +++ b/src/input/state/core/style.rs @@ -79,7 +79,9 @@ 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, + shape_recognition_sensitivity: drawing + .shape_recognition_sensitivity + .min(crate::config::MAX_SHAPE_RECOGNITION_SENSITIVITY), blur_style: drawing.default_blur_style, spotlight_dim_opacity: spotlight.dim_opacity, spotlight_feather: spotlight.feather, @@ -200,6 +202,25 @@ impl DrawingStyle { true } + pub(crate) fn nudge_shape_recognition_sensitivity(&mut self, delta: i32) -> bool { + let next = i32::from(self.shape_recognition_sensitivity) + .saturating_add(delta) + .clamp( + 0, + i32::from(crate::config::MAX_SHAPE_RECOGNITION_SENSITIVITY), + ); + self.set_shape_recognition_sensitivity(next as u8) + } + + pub(crate) fn set_shape_recognition_sensitivity(&mut self, level: u8) -> bool { + let level = level.min(crate::config::MAX_SHAPE_RECOGNITION_SENSITIVITY); + if level == self.shape_recognition_sensitivity { + return false; + } + self.shape_recognition_sensitivity = level; + true + } + pub(crate) fn set_spotlight_magnification(&mut self, magnification: f64) -> bool { let normalized = ToolbarSliderSpec::SPOTLIGHT_MAGNIFICATION.normalize_value( crate::draw::normalize_spotlight_magnification(magnification), @@ -409,6 +430,9 @@ impl DrawingStyle { if let Some(level) = snapshot.pen_smoothing { let _ = self.set_pen_smoothing(level); } + if let Some(level) = snapshot.shape_recognition_sensitivity { + let _ = self.set_shape_recognition_sensitivity(level); + } if let Some(opacity) = snapshot.marker_opacity { let _ = self.set_marker_opacity(opacity); } diff --git a/src/input/state/core/tool_controls/settings.rs b/src/input/state/core/tool_controls/settings.rs index 2e05cd7a..9670d6b9 100644 --- a/src/input/state/core/tool_controls/settings.rs +++ b/src/input/state/core/tool_controls/settings.rs @@ -224,6 +224,25 @@ impl InputState { true } + /// Steps how readily Shape Pen turns ink into shapes. Returns true if it + /// changed. Clamped rather than wrapped, like pen smoothing. + pub fn nudge_shape_recognition_sensitivity(&mut self, delta: i32) -> bool { + if !self.style.nudge_shape_recognition_sensitivity(delta) { + return false; + } + self.mark_session_dirty(); + true + } + + /// Sets the Shape Pen sensitivity directly, from the toolbar stepper. + pub fn set_shape_recognition_sensitivity(&mut self, level: u8) -> bool { + if !self.style.set_shape_recognition_sensitivity(level) { + return false; + } + self.mark_session_dirty(); + true + } + /// Sets the magnification stored on newly drawn spotlights. /// /// Deliberately requests no warning feedback: this changes what the *next* diff --git a/src/input/state/core/toolbar/apply/mod.rs b/src/input/state/core/toolbar/apply/mod.rs index d0a5481c..042b2be5 100644 --- a/src/input/state/core/toolbar/apply/mod.rs +++ b/src/input/state/core/toolbar/apply/mod.rs @@ -95,6 +95,9 @@ impl InputState { self.apply_toolbar_set_spotlight_magnification(value) } ToolbarEvent::SetPenSmoothing(level) => self.apply_toolbar_set_pen_smoothing(level), + ToolbarEvent::SetShapeRecognitionSensitivity(level) => { + self.set_shape_recognition_sensitivity(level) + } ToolbarEvent::OpenFontPicker => self.apply_toolbar_open_font_picker(), ToolbarEvent::SetEraserMode(mode) => self.apply_toolbar_set_eraser_mode(mode), ToolbarEvent::SetFont(descriptor) => self.apply_toolbar_set_font(descriptor), diff --git a/src/input/state/interaction/actions.rs b/src/input/state/interaction/actions.rs index 868fb8e5..2639dc46 100644 --- a/src/input/state/interaction/actions.rs +++ b/src/input/state/interaction/actions.rs @@ -38,6 +38,8 @@ pub(crate) fn classify_action(action: Action) -> ActionRoute { | Action::DecreaseMarkerOpacity | Action::IncreasePenSmoothing | Action::DecreasePenSmoothing + | Action::IncreaseShapeRecognitionSensitivity + | Action::DecreaseShapeRecognitionSensitivity | Action::CycleFontFamily | Action::OpenFontPicker | Action::SelectSelectionTool diff --git a/src/input/state/tests/shape_pen.rs b/src/input/state/tests/shape_pen.rs index 51f568fc..0fa910ac 100644 --- a/src/input/state/tests/shape_pen.rs +++ b/src/input/state/tests/shape_pen.rs @@ -1,6 +1,6 @@ use super::*; use crate::input::tool::ProvisionalToolStroke; -use crate::ui::toolbar::{ToolContext, ToolbarSnapshot}; +use crate::ui::toolbar::{ToolContext, ToolbarEvent, ToolbarSnapshot}; use crate::ui::{ShapeExtent, ShapeReadout}; /// A hand-drawn rectangle from the top-left corner, clockwise. @@ -42,6 +42,18 @@ fn release_at_end(state: &mut InputState, path: &[(i32, i32)]) { state.on_mouse_release(MouseButton::Left, last.0, last.1); } +fn run_action(state: &mut InputState, action: Action) { + let measurer = crate::draw::TextMeasurer::default(); + let ui_engine = crate::ui_text::UiTextEngine::default(); + state.handle_action_with_resources( + crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }, + action, + ); +} + #[test] fn recognized_closed_shapes_follow_the_fill_toggle() { for fill_enabled in [false, true] { @@ -105,3 +117,44 @@ fn readout_names_the_recognized_shape_and_stays_quiet_for_ink() { }) ); } + +#[test] +fn sensitivity_steps_at_runtime_and_announces_the_level() { + let mut state = shape_pen_state(); + let _ = state.apply_toolbar_event(ToolbarEvent::SetShapeRecognitionSensitivity(2)); + + run_action(&mut state, Action::IncreaseShapeRecognitionSensitivity); + assert_eq!(state.style.shape_recognition_sensitivity, 3); + assert_eq!( + state.active_toast().map(|toast| toast.message.as_str()), + Some("Shape Pen sensitivity 3/4") + ); + + for _ in 0..3 { + run_action(&mut state, Action::IncreaseShapeRecognitionSensitivity); + } + assert_eq!(state.style.shape_recognition_sensitivity, 4); + + assert!(state.apply_toolbar_event(ToolbarEvent::SetShapeRecognitionSensitivity(0))); + run_action(&mut state, Action::DecreaseShapeRecognitionSensitivity); + assert_eq!(state.style.shape_recognition_sensitivity, 0); +} + +#[test] +fn toolbar_sensitivity_applies_to_the_next_stroke() { + let rough_line = [(0, 0), (20, 5), (40, -4), (60, 6), (80, 1)]; + + for (level, expected_kind) in [(0, "Freehand"), (4, "Line")] { + let mut state = shape_pen_state(); + let _ = state.apply_toolbar_event(ToolbarEvent::SetShapeRecognitionSensitivity(level)); + + draw_path(&mut state, &rough_line); + release_at_end(&mut state, &rough_line); + + assert_eq!( + state.boards.active_frame().shapes[0].shape.kind_name(), + expected_kind, + "sensitivity {level}" + ); + } +} diff --git a/src/session/snapshot/tests.rs b/src/session/snapshot/tests.rs index a52ef739..35497bc0 100644 --- a/src/session/snapshot/tests.rs +++ b/src/session/snapshot/tests.rs @@ -74,6 +74,7 @@ fn sample_tool_state() -> ToolStateSnapshot { blur_style: Default::default(), recent_colors: Vec::new(), pen_smoothing: None, + shape_recognition_sensitivity: None, marker_opacity: Some(0.32), spotlight_magnification: None, fill_enabled: Some(false), diff --git a/src/session/snapshot/types.rs b/src/session/snapshot/types.rs index 836ab73a..54c9a05b 100644 --- a/src/session/snapshot/types.rs +++ b/src/session/snapshot/types.rs @@ -91,6 +91,10 @@ pub struct ToolStateSnapshot { /// existed, which restore the configured level instead. #[serde(default, skip_serializing_if = "Option::is_none")] pub pen_smoothing: Option, + /// Shape Pen recognition sensitivity. Absent in sessions written before + /// it could change at runtime, which restore the configured level. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub shape_recognition_sensitivity: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub spotlight_magnification: Option, #[serde(default)] @@ -147,6 +151,7 @@ impl From<(&DrawingStyle, Tool, Option)> for ToolStateSnapshot { recent_colors: style.recent_colors.clone(), marker_opacity: Some(style.marker_opacity), pen_smoothing: Some(style.pen_smoothing), + shape_recognition_sensitivity: Some(style.shape_recognition_sensitivity), spotlight_magnification: Some(style.spotlight_magnification), fill_enabled: Some(style.fill_enabled), tool_override: None, diff --git a/src/session/storage/tests.rs b/src/session/storage/tests.rs index 7e6e214a..6fa5b120 100644 --- a/src/session/storage/tests.rs +++ b/src/session/storage/tests.rs @@ -55,6 +55,7 @@ fn sample_tool_state() -> ToolStateSnapshot { blur_style: Default::default(), recent_colors: Vec::new(), pen_smoothing: None, + shape_recognition_sensitivity: None, marker_opacity: Some(0.32), spotlight_magnification: None, fill_enabled: Some(false), diff --git a/src/session/tests/limits.rs b/src/session/tests/limits.rs index 3711da00..c43230e6 100644 --- a/src/session/tests/limits.rs +++ b/src/session/tests/limits.rs @@ -35,6 +35,7 @@ fn save_snapshot_errors_when_payload_exceeds_max_file_size() { blur_style: Default::default(), recent_colors: Vec::new(), pen_smoothing: None, + shape_recognition_sensitivity: None, marker_opacity: Some(0.32), spotlight_magnification: None, fill_enabled: Some(false), diff --git a/src/session/tests/snapshot.rs b/src/session/tests/snapshot.rs index cb2c520c..f6822726 100644 --- a/src/session/tests/snapshot.rs +++ b/src/session/tests/snapshot.rs @@ -78,6 +78,7 @@ fn every_persisted_drawing_style_survives_snapshot_serialization_and_restore() { recent_colors: vec![recent, color], marker_opacity: Some(0.42), pen_smoothing: Some(5), + shape_recognition_sensitivity: Some(3), spotlight_magnification: Some(2.25), fill_enabled: Some(true), tool_override: Some(Tool::Marker), @@ -140,6 +141,27 @@ fn non_default_pen_smoothing_survives_snapshot_serialization_and_restore() { assert_eq!(restored.style.pen_smoothing, 5); } +#[test] +fn shape_recognition_sensitivity_survives_snapshot_serialization_and_restore() { + let mut source = dummy_input_state(); + let _ = source.set_shape_recognition_sensitivity(4); + + let captured = ToolStateSnapshot::from_input_state(&source); + let encoded = serde_json::to_vec(&captured).expect("serialize tool snapshot"); + let decoded: ToolStateSnapshot = + serde_json::from_slice(&encoded).expect("deserialize tool snapshot"); + + let mut restored = dummy_input_state(); + let _ = restored.set_shape_recognition_sensitivity(0); + apply_tool_state_snapshot( + &mut restored, + &crate::draw::TextMeasurer::default(), + decoded, + ); + + assert_eq!(restored.style.shape_recognition_sensitivity, 4); +} + #[test] fn legacy_snapshot_without_pen_smoothing_preserves_the_configured_level() { let source = dummy_input_state(); @@ -452,6 +474,7 @@ fn apply_legacy_snapshot_preserves_config_initialized_font_descriptor() { blur_style: Default::default(), recent_colors: Vec::new(), pen_smoothing: None, + shape_recognition_sensitivity: None, marker_opacity: Some(0.32), spotlight_magnification: None, fill_enabled: Some(false), @@ -508,6 +531,7 @@ fn apply_snapshot_clamps_restored_per_tool_thicknesses() { blur_style: Default::default(), recent_colors: Vec::new(), pen_smoothing: None, + shape_recognition_sensitivity: None, marker_opacity: Some(0.32), spotlight_magnification: None, fill_enabled: Some(false), @@ -559,6 +583,7 @@ fn apply_legacy_snapshot_uses_font_derived_step_marker_size() { blur_style: Default::default(), recent_colors: Vec::new(), pen_smoothing: None, + shape_recognition_sensitivity: None, marker_opacity: Some(0.32), spotlight_magnification: None, fill_enabled: Some(false), diff --git a/src/toolbar_gtk/view/top_bar/style_pill.rs b/src/toolbar_gtk/view/top_bar/style_pill.rs index b0dd755a..2f70be1e 100644 --- a/src/toolbar_gtk/view/top_bar/style_pill.rs +++ b/src/toolbar_gtk/view/top_bar/style_pill.rs @@ -346,6 +346,7 @@ impl TopBar { })); } model::StylePillControl::PenSmoothingStepper + | model::StylePillControl::ShapeSensitivityStepper | model::StylePillControl::SelectionStepper(_) => { // No spacing between the halves: the builtin lays the three // parts out abutting, at step + value + step exactly, and diff --git a/src/ui/help_overlay/sections/builder/sections.rs b/src/ui/help_overlay/sections/builder/sections.rs index eb23a360..a65d8d8d 100644 --- a/src/ui/help_overlay/sections/builder/sections.rs +++ b/src/ui/help_overlay/sections/builder/sections.rs @@ -131,6 +131,17 @@ pub(super) fn build_main_sections( ), "Adjust stroke smoothing", ), + row( + bindings_or_fallback( + bindings, + &[ + Action::IncreaseShapeRecognitionSensitivity, + Action::DecreaseShapeRecognitionSensitivity, + ], + NOT_BOUND_LABEL, + ), + "Adjust Shape Pen sensitivity", + ), ], badges: color_badges.clone(), icon: Some(toolbar_icons::draw_icon_pen), diff --git a/src/ui/toolbar/events.rs b/src/ui/toolbar/events.rs index c95b5d87..28664201 100644 --- a/src/ui/toolbar/events.rs +++ b/src/ui/toolbar/events.rs @@ -89,6 +89,8 @@ pub enum ToolbarEvent { SetSpotlightMagnification(f64), /// Smoothing passes applied to freehand and marker strokes on release. SetPenSmoothing(u8), + /// How readily Shape Pen turns ink into shapes, 0 (precise) to 4. + SetShapeRecognitionSensitivity(u8), SetEraserMode(EraserMode), SetFont(FontDescriptor), /// Turn bold on or off for selected text, or for the next label typed. diff --git a/src/ui/toolbar/model/event_policy.rs b/src/ui/toolbar/model/event_policy.rs index e56278ed..a2f5ef93 100644 --- a/src/ui/toolbar/model/event_policy.rs +++ b/src/ui/toolbar/model/event_policy.rs @@ -502,6 +502,7 @@ fn persistence_for_event(event: &ToolbarEvent) -> ToolbarPersistence { | ToolbarEvent::NudgeMarkerOpacity(_) | ToolbarEvent::SetSpotlightMagnification(_) | ToolbarEvent::SetPenSmoothing(_) + | ToolbarEvent::SetShapeRecognitionSensitivity(_) | ToolbarEvent::SetEraserMode(_) | ToolbarEvent::SetFont(_) | ToolbarEvent::SetFontBold(_) diff --git a/src/ui/toolbar/model/style_pill.rs b/src/ui/toolbar/model/style_pill.rs index 5c841bff..d8ed2b60 100644 --- a/src/ui/toolbar/model/style_pill.rs +++ b/src/ui/toolbar/model/style_pill.rs @@ -97,6 +97,9 @@ pub(crate) enum StylePillControl { /// on a 110px track is 18px of travel per step and fiddly to land on. It /// also keeps the pill from reading as a row of near-identical bars. PenSmoothingStepper, + /// Shape Pen recognition sensitivity, as a −/value/+ stepper over its + /// five levels. + ShapeSensitivityStepper, /// Shape fill toggle. FillToggle, /// Arrow style cycle button, showing the style the next arrow will use. @@ -273,6 +276,9 @@ impl StylePillSpec { if context.show_pen_smoothing && !plan.drop_style_extras { controls.push(StylePillControl::PenSmoothingStepper); } + if context.show_shape_sensitivity && !plan.drop_style_extras { + controls.push(StylePillControl::ShapeSensitivityStepper); + } if context.tool_options_kind == ToolOptionsKind::Spotlight { controls.push(StylePillControl::Slider( StylePillSlider::SpotlightMagnification, diff --git a/src/ui/toolbar/model/style_pill/control.rs b/src/ui/toolbar/model/style_pill/control.rs index 75757a94..7459085c 100644 --- a/src/ui/toolbar/model/style_pill/control.rs +++ b/src/ui/toolbar/model/style_pill/control.rs @@ -9,6 +9,7 @@ impl StylePillControl { Self::ThicknessValue => Cow::Borrowed("top.style.thickness-value"), Self::Slider(StylePillSlider::Opacity) => Cow::Borrowed("top.style.opacity"), Self::PenSmoothingStepper => Cow::Borrowed("top.style.pen-smoothing"), + Self::ShapeSensitivityStepper => Cow::Borrowed("top.style.shape-sensitivity"), Self::Slider(StylePillSlider::SpotlightMagnification) => { Cow::Borrowed("top.style.spotlight-magnification") } @@ -51,7 +52,9 @@ impl StylePillControl { } Self::EraserModeSegment => StylePillRole::Segmented, Self::SelectionCycle(_) => StylePillRole::Button, - Self::PenSmoothingStepper | Self::SelectionStepper(_) => StylePillRole::Stepper, + Self::PenSmoothingStepper + | Self::ShapeSensitivityStepper + | Self::SelectionStepper(_) => StylePillRole::Stepper, } } @@ -92,7 +95,10 @@ impl StylePillControl { Self::SelectionCycle(kind) => { ToolbarEvent::AdjustSelectionProperty { kind, direction: 1 } } - Self::EraserModeSegment | Self::PenSmoothingStepper | Self::SelectionStepper(_) => { + Self::EraserModeSegment + | Self::PenSmoothingStepper + | Self::ShapeSensitivityStepper + | Self::SelectionStepper(_) => { return None; } }) @@ -166,6 +172,9 @@ impl StylePillControl { } else { snapshot.pen_smoothing.to_string() }), + Self::ShapeSensitivityStepper => { + Some(snapshot.shape_recognition_sensitivity.to_string()) + } Self::Slider(StylePillSlider::FontSize) | Self::FontSizeValue => { Some(StylePillSlider::FontSize.formatter()(snapshot.font_size)) } @@ -250,6 +259,7 @@ impl StylePillControl { } Self::Slider(StylePillSlider::Opacity) => Cow::Borrowed("Marker opacity"), Self::PenSmoothingStepper => Cow::Borrowed("Smoothing"), + Self::ShapeSensitivityStepper => Cow::Borrowed("Sensitivity"), Self::Slider(StylePillSlider::SpotlightMagnification) => { Cow::Borrowed("Spotlight magnification") } @@ -316,6 +326,10 @@ impl StylePillControl { "Smooth freehand and marker strokes when the pen lifts. Off keeps the exact path." .to_string(), ), + Self::ShapeSensitivityStepper => Some( + "How readily Shape Pen turns ink into shapes: 0 is precise, 4 accepts rough strokes." + .to_string(), + ), Self::FontWeightToggle => { Some("Bold. Applies to selected text, or to the next label you type.".to_string()) } @@ -377,6 +391,9 @@ impl StylePillControl { if self == Self::PenSmoothingStepper { return Some(pen_smoothing_steps(snapshot)); } + if self == Self::ShapeSensitivityStepper { + return Some(shape_sensitivity_steps(snapshot)); + } let Self::SelectionStepper(kind) = self else { return None; }; @@ -461,6 +478,29 @@ fn pen_smoothing_steps(snapshot: &ToolbarSnapshot) -> [StylePillStep; 2] { ] } +/// The Shape Pen sensitivity stepper's halves, clamped like smoothing's. +fn shape_sensitivity_steps(snapshot: &ToolbarSnapshot) -> [StylePillStep; 2] { + let level = snapshot.shape_recognition_sensitivity; + [ + StylePillStep { + id: "top.style.shape-sensitivity.minus", + label: "\u{2212}", + event: ToolbarEvent::SetShapeRecognitionSensitivity(level.saturating_sub(1)), + tooltip: "Keep more strokes as ink".to_string(), + }, + StylePillStep { + id: "top.style.shape-sensitivity.plus", + label: "+", + event: ToolbarEvent::SetShapeRecognitionSensitivity( + level + .saturating_add(1) + .min(crate::config::MAX_SHAPE_RECOGNITION_SENSITIVITY), + ), + tooltip: "Recognize rougher strokes".to_string(), + }, + ] +} + /// A family name cut to something a width-planned pill can hold. /// /// The full name is in the tooltip. Truncating here rather than in the diff --git a/src/ui/toolbar/model/style_pill/tests/tool_states.rs b/src/ui/toolbar/model/style_pill/tests/tool_states.rs index de45c416..54d72432 100644 --- a/src/ui/toolbar/model/style_pill/tests/tool_states.rs +++ b/src/ui/toolbar/model/style_pill/tests/tool_states.rs @@ -222,6 +222,51 @@ fn the_smoothing_stepper_follows_the_tool_it_can_change() { } } +#[test] +fn the_shape_sensitivity_stepper_is_shape_pen_only_and_stays_in_range() { + let shape_pen = StylePillSpec::build(&snapshot_for_tool(Tool::LiveShape), &plan()); + let ids = control_ids(&shape_pen); + assert!(ids.contains(&"top.style.shape-sensitivity".to_string())); + assert!( + ids.contains(&"top.style.pen-smoothing".to_string()), + "ink Shape Pen keeps is still smoothed" + ); + for tool in [Tool::Pen, Tool::Rect, Tool::Triangle] { + let spec = StylePillSpec::build(&snapshot_for_tool(tool), &plan()); + assert!( + !control_ids(&spec).contains(&"top.style.shape-sensitivity".to_string()), + "{tool:?} recognizes nothing" + ); + } + + let stepper = StylePillControl::ShapeSensitivityStepper; + let mut snapshot = snapshot_for_tool(Tool::LiveShape); + snapshot.shape_recognition_sensitivity = 2; + assert_eq!(stepper.role(), StylePillRole::Stepper); + assert_eq!(stepper.value_text(&snapshot).as_deref(), Some("2")); + let steps = stepper.required_steps(&snapshot); + assert_eq!( + steps[0].event, + ToolbarEvent::SetShapeRecognitionSensitivity(1) + ); + assert_eq!( + steps[1].event, + ToolbarEvent::SetShapeRecognitionSensitivity(3) + ); + + let max = crate::config::MAX_SHAPE_RECOGNITION_SENSITIVITY; + snapshot.shape_recognition_sensitivity = 0; + assert_eq!( + stepper.required_steps(&snapshot)[0].event, + ToolbarEvent::SetShapeRecognitionSensitivity(0) + ); + snapshot.shape_recognition_sensitivity = max; + assert_eq!( + stepper.required_steps(&snapshot)[1].event, + ToolbarEvent::SetShapeRecognitionSensitivity(max) + ); +} + #[test] fn the_font_button_shows_the_family_in_use_and_opens_the_picker() { let mut snapshot = snapshot(); diff --git a/src/ui/toolbar/snapshot/build.rs b/src/ui/toolbar/snapshot/build.rs index 1e56b1e9..c11f4d27 100644 --- a/src/ui/toolbar/snapshot/build.rs +++ b/src/ui/toolbar/snapshot/build.rs @@ -116,6 +116,7 @@ impl ToolbarSnapshot { eraser_mode, marker_opacity: state.style.marker_opacity, pen_smoothing: state.style.pen_smoothing, + shape_recognition_sensitivity: state.style.shape_recognition_sensitivity, spotlight_magnification: state.style.spotlight_magnification, // Filled in by the backend that renders the canvas; see the field. spotlight_magnifier_source: None, diff --git a/src/ui/toolbar/snapshot/types.rs b/src/ui/toolbar/snapshot/types.rs index c1fe16ef..c0fc2992 100644 --- a/src/ui/toolbar/snapshot/types.rs +++ b/src/ui/toolbar/snapshot/types.rs @@ -78,6 +78,8 @@ pub struct ToolContext { /// the whole program, but it only reaches strokes the pen and marker /// accumulate, so a Line or Blur tool has nothing for the stepper to do. pub show_pen_smoothing: bool, + /// Whether the Shape Pen sensitivity stepper should be shown. + pub show_shape_sensitivity: bool, } impl ToolContext { @@ -107,6 +109,7 @@ impl ToolContext { show_polygon_sides_control: false, show_font_controls: true, show_pen_smoothing: false, + show_shape_sensitivity: false, }; } @@ -138,6 +141,7 @@ impl ToolContext { ctx.show_polygon_sides_control = true; } ctx.show_pen_smoothing = effective_tool.smooths_strokes(); + ctx.show_shape_sensitivity = effective_tool == Tool::LiveShape; ctx } @@ -157,6 +161,7 @@ impl ToolContext { show_font_controls: false, // Set from the tool by `from_snapshot`; a profile alone cannot say. show_pen_smoothing: false, + show_shape_sensitivity: false, } } @@ -189,6 +194,7 @@ impl ToolContext { show_polygon_sides_control, show_font_controls, show_pen_smoothing: true, + show_shape_sensitivity: true, } } } @@ -268,6 +274,8 @@ pub struct ToolbarSnapshot { pub marker_opacity: f64, /// Smoothing passes applied to freehand and marker strokes on release. pub pen_smoothing: u8, + /// How readily Shape Pen turns ink into shapes, 0 (precise) to 4. + pub shape_recognition_sensitivity: u8, pub spotlight_magnification: f64, /// Whether the active canvas has complete pixels for magnifying a Spotlight, /// or `None` when no backend has answered yet. diff --git a/tests/cli.rs b/tests/cli.rs index 524ecbc6..3feebe00 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -257,6 +257,7 @@ fn saved_tool_state() -> wayscriber::session::ToolStateSnapshot { blur_style: Default::default(), recent_colors: Vec::new(), pen_smoothing: None, + shape_recognition_sensitivity: None, marker_opacity: Some(0.32), spotlight_magnification: None, fill_enabled: Some(false), From 64ded8a68cadfb6a1bed588b62cfd1396f70d2de Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:40:35 +0200 Subject: [PATCH 05/11] feat: put Shape Pen on the toolbar and bind it to S Shape Pen lived only in the Shapes picker and had no shortcut. In the full toolbar it now sits next to Pen, and it no longer repeats in the full-mode picker; simple mode keeps it in the picker. Press S to select it. On a very narrow strip it is the first pen to move into the overflow menu, since the key and the command palette still reach it. --- README.md | 2 +- config.example.toml | 2 +- docs/CONFIG.md | 4 +-- .../wayland/toolbar/layout/tests/mod.rs | 19 ++++++++++++- src/backend/wayland/toolbar/view/top.rs | 10 +++++-- src/config/keybindings/defaults/tools.rs | 3 ++- src/config/keybindings/tests.rs | 2 +- src/input/state/tests/shape_pen.rs | 27 ++++++++++++++++++- src/ui/toolbar/model/tools.rs | 6 ++--- 9 files changed, 62 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index aa9d9924..c607ec46 100644 --- a/README.md +++ b/README.md @@ -117,7 +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, rectangles, or triangles; other strokes remain freehand. Choose **Shape Pen Tool** in the command palette or the toolbar's Shapes picker. Adjust recognition with the toolbar's **Sensitivity** stepper, `[drawing] shape_recognition_sensitivity` (0-4), or the configurator's Drawing page; the level is remembered with the session. +- Shape Pen: draw naturally and confident strokes preview and become editable lines, circles, ovals, rectangles, or triangles; other strokes remain freehand. Press `S`, click it next to Pen on the toolbar (in the Shapes picker in simple mode), or choose **Shape Pen Tool** in the command palette. Adjust recognition with the toolbar's **Sensitivity** stepper, `[drawing] shape_recognition_sensitivity` (0-4), or the configurator's Drawing page; the level is remembered with the session. - 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 c6cc0ae8..ce67e845 100644 --- a/config.example.toml +++ b/config.example.toml @@ -115,7 +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_live_shape_tool = ["S"] select_marker_tool = ["H"] select_step_marker_tool = [] select_eraser_tool = ["D"] diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 4daa427b..837025af 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -381,7 +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)) -- **Shape Pen**: Choose **Shape Pen** from the Shapes picker or **Shape Pen Tool** from the command palette; bind `select_live_shape_tool` for a shortcut. Confident lines, circles, ovals, axis-aligned rectangles, and triangles preview as shapes and commit as editable shapes. Recognized ovals, rectangles, and triangles follow the Fill toggle, like the dedicated shape tools. 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. While Shape Pen is active, the toolbar's **Sensitivity** stepper changes it at once; so do **Increase / Decrease Shape Pen Sensitivity** in the command palette, or bind `increase_shape_recognition_sensitivity` / `decrease_shape_recognition_sensitivity`. A session remembers the level it was saved at; the config value is the starting level. 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. Nearly horizontal or vertical triangle sides align the same way. Other ink stays freehand. +- **Shape Pen**: Press `S` (`select_live_shape_tool`), click **Shape Pen** next to Pen on the toolbar (in the Shapes picker in simple mode), or choose **Shape Pen Tool** from the command palette. Confident lines, circles, ovals, axis-aligned rectangles, and triangles preview as shapes and commit as editable shapes. Recognized ovals, rectangles, and triangles follow the Fill toggle, like the dedicated shape tools. 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. While Shape Pen is active, the toolbar's **Sensitivity** stepper changes it at once; so do **Increase / Decrease Shape Pen Sensitivity** in the command palette, or bind `increase_shape_recognition_sensitivity` / `decrease_shape_recognition_sensitivity`. A session remembers the level it was saved at; the config value is the starting level. 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. Nearly horizontal or vertical triangle sides align the same way. 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) @@ -2169,7 +2169,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 and shapes +select_live_shape_tool = ["S"] # draw ink that snaps to lines and shapes select_marker_tool = ["H"] select_step_marker_tool = [] select_eraser_tool = ["D"] diff --git a/src/backend/wayland/toolbar/layout/tests/mod.rs b/src/backend/wayland/toolbar/layout/tests/mod.rs index 86be6aeb..3ac32829 100644 --- a/src/backend/wayland/toolbar/layout/tests/mod.rs +++ b/src/backend/wayland/toolbar/layout/tests/mod.rs @@ -34,7 +34,7 @@ fn top_size_respects_icon_mode() { // drawing tool is active. assert_eq!( top_size(&crate::ui_text::UiTextEngine::default(), &snapshot), - (1227, 104) + (1278, 104) ); state.set_toolbar_use_icons(false); @@ -203,6 +203,23 @@ fn top_strip_fits_480_pixels_in_icon_and_text_modes() { } } +#[test] +fn compact_top_strip_moves_shape_pen_to_the_overflow_before_other_pens() { + let mut state = create_test_input_state(); + state.set_toolbar_use_icons(false); + let mut snapshot = snapshot_from_state(&state); + snapshot.top_viewport_max = Some(320.0); + + let plan = crate::backend::wayland::toolbar::view::top::plan_top_strip( + &crate::ui_text::UiTextEngine::default(), + &snapshot, + ); + + assert!(plan.compact); + assert!(plan.dropped_tools.contains(&crate::input::Tool::LiveShape)); + assert!(!plan.dropped_tools.contains(&crate::input::Tool::Pen)); +} + #[test] fn compact_top_strip_respects_budget_without_the_old_floor() { let mut state = create_test_input_state(); diff --git a/src/backend/wayland/toolbar/view/top.rs b/src/backend/wayland/toolbar/view/top.rs index eb1ec455..78420968 100644 --- a/src/backend/wayland/toolbar/view/top.rs +++ b/src/backend/wayland/toolbar/view/top.rs @@ -1,7 +1,7 @@ //! Top-strip tree builder. //! //! The strip reads left to right as detached pill islands. The Tools island: -//! drag grip, pens (Select/Pen/Marker/Step/Eraser), shapes (Line/Arrow/Shapes +//! drag grip, pens (Select/Pen/Shape Pen/Marker/Step/Eraser), shapes (Line/Arrow/Shapes //! picker), annotations (Text/Note/Screenshot/Highlight), with thin dividers //! between the groups — colors no longer sit here (M7 moved them into the //! style pill). The Presets island: the saved tool+color slots. The History @@ -129,7 +129,13 @@ pub fn plan_top_strip(engine: &UiTextEngine, snapshot: &ToolbarSnapshot) -> TopS sort_dropped_items(&mut plan, &visible_tools, &visible_utilities); return plan; } - for candidate in [Tool::StepMarker, Tool::Marker, Tool::Select] { + // Shape Pen yields first: it also has a default key and the palette. + for candidate in [ + Tool::LiveShape, + Tool::StepMarker, + Tool::Marker, + Tool::Select, + ] { if fits(&plan) { break; } diff --git a/src/config/keybindings/defaults/tools.rs b/src/config/keybindings/defaults/tools.rs index 7f0185d2..5bb85ffd 100644 --- a/src/config/keybindings/defaults/tools.rs +++ b/src/config/keybindings/defaults/tools.rs @@ -82,8 +82,9 @@ pub(crate) fn default_select_pen_tool() -> Vec { vec!["F".to_string()] } +/// `S` for Shape Pen; `F`, the plain pen, stays freehand. pub(crate) fn default_select_live_shape_tool() -> Vec { - Vec::new() + vec!["S".to_string()] } pub(crate) fn default_select_line_tool() -> Vec { diff --git a/src/config/keybindings/tests.rs b/src/config/keybindings/tests.rs index c1ce6427..799c8f4c 100644 --- a/src/config/keybindings/tests.rs +++ b/src/config/keybindings/tests.rs @@ -750,7 +750,7 @@ const DEFAULT_BINDING_SNAPSHOT: &[(&str, &[&str])] = &[ ("cycle_blur_style", &[]), ("cycle_arrow_style", &[]), ("select_pen_tool", &["F"]), - ("select_live_shape_tool", &[]), + ("select_live_shape_tool", &["S"]), ("select_line_tool", &[]), ("select_rect_tool", &[]), ("select_ellipse_tool", &[]), diff --git a/src/input/state/tests/shape_pen.rs b/src/input/state/tests/shape_pen.rs index 0fa910ac..5329577b 100644 --- a/src/input/state/tests/shape_pen.rs +++ b/src/input/state/tests/shape_pen.rs @@ -1,6 +1,6 @@ use super::*; use crate::input::tool::ProvisionalToolStroke; -use crate::ui::toolbar::{ToolContext, ToolbarEvent, ToolbarSnapshot}; +use crate::ui::toolbar::{ToolContext, ToolbarEvent, ToolbarSnapshot, model}; use crate::ui::{ShapeExtent, ShapeReadout}; /// A hand-drawn rectangle from the top-left corner, clockwise. @@ -158,3 +158,28 @@ fn toolbar_sensitivity_applies_to_the_next_stroke() { ); } } + +#[test] +fn full_toolbar_shows_shape_pen_beside_pen_and_simple_keeps_it_in_the_picker() { + let snapshot = ToolbarSnapshot::from_input(&create_test_input_state()); + + let strip: Vec<_> = model::visible_top_tool_buttons(false, &snapshot).collect(); + let pen = strip + .iter() + .position(|&tool| tool == Tool::Pen) + .expect("pen"); + assert_eq!(strip.get(pen + 1), Some(&Tool::LiveShape)); + assert!( + !model::visible_shape_picker_rows(&snapshot, false) + .concat() + .contains(&Tool::LiveShape), + "the full-mode picker lists only what the strip does not show" + ); + + assert!(!model::visible_top_tool_buttons(true, &snapshot).any(|tool| tool == Tool::LiveShape)); + assert!( + model::visible_shape_picker_rows(&snapshot, true) + .concat() + .contains(&Tool::LiveShape) + ); +} diff --git a/src/ui/toolbar/model/tools.rs b/src/ui/toolbar/model/tools.rs index a2b98acf..fbd43a28 100644 --- a/src/ui/toolbar/model/tools.rs +++ b/src/ui/toolbar/model/tools.rs @@ -39,9 +39,10 @@ const SIMPLE_TOOL_BUTTONS: [Tool; 5] = [ Tool::Eraser, ]; -const FULL_TOOL_BUTTONS: [Tool; 7] = [ +const FULL_TOOL_BUTTONS: [Tool; 8] = [ Tool::Select, Tool::Pen, + Tool::LiveShape, Tool::Marker, Tool::StepMarker, Tool::Eraser, @@ -50,8 +51,7 @@ const FULL_TOOL_BUTTONS: [Tool; 7] = [ ]; /// Full-mode shape picker: everything the strip no longer shows inline. -const FULL_SHAPE_PICKER_TOOLS: [Tool; 10] = [ - Tool::LiveShape, +const FULL_SHAPE_PICKER_TOOLS: [Tool; 9] = [ Tool::Rect, Tool::Ellipse, Tool::Blur, From 51109a11e9da3f1eb64e7c96906a5d0df02bd903 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:44:49 +0200 Subject: [PATCH 06/11] feat: undo a Shape Pen shape back to its ink A recognized stroke used to replace its ink outright, so undo removed the whole stroke. It now enters history as the ink and then becomes the shape as a second step: the first undo gives the original stroke back, and the second removes it. Strokes that stay ink are still one step. --- README.md | 2 +- docs/CONFIG.md | 2 +- src/input/state/mouse/release/drawing.rs | 34 +++++++++++++++--- src/input/state/tests/drawing.rs | 3 +- src/input/state/tests/shape_pen.rs | 38 ++++++++++++++++++++ src/input/state/tests/shape_pen_triangles.rs | 2 +- src/input/tool/drawing.rs | 36 +++++++++++++------ 7 files changed, 97 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index c607ec46..db040aab 100644 --- a/README.md +++ b/README.md @@ -117,7 +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, rectangles, or triangles; other strokes remain freehand. Press `S`, click it next to Pen on the toolbar (in the Shapes picker in simple mode), or choose **Shape Pen Tool** in the command palette. Adjust recognition with the toolbar's **Sensitivity** stepper, `[drawing] shape_recognition_sensitivity` (0-4), or the configurator's Drawing page; the level is remembered with the session. +- Shape Pen: draw naturally and confident strokes preview and become editable lines, circles, ovals, rectangles, or triangles; other strokes remain freehand, and one undo turns a recognized shape back into its ink. Press `S`, click it next to Pen on the toolbar (in the Shapes picker in simple mode), or choose **Shape Pen Tool** in the command palette. Adjust recognition with the toolbar's **Sensitivity** stepper, `[drawing] shape_recognition_sensitivity` (0-4), or the configurator's Drawing page; the level is remembered with the session. - 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/docs/CONFIG.md b/docs/CONFIG.md index 837025af..ddbed8a9 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -381,7 +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)) -- **Shape Pen**: Press `S` (`select_live_shape_tool`), click **Shape Pen** next to Pen on the toolbar (in the Shapes picker in simple mode), or choose **Shape Pen Tool** from the command palette. Confident lines, circles, ovals, axis-aligned rectangles, and triangles preview as shapes and commit as editable shapes. Recognized ovals, rectangles, and triangles follow the Fill toggle, like the dedicated shape tools. 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. While Shape Pen is active, the toolbar's **Sensitivity** stepper changes it at once; so do **Increase / Decrease Shape Pen Sensitivity** in the command palette, or bind `increase_shape_recognition_sensitivity` / `decrease_shape_recognition_sensitivity`. A session remembers the level it was saved at; the config value is the starting level. 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. Nearly horizontal or vertical triangle sides align the same way. Other ink stays freehand. +- **Shape Pen**: Press `S` (`select_live_shape_tool`), click **Shape Pen** next to Pen on the toolbar (in the Shapes picker in simple mode), or choose **Shape Pen Tool** from the command palette. Confident lines, circles, ovals, axis-aligned rectangles, and triangles preview as shapes and commit as editable shapes. Recognized ovals, rectangles, and triangles follow the Fill toggle, like the dedicated shape tools. If a stroke should have stayed ink, undo once to get the original stroke back; undo again to remove it. 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. While Shape Pen is active, the toolbar's **Sensitivity** stepper changes it at once; so do **Increase / Decrease Shape Pen Sensitivity** in the command palette, or bind `increase_shape_recognition_sensitivity` / `decrease_shape_recognition_sensitivity`. A session remembers the level it was saved at; the config value is the starting level. 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. Nearly horizontal or vertical triangle sides align the same way. 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) diff --git a/src/input/state/mouse/release/drawing.rs b/src/input/state/mouse/release/drawing.rs index e11983f2..91027176 100644 --- a/src/input/state/mouse/release/drawing.rs +++ b/src/input/state/mouse/release/drawing.rs @@ -1,7 +1,7 @@ use log::warn; use crate::draw::Shape; -use crate::draw::frame::UndoAction; +use crate::draw::frame::{ShapeSnapshot, UndoAction}; use crate::draw::shape::bounding_box_for_points; use crate::input::tool::{FinishedToolStroke, PolygonStrokeSnapshot, ToolStrokeSnapshot}; use crate::input::{InputState, Tool}; @@ -88,8 +88,9 @@ pub(super) fn finish_drawing( tool.finish_stroke(snapshot) }; - let (shape, usage) = match finished { - FinishedToolStroke::Shape { shape, usage } => (shape, usage), + let (shape, ink, usage) = match finished { + FinishedToolStroke::Shape { shape, usage } => (shape, None, usage), + FinishedToolStroke::Recognized { shape, ink, usage } => (shape, Some(ink), usage), FinishedToolStroke::EraseStroke { path } => { state.clear_provisional_dirty(); if state.erase_strokes_by_points_with(measurer, &path) { @@ -121,9 +122,13 @@ pub(super) fn finish_drawing( let mut limit_reached = false; let max_shapes = state.max_shapes_per_frame(); + let undo_limit = state.history_limits.undo_stack_limit(); let addition = { let frame = state.boards.active_frame_mut(); - match frame.try_add_shape_with_id(shape.clone(), max_shapes) { + // A recognized stroke enters history as its ink, then turns into the + // shape as a second step, so the first undo gives the ink back. + let created = ink.clone().unwrap_or_else(|| shape.clone()); + match frame.try_add_shape_with_id(created, max_shapes) { Some(new_id) => { if let Some(index) = frame.find_index(new_id) { if let Some(new_shape) = frame.shape(new_id) { @@ -132,8 +137,27 @@ pub(super) fn finish_drawing( UndoAction::Create { shapes: vec![(index, snapshot.clone())], }, - state.history_limits.undo_stack_limit(), + undo_limit, ); + if let Some(ink) = ink + && let Some(target) = frame.shape_mut(new_id) + { + target.set_shape(shape.clone()); + frame.push_undo_action( + UndoAction::Modify { + shape_id: new_id, + before: ShapeSnapshot { + shape: ink, + locked: snapshot.locked, + }, + after: ShapeSnapshot { + shape: shape.clone(), + locked: snapshot.locked, + }, + }, + undo_limit, + ); + } Some((new_id, snapshot)) } else { None diff --git a/src/input/state/tests/drawing.rs b/src/input/state/tests/drawing.rs index 7e500087..9611f3a3 100644 --- a/src/input/state/tests/drawing.rs +++ b/src/input/state/tests/drawing.rs @@ -135,7 +135,8 @@ fn live_shape_strokes_preview_and_commit_lines_and_circles() { } )); assert_eq!(state.boards.active_frame().shapes.len(), 3); - assert_eq!(state.boards.active_frame().undo_stack_len(), 3); + // Each recognized stroke is two steps: its ink, then the recognition. + assert_eq!(state.boards.active_frame().undo_stack_len(), 6); } #[test] diff --git a/src/input/state/tests/shape_pen.rs b/src/input/state/tests/shape_pen.rs index 5329577b..ccff65d7 100644 --- a/src/input/state/tests/shape_pen.rs +++ b/src/input/state/tests/shape_pen.rs @@ -183,3 +183,41 @@ fn full_toolbar_shows_shape_pen_beside_pen_and_simple_keeps_it_in_the_picker() { .contains(&Tool::LiveShape) ); } + +#[test] +fn first_undo_turns_a_recognized_shape_back_into_its_ink() { + let mut state = shape_pen_state(); + draw_path(&mut state, &RECTANGLE); + release_at_end(&mut state, &RECTANGLE); + let kind = |state: &InputState| { + let frame = state.boards.active_frame(); + frame.shapes.first().map(|drawn| drawn.shape.kind_name()) + }; + assert_eq!(kind(&state), Some("Rectangle")); + + run_action(&mut state, Action::Undo); + assert_eq!(kind(&state), Some("Freehand")); + let Shape::Freehand { points, .. } = &state.boards.active_frame().shapes[0].shape else { + panic!("the ink comes back as a freehand stroke"); + }; + assert_eq!(points.first(), Some(&RECTANGLE[0])); + + run_action(&mut state, Action::Undo); + assert_eq!(kind(&state), None); + + run_action(&mut state, Action::Redo); + assert_eq!(kind(&state), Some("Freehand")); + run_action(&mut state, Action::Redo); + assert_eq!(kind(&state), Some("Rectangle")); +} + +#[test] +fn ink_that_stays_ink_is_a_single_undo_step() { + let mut state = shape_pen_state(); + let scribble = [(0, 0), (30, 30), (0, 30), (30, 0)]; + + draw_path(&mut state, &scribble); + release_at_end(&mut state, &scribble); + + assert_eq!(state.boards.active_frame().undo_stack_len(), 1); +} diff --git a/src/input/state/tests/shape_pen_triangles.rs b/src/input/state/tests/shape_pen_triangles.rs index 9eba1c74..b2cfcb21 100644 --- a/src/input/state/tests/shape_pen_triangles.rs +++ b/src/input/state/tests/shape_pen_triangles.rs @@ -93,7 +93,7 @@ fn live_shape_previews_and_commits_hand_drawn_triangles() { let frame = state.boards.active_frame(); assert_eq!(frame.shapes.len(), 1); - assert_eq!(frame.undo_stack_len(), 1); + assert_eq!(frame.undo_stack_len(), 2, "the ink, then the recognition"); let Shape::Polygon { kind: PolygonKind::Triangle, points, diff --git a/src/input/tool/drawing.rs b/src/input/tool/drawing.rs index 8cd4dadd..b8d956a6 100644 --- a/src/input/tool/drawing.rs +++ b/src/input/tool/drawing.rs @@ -71,8 +71,20 @@ pub(crate) struct PolygonStrokeSnapshot { } pub(crate) enum FinishedToolStroke { - Shape { shape: Shape, usage: ToolUsage }, - EraseStroke { path: Vec<(i32, i32)> }, + Shape { + shape: Shape, + usage: ToolUsage, + }, + /// A Shape Pen stroke that became `shape`. `ink` is the stroke it would + /// otherwise have left, kept so one undo can bring it back. + Recognized { + shape: Shape, + ink: Shape, + usage: ToolUsage, + }, + EraseStroke { + path: Vec<(i32, i32)>, + }, Noop, } @@ -173,15 +185,17 @@ impl Tool { snapshot.shape_recognition_sensitivity, ) }; - if let Some(shape) = recognized { - FinishedToolStroke::Shape { shape, usage } - } else { - finish_path_stroke( - snapshot, - ToolPathKind::Freehand, - ToolPressureBehavior::OptionalPressureStroke, - usage, - ) + let ink = finish_path_stroke( + snapshot, + ToolPathKind::Freehand, + ToolPressureBehavior::OptionalPressureStroke, + usage, + ); + match (recognized, ink) { + (Some(shape), FinishedToolStroke::Shape { shape: ink, usage }) => { + FinishedToolStroke::Recognized { shape, ink, usage } + } + (_, ink) => ink, } } ToolDrawingBehavior::Line => finish_shape(snapshot, usage, |snapshot| Shape::Line { From 41cbcdd797fa33253cda420ec4d87afa6323c74c Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:49:00 +0200 Subject: [PATCH 07/11] feat: show Shape Pen's ink faintly under the shape preview The live preview swapped the ink for the recognized shape without a trace, and swapped it back if the stroke stopped fitting. The preview now draws the recognized shape over a faint copy of the stroke, so it is clear what was recognized and nothing jumps when the stroke turns back into ink. The preview damage covers both. --- docs/CONFIG.md | 2 +- .../wayland/state/render/canvas/mod.rs | 3 +- src/input/state/render.rs | 18 +++++++--- src/input/state/tests/drawing.rs | 30 ++++++++++++---- src/input/state/tests/shape_pen.rs | 35 +++++++++++++++++- src/input/state/tests/shape_pen_triangles.rs | 9 +++-- src/input/tool/drawing.rs | 36 ++++++++++++++++++- 7 files changed, 116 insertions(+), 17 deletions(-) diff --git a/docs/CONFIG.md b/docs/CONFIG.md index ddbed8a9..6dea5683 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -381,7 +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)) -- **Shape Pen**: Press `S` (`select_live_shape_tool`), click **Shape Pen** next to Pen on the toolbar (in the Shapes picker in simple mode), or choose **Shape Pen Tool** from the command palette. Confident lines, circles, ovals, axis-aligned rectangles, and triangles preview as shapes and commit as editable shapes. Recognized ovals, rectangles, and triangles follow the Fill toggle, like the dedicated shape tools. If a stroke should have stayed ink, undo once to get the original stroke back; undo again to remove it. 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. While Shape Pen is active, the toolbar's **Sensitivity** stepper changes it at once; so do **Increase / Decrease Shape Pen Sensitivity** in the command palette, or bind `increase_shape_recognition_sensitivity` / `decrease_shape_recognition_sensitivity`. A session remembers the level it was saved at; the config value is the starting level. 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. Nearly horizontal or vertical triangle sides align the same way. Other ink stays freehand. +- **Shape Pen**: Press `S` (`select_live_shape_tool`), click **Shape Pen** next to Pen on the toolbar (in the Shapes picker in simple mode), or choose **Shape Pen Tool** from the command palette. Confident lines, circles, ovals, axis-aligned rectangles, and triangles preview as shapes over a faint copy of the stroke and commit as editable shapes. Recognized ovals, rectangles, and triangles follow the Fill toggle, like the dedicated shape tools. If a stroke should have stayed ink, undo once to get the original stroke back; undo again to remove it. 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. While Shape Pen is active, the toolbar's **Sensitivity** stepper changes it at once; so do **Increase / Decrease Shape Pen Sensitivity** in the command palette, or bind `increase_shape_recognition_sensitivity` / `decrease_shape_recognition_sensitivity`. A session remembers the level it was saved at; the config value is the starting level. 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. Nearly horizontal or vertical triangle sides align the same way. 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) diff --git a/src/backend/wayland/state/render/canvas/mod.rs b/src/backend/wayland/state/render/canvas/mod.rs index 597daed2..2d3e4ba8 100644 --- a/src/backend/wayland/state/render/canvas/mod.rs +++ b/src/backend/wayland/state/render/canvas/mod.rs @@ -476,7 +476,8 @@ fn provisional_point_count(stroke: &crate::input::tool::ProvisionalToolStroke<'_ crate::input::tool::ProvisionalToolStroke::BorrowedFreehand { points, .. } | crate::input::tool::ProvisionalToolStroke::BorrowedPressureFreehand { points, .. } | crate::input::tool::ProvisionalToolStroke::BorrowedMarker { points, .. } - | crate::input::tool::ProvisionalToolStroke::EraserPreview { points, .. } => points.len(), + | crate::input::tool::ProvisionalToolStroke::EraserPreview { points, .. } + | crate::input::tool::ProvisionalToolStroke::Recognized { ink: points, .. } => points.len(), crate::input::tool::ProvisionalToolStroke::Shape(_) | crate::input::tool::ProvisionalToolStroke::BlurReplayPreview(_) | crate::input::tool::ProvisionalToolStroke::None => 0, diff --git a/src/input/state/render.rs b/src/input/state/render.rs index 155bf87f..73ac6e1c 100644 --- a/src/input/state/render.rs +++ b/src/input/state/render.rs @@ -33,10 +33,10 @@ impl InputState { Tool::LiveShape => true, _ => return None, }; - let ProvisionalToolStroke::Shape(shape) = - self.provisional_tool_stroke(current_x, current_y) - else { - return None; + let shape = match self.provisional_tool_stroke(current_x, current_y) { + ProvisionalToolStroke::Shape(shape) + | ProvisionalToolStroke::Recognized { shape, .. } => shape, + _ => return None, }; let extent = match &shape { @@ -169,6 +169,16 @@ impl InputState { render.render_shape_with_halo_with_measurer(measurer, &shape, text_halo_enabled); true } + ProvisionalToolStroke::Recognized { + shape, + ink, + ink_color, + ink_size, + } => { + render_freehand_borrowed(ctx, ink, ink_color, ink_size); + render.render_shape_with_halo_with_measurer(measurer, &shape, text_halo_enabled); + true + } ProvisionalToolStroke::BlurReplayPreview(params) => { render.render_shape_with_measurer( measurer, diff --git a/src/input/state/tests/drawing.rs b/src/input/state/tests/drawing.rs index 9611f3a3..fcb0bb75 100644 --- a/src/input/state/tests/drawing.rs +++ b/src/input/state/tests/drawing.rs @@ -80,7 +80,10 @@ fn live_shape_strokes_preview_and_commit_lines_and_circles() { } assert!(matches!( state.provisional_tool_stroke(80, 3), - ProvisionalToolStroke::Shape(Shape::Line { y2: 0, .. }) + ProvisionalToolStroke::Recognized { + shape: Shape::Line { y2: 0, .. }, + .. + } )); state.on_mouse_release(MouseButton::Left, 80, 3); assert!(matches!( @@ -104,7 +107,10 @@ fn live_shape_strokes_preview_and_commit_lines_and_circles() { } assert!(matches!( state.provisional_tool_stroke(120, 80), - ProvisionalToolStroke::Shape(Shape::Ellipse { .. }) + ProvisionalToolStroke::Recognized { + shape: Shape::Ellipse { .. }, + .. + } )); state.on_mouse_release(MouseButton::Left, 120, 80); assert!(matches!( @@ -155,7 +161,10 @@ fn live_shape_recognizes_densely_sampled_straight_diagonal() { assert!(matches!( state.provisional_tool_stroke(210, 110), - ProvisionalToolStroke::Shape(Shape::Line { .. }) + ProvisionalToolStroke::Recognized { + shape: Shape::Line { .. }, + .. + } )); state.on_mouse_release(MouseButton::Left, 210, 110); assert!(matches!( @@ -199,7 +208,10 @@ fn live_shape_previews_and_commits_hand_drawn_rectangles_and_ovals() { } assert!(matches!( state.provisional_tool_stroke(10, 10), - ProvisionalToolStroke::Shape(Shape::Rect { .. }) + ProvisionalToolStroke::Recognized { + shape: Shape::Rect { .. }, + .. + } )); state.on_mouse_release(MouseButton::Left, 10, 10); assert!(matches!( @@ -225,7 +237,10 @@ fn live_shape_previews_and_commits_hand_drawn_rectangles_and_ovals() { } assert!(matches!( state.provisional_tool_stroke(260, 80), - ProvisionalToolStroke::Shape(Shape::Ellipse { .. }) + ProvisionalToolStroke::Recognized { + shape: Shape::Ellipse { .. }, + .. + } )); state.on_mouse_release(MouseButton::Left, 260, 80); assert!(matches!( @@ -387,7 +402,10 @@ fn live_shape_snaps_lines_to_visible_board_grid() { } assert!(matches!( state.provisional_tool_stroke(100, 44), - ProvisionalToolStroke::Shape(Shape::Line { y1: 40, y2: 40, .. }) + ProvisionalToolStroke::Recognized { + shape: Shape::Line { y1: 40, y2: 40, .. }, + .. + } )); state.on_mouse_release(MouseButton::Left, 100, 44); assert!(matches!( diff --git a/src/input/state/tests/shape_pen.rs b/src/input/state/tests/shape_pen.rs index ccff65d7..c2dbb57f 100644 --- a/src/input/state/tests/shape_pen.rs +++ b/src/input/state/tests/shape_pen.rs @@ -63,7 +63,7 @@ fn recognized_closed_shapes_follow_the_fill_toggle() { draw_path(&mut state, &RECTANGLE); assert!(matches!( state.provisional_tool_stroke(10, 10), - ProvisionalToolStroke::Shape(Shape::Rect { fill, .. }) if fill == fill_enabled + ProvisionalToolStroke::Recognized { shape: Shape::Rect { fill, .. }, .. } if fill == fill_enabled )); release_at_end(&mut state, &RECTANGLE); @@ -221,3 +221,36 @@ fn ink_that_stays_ink_is_a_single_undo_step() { assert_eq!(state.boards.active_frame().undo_stack_len(), 1); } + +#[test] +fn recognized_preview_keeps_a_faint_copy_of_the_ink_underneath() { + let mut state = shape_pen_state(); + draw_path(&mut state, &RECTANGLE); + let color = state.active_drag_color_or_current(); + + let preview = state.provisional_tool_stroke(10, 10); + let ProvisionalToolStroke::Recognized { + shape: Shape::Rect { .. }, + ink, + ink_color, + .. + } = &preview + else { + panic!("expected a recognized rectangle preview"); + }; + assert_eq!(ink.len(), RECTANGLE.len()); + assert!(ink_color.a < color.a, "the ink is fainter than the shape"); + assert_eq!( + (ink_color.r, ink_color.g, ink_color.b), + (color.r, color.g, color.b) + ); + + // The preview paints the ink as well as the shape, so the damage it + // reports must cover every ink point. + let bounds = preview + .bounds_with(&crate::draw::TextMeasurer::default()) + .expect("preview bounds"); + for &(x, y) in &RECTANGLE { + assert!(bounds.contains(x, y), "({x}, {y}) outside {bounds:?}"); + } +} diff --git a/src/input/state/tests/shape_pen_triangles.rs b/src/input/state/tests/shape_pen_triangles.rs index b2cfcb21..e86cae4a 100644 --- a/src/input/state/tests/shape_pen_triangles.rs +++ b/src/input/state/tests/shape_pen_triangles.rs @@ -84,10 +84,13 @@ fn live_shape_previews_and_commits_hand_drawn_triangles() { draw_path(&mut state, &HAND_DRAWN); assert!(matches!( state.provisional_tool_stroke(99, 22), - ProvisionalToolStroke::Shape(Shape::Polygon { - kind: PolygonKind::Triangle, + ProvisionalToolStroke::Recognized { + shape: Shape::Polygon { + kind: PolygonKind::Triangle, + .. + }, .. - }) + } )); release_at_end(&mut state, &HAND_DRAWN); diff --git a/src/input/tool/drawing.rs b/src/input/tool/drawing.rs index b8d956a6..0a8bae1b 100644 --- a/src/input/tool/drawing.rs +++ b/src/input/tool/drawing.rs @@ -13,6 +13,11 @@ use crate::util::{self, Rect}; pub(crate) const PROVISIONAL_POLYGON_DAMAGE_PADDING: i32 = 2; +/// Share of the stroke's opacity kept by the ink drawn under a Shape Pen +/// preview: enough to compare the shape with the stroke, faint enough that +/// the shape reads as the result. +const RECOGNIZED_INK_OPACITY: f64 = 0.3; + /// Bend a freshly drawn arrow starts with. /// /// Only `Curved` gets one: a curved arrow created dead straight would look @@ -150,6 +155,14 @@ pub(crate) enum ProvisionalToolStroke<'a> { size: f64, }, Shape(Shape), + /// A Shape Pen preview: the recognized shape over a faint copy of the ink + /// it came from, so the swap from ink to shape is never silent. + Recognized { + shape: Shape, + ink: &'a [(i32, i32)], + ink_color: Color, + ink_size: f64, + }, BlurReplayPreview(BlurRectParams), None, } @@ -325,7 +338,15 @@ impl Tool { snapshot.grid, snapshot.shape_recognition_sensitivity, ) { - ProvisionalToolStroke::Shape(shape) + ProvisionalToolStroke::Recognized { + shape, + ink: snapshot.points, + ink_color: Color { + a: snapshot.color.a * RECOGNIZED_INK_OPACITY, + ..snapshot.color + }, + ink_size: snapshot.size, + } } else if !snapshot.point_thicknesses.is_empty() && snapshot.point_thicknesses.len() == snapshot.points.len() { @@ -510,6 +531,19 @@ impl<'a> ProvisionalToolStroke<'a> { bounds } } + Self::Recognized { + shape, + ink, + ink_size, + .. + } => { + let shape_bounds = shape.bounding_box_with(measurer); + let ink_bounds = bounding_box_for_points(ink, *ink_size); + match (shape_bounds, ink_bounds) { + (Some(shape_bounds), Some(ink_bounds)) => shape_bounds.union(ink_bounds), + (bounds, None) | (None, bounds) => bounds, + } + } Self::BlurReplayPreview(params) => { bounding_box_for_blur(params.x, params.y, params.w, params.h) } From 6918b1605281d1eea6de60e59fa677470540e2ff Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:54:55 +0200 Subject: [PATCH 08/11] feat: snap Shape Pen shapes to board paper, with an off switch Only recognized lines snapped to the board grid, and snapping could not be turned off. Rectangle and oval edges now snap to nearby Cartesian lines, and triangle corners to nearby Cartesian lines or isometric lattice points, using the same margin as lines. A snap that would collapse a shape is skipped. drawing.shape_recognition_grid_snap (default true), also on the configurator's Drawing page as "Snap Shape Pen to board paper", turns grid snapping off. Axis alignment still applies. --- config.example.toml | 6 + .../src/app/pages/drawing/defaults.rs | 6 + configurator/src/app/search/terms.rs | 2 + .../src/models/config/draft/from_config.rs | 1 + configurator/src/models/config/draft/mod.rs | 1 + configurator/src/models/config/setters.rs | 3 + configurator/src/models/config/tests.rs | 19 +++ .../src/models/config/to_config/drawing.rs | 1 + configurator/src/models/fields/toggles.rs | 1 + docs/CONFIG.md | 4 +- src/config/types/drawing.rs | 9 ++ src/input/state/core/style.rs | 2 + src/input/state/mouse/release/drawing.rs | 2 +- src/input/state/render.rs | 12 +- src/input/state/tests/shape_pen.rs | 25 +++ src/input/tool/live_shape.rs | 10 +- src/input/tool/live_shape/grid.rs | 150 ++++++++++++++++++ src/input/tool/live_shape/tests.rs | 63 +++++++- 18 files changed, 310 insertions(+), 7 deletions(-) create mode 100644 src/input/tool/live_shape/grid.rs diff --git a/config.example.toml b/config.example.toml index ce67e845..eb5f3800 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1131,6 +1131,12 @@ pen_smoothing = 3 # A session remembers the level it was saved at; this is the starting value. shape_recognition_sensitivity = 2 +# Snap Shape Pen results to board paper: lines to nearby grid lines, +# rectangle and oval edges to Cartesian lines, and triangle corners to +# Cartesian lines or isometric points. Set to false to keep recognized shapes +# exactly where you drew them. +shape_recognition_grid_snap = true + # Default fill state for fill-capable shapes default_fill_enabled = false diff --git a/configurator/src/app/pages/drawing/defaults.rs b/configurator/src/app/pages/drawing/defaults.rs index 60c37922..fe1ced52 100644 --- a/configurator/src/app/pages/drawing/defaults.rs +++ b/configurator/src/app/pages/drawing/defaults.rs @@ -66,6 +66,12 @@ pub(super) fn build(page: &mut PageBuilder) { ) }, ) + .switch_row( + "Snap Shape Pen to board paper", + "Recognized lines, rectangles, ovals, and triangles line up with nearby grid lines and points", + |app| app.draft.drawing_shape_recognition_grid_snap, + |value| Message::ToggleChanged(ToggleField::DrawingShapeRecognitionGridSnap, value), + ) .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 de1fd7cf..015bf3be 100644 --- a/configurator/src/app/search/terms.rs +++ b/configurator/src/app/search/terms.rs @@ -102,6 +102,8 @@ pub(super) const DRAWING_DEFAULT_TERMS: &[&str] = &[ "shape pen sensitivity", "shape recognition", "recognition sensitivity", + "snap shape pen to board paper", + "grid snap", "marker", "marker opacity", "fill", diff --git a/configurator/src/models/config/draft/from_config.rs b/configurator/src/models/config/draft/from_config.rs index 9315f4ae..6a5d2af7 100644 --- a/configurator/src/models/config/draft/from_config.rs +++ b/configurator/src/models/config/draft/from_config.rs @@ -78,6 +78,7 @@ impl ConfigDraft { .drawing .shape_recognition_sensitivity .to_string(), + drawing_shape_recognition_grid_snap: config.drawing.shape_recognition_grid_snap, 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 b054ae53..62e874f4 100644 --- a/configurator/src/models/config/draft/mod.rs +++ b/configurator/src/models/config/draft/mod.rs @@ -43,6 +43,7 @@ pub struct ConfigDraft { pub drawing_marker_opacity: String, pub drawing_pen_smoothing: String, pub drawing_shape_recognition_sensitivity: String, + pub drawing_shape_recognition_grid_snap: bool, /// 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 947fd252..21110fb4 100644 --- a/configurator/src/models/config/setters.rs +++ b/configurator/src/models/config/setters.rs @@ -157,6 +157,9 @@ impl ConfigDraft { ToggleField::DrawingFillEnabled => { self.drawing_default_fill_enabled = value; } + ToggleField::DrawingShapeRecognitionGridSnap => { + self.drawing_shape_recognition_grid_snap = value; + } ToggleField::PerformanceVsync => { self.set_performance_bool(PerformanceFieldId::EnableVsync, value); } diff --git a/configurator/src/models/config/tests.rs b/configurator/src/models/config/tests.rs index 65a17dfd..5786030d 100644 --- a/configurator/src/models/config/tests.rs +++ b/configurator/src/models/config/tests.rs @@ -22,6 +22,25 @@ fn shape_pen_sensitivity_round_trips_and_rejects_invalid_drafts() { assert_eq!(draft.drawing_shape_recognition_sensitivity, "5"); } +#[test] +fn shape_pen_grid_snap_round_trips() { + let mut config = Config::default(); + assert!(config.drawing.shape_recognition_grid_snap); + config.drawing.shape_recognition_grid_snap = false; + + let mut draft = ConfigDraft::from_config(&config); + assert!(!draft.drawing_shape_recognition_grid_snap); + + draft.set_toggle(ToggleField::DrawingShapeRecognitionGridSnap, true); + assert!( + draft + .to_config(&config) + .expect("valid draft") + .drawing + .shape_recognition_grid_snap + ); +} + #[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 8267bedb..c4094574 100644 --- a/configurator/src/models/config/to_config/drawing.rs +++ b/configurator/src/models/config/to_config/drawing.rs @@ -85,6 +85,7 @@ impl ConfigDraft { config.drawing.text_background_enabled = self.drawing_text_background_enabled; config.drawing.text_halo_enabled = self.drawing_text_halo_enabled; config.drawing.default_fill_enabled = self.drawing_default_fill_enabled; + config.drawing.shape_recognition_grid_snap = self.drawing_shape_recognition_grid_snap; config.drawing.drag_tool = legacy_tool( self.drawing_drag_tools.left.drag_tool, DragBindableTool::Pen, diff --git a/configurator/src/models/fields/toggles.rs b/configurator/src/models/fields/toggles.rs index 085e5654..a5c113ff 100644 --- a/configurator/src/models/fields/toggles.rs +++ b/configurator/src/models/fields/toggles.rs @@ -3,6 +3,7 @@ pub enum ToggleField { DrawingTextBackground, DrawingTextHalo, DrawingFillEnabled, + DrawingShapeRecognitionGridSnap, PerformanceVsync, UiShowStatusBar, UiStatusBarInteractive, diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 6dea5683..6fa66823 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -246,6 +246,8 @@ pen_smoothing = 3 # Shape Pen: 0 precise, 2 balanced (default), 4 more forgiving shape_recognition_sensitivity = 2 +# Snap Shape Pen results to nearby board-paper lines and points +shape_recognition_grid_snap = true # Default fill state for fill-capable shape tools default_fill_enabled = false @@ -381,7 +383,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)) -- **Shape Pen**: Press `S` (`select_live_shape_tool`), click **Shape Pen** next to Pen on the toolbar (in the Shapes picker in simple mode), or choose **Shape Pen Tool** from the command palette. Confident lines, circles, ovals, axis-aligned rectangles, and triangles preview as shapes over a faint copy of the stroke and commit as editable shapes. Recognized ovals, rectangles, and triangles follow the Fill toggle, like the dedicated shape tools. If a stroke should have stayed ink, undo once to get the original stroke back; undo again to remove it. 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. While Shape Pen is active, the toolbar's **Sensitivity** stepper changes it at once; so do **Increase / Decrease Shape Pen Sensitivity** in the command palette, or bind `increase_shape_recognition_sensitivity` / `decrease_shape_recognition_sensitivity`. A session remembers the level it was saved at; the config value is the starting level. 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. Nearly horizontal or vertical triangle sides align the same way. Other ink stays freehand. +- **Shape Pen**: Press `S` (`select_live_shape_tool`), click **Shape Pen** next to Pen on the toolbar (in the Shapes picker in simple mode), or choose **Shape Pen Tool** from the command palette. Confident lines, circles, ovals, axis-aligned rectangles, and triangles preview as shapes over a faint copy of the stroke and commit as editable shapes. Recognized ovals, rectangles, and triangles follow the Fill toggle, like the dedicated shape tools. If a stroke should have stayed ink, undo once to get the original stroke back; undo again to remove it. 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. While Shape Pen is active, the toolbar's **Sensitivity** stepper changes it at once; so do **Increase / Decrease Shape Pen Sensitivity** in the command palette, or bind `increase_shape_recognition_sensitivity` / `decrease_shape_recognition_sensitivity`. A session remembers the level it was saved at; the config value is the starting level. 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. Nearly horizontal or vertical triangle sides align the same way. Rectangle and oval edges also snap to nearby Cartesian lines, and triangle corners to nearby Cartesian lines or isometric points. Set `[drawing] shape_recognition_grid_snap = false`, or turn off **Snap Shape Pen to board paper** in the configurator, to keep recognized shapes exactly where you drew them; axis alignment still applies. 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) diff --git a/src/config/types/drawing.rs b/src/config/types/drawing.rs index affb2ea4..a45f11c5 100644 --- a/src/config/types/drawing.rs +++ b/src/config/types/drawing.rs @@ -76,6 +76,10 @@ pub struct DrawingConfig { #[serde(default = "default_shape_recognition_sensitivity")] pub shape_recognition_sensitivity: u8, + /// Whether Shape Pen results snap to nearby board-paper lines and points. + #[serde(default = "default_shape_recognition_grid_snap")] + pub shape_recognition_grid_snap: bool, + /// Whether shapes start filled when applicable #[serde(default = "default_fill_enabled")] pub default_fill_enabled: bool, @@ -164,6 +168,7 @@ impl Default for DrawingConfig { font_cycle: default_font_cycle(), pen_smoothing: default_pen_smoothing(), shape_recognition_sensitivity: default_shape_recognition_sensitivity(), + shape_recognition_grid_snap: default_shape_recognition_grid_snap(), default_fill_enabled: default_fill_enabled(), polygon_sides: default_polygon_sides(), default_font_size: default_font_size(), @@ -927,6 +932,10 @@ fn default_shape_recognition_sensitivity() -> u8 { DEFAULT_SHAPE_RECOGNITION_SENSITIVITY } +fn default_shape_recognition_grid_snap() -> bool { + true +} + fn default_fill_enabled() -> bool { false } diff --git a/src/input/state/core/style.rs b/src/input/state/core/style.rs index 3018041e..ad5e5956 100644 --- a/src/input/state/core/style.rs +++ b/src/input/state/core/style.rs @@ -32,6 +32,7 @@ pub(crate) struct DrawingStyle { pub(crate) marker_opacity: f64, pub(crate) pen_smoothing: u8, pub(crate) shape_recognition_sensitivity: u8, + pub(crate) shape_recognition_grid_snap: bool, pub(crate) blur_style: BlurStyle, pub(crate) spotlight_dim_opacity: f64, pub(crate) spotlight_feather: f64, @@ -82,6 +83,7 @@ impl From<(&DrawingConfig, &ArrowConfig, &SpotlightConfig)> for DrawingStyle { shape_recognition_sensitivity: drawing .shape_recognition_sensitivity .min(crate::config::MAX_SHAPE_RECOGNITION_SENSITIVITY), + shape_recognition_grid_snap: drawing.shape_recognition_grid_snap, blur_style: drawing.default_blur_style, spotlight_dim_opacity: spotlight.dim_opacity, spotlight_feather: spotlight.feather, diff --git a/src/input/state/mouse/release/drawing.rs b/src/input/state/mouse/release/drawing.rs index 91027176..771bd8bd 100644 --- a/src/input/state/mouse/release/drawing.rs +++ b/src/input/state/mouse/release/drawing.rs @@ -67,7 +67,7 @@ pub(super) fn finish_drawing( point_thicknesses: release.point_thicknesses, color: drawing_color, size: drawing_thickness, - grid: state.boards.active_board().spec.grid, + grid: state.shape_pen_grid(), shape_recognition_sensitivity: state.style.shape_recognition_sensitivity, marker_opacity: state.style.marker_opacity, fill_enabled: state.style.fill_enabled, diff --git a/src/input/state/render.rs b/src/input/state/render.rs index 73ac6e1c..46492bc2 100644 --- a/src/input/state/render.rs +++ b/src/input/state/render.rs @@ -64,6 +64,16 @@ impl InputState { }) } + /// The board paper Shape Pen snaps to: none when snapping is turned off. + pub(crate) fn shape_pen_grid(&self) -> crate::domain::BoardGrid { + let grid = self.boards.active_board().spec.grid; + if self.style.shape_recognition_grid_snap { + grid + } else { + grid.disabled() + } + } + pub(crate) fn provisional_tool_stroke( &self, current_x: i32, @@ -101,7 +111,7 @@ impl InputState { point_thicknesses, color: self.active_drag_color_or_current(), size: self.thickness_for_tool(*tool), - grid: self.boards.active_board().spec.grid, + grid: self.shape_pen_grid(), shape_recognition_sensitivity: self.style.shape_recognition_sensitivity, eraser_size: self.style.eraser_size, marker_opacity: self.style.marker_opacity, diff --git a/src/input/state/tests/shape_pen.rs b/src/input/state/tests/shape_pen.rs index c2dbb57f..aa740100 100644 --- a/src/input/state/tests/shape_pen.rs +++ b/src/input/state/tests/shape_pen.rs @@ -254,3 +254,28 @@ fn recognized_preview_keeps_a_faint_copy_of_the_ink_underneath() { assert!(bounds.contains(x, y), "({x}, {y}) outside {bounds:?}"); } } + +#[test] +fn turning_grid_snap_off_keeps_shapes_where_they_were_drawn() { + use crate::domain::{BoardGrid, BoardGridKind}; + use crate::input::BOARD_ID_WHITEBOARD; + + let line = [(0, 44), (30, 43), (60, 42), (100, 44)]; + for (snap, expected_y) in [(true, 40), (false, 44)] { + let mut state = shape_pen_state(); + state.switch_board(BOARD_ID_WHITEBOARD); + state.boards.active_board_mut().spec.grid = BoardGrid::new(BoardGridKind::Cartesian, 40); + state.style.shape_recognition_grid_snap = snap; + + draw_path(&mut state, &line); + release_at_end(&mut state, &line); + + assert!( + matches!( + state.boards.active_frame().shapes[0].shape, + Shape::Line { y1, y2, .. } if y1 == expected_y && y2 == expected_y + ), + "snap {snap}" + ); + } +} diff --git a/src/input/tool/live_shape.rs b/src/input/tool/live_shape.rs index b446d7ae..17ca6049 100644 --- a/src/input/tool/live_shape.rs +++ b/src/input/tool/live_shape.rs @@ -3,6 +3,7 @@ use crate::domain::{BoardGrid, BoardGridKind}; use crate::draw::{Color, Shape}; +mod grid; #[cfg(test)] mod tests; mod triangle; @@ -38,7 +39,7 @@ pub(super) fn recognize( { *filled = fill; } - return Some(shape); + return Some(grid::snap_closed_shape(shape, grid)); } if chord < 16.0 || resampled_length(points) > chord * (1.08 + 0.04 * f64::from(sensitivity)) { @@ -194,11 +195,16 @@ fn align_line( 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); + let margin = grid_snap_margin(spacing); ((first - coordinate).abs() <= margin && (last - coordinate).abs() <= margin) .then_some(coordinate.round() as i32) } +/// How far ink may sit from board paper and still snap to it. +fn grid_snap_margin(spacing: f64) -> f64 { + (spacing * 0.15).clamp(4.0, 8.0) +} + fn winds_once(points: &[(i32, i32)], bounds: Bounds) -> bool { let (cx, cy) = bounds.center(); let rx = bounds.width / 2.0; diff --git a/src/input/tool/live_shape/grid.rs b/src/input/tool/live_shape/grid.rs new file mode 100644 index 00000000..83331cf3 --- /dev/null +++ b/src/input/tool/live_shape/grid.rs @@ -0,0 +1,150 @@ +//! Snap recognized closed shapes to the board paper under them: rectangle +//! and ellipse edges to Cartesian lines, triangle corners to Cartesian lines +//! or isometric lattice points. Lines snap in `align_line`. + +use crate::domain::{BoardGrid, BoardGridKind}; +use crate::draw::Shape; + +use super::grid_snap_margin; + +pub(super) fn snap_closed_shape(shape: Shape, grid: BoardGrid) -> Shape { + let spacing = f64::from(grid.spacing()); + match (grid.kind, shape) { + ( + BoardGridKind::Cartesian, + Shape::Rect { + x, + y, + w, + h, + fill, + color, + thick, + }, + ) => { + let (x, w) = snap_span(x, w, spacing); + let (y, h) = snap_span(y, h, spacing); + Shape::Rect { + x, + y, + w, + h, + fill, + color, + thick, + } + } + ( + BoardGridKind::Cartesian, + Shape::Ellipse { + cx, + cy, + rx, + ry, + fill, + color, + thick, + }, + ) => { + let (left, width) = snap_span(cx - rx, 2 * rx, spacing); + let (top, height) = snap_span(cy - ry, 2 * ry, spacing); + Shape::Ellipse { + cx: left + width / 2, + cy: top + height / 2, + rx: width / 2, + ry: height / 2, + fill, + color, + thick, + } + } + ( + paper, + Shape::Polygon { + kind, + points, + fill, + color, + thick, + }, + ) if points.len() == 3 => { + let snap = |point: (i32, i32)| match paper { + BoardGridKind::Cartesian => ( + snap_coordinate(point.0, spacing), + snap_coordinate(point.1, spacing), + ), + BoardGridKind::Isometric | BoardGridKind::IsometricDots => { + snap_to_lattice(point, spacing) + } + BoardGridKind::None => point, + }; + let snapped: Vec<_> = points.iter().map(|&point| snap(point)).collect(); + // Corners pulled onto one lattice point would flatten the triangle. + let points = if doubled_area(&snapped) * 2 >= doubled_area(&points) { + snapped + } else { + points + }; + Shape::Polygon { + kind, + points, + fill, + color, + thick, + } + } + (_, shape) => shape, + } +} + +/// Snap both ends of one axis of a box, unless that would collapse it. +fn snap_span(start: i32, length: i32, spacing: f64) -> (i32, i32) { + let low = snap_coordinate(start, spacing); + let high = snap_coordinate(start + length, spacing); + if high > low { + (low, high - low) + } else { + (start, length) + } +} + +fn snap_coordinate(value: i32, spacing: f64) -> i32 { + let value = f64::from(value); + let line = (value / spacing).round() * spacing; + if (value - line).abs() <= grid_snap_margin(spacing) { + line.round() as i32 + } else { + value as i32 + } +} + +/// The nearest isometric lattice point, if it is close. Columns sit +/// `√3/2 · spacing` apart and odd columns are shifted down half a spacing, +/// matching the painted lines and dots. +fn snap_to_lattice(point: (i32, i32), spacing: f64) -> (i32, i32) { + let (x, y) = (f64::from(point.0), f64::from(point.1)); + let column = 3.0_f64.sqrt() * spacing / 2.0; + let nearest_column = (x / column).round() as i64; + + let (lattice, distance) = (nearest_column - 1..=nearest_column + 1) + .map(|index| { + let shift = index.rem_euclid(2) as f64 * spacing / 2.0; + let lattice = ( + index as f64 * column, + ((y - shift) / spacing).round() * spacing + shift, + ); + (lattice, (lattice.0 - x).hypot(lattice.1 - y)) + }) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .expect("three candidate columns"); + if distance <= grid_snap_margin(spacing) { + (lattice.0.round() as i32, lattice.1.round() as i32) + } else { + point + } +} + +fn doubled_area(points: &[(i32, i32)]) -> i64 { + let [a, b, c] = [points[0], points[1], points[2]].map(|(x, y)| (i64::from(x), i64::from(y))); + ((b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0)).abs() +} diff --git a/src/input/tool/live_shape/tests.rs b/src/input/tool/live_shape/tests.rs index 6f920a9c..77451c62 100644 --- a/src/input/tool/live_shape/tests.rs +++ b/src/input/tool/live_shape/tests.rs @@ -1,8 +1,12 @@ -use crate::domain::BoardGrid; +use crate::domain::{BoardGrid, BoardGridKind}; use crate::draw::{BLACK, PolygonKind, Shape}; fn recognize(points: &[(i32, i32)], sensitivity: u8) -> Option { - super::recognize(points, BLACK, 3.0, false, BoardGrid::default(), sensitivity) + recognize_on(points, BoardGrid::default(), sensitivity) +} + +fn recognize_on(points: &[(i32, i32)], grid: BoardGrid, sensitivity: u8) -> Option { + super::recognize(points, BLACK, 3.0, false, grid, sensitivity) } fn triangle_points(shape: Option) -> Option> { @@ -215,3 +219,58 @@ fn round_and_four_sided_strokes_never_become_triangles() { } } } + +#[test] +fn rectangles_snap_their_edges_to_nearby_cartesian_lines() { + let grid = BoardGrid::new(BoardGridKind::Cartesian, 40); + let near = [(42.0, 38.0), (158.0, 41.0), (157.0, 122.0), (41.0, 119.0)]; + let far = [(20.0, 20.0), (140.0, 20.0), (140.0, 100.0), (20.0, 100.0)]; + + let snapped = recognize_on(&trace(&near, 0.1, 3.0, 0.0), grid, 2); + let untouched = recognize_on(&trace(&far, 0.1, 3.0, 0.0), grid, 2); + + assert!( + matches!( + snapped, + Some(Shape::Rect { + x: 40, + y: 40, + w: 120, + h: 80, + .. + }) + ), + "{snapped:?}" + ); + assert!( + matches!( + untouched, + Some(Shape::Rect { + x: 20, + y: 20, + w: 120, + h: 80, + .. + }) + ), + "edges 20px from every line stay put: {untouched:?}" + ); +} + +#[test] +fn triangle_corners_snap_to_isometric_lattice_points() { + // Lattice points (i, j) sit at x = i·√3/2·s and y = j·s, shifted down half + // a spacing in odd columns. These corners are a few pixels off i = 2, 6 + // (y = 160) and i = 4 (y = 40). + let drawn = [(72.0, 157.0), (205.0, 163.0), (140.0, 43.0)]; + + for kind in [BoardGridKind::Isometric, BoardGridKind::IsometricDots] { + let grid = BoardGrid::new(kind, 40); + let points = triangle_points(recognize_on(&trace(&drawn, 0.1, 3.0, 0.0), grid, 2)) + .unwrap_or_else(|| panic!("triangle on {kind:?}")); + + let mut sorted = points.clone(); + sorted.sort_unstable(); + assert_eq!(sorted, vec![(69, 160), (139, 40), (208, 160)], "{kind:?}"); + } +} From 45f631a4798926e3c06f757ff9ac3ddaba66beff Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:02:11 +0200 Subject: [PATCH 09/11] feat: make Shape Pen more forgiving by default Quick mouse rectangles with a tilted, leaning, or rounded side stayed ink at the default level, because the rectangle fit compared the stroke with its bounding box. When that fit fails, find the four corners, fit a line through each side, and accept sides that alternate between roughly horizontal and roughly vertical, leaning up to 12 degrees plus 4 per sensitivity level. The rectangle takes each side's average position. Also raise the default sensitivity from 2 to 3. Level 4 remains the most forgiving, and one undo returns the ink when recognition guesses wrong. The triangle fit now shares its resampling and side fitting with the new fallback in live_shape/outline.rs. --- config.example.toml | 10 +- docs/CONFIG.md | 6 +- src/config/types/drawing.rs | 5 +- src/input/tool/live_shape.rs | 6 + src/input/tool/live_shape/outline.rs | 242 +++++++++++++++++++ src/input/tool/live_shape/rough_rectangle.rs | 82 +++++++ src/input/tool/live_shape/tests.rs | 93 +++++++ src/input/tool/live_shape/triangle.rs | 236 ++---------------- 8 files changed, 450 insertions(+), 230 deletions(-) create mode 100644 src/input/tool/live_shape/outline.rs create mode 100644 src/input/tool/live_shape/rough_rectangle.rs diff --git a/config.example.toml b/config.example.toml index eb5f3800..552cdf4c 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1122,14 +1122,14 @@ 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, -# rectangles, and triangles; it does not change the dedicated shape tools or -# grid spacing. +# Shape Pen recognition sensitivity (0 - 4): 0 is precise, 3 (the default) +# is forgiving, and 4 accepts the roughest lines and closed shapes. Applies to +# lines, ellipses, rectangles, and triangles; it does not change the dedicated +# shape tools or grid spacing. # # Also on the toolbar as the "Sensitivity" stepper while Shape Pen is up. # A session remembers the level it was saved at; this is the starting value. -shape_recognition_sensitivity = 2 +shape_recognition_sensitivity = 3 # Snap Shape Pen results to board paper: lines to nearby grid lines, # rectangle and oval edges to Cartesian lines, and triangle corners to diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 6fa66823..3528b3ea 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -244,8 +244,8 @@ 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 +# Shape Pen: 0 precise, 3 forgiving (default), 4 most forgiving +shape_recognition_sensitivity = 3 # Snap Shape Pen results to nearby board-paper lines and points shape_recognition_grid_snap = true @@ -383,7 +383,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)) -- **Shape Pen**: Press `S` (`select_live_shape_tool`), click **Shape Pen** next to Pen on the toolbar (in the Shapes picker in simple mode), or choose **Shape Pen Tool** from the command palette. Confident lines, circles, ovals, axis-aligned rectangles, and triangles preview as shapes over a faint copy of the stroke and commit as editable shapes. Recognized ovals, rectangles, and triangles follow the Fill toggle, like the dedicated shape tools. If a stroke should have stayed ink, undo once to get the original stroke back; undo again to remove it. 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. While Shape Pen is active, the toolbar's **Sensitivity** stepper changes it at once; so do **Increase / Decrease Shape Pen Sensitivity** in the command palette, or bind `increase_shape_recognition_sensitivity` / `decrease_shape_recognition_sensitivity`. A session remembers the level it was saved at; the config value is the starting level. 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. Nearly horizontal or vertical triangle sides align the same way. Rectangle and oval edges also snap to nearby Cartesian lines, and triangle corners to nearby Cartesian lines or isometric points. Set `[drawing] shape_recognition_grid_snap = false`, or turn off **Snap Shape Pen to board paper** in the configurator, to keep recognized shapes exactly where you drew them; axis alignment still applies. Other ink stays freehand. +- **Shape Pen**: Press `S` (`select_live_shape_tool`), click **Shape Pen** next to Pen on the toolbar (in the Shapes picker in simple mode), or choose **Shape Pen Tool** from the command palette. Confident lines, circles, ovals, rectangles, and triangles preview as shapes over a faint copy of the stroke and commit as editable shapes. Recognized ovals, rectangles, and triangles follow the Fill toggle, like the dedicated shape tools. If a stroke should have stayed ink, undo once to get the original stroke back; undo again to remove it. Set `[drawing] shape_recognition_sensitivity` from 0 (precise) to 4 (most forgiving), with 3 as the default, or edit **Shape Pen sensitivity** on the configurator's Drawing page. While Shape Pen is active, the toolbar's **Sensitivity** stepper changes it at once; so do **Increase / Decrease Shape Pen Sensitivity** in the command palette, or bind `increase_shape_recognition_sensitivity` / `decrease_shape_recognition_sensitivity`. A session remembers the level it was saved at; the config value is the starting level. 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. Nearly horizontal or vertical triangle sides align the same way. Rectangle and oval edges also snap to nearby Cartesian lines, and triangle corners to nearby Cartesian lines or isometric points. Set `[drawing] shape_recognition_grid_snap = false`, or turn off **Snap Shape Pen to board paper** in the configurator, to keep recognized shapes exactly where you drew them; axis alignment still applies. Rectangles drawn quickly, with leaning or skewed sides, are squared up to the average position of each side. 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) diff --git a/src/config/types/drawing.rs b/src/config/types/drawing.rs index a45f11c5..5d9e2f2c 100644 --- a/src/config/types/drawing.rs +++ b/src/config/types/drawing.rs @@ -12,8 +12,9 @@ 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; +/// Shape Pen recognition levels, from precise to forgiving. The default leans +/// forgiving: quick shapes should become shapes, and one undo returns the ink. +pub const DEFAULT_SHAPE_RECOGNITION_SENSITIVITY: u8 = 3; pub const MAX_SHAPE_RECOGNITION_SENSITIVITY: u8 = 4; /// Drawing-related settings. diff --git a/src/input/tool/live_shape.rs b/src/input/tool/live_shape.rs index 17ca6049..4c6d7d99 100644 --- a/src/input/tool/live_shape.rs +++ b/src/input/tool/live_shape.rs @@ -4,6 +4,8 @@ use crate::domain::{BoardGrid, BoardGridKind}; use crate::draw::{Color, Shape}; mod grid; +mod outline; +mod rough_rectangle; #[cfg(test)] mod tests; mod triangle; @@ -134,6 +136,10 @@ fn recognize_closed( } else { (None, None) }; + // A rectangle the box fit rejects may still be one whose sides lean. + let rectangle = rectangle.or_else(|| { + rough_rectangle::fit_rough_rectangle(points, bounds, color, thick, sensitivity) + }); let triangle = triangle::fit_triangle(points, bounds, color, thick, sensitivity); // Ties keep the earlier candidate, so an ellipse wins an exact tie. diff --git a/src/input/tool/live_shape/outline.rs b/src/input/tool/live_shape/outline.rs new file mode 100644 index 00000000..fc402e1e --- /dev/null +++ b/src/input/tool/live_shape/outline.rs @@ -0,0 +1,242 @@ +//! Shared geometry for straight-sided Shape Pen fits: resample a closed pen +//! path evenly, then fit a straight line through each side between known +//! corners, so rounded or overshooting corners still land where the sides +//! meet. + +pub(super) type Point = (f64, f64); + +/// Evenly spaced samples taken around the closed stroke. +const SAMPLES: usize = 96; +/// Share of each side, at both ends, left out of its line fit so the curve +/// of a hand-drawn corner does not tilt the side. +const CORNER_TRIM: f64 = 0.2; +/// Samples each side needs before its line can be fitted. +const MIN_SIDE_SAMPLES: usize = 6; +/// Adjacent sides closer to parallel than this (sine of the angle) have no +/// usable corner. +const MIN_CORNER_SINE: f64 = 0.05; + +#[derive(Clone, Copy)] +pub(super) struct Line { + pub(super) origin: Point, + pub(super) direction: Point, +} + +/// A closed stroke fitted with one straight line per side. +pub(super) struct SidedFit { + samples: Vec, + /// Sample indices where sides meet, in drawing order. + corners: Vec, + /// Side `k` runs from corner `k` to corner `k + 1`. + pub(super) lines: Vec, + /// Vertex `k` is where side `k - 1` meets side `k`. + pub(super) vertices: Vec, +} + +impl SidedFit { + pub(super) fn new(samples: Vec, mut corners: Vec) -> Option { + corners.sort_unstable(); + corners.dedup(); + let sides = corners.len(); + if sides < 3 + || (0..sides).any(|side| { + let (start, end) = side_span(samples.len(), &corners, side); + end - start < MIN_SIDE_SAMPLES + }) + { + return None; + } + + let lines = (0..sides) + .map(|side| { + let (start, end) = side_span(samples.len(), &corners, side); + let trim = ((end - start) as f64 * CORNER_TRIM) as usize; + fit_line((start + trim..=end - trim).map(|index| samples[index % samples.len()])) + }) + .collect::>>()?; + let vertices = (0..sides) + .map(|corner| intersect(lines[(corner + sides - 1) % sides], lines[corner])) + .collect::>>()?; + + Some(Self { + samples, + corners, + lines, + vertices, + }) + } + + /// Length of side `k`, between vertex `k` and vertex `k + 1`. + pub(super) fn side_lengths(&self) -> Vec { + let count = self.vertices.len(); + (0..count) + .map(|side| distance(self.vertices[side], self.vertices[(side + 1) % count])) + .collect() + } + + /// Whether the stroke passes close to every fitted vertex, within `reach` + /// of the shorter side beside it. Corners rounded off far inside describe + /// an ellipse instead. + pub(super) fn corners_drawn(&self, reach: f64) -> bool { + let sides = self.side_lengths(); + let count = sides.len(); + (0..count).all(|corner| { + let shorter_side = sides[corner].min(sides[(corner + count - 1) % count]); + distance_to_outline(&self.samples, self.vertices[corner]) <= shorter_side * reach + }) + } + + /// Mean and worst distance from each sample to the fitted segment of its + /// own side, relative to `scale`. + pub(super) fn deviation(&self, scale: f64) -> (f64, f64) { + let count = self.vertices.len(); + let mut total = 0.0; + let mut worst = 0.0_f64; + for side in 0..count { + let (start, end) = side_span(self.samples.len(), &self.corners, side); + let (a, b) = (self.vertices[side], self.vertices[(side + 1) % count]); + for index in start..end { + let sample = self.samples[index % self.samples.len()]; + let deviation = distance_to_segment(sample, a, b) / scale; + total += deviation; + worst = worst.max(deviation); + } + } + (total / self.samples.len() as f64, worst) + } +} + +/// Evenly spaced samples around the stroke, closed back to its start. +pub(super) fn resample_closed(points: &[(i32, i32)]) -> Option> { + let outline: Vec = points + .iter() + .map(|&(x, y)| (f64::from(x), f64::from(y))) + .collect(); + let segment = |index: usize| (outline[index], outline[(index + 1) % outline.len()]); + let total: f64 = (0..outline.len()) + .map(|index| { + let (start, end) = segment(index); + distance(start, end) + }) + .sum(); + if outline.len() < 3 || total <= 0.0 { + return None; + } + + let step = total / SAMPLES as f64; + let mut samples = Vec::with_capacity(SAMPLES); + let mut walked = 0.0; + for index in 0..outline.len() { + let (start, end) = segment(index); + let length = distance(start, end); + // Repeated pointer positions add nothing to walk along. + if length == 0.0 { + continue; + } + while samples.len() < SAMPLES && step * samples.len() as f64 <= walked + length { + let t = (step * samples.len() as f64 - walked) / length; + samples.push(( + start.0 + (end.0 - start.0) * t, + start.1 + (end.1 - start.1) * t, + )); + } + walked += length; + } + + (samples.len() == SAMPLES).then_some(samples) +} + +/// The stroke's length including the gap back to its start, free of the +/// integer pixel staircases that inflate dense diagonal input. +pub(super) fn closed_length(points: &[(i32, i32)]) -> Option { + Some(super::resampled_length(points) + super::distance(*points.last()?, *points.first()?)) +} + +/// Index of the sample that scores highest. +pub(super) fn farthest(samples: &[Point], measure: impl Fn(Point) -> f64) -> usize { + samples + .iter() + .map(|&point| measure(point)) + .enumerate() + .max_by(|a, b| a.1.total_cmp(&b.1)) + .map_or(0, |(index, _)| index) +} + +/// First and last sample index of one side; the last side wraps past the +/// stroke's start. +fn side_span(sample_count: usize, corners: &[usize], side: usize) -> (usize, usize) { + let start = corners[side]; + let end = match corners.get(side + 1) { + Some(&next) => next, + None => corners[0] + sample_count, + }; + (start, end) +} + +/// Total least-squares line through the points. +fn fit_line(points: impl Iterator + Clone) -> Option { + let count = points.clone().count(); + if count < 2 { + return None; + } + + let count = count as f64; + let origin = points.clone().fold((0.0, 0.0), |sum, point| { + (sum.0 + point.0 / count, sum.1 + point.1 / count) + }); + let (xx, yy, xy) = points.fold((0.0, 0.0, 0.0), |(xx, yy, xy), point| { + let dx = point.0 - origin.0; + let dy = point.1 - origin.1; + (xx + dx * dx, yy + dy * dy, xy + dx * dy) + }); + let angle = 0.5 * (2.0 * xy).atan2(xx - yy); + + Some(Line { + origin, + direction: (angle.cos(), angle.sin()), + }) +} + +fn intersect(first: Line, second: Line) -> Option { + let denominator = cross(first.direction, second.direction); + if denominator.abs() < MIN_CORNER_SINE { + return None; + } + + let t = cross(sub(second.origin, first.origin), second.direction) / denominator; + Some(( + first.origin.0 + first.direction.0 * t, + first.origin.1 + first.direction.1 * t, + )) +} + +fn distance_to_outline(samples: &[Point], target: Point) -> f64 { + (0..samples.len()) + .map(|index| { + distance_to_segment(target, samples[index], samples[(index + 1) % samples.len()]) + }) + .fold(f64::INFINITY, f64::min) +} + +fn distance_to_segment(point: Point, a: Point, b: Point) -> f64 { + let edge = sub(b, a); + let length_squared = edge.0 * edge.0 + edge.1 * edge.1; + let t = if length_squared > 0.0 { + (((point.0 - a.0) * edge.0 + (point.1 - a.1) * edge.1) / length_squared).clamp(0.0, 1.0) + } else { + 0.0 + }; + distance(point, (a.0 + edge.0 * t, a.1 + edge.1 * t)) +} + +pub(super) fn distance(a: Point, b: Point) -> f64 { + (a.0 - b.0).hypot(a.1 - b.1) +} + +pub(super) fn sub(a: Point, b: Point) -> Point { + (a.0 - b.0, a.1 - b.1) +} + +pub(super) fn cross(a: Point, b: Point) -> f64 { + a.0 * b.1 - a.1 * b.0 +} diff --git a/src/input/tool/live_shape/rough_rectangle.rs b/src/input/tool/live_shape/rough_rectangle.rs new file mode 100644 index 00000000..538bbf50 --- /dev/null +++ b/src/input/tool/live_shape/rough_rectangle.rs @@ -0,0 +1,82 @@ +//! Recognize rectangles drawn quickly by hand, whose sides lean or bow too +//! far from the stroke's bounding box for the box fit: find the four corners, +//! fit a line through each side, and square the sides up. + +use crate::draw::{Color, Shape}; + +use super::outline::{self, SidedFit, farthest}; +use super::{Bounds, ClosedFit}; + +pub(super) fn fit_rough_rectangle( + points: &[(i32, i32)], + bounds: Bounds, + color: Color, + thick: f64, + sensitivity: u8, +) -> Option { + let level = f64::from(sensitivity); + let samples = outline::resample_closed(points)?; + let length = outline::closed_length(points)?; + // The sample farthest toward each corner of the box, measured diagonally, + // is the drawn corner even when the sides beside it lean. + let corners = [(-1.0, -1.0), (1.0, -1.0), (1.0, 1.0), (-1.0, 1.0)] + .map(|(dx, dy)| farthest(&samples, |point| dx * point.0 + dy * point.1)); + let fit = SidedFit::new(samples, corners.to_vec())?; + if fit.lines.len() != 4 { + return None; + } + + // Sides must alternate between nearly horizontal and nearly vertical, + // each leaning at most this far from its axis. + let max_lean = (12.0 + 4.0 * level).to_radians().tan(); + let horizontal: Vec = fit + .lines + .iter() + .map(|line| line.direction.1.abs() <= line.direction.0.abs()) + .collect(); + if (0..4).any(|side| horizontal[side] == horizontal[(side + 1) % 4]) + || fit.lines.iter().any(|line| { + let (along, across) = (line.direction.0.abs(), line.direction.1.abs()); + along.min(across) > along.max(across) * max_lean + }) + { + return None; + } + + let perimeter: f64 = fit.side_lengths().iter().sum(); + if !(perimeter * (0.85 - 0.03 * level)..=perimeter * (1.25 + 0.08 * level)).contains(&length) + || !fit.corners_drawn(0.06 + 0.02 * level) + { + return None; + } + + let (error, worst) = fit.deviation(bounds.width.min(bounds.height)); + if error > 0.035 + 0.02 * level || worst > 0.13 + 0.035 * level { + return None; + } + + // Each side becomes the axis line through its average position. + let (mut xs, mut ys) = (Vec::with_capacity(2), Vec::with_capacity(2)); + for (line, &is_horizontal) in fit.lines.iter().zip(&horizontal) { + if is_horizontal { + ys.push(line.origin.1); + } else { + xs.push(line.origin.0); + } + } + let (left, right) = (xs[0].min(xs[1]), xs[0].max(xs[1])); + let (top, bottom) = (ys[0].min(ys[1]), ys[0].max(ys[1])); + + Some(ClosedFit { + shape: Shape::Rect { + x: left.round() as i32, + y: top.round() as i32, + w: (right - left).round() as i32, + h: (bottom - top).round() as i32, + fill: false, + color, + thick, + }, + error, + }) +} diff --git a/src/input/tool/live_shape/tests.rs b/src/input/tool/live_shape/tests.rs index 77451c62..89e87519 100644 --- a/src/input/tool/live_shape/tests.rs +++ b/src/input/tool/live_shape/tests.rs @@ -274,3 +274,96 @@ fn triangle_corners_snap_to_isometric_lattice_points() { assert_eq!(sorted, vec![(69, 160), (139, 40), (208, 160)], "{kind:?}"); } } + +/// Quick mouse rectangles traced from a screenshot of strokes Shape Pen left +/// as ink at the old default: one tilted, one with a rounded corner, and one +/// skewed with a side leaning about 17 degrees. +const QUICK_RECTANGLES: [&[(f64, f64)]; 3] = [ + &[(38.0, 122.0), (382.0, 150.0), (401.0, 256.0), (39.0, 240.0)], + &[ + (567.0, 234.0), + (845.0, 226.0), + (850.0, 386.0), + (622.0, 395.0), + (597.0, 350.0), + ], + &[ + (206.0, 343.0), + (457.0, 350.0), + (492.0, 466.0), + (252.0, 485.0), + ], +]; + +#[test] +fn quick_rectangles_with_leaning_sides_are_rectangles_by_default() { + let default = crate::config::DEFAULT_SHAPE_RECOGNITION_SENSITIVITY; + + for corners in QUICK_RECTANGLES { + let path = trace(corners, 0.05, 2.0, 1.0); + + let shape = recognize(&path, default); + + let Some(Shape::Rect { x, y, w, h, .. }) = shape else { + panic!("{corners:?} became {shape:?}"); + }; + // Each side lands on its average position, inside the drawn extremes. + let (xs, ys): (Vec, Vec) = corners.iter().copied().unzip(); + let min = |values: &[f64]| values.iter().copied().fold(f64::INFINITY, f64::min); + let max = |values: &[f64]| values.iter().copied().fold(f64::NEG_INFINITY, f64::max); + assert!(f64::from(x) >= min(&xs) - 2.0 && f64::from(x + w) <= max(&xs) + 2.0); + assert!(f64::from(y) >= min(&ys) - 2.0 && f64::from(y + h) <= max(&ys) + 2.0); + } +} + +#[test] +fn leaning_quadrilaterals_need_more_sensitivity_the_more_they_lean() { + // Left and right sides lean 21 degrees: a trapezoid, not a quick + // rectangle, until the most forgiving levels. + let trapezoid = [ + (100.0, 250.0), + (300.0, 250.0), + (250.0, 120.0), + (150.0, 120.0), + ]; + let path = trace(&trapezoid, 0.1, 3.0, 0.0); + + let kinds: Vec<_> = (0..=crate::config::MAX_SHAPE_RECOGNITION_SENSITIVITY) + .map(|level| recognize(&path, level).map(|shape| shape.kind_name())) + .collect(); + + assert_eq!( + kinds, + [None, None, None, Some("Rectangle"), Some("Rectangle")] + ); +} + +#[test] +fn diamonds_kites_and_round_strokes_never_become_rectangles() { + let outlines = [ + ("diamond", regular(4, (70.0, 70.0), 0.0)), + ( + "kite", + vec![ + (200.0, 100.0), + (260.0, 170.0), + (200.0, 300.0), + (140.0, 170.0), + ], + ), + ("circle", regular(48, (60.0, 60.0), 0.0)), + ("oval", regular(48, (90.0, 50.0), 0.0)), + ]; + + for (name, outline) in outlines { + for wobble in [0.0, 2.0] { + let path = trace(&outline, 0.1, 3.0, wobble); + for level in 0..=crate::config::MAX_SHAPE_RECOGNITION_SENSITIVITY { + assert!( + !matches!(recognize(&path, level), Some(Shape::Rect { .. })), + "{name} with wobble {wobble} at level {level}" + ); + } + } + } +} diff --git a/src/input/tool/live_shape/triangle.rs b/src/input/tool/live_shape/triangle.rs index 3d748242..2f1def41 100644 --- a/src/input/tool/live_shape/triangle.rs +++ b/src/input/tool/live_shape/triangle.rs @@ -4,32 +4,15 @@ use crate::draw::{Color, PolygonKind, Shape}; +use super::outline::{self, Point, SidedFit, cross, distance, farthest, sub}; use super::{Bounds, ClosedFit}; -/// Evenly spaced samples taken around the closed stroke. -const SAMPLES: usize = 96; -/// Share of each side, at both ends, left out of its line fit so the curve -/// of a hand-drawn corner does not tilt the edge. -const CORNER_TRIM: f64 = 0.2; -/// Samples each side needs before its edge can be fitted. -const MIN_SIDE_SAMPLES: usize = 6; -/// Adjacent edges closer to parallel than this (sine of the angle) have no -/// usable corner. -const MIN_CORNER_SINE: f64 = 0.05; /// Lowest height over the longest side; flatter strokes read as a line drawn /// out and back. const MIN_HEIGHT_RATIO: f64 = 0.15; /// Edges this close to horizontal or vertical are levelled, like lines. const AXIS_TOLERANCE: f64 = 0.12; -type Point = (f64, f64); - -#[derive(Clone, Copy)] -struct Line { - origin: Point, - direction: Point, -} - pub(super) fn fit_triangle( points: &[(i32, i32)], bounds: Bounds, @@ -38,34 +21,27 @@ pub(super) fn fit_triangle( sensitivity: u8, ) -> Option { let level = f64::from(sensitivity); - let samples = resample_closed(points)?; - let length = super::resampled_length(points) + super::distance(*points.last()?, points[0]); - let corners = find_corners(&samples)?; - let vertices = fit_vertices(&samples, corners)?; + let samples = outline::resample_closed(points)?; + let length = outline::closed_length(points)?; + let corners = find_corners(&samples); + let fit = SidedFit::new(samples, corners.to_vec())?; + let [a, b, c] = fit.vertices[..] else { + return None; + }; - let sides = [0, 1, 2].map(|side| distance(vertices[side], vertices[(side + 1) % 3])); + let sides = fit.side_lengths(); let perimeter: f64 = sides.iter().sum(); let longest = sides.iter().copied().fold(0.0_f64, f64::max); - let doubled_area = cross(sub(vertices[1], vertices[0]), sub(vertices[2], vertices[0])).abs(); + let doubled_area = cross(sub(b, a), sub(c, a)).abs(); if doubled_area < MIN_HEIGHT_RATIO * longest * longest || !(perimeter * (0.85 - 0.03 * level)..=perimeter * (1.25 + 0.08 * level)) .contains(&length) + || !fit.corners_drawn(0.06 + 0.02 * level) { return None; } - // A drawn corner passes close to where its edges meet. Corners rounded - // off far inside, relative to the shorter edge beside them, describe an - // ellipse instead. - let corner_reach = 0.06 + 0.02 * level; - if (0..3).any(|corner| { - let shorter_edge = sides[corner].min(sides[(corner + 2) % 3]); - distance_to_outline(&samples, vertices[corner]) > shorter_edge * corner_reach - }) { - return None; - } - - let (error, worst) = edge_deviation(&samples, corners, vertices, bounds); + let (error, worst) = fit.deviation(bounds.width.min(bounds.height)); if error > 0.035 + 0.02 * level || worst > 0.13 + 0.035 * level { return None; } @@ -73,7 +49,7 @@ pub(super) fn fit_triangle( Some(ClosedFit { shape: Shape::Polygon { kind: PolygonKind::Triangle, - points: level_edges(vertices) + points: level_edges([a, b, c]) .map(|(x, y)| (x.round() as i32, y.round() as i32)) .to_vec(), fill: false, @@ -84,49 +60,8 @@ pub(super) fn fit_triangle( }) } -/// Evenly spaced samples around the stroke, closed back to its start. -fn resample_closed(points: &[(i32, i32)]) -> Option> { - let outline: Vec = points - .iter() - .map(|&(x, y)| (f64::from(x), f64::from(y))) - .collect(); - let segment = |index: usize| (outline[index], outline[(index + 1) % outline.len()]); - let total: f64 = (0..outline.len()) - .map(|index| { - let (start, end) = segment(index); - distance(start, end) - }) - .sum(); - if outline.len() < 3 || total <= 0.0 { - return None; - } - - let step = total / SAMPLES as f64; - let mut samples = Vec::with_capacity(SAMPLES); - let mut walked = 0.0; - for index in 0..outline.len() { - let (start, end) = segment(index); - let length = distance(start, end); - // Repeated pointer positions add nothing to walk along. - if length == 0.0 { - continue; - } - while samples.len() < SAMPLES && step * samples.len() as f64 <= walked + length { - let t = (step * samples.len() as f64 - walked) / length; - samples.push(( - start.0 + (end.0 - start.0) * t, - start.1 + (end.1 - start.1) * t, - )); - } - walked += length; - } - - (samples.len() == SAMPLES).then_some(samples) -} - -/// Sample indices of the largest triangle inscribed in the stroke, in -/// drawing order. -fn find_corners(samples: &[Point]) -> Option<[usize; 3]> { +/// Sample indices of the largest triangle inscribed in the stroke. +fn find_corners(samples: &[Point]) -> [usize; 3] { let count = samples.len() as f64; let centroid = samples.iter().fold((0.0, 0.0), |sum, &point| { (sum.0 + point.0 / count, sum.1 + point.1 / count) @@ -147,123 +82,7 @@ fn find_corners(samples: &[Point]) -> Option<[usize; 3]> { b = farthest_from_line(c, a); c = farthest_from_line(a, b); } - - let mut corners = [a, b, c]; - corners.sort_unstable(); - let gaps = [ - corners[1] - corners[0], - corners[2] - corners[1], - samples.len() - corners[2] + corners[0], - ]; - gaps.iter() - .all(|&gap| gap >= MIN_SIDE_SAMPLES) - .then_some(corners) -} - -fn farthest(samples: &[Point], measure: impl Fn(Point) -> f64) -> usize { - samples - .iter() - .map(|&point| measure(point)) - .enumerate() - .max_by(|a, b| a.1.total_cmp(&b.1)) - .map_or(0, |(index, _)| index) -} - -/// Corners where the fitted edges meet. Corner `k` joins the side ending -/// there with the side starting there. -fn fit_vertices(samples: &[Point], corners: [usize; 3]) -> Option<[Point; 3]> { - let [first, second, third] = [0, 1, 2].map(|side| { - let (start, end) = side_span(samples, corners, side); - let trim = ((end - start) as f64 * CORNER_TRIM) as usize; - fit_line((start + trim..=end - trim).map(|index| samples[index % samples.len()])) - }); - let edges = [first?, second?, third?]; - - Some([ - intersect(edges[2], edges[0])?, - intersect(edges[0], edges[1])?, - intersect(edges[1], edges[2])?, - ]) -} - -/// First and last sample index of one side; the last side wraps past the -/// stroke's start. -fn side_span(samples: &[Point], corners: [usize; 3], side: usize) -> (usize, usize) { - let start = corners[side]; - let end = if side == 2 { - corners[0] + samples.len() - } else { - corners[side + 1] - }; - (start, end) -} - -/// Total least-squares line through the points. -fn fit_line(points: impl Iterator + Clone) -> Option { - let count = points.clone().count(); - if count < 2 { - return None; - } - - let count = count as f64; - let origin = points.clone().fold((0.0, 0.0), |sum, point| { - (sum.0 + point.0 / count, sum.1 + point.1 / count) - }); - let (xx, yy, xy) = points.fold((0.0, 0.0, 0.0), |(xx, yy, xy), point| { - let dx = point.0 - origin.0; - let dy = point.1 - origin.1; - (xx + dx * dx, yy + dy * dy, xy + dx * dy) - }); - let angle = 0.5 * (2.0 * xy).atan2(xx - yy); - - Some(Line { - origin, - direction: (angle.cos(), angle.sin()), - }) -} - -fn intersect(first: Line, second: Line) -> Option { - let denominator = cross(first.direction, second.direction); - if denominator.abs() < MIN_CORNER_SINE { - return None; - } - - let t = cross(sub(second.origin, first.origin), second.direction) / denominator; - Some(( - first.origin.0 + first.direction.0 * t, - first.origin.1 + first.direction.1 * t, - )) -} - -/// Mean and worst distance from each sample to the fitted edge of its own -/// side, relative to the stroke's short extent. -fn edge_deviation( - samples: &[Point], - corners: [usize; 3], - vertices: [Point; 3], - bounds: Bounds, -) -> (f64, f64) { - let scale = bounds.width.min(bounds.height); - let mut total = 0.0; - let mut worst = 0.0_f64; - for side in 0..3 { - let (start, end) = side_span(samples, corners, side); - let (a, b) = (vertices[side], vertices[(side + 1) % 3]); - for index in start..end { - let deviation = distance_to_segment(samples[index % samples.len()], a, b) / scale; - total += deviation; - worst = worst.max(deviation); - } - } - (total / samples.len() as f64, worst) -} - -fn distance_to_outline(samples: &[Point], target: Point) -> f64 { - (0..samples.len()) - .map(|index| { - distance_to_segment(target, samples[index], samples[(index + 1) % samples.len()]) - }) - .fold(f64::INFINITY, f64::min) + [a, b, c] } /// Level the side nearest horizontal and the side nearest vertical when @@ -295,26 +114,3 @@ fn level_edges(mut vertices: [Point; 3]) -> [Point; 3] { } vertices } - -fn distance_to_segment(point: Point, a: Point, b: Point) -> f64 { - let edge = sub(b, a); - let length_squared = edge.0 * edge.0 + edge.1 * edge.1; - let t = if length_squared > 0.0 { - (((point.0 - a.0) * edge.0 + (point.1 - a.1) * edge.1) / length_squared).clamp(0.0, 1.0) - } else { - 0.0 - }; - distance(point, (a.0 + edge.0 * t, a.1 + edge.1 * t)) -} - -fn distance(a: Point, b: Point) -> f64 { - (a.0 - b.0).hypot(a.1 - b.1) -} - -fn sub(a: Point, b: Point) -> Point { - (a.0 - b.0, a.1 - b.1) -} - -fn cross(a: Point, b: Point) -> f64 { - a.0 * b.1 - a.1 * b.0 -} From 4a5059fb60b0e54aeb0f33faf8e4582c73da2952 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:09:45 +0200 Subject: [PATCH 10/11] perf: recognize each Shape Pen stroke once per pointer move The preview, its damage bounds, and the shape readout each ran recognition over the whole stroke on every pointer move. A per-stroke memo keyed by the stroke's length, ends, and settings now shares one run, and it is reset when the next stroke starts. Shape Pen also opted out of append-only damage, so every move repainted the whole stroke's box. While the preview stays plain ink it now damages only the new segment, like the pen; a recognized shape, or a switch between ink and shape, still repaints the full preview. --- src/input/state/core/dirty.rs | 27 +++++++++--- src/input/state/core/pointer.rs | 26 ++++++++++++ src/input/state/mouse/press.rs | 1 + src/input/state/render.rs | 1 + src/input/state/tests/shape_pen.rs | 52 +++++++++++++++++++++++ src/input/tool/drawing.rs | 4 +- src/input/tool/live_shape.rs | 68 ++++++++++++++++++++++++++++++ src/input/tool/mod.rs | 1 + 8 files changed, 174 insertions(+), 6 deletions(-) diff --git a/src/input/state/core/dirty.rs b/src/input/state/core/dirty.rs index 879c0a53..c2c1846d 100644 --- a/src/input/state/core/dirty.rs +++ b/src/input/state/core/dirty.rs @@ -42,7 +42,9 @@ impl InputState { current_x: i32, current_y: i32, ) { - if let Some((append_bounds, append_regions)) = self.compute_append_only_provisional_damage() + if self.provisional_grows_at_tail(current_x, current_y) + && let Some((append_bounds, append_regions)) = + self.compute_append_only_provisional_damage() { for region in append_regions { self.dirty_tracker.mark_rect(region); @@ -109,6 +111,25 @@ impl InputState { } } + /// Whether only the end of the preview can have changed since the last + /// damage. Shape Pen ink grows at its end like the pen, but a recognized + /// shape, or a switch between ink and shape, can change anywhere. + fn provisional_grows_at_tail(&mut self, current_x: i32, current_y: i32) -> bool { + let DrawingState::Drawing { + tool: crate::input::Tool::LiveShape, + .. + } = &self.state + else { + return true; + }; + let shape = matches!( + self.provisional_tool_stroke(current_x, current_y), + crate::input::tool::ProvisionalToolStroke::Recognized { .. } + ); + let previous_shape = self.pointer.replace_live_shape_previewed_shape(shape); + !shape && !previous_shape + } + fn compute_append_only_provisional_damage(&self) -> Option<(Rect, Vec)> { let DrawingState::Drawing { tool, @@ -119,10 +140,6 @@ impl InputState { else { return None; }; - if *tool == crate::input::Tool::LiveShape { - return None; - } - let stroke_width = match tool.motion_behavior() { ToolMotionBehavior::NoPathAccumulation => return None, ToolMotionBehavior::AccumulatePath { diff --git a/src/input/state/core/pointer.rs b/src/input/state/core/pointer.rs index 8a004899..e0af38de 100644 --- a/src/input/state/core/pointer.rs +++ b/src/input/state/core/pointer.rs @@ -1,5 +1,6 @@ //! Pointer positions, hover invalidation, activity timing, and provisional bounds. +use crate::input::tool::LiveShapeMemo; use crate::util::Rect; use std::time::Instant; @@ -11,6 +12,10 @@ pub(in crate::input::state) struct PointerTracking { menu_hover_recalc_pending: bool, last_draw_activity: Instant, provisional_bounds: Option, + live_shape: LiveShapeMemo, + /// Whether the last damaged Shape Pen preview showed a recognized shape + /// rather than plain ink. + live_shape_previewed_shape: bool, } impl Default for PointerTracking { @@ -22,6 +27,8 @@ impl Default for PointerTracking { menu_hover_recalc_pending: false, last_draw_activity: Instant::now(), provisional_bounds: None, + live_shape: LiveShapeMemo::default(), + live_shape_previewed_shape: false, } } } @@ -93,6 +100,25 @@ impl PointerTracking { std::mem::replace(&mut self.provisional_bounds, bounds) } + /// Forget the previous stroke's Shape Pen preview. + pub(in crate::input::state) fn begin_stroke_preview(&mut self) { + self.live_shape = LiveShapeMemo::default(); + self.live_shape_previewed_shape = false; + } + + pub(in crate::input::state) fn live_shape(&self) -> &LiveShapeMemo { + &self.live_shape + } + + /// Record whether the Shape Pen preview now shows a shape, returning + /// whether the previous one did. + pub(in crate::input::state) fn replace_live_shape_previewed_shape( + &mut self, + shape: bool, + ) -> bool { + std::mem::replace(&mut self.live_shape_previewed_shape, shape) + } + pub(in crate::input::state) fn union_provisional_bounds(&mut self, bounds: Rect) { self.provisional_bounds = match self.provisional_bounds { Some(current) => current.union(bounds), diff --git a/src/input/state/mouse/press.rs b/src/input/state/mouse/press.rs index 57c51568..8bb4f774 100644 --- a/src/input/state/mouse/press.rs +++ b/src/input/state/mouse/press.rs @@ -590,6 +590,7 @@ impl InputState { point_thicknesses: vec![drawing_thickness as f32], }; self.pointer.replace_provisional_bounds(None); + self.pointer.begin_stroke_preview(); self.update_provisional_dirty_with(measurer, x, y); self.needs_redraw = true; } diff --git a/src/input/state/render.rs b/src/input/state/render.rs index 46492bc2..63d35e54 100644 --- a/src/input/state/render.rs +++ b/src/input/state/render.rs @@ -128,6 +128,7 @@ impl InputState { None }, step_marker_label: (*tool == Tool::StepMarker).then(|| self.next_step_marker_label()), + live_shape_memo: self.pointer.live_shape(), }; tool.provisional_stroke(snapshot) } diff --git a/src/input/state/tests/shape_pen.rs b/src/input/state/tests/shape_pen.rs index aa740100..a621e0c0 100644 --- a/src/input/state/tests/shape_pen.rs +++ b/src/input/state/tests/shape_pen.rs @@ -42,6 +42,10 @@ fn release_at_end(state: &mut InputState, path: &[(i32, i32)]) { state.on_mouse_release(MouseButton::Left, last.0, last.1); } +fn overlaps(a: crate::util::Rect, b: crate::util::Rect) -> bool { + a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height +} + fn run_action(state: &mut InputState, action: Action) { let measurer = crate::draw::TextMeasurer::default(); let ui_engine = crate::ui_text::UiTextEngine::default(); @@ -279,3 +283,51 @@ fn turning_grid_snap_off_keeps_shapes_where_they_were_drawn() { ); } } + +#[test] +fn preview_damage_and_readout_share_one_recognition_per_point() { + let mut state = shape_pen_state(); + draw_path(&mut state, &RECTANGLE); + let runs = state.pointer.live_shape().runs(); + assert!( + (1..=RECTANGLE.len()).contains(&runs), + "at most one recognition per point, got {runs}" + ); + + let _ = state.provisional_tool_stroke(10, 10); + let _ = state.provisional_shape_readout(10, 10); + let _ = state.provisional_tool_stroke(10, 10); + + assert_eq!(state.pointer.live_shape().runs(), runs); +} + +#[test] +fn ink_previews_damage_only_their_new_tail() { + let near_start = crate::util::Rect::new(0, 0, 12, 12).expect("probe"); + let touches_start = + |dirty: &[crate::util::Rect]| dirty.iter().any(|rect| overlaps(*rect, near_start)); + + // A sawtooth is neither a line nor a closed shape, so it stays ink. + let mut state = shape_pen_state(); + let sawtooth: Vec<_> = (0..=30).map(|step| (step * 10, (step % 2) * 20)).collect(); + draw_path(&mut state, &sawtooth); + let _ = state.take_dirty_regions(); + state.on_mouse_motion(310, 20); + assert!( + !touches_start(&state.take_dirty_regions()), + "ink grows at its end" + ); + + // A recognized rectangle can move anywhere, so its whole preview repaints, + // including the corner farthest from the new segment. + let mut state = shape_pen_state(); + let far_corner = crate::util::Rect::new(106, 86, 8, 8).expect("probe"); + draw_path(&mut state, &RECTANGLE[..RECTANGLE.len() - 1]); + let _ = state.take_dirty_regions(); + state.on_mouse_motion(10, 10); + let dirty = state.take_dirty_regions(); + assert!( + dirty.iter().any(|rect| overlaps(*rect, far_corner)), + "a recognized preview repaints in full: {dirty:?}" + ); +} diff --git a/src/input/tool/drawing.rs b/src/input/tool/drawing.rs index 0a8bae1b..4f571261 100644 --- a/src/input/tool/drawing.rs +++ b/src/input/tool/drawing.rs @@ -121,6 +121,8 @@ pub(crate) struct ProvisionalToolSnapshot<'a> { pub(crate) arrow_style: ArrowStyle, pub(crate) arrow_label: Option, pub(crate) step_marker_label: Option, + /// Shape Pen's recognition of this stroke so far. + pub(crate) live_shape_memo: &'a super::LiveShapeMemo, } /// Borrowed inputs needed to render the current live polygon preview. @@ -330,7 +332,7 @@ impl Tool { match self.drawing_behavior() { ToolDrawingBehavior::None => ProvisionalToolStroke::None, ToolDrawingBehavior::LiveShape => { - if let Some(shape) = super::live_shape::recognize( + if let Some(shape) = snapshot.live_shape_memo.recognize( snapshot.points, snapshot.color, snapshot.size, diff --git a/src/input/tool/live_shape.rs b/src/input/tool/live_shape.rs index 4c6d7d99..f7dc90ee 100644 --- a/src/input/tool/live_shape.rs +++ b/src/input/tool/live_shape.rs @@ -1,5 +1,7 @@ //! Recognize lines and closed shapes from a pen path. Ambiguous ink stays ink. +use std::cell::RefCell; + use crate::domain::{BoardGrid, BoardGridKind}; use crate::draw::{Color, Shape}; @@ -10,6 +12,72 @@ mod rough_rectangle; mod tests; mod triangle; +/// Remembers the last recognition of the stroke being drawn, so the preview, +/// its damage, and the shape readout share one recognition per pointer move +/// instead of each running it over the whole stroke. +#[derive(Debug, Clone, Default)] +pub(crate) struct LiveShapeMemo { + last: RefCell)>>, + #[cfg(test)] + runs: std::cell::Cell, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +struct MemoKey { + len: usize, + first: (i32, i32), + last: (i32, i32), + color: Color, + thick: f64, + fill: bool, + grid: BoardGrid, + sensitivity: u8, +} + +impl LiveShapeMemo { + /// Recognize `points`, reusing the last answer while neither the stroke + /// nor the settings have changed. A stroke only grows at its end while + /// it is drawn, and the memo is reset when the next one starts, so the + /// point count and both ends identify it. + pub(crate) fn recognize( + &self, + points: &[(i32, i32)], + color: Color, + thick: f64, + fill: bool, + grid: BoardGrid, + sensitivity: u8, + ) -> Option { + let key = MemoKey { + len: points.len(), + first: *points.first()?, + last: *points.last()?, + color, + thick, + fill, + grid, + sensitivity, + }; + if let Some((cached, shape)) = &*self.last.borrow() + && *cached == key + { + return shape.clone(); + } + + #[cfg(test)] + self.runs.set(self.runs.get() + 1); + let shape = recognize(points, color, thick, fill, grid, sensitivity); + *self.last.borrow_mut() = Some((key, shape.clone())); + shape + } + + /// How many recognitions actually ran. + #[cfg(test)] + pub(crate) fn runs(&self) -> usize { + self.runs.get() + } +} + pub(super) fn recognize( points: &[(i32, i32)], color: Color, diff --git a/src/input/tool/mod.rs b/src/input/tool/mod.rs index 0c46139b..a541c090 100644 --- a/src/input/tool/mod.rs +++ b/src/input/tool/mod.rs @@ -28,5 +28,6 @@ pub(crate) use drawing::{ FinishedToolStroke, PROVISIONAL_POLYGON_DAMAGE_PADDING, PolygonProvisionalSnapshot, PolygonStrokeSnapshot, ProvisionalToolSnapshot, ProvisionalToolStroke, ToolStrokeSnapshot, }; +pub(crate) use live_shape::LiveShapeMemo; pub(crate) use profile::{ToolControlGroup, ToolProfile, ToolSettingsSlot, ToolSizeSource}; pub use settings::{PerToolDrawingSettings, ToolDrawingSettings}; From da230e605b759b13eba864ed2f38af38ffc8c7ab Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:13:48 +0200 Subject: [PATCH 11/11] fix: clear Shape Pen's shape preview when release keeps ink If the last preview showed a recognized shape and the release point tipped the stroke back into ink, release repainted only the ink's own path. Parts of the preview that sat off the ink, such as an edge snapped to the grid, stayed on screen until something else repainted them. Release now repaints the whole previous preview in that case. --- src/input/state/mouse/release/drawing.rs | 23 +++++++++----- src/input/state/tests/shape_pen.rs | 39 ++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 8 deletions(-) diff --git a/src/input/state/mouse/release/drawing.rs b/src/input/state/mouse/release/drawing.rs index 771bd8bd..ea7351ac 100644 --- a/src/input/state/mouse/release/drawing.rs +++ b/src/input/state/mouse/release/drawing.rs @@ -111,14 +111,21 @@ pub(super) fn finish_drawing( if crate::draw::spotlight_magnification_is_active(magnification) ); let path_damage = finished_path_damage_regions(&shape, bounds); - // `Shape::Freehand` only, deliberately. This covers the case where a - // pressure preview drew wide samples and the release then *downgraded* to a - // plain Freehand at the tool's own thickness, leaving the preview wider than - // anything the committed shape damages. A committed `FreehandPressure` keeps - // the sampled thicknesses it was drawn with, so its own damage is already as - // wide as the preview was and it needs no help here. - let preserve_provisional_cleanup = - matches!(shape, Shape::Freehand { .. }) && pressure_preview_exceeds_final_width; + // Two previews can leave pixels outside anything the committed stroke + // damages, so their whole bounds are repainted. + // + // A pressure preview drew wide samples and the release then *downgraded* to + // a plain Freehand at the tool's own thickness. `Shape::Freehand` only, + // deliberately: a committed `FreehandPressure` keeps the sampled + // thicknesses it was drawn with, so its own damage is already as wide as + // the preview was. + // + // A Shape Pen preview showed a shape, fitted or snapped to the grid, and + // the release point tipped the stroke back into ink. + let previewed_shape = state.pointer.replace_live_shape_previewed_shape(false); + let preserve_provisional_cleanup = (matches!(shape, Shape::Freehand { .. }) + && pressure_preview_exceeds_final_width) + || (previewed_shape && ink.is_none()); let mut limit_reached = false; let max_shapes = state.max_shapes_per_frame(); diff --git a/src/input/state/tests/shape_pen.rs b/src/input/state/tests/shape_pen.rs index a621e0c0..ef5f5af0 100644 --- a/src/input/state/tests/shape_pen.rs +++ b/src/input/state/tests/shape_pen.rs @@ -331,3 +331,42 @@ fn ink_previews_damage_only_their_new_tail() { "a recognized preview repaints in full: {dirty:?}" ); } + +#[test] +fn releasing_as_ink_repaints_where_the_shape_preview_was() { + use crate::domain::{BoardGrid, BoardGridKind}; + use crate::input::BOARD_ID_WHITEBOARD; + + let mut state = shape_pen_state(); + state.switch_board(BOARD_ID_WHITEBOARD); + state.boards.active_board_mut().spec.grid = BoardGrid::new(BoardGridKind::Cartesian, 40); + // Five pixels inside the grid lines, so the preview snaps outward to them. + let rectangle: Vec<_> = RECTANGLE + .iter() + .map(|&(x, y)| (45 + (x - 8) * 110 / 104, 45 + (y - 8) * 70 / 84)) + .collect(); + draw_path(&mut state, &rectangle); + assert!(matches!( + state.provisional_tool_stroke(44, 45), + ProvisionalToolStroke::Recognized { + shape: Shape::Rect { x: 40, .. }, + .. + } + )); + let _ = state.take_dirty_regions(); + + // Released far from the start with no final motion, the stroke is ink. + state.on_mouse_release(MouseButton::Left, 400, 400); + assert_eq!( + state.boards.active_frame().shapes[0].shape.kind_name(), + "Freehand" + ); + + // The preview's left edge sat on x = 40, outside the ink. + let left_edge = crate::util::Rect::new(37, 75, 3, 10).expect("probe"); + let dirty = state.take_dirty_regions(); + assert!( + dirty.iter().any(|rect| overlaps(*rect, left_edge)), + "the shape preview's pixels must be repainted: {dirty:?}" + ); +}