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::