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,