diff --git a/README.md b/README.md index 6e39870c..c9d5bbfc 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ The v0.9.23+ prebuilt `wayscriber` packages require glibc 2.39 and GTK 4.12 — ### Drawing and editing - Freehand pen, highlighter, eraser (circle/rect) - Shapes: lines, rectangles, ellipses, polygons (with fill toggle) -- Arrows with optional auto-numbered labels; step markers for walkthroughs +- 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 - Blur tool with four styles: soften, pixelate, secure (flattens the region to one color), and black out - Spotlight tool: dims everything except the regions you draw, with optional 1×–4× magnification - Multiline text and sticky notes with smoothing @@ -1023,6 +1023,7 @@ pick_screen_color = ["I"] Notes: - Arrow labels can auto-number when enabled in the arrow toolbar; reset with Ctrl+Shift+R. +- Arrow style (standard, pointy, curved, double) is set from the arrow toolbar's style button, or by the **Cycle Arrow Style** command - which restyles selected arrows when there are any, and otherwise sets the style for the next arrow. It has no default shortcut; bind `cycle_arrow_style` in `config.toml` for direct keyboard access, and set `[arrow] style` there to pick which style new arrows start with. A selected curved arrow shows a round handle at the middle of its arc: drag it to reshape the curve, holding Shift to snap. - Step markers auto-increment and reset from the toolbar (or bind `reset_step_markers` in `config.toml`). - Preset slots can be saved/cleared from the toolbar; the slot changes right away and is written back to `config.toml` on a background worker, with the toast confirming it once the file has it. Edit names and advanced fields in the configurator's Presets tab. - The blur tool has no default keyboard shortcut; bind `select_blur_tool` in `config.toml` if you want direct keyboard access. diff --git a/config.example.toml b/config.example.toml index d920f921..df4ad522 100644 --- a/config.example.toml +++ b/config.example.toml @@ -124,6 +124,9 @@ toggle_eraser_mode = ["Ctrl+Shift+E"] select_spotlight_tool = [] # Step the blur tool through blur/pixelate/secure/black-out (unbound by default) cycle_blur_style = [] +# Step the arrow style through standard/pointy/curved/double (unbound by default). +# With arrows selected it restyles those instead of the next arrow. +cycle_arrow_style = [] select_line_tool = [] select_rect_tool = [] select_ellipse_tool = [] @@ -1290,6 +1293,13 @@ angle_degrees = 26.0 # Place the arrowhead at the end of the line instead of the start head_at_end = true +# Shape of the next arrow drawn: "standard", "pointy", "curved", or "double". +# Every arrow stores its own style, so this only seeds new ones — nothing +# already drawn is restyled, and the Cycle Arrow Style action changes it at +# runtime. "double" ignores head_at_end; "curved" starts with a gentle arc you +# can reshape by dragging the round handle at its midpoint. +style = "standard" + # ═══════════════════════════════════════════════════════════════════════════════ # PERFORMANCE SETTINGS # ═══════════════════════════════════════════════════════════════════════════════ diff --git a/configurator/src/app/pages/arrow.rs b/configurator/src/app/pages/arrow.rs index 90de2c74..4a2eb17c 100644 --- a/configurator/src/app/pages/arrow.rs +++ b/configurator/src/app/pages/arrow.rs @@ -9,7 +9,7 @@ use relm4::prelude::*; use crate::messages::Message; use crate::models::util::format_float; -use crate::models::{TabId, TextField, ToggleField}; +use crate::models::{ArrowStyleOption, TabId, TextField, ToggleField}; use super::super::search::SearchArea; use super::super::state::ConfiguratorApp; @@ -41,6 +41,18 @@ pub(super) fn build(sender: &ComponentSender) -> BuiltPage { "Off draws the head at the start of the line instead.", |app| app.draft.arrow_head_at_end, |value| Message::ToggleChanged(ToggleField::ArrowHeadAtEnd, value), + ) + .combo_row( + "Arrow style", + "Shape of the next arrow drawn. Every arrow keeps its own style, so \ + changing this never restyles existing drawings.", + ArrowStyleOption::list(), + ArrowStyleOption::list() + .iter() + .map(|option| option.label().to_string()) + .collect(), + |app| app.draft.arrow_style, + Message::ArrowStyleChanged, ); page.finish() diff --git a/configurator/src/app/search/terms.rs b/configurator/src/app/search/terms.rs index 55398f31..d98ed61d 100644 --- a/configurator/src/app/search/terms.rs +++ b/configurator/src/app/search/terms.rs @@ -131,8 +131,14 @@ pub(super) const ARROW_TERMS: &[&str] = &[ "arrow length px", "arrow angle deg", "place arrowhead at end of line", + "arrow style", "length", "angle", + "style", + "standard", + "pointy", + "curved", + "double", ]; pub(super) const HISTORY_MAIN_TERMS: &[&str] = &[ "history", diff --git a/configurator/src/app/search/tests.rs b/configurator/src/app/search/tests.rs index 5ebfa598..e86ba7b6 100644 --- a/configurator/src/app/search/tests.rs +++ b/configurator/src/app/search/tests.rs @@ -222,6 +222,11 @@ fn exact_static_section_labels_match_their_sections() { TabId::Arrow, SearchArea::Arrow, ), + ("arrow style", TabId::Arrow, SearchArea::Arrow), + // Searching for the style you want, rather than for the control that + // sets it, is the likelier way in — nobody knows the row is called + // "Arrow style" until they have already found it. + ("curved", TabId::Arrow, SearchArea::Arrow), ]; for (query, expected_tab, expected_area) in cases { diff --git a/configurator/src/app/update/fields/drawing.rs b/configurator/src/app/update/fields/drawing.rs index 606ed409..1761a9a0 100644 --- a/configurator/src/app/update/fields/drawing.rs +++ b/configurator/src/app/update/fields/drawing.rs @@ -1,6 +1,6 @@ use crate::models::{ - ColorMode, ColorPickerId, DragColorOption, DragMouseButton, DragToolField, DragToolOption, - EraserModeOption, NamedColorOption, + ArrowStyleOption, ColorMode, ColorPickerId, DragColorOption, DragMouseButton, DragToolField, + DragToolOption, EraserModeOption, NamedColorOption, }; use super::super::super::effects::Effect; @@ -180,6 +180,20 @@ impl ConfiguratorApp { Vec::new() } + /// `[arrow] style`: which shape the next arrow is drawn in. + /// + /// Seeds new arrows only. Nothing already drawn is restyled, because every + /// arrow stores its own style. + pub(in crate::app::update) fn handle_arrow_style_changed( + &mut self, + option: ArrowStyleOption, + ) -> Vec { + self.status = StatusMessage::idle(); + self.draft.arrow_style = option; + self.refresh_dirty_flag(); + Vec::new() + } + pub(in crate::app::update) fn handle_drawing_mouse_drag_tool_changed( &mut self, button: DragMouseButton, diff --git a/configurator/src/app/update/mod.rs b/configurator/src/app/update/mod.rs index b1c45ec3..a48ac91f 100644 --- a/configurator/src/app/update/mod.rs +++ b/configurator/src/app/update/mod.rs @@ -141,6 +141,7 @@ impl ConfiguratorApp { self.handle_quick_named_color_selected(index, option) } Message::EraserModeChanged(option) => self.handle_eraser_mode_changed(option), + Message::ArrowStyleChanged(option) => self.handle_arrow_style_changed(option), Message::DrawingDragMappingSectionToggled(button) => { self.handle_drawing_drag_mapping_section_toggled(button) } diff --git a/configurator/src/messages.rs b/configurator/src/messages.rs index 52e62d86..7ac2eeb7 100644 --- a/configurator/src/messages.rs +++ b/configurator/src/messages.rs @@ -3,11 +3,11 @@ use std::path::PathBuf; use wayscriber::config::{ConfigDocument, Shortcut, ToolbarItemId, ToolbarItemOrderGroup}; use crate::models::{ - BoardBackgroundOption, BoardItemTextField, BoardItemToggleField, ColorMode, ColorPickerId, - DaemonAction, DaemonActionResult, DaemonRuntimeStatus, DragColorOption, DragMouseButton, - DragToolField, DragToolOption, EraserModeOption, FontStyleOption, FontWeightOption, - InputHudModeOption, InputHudPositionOption, KeybindingField, KeybindingsTabId, - KeyboardModifiers, NamedColorOption, OverrideOption, PdfFitModeOption, + ArrowStyleOption, BoardBackgroundOption, BoardItemTextField, BoardItemToggleField, ColorMode, + ColorPickerId, DaemonAction, DaemonActionResult, DaemonRuntimeStatus, DragColorOption, + DragMouseButton, DragToolField, DragToolOption, EraserModeOption, FontStyleOption, + FontWeightOption, InputHudModeOption, InputHudPositionOption, KeybindingField, + KeybindingsTabId, KeyboardModifiers, NamedColorOption, OverrideOption, PdfFitModeOption, PdfLabelContentModeOption, PdfLabelPositionOption, PdfOrientationOption, PdfPageSizeOption, PdfTransparentBackgroundOption, PresenterToolBehaviorOption, PresenterToolbarModeOption, PresetEraserKindOption, PresetEraserModeOption, PresetTextField, PresetToggleField, @@ -112,6 +112,7 @@ pub enum Message { QuickColorModeChanged(usize, ColorMode), QuickNamedColorSelected(usize, NamedColorOption), EraserModeChanged(EraserModeOption), + ArrowStyleChanged(ArrowStyleOption), DrawingDragMappingSectionToggled(DragMouseButton), DrawingMouseDragToolChanged(DragMouseButton, DragToolField, DragToolOption), DrawingMouseDragColorChanged(DragMouseButton, DragToolField, DragColorOption), diff --git a/configurator/src/models/config/draft/from_config.rs b/configurator/src/models/config/draft/from_config.rs index 0f7944ad..3e89ae37 100644 --- a/configurator/src/models/config/draft/from_config.rs +++ b/configurator/src/models/config/draft/from_config.rs @@ -1,7 +1,7 @@ use super::super::super::color::{ColorInput, ColorQuadInput}; use super::super::super::fields::ZoomChipDisplayOption; use super::super::super::fields::{ - EraserModeOption, FontStyleOption, FontWeightOption, InputHudModeOption, + ArrowStyleOption, EraserModeOption, FontStyleOption, FontWeightOption, InputHudModeOption, InputHudPositionOption, PdfFitModeOption, PdfLabelContentModeOption, PdfLabelPositionOption, PdfOrientationOption, PdfPageSizeOption, PdfTransparentBackgroundOption, PresenterToolBehaviorOption, PresenterToolbarModeOption, ReducedMotionOption, @@ -100,6 +100,7 @@ impl ConfigDraft { arrow_length: format_float(config.arrow.length), arrow_angle: format_float(config.arrow.angle_degrees), arrow_head_at_end: config.arrow.head_at_end, + arrow_style: ArrowStyleOption::from_style(config.arrow.style), history_undo_all_delay_ms: config.history.undo_all_delay_ms.to_string(), history_redo_all_delay_ms: config.history.redo_all_delay_ms.to_string(), diff --git a/configurator/src/models/config/draft/mod.rs b/configurator/src/models/config/draft/mod.rs index 3023c3d1..b421cb14 100644 --- a/configurator/src/models/config/draft/mod.rs +++ b/configurator/src/models/config/draft/mod.rs @@ -3,7 +3,7 @@ mod from_config; use super::super::color::{ColorInput, ColorQuadInput}; use super::super::fields::ZoomChipDisplayOption; use super::super::fields::{ - EraserModeOption, FontStyleOption, FontWeightOption, InputHudModeOption, + ArrowStyleOption, EraserModeOption, FontStyleOption, FontWeightOption, InputHudModeOption, InputHudPositionOption, PdfFitModeOption, PdfLabelContentModeOption, PdfLabelPositionOption, PdfOrientationOption, PdfPageSizeOption, PdfTransparentBackgroundOption, PresenterToolBehaviorOption, PresenterToolbarModeOption, ReducedMotionOption, @@ -60,6 +60,7 @@ pub struct ConfigDraft { pub arrow_length: String, pub arrow_angle: String, pub arrow_head_at_end: bool, + pub arrow_style: ArrowStyleOption, pub history_undo_all_delay_ms: String, pub history_redo_all_delay_ms: String, diff --git a/configurator/src/models/config/tests.rs b/configurator/src/models/config/tests.rs index 16b8176a..672b8311 100644 --- a/configurator/src/models/config/tests.rs +++ b/configurator/src/models/config/tests.rs @@ -1,11 +1,11 @@ use super::super::color::ColorInput; use super::super::fields::{ - DragMouseButton, DragToolField, DragToolOption, FontWeightOption, InputHudModeOption, - InputHudPositionOption, OverrideOption, PdfFitModeOption, PdfLabelContentModeOption, - PdfOrientationOption, PdfPageSizeOption, PdfTransparentBackgroundOption, QuadField, - ReducedMotionOption, RegionPickerOption, SessionStorageModeOption, TextField, ToggleField, - ToolOption, ToolbarLayoutModeOption, ToolbarOverrideField, ToolbarRebindModifierOption, - UiThemeOption, + ArrowStyleOption, DragMouseButton, DragToolField, DragToolOption, FontWeightOption, + InputHudModeOption, InputHudPositionOption, OverrideOption, PdfFitModeOption, + PdfLabelContentModeOption, PdfOrientationOption, PdfPageSizeOption, + PdfTransparentBackgroundOption, QuadField, ReducedMotionOption, RegionPickerOption, + SessionStorageModeOption, TextField, ToggleField, ToolOption, ToolbarLayoutModeOption, + ToolbarOverrideField, ToolbarRebindModifierOption, UiThemeOption, }; #[test] @@ -28,6 +28,40 @@ fn config_draft_round_trips_toolbar_rebind_modifier() { ); } +#[test] +fn config_draft_round_trips_arrow_style() { + // Without this the configurator is a one-way door for the key: a config + // that sets a style loads as Standard in the UI, and saving anything from + // any tab writes that Standard back over the user's choice. + let mut config = Config::default(); + config.arrow.style = wayscriber::draw::ArrowStyle::Curved; + let mut draft = ConfigDraft::from_config(&config); + assert_eq!(draft.arrow_style, ArrowStyleOption::Curved); + + draft.arrow_style = ArrowStyleOption::Double; + let round_trip = draft.to_config(&config).expect("arrow style round trip"); + assert_eq!(round_trip.arrow.style, wayscriber::draw::ArrowStyle::Double); +} + +#[test] +fn every_arrow_style_survives_the_configurator() { + // The combo row lists these by index, so a variant missing from either + // conversion arm silently maps to the wrong style rather than failing. + for option in ArrowStyleOption::list() { + let config = Config::default(); + let mut draft = ConfigDraft::from_config(&config); + draft.arrow_style = option; + let round_trip = draft.to_config(&config).expect("arrow style round trip"); + assert_eq!(round_trip.arrow.style, option.to_style()); + assert_eq!( + ArrowStyleOption::from_style(round_trip.arrow.style), + option, + "{} did not survive the round trip", + option.label() + ); + } +} + #[test] fn config_draft_round_trips_status_bar_interactive() { let config = Config::default(); diff --git a/configurator/src/models/config/to_config/drawing.rs b/configurator/src/models/config/to_config/drawing.rs index 610e4b30..1147a443 100644 --- a/configurator/src/models/config/to_config/drawing.rs +++ b/configurator/src/models/config/to_config/drawing.rs @@ -126,6 +126,7 @@ impl ConfigDraft { |value| config.arrow.angle_degrees = value, ); config.arrow.head_at_end = self.arrow_head_at_end; + config.arrow.style = self.arrow_style.to_style(); } } diff --git a/configurator/src/models/fields/arrow.rs b/configurator/src/models/fields/arrow.rs new file mode 100644 index 00000000..0a651593 --- /dev/null +++ b/configurator/src/models/fields/arrow.rs @@ -0,0 +1,54 @@ +use wayscriber::draw::ArrowStyle; + +/// Startup shape of the arrow tool, as a combo-row choice. +/// +/// Wraps `ArrowStyle` rather than binding the core enum directly so the +/// configurator owns its own ordering and its own labels — the combo row lists +/// these in declaration order, which is not something the drawing code should +/// have to preserve. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ArrowStyleOption { + Standard, + Pointy, + Curved, + Double, +} + +impl ArrowStyleOption { + pub fn list() -> Vec { + vec![Self::Standard, Self::Pointy, Self::Curved, Self::Double] + } + + pub fn label(&self) -> &'static str { + match self { + Self::Standard => "Standard", + Self::Pointy => "Pointy", + Self::Curved => "Curved", + Self::Double => "Double", + } + } + + pub fn to_style(self) -> ArrowStyle { + match self { + Self::Standard => ArrowStyle::Standard, + Self::Pointy => ArrowStyle::Pointy, + Self::Curved => ArrowStyle::Curved, + Self::Double => ArrowStyle::Double, + } + } + + pub fn from_style(style: ArrowStyle) -> Self { + match style { + ArrowStyle::Standard => Self::Standard, + ArrowStyle::Pointy => Self::Pointy, + ArrowStyle::Curved => Self::Curved, + ArrowStyle::Double => Self::Double, + } + } +} + +impl std::fmt::Display for ArrowStyleOption { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.label()) + } +} diff --git a/configurator/src/models/fields/mod.rs b/configurator/src/models/fields/mod.rs index a7fcf9a3..a78925e7 100644 --- a/configurator/src/models/fields/mod.rs +++ b/configurator/src/models/fields/mod.rs @@ -1,3 +1,4 @@ +mod arrow; mod capture; mod eraser; mod export; @@ -13,6 +14,7 @@ mod toggles; mod tool; mod toolbar; +pub use arrow::ArrowStyleOption; pub use capture::RegionPickerOption; pub use eraser::{EraserModeOption, PresetEraserKindOption, PresetEraserModeOption}; pub use export::{ diff --git a/configurator/src/models/fields/tests.rs b/configurator/src/models/fields/tests.rs index da6af070..5dfe85c2 100644 --- a/configurator/src/models/fields/tests.rs +++ b/configurator/src/models/fields/tests.rs @@ -1,5 +1,22 @@ use super::*; +#[test] +fn arrow_style_options_cover_the_core_enum() { + // `ArrowStyle::ALL` is what the runtime cycle steps through. If the two + // lists ever disagree, a style is reachable from the overlay but not from + // the configurator — or worse, the combo row offers one that no longer + // exists and picks the wrong index for everything after it. + let core = wayscriber::draw::ArrowStyle::ALL; + let options = ArrowStyleOption::list(); + assert_eq!(options.len(), core.len()); + for (option, style) in options.iter().zip(core.iter()) { + assert_eq!(option.to_style(), *style); + assert_eq!(ArrowStyleOption::from_style(*style), *option); + // The combo row and the overlay's style pill name the same thing. + assert_eq!(option.label(), style.label()); + } +} + #[test] fn region_picker_options_cover_the_core_enum() { assert_eq!( diff --git a/configurator/src/models/keybindings/field/config/read.rs b/configurator/src/models/keybindings/field/config/read.rs index 75c80394..773d97d9 100644 --- a/configurator/src/models/keybindings/field/config/read.rs +++ b/configurator/src/models/keybindings/field/config/read.rs @@ -57,6 +57,7 @@ impl KeybindingField { Self::SelectBlurTool => &config.tools.select_blur_tool, Self::SelectSpotlightTool => &config.tools.select_spotlight_tool, Self::CycleBlurStyle => &config.tools.cycle_blur_style, + Self::CycleArrowStyle => &config.tools.cycle_arrow_style, Self::SelectHighlightTool => &config.tools.select_highlight_tool, Self::IncreaseFontSize => &config.tools.increase_font_size, Self::DecreaseFontSize => &config.tools.decrease_font_size, diff --git a/configurator/src/models/keybindings/field/config/write.rs b/configurator/src/models/keybindings/field/config/write.rs index 8d316c24..53d778a0 100644 --- a/configurator/src/models/keybindings/field/config/write.rs +++ b/configurator/src/models/keybindings/field/config/write.rs @@ -58,6 +58,7 @@ impl KeybindingField { Self::SelectBlurTool => config.tools.select_blur_tool = value, Self::SelectSpotlightTool => config.tools.select_spotlight_tool = value, Self::CycleBlurStyle => config.tools.cycle_blur_style = value, + Self::CycleArrowStyle => config.tools.cycle_arrow_style = value, Self::SelectHighlightTool => config.tools.select_highlight_tool = value, Self::IncreaseFontSize => config.tools.increase_font_size = value, Self::DecreaseFontSize => config.tools.decrease_font_size = value, diff --git a/configurator/src/models/keybindings/field/labels.rs b/configurator/src/models/keybindings/field/labels.rs index 5ebe9c27..d0ffd814 100644 --- a/configurator/src/models/keybindings/field/labels.rs +++ b/configurator/src/models/keybindings/field/labels.rs @@ -67,6 +67,7 @@ impl KeybindingField { Self::SelectBlurTool => "select_blur_tool", Self::SelectSpotlightTool => "select_spotlight_tool", Self::CycleBlurStyle => "cycle_blur_style", + Self::CycleArrowStyle => "cycle_arrow_style", Self::SelectHighlightTool => "select_highlight_tool", Self::IncreaseFontSize => "increase_font_size", Self::DecreaseFontSize => "decrease_font_size", diff --git a/configurator/src/models/keybindings/field/list.rs b/configurator/src/models/keybindings/field/list.rs index 877bc6ea..f56d9d9a 100644 --- a/configurator/src/models/keybindings/field/list.rs +++ b/configurator/src/models/keybindings/field/list.rs @@ -52,6 +52,7 @@ impl KeybindingField { Self::SelectBlurTool, Self::SelectSpotlightTool, Self::CycleBlurStyle, + Self::CycleArrowStyle, Self::SelectHighlightTool, Self::IncreaseFontSize, Self::DecreaseFontSize, diff --git a/configurator/src/models/keybindings/field/mod.rs b/configurator/src/models/keybindings/field/mod.rs index b0dbcbdd..bdf5df41 100644 --- a/configurator/src/models/keybindings/field/mod.rs +++ b/configurator/src/models/keybindings/field/mod.rs @@ -54,6 +54,7 @@ pub enum KeybindingField { SelectBlurTool, SelectSpotlightTool, CycleBlurStyle, + CycleArrowStyle, SelectHighlightTool, IncreaseFontSize, DecreaseFontSize, diff --git a/configurator/src/models/keybindings/field/tab.rs b/configurator/src/models/keybindings/field/tab.rs index faba941c..7e1d2103 100644 --- a/configurator/src/models/keybindings/field/tab.rs +++ b/configurator/src/models/keybindings/field/tab.rs @@ -42,6 +42,7 @@ impl KeybindingField { | Self::SelectBlurTool | Self::SelectSpotlightTool | Self::CycleBlurStyle + | Self::CycleArrowStyle | Self::SelectHighlightTool | Self::ToggleHighlightTool | Self::ResetArrowLabels diff --git a/configurator/src/models/mod.rs b/configurator/src/models/mod.rs index bed9060c..30e07a93 100644 --- a/configurator/src/models/mod.rs +++ b/configurator/src/models/mod.rs @@ -23,15 +23,15 @@ pub use daemon::{ LightShortcutApplyCapability, ShortcutApplyCapability, ShortcutBackend, }; pub use fields::{ - DragColorOption, DragMouseButton, DragToolField, DragToolOption, EraserModeOption, - FontStyleOption, FontWeightOption, InputHudModeOption, InputHudPositionOption, OverrideOption, - PdfFitModeOption, PdfLabelContentModeOption, PdfLabelPositionOption, PdfOrientationOption, - PdfPageSizeOption, PdfTransparentBackgroundOption, PresenterToolBehaviorOption, - PresenterToolbarModeOption, PresetEraserKindOption, PresetEraserModeOption, PresetTextField, - PresetToggleField, QuadField, ReducedMotionOption, RegionPickerOption, - SessionCompressionOption, SessionStorageModeOption, StatusPositionOption, TextField, - ToggleField, ToolOption, ToolbarLayoutModeOption, ToolbarOverrideField, - ToolbarRebindModifierOption, UiThemeOption, ZoomChipDisplayOption, + ArrowStyleOption, DragColorOption, DragMouseButton, DragToolField, DragToolOption, + EraserModeOption, FontStyleOption, FontWeightOption, InputHudModeOption, + InputHudPositionOption, OverrideOption, PdfFitModeOption, PdfLabelContentModeOption, + PdfLabelPositionOption, PdfOrientationOption, PdfPageSizeOption, + PdfTransparentBackgroundOption, PresenterToolBehaviorOption, PresenterToolbarModeOption, + PresetEraserKindOption, PresetEraserModeOption, PresetTextField, PresetToggleField, QuadField, + ReducedMotionOption, RegionPickerOption, SessionCompressionOption, SessionStorageModeOption, + StatusPositionOption, TextField, ToggleField, ToolOption, ToolbarLayoutModeOption, + ToolbarOverrideField, ToolbarRebindModifierOption, UiThemeOption, ZoomChipDisplayOption, }; #[cfg(feature = "tablet-input")] pub use fields::{PressureThicknessEditModeOption, PressureThicknessEntryModeOption}; diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 3003a8d3..45c1e1aa 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -368,6 +368,7 @@ drag_tool = "default" - **Eraser size**: Use +/- keys or scroll wheel when eraser tool is active (range: 1-50px) - **Eraser mode**: Use Ctrl+Shift+E to toggle brush vs stroke erasing - **Blur style**: Run **Cycle Blur Style** from the command palette to step through blur → pixelate → secure → black out (unbound by default; bind `cycle_blur_style`) +- **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 + / - **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) @@ -402,12 +403,25 @@ angle_degrees = 26.0 # Place the arrowhead at the end of the line instead of the start head_at_end = true + +# Shape of the next arrow drawn: "standard", "pointy", "curved", or "double" +style = "standard" ``` **Defaults:** - Length: 20.0px - Angle: 26.0° - Head at end: true +- Style: standard + +**Arrow styles.** Every arrow stores its own style, so it keeps that style through save/load, undo, duplicate, and resize. `style` seeds new arrows only — changing it never restyles anything already drawn. Set the startup style here or on the configurator's Arrow tab; pick one at runtime from the arrow tool's style pill or the **Cycle Arrow Style** action; the choice persists with the rest of the tool state, which takes precedence over this key (see [`[session]`](#session---session-persistence)). + +| Style | What it draws | +|---|---| +| Standard | Tapered shaft fused into one head. What arrows looked like before styles existed, and what a session written before them loads as. | +| Pointy | The same head with its rear notched forward into a concave V, for a dart silhouette. | +| Curved | Shaft follows an arc instead of a straight line, so an arrow can route around whatever sits between the pointer and its target. Drag the round handle at the arc's midpoint to reshape it; hold Shift to snap the bend to tenths. | +| Double | Parallel-sided shaft with a head at both ends. `head_at_end` has no effect on it. | ### `[presets]` - Quick Tool Slots @@ -1932,6 +1946,7 @@ select_step_marker_tool = [] select_eraser_tool = ["D"] toggle_eraser_mode = ["Ctrl+Shift+E"] cycle_blur_style = [] # blur -> pixelate -> secure -> black out +cycle_arrow_style = [] # standard -> pointy -> curved -> double select_spotlight_tool = [] # dim everything except a region select_line_tool = [] select_rect_tool = [] diff --git a/src/backend/wayland/backend/state_init/input_state.rs b/src/backend/wayland/backend/state_init/input_state.rs index 3820bf38..7a6cbca0 100644 --- a/src/backend/wayland/backend/state_init/input_state.rs +++ b/src/backend/wayland/backend/state_init/input_state.rs @@ -55,6 +55,7 @@ pub(super) fn build_input_state(config: &Config) -> InputState { input_state.set_undo_stack_limit(config.drawing.undo_stack_limit); input_state.polygon_sides = clamp_regular_sides(config.drawing.polygon_sides); input_state.blur_style = config.drawing.default_blur_style; + input_state.arrow_style = config.arrow.style; input_state.spotlight_dim_opacity = config.spotlight.dim_opacity; input_state.spotlight_feather = config.spotlight.feather; input_state.spotlight_magnification = config.spotlight.magnification; diff --git a/src/backend/wayland/handlers/pointer/cursor.rs b/src/backend/wayland/handlers/pointer/cursor.rs index 92d5ab34..74638cda 100644 --- a/src/backend/wayland/handlers/pointer/cursor.rs +++ b/src/backend/wayland/handlers/pointer/cursor.rs @@ -6,7 +6,7 @@ use super::*; use crate::backend::wayland::toolbar::ToolbarCursorHint; use crate::input::{ BoardPickerCursorHint, ColorPickerCursorHint, CommandPaletteCursorHint, ContextMenuCursorHint, - DrawingState, HelpOverlayCursorHint, SelectionHandle, + DrawingState, HelpOverlayCursorHint, IdleHandle, SelectionHandle, }; /// What the pointer is over on the screen-modal surfaces (the eyedropper and @@ -298,6 +298,11 @@ impl WaylandState { DrawingState::AdjustingSpotlightMagnification { .. } => { return CursorIcon::EwResize; } + // Dragging a curved arrow's bend handle - free travel, the + // perpendicular component of which is what the arc follows + DrawingState::BendingArrow { .. } => { + return CursorIcon::Grabbing; + } // Idle - check for hover contexts DrawingState::Idle => {} } @@ -317,29 +322,19 @@ impl WaylandState { return CursorIcon::Default; } - // Check if hovering over selection handles + // Hovering an on-canvas handle. Resolved through the same routing a + // press uses, so the cursor cannot promise one operation where a click + // would start another — these handles overlap, and a bend grip on a + // shallow arc lands within a few pixels of the selection box's edge + // handle. Checked on hover as well as during the drag, or a grip would + // look inert until it was already grabbed. let (canvas_x, canvas_y) = self.input_state.canvas_pointer_position(); - if let Some(handle) = self.input_state.hit_selection_handle(canvas_x, canvas_y) { - return resize_cursor(handle); - } - - // Check if hovering over text resize handle - if self - .input_state - .hit_text_resize_handle(canvas_x, canvas_y) - .is_some() - { - return CursorIcon::SeResize; - } - - // Hovering the loupe's magnification track. Checked here as well as - // during the drag, or the control would look inert until it is grabbed. - if self - .input_state - .hit_spotlight_magnification_track(canvas_x, canvas_y) - .is_some() - { - return CursorIcon::EwResize; + match self.input_state.hit_idle_handle(canvas_x, canvas_y) { + Some(IdleHandle::SpotlightMagnification(_)) => return CursorIcon::EwResize, + Some(IdleHandle::ArrowBend(_)) => return CursorIcon::Grab, + Some(IdleHandle::TextResize(_)) => return CursorIcon::SeResize, + Some(IdleHandle::SelectionResize(handle)) => return resize_cursor(handle), + None => {} } // Check if hovering over a selected shape (for move) diff --git a/src/backend/wayland/session/tests.rs b/src/backend/wayland/session/tests.rs index 8c117b96..dd601d7b 100644 --- a/src/backend/wayland/session/tests.rs +++ b/src/backend/wayland/session/tests.rs @@ -229,6 +229,7 @@ fn sample_tool_state() -> stored_session::ToolStateSnapshot { arrow_length: 20.0, arrow_angle: 30.0, arrow_head_at_end: Some(false), + arrow_style: None, arrow_label_enabled: Some(false), polygon_sides: REGULAR_POLYGON_DEFAULT_SIDES, board_previous_color: None, diff --git a/src/backend/wayland/state/render/canvas/overlays.rs b/src/backend/wayland/state/render/canvas/overlays.rs index 2e08edcd..425b4a25 100644 --- a/src/backend/wayland/state/render/canvas/overlays.rs +++ b/src/backend/wayland/state/render/canvas/overlays.rs @@ -28,6 +28,7 @@ impl WaylandState { } self.render_spotlight_magnification_control(ctx); + self.render_arrow_bend_handle(ctx); if matches!( self.input_state.state, @@ -50,6 +51,24 @@ impl WaylandState { } } + /// Draws the bend handle on a selected curved arrow's arc midpoint. + /// + /// Shown while idle and while it is being dragged, matching the text resize + /// handle: hiding it mid-drag would take the grab target out from under the + /// pointer that is holding it. + fn render_arrow_bend_handle(&mut self, ctx: &cairo::Context) { + if !matches!( + self.input_state.state, + DrawingState::Idle | DrawingState::BendingArrow { .. } + ) { + return; + } + let Some(handle) = self.input_state.selected_arrow_bend_handle() else { + return; + }; + crate::ui::render_arrow_bend_handle(ctx, handle.rect); + } + /// Draws the magnification slider above a selected loupe. /// /// Shown while idle and while the knob is being dragged, matching the text diff --git a/src/backend/wayland/state/toolbar/events.rs b/src/backend/wayland/state/toolbar/events.rs index 041b75f6..21ce66c2 100644 --- a/src/backend/wayland/state/toolbar/events.rs +++ b/src/backend/wayland/state/toolbar/events.rs @@ -24,7 +24,7 @@ fn toolbar_event_blocked_by_modal(input_state: &InputState) -> bool { input_state.command_palette_is_engaged() } -fn finalize_spotlight_wheel_gesture_before_toolbar_dispatch( +fn finalize_pointer_gestures_before_toolbar_dispatch( input_state: &mut InputState, spotlight_wheel_idle_deadline: &mut Option, ) { @@ -35,6 +35,37 @@ fn finalize_spotlight_wheel_gesture_before_toolbar_dispatch( // already-finished gesture leaves an idle wake behind. input_state.flush_spotlight_magnification_gesture(); *spotlight_wheel_idle_deadline = None; + // A held bend handle closes here too, and for the sharper version of the + // same reason: a session open or clear replaces the frame the gesture's + // snapshot belongs to, and shape ids restart per frame, so a bend flushed + // afterwards would attach to an unrelated shape on the new page. + input_state.finish_active_arrow_bend(); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ToolbarEventPreflight { + Continue, + RebindCaptured, +} + +fn handle_toolbar_event_preflight( + input_state: &mut InputState, + spotlight_wheel_idle_deadline: &mut Option, + event: &ToolbarEvent, + rebind_requested: bool, +) -> ToolbarEventPreflight { + // Rebind capture returns before every later backend and InputState route, + // so gesture finalization belongs ahead of that branch. This also covers + // GTK and secondary-device clicks, which enter with the rebind decision + // already resolved. + finalize_pointer_gestures_before_toolbar_dispatch(input_state, spotlight_wheel_idle_deadline); + + if rebind_requested && let Some(action) = crate::ui::toolbar::model::action_for_event(event) { + input_state.begin_keybinding_capture(action); + return ToolbarEventPreflight::RebindCaptured; + } + + ToolbarEventPreflight::Continue } /// Whether `event` dismisses `popover`. @@ -152,10 +183,13 @@ impl WaylandState { // shortcut capture so the capture modal owns subsequent keys. self.cancel_eyedropper(); self.cancel_region_for_toolbar_interaction(); - if rebind_requested - && let Some(action) = crate::ui::toolbar::model::action_for_event(&event) + if handle_toolbar_event_preflight( + &mut self.input_state, + &mut self.spotlight_wheel_idle_deadline, + &event, + rebind_requested, + ) == ToolbarEventPreflight::RebindCaptured { - self.input_state.begin_keybinding_capture(action); self.toolbar.mark_dirty(); self.input_state.needs_redraw = true; return; @@ -202,10 +236,6 @@ impl WaylandState { self.toolbar.mark_dirty(); self.input_state.needs_redraw = true; } - finalize_spotlight_wheel_gesture_before_toolbar_dispatch( - &mut self.input_state, - &mut self.spotlight_wheel_idle_deadline, - ); if self.handle_toolbar_session_event(&event, conn, qh) { return; } diff --git a/src/backend/wayland/state/toolbar/events/tests.rs b/src/backend/wayland/state/toolbar/events/tests.rs index ddbd6449..85d2789f 100644 --- a/src/backend/wayland/state/toolbar/events/tests.rs +++ b/src/backend/wayland/state/toolbar/events/tests.rs @@ -185,7 +185,15 @@ fn backend_session_dispatch_finalizes_spotlight_history_and_its_deadline() { ); let mut deadline = Some(std::time::Instant::now() + std::time::Duration::from_secs(1)); - finalize_spotlight_wheel_gesture_before_toolbar_dispatch(&mut input_state, &mut deadline); + assert_eq!( + handle_toolbar_event_preflight( + &mut input_state, + &mut deadline, + &ToolbarEvent::ClearSession, + false, + ), + ToolbarEventPreflight::Continue + ); assert!(deadline.is_none()); input_state.handle_action(Action::Undo); @@ -456,7 +464,11 @@ fn the_toolbar_rebind_gesture_opens_capture_for_the_controls_action() { .unwrap_or_else(|| panic!("{event:?} should name an action")); assert_eq!(action, expected, "{event:?} names the wrong action"); - assert!(input_state.begin_keybinding_capture(action)); + let mut deadline = None; + assert_eq!( + handle_toolbar_event_preflight(&mut input_state, &mut deadline, &event, true), + ToolbarEventPreflight::RebindCaptured + ); assert_eq!( input_state.keybinding_capture_action, Some(expected), @@ -470,6 +482,72 @@ fn the_toolbar_rebind_gesture_opens_capture_for_the_controls_action() { } } +#[test] +fn toolbar_rebind_capture_finalizes_a_held_arrow_bend_before_its_early_return() { + let mut input_state = make_test_input_state(); + let shape_id = input_state + .boards + .active_frame_mut() + .add_shape(crate::draw::Shape::Arrow { + x1: 0, + y1: 100, + x2: 400, + y2: 100, + color: input_state.current_color, + thick: 4.0, + arrow_length: 20.0, + arrow_angle: 30.0, + head_at_end: true, + style: crate::draw::ArrowStyle::Curved, + bend: 0.0, + label: None, + }); + input_state.set_selection(vec![shape_id]); + input_state.state = crate::input::state::DrawingState::BendingArrow { + shape_id, + snapshot: crate::draw::frame::ShapeSnapshot { + shape: input_state + .boards + .active_frame() + .shape(shape_id) + .expect("arrow") + .shape + .clone(), + locked: false, + }, + }; + assert!(input_state.drag_arrow_bend_to(200, 20, false)); + let mut deadline = None; + let event = ToolbarEvent::Undo; + + assert_eq!( + handle_toolbar_event_preflight(&mut input_state, &mut deadline, &event, true), + ToolbarEventPreflight::RebindCaptured + ); + assert_eq!(input_state.keybinding_capture_action, Some(Action::Undo)); + assert!( + matches!(input_state.state, crate::input::state::DrawingState::Idle), + "rebind capture returned while the bend gesture was still active" + ); + + // Escape belongs to the newly-opened capture modal, and the later pointer + // release must not revive or recommit the already-finalized bend. + input_state.on_key_press(crate::input::Key::Escape); + assert!(input_state.keybinding_capture_action.is_none()); + input_state.on_mouse_release(crate::input::MouseButton::Left, 200, 20); + input_state.handle_action(Action::Undo); + match input_state + .boards + .active_frame() + .shape(shape_id) + .expect("arrow") + .shape + { + crate::draw::Shape::Arrow { bend, .. } => assert_eq!(bend, 0.0), + ref other => panic!("expected an arrow, got {other:?}"), + } +} + /// The configurator route the palette's Ctrl+Shift+E uses: the same actions /// resolve to the configurator section that holds their shortcut. #[test] @@ -946,3 +1024,72 @@ fn section_toggle_event(flag: ToolbarSectionFlag, show: bool) -> ToolbarEvent { ToolbarSectionFlag::TextControls => ToolbarEvent::ToggleTextControls(show), } } + +#[test] +fn backend_session_dispatch_finalizes_a_held_arrow_bend() { + // Session routes return before `apply_toolbar_event`, and an open or clear + // replaces the frame the gesture's snapshot belongs to. Shape ids restart + // per frame, so a bend flushed after that would attach to an unrelated + // shape on the new page. + let mut input_state = make_test_input_state(); + let shape_id = input_state + .boards + .active_frame_mut() + .add_shape(crate::draw::Shape::Arrow { + x1: 0, + y1: 100, + x2: 400, + y2: 100, + color: input_state.current_color, + thick: 4.0, + arrow_length: 20.0, + arrow_angle: 30.0, + head_at_end: true, + style: crate::draw::ArrowStyle::Curved, + bend: 0.0, + label: None, + }); + input_state.set_selection(vec![shape_id]); + input_state.state = crate::input::state::DrawingState::BendingArrow { + shape_id, + snapshot: crate::draw::frame::ShapeSnapshot { + shape: input_state + .boards + .active_frame() + .shape(shape_id) + .expect("arrow") + .shape + .clone(), + locked: false, + }, + }; + assert!(input_state.drag_arrow_bend_to(200, 20, false)); + let mut deadline = None; + + assert_eq!( + handle_toolbar_event_preflight( + &mut input_state, + &mut deadline, + &ToolbarEvent::ClearSession, + false, + ), + ToolbarEventPreflight::Continue + ); + + assert!( + matches!(input_state.state, crate::input::state::DrawingState::Idle), + "the backend barrier left the bend gesture running" + ); + // Committed rather than discarded, so the undo stack can take it back. + input_state.handle_action(Action::Undo); + match input_state + .boards + .active_frame() + .shape(shape_id) + .expect("arrow") + .shape + { + crate::draw::Shape::Arrow { bend, .. } => assert_eq!(bend, 0.0), + ref other => panic!("expected an arrow, got {other:?}"), + } +} diff --git a/src/backend/wayland/toolbar/view/top/build.rs b/src/backend/wayland/toolbar/view/top/build.rs index aaa74535..99f439e2 100644 --- a/src/backend/wayland/toolbar/view/top/build.rs +++ b/src/backend/wayland/toolbar/view/top/build.rs @@ -796,7 +796,8 @@ fn push_style_pill( )); x += ToolbarLayoutSpec::TOP_STYLE_RESET_W + gap; } - model::StylePillControl::SelectionCycle(_) => { + model::StylePillControl::SelectionCycle(_) + | model::StylePillControl::ArrowStyleCycle => { let enabled = control.enabled(snapshot); nodes.push(WidgetNode::new( id, diff --git a/src/config/action_meta/entries/tools.rs b/src/config/action_meta/entries/tools.rs index 63fe44c6..72a5a943 100644 --- a/src/config/action_meta/entries/tools.rs +++ b/src/config/action_meta/entries/tools.rs @@ -234,4 +234,14 @@ pub const ENTRIES: &[ActionMeta] = &[ true, true ), + meta!( + CycleArrowStyle, + "Cycle Arrow Style", + None, + "Standard, pointy, curved, double", + Tools, + true, + true, + true + ), ]; diff --git a/src/config/action_meta/tests.rs b/src/config/action_meta/tests.rs index afc721dc..f41b534a 100644 --- a/src/config/action_meta/tests.rs +++ b/src/config/action_meta/tests.rs @@ -178,6 +178,7 @@ const EXPECTED_COMMAND_PALETTE_ACTIONS: &[Action] = &[ Action::ToggleEraserMode, Action::SelectSpotlightTool, Action::CycleBlurStyle, + Action::CycleArrowStyle, Action::IncreaseThickness, Action::DecreaseThickness, Action::IncreaseMarkerOpacity, diff --git a/src/config/keybindings/config/map/edit.rs b/src/config/keybindings/config/map/edit.rs index ee94148c..94c9b8bb 100644 --- a/src/config/keybindings/config/map/edit.rs +++ b/src/config/keybindings/config/map/edit.rs @@ -99,6 +99,7 @@ define_action_binding_accessors! { SelectEraserTool => tools.select_eraser_tool, ToggleEraserMode => tools.toggle_eraser_mode, CycleBlurStyle => tools.cycle_blur_style, + CycleArrowStyle => tools.cycle_arrow_style, SelectPenTool => tools.select_pen_tool, SelectLineTool => tools.select_line_tool, SelectRectTool => tools.select_rect_tool, diff --git a/src/config/keybindings/config/map/tools.rs b/src/config/keybindings/config/map/tools.rs index 3038cb08..f51e0478 100644 --- a/src/config/keybindings/config/map/tools.rs +++ b/src/config/keybindings/config/map/tools.rs @@ -29,6 +29,7 @@ impl KeybindingsConfig { inserter.insert_all(&self.tools.select_eraser_tool, Action::SelectEraserTool)?; inserter.insert_all(&self.tools.toggle_eraser_mode, Action::ToggleEraserMode)?; inserter.insert_all(&self.tools.cycle_blur_style, Action::CycleBlurStyle)?; + inserter.insert_all(&self.tools.cycle_arrow_style, Action::CycleArrowStyle)?; inserter.insert_all(&self.tools.select_pen_tool, Action::SelectPenTool)?; inserter.insert_all(&self.tools.select_line_tool, Action::SelectLineTool)?; inserter.insert_all(&self.tools.select_rect_tool, Action::SelectRectTool)?; diff --git a/src/config/keybindings/config/types/bindings/tools.rs b/src/config/keybindings/config/types/bindings/tools.rs index dcfd91a3..a7495b4c 100644 --- a/src/config/keybindings/config/types/bindings/tools.rs +++ b/src/config/keybindings/config/types/bindings/tools.rs @@ -35,6 +35,9 @@ pub struct ToolKeybindingsConfig { #[serde(default = "default_cycle_blur_style")] pub cycle_blur_style: Vec, + #[serde(default = "default_cycle_arrow_style")] + pub cycle_arrow_style: Vec, + #[serde(default = "default_select_pen_tool")] pub select_pen_tool: Vec, @@ -103,6 +106,7 @@ impl Default for ToolKeybindingsConfig { select_eraser_tool: default_select_eraser_tool(), toggle_eraser_mode: default_toggle_eraser_mode(), cycle_blur_style: default_cycle_blur_style(), + cycle_arrow_style: default_cycle_arrow_style(), select_pen_tool: default_select_pen_tool(), select_line_tool: default_select_line_tool(), select_rect_tool: default_select_rect_tool(), diff --git a/src/config/keybindings/defaults/tools.rs b/src/config/keybindings/defaults/tools.rs index caec0133..fd48372a 100644 --- a/src/config/keybindings/defaults/tools.rs +++ b/src/config/keybindings/defaults/tools.rs @@ -40,6 +40,12 @@ pub(crate) fn default_cycle_blur_style() -> Vec { Vec::new() } +/// Unbound by default, like the blur-style cycle it mirrors; reachable from the +/// command palette until the user binds a chord. +pub(crate) fn default_cycle_arrow_style() -> Vec { + Vec::new() +} + pub(crate) fn default_select_pen_tool() -> Vec { vec!["F".to_string()] } diff --git a/src/config/keybindings/tests.rs b/src/config/keybindings/tests.rs index 7fa5da0b..06d63a1b 100644 --- a/src/config/keybindings/tests.rs +++ b/src/config/keybindings/tests.rs @@ -742,6 +742,7 @@ const DEFAULT_BINDING_SNAPSHOT: &[(&str, &[&str])] = &[ ("select_eraser_tool", &["D"]), ("toggle_eraser_mode", &["Ctrl+Shift+E"]), ("cycle_blur_style", &[]), + ("cycle_arrow_style", &[]), ("select_pen_tool", &["F"]), ("select_line_tool", &[]), ("select_rect_tool", &[]), diff --git a/src/config/tests/load.rs b/src/config/tests/load.rs index 57f813d1..fe931ceb 100644 --- a/src/config/tests/load.rs +++ b/src/config/tests/load.rs @@ -1,6 +1,7 @@ use super::super::*; use super::save_through_document; use crate::config::test_helpers::with_temp_config_home; +use crate::draw::ArrowStyle; use std::fs; #[test] @@ -38,6 +39,32 @@ fn load_parses_xdg_focus_loss_behavior_stay() { }); } +#[test] +fn arrow_style_defaults_to_standard_and_parses_every_variant() { + // The style is a per-shape property, so this key only seeds the *next* + // arrow. Defaulting to anything but Standard would restyle nobody's + // existing drawings but would still surprise every existing user. + let defaults: Config = toml::from_str("").expect("empty config should use defaults"); + assert_eq!(defaults.arrow.style, ArrowStyle::Standard); + + for (key, expected) in [ + ("standard", ArrowStyle::Standard), + ("pointy", ArrowStyle::Pointy), + ("curved", ArrowStyle::Curved), + ("double", ArrowStyle::Double), + ] { + let config: Config = toml::from_str(&format!("[arrow]\nstyle = '{key}'\n")) + .unwrap_or_else(|err| panic!("arrow style {key} should parse: {err}")); + assert_eq!(config.arrow.style, expected, "parsed {key} wrong"); + } + + // A config written by a newer build must not take the whole file down. + assert!( + toml::from_str::("[arrow]\nstyle = 'squiggly'\n").is_err(), + "an unknown style should be a parse error, not a silent default" + ); +} + #[test] fn region_capture_defaults_and_explicit_values_round_trip() { let defaults: Config = toml::from_str("").expect("empty config should use defaults"); diff --git a/src/config/types/arrow.rs b/src/config/types/arrow.rs index 008ed9ab..ec0e1cb4 100644 --- a/src/config/types/arrow.rs +++ b/src/config/types/arrow.rs @@ -1,5 +1,7 @@ use serde::{Deserialize, Serialize}; +use crate::draw::ArrowStyle; + /// Arrow drawing settings. /// /// Controls the appearance of arrowheads when using the arrow tool. @@ -18,6 +20,13 @@ pub struct ArrowConfig { /// Place the arrowhead at the end of the line instead of the start #[serde(default = "default_arrow_head_at_end")] pub head_at_end: bool, + + /// Shape of the arrow drawn by the arrow tool: `"standard"`, `"pointy"`, + /// `"curved"`, or `"double"`. This is the startup default only — the style + /// is a per-shape property, so cycling it at runtime restyles the selection + /// or the next arrow without rewriting anything already drawn. + #[serde(default)] + pub style: ArrowStyle, } impl Default for ArrowConfig { @@ -26,6 +35,7 @@ impl Default for ArrowConfig { length: default_arrow_length(), angle_degrees: default_arrow_angle(), head_at_end: default_arrow_head_at_end(), + style: ArrowStyle::default(), } } } diff --git a/src/configurator_destination.rs b/src/configurator_destination.rs index ce2bbc66..3507550a 100644 --- a/src/configurator_destination.rs +++ b/src/configurator_destination.rs @@ -124,6 +124,7 @@ pub fn keybindings_section_for_action(action: Action) -> Option bool { + (y - radius..=y + radius).any(|py| { + (x - radius..=x + radius).any(|px| { + px >= 0 + && py >= 0 + && px < surface.width() + && py < surface.height() + && alpha_at(surface, px, py) > 0 + }) + }) + } + fn painted_column_height(surface: &mut ImageSurface, x: i32, height: i32) -> i32 { (0..height) .filter(|&y| alpha_at(surface, x, y) > 0) @@ -277,7 +301,20 @@ mod tests { // stroke (30px), so the head base sits at x = 390 and the bevelled // shoulder at x = 393. The samples below land on the tail, on the bare // shaft behind the head, and across the head itself. - render_arrow(&ctx, 20, 60, 420, 60, red, 10.0, 20.0, 24.0, true); + render_arrow( + &ctx, + 20, + 60, + 420, + 60, + red, + 10.0, + 20.0, + 24.0, + true, + ArrowStyle::Standard, + 0.0, + ); drop(ctx); let near_tail = painted_column_height(&mut surface, 25, 120); @@ -305,7 +342,20 @@ mod tests { a: 1.0, }; - render_arrow(&ctx, 20, 60, 180, 60, red, 20.0, 30.0, 30.0, true); + render_arrow( + &ctx, + 20, + 60, + 180, + 60, + red, + 20.0, + 30.0, + 30.0, + true, + ArrowStyle::Standard, + 0.0, + ); drop(ctx); // Every column between tail and tip must carry paint on the centre line. @@ -317,6 +367,105 @@ mod tests { } } + #[test] + fn curved_arrow_paints_off_the_chord_and_leaves_it_bare() { + let (mut surface, ctx) = surface_with_context(440, 240); + let red = Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }; + + // Tail at (20, 200), tip at (420, 200): a 400px chord bent by 0.5, + // which puts the arc's midpoint 100px above it at y = 100. + render_arrow( + &ctx, + 20, + 200, + 420, + 200, + red, + 8.0, + 20.0, + 30.0, + true, + ArrowStyle::Curved, + 0.5, + ); + + drop(ctx); + assert!( + alpha_at(&mut surface, 220, 100) > 0, + "the arc should paint at its midpoint" + ); + assert_eq!( + alpha_at(&mut surface, 220, 200), + 0, + "the chord the arrow routes around should stay bare" + ); + // Both ends still land where the arrow was drawn from and to. The + // shaft leaves the tail at 45 degrees here, so scan a small box rather + // than guess which pixel a 1px-wide tail rounds onto. + assert!( + painted_near(&mut surface, 20, 200, 6), + "the tail should still paint" + ); + assert!( + painted_near(&mut surface, 420, 200, 6), + "the head should still reach the tip" + ); + } + + #[test] + fn double_arrow_paints_a_head_at_both_ends() { + let (mut surface, ctx) = surface_with_context(240, 140); + let red = Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }; + + // 200px shaft at 8px thick: the head is 24px long with a half-base of + // 24 * tan(30 deg) ~ 13.9, so 8px in from each end the silhouette is + // far wider than the 8px shaft between them. + render_arrow( + &ctx, + 20, + 70, + 220, + 70, + red, + 8.0, + 20.0, + 30.0, + true, + ArrowStyle::Double, + 0.0, + ); + + drop(ctx); + let at_tail = painted_column_height(&mut surface, 28, 140); + let mid_shaft = painted_column_height(&mut surface, 120, 140); + let at_head = painted_column_height(&mut surface, 212, 140); + + assert!( + at_tail > mid_shaft, + "no head at the tail: {at_tail} vs shaft {mid_shaft}" + ); + assert!( + at_head > mid_shaft, + "no head at the tip: {at_head} vs shaft {mid_shaft}" + ); + // Both heads are the same triangle mirrored, so their silhouettes at + // equal distances from each end match to within rasterization noise. + assert!( + at_tail.abs_diff(at_head) <= 2, + "the two heads should match: {at_tail} vs {at_head}" + ); + } + #[test] fn arrow_does_not_connect_to_existing_current_path() { let (mut surface, ctx) = surface_with_context(220, 140); @@ -328,7 +477,20 @@ mod tests { }; ctx.move_to(10.0, 130.0); - render_arrow(&ctx, 120, 40, 200, 40, red, 8.0, 20.0, 30.0, true); + render_arrow( + &ctx, + 120, + 40, + 200, + 40, + red, + 8.0, + 20.0, + 30.0, + true, + ArrowStyle::Standard, + 0.0, + ); drop(ctx); assert_eq!( diff --git a/src/draw/render/selection.rs b/src/draw/render/selection.rs index 8748e77b..06ba1aac 100644 --- a/src/draw/render/selection.rs +++ b/src/draw/render/selection.rs @@ -111,6 +111,8 @@ pub fn render_selection_halo(ctx: &cairo::Context, drawn: &DrawnShape) { arrow_length, arrow_angle, head_at_end, + style, + bend, .. } => { render_arrow( @@ -124,6 +126,8 @@ pub fn render_selection_halo(ctx: &cairo::Context, drawn: &DrawnShape) { *arrow_length, *arrow_angle, *head_at_end, + *style, + *bend, ); } Shape::BlurRect { .. } => { diff --git a/src/draw/render/shapes.rs b/src/draw/render/shapes.rs index 7be2a31e..ab1afd1f 100644 --- a/src/draw/render/shapes.rs +++ b/src/draw/render/shapes.rs @@ -8,7 +8,7 @@ use super::text::{render_sticky_note, render_text}; use crate::draw::Color; use crate::draw::shape::Shape; use crate::draw::shape::{ - ARROW_LABEL_BACKGROUND, arrow_label_layout, measure_text_with_context, + ARROW_LABEL_BACKGROUND, arrow_label_ends, arrow_label_layout, measure_text_with_context, step_marker_outline_thickness, step_marker_radius, }; @@ -85,13 +85,15 @@ pub fn render_shape(ctx: &cairo::Context, shape: &Shape) { arrow_length, arrow_angle, head_at_end, + style, + bend, label, } => { - let (tip_x, tip_y, tail_x, tail_y) = if *head_at_end { - (*x2, *y2, *x1, *y1) - } else { - (*x1, *y1, *x2, *y2) - }; + // Only the label needs these: `render_arrow` reads `head_at_end` + // itself. `Double` deliberately ignores the flag here, matching the + // outline it draws either way. + let (tip_x, tip_y, tail_x, tail_y) = + arrow_label_ends(*x1, *y1, *x2, *y2, *head_at_end, *style); render_arrow( ctx, *x1, @@ -103,6 +105,8 @@ pub fn render_shape(ctx: &cairo::Context, shape: &Shape) { *arrow_length, *arrow_angle, *head_at_end, + *style, + *bend, ); if let Some(label) = label { let label_text = label.value.to_string(); @@ -112,6 +116,7 @@ pub fn render_shape(ctx: &cairo::Context, shape: &Shape) { tail_x, tail_y, *thick, + style.effective_bend(*bend), &label_text, label.size, &label.font_descriptor, diff --git a/src/draw/shape/arrow_label.rs b/src/draw/shape/arrow_label.rs index 8b90bf90..6eef463e 100644 --- a/src/draw/shape/arrow_label.rs +++ b/src/draw/shape/arrow_label.rs @@ -1,10 +1,39 @@ -use crate::draw::FontDescriptor; +use crate::draw::{ArrowStyle, FontDescriptor}; use crate::util::Rect; use super::text::{text_bounds_from_metrics, text_layout_metrics}; pub(crate) const ARROW_LABEL_BACKGROUND: bool = true; +/// The tip/tail pair an arrow's label is placed against. +/// +/// For every style but [`ArrowStyle::Double`] this is just the arrow's own +/// tip and tail, which is what puts the number on a consistent side of the +/// shaft as the arrow is redrawn. +/// +/// `Double` has a head on both ends, so `head_at_end` describes nothing about +/// it — the documented contract is that the setting has no effect on a double +/// arrow, and the outline it produces is the same polygon either way. Letting +/// the flag choose the label's side anyway would mean toggling Arrow Head +/// mirrors the number across a shaft that did not move, taking the label's +/// bounds and its hit area with it. Pinning `Double` to the `head_at_end` +/// reading keeps the contract and leaves every already-drawn double arrow +/// (the flag defaults to true) exactly where it is. +pub(crate) fn arrow_label_ends( + x1: i32, + y1: i32, + x2: i32, + y2: i32, + head_at_end: bool, + style: ArrowStyle, +) -> (i32, i32, i32, i32) { + if head_at_end || style == ArrowStyle::Double { + (x2, y2, x1, y1) + } else { + (x1, y1, x2, y2) + } +} + const LABEL_OFFSET_SCALE: f64 = 0.6; const LABEL_OFFSET_MIN: f64 = 6.0; const LABEL_THICKNESS_SCALE: f64 = 0.4; @@ -16,6 +45,13 @@ pub(crate) struct ArrowLabelLayout { pub(crate) bounds: Rect, } +/// Places an arrow's auto-number label beside the middle of its shaft. +/// +/// `bend` is the bend the shaft actually draws — callers gate the stored value +/// through [`ArrowStyle::effective_bend`] first. A bent shaft leaves the chord, +/// so a label anchored to the chord's midpoint would float in the gap the arrow +/// was drawn to route around; the anchor follows the arc instead, and sits on +/// the outside of the curve where there is room for it. #[allow(clippy::too_many_arguments)] pub(crate) fn arrow_label_layout( tip_x: i32, @@ -23,6 +59,7 @@ pub(crate) fn arrow_label_layout( tail_x: i32, tail_y: i32, thick: f64, + bend: f64, label_text: &str, label_size: f64, font_descriptor: &FontDescriptor, @@ -40,15 +77,37 @@ pub(crate) fn arrow_label_layout( let ux = dx / len; let uy = dy / len; - let nx = -uy; - let ny = ux; let along = len * LABEL_ALONG_RATIO; let offset = (label_size * LABEL_OFFSET_SCALE).max(LABEL_OFFSET_MIN) + thick * LABEL_THICKNESS_SCALE; - let anchor_x = tail_x as f64 + ux * along + nx * offset; - let anchor_y = tail_y as f64 + uy * along + ny * offset; + let bend = crate::util::clamp_arrow_bend(bend); + // Left normal of the tail-to-tip direction, matching the convention the + // arc's control point is offset along. + let (left_x, left_y) = (uy, -ux); + let (base_x, base_y, nx, ny) = if bend == 0.0 { + // Straight shaft: the historical placement, one side of the chord. + ( + tail_x as f64 + ux * along, + tail_y as f64 + uy * along, + -uy, + ux, + ) + } else { + // The arc's own midpoint sits half the control point's offset out. + let bulge = bend * len / 2.0; + let side = bend.signum(); + ( + tail_x as f64 + ux * along + left_x * bulge, + tail_y as f64 + uy * along + left_y * bulge, + left_x * side, + left_y * side, + ) + }; + + let anchor_x = base_x + nx * offset; + let anchor_y = base_y + ny * offset; let metrics = text_layout_metrics(label_text, label_size, font_descriptor, None)?; let center_offset_x = metrics.ink_x + metrics.ink_width / 2.0; diff --git a/src/draw/shape/bounds.rs b/src/draw/shape/bounds.rs index 17233885..8f9a76bc 100644 --- a/src/draw/shape/bounds.rs +++ b/src/draw/shape/bounds.rs @@ -1,7 +1,7 @@ use crate::util::{self, Rect}; -use super::arrow_label::arrow_label_layout; -use super::types::ArrowLabel; +use super::arrow_label::{arrow_label_ends, arrow_label_layout}; +use super::types::{ArrowLabel, ArrowStyle}; const MIN_COORDINATE: i64 = i32::MIN as i64; const MAX_COORDINATE_EXCLUSIVE: i64 = i32::MAX as i64 + 1; @@ -115,6 +115,12 @@ pub(crate) fn bounding_box_for_ellipse( ) } +/// Dirty-region bounds for one arrow. +/// +/// The endpoints alone are not enough once a style bends: a curved arrow's arc +/// bulges outside the chord's box, and an under-sized box leaves repaint trails +/// behind the shaft. The arc is unioned from the same sampler the renderer +/// walks, so the box cannot be tighter than what was drawn. #[allow(clippy::too_many_arguments)] pub(crate) fn bounding_box_for_arrow( x1: i32, @@ -125,6 +131,8 @@ pub(crate) fn bounding_box_for_arrow( arrow_length: f64, arrow_angle: f64, head_at_end: bool, + style: ArrowStyle, + bend: f64, label: Option<&ArrowLabel>, ) -> Option { let (tip_x, tip_y, tail_x, tail_y) = if head_at_end { @@ -132,13 +140,18 @@ pub(crate) fn bounding_box_for_arrow( } else { (x1, y1, x2, y2) }; + // The label does not always share the head's reading of the endpoints: + // `Double` has a head at each end, so `head_at_end` must not decide which + // side of the shaft the number sits on. + let (label_tip_x, label_tip_y, label_tail_x, label_tail_y) = + arrow_label_ends(x1, y1, x2, y2, head_at_end, style); let mut min_x = tip_x.min(tail_x) as f64; let mut max_x = tip_x.max(tail_x) as f64; let mut min_y = tip_y.min(tail_y) as f64; let mut max_y = tip_y.max(tail_y) as f64; - if let Some(geometry) = util::calculate_arrowhead_triangle_custom( + if let Some(skeleton) = util::calculate_arrow_skeleton( tip_x, tip_y, tail_x, @@ -146,11 +159,21 @@ pub(crate) fn bounding_box_for_arrow( thick, arrow_length, arrow_angle, + style, + bend, ) { - min_x = min_x.min(geometry.left.0).min(geometry.right.0); - max_x = max_x.max(geometry.left.0).max(geometry.right.0); - min_y = min_y.min(geometry.left.1).min(geometry.right.1); - max_y = max_y.max(geometry.left.1).max(geometry.right.1); + let heads = [Some(skeleton.head), skeleton.tail_head]; + for corner in heads + .iter() + .flatten() + .flat_map(|head| [head.tip, head.left, head.right]) + .chain(skeleton.spine.points().iter().copied()) + { + min_x = min_x.min(corner.0); + max_x = max_x.max(corner.0); + min_y = min_y.min(corner.1); + max_y = max_y.max(corner.1); + } } let padding = stroke_padding(thick) as f64; @@ -158,11 +181,12 @@ pub(crate) fn bounding_box_for_arrow( if let Some(label) = label { let label_text = label.value.to_string(); if let Some(layout) = arrow_label_layout( - tip_x, - tip_y, - tail_x, - tail_y, + label_tip_x, + label_tip_y, + label_tail_x, + label_tail_y, thick, + style.effective_bend(bend), &label_text, label.size, &label.font_descriptor, diff --git a/src/draw/shape/mod.rs b/src/draw/shape/mod.rs index 217b248a..c75feb6b 100644 --- a/src/draw/shape/mod.rs +++ b/src/draw/shape/mod.rs @@ -13,10 +13,11 @@ pub use polygon::{ REGULAR_POLYGON_MIN_SIDES, clamp_regular_sides, }; pub use types::{ - ArrowLabel, BlurStyle, EmbeddedImage, EraserBrush, EraserKind, Shape, StepMarkerLabel, + ArrowLabel, ArrowStyle, BlurStyle, EmbeddedImage, EraserBrush, EraserKind, Shape, + StepMarkerLabel, }; -pub(crate) use arrow_label::{ARROW_LABEL_BACKGROUND, arrow_label_layout}; +pub(crate) use arrow_label::{ARROW_LABEL_BACKGROUND, arrow_label_ends, arrow_label_layout}; pub(crate) use bounds::{bounding_box_for_blur, bounding_box_for_eraser, bounding_box_for_points}; pub(crate) use polygon::{PolygonTemplate, generated_points, has_minimum_distinct_points}; pub(crate) use step_marker::{step_marker_outline_thickness, step_marker_radius}; diff --git a/src/draw/shape/tests.rs b/src/draw/shape/tests.rs index 758c6163..272cdeec 100644 --- a/src/draw/shape/tests.rs +++ b/src/draw/shape/tests.rs @@ -1,6 +1,9 @@ use super::types::Shape; use super::{EmbeddedImage, EraserBrush}; -use crate::draw::{EraserKind, FontDescriptor, PolygonKind, StepMarkerLabel, color::WHITE}; +use crate::draw::{ + ArrowLabel, ArrowStyle, EraserKind, FontDescriptor, PolygonKind, StepMarkerLabel, + color::{BLACK, WHITE}, +}; use crate::util; #[test] @@ -48,6 +51,8 @@ fn arrow_bounding_box_includes_head() { arrow_length: 20.0, arrow_angle: 30.0, head_at_end: false, + style: ArrowStyle::Standard, + bend: 0.0, label: None, }; @@ -60,18 +65,155 @@ fn arrow_bounding_box_includes_head() { assert!(x_min <= 50 && x_max >= 100); assert!(y_min <= 100 && y_max >= 120); - let geometry = util::calculate_arrowhead_triangle_custom(100, 100, 50, 120, 3.0, 20.0, 30.0) - .expect("arrow geometry should exist"); - for (px, py) in [geometry.left, geometry.right] { + let skeleton = util::calculate_arrow_skeleton( + 100, + 100, + 50, + 120, + 3.0, + 20.0, + 30.0, + ArrowStyle::Standard, + 0.0, + ) + .expect("arrow geometry should exist"); + for (px, py) in [skeleton.head.left, skeleton.head.right] { assert!(px >= x_min as f64 && px <= x_max as f64); assert!(py >= y_min as f64 && py <= y_max as f64); } } +#[test] +fn arrow_without_a_style_field_loads_as_standard_and_straight() { + // The back-compat guarantee: every session written before styles existed + // has arrows with no `style` and no `bend`. Drop `#[serde(default)]` from + // either field and this stops deserializing at all. + let shape: Shape = serde_json::from_str( + r#"{"Arrow":{"x1":0,"y1":0,"x2":100,"y2":0,"color":{"r":1.0,"g":1.0,"b":1.0,"a":1.0}, + "thick":2.0,"arrow_length":20.0,"arrow_angle":30.0,"head_at_end":true}}"#, + ) + .expect("historical arrow should deserialize"); + + match shape { + Shape::Arrow { style, bend, .. } => { + assert_eq!(style, ArrowStyle::Standard); + assert_eq!(bend, 0.0); + } + other => panic!("expected arrow shape, got {other:?}"), + } +} + +#[test] +fn arrow_style_and_bend_survive_a_serde_round_trip() { + for style in ArrowStyle::ALL { + let shape = Shape::Arrow { + x1: 0, + y1: 0, + x2: 100, + y2: 0, + color: WHITE, + thick: 2.0, + arrow_length: 20.0, + arrow_angle: 30.0, + head_at_end: true, + style, + bend: -0.35, + label: None, + }; + + let json = serde_json::to_string(&shape).expect("serialize arrow"); + let restored: Shape = serde_json::from_str(&json).expect("deserialize arrow"); + match restored { + Shape::Arrow { + style: restored_style, + bend, + .. + } => { + assert_eq!(restored_style, style); + assert_eq!(bend, -0.35); + } + other => panic!("expected arrow shape, got {other:?}"), + } + } +} + +#[test] +fn curved_arrow_bounds_contain_the_arc_not_just_the_chord() { + // The arc bulges outside the chord's box. An under-sized box leaves repaint + // trails behind the shaft, so break this by unioning only the endpoints and + // the head and the assertion below fails on the very first pixel of arc. + let curved = Shape::Arrow { + x1: 0, + y1: 100, + x2: 400, + y2: 100, + color: WHITE, + thick: 4.0, + arrow_length: 20.0, + arrow_angle: 30.0, + head_at_end: true, + style: ArrowStyle::Curved, + bend: 0.5, + label: None, + }; + + let rect = curved + .bounding_box() + .expect("curved arrow should have bounds"); + // Bend 0.5 over a 400px chord puts the arc's furthest point 100px off it, + // at y = 0. The chord itself never leaves y = 100. + assert!( + rect.y <= 0, + "bounds start at y = {} and clip the arc's bulge", + rect.y + ); + assert!( + rect.y + rect.height >= 100, + "bounds stop at y = {} and clip the chord", + rect.y + rect.height + ); +} + +#[test] +fn double_arrow_bounds_contain_the_second_head() { + fn arrow(style: ArrowStyle) -> Shape { + // Diagonal on purpose: on a horizontal arrow both heads project the + // same vertical extent and the boxes coincide. Off-axis, the tail + // head's barbs reach past the tail point the chord's box stops at. + Shape::Arrow { + x1: 20, + y1: 20, + x2: 200, + y2: 200, + color: WHITE, + thick: 10.0, + arrow_length: 20.0, + arrow_angle: 60.0, + head_at_end: true, + style, + bend: 0.0, + label: None, + } + } + + let double_rect = arrow(ArrowStyle::Double) + .bounding_box() + .expect("double arrow bounds"); + let standard_rect = arrow(ArrowStyle::Standard) + .bounding_box() + .expect("standard arrow bounds"); + // The tail head's barbs stick out past the tapered tail a standard arrow + // ends in, so the box has to grow in both directions. + assert!( + double_rect.x < standard_rect.x && double_rect.y < standard_rect.y, + "double bounds {double_rect:?} do not cover the tail head; standard was {standard_rect:?}" + ); +} + #[test] fn arrow_label_layout_offsets_from_line() { let font = FontDescriptor::default(); - let layout = super::arrow_label_layout(100, 0, 0, 0, 2.0, "1", 12.0, &font) + let layout = super::arrow_label_layout(100, 0, 0, 0, 2.0, 0.0, "1", 12.0, &font) .expect("label layout should exist"); let center_x = layout.bounds.x + layout.bounds.width / 2; let center_y = layout.bounds.y + layout.bounds.height / 2; @@ -79,7 +221,7 @@ fn arrow_label_layout_offsets_from_line() { assert!(center_y > 0); assert!((center_x - 50).abs() <= 20); - let layout = super::arrow_label_layout(0, 100, 0, 0, 2.0, "1", 12.0, &font) + let layout = super::arrow_label_layout(0, 100, 0, 0, 2.0, 0.0, "1", 12.0, &font) .expect("label layout should exist"); let center_x = layout.bounds.x + layout.bounds.width / 2; let center_y = layout.bounds.y + layout.bounds.height / 2; @@ -349,6 +491,78 @@ fn pressure_and_image_bounds_handle_extreme_coordinates() { assert!(image_bounds.contains(i32::MAX, i32::MAX)); } +#[test] +fn curved_arrow_label_follows_the_arc_not_the_chord() { + // Anchored to the chord, the label would sit in the gap the arrow was drawn + // to route around - far from the shaft it numbers. + let font = FontDescriptor::default(); + // Tail at (0, 100), tip at (400, 100), bulging up by 0.5 * 400 / 2 = 100px. + let straight = super::arrow_label_layout(400, 100, 0, 100, 4.0, 0.0, "1", 12.0, &font) + .expect("straight label layout"); + let curved = super::arrow_label_layout(400, 100, 0, 100, 4.0, 0.5, "1", 12.0, &font) + .expect("curved label layout"); + + // Both sit at the middle of the span horizontally. + assert_eq!(straight.bounds.x, curved.bounds.x); + // The curved one tracks the arc's midpoint at y = 0, on the outside of the + // bend; the straight one stays beside the chord at y = 100. + assert!( + curved.bounds.y < straight.bounds.y - 90, + "curved label at y = {} did not follow the arc from y = {}", + curved.bounds.y, + straight.bounds.y + ); +} + +/// A labelled arrow along y = 100 from (0, 100) to (400, 100). +fn labelled_arrow(style: ArrowStyle, head_at_end: bool) -> Shape { + Shape::Arrow { + x1: 0, + y1: 100, + x2: 400, + y2: 100, + color: BLACK, + thick: 4.0, + arrow_length: 20.0, + arrow_angle: 30.0, + head_at_end, + style, + bend: 0.0, + label: Some(ArrowLabel { + value: 7, + size: 12.0, + font_descriptor: FontDescriptor::default(), + }), + } +} + +#[test] +fn flipping_the_head_does_not_move_a_double_arrow_label() { + // docs/CONFIG.md states head_at_end has no effect on Double, and the + // outline honours that — the polygon is the same either way. The label was + // the hole in the contract: anchored to the head's reading of the + // endpoints, toggling Arrow Head mirrored the number across a shaft that + // had not moved, and took its bounds and hit area with it. + let at_end = labelled_arrow(ArrowStyle::Double, true).bounding_box(); + let at_start = labelled_arrow(ArrowStyle::Double, false).bounding_box(); + assert_eq!( + at_end, at_start, + "flipping the head moved a double arrow's label" + ); +} + +#[test] +fn flipping_the_head_still_moves_a_single_headed_arrow_label() { + // The normalization is Double-only. For every other style the head really + // does pick an end, and the label follows it — flattening that would put + // the number on the wrong side of a reversed arrow. + for style in [ArrowStyle::Standard, ArrowStyle::Pointy, ArrowStyle::Curved] { + let at_end = labelled_arrow(style, true).bounding_box(); + let at_start = labelled_arrow(style, false).bounding_box(); + assert_ne!(at_end, at_start, "{style:?} label ignored head_at_end"); + } +} + #[test] fn arrow_label_layout_handles_full_span_endpoints() { let font = FontDescriptor::default(); @@ -358,6 +572,7 @@ fn arrow_label_layout_handles_full_span_endpoints() { i32::MIN, i32::MIN, 2.0, + 0.0, "1", 12.0, &font, diff --git a/src/draw/shape/types.rs b/src/draw/shape/types.rs index 667e4760..c43bd3a7 100644 --- a/src/draw/shape/types.rs +++ b/src/draw/shape/types.rs @@ -89,6 +89,82 @@ impl BlurStyle { } } +/// How an arrow's shaft and head are shaped. +/// +/// The variants are drawing styles, not different shapes: every one of them +/// still stores the same two endpoints and the same head sizing, so switching +/// styles never loses geometry. `Standard` is what arrows looked like before +/// styles existed, which is what makes it the serde default. +#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))] +#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "kebab-case")] +pub enum ArrowStyle { + /// Tapered shaft fused into one head. The historical arrow. + #[default] + Standard, + /// Dart head: the rear edge is notched forward into a concave V. + Pointy, + /// Shaft follows a quadratic Bezier arc so it can route around whatever + /// sits between the pointer and its target. + Curved, + /// Parallel-sided shaft with a head at both ends. + Double, +} + +impl ArrowStyle { + /// Every style, in the order the toolbar and cycling action step through them. + pub const ALL: [Self; 4] = [Self::Standard, Self::Pointy, Self::Curved, Self::Double]; + + /// Short human-readable name for toolbars, menus, and toasts. + pub fn label(self) -> &'static str { + match self { + Self::Standard => "Standard", + Self::Pointy => "Pointy", + Self::Curved => "Curved", + Self::Double => "Double", + } + } + + /// Next style in [`Self::ALL`] order, wrapping at the end. + pub fn next(self) -> Self { + match self { + Self::Standard => Self::Pointy, + Self::Pointy => Self::Curved, + Self::Curved => Self::Double, + Self::Double => Self::Standard, + } + } + + /// Previous style in [`Self::ALL`] order, wrapping at the start. + /// + /// The properties panel steps entries in both directions, so a four-way + /// cycle needs a way back that is not three presses forward. + pub fn previous(self) -> Self { + match self { + Self::Standard => Self::Double, + Self::Pointy => Self::Standard, + Self::Curved => Self::Pointy, + Self::Double => Self::Curved, + } + } + + /// Whether this style bends its shaft, and so needs the `bend` field and + /// the curve sampler rather than the straight-line fast paths. + pub fn is_curved(self) -> bool { + matches!(self, Self::Curved) + } + + /// The bend this style actually draws. + /// + /// Every arrow carries a `bend` so a trip round the style cycle does not + /// lose the arc the user shaped, which means readers have to ask the style + /// whether that stored value is on screen before they position anything + /// against it. + pub fn effective_bend(self, bend: f64) -> f64 { + if self.is_curved() { bend } else { 0.0 } + } +} + /// Label metadata for numbered arrows. #[derive(Clone, Debug, Serialize, Deserialize)] pub struct ArrowLabel { @@ -216,6 +292,19 @@ pub enum Shape { /// Whether the arrowhead sits at the end of the line #[serde(default = "default_arrow_head_at_end")] head_at_end: bool, + /// How the shaft and head are drawn. Absent in sessions written before + /// styles existed, which deserialize as the historical straight arrow. + #[serde(default)] + style: ArrowStyle, + /// Signed bend as a fraction of the tail-to-tip chord length. `0.0` is + /// straight; positive bulges toward the left of the tail-to-tip + /// direction (screen coordinates, y down), negative toward the right. + /// + /// Only [`ArrowStyle::Curved`] draws it, but every arrow carries it so + /// switching a curved arrow to another style and back keeps the arc the + /// user shaped. + #[serde(default)] + bend: f64, /// Optional label rendered near the arrow. #[serde(default, skip_serializing_if = "Option::is_none")] label: Option, @@ -379,6 +468,8 @@ impl Shape { arrow_length, arrow_angle, head_at_end, + style, + bend, label, color: _, } => bounding_box_for_arrow( @@ -390,6 +481,8 @@ impl Shape { *arrow_length, *arrow_angle, *head_at_end, + *style, + *bend, label.as_ref(), ), Shape::BlurRect { x, y, w, h, .. } => bounding_box_for_blur(*x, *y, *w, *h), diff --git a/src/input/hit_test/mod.rs b/src/input/hit_test/mod.rs index c47ac582..2435315d 100644 --- a/src/input/hit_test/mod.rs +++ b/src/input/hit_test/mod.rs @@ -6,7 +6,9 @@ mod shapes; #[cfg(test)] mod tests; -use crate::draw::shape::{arrow_label_layout, step_marker_outline_thickness, step_marker_radius}; +use crate::draw::shape::{ + arrow_label_ends, arrow_label_layout, step_marker_outline_thickness, step_marker_radius, +}; use crate::draw::{DrawnShape, Shape}; use crate::util::Rect; @@ -115,6 +117,8 @@ pub(crate) fn hit_test_with_tolerance( arrow_length, arrow_angle, head_at_end, + style, + bend, label, .. } => { @@ -123,27 +127,46 @@ pub(crate) fn hit_test_with_tolerance( } else { (*x1, *y1, *x2, *y2) }; + // `Double` reads its endpoints differently for the label than for + // the heads, so the grabbable area follows the number rather than + // the flag that does not move it. + let (label_tip_x, label_tip_y, label_tail_x, label_tail_y) = + arrow_label_ends(*x1, *y1, *x2, *y2, *head_at_end, *style); - let mut hit = shapes::segment_hit(*x1, *y1, *x2, *y2, *thick, point, tolerance) - || shapes::arrowhead_hit( - tip_x, - tip_y, - tail_x, - tail_y, - *thick, - *arrow_length, - *arrow_angle, - point, - tolerance, - ); + // Shaft and heads both come from the shared skeleton, so a curved + // arrow is grabbed along its arc and a double-ended one is grabbed + // by either head. + let skeleton = crate::util::calculate_arrow_skeleton( + tip_x, + tip_y, + tail_x, + tail_y, + *thick, + *arrow_length, + *arrow_angle, + *style, + *bend, + ); + let mut hit = match &skeleton { + Some(skeleton) => { + shapes::spine_hit(skeleton.spine.points(), *thick, point, tolerance) + || shapes::arrowhead_triangle_hit(&skeleton.head, point, tolerance) + || skeleton.tail_head.as_ref().is_some_and(|tail_head| { + shapes::arrowhead_triangle_hit(tail_head, point, tolerance) + }) + } + // Too short for a head: the chord is all there is to grab. + None => shapes::segment_hit(*x1, *y1, *x2, *y2, *thick, point, tolerance), + }; if !hit && let Some(label) = label { let label_text = label.value.to_string(); if let Some(layout) = arrow_label_layout( - tip_x, - tip_y, - tail_x, - tail_y, + label_tip_x, + label_tip_y, + label_tail_x, + label_tail_y, *thick, + style.effective_bend(*bend), &label_text, label.size, &label.font_descriptor, diff --git a/src/input/hit_test/shapes.rs b/src/input/hit_test/shapes.rs index 8f59c4b7..25e07bd6 100644 --- a/src/input/hit_test/shapes.rs +++ b/src/input/hit_test/shapes.rs @@ -1,4 +1,4 @@ -use crate::util; +use crate::util::{self, ArrowheadTriangle}; use super::geometry::{ EPS, distance_point_to_point, distance_point_to_segment, p_as_i32, point_in_polygon, @@ -213,28 +213,16 @@ pub(super) fn circle_hit(cx: i32, cy: i32, radius: f64, point: (i32, i32), toler } #[allow(clippy::too_many_arguments)] -pub(super) fn arrowhead_hit( - tip_x: i32, - tip_y: i32, - tail_x: i32, - tail_y: i32, - thick: f64, - arrow_length: f64, - arrow_angle: f64, +/// Whether `point` lands on an already-computed arrowhead triangle. +/// +/// Arrow hit-testing takes its triangles from the shared skeleton so a curved +/// head, whose triangle is aimed along the curve's end tangent rather than the +/// chord, is tested where it was actually drawn. +pub(super) fn arrowhead_triangle_hit( + geometry: &ArrowheadTriangle, point: (i32, i32), tolerance: f64, ) -> bool { - let Some(geometry) = util::calculate_arrowhead_triangle_custom( - tip_x, - tip_y, - tail_x, - tail_y, - thick, - arrow_length, - arrow_angle, - ) else { - return false; - }; let tip = geometry.tip; let (left_x, left_y) = geometry.left; let (right_x, right_y) = geometry.right; @@ -253,3 +241,20 @@ pub(super) fn arrowhead_hit( distance_point_to_segment(p_as_i32(p), to_i32_pair(a), to_i32_pair(b)) <= padded }) } + +/// Whether `point` lands on any segment of a shaft centre line. +/// +/// A straight spine is a single segment and this reduces to [`segment_hit`]; a +/// curved one walks the sampled arc, so grabbing a curved arrow means grabbing +/// where its shaft actually runs rather than the chord it bypasses. +pub(super) fn spine_hit( + spine: &[(f64, f64)], + thickness: f64, + point: (i32, i32), + tolerance: f64, +) -> bool { + let padded = tolerance.max(thickness / 2.0); + spine.windows(2).any(|pair| { + distance_point_to_segment(point, to_i32_pair(pair[0]), to_i32_pair(pair[1])) <= padded + }) +} diff --git a/src/input/hit_test/tests.rs b/src/input/hit_test/tests.rs index d6e37b8b..8308d958 100644 --- a/src/input/hit_test/tests.rs +++ b/src/input/hit_test/tests.rs @@ -1,7 +1,7 @@ use super::*; use crate::draw::{ - ArrowLabel, BLACK, DrawnShape, EmbeddedImage, EraserBrush, EraserKind, FontDescriptor, - PolygonKind, Shape, StepMarkerLabel, + ArrowLabel, ArrowStyle, BLACK, DrawnShape, EmbeddedImage, EraserBrush, EraserKind, + FontDescriptor, PolygonKind, Shape, StepMarkerLabel, }; #[test] @@ -243,18 +243,91 @@ fn arrowhead_hit_detects_point_near_tip_and_rejects_distant_point() { // Arrow pointing upwards from tail at (0, -20) to tip at (0, 0). let tip = (0, 0); let tail = (0, -20); + let skeleton = crate::util::calculate_arrow_skeleton( + tip.0, + tip.1, + tail.0, + tail.1, + 2.0, + 10.0, + 30.0, + ArrowStyle::Standard, + 0.0, + ) + .expect("non-degenerate arrow should yield geometry"); assert!( - shapes::arrowhead_hit(tip.0, tip.1, tail.0, tail.1, 2.0, 10.0, 30.0, tip, 0.5), + shapes::arrowhead_triangle_hit(&skeleton.head, tip, 0.5), "tip point should be inside arrowhead" ); assert!( - !shapes::arrowhead_hit(tip.0, tip.1, tail.0, tail.1, 2.0, 10.0, 30.0, (50, 50), 0.5), + !shapes::arrowhead_triangle_hit(&skeleton.head, (50, 50), 0.5), "faraway point should not be inside arrowhead even with tolerance" ); } +fn arrow_shape(style: ArrowStyle, bend: f64) -> DrawnShape { + // Runs right along y = 100 with the head at (400, 100). + DrawnShape::with_metadata( + 7, + Shape::Arrow { + x1: 0, + y1: 100, + x2: 400, + y2: 100, + color: BLACK, + thick: 4.0, + arrow_length: 20.0, + arrow_angle: 30.0, + head_at_end: true, + style, + bend, + label: None, + }, + 0, + false, + ) +} + +#[test] +fn curved_arrow_is_grabbed_on_its_arc_and_not_on_the_chord_it_bypasses() { + // The point of a curved arrow is that it routes around whatever sits on the + // chord. Testing the chord instead would select it by clicking the thing it + // was drawn to avoid. + let curved = arrow_shape(ArrowStyle::Curved, 0.5); + + // Bend 0.5 over a 400px chord puts the arc's midpoint 100px above it. + assert!( + hit_test(&curved, (200, 0), 2.0), + "the arc's bulge should be grabbable" + ); + assert!( + !hit_test(&curved, (200, 100), 2.0), + "the chord the arrow routes around should not be a hit target" + ); +} + +#[test] +fn double_arrow_is_grabbed_by_either_head() { + let double = arrow_shape(ArrowStyle::Double, 0.0); + let standard = arrow_shape(ArrowStyle::Standard, 0.0); + + // The head is 20px long with a half-base of 20*tan(30 deg) ~ 11.5, so at + // 10px in from the tail it spans 5.8px either side of the chord. A point + // 5px off the chord there is inside the tail head and well outside the + // 2px-radius shaft a standard arrow tapers to. + let on_tail_barb = (10, 95); + assert!( + hit_test(&double, on_tail_barb, 0.5), + "the second head should be a hit target" + ); + assert!( + !hit_test(&standard, on_tail_barb, 0.5), + "test setup: a standard arrow has no barb there to hit" + ); +} + #[test] fn arrow_label_hit_detects_label_bounds() { let font = FontDescriptor::default(); @@ -275,6 +348,8 @@ fn arrow_label_hit_detects_label_bounds() { arrow_length: 10.0, arrow_angle: 30.0, head_at_end: true, + style: ArrowStyle::Standard, + bend: 0.0, label: Some(label), }, 0, @@ -282,8 +357,9 @@ fn arrow_label_hit_detects_label_bounds() { ); let label_text = "12"; - let layout = crate::draw::shape::arrow_label_layout(100, 0, 0, 0, 2.0, label_text, 12.0, &font) - .expect("label layout should exist"); + let layout = + crate::draw::shape::arrow_label_layout(100, 0, 0, 0, 2.0, 0.0, label_text, 12.0, &font) + .expect("label layout should exist"); let hit_point = ( layout.bounds.x + layout.bounds.width / 2, layout.bounds.y + layout.bounds.height / 2, @@ -431,3 +507,57 @@ fn pressure_stroke_hit_includes_one_point_stylus_dots() { assert!(hit_test(&dot, (55, 50), 1.0)); assert!(!hit_test(&dot, (80, 80), 1.0)); } + +/// A labelled arrow along y = 100 from (0, 100) to (400, 100). +fn labelled_arrow_shape(style: ArrowStyle, head_at_end: bool) -> DrawnShape { + DrawnShape::with_metadata( + 1, + Shape::Arrow { + x1: 0, + y1: 100, + x2: 400, + y2: 100, + color: BLACK, + thick: 4.0, + arrow_length: 20.0, + arrow_angle: 30.0, + head_at_end, + style, + bend: 0.0, + label: Some(ArrowLabel { + value: 7, + size: 12.0, + font_descriptor: FontDescriptor::default(), + }), + }, + 0, + false, + ) +} + +#[test] +fn a_double_arrow_label_is_grabbable_from_the_same_place_either_way() { + // head_at_end has no effect on a Double arrow — the outline is the same + // polygon either way, and docs/CONFIG.md says so. The hit area has to + // follow, or the number is grabbable where it is not painted on exactly + // one of the two readings. + let font = FontDescriptor::default(); + let layout = + crate::draw::shape::arrow_label_layout(400, 100, 0, 100, 4.0, 0.0, "7", 12.0, &font) + .expect("label layout should exist"); + let center = ( + layout.bounds.x + layout.bounds.width / 2, + layout.bounds.y + layout.bounds.height / 2, + ); + + for head_at_end in [true, false] { + assert!( + hit_test( + &labelled_arrow_shape(ArrowStyle::Double, head_at_end), + center, + 0.1 + ), + "double arrow label was not grabbable with head_at_end = {head_at_end}" + ); + } +} diff --git a/src/input/mod.rs b/src/input/mod.rs index b89a996b..6370fd23 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -21,6 +21,7 @@ pub use boards::{ }; pub use events::{Key, MouseButton}; #[allow(unused_imports)] +pub(crate) use state::IdleHandle; pub use state::{ BoardPickerCursorHint, ClickHighlightSettings, ColorPickerCursorHint, CommandPaletteCursorHint, ContextMenuCursorHint, DrawingState, EyedropperUiState, HelpOverlayClick, diff --git a/src/input/state/actions/action_dispatch.rs b/src/input/state/actions/action_dispatch.rs index ebd39cd7..a576c207 100644 --- a/src/input/state/actions/action_dispatch.rs +++ b/src/input/state/actions/action_dispatch.rs @@ -3,15 +3,11 @@ use crate::domain::Action; use super::super::{InputState, interaction}; impl InputState { - /// Handle an action triggered by a keybinding. + /// Handle an action that a non-key caller has already resolved. /// - /// Any action closes an in-flight wheel adjustment of a loupe first. This - /// is the one place every page switch, board switch, session load, undo, - /// and redo passes through, and a gesture must never outlive the frame it - /// started on: shape ids restart per frame, so a snapshot flushed after a - /// page change would attach to an unrelated shape. + /// Bound keys enter [`interaction::route_action`] directly, so action-wide + /// gesture preflights live at that shared boundary rather than here. pub(crate) fn handle_action(&mut self, action: Action) { - self.flush_spotlight_magnification_gesture(); let _ = interaction::route_action(self, action); } } diff --git a/src/input/state/actions/action_tools.rs b/src/input/state/actions/action_tools.rs index 13de806a..8ff9e56e 100644 --- a/src/input/state/actions/action_tools.rs +++ b/src/input/state/actions/action_tools.rs @@ -56,6 +56,22 @@ impl InputState { ); } } + Action::CycleArrowStyle => { + // Selected arrows are the target when there are any, so one + // key both restyles what is on screen and sets what the next + // arrow will be, without a modifier to remember. + if self.selection_contains_arrow() { + self.cycle_selected_arrow_style_from_action(); + } else if self.cycle_arrow_style() { + let label = self.arrow_style.label(); + info!("Arrow style set to {label}"); + self.push_toast( + ToastPriority::Info, + "arrow-style", + Toast::info(format!("Arrow style: {label}")), + ); + } + } Action::IncreaseFontSize => { self.adjust_font_size(2.0); } diff --git a/src/input/state/core/base/state/init.rs b/src/input/state/core/base/state/init.rs index 790db833..a6643fbd 100644 --- a/src/input/state/core/base/state/init.rs +++ b/src/input/state/core/base/state/init.rs @@ -11,7 +11,7 @@ use crate::config::{ Action, BoardsConfig, PRESET_SLOTS_MAX, QuickColorPalette, RadialMenuMouseBinding, Shortcut, }; use crate::draw::{ - BlurStyle, DirtyTracker, EraserKind, FontDescriptor, REGULAR_POLYGON_DEFAULT_SIDES, + ArrowStyle, BlurStyle, DirtyTracker, EraserKind, FontDescriptor, REGULAR_POLYGON_DEFAULT_SIDES, }; use crate::input::state::highlight::{ClickHighlightSettings, ClickHighlightState}; use crate::input::state::input_hud::{InputHudSettings, InputHudState}; @@ -107,6 +107,7 @@ impl InputState { arrow_length, arrow_angle, arrow_head_at_end, + arrow_style: ArrowStyle::default(), arrow_label_enabled: false, arrow_label_counter: 1, step_marker_counter: 1, diff --git a/src/input/state/core/base/state/structs.rs b/src/input/state/core/base/state/structs.rs index bd6e7b67..c91094f4 100644 --- a/src/input/state/core/base/state/structs.rs +++ b/src/input/state/core/base/state/structs.rs @@ -29,7 +29,9 @@ use crate::config::{ Shortcut, ToolPresetConfig, ToolbarItemId, ToolbarItemOrderGroup, ToolbarItemsConfig, }; use crate::draw::frame::ShapeSnapshot; -use crate::draw::{BlurStyle, Color, DirtyTracker, EraserKind, FontDescriptor, Shape, ShapeId}; +use crate::draw::{ + ArrowStyle, BlurStyle, Color, DirtyTracker, EraserKind, FontDescriptor, Shape, ShapeId, +}; use crate::input::BoardManager; use crate::input::boards::{BoardRestoreRequest, PageRestoreRequest, PendingBoardRuntimeUiAction}; use crate::input::state::highlight::ClickHighlightState; @@ -138,6 +140,8 @@ pub struct InputState { pub arrow_angle: f64, /// Whether the arrowhead is placed at the end of the line pub arrow_head_at_end: bool, + /// Style copied into the next arrow drawn + pub arrow_style: ArrowStyle, /// Whether auto-numbered arrow labels are enabled pub arrow_label_enabled: bool, /// Next label value for auto-numbered arrows diff --git a/src/input/state/core/base/types.rs b/src/input/state/core/base/types.rs index 4207c519..77823af5 100644 --- a/src/input/state/core/base/types.rs +++ b/src/input/state/core/base/types.rs @@ -117,6 +117,13 @@ pub enum DrawingState { /// Font size used to set minimum width size: f64, }, + /// Drag the bend handle of a selected curved arrow. + BendingArrow { + /// Arrow whose arc is being reshaped. + shape_id: ShapeId, + /// Snapshot before the drag, for one undo entry and for Escape. + snapshot: ShapeSnapshot, + }, /// Drag the on-canvas magnification knob of a selected Spotlight. AdjustingSpotlightMagnification { /// Spotlight whose factor is being dragged. diff --git a/src/input/state/core/mod.rs b/src/input/state/core/mod.rs index 33a6cd83..8b439e3d 100644 --- a/src/input/state/core/mod.rs +++ b/src/input/state/core/mod.rs @@ -18,7 +18,7 @@ pub(crate) mod radial_menu; mod region_select; mod selection; mod selection_actions; -pub(crate) use selection_actions::SpotlightMagnificationTrack; +pub(crate) use selection_actions::{IdleHandle, SpotlightMagnificationTrack}; mod session; mod session_preflight; mod session_preflight_exact; diff --git a/src/input/state/core/properties/apply.rs b/src/input/state/core/properties/apply.rs index bb16efad..92dc060a 100644 --- a/src/input/state/core/properties/apply.rs +++ b/src/input/state/core/properties/apply.rs @@ -1,5 +1,6 @@ use super::super::base::InputState; use super::types::SelectionPropertyKind; +use crate::draw::Shape; impl InputState { pub(crate) fn activate_properties_panel_entry(&mut self) -> bool { @@ -38,6 +39,19 @@ impl InputState { changed } + /// Whether the current selection holds at least one arrow. + /// + /// The arrow-style cycle needs this before it routes: a selection with no + /// arrows in it should step the next-arrow default rather than silently do + /// nothing. + pub(crate) fn selection_contains_arrow(&self) -> bool { + let frame = self.boards.active_frame(); + self.selected_shape_ids() + .iter() + .filter_map(|id| frame.shape(*id)) + .any(|drawn| matches!(drawn.shape, Shape::Arrow { .. })) + } + /// Style-pill path into the same apply machinery as the properties /// popup: adjusts the selection property of `kind` when the current /// selection exposes it and the entry is editable. Refreshes the @@ -68,7 +82,31 @@ impl InputState { changed } + /// Arrow-style action path, whose command remains meaningful when every + /// selected arrow is locked. Visible property controls stay disabled, while + /// the action still reaches the shared apply reporter so it can explain why + /// nothing changed. + pub(crate) fn cycle_selected_arrow_style_from_action(&mut self) -> bool { + let changed = self.dispatch_selection_property(SelectionPropertyKind::ArrowStyle, 1); + + if changed && self.is_properties_panel_open() { + self.refresh_properties_panel(); + } + + changed + } + fn dispatch_selection_property(&mut self, kind: SelectionPropertyKind, direction: i32) -> bool { + // Every property route lands here — the keyboard action, the toolbar's + // AdjustSelectionProperty, and the shape properties panel — so this is + // the one place that has to end a live bend drag first. That drag holds + // a snapshot from before it started; a property change pushed on top of + // it records one undo entry now, and the eventual release records a + // second measured from the same stale snapshot, so undoing walks back + // through a shape that was never on screen (and reverts the property + // change along the way). Restyling is the case that bites hardest, + // because leaving Curved hides the arc the drag is editing. + self.finish_active_arrow_bend(); match kind { SelectionPropertyKind::Color => self.apply_selection_color(direction), SelectionPropertyKind::Thickness => { @@ -79,6 +117,7 @@ impl InputState { self.apply_selection_font_size(direction_or_default(direction)) } SelectionPropertyKind::ArrowHead => self.apply_selection_arrow_head(direction), + SelectionPropertyKind::ArrowStyle => self.apply_selection_arrow_style(direction), SelectionPropertyKind::ArrowLength => { self.apply_selection_arrow_length(direction_or_default(direction)) } diff --git a/src/input/state/core/properties/apply_selection/actions/arrow.rs b/src/input/state/core/properties/apply_selection/actions/arrow.rs index 2e5e8074..388bc23c 100644 --- a/src/input/state/core/properties/apply_selection/actions/arrow.rs +++ b/src/input/state/core/properties/apply_selection/actions/arrow.rs @@ -1,10 +1,25 @@ -use crate::draw::Shape; +use crate::draw::{ArrowStyle, Shape}; use crate::input::state::core::base::InputState; use crate::input::state::core::properties::apply_selection::constants::{ MAX_ARROW_ANGLE, MAX_ARROW_LENGTH, MIN_ARROW_ANGLE, MIN_ARROW_LENGTH, SELECTION_ARROW_ANGLE_STEP, SELECTION_ARROW_LENGTH_STEP, }; use crate::input::state::{Toast, ToastPriority}; +use crate::util::DEFAULT_ARROW_BEND; + +/// What a restyle of the current selection should do. +/// +/// "No arrows" and "arrows, but every one is locked" report differently, and +/// collapsing them into one `None` is what made a locked selection claim it +/// held no arrows. +enum ArrowStyleTarget { + /// The selection holds no arrows at all. + NoArrows, + /// It holds arrows, and every one of them is locked. + AllLocked, + /// The style every editable arrow should end up in. + Style(ArrowStyle), +} impl InputState { pub(in crate::input::state::core::properties) fn apply_selection_arrow_head( @@ -43,6 +58,90 @@ impl InputState { self.report_selection_apply_result(result, "arrow head") } + /// Steps the style of every selected arrow. + /// + /// A mixed selection agrees first and steps second, matching how the + /// boolean arrow properties resolve a mixed target: pressing once on a + /// mixed selection is a normalization, not a jump nobody can predict. + pub(in crate::input::state::core::properties) fn apply_selection_arrow_style( + &mut self, + direction: i32, + ) -> bool { + let target = match self.selection_arrow_style_target(direction) { + ArrowStyleTarget::NoArrows => { + self.push_toast( + ToastPriority::Info, + "selection.apply", + Toast::warning("No arrows selected."), + ); + return false; + } + // There is nothing to step them to, but the apply still has to run: + // it is what counts the locked arrows, and the shared reporter needs + // that count to say they are locked instead of claiming the + // selection holds no arrows at all. `apply_selection_change` skips + // locked shapes, so the target below is never written. + ArrowStyleTarget::AllLocked => ArrowStyle::default(), + ArrowStyleTarget::Style(style) => style, + }; + + let result = self.apply_selection_change( + |shape| matches!(shape, Shape::Arrow { .. }), + |shape| match shape { + Shape::Arrow { style, bend, .. } => { + let mut changed = *style != target; + *style = target; + // A curved arrow drawn as something else has no arc yet, so + // switching to Curved would otherwise render identically to + // the style it replaced. + if target.is_curved() && *bend == 0.0 { + *bend = DEFAULT_ARROW_BEND; + changed = true; + } + changed + } + _ => false, + }, + ); + + self.report_selection_apply_result(result, "arrow style") + } + + /// The style a restyle should move the selection to. + fn selection_arrow_style_target(&self, direction: i32) -> ArrowStyleTarget { + let frame = self.boards.active_frame(); + let mut applicable = 0; + let mut editable = Vec::new(); + for id in self.selected_shape_ids() { + let Some(drawn) = frame.shape(*id) else { + continue; + }; + let Shape::Arrow { style, .. } = &drawn.shape else { + continue; + }; + applicable += 1; + if !drawn.locked { + editable.push(*style); + } + } + + if applicable == 0 { + return ArrowStyleTarget::NoArrows; + } + let Some(first) = editable.first().copied() else { + return ArrowStyleTarget::AllLocked; + }; + if editable.iter().any(|style| *style != first) { + // Mixed: land them all on one style before stepping any of them. + return ArrowStyleTarget::Style(first); + } + ArrowStyleTarget::Style(if direction < 0 { + first.previous() + } else { + first.next() + }) + } + pub(in crate::input::state::core::properties) fn apply_selection_arrow_length( &mut self, direction: i32, @@ -143,6 +242,16 @@ mod tests { state: &mut InputState, head_at_end: bool, arrow_angle: f64, + ) -> crate::draw::ShapeId { + add_styled_arrow(state, head_at_end, arrow_angle, ArrowStyle::Standard, 0.0) + } + + fn add_styled_arrow( + state: &mut InputState, + head_at_end: bool, + arrow_angle: f64, + style: ArrowStyle, + bend: f64, ) -> crate::draw::ShapeId { state.boards.active_frame_mut().add_shape(Shape::Arrow { x1: 0, @@ -154,10 +263,184 @@ mod tests { arrow_length: 24.0, arrow_angle, head_at_end, + style, + bend, label: None, }) } + fn arrow_style(state: &InputState, id: crate::draw::ShapeId) -> ArrowStyle { + match &state.boards.active_frame().shape(id).expect("arrow").shape { + Shape::Arrow { style, .. } => *style, + other => panic!("expected arrow, got {other:?}"), + } + } + + fn arrow_bend(state: &InputState, id: crate::draw::ShapeId) -> f64 { + match &state.boards.active_frame().shape(id).expect("arrow").shape { + Shape::Arrow { bend, .. } => *bend, + other => panic!("expected arrow, got {other:?}"), + } + } + + #[test] + fn restyling_several_arrows_steps_them_all_in_one_undo_entry() { + let mut state = make_state(); + let first = add_arrow(&mut state, true, 30.0); + let second = add_arrow(&mut state, false, 30.0); + state.set_selection(vec![first, second]); + + assert!(state.apply_selection_arrow_style(1)); + assert_eq!(arrow_style(&state, first), ArrowStyle::Pointy); + assert_eq!(arrow_style(&state, second), ArrowStyle::Pointy); + + // One entry, not one per shape: a restyle is a single gesture and has + // to come back in a single undo. + let action = state + .boards + .active_frame_mut() + .undo_last() + .expect("restyle should be undoable"); + state.apply_action_side_effects(&action); + assert_eq!(arrow_style(&state, first), ArrowStyle::Standard); + assert_eq!(arrow_style(&state, second), ArrowStyle::Standard); + } + + #[test] + fn restyling_a_mixed_selection_makes_it_agree_before_it_steps() { + let mut state = make_state(); + let standard = add_arrow(&mut state, true, 30.0); + let curved = add_styled_arrow(&mut state, true, 30.0, ArrowStyle::Curved, 0.3); + state.set_selection(vec![standard, curved]); + + // First press normalizes on the first editable style rather than + // jumping both to somewhere neither of them was. + assert!(state.apply_selection_arrow_style(1)); + assert_eq!(arrow_style(&state, standard), ArrowStyle::Standard); + assert_eq!(arrow_style(&state, curved), ArrowStyle::Standard); + + // Second press steps the now-uniform selection. + assert!(state.apply_selection_arrow_style(1)); + assert_eq!(arrow_style(&state, standard), ArrowStyle::Pointy); + assert_eq!(arrow_style(&state, curved), ArrowStyle::Pointy); + } + + #[test] + fn restyling_backwards_walks_the_cycle_the_other_way() { + let mut state = make_state(); + let arrow = add_arrow(&mut state, true, 30.0); + state.set_selection(vec![arrow]); + + assert!(state.apply_selection_arrow_style(-1)); + assert_eq!(arrow_style(&state, arrow), ArrowStyle::Double); + } + + #[test] + fn restyling_to_curved_gives_a_flat_arrow_an_arc_to_show() { + // A curved arrow at bend zero draws exactly like the style it replaced, + // so switching to it would look like nothing happened. + let mut state = make_state(); + let arrow = add_arrow(&mut state, true, 30.0); + state.set_selection(vec![arrow]); + + assert!(state.apply_selection_arrow_style(1)); // Pointy + assert!(state.apply_selection_arrow_style(1)); // Curved + assert_eq!(arrow_style(&state, arrow), ArrowStyle::Curved); + assert_eq!(arrow_bend(&state, arrow), DEFAULT_ARROW_BEND); + } + + #[test] + fn restyling_away_from_curved_and_back_keeps_the_shaped_arc() { + let mut state = make_state(); + let arrow = add_styled_arrow(&mut state, true, 30.0, ArrowStyle::Curved, 0.7); + state.set_selection(vec![arrow]); + + // Curved -> Double -> Standard -> Pointy -> Curved. + for _ in 0..4 { + assert!(state.apply_selection_arrow_style(1)); + } + assert_eq!(arrow_style(&state, arrow), ArrowStyle::Curved); + assert_eq!( + arrow_bend(&state, arrow), + 0.7, + "the arc the user shaped was lost on the way round the cycle" + ); + } + + #[test] + fn restyling_reports_when_no_arrows_are_selected() { + let mut state = make_state(); + assert!(!state.apply_selection_arrow_style(1)); + assert_eq!( + state.ui_toast.as_ref().map(|toast| toast.message.as_str()), + Some("No arrows selected.") + ); + } + + #[test] + fn cycle_arrow_style_reports_a_locked_arrow_while_its_property_control_stays_disabled() { + // There is no style to step a locked selection to, but "no arrows" + // is the wrong reason to give: the arrows are right there and the user + // needs to be told to unlock them, not to select something. + let mut state = make_state(); + let id = add_arrow(&mut state, true, 30.0); + state + .boards + .active_frame_mut() + .shape_mut(id) + .expect("arrow") + .locked = true; + state.set_selection(vec![id]); + + let style_entry = state + .build_selection_property_entries(&[id]) + .into_iter() + .find(|entry| entry.kind == crate::input::SelectionPropertyKind::ArrowStyle) + .expect("locked arrow style property"); + assert!( + style_entry.disabled, + "locked properties stay non-interactive" + ); + assert_eq!(style_entry.value, "Locked"); + + state.handle_action(crate::domain::Action::CycleArrowStyle); + assert_eq!( + state.ui_toast.as_ref().map(|toast| toast.message.as_str()), + Some("All arrow style shapes are locked.") + ); + assert_eq!( + arrow_style(&state, id), + ArrowStyle::Standard, + "a locked arrow was restyled anyway" + ); + } + + #[test] + fn restyling_a_partly_locked_selection_steps_only_the_unlocked_arrows() { + // The locked one must not vote on the target either — it is skipped by + // the apply, so letting it into the "are they all the same style?" + // check would strand the selection agreeing with a shape that cannot + // move. + let mut state = make_state(); + let locked = add_styled_arrow(&mut state, true, 30.0, ArrowStyle::Double, 0.0); + let editable = add_arrow(&mut state, true, 30.0); + state + .boards + .active_frame_mut() + .shape_mut(locked) + .expect("arrow") + .locked = true; + state.set_selection(vec![locked, editable]); + + assert!(state.apply_selection_arrow_style(1)); + assert_eq!(arrow_style(&state, editable), ArrowStyle::Pointy); + assert_eq!( + arrow_style(&state, locked), + ArrowStyle::Double, + "the locked arrow was restyled" + ); + } + #[test] fn apply_selection_arrow_head_on_mixed_selection_sets_heads_to_end() { let mut state = make_state(); diff --git a/src/input/state/core/properties/entries.rs b/src/input/state/core/properties/entries.rs index 3b1b69b6..adbc44fd 100644 --- a/src/input/state/core/properties/entries.rs +++ b/src/input/state/core/properties/entries.rs @@ -1,7 +1,7 @@ use super::super::base::InputState; use super::summary::{ - PropertySummary, shape_arrow_angle, shape_arrow_head, shape_arrow_length, shape_color, - shape_fill, shape_font_size, shape_spotlight_magnification, shape_text_background, + PropertySummary, shape_arrow_angle, shape_arrow_head, shape_arrow_length, shape_arrow_style, + shape_color, shape_fill, shape_font_size, shape_spotlight_magnification, shape_text_background, shape_thickness, summarize_property, }; use super::types::{SelectionPropertyEntry, SelectionPropertyKind}; @@ -136,6 +136,17 @@ impl InputState { }); } + let style_summary = summarize_property(frame, ids, shape_arrow_style, |a, b| a == b); + if style_summary.applicable { + let value = summary_value(&style_summary, |v| v.label().to_string()); + entries.push(SelectionPropertyEntry { + label: "Arrow style".to_string(), + value, + kind: SelectionPropertyKind::ArrowStyle, + disabled: !style_summary.editable, + }); + } + let length_summary = summarize_property(frame, ids, shape_arrow_length, approx_eq); if length_summary.applicable { let value = summary_value(&length_summary, |v| format!("{v:.0}px")); @@ -194,7 +205,7 @@ impl InputState { mod tests { use super::*; use crate::config::{BoardsConfig, KeybindingsConfig, PresenterModeConfig}; - use crate::draw::{Color, FontDescriptor}; + use crate::draw::{ArrowStyle, Color, FontDescriptor}; use crate::input::{ClickHighlightSettings, EraserMode}; fn make_state() -> InputState { @@ -326,6 +337,8 @@ mod tests { arrow_length: 24.0, arrow_angle: 35.0, head_at_end: true, + style: ArrowStyle::Standard, + bend: 0.0, label: None, }); @@ -349,6 +362,8 @@ mod tests { arrow_length: 24.0, arrow_angle: 35.0, head_at_end: true, + style: ArrowStyle::Standard, + bend: 0.0, label: None, }); let second = state.boards.active_frame_mut().add_shape(Shape::Arrow { @@ -361,6 +376,8 @@ mod tests { arrow_length: 24.0, arrow_angle: 35.0, head_at_end: false, + style: ArrowStyle::Standard, + bend: 0.0, label: None, }); diff --git a/src/input/state/core/properties/summary.rs b/src/input/state/core/properties/summary.rs index 337ca355..dece88ff 100644 --- a/src/input/state/core/properties/summary.rs +++ b/src/input/state/core/properties/summary.rs @@ -1,4 +1,4 @@ -use crate::draw::{Color, Frame, Shape, ShapeId}; +use crate::draw::{ArrowStyle, Color, Frame, Shape, ShapeId}; #[derive(Debug)] pub(super) struct PropertySummary { @@ -120,6 +120,13 @@ pub(super) fn shape_arrow_head(shape: &Shape) -> Option { } } +pub(super) fn shape_arrow_style(shape: &Shape) -> Option { + match shape { + Shape::Arrow { style, .. } => Some(*style), + _ => None, + } +} + pub(super) fn shape_arrow_length(shape: &Shape) -> Option { match shape { Shape::Arrow { arrow_length, .. } => Some(*arrow_length), diff --git a/src/input/state/core/properties/types.rs b/src/input/state/core/properties/types.rs index 447c9988..61ce139d 100644 --- a/src/input/state/core/properties/types.rs +++ b/src/input/state/core/properties/types.rs @@ -7,6 +7,7 @@ pub enum SelectionPropertyKind { Fill, FontSize, ArrowHead, + ArrowStyle, ArrowLength, ArrowAngle, TextBackground, diff --git a/src/input/state/core/selection_actions/arrow_bend.rs b/src/input/state/core/selection_actions/arrow_bend.rs new file mode 100644 index 00000000..2c6a8c5c --- /dev/null +++ b/src/input/state/core/selection_actions/arrow_bend.rs @@ -0,0 +1,268 @@ +//! The on-canvas bend handle of a selected curved arrow. +//! +//! A curved arrow's shaft follows a quadratic Bezier whose control point is +//! pinned to the chord's perpendicular bisector, so the whole arc is described +//! by one signed scalar: `bend`. The handle rides the arc's midpoint, and +//! dragging it sets that scalar from the pointer's perpendicular distance to +//! the chord. + +use crate::draw::{ArrowStyle, Shape, ShapeId}; +use crate::input::InputState; +use crate::util::{self, Rect}; + +/// Side length of the square bend handle, in canvas pixels. +const BEND_HANDLE_SIZE: i32 = 10; + +/// Increment the bend snaps to while `Shift` is held. +/// +/// `Shift` means constrain everywhere else in this codebase; on a control whose +/// arc is already symmetric by construction, the thing left to constrain is the +/// magnitude. Ten steps each way is fine enough to shape an arc and coarse +/// enough that two arrows drawn a minute apart can be made to match. +const BEND_SNAP_STEP: f64 = 0.1; + +/// The bend handle of one selected curved arrow. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct ArrowBendHandle { + pub(crate) shape_id: ShapeId, + /// Square to draw and to hit-test, centred on the arc's midpoint. + pub(crate) rect: Rect, +} + +impl InputState { + /// The bend handle, when exactly one unlocked curved arrow is selected. + /// + /// Single selection only, like the text resize handle and the spotlight + /// knob: the handle edits one arrow's arc, and a mixed selection has no + /// honest position to put it at. + pub(crate) fn selected_arrow_bend_handle(&self) -> Option { + let ids = self.selected_shape_ids(); + if ids.len() != 1 { + return None; + } + let shape_id = ids[0]; + let drawn = self.boards.active_frame().shape(shape_id)?; + if drawn.locked { + return None; + } + let Shape::Arrow { + x1, + y1, + x2, + y2, + head_at_end, + style, + bend, + .. + } = drawn.shape + else { + return None; + }; + if style != ArrowStyle::Curved { + return None; + } + + let (tip, tail) = arrow_ends((x1, y1), (x2, y2), head_at_end); + let (mx, my) = arc_midpoint(tip, tail, bend)?; + let half = BEND_HANDLE_SIZE / 2; + let rect = Rect::new( + mx.round() as i32 - half, + my.round() as i32 - half, + BEND_HANDLE_SIZE, + BEND_HANDLE_SIZE, + )?; + Some(ArrowBendHandle { shape_id, rect }) + } + + /// Whether the pointer is on the bend handle, and which arrow it belongs to. + pub(crate) fn hit_arrow_bend_handle(&self, x: i32, y: i32) -> Option { + let handle = self.selected_arrow_bend_handle()?; + let tolerance = self.hit_test_tolerance.ceil() as i32; + let hit = handle.rect.inflated(tolerance).unwrap_or(handle.rect); + hit.contains(x, y).then_some(handle) + } + + /// Applies a pointer position to the arrow being bent. + /// + /// The chord is re-read from the shape rather than frozen at press: bending + /// never moves the endpoints, so the mapping is stable for the whole + /// gesture and cannot drift from what is on screen. + pub(crate) fn drag_arrow_bend_to(&mut self, x: i32, y: i32, snap: bool) -> bool { + let crate::input::state::DrawingState::BendingArrow { shape_id, .. } = self.state else { + return false; + }; + let frame = self.boards.active_frame(); + let Some(drawn) = frame.shape(shape_id) else { + return false; + }; + let Shape::Arrow { + x1, + y1, + x2, + y2, + head_at_end, + .. + } = drawn.shape + else { + return false; + }; + let (tip, tail) = arrow_ends((x1, y1), (x2, y2), head_at_end); + let Some(bend) = bend_for_pointer(tip, tail, (x, y)) else { + return false; + }; + let bend = if snap { + (bend / BEND_SNAP_STEP).round() * BEND_SNAP_STEP + } else { + bend + }; + self.set_arrow_shape_bend(shape_id, bend) + } + + /// Writes a bend onto one arrow, marking what the change repainted. + /// + /// Records no undo entry: the drag pushes one entry when it ends, so the + /// whole gesture undoes in a single step instead of once per motion event. + pub(crate) fn set_arrow_shape_bend(&mut self, shape_id: ShapeId, bend: f64) -> bool { + let clamped = util::clamp_arrow_bend(bend); + let frame = self.boards.active_frame_mut(); + let Some(drawn) = frame.shape_mut(shape_id) else { + return false; + }; + let before = drawn.bounding_box(); + let Shape::Arrow { bend: current, .. } = &mut drawn.shape else { + return false; + }; + if (*current - clamped).abs() <= f64::EPSILON { + return false; + } + *current = clamped; + drawn.invalidate_bounds(); + let after = drawn.bounding_box(); + self.mark_selection_dirty_region(before); + self.mark_selection_dirty_region(after); + self.invalidate_hit_cache_for(shape_id); + self.mark_session_dirty(); + self.needs_redraw = true; + true + } + + /// Ends an in-progress bend drag, committing what it has already changed. + /// + /// Anything that mutates the arrow being bent has to call this first. + /// Otherwise the mutation pushes its own undo entry while the gesture is + /// still holding a snapshot from before the bend, and the eventual release + /// pushes a second entry measured from that same stale snapshot — so undo + /// walks back through a shape that never existed. Restyling is the case + /// that bites, because leaving `Curved` hides the arc the drag is editing. + /// + /// Returns `true` when a gesture was actually ended. + pub(crate) fn finish_active_arrow_bend(&mut self) -> bool { + // Checked before the take, not after: `mem::replace` would install + // `Idle` on the way to discovering there was no bend to end, silently + // cancelling whatever interaction was actually running. + if !matches!( + self.state, + crate::input::state::DrawingState::BendingArrow { .. } + ) { + return false; + } + let crate::input::state::DrawingState::BendingArrow { shape_id, snapshot } = + std::mem::replace(&mut self.state, crate::input::state::DrawingState::Idle) + else { + return false; + }; + self.commit_arrow_bend(shape_id, snapshot); + self.end_pointer_drag(); + true + } + + /// Records one finished bend drag as a single undo entry. + /// + /// The live updates during the drag deliberately recorded nothing, so the + /// whole gesture undoes in one step rather than once per motion event. A + /// drag that ended where it started records nothing at all. + pub(crate) fn commit_arrow_bend( + &mut self, + shape_id: ShapeId, + snapshot: crate::draw::frame::ShapeSnapshot, + ) { + let Some(shape) = self.boards.active_frame().shape(shape_id) else { + return; + }; + let after = crate::draw::frame::ShapeSnapshot { + shape: shape.shape.clone(), + locked: shape.locked, + }; + if snapshot_bend(&snapshot) == snapshot_bend(&after) { + return; + } + let limit = self.undo_stack_limit; + self.boards.active_frame_mut().push_undo_action( + crate::draw::frame::UndoAction::Modify { + shape_id, + before: snapshot, + after, + }, + limit, + ); + self.mark_session_dirty(); + } +} + +/// Bit pattern of an arrow snapshot's bend, so the comparison above is exact +/// rather than an epsilon nobody chose. +fn snapshot_bend(snapshot: &crate::draw::frame::ShapeSnapshot) -> Option { + match &snapshot.shape { + Shape::Arrow { bend, .. } => Some(bend.to_bits()), + _ => None, + } +} + +/// Splits an arrow's stored endpoints into tip and tail. +/// +/// `head_at_end` is what decides which stored point the head sits on, and the +/// bend's sign is measured against the tail-to-tip direction — so reading it +/// here is what keeps flipping the head from also flipping which way the arc +/// bulges. +fn arrow_ends(p1: (i32, i32), p2: (i32, i32), head_at_end: bool) -> ((i32, i32), (i32, i32)) { + if head_at_end { (p2, p1) } else { (p1, p2) } +} + +/// Point on the arc at `t = 0.5`, which is where the handle rides. +/// +/// For a quadratic Bezier with the control point at `M + perp * bend * chord`, +/// that midpoint works out to `M + perp * bend * chord / 2` — half the control +/// point's offset, and always on the curve rather than off it. +fn arc_midpoint(tip: (i32, i32), tail: (i32, i32), bend: f64) -> Option<(f64, f64)> { + let (perp, chord_len) = chord_frame(tip, tail)?; + let mid_x = (tip.0 as f64 + tail.0 as f64) / 2.0; + let mid_y = (tip.1 as f64 + tail.1 as f64) / 2.0; + let offset = util::clamp_arrow_bend(bend) * chord_len / 2.0; + Some((mid_x + perp.0 * offset, mid_y + perp.1 * offset)) +} + +/// The bend that would put the arc's midpoint under `pointer`. +/// +/// Inverts [`arc_midpoint`]: the perpendicular distance from the chord's +/// midpoint is half the control offset, so the bend is twice that distance over +/// the chord length. Distance along the chord is ignored, which is what keeps +/// the arc symmetric no matter where along it the user grabs. +fn bend_for_pointer(tip: (i32, i32), tail: (i32, i32), pointer: (i32, i32)) -> Option { + let (perp, chord_len) = chord_frame(tip, tail)?; + let mid_x = (tip.0 as f64 + tail.0 as f64) / 2.0; + let mid_y = (tip.1 as f64 + tail.1 as f64) / 2.0; + let offset = (pointer.0 as f64 - mid_x) * perp.0 + (pointer.1 as f64 - mid_y) * perp.1; + Some(2.0 * offset / chord_len) +} + +/// Left normal of the tail-to-tip direction, and the chord length. +/// +/// Shares `util::arrow`'s normal rather than deriving one, so the handle and +/// the arc it edits cannot disagree about which way a positive bend bulges and +/// leave the arrow curving away from the drag. +fn chord_frame(tip: (i32, i32), tail: (i32, i32)) -> Option<((f64, f64), f64)> { + util::chord_normal((tail.0 as f64, tail.1 as f64), (tip.0 as f64, tip.1 as f64)) +} + +#[cfg(test)] +mod tests; diff --git a/src/input/state/core/selection_actions/arrow_bend/tests.rs b/src/input/state/core/selection_actions/arrow_bend/tests.rs new file mode 100644 index 00000000..958dc516 --- /dev/null +++ b/src/input/state/core/selection_actions/arrow_bend/tests.rs @@ -0,0 +1,456 @@ +use super::*; +use crate::draw::Color; +use crate::input::state::DrawingState; +use crate::input::state::test_support::make_test_input_state; + +/// A curved arrow pointing right along y = 100, from (0, 100) to (400, 100). +/// +/// `head_at_end` is true, so `(x2, y2)` is the tip and the tail-to-tip +/// direction runs left to right — which is what the bend's sign is measured +/// against. +fn add_curved_arrow(state: &mut crate::input::InputState, bend: f64) -> crate::draw::ShapeId { + state.boards.active_frame_mut().add_shape(Shape::Arrow { + x1: 0, + y1: 100, + x2: 400, + y2: 100, + color: Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + thick: 4.0, + arrow_length: 20.0, + arrow_angle: 30.0, + head_at_end: true, + style: ArrowStyle::Curved, + bend, + label: None, + }) +} + +#[test] +fn handle_rides_the_arc_not_the_chord() { + let mut state = make_test_input_state(); + let id = add_curved_arrow(&mut state, 0.4); + state.set_selection(vec![id]); + + let handle = state + .selected_arrow_bend_handle() + .expect("a selected curved arrow has a bend handle"); + let center_y = handle.rect.y + handle.rect.height / 2; + // Bend 0.4 over a 400px chord puts the arc's midpoint 80px off the chord. + // A handle parked on the chord midpoint would sit at y = 100 and be + // nowhere near the curve it edits. + assert_eq!(handle.rect.x + handle.rect.width / 2, 200); + assert_eq!(center_y, 20); +} + +#[test] +fn handle_is_offered_only_for_a_single_unlocked_curved_arrow() { + let mut state = make_test_input_state(); + let curved = add_curved_arrow(&mut state, 0.3); + let straight = state.boards.active_frame_mut().add_shape(Shape::Arrow { + x1: 0, + y1: 200, + x2: 400, + y2: 200, + color: state.current_color, + thick: 4.0, + arrow_length: 20.0, + arrow_angle: 30.0, + head_at_end: true, + style: ArrowStyle::Standard, + bend: 0.5, + label: None, + }); + + state.set_selection(vec![straight]); + assert!( + state.selected_arrow_bend_handle().is_none(), + "a straight arrow draws no arc, so there is nothing to bend" + ); + + state.set_selection(vec![curved, straight]); + assert!( + state.selected_arrow_bend_handle().is_none(), + "a multi-selection has no honest handle position" + ); + + state.set_selection(vec![curved]); + assert!(state.selected_arrow_bend_handle().is_some()); + + let index = state + .boards + .active_frame() + .shapes + .iter() + .position(|drawn| drawn.id == curved) + .expect("curved arrow index"); + state.boards.active_frame_mut().shapes[index].locked = true; + assert!( + state.selected_arrow_bend_handle().is_none(), + "a locked arrow must not offer an edit handle" + ); +} + +#[test] +fn dragging_the_handle_bends_toward_the_pointer() { + let mut state = make_test_input_state(); + let id = add_curved_arrow(&mut state, 0.0); + state.set_selection(vec![id]); + state.state = DrawingState::BendingArrow { + shape_id: id, + snapshot: crate::draw::frame::ShapeSnapshot { + shape: state + .boards + .active_frame() + .shape(id) + .expect("arrow") + .shape + .clone(), + locked: false, + }, + }; + + // Pointer 80px above the chord midpoint: the arc's midpoint should follow + // it, which needs bend = 2 * 80 / 400 = 0.4 on the left-of-travel side. + assert!(state.drag_arrow_bend_to(200, 20, false)); + assert!((arrow_bend(&state, id) - 0.4).abs() < 1e-9); + + // And the other way. A sign flip here means the arrow curves away from + // the drag. + assert!(state.drag_arrow_bend_to(200, 180, false)); + assert!((arrow_bend(&state, id) + 0.4).abs() < 1e-9); +} + +#[test] +fn dragging_along_the_chord_does_not_change_the_bend() { + let mut state = make_test_input_state(); + let id = add_curved_arrow(&mut state, 0.0); + state.set_selection(vec![id]); + state.state = DrawingState::BendingArrow { + shape_id: id, + snapshot: crate::draw::frame::ShapeSnapshot { + shape: state + .boards + .active_frame() + .shape(id) + .expect("arrow") + .shape + .clone(), + locked: false, + }, + }; + + // Only the perpendicular component counts, which is what keeps the arc + // symmetric however far along it the user grabs. + assert!(state.drag_arrow_bend_to(120, 40, false)); + let from_left = arrow_bend(&state, id); + assert!( + !state.drag_arrow_bend_to(330, 40, false), + "sliding along the chord should be a no-op, not a new bend" + ); + assert!((arrow_bend(&state, id) - from_left).abs() < 1e-9); +} + +#[test] +fn shift_snaps_the_bend_to_tenths() { + let mut state = make_test_input_state(); + let id = add_curved_arrow(&mut state, 0.0); + state.set_selection(vec![id]); + state.state = DrawingState::BendingArrow { + shape_id: id, + snapshot: crate::draw::frame::ShapeSnapshot { + shape: state + .boards + .active_frame() + .shape(id) + .expect("arrow") + .shape + .clone(), + locked: false, + }, + }; + + // 43px off the chord is bend 0.215, which snaps to 0.2. + assert!(state.drag_arrow_bend_to(200, 57, true)); + assert!( + (arrow_bend(&state, id) - 0.2).abs() < 1e-9, + "shift did not snap: got {}", + arrow_bend(&state, id) + ); +} + +#[test] +fn the_handle_follows_the_head_end() { + // `head_at_end` decides which stored point is the tip, and the bend sign is + // measured against the tail-to-tip direction. Flipping the head therefore + // has to flip which side the same bend bulges toward, or the handle and the + // rendered arc end up on opposite sides of the chord. + let mut state = make_test_input_state(); + let id = add_curved_arrow(&mut state, 0.4); + state.set_selection(vec![id]); + let above = state.selected_arrow_bend_handle().expect("handle").rect.y; + + if let Some(drawn) = state.boards.active_frame_mut().shape_mut(id) + && let Shape::Arrow { head_at_end, .. } = &mut drawn.shape + { + *head_at_end = false; + } + let below = state.selected_arrow_bend_handle().expect("handle").rect.y; + + assert!( + above < 100 && below > 100, + "flipping the head did not mirror the handle: {above} then {below}" + ); +} + +fn arrow_bend(state: &crate::input::InputState, id: crate::draw::ShapeId) -> f64 { + match &state.boards.active_frame().shape(id).expect("arrow").shape { + Shape::Arrow { bend, .. } => *bend, + other => panic!("expected arrow, got {other:?}"), + } +} + +#[test] +fn restyling_mid_gesture_ends_the_bend_instead_of_stacking_on_it() { + // `CycleArrowStyle` is bindable, so it can land while the bend handle is + // still held. Restyling on top of a live gesture pushes an undo entry while + // the gesture is still holding a pre-bend snapshot, and the eventual + // release pushes a second entry measured from that same stale snapshot — so + // undo walks back through an arrow that never existed on screen. Leaving + // `Curved` also hides the very arc the drag is editing. + let mut state = make_test_input_state(); + let id = add_curved_arrow(&mut state, 0.0); + state.set_selection(vec![id]); + let before = crate::draw::frame::ShapeSnapshot { + shape: state + .boards + .active_frame() + .shape(id) + .expect("arrow") + .shape + .clone(), + locked: false, + }; + state.state = DrawingState::BendingArrow { + shape_id: id, + snapshot: before, + }; + assert!(state.drag_arrow_bend_to(200, 20, false)); + let bent = arrow_bend(&state, id); + assert!(bent.abs() > 0.1, "test setup should have bent the arrow"); + + state.handle_action(crate::config::Action::CycleArrowStyle); + + assert!( + matches!(state.state, DrawingState::Idle), + "restyling left the bend gesture running" + ); + // The bend is recorded first, then the restyle: two entries, each measured + // from the state the one before it left behind. + assert_eq!(state.boards.active_frame().undo_stack_len(), 2); + + // Undo the restyle, then the bend, and the arrow is back where it started + // with nothing in between that was never drawn. + state.handle_action(crate::config::Action::Undo); + assert_eq!(arrow_style(&state, id), ArrowStyle::Curved); + assert!((arrow_bend(&state, id) - bent).abs() < 1e-9); + state.handle_action(crate::config::Action::Undo); + assert_eq!(arrow_bend(&state, id), 0.0); + assert_eq!(arrow_style(&state, id), ArrowStyle::Curved); +} + +fn arrow_style(state: &crate::input::InputState, id: crate::draw::ShapeId) -> ArrowStyle { + match &state.boards.active_frame().shape(id).expect("arrow").shape { + Shape::Arrow { style, .. } => *style, + other => panic!("expected arrow, got {other:?}"), + } +} + +#[test] +fn any_selection_property_change_ends_a_live_bend_first() { + // The guard sits on `dispatch_selection_property`, not on the arrow-style + // action, because the toolbar and the shape properties panel reach the same + // mutators by other routes — and because the hazard is not style-specific. + // A thickness change swallowed into a live bend's snapshot pair would be + // undone by undoing the bend, which is not what either edit asked for. + let mut state = make_test_input_state(); + let id = add_curved_arrow(&mut state, 0.0); + state.set_selection(vec![id]); + state.state = DrawingState::BendingArrow { + shape_id: id, + snapshot: crate::draw::frame::ShapeSnapshot { + shape: state + .boards + .active_frame() + .shape(id) + .expect("arrow") + .shape + .clone(), + locked: false, + }, + }; + assert!(state.drag_arrow_bend_to(200, 20, false)); + let bent = arrow_bend(&state, id); + + state.adjust_selection_property_kind( + crate::input::state::core::properties::SelectionPropertyKind::Thickness, + 1, + ); + + assert!( + matches!(state.state, DrawingState::Idle), + "a thickness change left the bend gesture running" + ); + // Undoing the thickness change must leave the bend intact rather than + // rolling the arrow back past a gesture that had already been committed. + state.handle_action(crate::config::Action::Undo); + assert!((arrow_bend(&state, id) - bent).abs() < 1e-9); +} + +#[test] +fn a_nudge_key_mid_gesture_ends_the_bend_instead_of_stacking_on_it() { + // Pressed as a real key, not dispatched as an action: a bound key goes + // from `keyboard.rs` straight into `route_action` and never touches + // `handle_action`, so a preflight hung off the latter would leave the + // ordinary key press — which is most of them — going around it. + // + // Recording a nudge from the live bent shape while the gesture still holds + // a pre-bend snapshot means the release records a second entry from that + // stale snapshot, so undoing twice puts the bend *back* instead of taking + // it away. + let mut state = make_test_input_state(); + let id = add_curved_arrow(&mut state, 0.0); + state.set_selection(vec![id]); + state.state = DrawingState::BendingArrow { + shape_id: id, + snapshot: crate::draw::frame::ShapeSnapshot { + shape: state + .boards + .active_frame() + .shape(id) + .expect("arrow") + .shape + .clone(), + locked: false, + }, + }; + assert!(state.drag_arrow_bend_to(200, 20, false)); + let bent = arrow_bend(&state, id); + assert!(bent.abs() > 0.1, "test setup should have bent the arrow"); + + state.on_key_press(crate::input::Key::Right); + + assert!( + matches!(state.state, DrawingState::Idle), + "a nudge left the bend gesture running" + ); + assert_eq!(state.boards.active_frame().undo_stack_len(), 2); + + // Undo the nudge, then the bend. Neither step may resurrect the other. + state.handle_action(crate::config::Action::Undo); + assert!((arrow_bend(&state, id) - bent).abs() < 1e-9); + state.handle_action(crate::config::Action::Undo); + assert_eq!(arrow_bend(&state, id), 0.0); +} + +#[test] +fn escape_still_cancels_a_bend_rather_than_committing_it() { + // `route_action` ends a live bend for every action except Exit, whose whole + // job is to back out of the gesture. Committing first would leave Escape + // with nothing to cancel and quietly keep the arc. Driven through the key + // so the exception is checked on the path that actually carries it. + let mut state = make_test_input_state(); + let id = add_curved_arrow(&mut state, 0.0); + state.set_selection(vec![id]); + state.state = DrawingState::BendingArrow { + shape_id: id, + snapshot: crate::draw::frame::ShapeSnapshot { + shape: state + .boards + .active_frame() + .shape(id) + .expect("arrow") + .shape + .clone(), + locked: false, + }, + }; + assert!(state.drag_arrow_bend_to(200, 20, false)); + + state.on_key_press(crate::input::Key::Escape); + + assert!(matches!(state.state, DrawingState::Idle)); + assert_eq!(arrow_bend(&state, id), 0.0, "Escape kept the bend"); + assert_eq!( + state.boards.active_frame().undo_stack_len(), + 0, + "a cancelled bend must not be recorded" + ); + assert!( + !state.should_exit, + "Escape cancelled the gesture, not the app" + ); +} + +#[test] +fn an_action_with_no_bend_running_leaves_the_interaction_alone() { + // `finish_active_arrow_bend` runs on every action now, so taking the state + // apart before confirming a bend is running would cancel whatever else was. + let mut state = make_test_input_state(); + state.state = DrawingState::Selecting { + start_x: 10, + start_y: 10, + additive: false, + }; + + assert!(!state.finish_active_arrow_bend()); + assert!( + matches!(state.state, DrawingState::Selecting { .. }), + "an unrelated interaction was cancelled, got {:?}", + state.state + ); +} + +#[test] +fn a_toolbar_event_mid_gesture_ends_the_bend_before_it_can_lose_the_arrow() { + // Toolbar events never reach `route_action`, and touch, tablet, and the GTK + // toolbar all deliver them while a pointer-held gesture is running. Undo All + // is the sharp case: it can remove the arrow outright, after which the + // release finds no shape and drops the bend without a trace. + let mut state = make_test_input_state(); + let id = add_curved_arrow(&mut state, 0.0); + state.set_selection(vec![id]); + state.state = DrawingState::BendingArrow { + shape_id: id, + snapshot: crate::draw::frame::ShapeSnapshot { + shape: state + .boards + .active_frame() + .shape(id) + .expect("arrow") + .shape + .clone(), + locked: false, + }, + }; + assert!(state.drag_arrow_bend_to(200, 20, false)); + let bent = arrow_bend(&state, id); + + state.apply_toolbar_event(crate::ui::toolbar::ToolbarEvent::UndoAll); + + assert!( + matches!(state.state, DrawingState::Idle), + "a toolbar event left the bend gesture running" + ); + // The bend was recorded before Undo All ran, so it is on the stack to be + // undone rather than lost with the shape. + state.handle_action(crate::config::Action::RedoAll); + assert!( + (arrow_bend(&state, id) - bent).abs() < 1e-9, + "the bend was dropped instead of committed before the toolbar event" + ); +} diff --git a/src/input/state/core/selection_actions/handles.rs b/src/input/state/core/selection_actions/handles.rs new file mode 100644 index 00000000..f2852e65 --- /dev/null +++ b/src/input/state/core/selection_actions/handles.rs @@ -0,0 +1,68 @@ +//! Which on-canvas handle a point belongs to. +//! +//! The handles a selection can show overlap: a curved arrow's bend grip rides +//! the arc's midpoint, which on a shallow curve lands within a few pixels of +//! the selection box's edge handle. Whichever probe runs first wins, so the +//! order is a real decision and not an implementation detail. +//! +//! It is also a decision that has to be made once. A press and the cursor that +//! previews it are answered in different layers — `handle_idle_tool_click` in +//! the input state, the compositor's pointer handler in the backend — and when +//! those layers each kept their own list, they disagreed: the pointer showed a +//! resize arrow over a grip that a click would bend. Both now ask this. + +use crate::draw::ShapeId; +use crate::draw::frame::ShapeSnapshot; +use crate::input::InputState; +use crate::input::state::core::base::SelectionHandle; + +/// The handle under a point, or `None` when the point is on none of them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum IdleHandle { + /// A selected loupe's magnification track. + SpotlightMagnification(ShapeId), + /// A selected curved arrow's bend grip. + ArrowBend(ShapeId), + /// A selected text block's font-size grip. + TextResize(ShapeId), + /// One of the eight handles on the selection's bounding box. + SelectionResize(SelectionHandle), +} + +impl InputState { + /// Resolves a canvas point to the handle a press there would grab. + /// + /// Ordered most-specific first. The magnification track and the bend grip + /// both sit outside the shape's own bounding box, so nothing else claims + /// their pixels and letting the selection box swallow them would make them + /// unusable on exactly the shapes that need them most — a shallow arc, a + /// loupe with the Spotlight tool still active. + pub(crate) fn hit_idle_handle(&self, x: i32, y: i32) -> Option { + if let Some(control) = self.hit_spotlight_magnification_track(x, y) { + return Some(IdleHandle::SpotlightMagnification(control.shape_id)); + } + if let Some(handle) = self.hit_arrow_bend_handle(x, y) { + return Some(IdleHandle::ArrowBend(handle.shape_id)); + } + if let Some(shape_id) = self.hit_text_resize_handle(x, y) { + return Some(IdleHandle::TextResize(shape_id)); + } + self.hit_selection_handle(x, y) + .map(IdleHandle::SelectionResize) + } + + /// Snapshot of one shape on the active frame, for a gesture about to + /// change it. `None` when the id no longer resolves. + pub(crate) fn shape_snapshot(&self, shape_id: ShapeId) -> Option { + self.boards + .active_frame() + .shape(shape_id) + .map(|shape| ShapeSnapshot { + shape: shape.shape.clone(), + locked: shape.locked, + }) + } +} + +#[cfg(test)] +mod tests; diff --git a/src/input/state/core/selection_actions/handles/tests.rs b/src/input/state/core/selection_actions/handles/tests.rs new file mode 100644 index 00000000..3487c944 --- /dev/null +++ b/src/input/state/core/selection_actions/handles/tests.rs @@ -0,0 +1,91 @@ +use super::*; +use crate::draw::{ArrowStyle, Color, Shape}; +use crate::input::state::DrawingState; +use crate::input::state::test_support::make_test_input_state; + +/// A curved arrow whose arc is shallow enough that its bend grip lands inside +/// the selection box's top-edge handle. +fn add_shallow_curved_arrow(state: &mut crate::input::InputState) -> crate::draw::ShapeId { + state.boards.active_frame_mut().add_shape(Shape::Arrow { + x1: 0, + y1: 100, + x2: 400, + y2: 100, + color: Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + thick: 4.0, + arrow_length: 20.0, + arrow_angle: 30.0, + head_at_end: true, + style: ArrowStyle::Curved, + bend: 0.05, + label: None, + }) +} + +#[test] +fn a_shallow_bend_grip_outranks_the_selection_edge_handle_it_overlaps() { + // This is the collision the ordering exists for. At a shallow bend the grip + // sits a couple of pixels off the chord, well inside the top edge handle's + // tolerance, and whichever probe runs first wins the pixel. + let mut state = make_test_input_state(); + let id = add_shallow_curved_arrow(&mut state); + state.set_selection(vec![id]); + + let grip = state + .selected_arrow_bend_handle() + .expect("a selected curved arrow has a bend handle"); + let center = ( + grip.rect.x + grip.rect.width / 2, + grip.rect.y + grip.rect.height / 2, + ); + + assert!( + state.hit_selection_handle(center.0, center.1).is_some(), + "test setup should have put the grip inside an edge handle" + ); + assert_eq!( + state.hit_idle_handle(center.0, center.1), + Some(IdleHandle::ArrowBend(id)), + "the edge handle swallowed the bend grip" + ); +} + +#[test] +fn what_the_routing_reports_is_what_a_press_starts() { + // The cursor is drawn from `hit_idle_handle` and the press is dispatched + // from it, so the two agree by construction — but only while the press arms + // keep matching the variants. This is what notices if one drifts, and it is + // the bug the shared routing replaced: the pointer showed a resize arrow + // over a grip that a click would bend. + let mut state = make_test_input_state(); + let id = add_shallow_curved_arrow(&mut state); + state.set_selection(vec![id]); + + let grip = state.selected_arrow_bend_handle().expect("bend handle"); + let center = ( + grip.rect.x + grip.rect.width / 2, + grip.rect.y + grip.rect.height / 2, + ); + assert_eq!( + state.hit_idle_handle(center.0, center.1), + Some(IdleHandle::ArrowBend(id)) + ); + + state.on_mouse_press(crate::input::events::MouseButton::Left, center.0, center.1); + assert!( + matches!(state.state, DrawingState::BendingArrow { .. }), + "routing promised a bend but the press started {:?}", + state.state + ); +} + +#[test] +fn empty_canvas_routes_to_no_handle() { + let state = make_test_input_state(); + assert_eq!(state.hit_idle_handle(50, 50), None); +} diff --git a/src/input/state/core/selection_actions/mod.rs b/src/input/state/core/selection_actions/mod.rs index cb803948..26eff001 100644 --- a/src/input/state/core/selection_actions/mod.rs +++ b/src/input/state/core/selection_actions/mod.rs @@ -1,6 +1,9 @@ +mod arrow_bend; mod clipboard; mod delete; mod geometry; +mod handles; +pub(crate) use handles::IdleHandle; mod reorder; mod resize; mod spotlight; diff --git a/src/input/state/core/selection_actions/resize.rs b/src/input/state/core/selection_actions/resize.rs index 0ceec55a..0aa4ee8b 100644 --- a/src/input/state/core/selection_actions/resize.rs +++ b/src/input/state/core/selection_actions/resize.rs @@ -195,6 +195,8 @@ impl InputState { arrow_length, arrow_angle, head_at_end, + style, + bend, label, } => { let (nx1, ny1) = @@ -211,6 +213,20 @@ impl InputState { arrow_length: *arrow_length, arrow_angle: *arrow_angle, head_at_end: *head_at_end, + style: *style, + // `bend` is a fraction of the chord, so a uniform scale + // carries the arc for free — but a non-uniform one has to + // scale the arc itself, or the bulge (the only part of a + // flat curved arrow with any height) ignores the drag. + bend: crate::util::scaled_arrow_bend( + (*x1 as f64, *y1 as f64), + (*x2 as f64, *y2 as f64), + (nx1 as f64, ny1 as f64), + (nx2 as f64, ny2 as f64), + *bend, + scale_x, + scale_y, + ), label: label.clone(), } } diff --git a/src/input/state/core/session.rs b/src/input/state/core/session.rs index 3f1eeb5f..06bd2a3a 100644 --- a/src/input/state/core/session.rs +++ b/src/input/state/core/session.rs @@ -175,6 +175,7 @@ impl InputState { | DrawingState::Selecting { .. } | DrawingState::ResizingText { .. } | DrawingState::ResizingSelection { .. } + | DrawingState::BendingArrow { .. } | DrawingState::AdjustingSpotlightMagnification { .. } ) || self.board_picker_is_dragging() diff --git a/src/input/state/core/tool_controls/settings.rs b/src/input/state/core/tool_controls/settings.rs index ead1c5e3..c232f1fb 100644 --- a/src/input/state/core/tool_controls/settings.rs +++ b/src/input/state/core/tool_controls/settings.rs @@ -1,5 +1,5 @@ use super::super::base::{DrawingState, InputState, MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS}; -use crate::draw::{BlurStyle, Color, FontDescriptor, clamp_regular_sides}; +use crate::draw::{ArrowStyle, BlurStyle, Color, FontDescriptor, clamp_regular_sides}; use crate::input::state::{Toast, ToastPriority}; use crate::input::{ DragBinding, MouseButton, @@ -426,6 +426,23 @@ impl InputState { true } + /// Sets the style copied into the next arrow. Returns true if changed. + pub fn set_arrow_style(&mut self, style: ArrowStyle) -> bool { + if self.arrow_style == style { + return false; + } + self.arrow_style = style; + self.dirty_tracker.mark_full(); + self.needs_redraw = true; + self.mark_session_dirty(); + true + } + + /// Steps to the next arrow style, wrapping around. + pub fn cycle_arrow_style(&mut self) -> bool { + self.set_arrow_style(self.arrow_style.next()) + } + /// Sets the font descriptor used for text rendering. Returns true if changed. #[allow(dead_code)] pub fn set_font_descriptor(&mut self, descriptor: FontDescriptor) -> bool { diff --git a/src/input/state/core/utility/interaction.rs b/src/input/state/core/utility/interaction.rs index eafbb76d..baa97d83 100644 --- a/src/input/state/core/utility/interaction.rs +++ b/src/input/state/core/utility/interaction.rs @@ -247,7 +247,8 @@ impl InputState { self.restore_selection_from_snapshots(vec![(*shape_id, snapshot.clone())]); self.state = DrawingState::Idle; } - DrawingState::AdjustingSpotlightMagnification { shape_id, snapshot } => { + DrawingState::BendingArrow { shape_id, snapshot } + | DrawingState::AdjustingSpotlightMagnification { shape_id, snapshot } => { self.restore_selection_from_snapshots(vec![(*shape_id, snapshot.clone())]); self.state = DrawingState::Idle; } diff --git a/src/input/state/interaction/actions.rs b/src/input/state/interaction/actions.rs index 96c3d662..ae627f89 100644 --- a/src/input/state/interaction/actions.rs +++ b/src/input/state/interaction/actions.rs @@ -42,6 +42,7 @@ pub(crate) fn classify_action(action: Action) -> ActionRoute { | Action::SelectEraserTool | Action::ToggleEraserMode | Action::CycleBlurStyle + | Action::CycleArrowStyle | Action::SelectPenTool | Action::SelectLineTool | Action::SelectRectTool @@ -170,7 +171,35 @@ pub(crate) fn classify_action(action: Action) -> ActionRoute { } } +/// Dispatches one action, whatever raised it. +/// +/// Both entry points land here — `InputState::handle_action` for the command +/// palette and the toolbar's action equivalents, and a matched keybinding +/// straight from `keyboard.rs` — so this is where an action-wide preflight +/// belongs. Hanging one off `handle_action` alone leaves the ordinary key +/// press, which is most of them, going around it. pub(crate) fn route_action(state: &mut InputState, action: Action) -> RoutingOutcome { + // A wheel burst owns a snapshot from the frame where it started. Every + // action route closes it before dispatch because page and board actions can + // replace that frame, whose shape ids may alias the old one. Bound keys + // enter here directly and do not pass through `handle_action`. + state.flush_spotlight_magnification_gesture(); + + // A held bend handle ends before the action runs. Keys still reach the + // overlay while the pointer button is down, and any action that mutates the + // arrow — Nudge, Delete, Duplicate, a property change — would record an + // entry against the already-bent shape while the gesture still holds a + // pre-bend snapshot. The release would then record a second entry from that + // stale snapshot, so undoing twice would put the bend back instead of + // taking it away. + // + // `Exit` is the exception: cancelling that gesture is precisely its job, and + // committing first would leave Escape nothing to cancel and quietly keep the + // arc the user was backing out of. + if !matches!(action, Action::Exit) { + state.finish_active_arrow_bend(); + } + if !matches!( action, Action::OpenContextMenu | Action::ToggleSelectionProperties diff --git a/src/input/state/interaction/active.rs b/src/input/state/interaction/active.rs index bc8ceb22..54f055a6 100644 --- a/src/input/state/interaction/active.rs +++ b/src/input/state/interaction/active.rs @@ -12,6 +12,7 @@ pub(crate) fn active_interaction_kind(state: &InputState) -> Option Some(ActiveInteractionKind::BoxSelecting), DrawingState::ResizingText { .. } => Some(ActiveInteractionKind::ResizingText), DrawingState::ResizingSelection { .. } => Some(ActiveInteractionKind::ResizingSelection), + DrawingState::BendingArrow { .. } => Some(ActiveInteractionKind::BendingArrow), DrawingState::AdjustingSpotlightMagnification { .. } => { Some(ActiveInteractionKind::AdjustingSpotlightMagnification) } diff --git a/src/input/state/interaction/adapters/active_motion.rs b/src/input/state/interaction/adapters/active_motion.rs index b25eef12..1e8e0c14 100644 --- a/src/input/state/interaction/adapters/active_motion.rs +++ b/src/input/state/interaction/adapters/active_motion.rs @@ -30,6 +30,15 @@ pub(crate) fn handle_active_motion( )); } + if matches!(state.state, DrawingState::BendingArrow { .. }) { + // Shift snaps the magnitude; the arc is symmetric either way. + let snap = state.modifiers.shift; + state.drag_arrow_bend_to(canvas.x(), canvas.y(), snap); + return Some(RoutingOutcome::Continued( + ActiveInteractionKind::BendingArrow, + )); + } + if let DrawingState::ResizingText { shape_id, base_x, @@ -194,6 +203,7 @@ pub(crate) fn releasable_active_kind(state: &InputState) -> Option Some(ActiveInteractionKind::PendingTextClick), DrawingState::ResizingText { .. } => Some(ActiveInteractionKind::ResizingText), DrawingState::ResizingSelection { .. } => Some(ActiveInteractionKind::ResizingSelection), + DrawingState::BendingArrow { .. } => Some(ActiveInteractionKind::BendingArrow), DrawingState::AdjustingSpotlightMagnification { .. } => { Some(ActiveInteractionKind::AdjustingSpotlightMagnification) } diff --git a/src/input/state/interaction/adapters/pointer.rs b/src/input/state/interaction/adapters/pointer.rs index d39008a4..4db9c981 100644 --- a/src/input/state/interaction/adapters/pointer.rs +++ b/src/input/state/interaction/adapters/pointer.rs @@ -220,6 +220,7 @@ pub(crate) fn handle_unbound_left_press( | DrawingState::PendingTextClick { .. } | DrawingState::ResizingText { .. } | DrawingState::ResizingSelection { .. } + | DrawingState::BendingArrow { .. } | DrawingState::AdjustingSpotlightMagnification { .. } => { RoutingOutcome::NoRoute(NoRouteReason::NoPointerBinding) } diff --git a/src/input/state/interaction/outcome.rs b/src/input/state/interaction/outcome.rs index 90cd54e4..93735f45 100644 --- a/src/input/state/interaction/outcome.rs +++ b/src/input/state/interaction/outcome.rs @@ -40,6 +40,7 @@ pub(crate) enum ActiveInteractionKind { BoxSelecting, ResizingText, ResizingSelection, + BendingArrow, AdjustingSpotlightMagnification, } diff --git a/src/input/state/mod.rs b/src/input/state/mod.rs index e1289a8b..59f12a64 100644 --- a/src/input/state/mod.rs +++ b/src/input/state/mod.rs @@ -6,7 +6,7 @@ pub(crate) mod interaction; mod mouse; mod render; mod spotlight; -pub(crate) use core::SpotlightMagnificationTrack; +pub(crate) use core::{IdleHandle, SpotlightMagnificationTrack}; pub(crate) use spotlight::{ SpotlightFrameRegions, SpotlightMagnificationGesture, SpotlightWheelClaim, SpotlightWheelOutcome, diff --git a/src/input/state/mouse/press.rs b/src/input/state/mouse/press.rs index d99254bf..83184e9e 100644 --- a/src/input/state/mouse/press.rs +++ b/src/input/state/mouse/press.rs @@ -1,10 +1,9 @@ use crate::draw::Shape; -use crate::draw::frame::ShapeSnapshot; use crate::input::tool::ToolPressBehavior; use crate::input::{DragTool, Tool, events::MouseButton}; use std::sync::Arc; -use super::super::core::MenuCommand; +use super::super::core::{IdleHandle, MenuCommand}; use super::super::{ ContextMenuKind, DrawingState, InputState, interaction::{CanvasPoint, PointerPoints, PointerPress, ScreenPoint, route_pointer_press}, @@ -211,6 +210,7 @@ impl InputState { | DrawingState::PendingTextClick { .. } | DrawingState::ResizingText { .. } | DrawingState::ResizingSelection { .. } + | DrawingState::BendingArrow { .. } | DrawingState::AdjustingSpotlightMagnification { .. } => {} } } @@ -385,72 +385,69 @@ impl InputState { self.modifiers.alt || matches!(tool.press_behavior(), ToolPressBehavior::Selection); let hit_id = self.hit_test_at(x, y); - // The magnification knob is checked before the resize handles and - // before tool dispatch: it sits outside the loupe's bounding box, so - // nothing else claims those pixels, and with the Spotlight tool active - // a press would otherwise start drawing a new loupe on top of it. - if let Some(control) = self.hit_spotlight_magnification_track(x, y) { - let shape_id = control.shape_id; - let snapshot = { - let frame = self.boards.active_frame(); - frame.shape(shape_id).map(|shape| ShapeSnapshot { - shape: shape.shape.clone(), - locked: shape.locked, - }) - }; - if let Some(snapshot) = snapshot { - self.last_text_click = None; - self.begin_pointer_drag(button, color); - self.state = DrawingState::AdjustingSpotlightMagnification { shape_id, snapshot }; - // Jump to where the user pressed, so a click anywhere on the - // track is itself an adjustment rather than dead travel. - self.drag_spotlight_magnification_to(x); - return; + // Handles are claimed before tool dispatch, in the order + // `hit_idle_handle` fixes — the same order the pointer cursor previews, + // so what the user sees is what the press does. + match self.hit_idle_handle(x, y) { + Some(IdleHandle::SpotlightMagnification(shape_id)) => { + if let Some(snapshot) = self.shape_snapshot(shape_id) { + self.last_text_click = None; + self.begin_pointer_drag(button, color); + self.state = + DrawingState::AdjustingSpotlightMagnification { shape_id, snapshot }; + // Jump to where the user pressed, so a click anywhere on + // the track is itself an adjustment rather than dead travel. + self.drag_spotlight_magnification_to(x); + return; + } } - } - - if let Some(shape_id) = self.hit_text_resize_handle(x, y) { - let snapshot = { - let frame = self.boards.active_frame(); - frame.shape(shape_id).map(|shape| ShapeSnapshot { - shape: shape.shape.clone(), - locked: shape.locked, - }) - }; - if let Some(snapshot) = snapshot { - let (base_x, size) = match &snapshot.shape { - Shape::Text { x, size, .. } => (*x, *size), - Shape::StickyNote { x, size, .. } => (*x, *size), - _ => return, - }; - self.last_text_click = None; - self.begin_pointer_drag(button, color); - self.state = DrawingState::ResizingText { - shape_id, - snapshot, - base_x, - size, - }; - return; + Some(IdleHandle::ArrowBend(shape_id)) => { + if let Some(snapshot) = self.shape_snapshot(shape_id) { + self.last_text_click = None; + self.begin_pointer_drag(button, color); + self.state = DrawingState::BendingArrow { shape_id, snapshot }; + // Jump the arc to where the user pressed, so a click beside + // the handle is itself an adjustment rather than dead travel. + self.drag_arrow_bend_to(x, y, self.modifiers.shift); + return; + } } - } - - if let Some(handle) = self.hit_selection_handle(x, y) - && let Some(original_bounds) = self.selection_bounds() - { - let snapshots = self.capture_resize_selection_snapshots(); - if !snapshots.is_empty() { - self.last_text_click = None; - self.begin_pointer_drag(button, color); - self.state = DrawingState::ResizingSelection { - handle, - original_bounds, - start_x: x, - start_y: y, - snapshots: Arc::new(snapshots), - }; - return; + Some(IdleHandle::TextResize(shape_id)) => { + if let Some(snapshot) = self.shape_snapshot(shape_id) { + let (base_x, size) = match &snapshot.shape { + Shape::Text { x, size, .. } => (*x, *size), + Shape::StickyNote { x, size, .. } => (*x, *size), + _ => return, + }; + self.last_text_click = None; + self.begin_pointer_drag(button, color); + self.state = DrawingState::ResizingText { + shape_id, + snapshot, + base_x, + size, + }; + return; + } + } + Some(IdleHandle::SelectionResize(handle)) => { + if let Some(original_bounds) = self.selection_bounds() { + let snapshots = self.capture_resize_selection_snapshots(); + if !snapshots.is_empty() { + self.last_text_click = None; + self.begin_pointer_drag(button, color); + self.state = DrawingState::ResizingSelection { + handle, + original_bounds, + start_x: x, + start_y: y, + snapshots: Arc::new(snapshots), + }; + return; + } + } } + None => {} } if !selection_click && let Some(hit_id) = hit_id { diff --git a/src/input/state/mouse/release/drawing.rs b/src/input/state/mouse/release/drawing.rs index 577cf0ec..49e1c18e 100644 --- a/src/input/state/mouse/release/drawing.rs +++ b/src/input/state/mouse/release/drawing.rs @@ -52,6 +52,7 @@ pub(super) fn finish_drawing(state: &mut InputState, tool: Tool, release: Drawin arrow_length: state.arrow_length, arrow_angle: state.arrow_angle, arrow_head_at_end: state.arrow_head_at_end, + arrow_style: state.arrow_style, arrow_label: state.next_arrow_label(), step_marker_label: state.next_step_marker_label(), eraser_mode: state.eraser_mode, diff --git a/src/input/state/mouse/release/mod.rs b/src/input/state/mouse/release/mod.rs index d764e1c1..69850381 100644 --- a/src/input/state/mouse/release/mod.rs +++ b/src/input/state/mouse/release/mod.rs @@ -108,6 +108,9 @@ impl InputState { DrawingState::AdjustingSpotlightMagnification { shape_id, snapshot } => { selection::finish_spotlight_magnification(self, shape_id, snapshot); } + DrawingState::BendingArrow { shape_id, snapshot } => { + selection::finish_arrow_bend(self, shape_id, snapshot); + } DrawingState::Drawing { tool, start_x, diff --git a/src/input/state/mouse/release/selection.rs b/src/input/state/mouse/release/selection.rs index d7643bef..ae35b847 100644 --- a/src/input/state/mouse/release/selection.rs +++ b/src/input/state/mouse/release/selection.rs @@ -67,6 +67,19 @@ pub(super) fn finish_spotlight_magnification( state.record_spotlight_magnification_change(shape_id, snapshot, after); } +/// Commits one bend drag as a single undo entry. +/// +/// Shares the commit with `finish_active_arrow_bend`, which anything that +/// mutates the arrow mid-gesture has to run first — a release and an +/// interrupted gesture must record the drag the same way. +pub(super) fn finish_arrow_bend( + state: &mut InputState, + shape_id: ShapeId, + snapshot: ShapeSnapshot, +) { + state.commit_arrow_bend(shape_id, snapshot); +} + pub(super) fn finish_text_resize( state: &mut InputState, shape_id: ShapeId, diff --git a/src/input/state/render.rs b/src/input/state/render.rs index 2de5e776..ed110ad0 100644 --- a/src/input/state/render.rs +++ b/src/input/state/render.rs @@ -86,6 +86,7 @@ impl InputState { arrow_length: self.arrow_length, arrow_angle: self.arrow_angle, arrow_head_at_end: self.arrow_head_at_end, + arrow_style: self.arrow_style, arrow_label: if *tool == Tool::Arrow { self.next_arrow_label() } else { diff --git a/src/input/state/tests/arrow_labels.rs b/src/input/state/tests/arrow_labels.rs index 78f5dfaf..e4ca1025 100644 --- a/src/input/state/tests/arrow_labels.rs +++ b/src/input/state/tests/arrow_labels.rs @@ -1,6 +1,6 @@ use super::*; -use crate::draw::ArrowLabel; +use crate::draw::{ArrowLabel, ArrowStyle}; use crate::input::{BOARD_ID_BLACKBOARD, BOARD_ID_WHITEBOARD}; fn arrow_with_label(value: u32, font_descriptor: &FontDescriptor) -> Shape { @@ -19,6 +19,8 @@ fn arrow_with_label(value: u32, font_descriptor: &FontDescriptor) -> Shape { arrow_length: 10.0, arrow_angle: 30.0, head_at_end: true, + style: ArrowStyle::Standard, + bend: 0.0, label: Some(ArrowLabel { value, size: 12.0, diff --git a/src/input/state/tests/erase.rs b/src/input/state/tests/erase.rs index 3e8287e5..0be8d674 100644 --- a/src/input/state/tests/erase.rs +++ b/src/input/state/tests/erase.rs @@ -1,4 +1,5 @@ use super::*; +use crate::draw::ArrowStyle; #[test] fn erase_stroke_samples_sparse_path() { @@ -200,6 +201,8 @@ fn erase_stroke_hits_various_shapes() { arrow_length: 20.0, arrow_angle: 30.0, head_at_end: true, + style: ArrowStyle::Standard, + bend: 0.0, label: None, }, vec![(0, 90), (100, 90)], diff --git a/src/input/state/tests/properties_panel.rs b/src/input/state/tests/properties_panel.rs index 0220cb97..d16ef19b 100644 --- a/src/input/state/tests/properties_panel.rs +++ b/src/input/state/tests/properties_panel.rs @@ -1,4 +1,5 @@ use super::*; +use crate::draw::ArrowStyle; use crate::input::BOARD_ID_WHITEBOARD; use crate::util::Rect; @@ -340,6 +341,8 @@ fn adjust_arrow_length_entry_clamps_to_max_and_refreshes_panel_value() { arrow_length: 49.0, arrow_angle: 30.0, head_at_end: true, + style: ArrowStyle::Standard, + bend: 0.0, label: None, }); state.set_selection(vec![shape_id]); diff --git a/src/input/state/tests/spotlight.rs b/src/input/state/tests/spotlight.rs index bed7966c..ecd22ab6 100644 --- a/src/input/state/tests/spotlight.rs +++ b/src/input/state/tests/spotlight.rs @@ -458,9 +458,14 @@ fn a_wheel_gesture_never_commits_against_a_different_page() { ); assert_eq!(magnification_of(&state, id), 2.25); - // The page switch goes through a real action, which flushes first, so the - // entry lands on the page it belongs to. - state.handle_action(Action::PageNew); + // The page switch goes through the ordinary configured-key route. Bound + // keys enter `route_action` directly rather than calling `handle_action`, + // so the shared action preflight has to flush the wheel gesture there. + state.modifiers.ctrl = true; + state.modifiers.alt = true; + state.on_key_press(Key::Char('n')); + state.modifiers.ctrl = false; + state.modifiers.alt = false; assert_ne!( state.boards.active_page_index(), 0, diff --git a/src/input/state/tests/tool_controls.rs b/src/input/state/tests/tool_controls.rs index 9e53e236..24857c10 100644 --- a/src/input/state/tests/tool_controls.rs +++ b/src/input/state/tests/tool_controls.rs @@ -1,6 +1,6 @@ use super::*; use crate::config::{PresenterToolBehavior, PresetToolStatesConfig, ToolPresetConfig}; -use crate::draw::BlurStyle; +use crate::draw::{ArrowStyle, BlurStyle}; use crate::input::{DragBinding, DragToolBindings, PerToolDrawingSettings}; use crate::ui::toolbar::{ToolContext, ToolOptionsKind, ToolbarEvent, ToolbarSnapshot}; @@ -2339,3 +2339,97 @@ fn a_quick_color_recolor_queues_the_write_without_touching_the_file_itself() { ); }); } + +#[test] +fn cycling_arrow_style_with_nothing_selected_only_moves_the_next_arrow() { + let mut state = create_test_input_state(); + let existing = state.boards.active_frame_mut().add_shape(Shape::Arrow { + x1: 0, + y1: 0, + x2: 100, + y2: 0, + color: state.current_color, + thick: 4.0, + arrow_length: 20.0, + arrow_angle: 30.0, + head_at_end: true, + style: ArrowStyle::Standard, + bend: 0.0, + label: None, + }); + + state.handle_action(Action::CycleArrowStyle); + + assert_eq!(state.arrow_style, ArrowStyle::Pointy); + match &state + .boards + .active_frame() + .shape(existing) + .expect("arrow") + .shape + { + Shape::Arrow { style, .. } => assert_eq!( + *style, + ArrowStyle::Standard, + "an unselected arrow must not be restyled" + ), + other => panic!("expected arrow, got {other:?}"), + } +} + +#[test] +fn cycling_arrow_style_with_arrows_selected_restyles_them_instead() { + let mut state = create_test_input_state(); + let arrow = state.boards.active_frame_mut().add_shape(Shape::Arrow { + x1: 0, + y1: 0, + x2: 100, + y2: 0, + color: state.current_color, + thick: 4.0, + arrow_length: 20.0, + arrow_angle: 30.0, + head_at_end: true, + style: ArrowStyle::Standard, + bend: 0.0, + label: None, + }); + state.set_selection(vec![arrow]); + + state.handle_action(Action::CycleArrowStyle); + + match &state + .boards + .active_frame() + .shape(arrow) + .expect("arrow") + .shape + { + Shape::Arrow { style, .. } => assert_eq!(*style, ArrowStyle::Pointy), + other => panic!("expected arrow, got {other:?}"), + } + assert_eq!( + state.arrow_style, + ArrowStyle::Standard, + "restyling a selection must not also move the next-arrow default" + ); +} + +#[test] +fn cycling_arrow_style_with_a_non_arrow_selected_falls_back_to_the_default() { + let mut state = create_test_input_state(); + let rect = state.boards.active_frame_mut().add_shape(Shape::Rect { + x: 0, + y: 0, + w: 20, + h: 20, + fill: false, + color: state.current_color, + thick: 2.0, + }); + state.set_selection(vec![rect]); + + state.handle_action(Action::CycleArrowStyle); + + assert_eq!(state.arrow_style, ArrowStyle::Pointy); +} diff --git a/src/input/state/tests/transform.rs b/src/input/state/tests/transform.rs index f7426682..f84480a2 100644 --- a/src/input/state/tests/transform.rs +++ b/src/input/state/tests/transform.rs @@ -366,3 +366,133 @@ fn restore_selection_snapshots_reverts_translation() { _ => panic!("Expected text shape"), } } + +#[test] +fn resizing_a_curved_arrow_keeps_its_style_and_curvature() { + // `scale_shape` rebuilds `Shape::Arrow` field by field, so a field left out + // there silently resets on every resize. `style` has to survive untouched; + // `bend` has to survive as an *arc*, which a non-uniform scale means is not + // the same as surviving as a number. + let mut state = create_test_input_state(); + let shape_id = state.boards.active_frame_mut().add_shape(Shape::Arrow { + x1: 0, + y1: 0, + x2: 100, + y2: 0, + color: state.current_color, + thick: 4.0, + arrow_length: 20.0, + arrow_angle: 30.0, + head_at_end: true, + style: crate::draw::ArrowStyle::Curved, + bend: 0.4, + label: None, + }); + + state.set_selection(vec![shape_id]); + let original_bounds = state + .selection_bounds() + .expect("selection should have bounds"); + let snapshots = state.capture_resize_selection_snapshots(); + + state.apply_selection_resize( + SelectionHandle::BottomRight, + &original_bounds, + 100, + 60, + &snapshots, + ); + + let frame = state.boards.active_frame(); + match &frame.shape(shape_id).expect("arrow").shape { + Shape::Arrow { + style, + bend, + x1, + x2, + .. + } => { + assert_eq!( + *style, + crate::draw::ArrowStyle::Curved, + "resize reset the style" + ); + assert!(*bend > 0.0, "resize reset the bend"); + assert!(x2 - x1 > 100, "test setup should have widened the arrow"); + } + other => panic!("expected arrow, got {other:?}"), + } +} + +#[test] +fn stretching_a_flat_curved_arrow_downward_grows_its_arc() { + // A horizontal curved arrow's height is almost entirely its arc. Dragging + // the bottom handle does not lengthen the chord, so a bend copied through + // unchanged keeps exactly the bulge it had and the selection refuses to + // follow the pointer — the arrow is the one shape a vertical resize cannot + // move. Scaling the arc itself is what makes the handle mean something. + let mut state = create_test_input_state(); + let shape_id = state.boards.active_frame_mut().add_shape(Shape::Arrow { + x1: 0, + y1: 200, + x2: 300, + y2: 200, + color: state.current_color, + thick: 4.0, + arrow_length: 20.0, + arrow_angle: 30.0, + head_at_end: true, + style: crate::draw::ArrowStyle::Curved, + bend: 0.3, + label: None, + }); + + state.set_selection(vec![shape_id]); + let original_bounds = state + .selection_bounds() + .expect("selection should have bounds"); + let snapshots = state.capture_resize_selection_snapshots(); + + state.apply_selection_resize( + SelectionHandle::Bottom, + &original_bounds, + 0, + original_bounds.height, + &snapshots, + ); + + let resized = state + .selection_bounds() + .expect("selection should still have bounds"); + // Doubling the drag height should roughly double the box. Exactness is not + // the claim — the endpoints round to whole pixels and the arrowhead adds a + // few of its own — but "grew by most of the drag" separates a working + // handle from one that does nothing. + assert!( + resized.height >= original_bounds.height * 2 - 4, + "vertical resize did not carry the arc: {} -> {}", + original_bounds.height, + resized.height + ); + + match &state + .boards + .active_frame() + .shape(shape_id) + .expect("arrow") + .shape + { + Shape::Arrow { bend, x1, x2, .. } => { + assert!( + *bend > 0.3, + "bend should have grown with the height, got {bend}" + ); + assert_eq!( + x2 - x1, + 300, + "a vertical resize must not have moved the endpoints" + ); + } + other => panic!("expected arrow, got {other:?}"), + } +} diff --git a/src/input/tool/drawing.rs b/src/input/tool/drawing.rs index 7e68027a..b4d584ba 100644 --- a/src/input/tool/drawing.rs +++ b/src/input/tool/drawing.rs @@ -1,5 +1,7 @@ use crate::draw::shape::{bounding_box_for_blur, bounding_box_for_eraser, bounding_box_for_points}; -use crate::draw::{ArrowLabel, BlurRectParams, BlurStyle, Color, EraserBrush, EraserKind, Shape}; +use crate::draw::{ + ArrowLabel, ArrowStyle, BlurRectParams, BlurStyle, Color, EraserBrush, EraserKind, Shape, +}; use crate::input::tool::{ EraserMode, Tool, ToolDrawingBehavior, ToolPathKind, ToolPressureBehavior, }; @@ -7,6 +9,21 @@ use crate::util::{self, Rect}; pub(crate) const PROVISIONAL_POLYGON_DAMAGE_PADDING: i32 = 2; +/// Bend a freshly drawn arrow starts with. +/// +/// Only `Curved` gets one: a curved arrow created dead straight would look +/// exactly like a standard one, so choosing the style would appear to do +/// nothing until the bend handle was found. Every other style ignores the +/// field, and storing zero there keeps a later switch to `Curved` from +/// inheriting a bend the user never asked for. +fn initial_arrow_bend(style: ArrowStyle) -> f64 { + if style.is_curved() { + util::DEFAULT_ARROW_BEND + } else { + 0.0 + } +} + mod polygon; /// Immutable inputs needed to turn one completed drag into an app-level outcome. @@ -25,6 +42,7 @@ pub(crate) struct ToolStrokeSnapshot { pub(crate) arrow_length: f64, pub(crate) arrow_angle: f64, pub(crate) arrow_head_at_end: bool, + pub(crate) arrow_style: ArrowStyle, pub(crate) arrow_label: Option, pub(crate) step_marker_label: crate::draw::StepMarkerLabel, pub(crate) eraser_mode: EraserMode, @@ -73,6 +91,7 @@ pub(crate) struct ProvisionalToolSnapshot<'a> { pub(crate) arrow_length: f64, pub(crate) arrow_angle: f64, pub(crate) arrow_head_at_end: bool, + pub(crate) arrow_style: ArrowStyle, pub(crate) arrow_label: Option, pub(crate) step_marker_label: Option, } @@ -181,6 +200,8 @@ impl Tool { arrow_length: snapshot.arrow_length, arrow_angle: snapshot.arrow_angle, head_at_end: snapshot.arrow_head_at_end, + style: snapshot.arrow_style, + bend: initial_arrow_bend(snapshot.arrow_style), label: snapshot.arrow_label, }) } @@ -328,6 +349,8 @@ impl Tool { arrow_length: snapshot.arrow_length, arrow_angle: snapshot.arrow_angle, head_at_end: snapshot.arrow_head_at_end, + style: snapshot.arrow_style, + bend: initial_arrow_bend(snapshot.arrow_style), label: snapshot.arrow_label, }), ToolDrawingBehavior::BlurRect => { diff --git a/src/session/snapshot/apply.rs b/src/session/snapshot/apply.rs index 6708be7e..3519c1ec 100644 --- a/src/session/snapshot/apply.rs +++ b/src/session/snapshot/apply.rs @@ -132,6 +132,9 @@ pub(crate) fn apply_tool_state_snapshot(input: &mut InputState, tool_state: Tool if let Some(head_at_end) = tool_state.arrow_head_at_end { input.arrow_head_at_end = head_at_end; } + if let Some(style) = tool_state.arrow_style { + let _ = input.set_arrow_style(style); + } if let Some(label_enabled) = tool_state.arrow_label_enabled { input.arrow_label_enabled = label_enabled; } diff --git a/src/session/snapshot/tests.rs b/src/session/snapshot/tests.rs index 2b197195..c13c4067 100644 --- a/src/session/snapshot/tests.rs +++ b/src/session/snapshot/tests.rs @@ -13,7 +13,7 @@ use super::types::{ }; use super::{load_snapshot, save_snapshot}; use crate::draw::frame::{ShapeSnapshot, UndoAction}; -use crate::draw::{Color, FontDescriptor, Frame, Shape}; +use crate::draw::{ArrowStyle, Color, FontDescriptor, Frame, Shape}; use crate::input::EraserMode; use crate::session::options::{CompressionMode, SessionOptions}; use crate::test_temp::tempdir; @@ -82,6 +82,7 @@ fn sample_tool_state() -> ToolStateSnapshot { arrow_length: 20.0, arrow_angle: 30.0, arrow_head_at_end: Some(false), + arrow_style: None, arrow_label_enabled: Some(false), polygon_sides: crate::draw::REGULAR_POLYGON_DEFAULT_SIDES, board_previous_color: None, @@ -280,6 +281,51 @@ fn saved_session_round_trips_the_starting_spotlight_magnification() { ); } +#[test] +fn saved_session_round_trips_the_arrow_style() { + // `restore_tool_state` defaults on, so a style picked for the next arrow is + // expected to still be picked after a restart. Leaving it out of the + // snapshot silently resets it to Standard on every launch. + let temp = tempdir().unwrap(); + let mut options = SessionOptions::new(temp.path().to_path_buf(), "arrow-style-tool-state"); + options.persist_transparent = true; + options.restore_tool_state = true; + options.compression = CompressionMode::Off; + let mut tool_state = sample_tool_state(); + tool_state.arrow_style = Some(ArrowStyle::Curved); + let snapshot = SessionSnapshot { + tool_state: Some(tool_state), + ..sample_snapshot() + }; + + save_snapshot(&snapshot, &options).expect("save arrow tool state"); + let restored = load_snapshot(&options) + .expect("load arrow tool state") + .expect("saved session exists"); + + assert_eq!( + restored.tool_state.and_then(|state| state.arrow_style), + Some(ArrowStyle::Curved) + ); +} + +#[test] +fn a_session_written_before_arrow_styles_restores_the_configured_default() { + // Sessions on disk predate the field. They must not deserialize as an error + // and must not pin Standard over a configured default either — an absent + // style has to stay absent so `apply_tool_state_snapshot` leaves whatever + // the config seeded in place. + let payload = serde_json::to_value(sample_tool_state()).expect("serialize tool state"); + assert!( + payload.get("arrow_style").is_none(), + "a None style should not be written at all, got {payload:#}" + ); + + let restored: ToolStateSnapshot = + serde_json::from_value(payload).expect("legacy tool state should still load"); + assert_eq!(restored.arrow_style, None); +} + #[test] fn save_snapshot_respects_auto_compression_threshold() { let temp = tempdir().unwrap(); diff --git a/src/session/snapshot/types.rs b/src/session/snapshot/types.rs index 0b68c1c2..c88759b9 100644 --- a/src/session/snapshot/types.rs +++ b/src/session/snapshot/types.rs @@ -1,6 +1,6 @@ use crate::config::Config; use crate::draw::{ - BlurStyle, Color, EraserKind, FontDescriptor, Frame, REGULAR_POLYGON_DEFAULT_SIDES, + ArrowStyle, BlurStyle, Color, EraserKind, FontDescriptor, Frame, REGULAR_POLYGON_DEFAULT_SIDES, clamp_regular_sides, }; use crate::input::{EraserMode, InputState, PerToolDrawingSettings, Tool}; @@ -91,6 +91,10 @@ pub struct ToolStateSnapshot { pub arrow_angle: f64, #[serde(default)] pub arrow_head_at_end: Option, + /// Style copied into the next arrow drawn. Absent in sessions written + /// before arrow styles existed, which restore the configured default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub arrow_style: Option, #[serde(default)] pub arrow_label_enabled: Option, #[serde(default = "default_polygon_sides_for_snapshot")] @@ -121,6 +125,7 @@ impl ToolStateSnapshot { arrow_length: input.arrow_length, arrow_angle: input.arrow_angle, arrow_head_at_end: Some(input.arrow_head_at_end), + arrow_style: Some(input.arrow_style), arrow_label_enabled: Some(input.arrow_label_enabled), polygon_sides: input.polygon_sides, board_previous_color: input.board_previous_color, @@ -159,6 +164,7 @@ impl ToolStateSnapshot { arrow_length: config.arrow.length, arrow_angle: config.arrow.angle_degrees, arrow_head_at_end: Some(config.arrow.head_at_end), + arrow_style: Some(config.arrow.style), arrow_label_enabled: Some(false), polygon_sides: clamp_regular_sides(config.drawing.polygon_sides), board_previous_color: None, diff --git a/src/session/storage/tests.rs b/src/session/storage/tests.rs index 84bc580f..b85f6208 100644 --- a/src/session/storage/tests.rs +++ b/src/session/storage/tests.rs @@ -63,6 +63,7 @@ fn sample_tool_state() -> ToolStateSnapshot { arrow_length: 20.0, arrow_angle: 30.0, arrow_head_at_end: Some(false), + arrow_style: None, arrow_label_enabled: Some(false), polygon_sides: crate::draw::REGULAR_POLYGON_DEFAULT_SIDES, board_previous_color: None, diff --git a/src/session/tests/limits.rs b/src/session/tests/limits.rs index 7214d8f3..b6a98454 100644 --- a/src/session/tests/limits.rs +++ b/src/session/tests/limits.rs @@ -44,6 +44,7 @@ fn save_snapshot_errors_when_payload_exceeds_max_file_size() { arrow_length: 20.0, arrow_angle: 30.0, arrow_head_at_end: Some(false), + arrow_style: None, arrow_label_enabled: Some(false), polygon_sides: crate::draw::REGULAR_POLYGON_DEFAULT_SIDES, board_previous_color: None, diff --git a/src/session/tests/snapshot.rs b/src/session/tests/snapshot.rs index 1fd02979..788c54e3 100644 --- a/src/session/tests/snapshot.rs +++ b/src/session/tests/snapshot.rs @@ -330,6 +330,7 @@ fn apply_legacy_snapshot_preserves_config_initialized_font_descriptor() { arrow_length: 20.0, arrow_angle: 30.0, arrow_head_at_end: Some(false), + arrow_style: None, arrow_label_enabled: Some(false), polygon_sides: crate::draw::REGULAR_POLYGON_DEFAULT_SIDES, board_previous_color: None, @@ -384,6 +385,7 @@ fn apply_snapshot_clamps_restored_per_tool_thicknesses() { arrow_length: 20.0, arrow_angle: 30.0, arrow_head_at_end: Some(false), + arrow_style: None, arrow_label_enabled: Some(false), polygon_sides: crate::draw::REGULAR_POLYGON_DEFAULT_SIDES, board_previous_color: None, @@ -433,6 +435,7 @@ fn apply_legacy_snapshot_uses_font_derived_step_marker_size() { arrow_length: 20.0, arrow_angle: 30.0, arrow_head_at_end: Some(false), + arrow_style: None, arrow_label_enabled: Some(false), polygon_sides: crate::draw::REGULAR_POLYGON_DEFAULT_SIDES, board_previous_color: None, diff --git a/src/toolbar_gtk/view/top_bar/style_pill.rs b/src/toolbar_gtk/view/top_bar/style_pill.rs index 86d30829..0192f4d8 100644 --- a/src/toolbar_gtk/view/top_bar/style_pill.rs +++ b/src/toolbar_gtk/view/top_bar/style_pill.rs @@ -288,7 +288,8 @@ impl TopBar { } })); } - model::StylePillControl::SelectionCycle(_) => { + model::StylePillControl::SelectionCycle(_) + | model::StylePillControl::ArrowStyleCycle => { let button = pill_button( &control.required_value_text(snapshot), sz(STYLE_SEL_VALUE_W), diff --git a/src/toolbar_gtk/view/top_bar/tests.rs b/src/toolbar_gtk/view/top_bar/tests.rs index a9cfb55e..5718467a 100644 --- a/src/toolbar_gtk/view/top_bar/tests.rs +++ b/src/toolbar_gtk/view/top_bar/tests.rs @@ -879,10 +879,11 @@ fn assert_gtk_style_widget( .clone() .downcast::() .unwrap_or_else(|_| panic!("{id} is a button")); - // Docked selection cycle buttons show the live value; plain - // buttons show their label. + // Cycle buttons show the live value they step; plain buttons show + // their label. let expected_text = match control { - model::StylePillControl::SelectionCycle(_) => { + model::StylePillControl::SelectionCycle(_) + | model::StylePillControl::ArrowStyleCycle => { control.value_text(snapshot).expect("cycle value text") } _ => control.label(snapshot).into_owned(), @@ -1405,10 +1406,11 @@ fn style_pill_spec_matches_builtin_tree_across_morph_states() { ); } (model::StylePillRole::Button, W::TextButton { label, style }) => { - // Docked selection cycle buttons show the live - // value; plain buttons show their label. + // Cycle buttons show the live value they step; + // plain buttons show their label. let expected_text = match control { - model::StylePillControl::SelectionCycle(_) => { + model::StylePillControl::SelectionCycle(_) + | model::StylePillControl::ArrowStyleCycle => { control.value_text(&snapshot).expect("cycle value text") } _ => control.label(&snapshot).into_owned(), diff --git a/src/ui.rs b/src/ui.rs index b65caa88..e25c41fb 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -1,6 +1,7 @@ pub mod toolbar; pub mod anim; +mod arrow_bend_handle; mod board_picker; mod color_picker_popup; mod command_palette; @@ -27,6 +28,7 @@ pub mod theme; mod toasts; mod tour; +pub(crate) use arrow_bend_handle::render_arrow_bend_handle; pub use board_picker::render_board_picker; pub use color_picker_popup::{color_picker_popup_visual_geometry, render_color_picker_popup}; pub use command_palette::{command_palette_visual_geometry, render_command_palette}; diff --git a/src/ui/arrow_bend_handle.rs b/src/ui/arrow_bend_handle.rs new file mode 100644 index 00000000..4e89c979 --- /dev/null +++ b/src/ui/arrow_bend_handle.rs @@ -0,0 +1,31 @@ +//! The bend handle drawn on a selected curved arrow. + +use crate::util::Rect; + +/// Border width of the handle, matching the selection box's handles so the two +/// read as one family of grips. +const HANDLE_BORDER: f64 = 1.5; + +/// Draws the bend grip centred in `rect`. +/// +/// Round rather than square, which is what separates it at a glance from the +/// eight square resize handles it sits among: those scale the whole selection, +/// this one reshapes a single arc. +pub(crate) fn render_arrow_bend_handle(ctx: &cairo::Context, rect: Rect) { + let radius = f64::from(rect.width.min(rect.height)) / 2.0; + if radius <= 0.0 { + return; + } + let cx = f64::from(rect.x) + f64::from(rect.width) / 2.0; + let cy = f64::from(rect.y) + f64::from(rect.height) / 2.0; + + let _ = ctx.save(); + ctx.new_path(); + ctx.arc(cx, cy, radius, 0.0, std::f64::consts::TAU); + ctx.set_source_rgba(1.0, 1.0, 1.0, 0.95); + let _ = ctx.fill_preserve(); + ctx.set_source_rgba(0.2, 0.45, 1.0, 0.95); + ctx.set_line_width(HANDLE_BORDER); + let _ = ctx.stroke(); + let _ = ctx.restore(); +} diff --git a/src/ui/help_overlay/sections/builder/sections.rs b/src/ui/help_overlay/sections/builder/sections.rs index 623f5755..fd3b5e71 100644 --- a/src/ui/help_overlay/sections/builder/sections.rs +++ b/src/ui/help_overlay/sections/builder/sections.rs @@ -108,6 +108,7 @@ pub(super) fn build_main_sections( action_row(bindings, Action::SelectRectTool, "Ctrl+Drag"), action_row(bindings, Action::SelectEllipseTool, "Tab+Drag"), action_row(bindings, Action::SelectArrowTool, "Ctrl+Shift+Drag"), + action_row(bindings, Action::CycleArrowStyle, NOT_BOUND_LABEL), action_row(bindings, Action::SelectBlurTool, NOT_BOUND_LABEL), action_row(bindings, Action::ToggleHighlightTool, NOT_BOUND_LABEL), action_row(bindings, Action::SelectMarkerTool, NOT_BOUND_LABEL), diff --git a/src/ui/status/bar/helpers.rs b/src/ui/status/bar/helpers.rs index 759f75fd..88b0c312 100644 --- a/src/ui/status/bar/helpers.rs +++ b/src/ui/status/bar/helpers.rs @@ -40,6 +40,7 @@ pub(super) fn tool_display_name(input_state: &InputState, tool: Tool) -> &'stati DrawingState::MovingSelection { .. } => "Move", DrawingState::Selecting { .. } => "Select", DrawingState::ResizingText { .. } | DrawingState::ResizingSelection { .. } => "Resize", + DrawingState::BendingArrow { .. } => "Bend", DrawingState::AdjustingSpotlightMagnification { .. } => "Magnify", DrawingState::PendingTextClick { .. } | DrawingState::Idle => tool_action_label(tool), } diff --git a/src/ui/toolbar/apply/mod.rs b/src/ui/toolbar/apply/mod.rs index b02a4534..f27a7b1d 100644 --- a/src/ui/toolbar/apply/mod.rs +++ b/src/ui/toolbar/apply/mod.rs @@ -22,6 +22,11 @@ impl InputState { // gesture is closed here as well. A wheel adjustment must not outlive // the frame it started on: shape ids restart per frame. self.flush_spotlight_magnification_gesture(); + // Same barrier for a held bend. Touch, tablet, and the GTK toolbar all + // deliver events while a pointer-held gesture is running, and Undo All + // can delete the arrow outright — after which the release finds no + // shape and drops the bend without a trace. + self.finish_active_arrow_bend(); let changed = self.apply_toolbar_event_inner(event); self.note_toolbar_shortcut_slow_path(coach_action, changed); changed @@ -71,6 +76,7 @@ impl InputState { ToolbarEvent::ToggleArrowLabels(enable) => { self.apply_toolbar_toggle_arrow_labels(enable) } + ToolbarEvent::CycleArrowStyle => self.apply_toolbar_cycle_arrow_style(), ToolbarEvent::ResetArrowLabelCounter => self.apply_toolbar_reset_arrow_label_counter(), ToolbarEvent::ResetStepMarkerCounter => self.apply_toolbar_reset_step_marker_counter(), ToolbarEvent::SetUndoDelay(delay_secs) => self.apply_toolbar_set_undo_delay(delay_secs), diff --git a/src/ui/toolbar/apply/tools.rs b/src/ui/toolbar/apply/tools.rs index 3402ca82..aad318b7 100644 --- a/src/ui/toolbar/apply/tools.rs +++ b/src/ui/toolbar/apply/tools.rs @@ -107,6 +107,13 @@ impl InputState { self.set_arrow_label_enabled(enable) } + /// The pill button targets the next arrow only. Restyling a selection is + /// the keyboard action's job, which routes on what is selected; a pill + /// that silently retargeted itself would make the label it shows a lie. + pub(super) fn apply_toolbar_cycle_arrow_style(&mut self) -> bool { + self.cycle_arrow_style() + } + pub(super) fn apply_toolbar_reset_arrow_label_counter(&mut self) -> bool { self.reset_arrow_label_counter() } diff --git a/src/ui/toolbar/events.rs b/src/ui/toolbar/events.rs index e981a62a..336951ad 100644 --- a/src/ui/toolbar/events.rs +++ b/src/ui/toolbar/events.rs @@ -95,6 +95,8 @@ pub enum ToolbarEvent { SetPolygonSides(u8), NudgePolygonSides(i8), ToggleArrowLabels(bool), + /// Step the next arrow's style through the four arrow styles. + CycleArrowStyle, ResetArrowLabelCounter, ResetStepMarkerCounter, SetUndoDelay(f64), diff --git a/src/ui/toolbar/model/event_policy.rs b/src/ui/toolbar/model/event_policy.rs index db3d704c..974d7ec7 100644 --- a/src/ui/toolbar/model/event_policy.rs +++ b/src/ui/toolbar/model/event_policy.rs @@ -508,6 +508,7 @@ fn persistence_for_event(event: &ToolbarEvent) -> ToolbarPersistence { | ToolbarEvent::SetPolygonSides(_) | ToolbarEvent::NudgePolygonSides(_) | ToolbarEvent::ToggleArrowLabels(_) + | ToolbarEvent::CycleArrowStyle | ToolbarEvent::ResetArrowLabelCounter | ToolbarEvent::ResetStepMarkerCounter | ToolbarEvent::SetUndoDelay(_) @@ -605,6 +606,11 @@ mod tests { use super::*; use crate::draw::color::RED; + #[test] + fn next_arrow_style_event_has_no_selection_aware_action_equivalent() { + assert_eq!(action_for_event(&ToolbarEvent::CycleArrowStyle), None); + } + #[test] fn duplicate_quick_colors_keep_the_clicked_binding_identity() { let first = ToolbarEvent::SetQuickColor { diff --git a/src/ui/toolbar/model/style_pill.rs b/src/ui/toolbar/model/style_pill.rs index cc8db11f..b28e9a4a 100644 --- a/src/ui/toolbar/model/style_pill.rs +++ b/src/ui/toolbar/model/style_pill.rs @@ -96,6 +96,11 @@ pub(crate) enum StylePillControl { SpotlightMagnificationSlider, /// Shape fill toggle. FillToggle, + /// Arrow style cycle button, showing the style the next arrow will use. + /// Clicking steps through [`ArrowStyle::ALL`]. A four-way choice does not + /// fit the two-half segmented control, and cycling is already how the + /// keyboard action and the docked selection entry step it. + ArrowStyleCycle, /// Arrow auto-number toggle. AutoNumberToggle, /// Reset the arrow/step counter; tooltip carries the next number. @@ -163,6 +168,7 @@ pub(crate) const fn selection_kind_slug(kind: SelectionPropertyKind) -> &'static SelectionPropertyKind::Fill => "fill", SelectionPropertyKind::FontSize => "font-size", SelectionPropertyKind::ArrowHead => "arrow-head", + SelectionPropertyKind::ArrowStyle => "arrow-style", SelectionPropertyKind::ArrowLength => "arrow-length", SelectionPropertyKind::ArrowAngle => "arrow-angle", SelectionPropertyKind::TextBackground => "text-background", @@ -177,6 +183,7 @@ pub(crate) const fn selection_control_for_kind(kind: SelectionPropertyKind) -> S SelectionPropertyKind::Color | SelectionPropertyKind::Fill | SelectionPropertyKind::ArrowHead + | SelectionPropertyKind::ArrowStyle | SelectionPropertyKind::TextBackground => StylePillControl::SelectionCycle(kind), SelectionPropertyKind::Thickness | SelectionPropertyKind::FontSize @@ -261,6 +268,9 @@ impl StylePillSpec { if context.show_fill_toggle { controls.push(StylePillControl::FillToggle); } + if context.tool_options_kind == ToolOptionsKind::Arrow { + controls.push(StylePillControl::ArrowStyleCycle); + } if context.show_arrow_labels { controls.push(StylePillControl::AutoNumberToggle); if snapshot.arrow_label_enabled { diff --git a/src/ui/toolbar/model/style_pill/control.rs b/src/ui/toolbar/model/style_pill/control.rs index 5ed701b1..4652f86c 100644 --- a/src/ui/toolbar/model/style_pill/control.rs +++ b/src/ui/toolbar/model/style_pill/control.rs @@ -12,6 +12,7 @@ impl StylePillControl { Cow::Borrowed("top.style.spotlight-magnification") } Self::FillToggle => Cow::Borrowed("top.style.fill"), + Self::ArrowStyleCycle => Cow::Borrowed("top.style.arrow-style"), Self::AutoNumberToggle => Cow::Borrowed("top.style.auto-number"), // Distinct per counter: classic mode (context_aware_ui = false) // can materialize both resets in one spec, and the frontends @@ -41,7 +42,7 @@ impl StylePillControl { | Self::FontSizeSlider => StylePillRole::Slider, Self::ThicknessValue | Self::FontSizeValue => StylePillRole::Value, Self::FillToggle | Self::AutoNumberToggle => StylePillRole::Toggle, - Self::CounterReset(_) => StylePillRole::Button, + Self::CounterReset(_) | Self::ArrowStyleCycle => StylePillRole::Button, Self::FontFamilySegment | Self::EraserModeSegment => StylePillRole::Segmented, Self::SelectionCycle(_) => StylePillRole::Button, Self::SelectionStepper(_) => StylePillRole::Stepper, @@ -71,6 +72,7 @@ impl StylePillControl { Self::AutoNumberToggle => { ToolbarEvent::ToggleArrowLabels(!snapshot.arrow_label_enabled) } + Self::ArrowStyleCycle => ToolbarEvent::CycleArrowStyle, Self::CounterReset(StylePillCounter::Arrow) => ToolbarEvent::ResetArrowLabelCounter, Self::CounterReset(StylePillCounter::Step) => ToolbarEvent::ResetStepMarkerCounter, // The numerals open the precise-entry popup on the overlay. @@ -160,6 +162,7 @@ impl StylePillControl { Self::FontSizeSlider | Self::FontSizeValue => { Some(format!("{:.0}pt", snapshot.font_size)) } + Self::ArrowStyleCycle => Some(snapshot.arrow_style.label().to_string()), Self::SelectionCycle(kind) | Self::SelectionStepper(kind) => { selection_entry(snapshot, kind).map(|entry| entry.value.clone()) } @@ -226,6 +229,7 @@ impl StylePillControl { Self::ThicknessValue => Cow::Owned(format!("{:.0}px", snapshot.thickness)), Self::FontSizeValue => Cow::Owned(format!("{:.0}pt", snapshot.font_size)), Self::FillToggle => Cow::Borrowed(action_short_label(Action::ToggleFill)), + Self::ArrowStyleCycle => Cow::Borrowed("Arrow style"), Self::AutoNumberToggle => Cow::Borrowed("Auto-number"), Self::CounterReset(_) => Cow::Borrowed("Reset"), Self::FontFamilySegment => Cow::Borrowed("Font"), @@ -262,6 +266,10 @@ impl StylePillControl { .binding_hints .binding_for_action(Action::ToggleFill), )), + Self::ArrowStyleCycle => Some(format!( + "Next arrow style: {}", + snapshot.arrow_style.label() + )), Self::AutoNumberToggle => Some("Auto-number arrows 1, 2, 3.".to_string()), Self::CounterReset(StylePillCounter::Arrow) => Some(format!( "Reset numbering to 1 (next: {})", 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 38fc9494..a7d9a8ea 100644 --- a/src/ui/toolbar/model/style_pill/tests/tool_states.rs +++ b/src/ui/toolbar/model/style_pill/tests/tool_states.rs @@ -289,6 +289,36 @@ fn arrow_state_gates_the_reset_button_on_the_toggle() { ); } +#[test] +fn next_arrow_style_control_does_not_advertise_the_selection_aware_shortcut() { + use crate::config::{Action, Shortcut}; + use crate::input::state::test_support::make_test_input_state_with_action_bindings; + use std::collections::HashMap; + + let state = make_test_input_state_with_action_bindings(HashMap::from([( + Action::CycleArrowStyle, + vec![Shortcut::parse("Ctrl+J").expect("binding")], + )])); + let hints = ToolbarBindingHints::from_input_state(&state); + assert_eq!( + hints.binding_for_action(Action::CycleArrowStyle), + Some("Ctrl+J"), + "the regression needs a real selection-aware shortcut to suppress" + ); + let mut snapshot = ToolbarSnapshot::from_input_with_bindings(&state, hints); + snapshot.active_tool = Tool::Arrow; + snapshot.tool_override = None; + snapshot.show_text_controls = false; + snapshot.show_marker_opacity_section = false; + + assert_eq!( + StylePillControl::ArrowStyleCycle + .tooltip(&snapshot) + .as_deref(), + Some("Next arrow style: Standard") + ); +} + #[test] fn step_marker_state_carries_the_step_reset() { let mut snapshot = snapshot_for_tool(Tool::StepMarker); diff --git a/src/ui/toolbar/snapshot/build.rs b/src/ui/toolbar/snapshot/build.rs index 09c96d36..8ffd458d 100644 --- a/src/ui/toolbar/snapshot/build.rs +++ b/src/ui/toolbar/snapshot/build.rs @@ -126,6 +126,7 @@ impl ToolbarSnapshot { fill_enabled: state.fill_enabled, polygon_sides: state.polygon_sides, arrow_label_enabled: state.arrow_label_enabled, + arrow_style: state.arrow_style, arrow_label_next: state.arrow_label_counter.max(1), step_marker_next: state.step_marker_counter.max(1), undo_available: frame.undo_stack_len() > 0, diff --git a/src/ui/toolbar/snapshot/types.rs b/src/ui/toolbar/snapshot/types.rs index 47907c2b..ecace1ff 100644 --- a/src/ui/toolbar/snapshot/types.rs +++ b/src/ui/toolbar/snapshot/types.rs @@ -1,6 +1,6 @@ use crate::config::QuickColorPalette; use crate::config::{ResolvedToolbarItems, ToolbarItemId, ToolbarLayoutMode, TopDisplayMode}; -use crate::draw::{Color, EraserKind, FontDescriptor}; +use crate::draw::{ArrowStyle, Color, EraserKind, FontDescriptor}; use crate::input::state::PresetFeedbackKind; use crate::input::tool::{ToolControlGroup, ToolProfile}; use crate::input::{EraserMode, Tool}; @@ -277,6 +277,8 @@ pub struct ToolbarSnapshot { pub fill_enabled: bool, pub polygon_sides: u8, pub arrow_label_enabled: bool, + /// Style copied into the next arrow drawn. + pub arrow_style: ArrowStyle, pub arrow_label_next: u32, pub step_marker_next: u32, pub undo_available: bool, diff --git a/src/util/arrow.rs b/src/util/arrow.rs index 039876e6..ffe2226a 100644 --- a/src/util/arrow.rs +++ b/src/util/arrow.rs @@ -1,3 +1,5 @@ +use crate::draw::ArrowStyle; + /// Half-width of the shaft at the tail, as a fraction of its half-width where it /// meets the arrowhead. The taper is what makes an arrow read as directional /// rather than as a plain line with a triangle stuck on the end, and a strong one @@ -141,12 +143,17 @@ impl ArrowAxis { } } -/// Calculates arrowhead triangle points matching the renderer's geometry model. +/// Calculates the arrow's single filled outline: tapered shaft plus arrowhead. /// -/// This helper must remain in sync with `render_arrow` so dirty-region bounds and -/// hit-testing stay aligned with the visual arrowhead. +/// The tail is narrower than the shoulder where the shaft meets the head, and +/// both are emitted as one closed polygon so there is no seam to show through a +/// semi-transparent color and no width step at the shoulders. +/// +/// The shoulders sit slightly forward of the head base, so the rear of the head +/// bevels into the shaft rather than running straight across it. The outline +/// stays within the head triangle that hit-testing and dirty-region bounds use. #[allow(clippy::too_many_arguments)] -pub(crate) fn calculate_arrowhead_triangle_custom( +pub(crate) fn calculate_arrow_outline( tip_x: i32, tip_y: i32, tail_x: i32, @@ -154,7 +161,7 @@ pub(crate) fn calculate_arrowhead_triangle_custom( thick: f64, arrow_length: f64, arrow_angle: f64, -) -> Option { +) -> Option { let axis = arrow_axis( tip_x, tip_y, @@ -165,25 +172,349 @@ pub(crate) fn calculate_arrowhead_triangle_custom( arrow_angle, )?; let base = axis.base(); + let notch = axis.notch(); + let (shoulder_half, tail_half) = axis.shaft_half_widths(thick); - Some(ArrowheadTriangle { - tip: axis.tip, - left: axis.offset(base, 1.0, axis.head_half_base), - right: axis.offset(base, -1.0, axis.head_half_base), + Some(ArrowOutline { + points: [ + axis.offset(axis.tail, 1.0, tail_half), + axis.offset(notch, 1.0, shoulder_half), + axis.offset(base, 1.0, axis.head_half_base), + axis.tip, + axis.offset(base, -1.0, axis.head_half_base), + axis.offset(notch, -1.0, shoulder_half), + axis.offset(axis.tail, -1.0, tail_half), + ], }) } -/// Calculates the arrow's single filled outline: tapered shaft plus arrowhead. +/// Where the dart notch of [`ArrowStyle::Pointy`] sits, as a fraction of the +/// distance from the tip back to the head base. /// -/// The tail is narrower than the shoulder where the shaft meets the head, and -/// both are emitted as one closed polygon so there is no seam to show through a -/// semi-transparent color and no width step at the shoulders. +/// This is [`HEAD_SWEEP_RATIO`] deliberately pulled past the depth that +/// constant's comment warns about: for a dart, the barbs sweeping clear of the +/// shaft *is* the shape. It still has a floor — below roughly `0.4` the barbs +/// grow long enough to read as two separate spikes with a stick between them +/// rather than as one head. +const POINTY_NOTCH_RATIO: f64 = 0.55; + +/// Samples per pixel of chord length used to walk a curved shaft. /// -/// The shoulders sit slightly forward of the head base, so the rear of the head -/// bevels into the shaft rather than running straight across it. The outline -/// stays within the head triangle that hit-testing and dirty-region bounds use. +/// One sample per eight pixels, floored and capped by the two constants below, +/// so a short arc still gets enough points to hide its facets and a +/// screen-wide one does not pay for hundreds it cannot show. +const CURVE_SAMPLES_PER_PX: f64 = 1.0 / 8.0; +const MIN_CURVE_SEGMENTS: usize = 12; +const MAX_CURVE_SEGMENTS: usize = 64; + +/// Default bend for a newly drawn [`ArrowStyle::Curved`] arrow. +/// +/// A curved arrow created with `bend` at zero would draw exactly like a +/// standard one, so picking the style would appear to do nothing. This is the +/// smallest arc that reads as deliberate rather than as a wobble. +pub(crate) const DEFAULT_ARROW_BEND: f64 = 0.25; + +/// Cap on `bend`, as a fraction of the chord length. +/// +/// The arc's furthest point sits `bend / 2` chord-lengths off the chord, so +/// this puts the bulge half a chord out — already a quarter-circle. Past it the +/// arrow stops pointing at anything recognizable. +pub(crate) const MAX_ARROW_BEND: f64 = 1.0; + +/// Clamps a bend to the range the geometry is defined over. +pub(crate) fn clamp_arrow_bend(bend: f64) -> f64 { + if bend.is_finite() { + bend.clamp(-MAX_ARROW_BEND, MAX_ARROW_BEND) + } else { + 0.0 + } +} + +/// The bend that carries a curved arrow's arc through a scale transform. +/// +/// `bend` is stored as a fraction of the chord, so a *uniform* scale needs no +/// work: chord and bulge grow together. A non-uniform one does. Dragging the +/// bottom handle of a horizontal curved arrow leaves the chord alone and so +/// leaves the bulge alone, and the arc — the only part of that arrow with any +/// height — refuses to follow the pointer. +/// +/// The fix is to scale the arc itself. A quadratic Bezier maps through an +/// affine transform by mapping its control point, and the control point's +/// offset from the chord midpoint maps through the transform's linear part +/// alone, so the anchor never enters: the caller passes the same `scale_x` and +/// `scale_y` it used on the endpoints. Projecting the scaled offset back onto +/// the new chord's normal drops any component that has rotated to lie *along* +/// the chord, which is what keeps the arc symmetric — the single-scalar `bend` +/// has nowhere to put a lopsided arc anyway. +/// +/// Endpoints are whichever pair the caller holds; they need not be tail-first. +/// Naming them backwards flips the normal on both sides of the projection, and +/// the two sign flips cancel. +pub(crate) fn scaled_arrow_bend( + old_start: (f64, f64), + old_end: (f64, f64), + new_start: (f64, f64), + new_end: (f64, f64), + bend: f64, + scale_x: f64, + scale_y: f64, +) -> f64 { + let bend = clamp_arrow_bend(bend); + if bend == 0.0 || !scale_x.is_finite() || !scale_y.is_finite() { + return bend; + } + let Some((old_perp, old_chord)) = chord_normal(old_start, old_end) else { + return bend; + }; + let Some((new_perp, new_chord)) = chord_normal(new_start, new_end) else { + return bend; + }; + let offset = ( + old_perp.0 * bend * old_chord * scale_x, + old_perp.1 * bend * old_chord * scale_y, + ); + clamp_arrow_bend((offset.0 * new_perp.0 + offset.1 * new_perp.1) / new_chord) +} + +/// Left normal of `start` -> `end` and the chord length, or `None` when the two +/// points are too close together for the direction to mean anything. +/// +/// "Left" is in screen coordinates, where y grows downward, and matches +/// `ArrowAxis::perp`. Every consumer of a signed bend measures against this one +/// normal, so the sign documented on `Shape::Arrow::bend` means the same thing +/// to the renderer, the bend handle, and a resize. +pub(crate) fn chord_normal(start: (f64, f64), end: (f64, f64)) -> Option<((f64, f64), f64)> { + let dx = end.0 - start.0; + let dy = end.1 - start.1; + let chord = (dx * dx + dy * dy).sqrt(); + if !chord.is_finite() || chord < MIN_CHORD_LENGTH { + return None; + } + Some(((dy / chord, -dx / chord), chord)) +} + +/// Shortest chord a bend is defined over. +/// +/// Below this the normal is dominated by the endpoints' own rounding to whole +/// pixels, so a recomputed bend would be noise. +const MIN_CHORD_LENGTH: f64 = 1.0; + +/// The shaft's centre line, from tail to tip. +/// +/// Straight styles carry the chord itself and allocate nothing; only +/// [`ArrowStyle::Curved`] pays for the sampled polyline. Bounds and +/// hit-testing both read this rather than walking the curve themselves, which +/// is what keeps them from drifting apart from the renderer. +#[derive(Debug, Clone)] +pub(crate) enum ArrowSpine { + Straight([(f64, f64); 2]), + Curved(Vec<(f64, f64)>), +} + +impl ArrowSpine { + pub(crate) fn points(&self) -> &[(f64, f64)] { + match self { + Self::Straight(points) => points.as_slice(), + Self::Curved(points) => points.as_slice(), + } + } +} + +/// The non-filled geometry every arrow consumer other than the renderer needs: +/// the head triangles to test and bound, plus the shaft's centre line. +#[derive(Debug, Clone)] +pub(crate) struct ArrowSkeleton { + /// Head at the tip. Aimed along the curve's end tangent when the style bends. + pub head: ArrowheadTriangle, + /// Second head at the tail. `Some` only for [`ArrowStyle::Double`]. + pub tail_head: Option, + pub spine: ArrowSpine, +} + +/// One point on a sampled quadratic Bezier, with the direction of travel there. +#[derive(Debug, Clone, Copy)] +struct CurveSample { + point: (f64, f64), + /// Unit tangent pointing from the tail toward the tip. + tangent: (f64, f64), + /// Arc length from the tail, approximated along the sampled polyline. + arc: f64, +} + +impl ArrowAxis { + /// Unit vector pointing from the tail toward the tip. + fn toward_tip(&self) -> (f64, f64) { + (-self.toward_tail.0, -self.toward_tail.1) + } + + /// Point `along` pixels from the tail toward the tip, on the chord. + fn along_from_tail(&self, along: f64) -> (f64, f64) { + let forward = self.toward_tip(); + ( + self.tail.0 + forward.0 * along, + self.tail.1 + forward.1 * along, + ) + } + + /// Half-width where the shaft meets the head, and at the tapered tail. + /// + /// Shared by every style so the taper tuning stays in one place; `Double` + /// takes only the shoulder value and keeps it the whole way. + fn shaft_half_widths(&self, thick: f64) -> (f64, f64) { + // The shaft never pokes outside the head it feeds into, which has already + // narrowed by `HEAD_SWEEP_RATIO` by the time it reaches the notch. The + // `thick * 0.6` floor on the head keeps that product above `thick / 2`, so + // the shaft still joins at its full width. + let shoulder_half = (thick / 2.0).min(self.head_half_base * HEAD_SWEEP_RATIO); + let tail_half = (shoulder_half * TAIL_TAPER_RATIO) + .max(MIN_TAIL_HALF_WIDTH) + .min(shoulder_half); + (shoulder_half, tail_half) + } + + /// Head triangle at the tip, aimed straight down the chord. + fn head_triangle(&self) -> ArrowheadTriangle { + let base = self.base(); + ArrowheadTriangle { + tip: self.tip, + left: self.offset(base, 1.0, self.head_half_base), + right: self.offset(base, -1.0, self.head_half_base), + } + } + + /// Head triangle at the tail end, for [`ArrowStyle::Double`]. + fn tail_head_triangle(&self) -> ArrowheadTriangle { + let base = self.along_from_tail(self.head_length); + ArrowheadTriangle { + tip: self.tail, + left: self.offset(base, 1.0, self.head_half_base), + right: self.offset(base, -1.0, self.head_half_base), + } + } + + /// Control point of the shaft's quadratic Bezier. + /// + /// Sits on the chord's perpendicular bisector, offset by `bend` chord + /// lengths toward the left of the tail-to-tip direction. `perp` is already + /// that left normal, so a positive bend bulges left and a negative one + /// right, matching the sign convention documented on `Shape::Arrow::bend`. + fn curve_control(&self, bend: f64) -> (f64, f64) { + let chord_len = self.chord_length(); + let mid = ( + (self.tip.0 + self.tail.0) / 2.0, + (self.tip.1 + self.tail.1) / 2.0, + ); + let offset = clamp_arrow_bend(bend) * chord_len; + (mid.0 + self.perp.0 * offset, mid.1 + self.perp.1 * offset) + } + + fn chord_length(&self) -> f64 { + let dx = self.tail.0 - self.tip.0; + let dy = self.tail.1 - self.tip.1; + (dx * dx + dy * dy).sqrt() + } + + /// Walks the shaft's Bezier from tail to tip. + /// + /// This is the only place the curve is evaluated. The renderer, the + /// dirty-region bounds, and hit-testing all consume its output, so a curved + /// arrow cannot end up drawn along one path and tested along another. + fn sample_curve(&self, bend: f64) -> Vec { + let control = self.curve_control(bend); + let segments = ((self.chord_length() * CURVE_SAMPLES_PER_PX).round() as usize) + .clamp(MIN_CURVE_SEGMENTS, MAX_CURVE_SEGMENTS); + + let mut samples = Vec::with_capacity(segments + 1); + let mut arc = 0.0; + let mut previous: Option<(f64, f64)> = None; + + for step in 0..=segments { + let t = step as f64 / segments as f64; + let inv = 1.0 - t; + let point = ( + inv * inv * self.tail.0 + 2.0 * t * inv * control.0 + t * t * self.tip.0, + inv * inv * self.tail.1 + 2.0 * t * inv * control.1 + t * t * self.tip.1, + ); + + // B'(t) = 2(1-t)(C - A) + 2t(T - C). Degenerate only if the control + // point lands exactly on both endpoints, which means a zero-length + // arrow, and `arrow_axis` has already rejected those. + let raw = ( + 2.0 * inv * (control.0 - self.tail.0) + 2.0 * t * (self.tip.0 - control.0), + 2.0 * inv * (control.1 - self.tail.1) + 2.0 * t * (self.tip.1 - control.1), + ); + let len = (raw.0 * raw.0 + raw.1 * raw.1).sqrt(); + let tangent = if len > f64::EPSILON { + (raw.0 / len, raw.1 / len) + } else { + self.toward_tip() + }; + + if let Some(prev) = previous { + let dx = point.0 - prev.0; + let dy = point.1 - prev.1; + arc += (dx * dx + dy * dy).sqrt(); + } + previous = Some(point); + + samples.push(CurveSample { + point, + tangent, + arc, + }); + } + + samples + } +} + +/// Left normal of a unit tangent, matching [`ArrowAxis::perp`]'s convention. +fn left_normal(tangent: (f64, f64)) -> (f64, f64) { + (tangent.1, -tangent.0) +} + +fn offset_by(point: (f64, f64), normal: (f64, f64), half_width: f64) -> (f64, f64) { + ( + point.0 + normal.0 * half_width, + point.1 + normal.1 * half_width, + ) +} + +/// Head triangle plus its shaft-join notch for a curved arrow's tip. +/// +/// Aimed along the tangent at `t = 1`, not along the chord. On a strongly bent +/// arrow those differ by tens of degrees, and a head aimed down the chord +/// visibly points somewhere the arrow does not. +fn curved_head(axis: &ArrowAxis, end_tangent: (f64, f64)) -> (ArrowheadTriangle, (f64, f64)) { + let backward = (-end_tangent.0, -end_tangent.1); + let normal = left_normal(end_tangent); + let base = ( + axis.tip.0 + backward.0 * axis.head_length, + axis.tip.1 + backward.1 * axis.head_length, + ); + let notch_along = axis.head_length * HEAD_SWEEP_RATIO; + let notch = ( + axis.tip.0 + backward.0 * notch_along, + axis.tip.1 + backward.1 * notch_along, + ); + + ( + ArrowheadTriangle { + tip: axis.tip, + left: offset_by(base, normal, axis.head_half_base), + right: offset_by(base, normal, -axis.head_half_base), + }, + notch, + ) +} + +/// Head triangles and shaft centre line for one arrow, in the form bounds and +/// hit-testing consume. +/// +/// Straight styles cost no allocation here; `Curved` pays for one sampled +/// polyline, which both callers then share. #[allow(clippy::too_many_arguments)] -pub(crate) fn calculate_arrow_outline( +pub(crate) fn calculate_arrow_skeleton( tip_x: i32, tip_y: i32, tail_x: i32, @@ -191,7 +522,9 @@ pub(crate) fn calculate_arrow_outline( thick: f64, arrow_length: f64, arrow_angle: f64, -) -> Option { + style: ArrowStyle, + bend: f64, +) -> Option { let axis = arrow_axis( tip_x, tip_y, @@ -201,27 +534,139 @@ pub(crate) fn calculate_arrow_outline( arrow_length, arrow_angle, )?; - let base = axis.base(); - let notch = axis.notch(); - // The shaft never pokes outside the head it feeds into, which has already - // narrowed by `HEAD_SWEEP_RATIO` by the time it reaches the notch. The - // `thick * 0.6` floor on the head keeps that product above `thick / 2`, so - // the shaft still joins at its full width. - let shoulder_half = (thick / 2.0).min(axis.head_half_base * HEAD_SWEEP_RATIO); - let tail_half = (shoulder_half * TAIL_TAPER_RATIO) - .max(MIN_TAIL_HALF_WIDTH) - .min(shoulder_half); + if style.is_curved() { + let samples = axis.sample_curve(bend); + let end_tangent = samples.last()?.tangent; + let (head, _) = curved_head(&axis, end_tangent); + return Some(ArrowSkeleton { + head, + tail_head: None, + spine: ArrowSpine::Curved(samples.into_iter().map(|sample| sample.point).collect()), + }); + } - Some(ArrowOutline { - points: [ - axis.offset(axis.tail, 1.0, tail_half), - axis.offset(notch, 1.0, shoulder_half), - axis.offset(base, 1.0, axis.head_half_base), - axis.tip, - axis.offset(base, -1.0, axis.head_half_base), - axis.offset(notch, -1.0, shoulder_half), - axis.offset(axis.tail, -1.0, tail_half), - ], + Some(ArrowSkeleton { + head: axis.head_triangle(), + tail_head: match style { + ArrowStyle::Double => Some(axis.tail_head_triangle()), + _ => None, + }, + spine: ArrowSpine::Straight([axis.tail, axis.tip]), }) } + +/// Calculates the arrow's single filled outline for any style. +/// +/// Every style is one closed polygon, including `Double`: fusing the second +/// head into the shaft rather than filling it separately is what keeps a +/// semi-transparent color from painting a seam where the two overlap. +/// +/// `ArrowStyle::Standard` returns exactly the points [`calculate_arrow_outline`] +/// produces, so sessions drawn before styles existed render unchanged. +#[allow(clippy::too_many_arguments)] +pub(crate) fn calculate_arrow_outline_styled( + tip_x: i32, + tip_y: i32, + tail_x: i32, + tail_y: i32, + thick: f64, + arrow_length: f64, + arrow_angle: f64, + style: ArrowStyle, + bend: f64, +) -> Option> { + let axis = arrow_axis( + tip_x, + tip_y, + tail_x, + tail_y, + thick, + arrow_length, + arrow_angle, + )?; + let (shoulder_half, tail_half) = axis.shaft_half_widths(thick); + + let points = match style { + ArrowStyle::Standard => calculate_arrow_outline( + tip_x, + tip_y, + tail_x, + tail_y, + thick, + arrow_length, + arrow_angle, + )? + .points + .to_vec(), + ArrowStyle::Pointy => { + let base = axis.base(); + let notch_along = axis.head_length * POINTY_NOTCH_RATIO; + let notch = ( + axis.tip.0 + axis.toward_tail.0 * notch_along, + axis.tip.1 + axis.toward_tail.1 * notch_along, + ); + vec![ + axis.offset(axis.tail, 1.0, tail_half), + axis.offset(notch, 1.0, shoulder_half), + axis.offset(base, 1.0, axis.head_half_base), + axis.tip, + axis.offset(base, -1.0, axis.head_half_base), + axis.offset(notch, -1.0, shoulder_half), + axis.offset(axis.tail, -1.0, tail_half), + ] + } + ArrowStyle::Double => { + // Both ends are heads, so there is no tail to taper into: the shaft + // keeps its shoulder width the whole way and the silhouette stays + // symmetric end to end. + let base = axis.base(); + let notch = axis.notch(); + let tail_base = axis.along_from_tail(axis.head_length); + let tail_notch = axis.along_from_tail(axis.head_length * HEAD_SWEEP_RATIO); + vec![ + axis.tail, + axis.offset(tail_base, 1.0, axis.head_half_base), + axis.offset(tail_notch, 1.0, shoulder_half), + axis.offset(notch, 1.0, shoulder_half), + axis.offset(base, 1.0, axis.head_half_base), + axis.tip, + axis.offset(base, -1.0, axis.head_half_base), + axis.offset(notch, -1.0, shoulder_half), + axis.offset(tail_notch, -1.0, shoulder_half), + axis.offset(tail_base, -1.0, axis.head_half_base), + ] + } + ArrowStyle::Curved => { + let samples = axis.sample_curve(bend); + let end_tangent = samples.last()?.tangent; + let (head, notch) = curved_head(&axis, end_tangent); + let head_normal = left_normal(end_tangent); + + // The shaft stops where the head's rear bevel starts, so the two + // meet at the notch exactly as they do on a straight arrow. + let total_arc = samples.last()?.arc; + let join_arc = (total_arc - axis.head_length * HEAD_SWEEP_RATIO).max(f64::EPSILON); + + let mut left = Vec::with_capacity(samples.len() + 5); + let mut right = Vec::with_capacity(samples.len()); + for sample in samples.iter().take_while(|s| s.arc <= join_arc) { + let progress = (sample.arc / join_arc).clamp(0.0, 1.0); + let half_width = tail_half + (shoulder_half - tail_half) * progress; + let normal = left_normal(sample.tangent); + left.push(offset_by(sample.point, normal, half_width)); + right.push(offset_by(sample.point, normal, -half_width)); + } + + left.push(offset_by(notch, head_normal, shoulder_half)); + left.push(head.left); + left.push(head.tip); + left.push(head.right); + left.push(offset_by(notch, head_normal, -shoulder_half)); + left.extend(right.into_iter().rev()); + left + } + }; + + Some(points) +} diff --git a/src/util/mod.rs b/src/util/mod.rs index fb69e6e0..c1c45348 100644 --- a/src/util/mod.rs +++ b/src/util/mod.rs @@ -10,7 +10,10 @@ mod colors; mod geometry; mod text; -pub(crate) use arrow::{calculate_arrow_outline, calculate_arrowhead_triangle_custom}; +pub(crate) use arrow::{ + ArrowheadTriangle, DEFAULT_ARROW_BEND, calculate_arrow_outline_styled, + calculate_arrow_skeleton, chord_normal, clamp_arrow_bend, scaled_arrow_bend, +}; pub use colors::{ConfigHexColorError, color_to_name, name_to_color, parse_config_hex_color}; pub(crate) use geometry::normalize_i32_rect; pub use geometry::{Rect, ellipse_bounds}; diff --git a/src/util/tests.rs b/src/util/tests.rs index f9e22958..03879038 100644 --- a/src/util/tests.rs +++ b/src/util/tests.rs @@ -1,5 +1,32 @@ +use super::arrow::calculate_arrow_outline; use super::*; -use crate::draw::{BLACK, Color, RED, WHITE}; +use crate::draw::{ArrowStyle, BLACK, Color, RED, WHITE}; + +/// The head triangle of a straight arrow, through the same skeleton the +/// renderer, bounds, and hit-testing all read. +#[allow(clippy::too_many_arguments)] +fn head_triangle( + tip_x: i32, + tip_y: i32, + tail_x: i32, + tail_y: i32, + thick: f64, + arrow_length: f64, + arrow_angle: f64, +) -> Option { + calculate_arrow_skeleton( + tip_x, + tip_y, + tail_x, + tail_y, + thick, + arrow_length, + arrow_angle, + ArrowStyle::Standard, + 0.0, + ) + .map(|skeleton| skeleton.head) +} /// Midpoint of the head's back edge, where the shaft meets the head. fn head_base(geometry: &super::arrow::ArrowheadTriangle) -> (f64, f64) { @@ -16,14 +43,14 @@ fn distance(a: (f64, f64), b: (f64, f64)) -> f64 { #[test] fn arrowhead_triangle_caps_at_forty_percent_of_line_length() { // Line length = 10, requested head length = 100 -> capped at 40% = 4. - let geometry = calculate_arrowhead_triangle_custom(10, 10, 0, 10, 1.0, 100.0, 30.0) + let geometry = head_triangle(10, 10, 0, 10, 1.0, 100.0, 30.0) .expect("non-degenerate line should yield geometry"); assert!((distance(geometry.tip, head_base(&geometry)) - 4.0).abs() < f64::EPSILON); } #[test] fn arrowhead_triangle_handles_degenerate_lines() { - let geometry = calculate_arrowhead_triangle_custom(5, 5, 5, 5, 2.0, 15.0, 45.0); + let geometry = head_triangle(5, 5, 5, 5, 2.0, 15.0, 45.0); assert!(geometry.is_none()); } @@ -74,7 +101,7 @@ fn arrowhead_triangle_scales_the_head_with_stroke_width() { // A stub head on a thick stroke reads as a line with a nub, so the head // grows with the stroke: 10px thick -> 30px head, past `arrow_length`. // The line is long enough that the 40%-of-length cap does not bite. - let geometry = calculate_arrowhead_triangle_custom(400, 0, 0, 0, 10.0, 1.0, 24.0) + let geometry = head_triangle(400, 0, 0, 0, 10.0, 1.0, 24.0) .expect("non-degenerate line should yield geometry"); assert!((distance(geometry.tip, head_base(&geometry)) - 30.0).abs() < 1e-9); } @@ -83,14 +110,14 @@ fn arrowhead_triangle_scales_the_head_with_stroke_width() { fn arrow_length_is_the_floor_for_thin_strokes() { // Below the scaled size, `arrow.length` still decides, so hairline strokes // keep a visible head. - let geometry = calculate_arrowhead_triangle_custom(400, 0, 0, 0, 1.0, 20.0, 24.0) + let geometry = head_triangle(400, 0, 0, 0, 1.0, 20.0, 24.0) .expect("non-degenerate line should yield geometry"); assert!((distance(geometry.tip, head_base(&geometry)) - 20.0).abs() < 1e-9); } #[test] fn arrowhead_triangle_uses_thickness_floor_for_half_base() { - let geometry = calculate_arrowhead_triangle_custom(100, 0, 0, 0, 10.0, 5.0, 1.0) + let geometry = head_triangle(100, 0, 0, 0, 10.0, 5.0, 1.0) .expect("non-degenerate line should yield geometry"); let half_base = (geometry.left.1 - geometry.right.1).abs() / 2.0; assert!((half_base - 6.0).abs() < 1e-9); @@ -98,7 +125,7 @@ fn arrowhead_triangle_uses_thickness_floor_for_half_base() { #[test] fn arrowhead_back_edge_is_perpendicular_to_the_shaft() { - let geometry = calculate_arrowhead_triangle_custom(50, 50, 0, 0, 3.0, 20.0, 30.0) + let geometry = head_triangle(50, 50, 0, 0, 3.0, 20.0, 30.0) .expect("non-degenerate line should yield geometry"); // The base midpoint must sit on the tip -> tail axis, so the head is not skewed. let base = head_base(&geometry); @@ -136,7 +163,7 @@ fn arrow_outline_handles_degenerate_lines() { #[test] fn arrow_outline_head_matches_the_head_triangle() { // Render (outline) and hit-test/bounds (triangle) must not drift apart. - let triangle = calculate_arrowhead_triangle_custom(90, 40, 10, 70, 6.0, 20.0, 30.0) + let triangle = head_triangle(90, 40, 10, 70, 6.0, 20.0, 30.0) .expect("non-degenerate line should yield geometry"); let outline = calculate_arrow_outline(90, 40, 10, 70, 6.0, 20.0, 30.0) .expect("non-degenerate line should yield geometry"); @@ -152,7 +179,7 @@ fn arrow_outline_bevels_the_rear_edge_into_the_shaft() { // sit forward of the base so each rear edge bevels inward to the shaft. let tip = (90.0, 40.0); let tail = (10.0, 70.0); - let triangle = calculate_arrowhead_triangle_custom(90, 40, 10, 70, 6.0, 20.0, 30.0) + let triangle = head_triangle(90, 40, 10, 70, 6.0, 20.0, 30.0) .expect("non-degenerate line should yield geometry"); let outline = calculate_arrow_outline(90, 40, 10, 70, 6.0, 20.0, 30.0) .expect("non-degenerate line should yield geometry"); @@ -174,7 +201,7 @@ fn arrow_outline_bevel_stays_shallow_enough_to_read_as_one_arrow() { // between barb and shaft, and the head stops reading as part of the arrow. let tip = (90.0, 40.0); let tail = (10.0, 70.0); - let triangle = calculate_arrowhead_triangle_custom(90, 40, 10, 70, 6.0, 20.0, 30.0) + let triangle = head_triangle(90, 40, 10, 70, 6.0, 20.0, 30.0) .expect("non-degenerate line should yield geometry"); let outline = calculate_arrow_outline(90, 40, 10, 70, 6.0, 20.0, 30.0) .expect("non-degenerate line should yield geometry"); @@ -199,7 +226,7 @@ fn arrow_outline_shoulders_stay_inside_the_head_triangle() { let tail = (0.0, 0.0); let outline = calculate_arrow_outline(100, 0, 0, 0, 20.0, 5.0, 15.0) .expect("non-degenerate line should yield geometry"); - let triangle = calculate_arrowhead_triangle_custom(100, 0, 0, 0, 20.0, 5.0, 15.0) + let triangle = head_triangle(100, 0, 0, 0, 20.0, 5.0, 15.0) .expect("non-degenerate line should yield geometry"); let head_half = perpendicular_offset(triangle.left, tip, tail).abs(); @@ -291,3 +318,356 @@ fn arrow_outline_keeps_a_visible_tail_for_thin_strokes() { "taper inverted: tail {tail_half} wider than shoulder {shoulder_half}" ); } + +// --- Arrow styles --------------------------------------------------------- + +/// Every style's outline, for one horizontal arrow pointing right. +fn styled_outline(style: ArrowStyle, bend: f64) -> Vec<(f64, f64)> { + calculate_arrow_outline_styled(400, 100, 0, 100, 8.0, 20.0, 30.0, style, bend) + .expect("non-degenerate arrow should yield an outline") +} + +fn styled_skeleton(style: ArrowStyle, bend: f64) -> super::arrow::ArrowSkeleton { + calculate_arrow_skeleton(400, 100, 0, 100, 8.0, 20.0, 30.0, style, bend) + .expect("non-degenerate arrow should yield a skeleton") +} + +#[test] +fn standard_styled_outline_is_the_historical_outline_unchanged() { + // The back-compat guarantee at the geometry level: sessions drawn before + // styles existed have to paint the same pixels. Break it by giving + // `Standard` its own point list and this fails on the first coordinate. + let historical = calculate_arrow_outline(400, 100, 0, 100, 8.0, 20.0, 30.0) + .expect("non-degenerate arrow should yield geometry"); + let styled = styled_outline(ArrowStyle::Standard, 0.0); + + assert_eq!(styled.len(), historical.points.len()); + for (index, (styled_point, historical_point)) in + styled.iter().zip(historical.points.iter()).enumerate() + { + assert_eq!( + styled_point, historical_point, + "point {index} drifted from the historical outline" + ); + } +} + +#[test] +fn every_style_produces_a_distinct_outline() { + let outlines: Vec> = ArrowStyle::ALL + .iter() + .map(|style| styled_outline(*style, 0.3)) + .collect(); + + for (i, first) in outlines.iter().enumerate() { + for (j, second) in outlines.iter().enumerate().skip(i + 1) { + assert_ne!( + first, + second, + "styles {:?} and {:?} draw the same outline", + ArrowStyle::ALL[i], + ArrowStyle::ALL[j] + ); + } + } +} + +#[test] +fn pointy_notches_the_head_rear_deeper_than_standard() { + // Both styles keep the same barbs; what makes a dart is how far forward the + // rear notch is pulled. Measured from the tip along the shaft. + let standard = styled_outline(ArrowStyle::Standard, 0.0); + let pointy = styled_outline(ArrowStyle::Pointy, 0.0); + + // Index 1 is the shaft/head join on the first side, and the arrow points + // left-to-right along y = 100 with the tip at x = 400. + let standard_notch_x = standard[1].0; + let pointy_notch_x = pointy[1].0; + assert!( + pointy_notch_x > standard_notch_x, + "pointy notch at {pointy_notch_x} is not forward of standard's {standard_notch_x}" + ); + // The barbs themselves must not move, or `arrow_angle` would mean something + // different per style. + assert_eq!(standard[2], pointy[2], "pointy moved the head barb"); + assert_eq!(standard[3], pointy[3], "pointy moved the tip"); +} + +#[test] +fn double_puts_a_head_at_the_tail_and_drops_the_taper() { + let skeleton = styled_skeleton(ArrowStyle::Double, 0.0); + let tail_head = skeleton + .tail_head + .expect("double-ended arrows carry a second head"); + assert_eq!( + tail_head.tip, + (0.0, 100.0), + "tail head should point at the tail" + ); + + // The barbs of the tail head are as wide as the tip head's. + let tip_half = (skeleton.head.left.1 - skeleton.head.right.1).abs(); + let tail_half = (tail_head.left.1 - tail_head.right.1).abs(); + assert!( + (tip_half - tail_half).abs() < 1e-9, + "heads disagree on width: tip {tip_half} vs tail {tail_half}" + ); + + // A tapered shaft would make the tail end narrower than the shoulder; a + // double-ended one keeps parallel sides. + let outline = styled_outline(ArrowStyle::Double, 0.0); + let tail_notch_half = (outline[2].1 - 100.0).abs(); + let tip_notch_half = (outline[3].1 - 100.0).abs(); + assert!( + (tail_notch_half - tip_notch_half).abs() < 1e-9, + "double shaft tapered: {tail_notch_half} at the tail vs {tip_notch_half} at the head" + ); +} + +#[test] +fn other_styles_ignore_bend() { + for style in [ArrowStyle::Standard, ArrowStyle::Pointy, ArrowStyle::Double] { + assert_eq!( + styled_outline(style, 0.0), + styled_outline(style, 0.8), + "{style:?} changed shape for a bend it does not draw" + ); + } +} + +#[test] +fn curved_spine_bulges_off_the_chord_and_follows_the_bend_sign() { + // The arrow runs right-to-left along y = 100 (tip at x = 400, tail at 0), + // so a positive bend bulges to the left of travel, which is up on screen. + let positive = styled_skeleton(ArrowStyle::Curved, 0.4); + let negative = styled_skeleton(ArrowStyle::Curved, -0.4); + + let lowest_y = |skeleton: &super::arrow::ArrowSkeleton| { + skeleton + .spine + .points() + .iter() + .map(|point| point.1) + .fold(f64::INFINITY, f64::min) + }; + let highest_y = |skeleton: &super::arrow::ArrowSkeleton| { + skeleton + .spine + .points() + .iter() + .map(|point| point.1) + .fold(f64::NEG_INFINITY, f64::max) + }; + + assert!( + lowest_y(&positive) < 100.0 - 10.0, + "positive bend did not bulge above the chord: min y {}", + lowest_y(&positive) + ); + assert!( + highest_y(&negative) > 100.0 + 10.0, + "negative bend did not bulge below the chord: max y {}", + highest_y(&negative) + ); +} + +#[test] +fn curved_with_zero_bend_stays_on_the_chord() { + // Choosing Curved and then flattening it must not leave a wobble behind. + let skeleton = styled_skeleton(ArrowStyle::Curved, 0.0); + for point in skeleton.spine.points() { + assert!( + (point.1 - 100.0).abs() < 1e-6, + "flat curve left the chord at {point:?}" + ); + } +} + +#[test] +fn curved_head_aims_along_the_end_tangent_not_the_chord() { + // A head aimed down the chord on a strongly bent arrow points visibly + // wrong. Break it by building the head from `axis.base()` and this fails. + let skeleton = styled_skeleton(ArrowStyle::Curved, 0.8); + let head = skeleton.head; + let base = ( + (head.left.0 + head.right.0) / 2.0, + (head.left.1 + head.right.1) / 2.0, + ); + // Direction from the base to the tip: the axis the head is aimed along. + let aim = (head.tip.0 - base.0, head.tip.1 - base.1); + let aim_len = (aim.0 * aim.0 + aim.1 * aim.1).sqrt(); + let aim = (aim.0 / aim_len, aim.1 / aim_len); + + // The chord runs from the tail (0, 100) to the tip (400, 100): dead right. + let chord = (1.0, 0.0); + let cosine = aim.0 * chord.0 + aim.1 * chord.1; + assert!( + cosine < 0.95, + "head is still aimed along the chord (cos = {cosine}); a bent arrow would point wrong" + ); + + // And it does aim along the curve: the last spine segment's direction. + let points = skeleton.spine.points(); + let last = points[points.len() - 1]; + let previous = points[points.len() - 2]; + let tangent = (last.0 - previous.0, last.1 - previous.1); + let tangent_len = (tangent.0 * tangent.0 + tangent.1 * tangent.1).sqrt(); + let tangent = (tangent.0 / tangent_len, tangent.1 / tangent_len); + let alignment = aim.0 * tangent.0 + aim.1 * tangent.1; + assert!( + alignment > 0.99, + "head is not aimed along the curve's end tangent (cos = {alignment})" + ); +} + +#[test] +fn curve_sampling_scales_with_chord_length_within_bounds() { + let short = calculate_arrow_skeleton(40, 0, 0, 0, 2.0, 10.0, 30.0, ArrowStyle::Curved, 0.3) + .expect("short arrow should yield a skeleton"); + let long = calculate_arrow_skeleton(2000, 0, 0, 0, 2.0, 10.0, 30.0, ArrowStyle::Curved, 0.3) + .expect("long arrow should yield a skeleton"); + + // Floor of 12 segments (13 points) and ceiling of 64 (65 points). + assert_eq!(short.spine.points().len(), 13); + assert_eq!(long.spine.points().len(), 65); +} + +#[test] +fn bend_is_clamped_to_the_supported_range() { + assert_eq!(super::arrow::clamp_arrow_bend(5.0), 1.0); + assert_eq!(super::arrow::clamp_arrow_bend(-5.0), -1.0); + assert_eq!(super::arrow::clamp_arrow_bend(f64::NAN), 0.0); + // An unclamped bend would let a session file draw an arc off-screen. + let wild = styled_skeleton(ArrowStyle::Curved, 50.0); + let capped = styled_skeleton(ArrowStyle::Curved, 1.0); + assert_eq!(wild.spine.points(), capped.spine.points()); +} + +#[test] +fn a_uniform_scale_leaves_the_bend_alone() { + // `bend` is a fraction of the chord, so chord and bulge grow together and + // the stored number is already correct. Recomputing it anyway has to be + // exactly the identity, or every resize would nudge the arc. + let bend = scaled_arrow_bend( + (0.0, 0.0), + (100.0, 0.0), + (0.0, 0.0), + (200.0, 0.0), + 0.4, + 2.0, + 2.0, + ); + assert!( + (bend - 0.4).abs() < 1e-12, + "uniform scale changed the bend to {bend}" + ); +} + +#[test] +fn stretching_across_the_chord_grows_the_bend() { + // The bug this exists for: dragging the bottom handle of a horizontal + // curved arrow leaves the chord untouched, so a bend copied through + // unchanged keeps the same bulge and the arc — the only part of that arrow + // with any height — ignores the drag. + let bend = scaled_arrow_bend( + (0.0, 0.0), + (100.0, 0.0), + (0.0, 0.0), + (100.0, 0.0), + 0.2, + 1.0, + 3.0, + ); + assert!( + (bend - 0.6).abs() < 1e-12, + "tripling the height should triple the bend, got {bend}" + ); +} + +#[test] +fn stretching_along_the_chord_shrinks_the_bend() { + // The mirror image: widening an arrow without making it taller has to leave + // the arc's height where it is, which as a *fraction* of a chord three + // times longer is a third of the bend. + let bend = scaled_arrow_bend( + (0.0, 0.0), + (100.0, 0.0), + (0.0, 0.0), + (300.0, 0.0), + 0.6, + 3.0, + 1.0, + ); + assert!( + (bend - 0.2).abs() < 1e-12, + "widening should shrink the bend fraction, got {bend}" + ); +} + +#[test] +fn naming_the_endpoints_backwards_gives_the_same_bend() { + // The caller holds `(x1, y1)` and `(x2, y2)` and has no idea which is the + // tail; `head_at_end` decides that. This is what lets it stay ignorant: + // swapping the two flips the normal on both sides of the projection, so + // the sign cancels rather than mirroring the arc. + let forward = scaled_arrow_bend( + (0.0, 0.0), + (100.0, 0.0), + (0.0, 0.0), + (300.0, 0.0), + 0.6, + 3.0, + 1.0, + ); + let backward = scaled_arrow_bend( + (100.0, 0.0), + (0.0, 0.0), + (300.0, 0.0), + (0.0, 0.0), + 0.6, + 3.0, + 1.0, + ); + assert_eq!(forward, backward); +} + +#[test] +fn a_scaled_bend_stays_in_range_and_survives_degenerate_input() { + // Stretching hard enough to push the arc past a quarter-circle clamps + // rather than producing geometry the sampler is not defined over. + let extreme = scaled_arrow_bend( + (0.0, 0.0), + (100.0, 0.0), + (0.0, 0.0), + (100.0, 0.0), + 0.8, + 1.0, + 8.0, + ); + assert_eq!(extreme, 1.0); + + // A chord collapsed to nothing has no normal to project onto, so the stored + // bend is kept as-is instead of being replaced by a division by zero. + let collapsed = scaled_arrow_bend( + (0.0, 0.0), + (100.0, 0.0), + (5.0, 5.0), + (5.0, 5.0), + 0.4, + 0.0, + 0.0, + ); + assert_eq!(collapsed, 0.4); + + // A straight arrow has no arc to carry, and no scale can give it one. + let straight = scaled_arrow_bend( + (0.0, 0.0), + (100.0, 0.0), + (0.0, 0.0), + (100.0, 0.0), + 0.0, + 1.0, + 5.0, + ); + assert_eq!(straight, 0.0); +} diff --git a/tests/cli.rs b/tests/cli.rs index d65e5f9f..26184c40 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -266,6 +266,7 @@ fn saved_tool_state() -> wayscriber::session::ToolStateSnapshot { arrow_length: 20.0, arrow_angle: 30.0, arrow_head_at_end: Some(false), + arrow_style: None, arrow_label_enabled: Some(false), polygon_sides: wayscriber::draw::REGULAR_POLYGON_DEFAULT_SIDES, board_previous_color: None,