From 9eb51f1a11e4533a4abfad4792159afeaff95593 Mon Sep 17 00:00:00 2001
From: devmobasa <4170275+devmobasa@users.noreply.github.com>
Date: Thu, 12 Feb 2026 14:23:05 +0100
Subject: [PATCH 1/5] feat(input): add radial menu for quick
tool/color/thickness access
Middle-click opens a 3-ring radial menu centered on cursor:
- Inner ring: 9 tool segments (Pen, Marker, Line, Arrow, Shapes, Text, Eraser, Select, Clear)
- Outer ring: 8 color swatches matching existing palette
- Sub-rings: Shapes expands to Rect/Ellipse, Text expands to Text/Sticky/Step
- Center circle shows current tool + color indicator, click to dismiss
- Scroll wheel adjusts thickness while menu is open
- Escape, middle-click, or right-click dismisses
Hit-test angles aligned with rendered wedge offsets. Middle-click
toggle blocked during active drag states to prevent stuck interactions.
---
README.md | 2 +
src/backend/wayland/handlers/pointer/axis.rs | 88 +++--
src/backend/wayland/state/render/ui.rs | 7 +
src/input/state/actions/key_press/mod.rs | 7 +
src/input/state/core/base/state/init.rs | 4 +-
src/input/state/core/base/state/structs.rs | 5 +
src/input/state/core/menus/shortcuts.rs | 11 +-
src/input/state/core/mod.rs | 7 +
src/input/state/core/radial_menu/hit_test.rs | 75 ++++
src/input/state/core/radial_menu/layout.rs | 58 ++++
src/input/state/core/radial_menu/mod.rs | 73 ++++
src/input/state/core/radial_menu/state.rs | 300 ++++++++++++++++
src/input/state/core/tool_controls/presets.rs | 6 +-
.../state/core/tool_controls/settings.rs | 8 +
src/input/state/core/tour.rs | 2 +-
src/input/state/mod.rs | 6 +-
src/input/state/mouse/motion.rs | 5 +
src/input/state/mouse/press.rs | 27 +-
src/input/state/mouse/release/mod.rs | 6 +
src/input/state/tests/menus/context_menu.rs | 13 +
src/input/state/tests/mod.rs | 1 +
src/input/state/tests/radial_menu.rs | 154 +++++++++
src/ui.rs | 2 +
src/ui/radial_menu.rs | 327 ++++++++++++++++++
src/ui/status/bar.rs | 6 +-
25 files changed, 1147 insertions(+), 53 deletions(-)
create mode 100644 src/input/state/core/radial_menu/hit_test.rs
create mode 100644 src/input/state/core/radial_menu/layout.rs
create mode 100644 src/input/state/core/radial_menu/mod.rs
create mode 100644 src/input/state/core/radial_menu/state.rs
create mode 100644 src/input/state/tests/radial_menu.rs
create mode 100644 src/ui/radial_menu.rs
diff --git a/README.md b/README.md
index 2955a4a0..bafa8104 100644
--- a/README.md
+++ b/README.md
@@ -89,6 +89,7 @@ https://github.com/user-attachments/assets/4b5ed159-8d1c-44cb-8fe4-e0f2ea41d818
- Selection: Alt-drag, V tool, properties panel
- Duplicate (Ctrl+D), delete (Delete), undo/redo
- Color picker, palettes, size via hotkeys or scroll
+- Radial menu at cursor (Middle-click): quick tool/color selection + scroll size adjust
### Boards
- Named boards with transparent overlay or custom backgrounds
@@ -530,6 +531,7 @@ Press F1 for the complete in-app cheat sheet.
| Quick reference | Shift+F1 |
| Configurator | F11 |
| Command palette | Ctrl+K |
+| Radial menu | Middle-click (idle) open/close; Left-click select; Right-click/Escape dismiss; scroll adjusts active tool size |
| Status bar | F4 / F12 |
| Apply preset slot | 1 - 5 |
| Save preset slot | Shift+1 - Shift+5 |
diff --git a/src/backend/wayland/handlers/pointer/axis.rs b/src/backend/wayland/handlers/pointer/axis.rs
index d6646cc6..a5df8815 100644
--- a/src/backend/wayland/handlers/pointer/axis.rs
+++ b/src/backend/wayland/handlers/pointer/axis.rs
@@ -20,6 +20,15 @@ impl WaylandState {
} else {
0
};
+ // Handle radial menu scroll-to-thickness
+ if self.input_state.is_radial_menu_open() {
+ if scroll_direction != 0 {
+ let delta = if scroll_direction > 0 { -1.0 } else { 1.0 };
+ self.adjust_active_tool_thickness(delta, true);
+ }
+ return;
+ }
+
// Handle command palette scrolling
if self.input_state.command_palette_open {
if scroll_direction != 0 {
@@ -114,42 +123,53 @@ impl WaylandState {
}
std::cmp::Ordering::Greater | std::cmp::Ordering::Less => {
let delta = if scroll_direction > 0 { -1.0 } else { 1.0 };
- let eraser_active = self.input_state.active_tool() == Tool::Eraser;
- #[cfg(tablet)]
- let prev_thickness = self.input_state.current_thickness;
-
- if self.input_state.nudge_thickness_for_active_tool(delta) {
- if eraser_active {
- debug!(
- "Eraser size adjusted: {:.0}px",
- self.input_state.eraser_size
- );
- } else {
- debug!(
- "Thickness adjusted: {:.0}px",
- self.input_state.current_thickness
- );
- }
- self.input_state.needs_redraw = true;
- if !eraser_active {
- self.save_drawing_preferences();
- }
- }
- #[cfg(tablet)]
- if !eraser_active
- && (self.input_state.current_thickness - prev_thickness).abs() > f64::EPSILON
- {
- self.stylus_base_thickness = Some(self.input_state.current_thickness);
- if self.stylus_tip_down {
- self.stylus_pressure_thickness = Some(self.input_state.current_thickness);
- self.record_stylus_peak(self.input_state.current_thickness);
- } else {
- self.stylus_pressure_thickness = None;
- self.stylus_peak_thickness = None;
- }
- }
+ self.adjust_active_tool_thickness(delta, false);
}
std::cmp::Ordering::Equal => {}
}
}
+
+ fn adjust_active_tool_thickness(&mut self, delta: f64, radial_menu_path: bool) {
+ let eraser_active = self.input_state.active_tool() == Tool::Eraser;
+ #[cfg(tablet)]
+ let prev_thickness = self.input_state.current_thickness;
+
+ let changed = if radial_menu_path {
+ self.input_state.radial_menu_adjust_thickness(delta)
+ } else if self.input_state.nudge_thickness_for_active_tool(delta) {
+ self.input_state.needs_redraw = true;
+ true
+ } else {
+ false
+ };
+
+ if changed {
+ if eraser_active {
+ debug!(
+ "Eraser size adjusted: {:.0}px",
+ self.input_state.eraser_size
+ );
+ } else {
+ debug!(
+ "Thickness adjusted: {:.0}px",
+ self.input_state.current_thickness
+ );
+ self.save_drawing_preferences();
+ }
+ }
+
+ #[cfg(tablet)]
+ if !eraser_active
+ && (self.input_state.current_thickness - prev_thickness).abs() > f64::EPSILON
+ {
+ self.stylus_base_thickness = Some(self.input_state.current_thickness);
+ if self.stylus_tip_down {
+ self.stylus_pressure_thickness = Some(self.input_state.current_thickness);
+ self.record_stylus_peak(self.input_state.current_thickness);
+ } else {
+ self.stylus_pressure_thickness = None;
+ self.stylus_peak_thickness = None;
+ }
+ }
+ }
}
diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs
index f876e00c..68549df6 100644
--- a/src/backend/wayland/state/render/ui.rs
+++ b/src/backend/wayland/state/render/ui.rs
@@ -127,6 +127,13 @@ impl WaylandState {
self.input_state.clear_color_picker_popup_layout();
}
+ if self.input_state.is_radial_menu_open() {
+ self.input_state.update_radial_menu_layout(width, height);
+ crate::ui::render_radial_menu(ctx, &self.input_state, width, height);
+ } else {
+ self.input_state.clear_radial_menu_layout();
+ }
+
self.input_state.ui_toast_bounds =
crate::ui::render_ui_toast(ctx, &self.input_state, width, height);
crate::ui::render_preset_toast(ctx, &self.input_state, width, height);
diff --git a/src/input/state/actions/key_press/mod.rs b/src/input/state/actions/key_press/mod.rs
index 7d089614..9a495e70 100644
--- a/src/input/state/actions/key_press/mod.rs
+++ b/src/input/state/actions/key_press/mod.rs
@@ -34,6 +34,13 @@ impl InputState {
return;
}
+ if self.is_radial_menu_open() {
+ if matches!(key, Key::Escape) {
+ self.close_radial_menu();
+ }
+ return;
+ }
+
if self.is_color_picker_popup_open() && self.handle_color_picker_popup_key(key) {
return;
}
diff --git a/src/input/state/core/base/state/init.rs b/src/input/state/core/base/state/init.rs
index 0d38f5a1..2c504c07 100644
--- a/src/input/state/core/base/state/init.rs
+++ b/src/input/state/core/base/state/init.rs
@@ -1,6 +1,6 @@
use super::super::super::{
board_picker::BoardPickerState, color_picker_popup::ColorPickerPopupState,
- menus::ContextMenuState, selection::SelectionState,
+ menus::ContextMenuState, radial_menu::RadialMenuState, selection::SelectionState,
};
use super::super::types::{
CompositorCapabilities, DrawingState, MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS,
@@ -157,6 +157,8 @@ impl InputState {
board_picker_page_edit: None,
color_picker_popup_state: ColorPickerPopupState::Hidden,
color_picker_popup_layout: None,
+ radial_menu_state: RadialMenuState::Hidden,
+ radial_menu_layout: None,
hit_test_cache: HashMap::new(),
hit_test_tolerance: 6.0,
max_linear_hit_test: 400,
diff --git a/src/input/state/core/base/state/structs.rs b/src/input/state/core/base/state/structs.rs
index 620d1bf3..c7af0d09 100644
--- a/src/input/state/core/base/state/structs.rs
+++ b/src/input/state/core/base/state/structs.rs
@@ -7,6 +7,7 @@ use super::super::super::{
index::SpatialGrid,
menus::{ContextMenuLayout, ContextMenuState},
properties::{PropertiesPanelLayout, ShapePropertiesPanel},
+ radial_menu::{RadialMenuLayout, RadialMenuState},
selection::SelectionState,
};
use super::super::types::{
@@ -229,6 +230,10 @@ pub struct InputState {
pub color_picker_popup_state: ColorPickerPopupState,
/// Cached layout details for the color picker popup
pub color_picker_popup_layout: Option,
+ /// Current radial menu state
+ pub radial_menu_state: RadialMenuState,
+ /// Cached layout details for the radial menu
+ pub radial_menu_layout: Option,
/// Cached hit-test bounds per shape id
pub(in crate::input::state::core) hit_test_cache: HashMap,
/// Hit test tolerance in pixels
diff --git a/src/input/state/core/menus/shortcuts.rs b/src/input/state/core/menus/shortcuts.rs
index 1d1001f6..317e9420 100644
--- a/src/input/state/core/menus/shortcuts.rs
+++ b/src/input/state/core/menus/shortcuts.rs
@@ -5,9 +5,12 @@ impl InputState {
/// Get the display string for the first keybinding of an action.
/// Returns None if no binding exists.
pub fn shortcut_for_action(&self, action: Action) -> Option {
- self.action_bindings
- .get(&action)
- .and_then(|bindings| bindings.first())
- .map(|binding| binding.to_string())
+ let mut labels = self.action_binding_labels(action);
+ if action == Action::ToggleHelp
+ && let Some(idx) = labels.iter().position(|label| label == "F1")
+ {
+ return Some(labels.swap_remove(idx));
+ }
+ labels.into_iter().next()
}
}
diff --git a/src/input/state/core/mod.rs b/src/input/state/core/mod.rs
index d7202a41..b37b1bbd 100644
--- a/src/input/state/core/mod.rs
+++ b/src/input/state/core/mod.rs
@@ -9,6 +9,7 @@ mod history;
mod index;
mod menus;
mod properties;
+pub(crate) mod radial_menu;
mod selection;
mod selection_actions;
mod session;
@@ -38,6 +39,12 @@ pub use command_palette::{COMMAND_PALETTE_MAX_VISIBLE, CommandPaletteCursorHint}
pub use menus::{
ContextMenuCursorHint, ContextMenuEntry, ContextMenuKind, ContextMenuState, MenuCommand,
};
+pub use radial_menu::state::{radial_color_for_index, sub_ring_child_count, sub_ring_child_label};
+pub use radial_menu::{
+ COLOR_SEGMENT_COUNT as RADIAL_COLOR_SEGMENT_COUNT, RadialMenuLayout, RadialMenuState,
+ RadialSegmentId, TOOL_LABELS as RADIAL_TOOL_LABELS,
+ TOOL_SEGMENT_COUNT as RADIAL_TOOL_SEGMENT_COUNT,
+};
pub use selection::SelectionState;
pub use tour::TourStep;
pub use utility::HelpOverlayCursorHint;
diff --git a/src/input/state/core/radial_menu/hit_test.rs b/src/input/state/core/radial_menu/hit_test.rs
new file mode 100644
index 00000000..cca9b175
--- /dev/null
+++ b/src/input/state/core/radial_menu/hit_test.rs
@@ -0,0 +1,75 @@
+use super::state::sub_ring_child_count;
+use super::{COLOR_SEGMENT_COUNT, RadialMenuLayout, RadialSegmentId, TOOL_SEGMENT_COUNT};
+use std::f64::consts::PI;
+
+/// Perform a hit-test on the radial menu and return the segment under (x, y).
+pub fn hit_test_radial(
+ layout: &RadialMenuLayout,
+ expanded_sub_ring: Option,
+ x: f64,
+ y: f64,
+) -> Option {
+ let dx = x - layout.center_x;
+ let dy = y - layout.center_y;
+ let dist = (dx * dx + dy * dy).sqrt();
+
+ // Center circle
+ if dist <= layout.center_radius {
+ return Some(RadialSegmentId::Center);
+ }
+
+ // Compute tool angle: 0 at top, clockwise, in [0, 2*PI).
+ // Add half-segment offset so boundaries align with rendered tool wedges.
+ let tool_half_seg = PI / TOOL_SEGMENT_COUNT as f64; // == tool_seg_angle / 2
+ let tool_angle = normalize_angle(dy.atan2(dx) + PI / 2.0 + tool_half_seg);
+
+ // Sub-ring band (checked before tool ring when a sub-ring is expanded)
+ if let Some(parent_idx) = expanded_sub_ring
+ && dist >= layout.sub_inner
+ && dist <= layout.sub_outer
+ {
+ let child_count = sub_ring_child_count(parent_idx);
+ if child_count > 0 {
+ let segment_angle = 2.0 * PI / TOOL_SEGMENT_COUNT as f64;
+ let parent_start = segment_angle * parent_idx as f64;
+ let parent_end = parent_start + segment_angle;
+ if tool_angle >= parent_start && tool_angle < parent_end {
+ let child_angle = segment_angle / child_count as f64;
+ let offset = tool_angle - parent_start;
+ let child_idx = (offset / child_angle).floor() as u8;
+ let child_idx = child_idx.min(child_count as u8 - 1);
+ return Some(RadialSegmentId::SubTool(parent_idx, child_idx));
+ }
+ }
+ }
+
+ // Tool ring
+ if dist >= layout.tool_inner && dist <= layout.tool_outer {
+ let idx = angle_to_segment(tool_angle, TOOL_SEGMENT_COUNT);
+ return Some(RadialSegmentId::Tool(idx));
+ }
+
+ // Color ring
+ if dist >= layout.color_inner && dist <= layout.color_outer {
+ // Color ring uses its own segment size and render offset.
+ let color_half_seg = PI / COLOR_SEGMENT_COUNT as f64; // == color_seg_angle / 2
+ let color_angle = normalize_angle(dy.atan2(dx) + PI / 2.0 + color_half_seg);
+ let idx = angle_to_segment(color_angle, COLOR_SEGMENT_COUNT);
+ return Some(RadialSegmentId::Color(idx));
+ }
+
+ None
+}
+
+/// Map an angle (0 at top, clockwise) to a segment index.
+fn angle_to_segment(angle: f64, count: usize) -> u8 {
+ let segment_angle = 2.0 * PI / count as f64;
+ let idx = (angle / segment_angle).floor() as u8;
+ idx.min(count as u8 - 1)
+}
+
+/// Normalize an angle to the range [0, 2*PI).
+fn normalize_angle(a: f64) -> f64 {
+ let two_pi = 2.0 * PI;
+ ((a % two_pi) + two_pi) % two_pi
+}
diff --git a/src/input/state/core/radial_menu/layout.rs b/src/input/state/core/radial_menu/layout.rs
new file mode 100644
index 00000000..c220cb31
--- /dev/null
+++ b/src/input/state/core/radial_menu/layout.rs
@@ -0,0 +1,58 @@
+use super::{RadialMenuLayout, RadialMenuState};
+use crate::input::state::InputState;
+
+/// Center circle radius.
+const CENTER_RADIUS: f64 = 30.0;
+/// Inner radius of the tool ring.
+const TOOL_INNER: f64 = 40.0;
+/// Outer radius of the tool ring.
+const TOOL_OUTER: f64 = 100.0;
+/// Inner radius of the sub-ring band (flush with tool outer, no gap).
+const SUB_INNER: f64 = 100.0;
+/// Outer radius of the sub-ring band.
+const SUB_OUTER: f64 = 150.0;
+/// Inner radius of the color ring (flush with sub outer, no gap).
+const COLOR_INNER: f64 = 150.0;
+/// Outer radius of the color ring.
+const COLOR_OUTER: f64 = 184.0;
+
+impl InputState {
+ /// Compute and cache the radial menu layout, clamping the center to keep it on screen.
+ pub fn update_radial_menu_layout(&mut self, width: u32, height: u32) {
+ if let RadialMenuState::Open {
+ center_x, center_y, ..
+ } = &self.radial_menu_state
+ {
+ let margin = COLOR_OUTER + 4.0;
+ let cx = clamp_center_coordinate(*center_x, width as f64, margin);
+ let cy = clamp_center_coordinate(*center_y, height as f64, margin);
+
+ self.radial_menu_layout = Some(RadialMenuLayout {
+ center_x: cx,
+ center_y: cy,
+ center_radius: CENTER_RADIUS,
+ tool_inner: TOOL_INNER,
+ tool_outer: TOOL_OUTER,
+ sub_inner: SUB_INNER,
+ sub_outer: SUB_OUTER,
+ color_inner: COLOR_INNER,
+ color_outer: COLOR_OUTER,
+ });
+ }
+ }
+
+ /// Clear the cached layout when the menu is hidden.
+ pub fn clear_radial_menu_layout(&mut self) {
+ self.radial_menu_layout = None;
+ }
+}
+
+fn clamp_center_coordinate(coord: f64, extent: f64, margin: f64) -> f64 {
+ let min = margin;
+ let max = extent - margin;
+ if min <= max {
+ coord.clamp(min, max)
+ } else {
+ extent / 2.0
+ }
+}
diff --git a/src/input/state/core/radial_menu/mod.rs b/src/input/state/core/radial_menu/mod.rs
new file mode 100644
index 00000000..4aadcf54
--- /dev/null
+++ b/src/input/state/core/radial_menu/mod.rs
@@ -0,0 +1,73 @@
+pub(crate) mod hit_test;
+mod layout;
+pub(crate) mod state;
+
+/// State of the radial menu overlay.
+#[derive(Debug, Clone, Default)]
+pub enum RadialMenuState {
+ /// Menu is not visible.
+ #[default]
+ Hidden,
+ /// Menu is open at a given center position.
+ Open {
+ /// Center X in surface coordinates.
+ center_x: f64,
+ /// Center Y in surface coordinates.
+ center_y: f64,
+ /// Currently hovered segment (if any).
+ hover: Option,
+ /// Expanded sub-ring parent index (Shapes=4, Text=5).
+ expanded_sub_ring: Option,
+ },
+}
+
+/// Identifies a segment in the radial menu for hit-testing.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum RadialSegmentId {
+ /// Primary tool ring segment (index 0..TOOL_SEGMENT_COUNT).
+ Tool(u8),
+ /// Sub-ring child segment (parent index, child index).
+ SubTool(u8, u8),
+ /// Color ring segment (index 0..8).
+ Color(u8),
+ /// Center circle (dismiss).
+ Center,
+}
+
+/// Cached layout metrics for the radial menu.
+#[derive(Debug, Clone, Copy)]
+pub struct RadialMenuLayout {
+ /// Clamped center X.
+ pub center_x: f64,
+ /// Clamped center Y.
+ pub center_y: f64,
+ /// Radius of the center circle.
+ pub center_radius: f64,
+ /// Inner radius of the tool ring.
+ pub tool_inner: f64,
+ /// Outer radius of the tool ring.
+ pub tool_outer: f64,
+ /// Inner radius of the sub-ring band.
+ pub sub_inner: f64,
+ /// Outer radius of the sub-ring band.
+ pub sub_outer: f64,
+ /// Inner radius of the color ring.
+ pub color_inner: f64,
+ /// Outer radius of the color ring.
+ pub color_outer: f64,
+}
+
+/// Number of segments in the primary tool ring.
+pub const TOOL_SEGMENT_COUNT: usize = 9;
+/// Number of segments in the color ring.
+pub const COLOR_SEGMENT_COUNT: usize = 8;
+
+/// Sub-ring children for the Shapes segment (index 4).
+pub const SHAPES_CHILDREN: &[&str] = &["Rect", "Ellipse"];
+/// Sub-ring children for the Text segment (index 5).
+pub const TEXT_CHILDREN: &[&str] = &["Text", "Sticky", "Step"];
+
+/// Tool labels for the primary ring (clockwise from top).
+pub const TOOL_LABELS: [&str; TOOL_SEGMENT_COUNT] = [
+ "Pen", "Marker", "Line", "Arrow", "Shapes", "Text", "Eraser", "Select", "Clear",
+];
diff --git a/src/input/state/core/radial_menu/state.rs b/src/input/state/core/radial_menu/state.rs
new file mode 100644
index 00000000..cbb47919
--- /dev/null
+++ b/src/input/state/core/radial_menu/state.rs
@@ -0,0 +1,300 @@
+use super::{RadialMenuState, RadialSegmentId, SHAPES_CHILDREN, TEXT_CHILDREN};
+use crate::config::Action;
+use crate::draw::color;
+use crate::input::DrawingState;
+use crate::input::state::InputState;
+use crate::input::state::TextInputMode;
+use crate::input::tool::Tool;
+
+impl InputState {
+ /// Whether the radial menu is currently visible.
+ pub fn is_radial_menu_open(&self) -> bool {
+ matches!(self.radial_menu_state, RadialMenuState::Open { .. })
+ }
+
+ /// Open the radial menu centered on the given surface coordinates.
+ pub fn open_radial_menu(&mut self, x: f64, y: f64) {
+ // Mutual exclusion with other popups
+ if self.show_help {
+ self.toggle_help_overlay();
+ }
+ if self.is_context_menu_open() {
+ self.close_context_menu();
+ }
+ if self.is_color_picker_popup_open() {
+ self.close_color_picker_popup(true);
+ }
+ if self.is_properties_panel_open() {
+ self.close_properties_panel();
+ }
+ if self.command_palette_open {
+ self.command_palette_open = false;
+ }
+
+ self.radial_menu_state = RadialMenuState::Open {
+ center_x: x,
+ center_y: y,
+ hover: None,
+ expanded_sub_ring: None,
+ };
+ self.dirty_tracker.mark_full();
+ self.needs_redraw = true;
+ }
+
+ /// Close the radial menu.
+ pub fn close_radial_menu(&mut self) {
+ if self.is_radial_menu_open() {
+ self.radial_menu_state = RadialMenuState::Hidden;
+ self.radial_menu_layout = None;
+ self.dirty_tracker.mark_full();
+ self.needs_redraw = true;
+ }
+ }
+
+ /// Toggle the radial menu open/closed at the given position.
+ pub fn toggle_radial_menu(&mut self, x: f64, y: f64) {
+ if self.is_radial_menu_open() {
+ self.close_radial_menu();
+ } else {
+ self.open_radial_menu(x, y);
+ }
+ }
+
+ /// Update the hovered segment based on pointer position.
+ pub fn update_radial_menu_hover(&mut self, x: f64, y: f64) {
+ if let RadialMenuState::Open {
+ ref mut hover,
+ ref mut expanded_sub_ring,
+ ..
+ } = self.radial_menu_state
+ && let Some(layout) = &self.radial_menu_layout
+ {
+ let segment = super::hit_test::hit_test_radial(layout, *expanded_sub_ring, x, y);
+ let old_hover = *hover;
+ let old_expanded_sub_ring = *expanded_sub_ring;
+ *hover = segment;
+
+ // Expand/collapse sub-ring based on hovered segment
+ match segment {
+ Some(RadialSegmentId::Tool(4)) => {
+ *expanded_sub_ring = Some(4);
+ }
+ Some(RadialSegmentId::Tool(5)) => {
+ *expanded_sub_ring = Some(5);
+ }
+ // Keep sub-ring expanded while hovering its children
+ Some(RadialSegmentId::SubTool(_, _)) => {}
+ // Keep sub-ring expanded when cursor is in/near the sub-ring
+ // band (None from gap or outside parent angle range)
+ None if expanded_sub_ring.is_some() => {
+ let dx = x - layout.center_x;
+ let dy = y - layout.center_y;
+ let dist = (dx * dx + dy * dy).sqrt();
+ // Only collapse if cursor left the sub-ring distance band
+ if dist < layout.tool_inner || dist > layout.sub_outer {
+ *expanded_sub_ring = None;
+ }
+ }
+ // Collapse when hovering a different tool or color segment
+ Some(RadialSegmentId::Tool(_))
+ | Some(RadialSegmentId::Color(_))
+ | Some(RadialSegmentId::Center) => {
+ *expanded_sub_ring = None;
+ }
+ _ => {}
+ }
+
+ if old_hover != *hover || old_expanded_sub_ring != *expanded_sub_ring {
+ self.dirty_tracker.mark_full();
+ self.needs_redraw = true;
+ }
+ }
+ }
+
+ /// Select the currently hovered segment and close the menu.
+ pub fn radial_menu_select_hovered(&mut self) {
+ let hover = match &self.radial_menu_state {
+ RadialMenuState::Open { hover, .. } => *hover,
+ _ => return,
+ };
+
+ match hover {
+ Some(RadialSegmentId::Tool(idx)) if sub_ring_child_count(idx) > 0 => {
+ // Parent with children — expand sub-ring, don't close
+ if let RadialMenuState::Open {
+ ref mut expanded_sub_ring,
+ ..
+ } = self.radial_menu_state
+ {
+ *expanded_sub_ring = Some(idx);
+ }
+ self.dirty_tracker.mark_full();
+ self.needs_redraw = true;
+ return;
+ }
+ Some(RadialSegmentId::Tool(idx)) => {
+ self.dispatch_tool_segment(idx);
+ }
+ Some(RadialSegmentId::SubTool(parent, child)) => {
+ self.dispatch_sub_tool_segment(parent, child);
+ }
+ Some(RadialSegmentId::Color(idx)) => {
+ self.dispatch_color_segment(idx);
+ }
+ Some(RadialSegmentId::Center) | None => {
+ // Dismiss only
+ }
+ }
+
+ self.close_radial_menu();
+ }
+
+ /// Adjust thickness via scroll wheel while the menu is open.
+ pub fn radial_menu_adjust_thickness(&mut self, delta: f64) -> bool {
+ if !self.nudge_thickness_for_active_tool(delta) {
+ return false;
+ }
+ self.dirty_tracker.mark_full();
+ self.needs_redraw = true;
+ true
+ }
+
+ // ── dispatch helpers ──
+
+ fn dispatch_tool_segment(&mut self, idx: u8) {
+ match idx {
+ 0 => {
+ self.set_tool_override(Some(Tool::Pen));
+ }
+ 1 => {
+ self.set_tool_override(Some(Tool::Marker));
+ }
+ 2 => {
+ self.set_tool_override(Some(Tool::Line));
+ }
+ 3 => {
+ self.set_tool_override(Some(Tool::Arrow));
+ }
+ 4 => {
+ // Shapes parent — default to Rect
+ self.set_tool_override(Some(Tool::Rect));
+ }
+ 5 => {
+ // Text parent — default to text mode
+ self.enter_text_mode_at_center();
+ }
+ 6 => {
+ self.set_tool_override(Some(Tool::Eraser));
+ }
+ 7 => {
+ self.set_tool_override(Some(Tool::Select));
+ }
+ 8 => {
+ self.handle_action(Action::ClearCanvas);
+ }
+ _ => {}
+ }
+ }
+
+ fn dispatch_sub_tool_segment(&mut self, parent: u8, child: u8) {
+ match parent {
+ 4 => {
+ // Shapes sub-ring
+ match child {
+ 0 => {
+ self.set_tool_override(Some(Tool::Rect));
+ }
+ 1 => {
+ self.set_tool_override(Some(Tool::Ellipse));
+ }
+ _ => {}
+ }
+ }
+ 5 => {
+ // Text sub-ring
+ match child {
+ 0 => self.enter_text_mode_at_center(),
+ 1 => self.enter_sticky_note_mode_at_center(),
+ 2 => {
+ self.set_tool_override(Some(Tool::StepMarker));
+ }
+ _ => {}
+ }
+ }
+ _ => {}
+ }
+ }
+
+ fn dispatch_color_segment(&mut self, idx: u8) {
+ let c = radial_color_for_index(idx);
+ self.apply_color_from_ui(c);
+ }
+
+ fn enter_text_mode_at_center(&mut self) {
+ if matches!(self.state, DrawingState::Idle) {
+ self.text_input_mode = TextInputMode::Plain;
+ self.text_edit_target = None;
+ self.text_wrap_width = None;
+ self.state = DrawingState::TextInput {
+ x: (self.screen_width / 2) as i32,
+ y: (self.screen_height / 2) as i32,
+ buffer: String::new(),
+ };
+ self.last_text_preview_bounds = None;
+ self.update_text_preview_dirty();
+ self.needs_redraw = true;
+ }
+ }
+
+ fn enter_sticky_note_mode_at_center(&mut self) {
+ if matches!(self.state, DrawingState::Idle) {
+ self.text_input_mode = TextInputMode::StickyNote;
+ self.text_edit_target = None;
+ self.text_wrap_width = None;
+ self.state = DrawingState::TextInput {
+ x: (self.screen_width / 2) as i32,
+ y: (self.screen_height / 2) as i32,
+ buffer: String::new(),
+ };
+ self.last_text_preview_bounds = None;
+ self.update_text_preview_dirty();
+ self.needs_redraw = true;
+ }
+ }
+}
+
+/// Map a color segment index to the corresponding draw color constant.
+pub fn radial_color_for_index(idx: u8) -> crate::draw::Color {
+ match idx {
+ 0 => color::RED,
+ 1 => color::GREEN,
+ 2 => color::BLUE,
+ 3 => color::YELLOW,
+ 4 => color::ORANGE,
+ 5 => color::PINK,
+ 6 => color::WHITE,
+ 7 => color::BLACK,
+ _ => color::RED,
+ }
+}
+
+/// Return the number of sub-ring children for a given tool index, or 0 if none.
+pub fn sub_ring_child_count(parent_idx: u8) -> usize {
+ match parent_idx {
+ 4 => SHAPES_CHILDREN.len(),
+ 5 => TEXT_CHILDREN.len(),
+ _ => 0,
+ }
+}
+
+/// Return the label for a sub-ring child.
+pub fn sub_ring_child_label(parent_idx: u8, child_idx: u8) -> &'static str {
+ match parent_idx {
+ 4 => SHAPES_CHILDREN
+ .get(child_idx as usize)
+ .copied()
+ .unwrap_or(""),
+ 5 => TEXT_CHILDREN.get(child_idx as usize).copied().unwrap_or(""),
+ _ => "",
+ }
+}
diff --git a/src/input/state/core/tool_controls/presets.rs b/src/input/state/core/tool_controls/presets.rs
index a2a8710d..ba0b2cea 100644
--- a/src/input/state/core/tool_controls/presets.rs
+++ b/src/input/state/core/tool_controls/presets.rs
@@ -219,11 +219,7 @@ impl InputState {
fn capture_current_preset(&self) -> ToolPresetConfig {
let active_tool = self.active_tool();
- let size = if active_tool == Tool::Eraser {
- self.eraser_size
- } else {
- self.current_thickness
- };
+ let size = self.size_for_active_tool();
ToolPresetConfig {
name: None,
tool: active_tool,
diff --git a/src/input/state/core/tool_controls/settings.rs b/src/input/state/core/tool_controls/settings.rs
index fe21e999..bbad9f5f 100644
--- a/src/input/state/core/tool_controls/settings.rs
+++ b/src/input/state/core/tool_controls/settings.rs
@@ -69,6 +69,14 @@ impl InputState {
}
}
+ /// Returns the current size value for the active tool.
+ pub fn size_for_active_tool(&self) -> f64 {
+ match self.active_tool() {
+ Tool::Eraser => self.eraser_size,
+ _ => self.current_thickness,
+ }
+ }
+
/// Updates the current drawing color to an arbitrary value. Returns true if changed.
pub fn set_color(&mut self, color: Color) -> bool {
if self.current_color == color {
diff --git a/src/input/state/core/tour.rs b/src/input/state/core/tour.rs
index 514b7619..91a318fc 100644
--- a/src/input/state/core/tour.rs
+++ b/src/input/state/core/tour.rs
@@ -57,7 +57,7 @@ impl TourStep {
"Wayscriber is a screen annotation tool.\nDraw anywhere on your screen to highlight, explain, or present."
}
Self::DrawingBasics => {
- "Click and drag to draw with the pen tool.\nUse R/G/B/Y keys to change colors.\nScroll wheel or +/- to adjust thickness."
+ "Click and drag to draw with the pen tool.\nUse R/G/B/Y keys to change colors.\nScroll wheel or +/- to adjust thickness.\nMiddle-click opens the radial menu for quick tool/color changes."
}
Self::ToolbarIntro => {
"Press F2 to toggle the toolbar.\nThe toolbar provides quick access to all tools and settings."
diff --git a/src/input/state/mod.rs b/src/input/state/mod.rs
index 0e78e833..ae253635 100644
--- a/src/input/state/mod.rs
+++ b/src/input/state/mod.rs
@@ -21,8 +21,10 @@ pub use core::{
ContextMenuState, DrawingState, HelpOverlayCursorHint, InputState, MAX_STROKE_THICKNESS,
MIN_STROKE_THICKNESS, OutputFocusAction, PRESET_FEEDBACK_DURATION_MS, PRESET_TOAST_DURATION_MS,
PresetAction, PresetFeedbackKind, PressureThicknessEditMode, PressureThicknessEntryMode,
- SelectionAxis, SelectionHandle, SelectionState, TextInputMode, ToolbarDrawerTab, TourStep,
- UI_TOAST_DURATION_MS, UiToastKind, ZoomAction, color_picker_rgb_to_hsv,
+ RADIAL_COLOR_SEGMENT_COUNT, RADIAL_TOOL_LABELS, RADIAL_TOOL_SEGMENT_COUNT, RadialMenuLayout,
+ RadialMenuState, RadialSegmentId, SelectionAxis, SelectionHandle, SelectionState,
+ TextInputMode, ToolbarDrawerTab, TourStep, UI_TOAST_DURATION_MS, UiToastKind, ZoomAction,
+ color_picker_rgb_to_hsv, radial_color_for_index, sub_ring_child_count, sub_ring_child_label,
};
pub(crate) use core::{
COMMAND_PALETTE_INPUT_HEIGHT, COMMAND_PALETTE_ITEM_HEIGHT, COMMAND_PALETTE_LIST_GAP,
diff --git a/src/input/state/mouse/motion.rs b/src/input/state/mouse/motion.rs
index 92ccc480..1d6d8410 100644
--- a/src/input/state/mouse/motion.rs
+++ b/src/input/state/mouse/motion.rs
@@ -17,6 +17,11 @@ impl InputState {
pub fn on_mouse_motion(&mut self, x: i32, y: i32) {
self.update_pointer_position(x, y);
+ if self.is_radial_menu_open() {
+ self.update_radial_menu_hover(x as f64, y as f64);
+ return;
+ }
+
if self.is_color_picker_popup_open() {
if self.color_picker_popup_is_dragging()
&& let Some(layout) = self.color_picker_popup_layout()
diff --git a/src/input/state/mouse/press.rs b/src/input/state/mouse/press.rs
index dff889e1..bef0a975 100644
--- a/src/input/state/mouse/press.rs
+++ b/src/input/state/mouse/press.rs
@@ -111,6 +111,27 @@ impl InputState {
/// - Left click during TextInput: Updates text position
/// - Right click: Cancels current action
pub fn on_mouse_press(&mut self, button: MouseButton, x: i32, y: i32) {
+ // Radial menu intercept
+ if self.is_radial_menu_open() {
+ self.update_pointer_position(x, y);
+ match button {
+ MouseButton::Left => {
+ // Update hover at exact click position before selecting
+ self.update_radial_menu_hover(x as f64, y as f64);
+ self.radial_menu_select_hovered();
+ }
+ MouseButton::Right => {
+ // Keep right-click context-menu flow consistent even when radial is visible.
+ self.close_radial_menu();
+ self.handle_right_click(x, y);
+ }
+ MouseButton::Middle => {
+ self.close_radial_menu();
+ }
+ }
+ return;
+ }
+
if self.is_color_picker_popup_open() {
self.update_pointer_position(x, y);
match button {
@@ -346,7 +367,11 @@ impl InputState {
| DrawingState::ResizingSelection { .. } => {}
}
}
- MouseButton::Middle => {}
+ MouseButton::Middle => {
+ if !self.zoom_active() && matches!(self.state, DrawingState::Idle) {
+ self.toggle_radial_menu(x as f64, y as f64);
+ }
+ }
}
}
}
diff --git a/src/input/state/mouse/release/mod.rs b/src/input/state/mouse/release/mod.rs
index a7e6facb..c9e5368d 100644
--- a/src/input/state/mouse/release/mod.rs
+++ b/src/input/state/mouse/release/mod.rs
@@ -22,6 +22,12 @@ impl InputState {
/// - Returns to Idle state
pub fn on_mouse_release(&mut self, button: MouseButton, x: i32, y: i32) {
self.update_pointer_position(x, y);
+
+ // Radial menu uses press for selection, ignore release
+ if self.is_radial_menu_open() {
+ return;
+ }
+
if button == MouseButton::Left {
if panels::handle_color_picker_popup_release(self, x, y) {
return;
diff --git a/src/input/state/tests/menus/context_menu.rs b/src/input/state/tests/menus/context_menu.rs
index beca9af6..edb61c50 100644
--- a/src/input/state/tests/menus/context_menu.rs
+++ b/src/input/state/tests/menus/context_menu.rs
@@ -167,3 +167,16 @@ fn keyboard_context_menu_focuses_edit_for_selected_text() {
let entries = state.context_menu_entries();
assert_eq!(entries[*focus_index].command, Some(MenuCommand::EditText));
}
+
+#[test]
+fn context_menu_help_entry_prefers_f1_shortcut_label() {
+ let mut state = create_test_input_state();
+ state.toggle_context_menu_via_keyboard();
+
+ let entries = state.context_menu_entries();
+ let help_entry = entries
+ .iter()
+ .find(|entry| entry.label == "Help")
+ .expect("help entry should exist in context menu");
+ assert_eq!(help_entry.shortcut.as_deref(), Some("F1"));
+}
diff --git a/src/input/state/tests/mod.rs b/src/input/state/tests/mod.rs
index c359cf93..ecacec83 100644
--- a/src/input/state/tests/mod.rs
+++ b/src/input/state/tests/mod.rs
@@ -16,6 +16,7 @@ mod erase;
mod menus;
mod presenter_mode;
mod pressure_modes;
+mod radial_menu;
mod selection;
mod step_markers;
mod text_edit;
diff --git a/src/input/state/tests/radial_menu.rs b/src/input/state/tests/radial_menu.rs
new file mode 100644
index 00000000..2c55ffb5
--- /dev/null
+++ b/src/input/state/tests/radial_menu.rs
@@ -0,0 +1,154 @@
+use super::*;
+use std::f64::consts::PI;
+
+fn point_at(cx: f64, cy: f64, radius: f64, degrees: f64) -> (f64, f64) {
+ let angle = degrees.to_radians();
+ (cx + radius * angle.cos(), cy + radius * angle.sin())
+}
+
+fn point_in_tool_segment(layout: &RadialMenuLayout, segment_idx: u8) -> (f64, f64) {
+ let seg_angle = 2.0 * PI / RADIAL_TOOL_SEGMENT_COUNT as f64;
+ let angle = -PI / 2.0 + segment_idx as f64 * seg_angle;
+ let radius = (layout.tool_inner + layout.tool_outer) / 2.0;
+ (
+ layout.center_x + radius * angle.cos(),
+ layout.center_y + radius * angle.sin(),
+ )
+}
+
+#[test]
+fn radial_layout_small_surface_centers_menu_without_panic() {
+ let mut state = create_test_input_state();
+ state.open_radial_menu(8.0, 6.0);
+
+ state.update_radial_menu_layout(120, 90);
+
+ let layout = state
+ .radial_menu_layout
+ .expect("layout should be computed even on tiny surfaces");
+ assert!((layout.center_x - 60.0).abs() < f64::EPSILON);
+ assert!((layout.center_y - 45.0).abs() < f64::EPSILON);
+}
+
+#[test]
+fn radial_hover_collapse_without_hover_change_requests_redraw() {
+ let mut state = create_test_input_state();
+ state.open_radial_menu(400.0, 300.0);
+ state.update_radial_menu_layout(800, 600);
+
+ let layout = state
+ .radial_menu_layout
+ .expect("layout should exist for open radial menu");
+
+ // Hover Shapes parent (segment 4) to expand the sub-ring.
+ state.needs_redraw = false;
+ let (tool4_x, tool4_y) = point_in_tool_segment(&layout, 4);
+ state.update_radial_menu_hover(tool4_x, tool4_y);
+ assert!(state.needs_redraw);
+
+ // Move to sub-ring radius outside parent angle. Hover becomes None, sub-ring stays expanded.
+ state.needs_redraw = false;
+ let (sub_none_x, sub_none_y) = point_at(layout.center_x, layout.center_y, 110.0, 0.0);
+ state.update_radial_menu_hover(sub_none_x, sub_none_y);
+ assert!(state.needs_redraw);
+
+ // Move into center/tool gap: hover remains None, but expanded sub-ring should collapse.
+ // This transition must still request redraw.
+ state.needs_redraw = false;
+ let (collapse_x, collapse_y) = point_at(layout.center_x, layout.center_y, 35.0, 0.0);
+ state.update_radial_menu_hover(collapse_x, collapse_y);
+ assert!(state.needs_redraw);
+}
+
+#[test]
+fn size_for_active_tool_uses_eraser_size_for_eraser() {
+ let mut state = create_test_input_state();
+ state.current_thickness = 3.0;
+ state.eraser_size = 17.0;
+
+ assert!(state.set_tool_override(Some(Tool::Eraser)));
+ assert!((state.size_for_active_tool() - 17.0).abs() < f64::EPSILON);
+
+ assert!(state.set_tool_override(Some(Tool::Pen)));
+ assert!((state.size_for_active_tool() - 3.0).abs() < f64::EPSILON);
+}
+
+#[test]
+fn opening_radial_menu_closes_help_overlay() {
+ let mut state = create_test_input_state();
+ state.toggle_help_overlay();
+ assert!(state.show_help);
+
+ state.open_radial_menu(120.0, 90.0);
+
+ assert!(!state.show_help);
+ assert!(state.is_radial_menu_open());
+}
+
+#[test]
+fn right_click_when_radial_open_opens_context_menu() {
+ let mut state = create_test_input_state();
+ state.open_radial_menu(200.0, 150.0);
+ assert!(state.is_radial_menu_open());
+
+ state.on_mouse_press(MouseButton::Right, 200, 150);
+
+ assert!(!state.is_radial_menu_open());
+ assert!(state.is_context_menu_open());
+}
+
+#[test]
+fn selecting_clear_tool_segment_clears_canvas() {
+ let mut state = create_test_input_state();
+ let shape_id = state.boards.active_frame_mut().add_shape(Shape::Rect {
+ x: 24,
+ y: 24,
+ w: 80,
+ h: 64,
+ fill: false,
+ color: state.current_color,
+ thick: state.current_thickness,
+ });
+ assert!(state.boards.active_frame().shape(shape_id).is_some());
+
+ state.open_radial_menu(220.0, 160.0);
+ state.update_radial_menu_layout(800, 600);
+ let layout = state
+ .radial_menu_layout
+ .expect("layout should exist for open radial menu");
+
+ // Segment 8 is the Clear Canvas action.
+ let (clear_x, clear_y) = point_in_tool_segment(&layout, 8);
+ state.update_radial_menu_hover(clear_x, clear_y);
+ state.radial_menu_select_hovered();
+
+ assert!(!state.is_radial_menu_open());
+ assert!(state.boards.active_frame().shape(shape_id).is_none());
+}
+
+#[test]
+fn color_hit_test_uses_color_ring_alignment_when_tool_count_differs() {
+ let mut state = create_test_input_state();
+ state.open_radial_menu(300.0, 220.0);
+ state.update_radial_menu_layout(900, 700);
+ let layout = state
+ .radial_menu_layout
+ .expect("layout should exist for open radial menu");
+
+ // Just inside color segment 1 (Green), close to the segment boundary.
+ let seg = 2.0 * PI / RADIAL_COLOR_SEGMENT_COUNT as f64;
+ let probe_angle = -PI / 2.0 + seg + 0.04;
+ let probe_radius = (layout.color_inner + layout.color_outer) / 2.0;
+ let probe_x = layout.center_x + probe_radius * probe_angle.cos();
+ let probe_y = layout.center_y + probe_radius * probe_angle.sin();
+
+ state.update_radial_menu_hover(probe_x, probe_y);
+ state.radial_menu_select_hovered();
+
+ let expected = radial_color_for_index(1);
+ assert!(colors_approx_eq(&state.current_color, &expected));
+}
+
+fn colors_approx_eq(a: &Color, b: &Color) -> bool {
+ (a.r - b.r).abs() < 0.01 && (a.g - b.g).abs() < 0.01 && (a.b - b.b).abs() < 0.01
+}
diff --git a/src/ui.rs b/src/ui.rs
index 91b4ab56..68785a81 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -8,6 +8,7 @@ mod context_menu;
mod help_overlay;
mod primitives;
mod properties_panel;
+mod radial_menu;
mod status;
mod toasts;
mod tour;
@@ -21,6 +22,7 @@ pub use help_overlay::HelpOverlayBindings;
#[allow(unused_imports)]
pub use help_overlay::{invalidate_help_overlay_cache, render_help_overlay};
pub use properties_panel::render_properties_panel;
+pub use radial_menu::render_radial_menu;
pub use status::{
render_editing_badge, render_frozen_badge, render_page_badge, render_status_bar,
render_zoom_badge,
diff --git a/src/ui/radial_menu.rs b/src/ui/radial_menu.rs
new file mode 100644
index 00000000..14ee198e
--- /dev/null
+++ b/src/ui/radial_menu.rs
@@ -0,0 +1,327 @@
+use std::f64::consts::PI;
+
+use crate::input::InputState;
+use crate::input::Tool;
+use crate::input::state::{
+ RADIAL_COLOR_SEGMENT_COUNT, RADIAL_TOOL_LABELS, RADIAL_TOOL_SEGMENT_COUNT, RadialMenuLayout,
+ RadialMenuState, RadialSegmentId, radial_color_for_index, sub_ring_child_count,
+ sub_ring_child_label,
+};
+
+/// Render the radial menu overlay.
+pub fn render_radial_menu(ctx: &cairo::Context, input_state: &InputState, width: u32, height: u32) {
+ let (hover, expanded_sub_ring) = match &input_state.radial_menu_state {
+ RadialMenuState::Open {
+ hover,
+ expanded_sub_ring,
+ ..
+ } => (*hover, *expanded_sub_ring),
+ RadialMenuState::Hidden => return,
+ };
+
+ let layout = match &input_state.radial_menu_layout {
+ Some(l) => *l,
+ None => return,
+ };
+
+ let _ = ctx.save();
+
+ // Semi-transparent backdrop
+ ctx.set_source_rgba(0.0, 0.0, 0.0, 0.25);
+ ctx.rectangle(0.0, 0.0, width as f64, height as f64);
+ let _ = ctx.fill();
+
+ let cx = layout.center_x;
+ let cy = layout.center_y;
+ let seg_angle = 2.0 * PI / RADIAL_TOOL_SEGMENT_COUNT as f64;
+ let color_seg_angle = 2.0 * PI / RADIAL_COLOR_SEGMENT_COUNT as f64;
+ // Angle offset: segment 0 starts at top, centered
+ let offset = -PI / 2.0 - seg_angle / 2.0;
+ let color_offset = -PI / 2.0 - color_seg_angle / 2.0;
+
+ let active_tool = input_state.active_tool();
+
+ // ── Color ring (outermost) ──
+ for i in 0..RADIAL_COLOR_SEGMENT_COUNT {
+ let start = color_offset + i as f64 * color_seg_angle;
+ let end = start + color_seg_angle;
+ let is_hovered = hover == Some(RadialSegmentId::Color(i as u8));
+
+ let c = radial_color_for_index(i as u8);
+ // Check if this color matches current
+ let is_active = colors_match(&input_state.current_color, &c);
+
+ draw_annular_sector(
+ ctx,
+ cx,
+ cy,
+ layout.color_inner,
+ layout.color_outer,
+ start,
+ end,
+ );
+ ctx.set_source_rgba(c.r, c.g, c.b, c.a);
+ let _ = ctx.fill_preserve();
+
+ if is_hovered {
+ ctx.set_source_rgba(1.0, 1.0, 1.0, 0.35);
+ let _ = ctx.fill_preserve();
+ }
+
+ // Border
+ if is_active {
+ ctx.set_source_rgba(1.0, 1.0, 1.0, 0.9);
+ ctx.set_line_width(2.5);
+ } else {
+ ctx.set_source_rgba(0.0, 0.0, 0.0, 0.4);
+ ctx.set_line_width(1.0);
+ }
+ let _ = ctx.stroke();
+ }
+
+ // ── Sub-ring (if expanded) ──
+ if let Some(parent_idx) = expanded_sub_ring {
+ let child_count = sub_ring_child_count(parent_idx);
+ if child_count > 0 {
+ let parent_start = offset + parent_idx as f64 * seg_angle;
+ let child_angle = seg_angle / child_count as f64;
+
+ for ci in 0..child_count {
+ let start = parent_start + ci as f64 * child_angle;
+ let end = start + child_angle;
+ let is_hovered = hover == Some(RadialSegmentId::SubTool(parent_idx, ci as u8));
+
+ draw_annular_sector(ctx, cx, cy, layout.sub_inner, layout.sub_outer, start, end);
+
+ // Background
+ if is_hovered {
+ ctx.set_source_rgba(0.30, 0.50, 0.80, 0.85);
+ } else {
+ ctx.set_source_rgba(0.15, 0.18, 0.25, 0.90);
+ }
+ let _ = ctx.fill_preserve();
+
+ // Border
+ ctx.set_source_rgba(0.35, 0.40, 0.50, 0.7);
+ ctx.set_line_width(1.0);
+ let _ = ctx.stroke();
+
+ // Label
+ let mid_angle = start + child_angle / 2.0;
+ let mid_r = (layout.sub_inner + layout.sub_outer) / 2.0;
+ let lx = cx + mid_r * mid_angle.cos();
+ let ly = cy + mid_r * mid_angle.sin();
+ let label = sub_ring_child_label(parent_idx, ci as u8);
+ draw_centered_label(ctx, lx, ly, label, 10.0, is_hovered);
+ }
+ }
+ }
+
+ // ── Tool ring ──
+ for (i, label) in RADIAL_TOOL_LABELS.iter().enumerate() {
+ let start = offset + i as f64 * seg_angle;
+ let end = start + seg_angle;
+ let segment_idx = i as u8;
+ let is_hovered = hover == Some(RadialSegmentId::Tool(segment_idx));
+ let is_active = tool_segment_matches(segment_idx, active_tool, input_state);
+
+ draw_annular_sector(
+ ctx,
+ cx,
+ cy,
+ layout.tool_inner,
+ layout.tool_outer,
+ start,
+ end,
+ );
+
+ // Background
+ if is_hovered {
+ ctx.set_source_rgba(0.30, 0.50, 0.80, 0.85);
+ } else if is_active {
+ ctx.set_source_rgba(0.22, 0.35, 0.55, 0.85);
+ } else {
+ ctx.set_source_rgba(0.12, 0.15, 0.22, 0.88);
+ }
+ let _ = ctx.fill_preserve();
+
+ // Border
+ if is_active {
+ ctx.set_source_rgba(0.45, 0.65, 0.95, 0.85);
+ ctx.set_line_width(2.0);
+ } else {
+ ctx.set_source_rgba(0.30, 0.35, 0.45, 0.7);
+ ctx.set_line_width(1.0);
+ }
+ let _ = ctx.stroke();
+
+ // Label
+ let mid_angle = start + seg_angle / 2.0;
+ let mid_r = (layout.tool_inner + layout.tool_outer) / 2.0;
+ let lx = cx + mid_r * mid_angle.cos();
+ let ly = cy + mid_r * mid_angle.sin();
+ draw_centered_label(ctx, lx, ly, label, 11.0, is_hovered);
+ }
+
+ // ── Center circle ──
+ let is_center_hovered = hover == Some(RadialSegmentId::Center);
+ ctx.new_path();
+ ctx.arc(cx, cy, layout.center_radius, 0.0, 2.0 * PI);
+ if is_center_hovered {
+ ctx.set_source_rgba(0.25, 0.30, 0.40, 0.95);
+ } else {
+ ctx.set_source_rgba(0.10, 0.13, 0.18, 0.95);
+ }
+ let _ = ctx.fill_preserve();
+
+ // Color indicator ring around center
+ ctx.set_source_rgba(
+ input_state.current_color.r,
+ input_state.current_color.g,
+ input_state.current_color.b,
+ 0.9,
+ );
+ ctx.set_line_width(3.0);
+ let _ = ctx.stroke();
+
+ // Current tool label in center
+ let tool_label = active_tool_short_label(active_tool, input_state);
+ draw_centered_label(ctx, cx, cy - 2.0, tool_label, 10.0, false);
+
+ // Thickness gauge (small arc near bottom of center)
+ draw_thickness_gauge(ctx, &layout, input_state);
+
+ let _ = ctx.restore();
+}
+
+/// Draw an annular (ring) sector path.
+fn draw_annular_sector(
+ ctx: &cairo::Context,
+ cx: f64,
+ cy: f64,
+ r_inner: f64,
+ r_outer: f64,
+ start_angle: f64,
+ end_angle: f64,
+) {
+ ctx.new_path();
+ ctx.arc(cx, cy, r_outer, start_angle, end_angle);
+ ctx.arc_negative(cx, cy, r_inner, end_angle, start_angle);
+ ctx.close_path();
+}
+
+/// Draw a centered text label at the given position.
+fn draw_centered_label(
+ ctx: &cairo::Context,
+ x: f64,
+ y: f64,
+ text: &str,
+ size: f64,
+ highlighted: bool,
+) {
+ ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Normal);
+ ctx.set_font_size(size);
+ let Ok(extents) = ctx.text_extents(text) else {
+ return;
+ };
+ let tx = x - extents.width() / 2.0 - extents.x_bearing();
+ let ty = y - extents.height() / 2.0 - extents.y_bearing();
+
+ if highlighted {
+ ctx.set_source_rgba(1.0, 1.0, 1.0, 1.0);
+ } else {
+ ctx.set_source_rgba(0.85, 0.88, 0.93, 0.95);
+ }
+ ctx.move_to(tx, ty);
+ let _ = ctx.show_text(text);
+}
+
+/// Draw a small thickness gauge arc near the center bottom.
+fn draw_thickness_gauge(ctx: &cairo::Context, layout: &RadialMenuLayout, input_state: &InputState) {
+ use crate::input::state::{MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS};
+
+ let size = input_state.size_for_active_tool();
+ let frac = (size - MIN_STROKE_THICKNESS) / (MAX_STROKE_THICKNESS - MIN_STROKE_THICKNESS);
+ let frac = frac.clamp(0.0, 1.0);
+
+ let gauge_r = layout.center_radius - 6.0;
+ // Arc from 0.3*PI to 0.7*PI (bottom arc)
+ let arc_start = 0.3 * PI;
+ let arc_end = 0.7 * PI;
+ let arc_range = arc_end - arc_start;
+
+ // Track
+ ctx.new_path();
+ ctx.set_source_rgba(0.4, 0.4, 0.45, 0.5);
+ ctx.set_line_width(2.0);
+ ctx.arc(
+ layout.center_x,
+ layout.center_y,
+ gauge_r,
+ arc_start,
+ arc_end,
+ );
+ let _ = ctx.stroke();
+
+ // Fill
+ if frac > 0.01 {
+ ctx.new_path();
+ ctx.set_source_rgba(0.45, 0.70, 1.0, 0.8);
+ ctx.set_line_width(2.5);
+ ctx.arc(
+ layout.center_x,
+ layout.center_y,
+ gauge_r,
+ arc_start,
+ arc_start + arc_range * frac,
+ );
+ let _ = ctx.stroke();
+ }
+}
+
+/// Check whether two colors are approximately equal.
+fn colors_match(a: &crate::draw::Color, b: &crate::draw::Color) -> bool {
+ (a.r - b.r).abs() < 0.01 && (a.g - b.g).abs() < 0.01 && (a.b - b.b).abs() < 0.01
+}
+
+/// Check whether a tool segment index matches the current active tool.
+fn tool_segment_matches(idx: u8, tool: Tool, input_state: &InputState) -> bool {
+ match idx {
+ 0 => tool == Tool::Pen,
+ 1 => tool == Tool::Marker,
+ 2 => tool == Tool::Line,
+ 3 => tool == Tool::Arrow,
+ 4 => tool == Tool::Rect || tool == Tool::Ellipse,
+ 5 => {
+ matches!(
+ input_state.state,
+ crate::input::DrawingState::TextInput { .. }
+ ) || tool == Tool::StepMarker
+ }
+ 6 => tool == Tool::Eraser,
+ 7 => tool == Tool::Select,
+ _ => false,
+ }
+}
+
+/// Short label for the active tool shown in the center circle.
+fn active_tool_short_label(tool: Tool, input_state: &InputState) -> &'static str {
+ if matches!(
+ input_state.state,
+ crate::input::DrawingState::TextInput { .. }
+ ) {
+ return "Text";
+ }
+ match tool {
+ Tool::Pen => "Pen",
+ Tool::Marker => "Marker",
+ Tool::Line => "Line",
+ Tool::Arrow => "Arrow",
+ Tool::Rect => "Rect",
+ Tool::Ellipse => "Ellipse",
+ Tool::Eraser => "Eraser",
+ Tool::Select => "Select",
+ Tool::Highlight => "Highlight",
+ Tool::StepMarker => "Step",
+ }
+}
diff --git a/src/ui/status/bar.rs b/src/ui/status/bar.rs
index ce7697ae..e642fa79 100644
--- a/src/ui/status/bar.rs
+++ b/src/ui/status/bar.rs
@@ -32,11 +32,7 @@ pub fn render_status_bar(
) {
let color = &input_state.current_color;
let tool = input_state.active_tool();
- let thickness = if tool == Tool::Eraser {
- input_state.eraser_size
- } else {
- input_state.current_thickness
- };
+ let thickness = input_state.size_for_active_tool();
let tool_name = tool_display_name(input_state, tool);
let color_name = crate::util::color_to_name(color);
From 6b6a032b93504e789fec233079a17f079bdfd072 Mon Sep 17 00:00:00 2001
From: devmobasa <4170275+devmobasa@users.noreply.github.com>
Date: Thu, 12 Feb 2026 14:51:33 +0100
Subject: [PATCH 2/5] fix(arrow): unify render, hit-test, and bounds geometry
---
src/draw/render/primitives.rs | 67 +++++++----------------
src/draw/shape/bounds.rs | 21 ++++---
src/draw/shape/tests.rs | 5 +-
src/input/hit_test/mod.rs | 1 +
src/input/hit_test/shapes.rs | 18 +++++-
src/input/hit_test/tests.rs | 4 +-
src/util/arrow.rs | 100 +++++++++++++++++++---------------
src/util/mod.rs | 2 +-
src/util/tests.rs | 19 ++++---
9 files changed, 121 insertions(+), 116 deletions(-)
diff --git a/src/draw/render/primitives.rs b/src/draw/render/primitives.rs
index 7577afe3..e00200cb 100644
--- a/src/draw/render/primitives.rs
+++ b/src/draw/render/primitives.rs
@@ -1,4 +1,5 @@
use crate::draw::Color;
+use crate::util;
/// Render a straight line
pub(super) fn render_line(
@@ -106,57 +107,29 @@ pub(super) fn render_arrow(
arrow_angle: f64,
head_at_end: bool,
) {
- let dx = (x2 - x1) as f64;
- let dy = (y2 - y1) as f64;
- let line_length = (dx * dx + dy * dy).sqrt();
-
- if line_length < 1.0 {
- return;
- }
-
- // Unit vector along the arrow direction (from tail to tip)
- let ux = dx / line_length;
- let uy = dy / line_length;
-
- // Perpendicular unit vector
- let px = -uy;
- let py = ux;
-
// Determine which end gets the arrowhead
let (tip_x, tip_y, tail_x, tail_y) = if head_at_end {
- (x2 as f64, y2 as f64, x1 as f64, y1 as f64)
+ (x2, y2, x1, y1)
} else {
- (x1 as f64, y1 as f64, x2 as f64, y2 as f64)
+ (x1, y1, x2, y2)
};
- // Direction from tip toward tail
- let arrow_dx = tail_x - tip_x;
- let arrow_dy = tail_y - tip_y;
- let arrow_dist = (arrow_dx * arrow_dx + arrow_dy * arrow_dy).sqrt();
- let (arrow_ux, arrow_uy) = if arrow_dist > 0.0 {
- (arrow_dx / arrow_dist, arrow_dy / arrow_dist)
- } else {
- (0.0, 0.0)
+ let Some(geometry) = util::calculate_arrowhead_triangle_custom(
+ tip_x,
+ tip_y,
+ tail_x,
+ tail_y,
+ thick,
+ arrow_length,
+ arrow_angle,
+ ) else {
+ return;
};
-
- // Scale arrowhead: ensure it's at least 2x the line thickness for visibility
- let scaled_length = arrow_length.max(thick * 2.5);
- // Cap at 40% of line length to avoid oversized heads on short arrows
- let effective_length = scaled_length.min(line_length * 0.4);
-
- // Calculate arrowhead base width: must cover the line thickness, plus extra for the angle
- let angle_rad = arrow_angle.to_radians();
- let half_base_from_angle = effective_length * angle_rad.tan();
- let half_base = half_base_from_angle.max(thick * 0.6);
-
- // Arrowhead points
- let base_x = tip_x + arrow_ux * effective_length;
- let base_y = tip_y + arrow_uy * effective_length;
-
- let left_x = base_x + px * half_base;
- let left_y = base_y + py * half_base;
- let right_x = base_x - px * half_base;
- let right_y = base_y - py * half_base;
+ let (tip_x, tip_y) = geometry.tip;
+ let (base_x, base_y) = geometry.base;
+ let (left_x, left_y) = geometry.left;
+ let (right_x, right_y) = geometry.right;
+ let (tail_x, tail_y) = (tail_x as f64, tail_y as f64);
// Draw the shaft line, stopping at the arrowhead base to avoid overlap
ctx.save().ok();
@@ -165,11 +138,11 @@ pub(super) fn render_arrow(
ctx.set_line_cap(cairo::LineCap::Butt);
if head_at_end {
- ctx.move_to(x1 as f64, y1 as f64);
+ ctx.move_to(tail_x, tail_y);
ctx.line_to(base_x, base_y);
} else {
ctx.move_to(base_x, base_y);
- ctx.line_to(x2 as f64, y2 as f64);
+ ctx.line_to(tail_x, tail_y);
}
let _ = ctx.stroke();
diff --git a/src/draw/shape/bounds.rs b/src/draw/shape/bounds.rs
index 0005ca04..c44d202c 100644
--- a/src/draw/shape/bounds.rs
+++ b/src/draw/shape/bounds.rs
@@ -93,19 +93,24 @@ pub(crate) fn bounding_box_for_arrow(
(x1, y1, x2, y2)
};
- let arrow_points =
- util::calculate_arrowhead_custom(tip_x, tip_y, tail_x, tail_y, arrow_length, arrow_angle);
-
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;
- for &(px, py) in &arrow_points {
- min_x = min_x.min(px);
- max_x = max_x.max(px);
- min_y = min_y.min(py);
- max_y = max_y.max(py);
+ if let Some(geometry) = util::calculate_arrowhead_triangle_custom(
+ tip_x,
+ tip_y,
+ tail_x,
+ tail_y,
+ thick,
+ arrow_length,
+ arrow_angle,
+ ) {
+ 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 padding = stroke_padding(thick) as f64;
diff --git a/src/draw/shape/tests.rs b/src/draw/shape/tests.rs
index 03f4aef2..51983002 100644
--- a/src/draw/shape/tests.rs
+++ b/src/draw/shape/tests.rs
@@ -60,8 +60,9 @@ fn arrow_bounding_box_includes_head() {
assert!(x_min <= 50 && x_max >= 100);
assert!(y_min <= 100 && y_max >= 120);
- let arrow_points = util::calculate_arrowhead_custom(100, 100, 50, 120, 20.0, 30.0);
- for &(px, py) in &arrow_points {
+ 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] {
assert!(px >= x_min as f64 && px <= x_max as f64);
assert!(py >= y_min as f64 && py <= y_max as f64);
}
diff --git a/src/input/hit_test/mod.rs b/src/input/hit_test/mod.rs
index 71a0ff89..b27315a8 100644
--- a/src/input/hit_test/mod.rs
+++ b/src/input/hit_test/mod.rs
@@ -92,6 +92,7 @@ pub fn hit_test(shape: &DrawnShape, point: (i32, i32), tolerance: f64) -> bool {
tip_y,
tail_x,
tail_y,
+ *thick,
*arrow_length,
*arrow_angle,
point,
diff --git a/src/input/hit_test/shapes.rs b/src/input/hit_test/shapes.rs
index 553616c2..32744d1a 100644
--- a/src/input/hit_test/shapes.rs
+++ b/src/input/hit_test/shapes.rs
@@ -132,14 +132,26 @@ pub(super) fn arrowhead_hit(
tip_y: i32,
tail_x: i32,
tail_y: i32,
+ thick: f64,
arrow_length: f64,
arrow_angle: f64,
point: (i32, i32),
tolerance: f64,
) -> bool {
- let [(left_x, left_y), (right_x, right_y)] =
- util::calculate_arrowhead_custom(tip_x, tip_y, tail_x, tail_y, arrow_length, arrow_angle);
- let tip = (tip_x as f64, tip_y as f64);
+ 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;
let p = (point.0 as f64, point.1 as f64);
if point_in_triangle(p, tip, (left_x, left_y), (right_x, right_y)) {
return true;
diff --git a/src/input/hit_test/tests.rs b/src/input/hit_test/tests.rs
index 0473802d..07787a0b 100644
--- a/src/input/hit_test/tests.rs
+++ b/src/input/hit_test/tests.rs
@@ -103,12 +103,12 @@ fn arrowhead_hit_detects_point_near_tip_and_rejects_distant_point() {
let tail = (0, -20);
assert!(
- shapes::arrowhead_hit(tip.0, tip.1, tail.0, tail.1, 10.0, 30.0, tip, 0.5),
+ shapes::arrowhead_hit(tip.0, tip.1, tail.0, tail.1, 2.0, 10.0, 30.0, tip, 0.5),
"tip point should be inside arrowhead"
);
assert!(
- !shapes::arrowhead_hit(tip.0, tip.1, tail.0, tail.1, 10.0, 30.0, (50, 50), 0.5),
+ !shapes::arrowhead_hit(tip.0, tip.1, tail.0, tail.1, 2.0, 10.0, 30.0, (50, 50), 0.5),
"faraway point should not be inside arrowhead even with tolerance"
);
}
diff --git a/src/util/arrow.rs b/src/util/arrow.rs
index b22273e3..d089003c 100644
--- a/src/util/arrow.rs
+++ b/src/util/arrow.rs
@@ -1,56 +1,66 @@
-/// Calculates arrowhead points with custom length and angle.
-///
-/// Creates a V-shaped arrowhead at position (x1, y1) pointing in the direction
-/// from (x2, y2) to (x1, y1). The arrowhead length is automatically capped at
-/// 30% of the line length to prevent weird-looking arrows on short lines.
-///
-/// # Arguments
-/// * `x1` - Arrowhead tip X coordinate
-/// * `y1` - Arrowhead tip Y coordinate
-/// * `x2` - Arrow tail X coordinate
-/// * `y2` - Arrow tail Y coordinate
-/// * `length` - Desired arrowhead length in pixels (will be capped at 30% of line length)
-/// * `angle_degrees` - Arrowhead angle in degrees (angle between arrowhead lines and main line)
+/// Arrowhead triangle geometry used by rendering, hit-testing, and bounds.
+#[derive(Debug, Clone, Copy)]
+pub(crate) struct ArrowheadTriangle {
+ pub tip: (f64, f64),
+ pub base: (f64, f64),
+ pub left: (f64, f64),
+ pub right: (f64, f64),
+}
+
+/// Calculates arrowhead triangle points matching the renderer's geometry model.
///
-/// # Returns
-/// Array of two points `[(left_x, left_y), (right_x, right_y)]` for the arrowhead lines.
-/// If the line is too short (< 1 pixel), both points equal (x1, y1).
-pub fn calculate_arrowhead_custom(
- x1: i32,
- y1: i32,
- x2: i32,
- y2: i32,
- length: f64,
- angle_degrees: f64,
-) -> [(f64, f64); 2] {
- let dx = (x1 - x2) as f64; // Direction from END to START (reversed)
- let dy = (y1 - y2) as f64;
- let line_length = (dx * dx + dy * dy).sqrt();
+/// This helper must remain in sync with `render_arrow` so dirty-region bounds and
+/// hit-testing stay aligned with the visual arrowhead.
+#[allow(clippy::too_many_arguments)]
+pub(crate) fn calculate_arrowhead_triangle_custom(
+ tip_x: i32,
+ tip_y: i32,
+ tail_x: i32,
+ tail_y: i32,
+ thick: f64,
+ arrow_length: f64,
+ arrow_angle: f64,
+) -> Option {
+ let tip_x = tip_x as f64;
+ let tip_y = tip_y as f64;
+ let tail_x = tail_x as f64;
+ let tail_y = tail_y as f64;
+ let dir_x = tail_x - tip_x;
+ let dir_y = tail_y - tip_y;
+ let line_length = (dir_x * dir_x + dir_y * dir_y).sqrt();
if line_length < 1.0 {
- // Line too short for arrowhead
- return [(x1 as f64, y1 as f64), (x1 as f64, y1 as f64)];
+ return None;
}
- // Normalize direction vector (pointing from end to start)
- let ux = dx / line_length;
- let uy = dy / line_length;
+ // Direction from tip toward tail.
+ let ux = dir_x / line_length;
+ let uy = dir_y / line_length;
+
+ // Perpendicular unit vector.
+ let px = -uy;
+ let py = ux;
- // Arrowhead length (max 30% of line length to avoid weird-looking arrows on short lines)
- let arrow_length = length.min(line_length * 0.3);
+ // Keep heads visible for thick strokes but avoid oversized heads on short lines.
+ let scaled_length = arrow_length.max(thick * 2.5);
+ let effective_length = scaled_length.min(line_length * 0.4);
- // Convert angle to radians
- let angle = angle_degrees.to_radians();
- let cos_a = angle.cos();
- let sin_a = angle.sin();
+ let angle_rad = arrow_angle.to_radians();
+ let half_base_from_angle = effective_length * angle_rad.tan();
+ let half_base = half_base_from_angle.max(thick * 0.6);
- // Left side of arrowhead (at START point)
- let left_x = x1 as f64 - arrow_length * (ux * cos_a - uy * sin_a);
- let left_y = y1 as f64 - arrow_length * (uy * cos_a + ux * sin_a);
+ let base_x = tip_x + ux * effective_length;
+ let base_y = tip_y + uy * effective_length;
- // Right side of arrowhead (at START point)
- let right_x = x1 as f64 - arrow_length * (ux * cos_a + uy * sin_a);
- let right_y = y1 as f64 - arrow_length * (uy * cos_a - ux * sin_a);
+ let left_x = base_x + px * half_base;
+ let left_y = base_y + py * half_base;
+ let right_x = base_x - px * half_base;
+ let right_y = base_y - py * half_base;
- [(left_x, left_y), (right_x, right_y)]
+ Some(ArrowheadTriangle {
+ tip: (tip_x, tip_y),
+ base: (base_x, base_y),
+ left: (left_x, left_y),
+ right: (right_x, right_y),
+ })
}
diff --git a/src/util/mod.rs b/src/util/mod.rs
index f19897f6..1136ee79 100644
--- a/src/util/mod.rs
+++ b/src/util/mod.rs
@@ -11,7 +11,7 @@ mod colors;
mod geometry;
mod text;
-pub use arrow::calculate_arrowhead_custom;
+pub(crate) use arrow::calculate_arrowhead_triangle_custom;
pub use colors::{color_to_name, key_to_color, name_to_color};
pub use geometry::{Rect, ellipse_bounds};
pub use text::truncate_with_ellipsis;
diff --git a/src/util/tests.rs b/src/util/tests.rs
index 56de345d..24d56e3c 100644
--- a/src/util/tests.rs
+++ b/src/util/tests.rs
@@ -2,17 +2,20 @@ use super::*;
use crate::draw::{BLACK, Color, RED, WHITE};
#[test]
-fn arrowhead_caps_at_thirty_percent_of_line_length() {
- let [(lx, ly), _] = calculate_arrowhead_custom(10, 10, 0, 10, 100.0, 30.0);
- let distance = ((10.0 - lx).powi(2) + (10.0 - ly).powi(2)).sqrt();
- assert!((distance - 3.0).abs() < f64::EPSILON);
+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)
+ .expect("non-degenerate line should yield geometry");
+ let distance = ((geometry.tip.0 - geometry.base.0).powi(2)
+ + (geometry.tip.1 - geometry.base.1).powi(2))
+ .sqrt();
+ assert!((distance - 4.0).abs() < f64::EPSILON);
}
#[test]
-fn arrowhead_handles_degenerate_lines() {
- let [(lx, ly), (rx, ry)] = calculate_arrowhead_custom(5, 5, 5, 5, 15.0, 45.0);
- assert_eq!((lx, ly), (5.0, 5.0));
- assert_eq!((rx, ry), (5.0, 5.0));
+fn arrowhead_triangle_handles_degenerate_lines() {
+ let geometry = calculate_arrowhead_triangle_custom(5, 5, 5, 5, 2.0, 15.0, 45.0);
+ assert!(geometry.is_none());
}
#[test]
From fa6577a5cd33a01d0698d96a003c31381404a6ed Mon Sep 17 00:00:00 2001
From: devmobasa <4170275+devmobasa@users.noreply.github.com>
Date: Thu, 12 Feb 2026 14:54:24 +0100
Subject: [PATCH 3/5] update gitignore
---
.gitignore | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.gitignore b/.gitignore
index 5f249a8e..61acde2e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -15,7 +15,7 @@
# Local agent instructions
AGENTS.md
-CLAUDE.MD
+CLAUDE.md
From fd619413cdc2b1e7b60bf6ae229b794df9bdec94 Mon Sep 17 00:00:00 2001
From: devmobasa <4170275+devmobasa@users.noreply.github.com>
Date: Thu, 12 Feb 2026 16:16:39 +0100
Subject: [PATCH 4/5] feat(radial-menu): add configurable triggers and
keybinding toggle
---
config.example.toml | 7 +++
docs/CONFIG.md | 9 ++++
.../wayland/backend/state_init/input_state.rs | 1 +
src/config/action_meta/entries/ui.rs | 11 ++++
src/config/enums.rs | 12 +++++
src/config/keybindings/actions.rs | 1 +
src/config/keybindings/config/map/ui.rs | 1 +
.../keybindings/config/types/bindings/ui.rs | 4 ++
src/config/keybindings/defaults/ui.rs | 4 ++
src/config/mod.rs | 2 +-
src/config/types/ui.rs | 11 +++-
src/input/state/actions/action_ui.rs | 9 ++++
src/input/state/actions/key_press/mod.rs | 50 +++++++++++-------
src/input/state/core/base/state/init.rs | 3 +-
src/input/state/core/base/state/structs.rs | 6 ++-
src/input/state/mouse/press.rs | 30 +++++++++--
src/input/state/tests/helpers.rs | 7 ++-
src/input/state/tests/radial_menu.rs | 52 +++++++++++++++++++
18 files changed, 192 insertions(+), 28 deletions(-)
diff --git a/config.example.toml b/config.example.toml
index 1d048b71..cdb7c668 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -132,6 +132,9 @@ toggle_presenter_mode = ["Ctrl+Shift+M"]
# Toggle fill for rectangle/ellipse
toggle_fill = []
+# Optional keyboard binding to toggle radial menu at cursor
+toggle_radial_menu = []
+
# Toggle selection properties panel
toggle_selection_properties = ["Ctrl+Alt+P"]
@@ -259,6 +262,10 @@ active_output_badge = true
# Request fullscreen for the GNOME fallback overlay. Disable if fullscreen appears opaque.
#xdg_fullscreen = false
+# Mouse button that toggles radial menu
+# Options: "middle", "right", "disabled"
+radial_menu_mouse_binding = "middle"
+
# ───────────────────────────────────────────────────────────────────────────────
# Floating Toolbars (Press F2/F9 to toggle)
# ───────────────────────────────────────────────────────────────────────────────
diff --git a/docs/CONFIG.md b/docs/CONFIG.md
index 5450fb49..565da1b7 100644
--- a/docs/CONFIG.md
+++ b/docs/CONFIG.md
@@ -254,6 +254,10 @@ active_output_badge = true
# Request fullscreen for the GNOME fallback overlay (disable if opaque)
#xdg_fullscreen = false
+# Mouse button that toggles radial menu
+# Options: "middle", "right", "disabled"
+radial_menu_mouse_binding = "middle"
+
# Status bar styling
[ui.status_bar_style]
font_size = 21.0
@@ -309,6 +313,7 @@ enabled = true
- **Context menu**: `ui.context_menu.enabled` toggles right-click / keyboard menus
- **Output focus**: `multi_monitor_enabled` controls output-cycling shortcuts; `active_output_badge` shows the current monitor in the status bar
- **GNOME fallback**: `preferred_output` pins the xdg-shell overlay to a specific monitor; `xdg_fullscreen` requests fullscreen instead of maximized
+- **Radial menu trigger**: `radial_menu_mouse_binding` selects which mouse button opens radial menu (`middle` default, `right`, or `disabled`)
**Multi-monitor behavior:**
- Use `focus_prev_output` / `focus_next_output` (default: Ctrl+Alt+Shift+←/Ctrl+Alt+Shift+→) to move overlay focus between outputs.
@@ -321,6 +326,7 @@ enabled = true
- Show status bar: true
- Show frozen badge: false
- Position: bottom-left
+- Radial menu mouse trigger: middle
- Status bar font: 21px
- Help overlay font: 14px
- Semi-transparent dark backgrounds with muted borders
@@ -799,6 +805,9 @@ toggle_click_highlight = ["Ctrl+Shift+H"]
# Toggle fill for rectangle/ellipse
toggle_fill = []
+# Optional keyboard binding to toggle radial menu at cursor
+toggle_radial_menu = []
+
# Toggle selection properties panel
toggle_selection_properties = ["Ctrl+Alt+P"]
diff --git a/src/backend/wayland/backend/state_init/input_state.rs b/src/backend/wayland/backend/state_init/input_state.rs
index 036b76dd..87eb1c98 100644
--- a/src/backend/wayland/backend/state_init/input_state.rs
+++ b/src/backend/wayland/backend/state_init/input_state.rs
@@ -53,6 +53,7 @@ pub(super) fn build_input_state(config: &Config) -> InputState {
input_state.show_floating_badge_always = config.ui.show_floating_badge_always;
input_state.show_active_output_badge = config.ui.active_output_badge;
input_state.command_palette_toast_duration_ms = config.ui.command_palette_toast_duration_ms;
+ input_state.radial_menu_mouse_binding = config.ui.radial_menu_mouse_binding;
#[cfg(tablet)]
{
input_state.pressure_variation_threshold = config.tablet.pressure_variation_threshold;
diff --git a/src/config/action_meta/entries/ui.rs b/src/config/action_meta/entries/ui.rs
index d8fcef39..fc2788bf 100644
--- a/src/config/action_meta/entries/ui.rs
+++ b/src/config/action_meta/entries/ui.rs
@@ -61,6 +61,17 @@ pub const ENTRIES: &[ActionMeta] = &[
true,
false
),
+ meta!(
+ ToggleRadialMenu,
+ "Radial Menu",
+ None,
+ "Toggle radial menu at cursor",
+ UI,
+ true,
+ false,
+ false,
+ &["pie menu"]
+ ),
meta!(
ToggleSelectionProperties,
"Selection Properties",
diff --git a/src/config/enums.rs b/src/config/enums.rs
index 3ebc8903..3e9c0b49 100644
--- a/src/config/enums.rs
+++ b/src/config/enums.rs
@@ -21,6 +21,18 @@ pub enum StatusPosition {
BottomRight,
}
+/// Mouse button used to toggle the radial menu.
+#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, JsonSchema)]
+#[serde(rename_all = "kebab-case")]
+pub enum RadialMenuMouseBinding {
+ /// Toggle radial menu with middle click.
+ Middle,
+ /// Toggle radial menu with right click.
+ Right,
+ /// Disable mouse-button toggling (keyboard action only).
+ Disabled,
+}
+
/// Color specification - either a named color or RGB values.
///
/// # Examples
diff --git a/src/config/keybindings/actions.rs b/src/config/keybindings/actions.rs
index 1cce60b7..7576e3a6 100644
--- a/src/config/keybindings/actions.rs
+++ b/src/config/keybindings/actions.rs
@@ -109,6 +109,7 @@ pub enum Action {
TogglePresenterMode,
ToggleHighlightTool,
ToggleFill,
+ ToggleRadialMenu,
ToggleSelectionProperties,
OpenContextMenu,
diff --git a/src/config/keybindings/config/map/ui.rs b/src/config/keybindings/config/map/ui.rs
index 0d037dd0..ea359c92 100644
--- a/src/config/keybindings/config/map/ui.rs
+++ b/src/config/keybindings/config/map/ui.rs
@@ -14,6 +14,7 @@ impl KeybindingsConfig {
inserter.insert_all(&self.ui.toggle_toolbar, Action::ToggleToolbar)?;
inserter.insert_all(&self.ui.toggle_presenter_mode, Action::TogglePresenterMode)?;
inserter.insert_all(&self.ui.toggle_fill, Action::ToggleFill)?;
+ inserter.insert_all(&self.ui.toggle_radial_menu, Action::ToggleRadialMenu)?;
inserter.insert_all(
&self.ui.toggle_selection_properties,
Action::ToggleSelectionProperties,
diff --git a/src/config/keybindings/config/types/bindings/ui.rs b/src/config/keybindings/config/types/bindings/ui.rs
index 741c400e..855180ed 100644
--- a/src/config/keybindings/config/types/bindings/ui.rs
+++ b/src/config/keybindings/config/types/bindings/ui.rs
@@ -26,6 +26,9 @@ pub struct UiKeybindingsConfig {
#[serde(default = "default_toggle_fill")]
pub toggle_fill: Vec,
+ #[serde(default = "default_toggle_radial_menu")]
+ pub toggle_radial_menu: Vec,
+
#[serde(default = "default_toggle_selection_properties")]
pub toggle_selection_properties: Vec,
@@ -49,6 +52,7 @@ impl Default for UiKeybindingsConfig {
toggle_toolbar: default_toggle_toolbar(),
toggle_presenter_mode: default_toggle_presenter_mode(),
toggle_fill: default_toggle_fill(),
+ toggle_radial_menu: default_toggle_radial_menu(),
toggle_selection_properties: default_toggle_selection_properties(),
open_context_menu: default_open_context_menu(),
open_configurator: default_open_configurator(),
diff --git a/src/config/keybindings/defaults/ui.rs b/src/config/keybindings/defaults/ui.rs
index a9251d44..f4cd0d1b 100644
--- a/src/config/keybindings/defaults/ui.rs
+++ b/src/config/keybindings/defaults/ui.rs
@@ -26,6 +26,10 @@ pub(crate) fn default_toggle_fill() -> Vec {
Vec::new()
}
+pub(crate) fn default_toggle_radial_menu() -> Vec {
+ Vec::new()
+}
+
pub(crate) fn default_toggle_selection_properties() -> Vec {
vec!["Ctrl+Alt+P".to_string()]
}
diff --git a/src/config/mod.rs b/src/config/mod.rs
index 16a80f42..f294cb35 100644
--- a/src/config/mod.rs
+++ b/src/config/mod.rs
@@ -29,7 +29,7 @@ pub use action_meta::{
action_meta, action_meta_iter, action_short_label,
};
pub use core::Config;
-pub use enums::StatusPosition;
+pub use enums::{RadialMenuMouseBinding, StatusPosition};
#[allow(unused_imports)]
pub use io::{ConfigSource, LoadedConfig};
pub use keybindings::{Action, KeyBinding, KeybindingsConfig};
diff --git a/src/config/types/ui.rs b/src/config/types/ui.rs
index 1371f56e..051d6b8d 100644
--- a/src/config/types/ui.rs
+++ b/src/config/types/ui.rs
@@ -1,4 +1,4 @@
-use crate::config::enums::StatusPosition;
+use crate::config::enums::{RadialMenuMouseBinding, StatusPosition};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@@ -76,6 +76,10 @@ pub struct UiConfig {
#[serde(default = "default_xdg_fullscreen")]
pub xdg_fullscreen: bool,
+ /// Mouse button used to toggle the radial menu.
+ #[serde(default = "default_radial_menu_mouse_binding")]
+ pub radial_menu_mouse_binding: RadialMenuMouseBinding,
+
/// Click highlight visual indicator settings
#[serde(default)]
pub click_highlight: ClickHighlightConfig,
@@ -106,6 +110,7 @@ impl Default for UiConfig {
active_output_badge: default_active_output_badge(),
command_palette_toast_duration_ms: default_command_palette_toast_duration_ms(),
xdg_fullscreen: default_xdg_fullscreen(),
+ radial_menu_mouse_binding: default_radial_menu_mouse_binding(),
click_highlight: ClickHighlightConfig::default(),
context_menu: ContextMenuUiConfig::default(),
toolbar: ToolbarConfig::default(),
@@ -156,3 +161,7 @@ fn default_active_output_badge() -> bool {
fn default_status_position() -> StatusPosition {
StatusPosition::BottomLeft
}
+
+fn default_radial_menu_mouse_binding() -> RadialMenuMouseBinding {
+ RadialMenuMouseBinding::Middle
+}
diff --git a/src/input/state/actions/action_ui.rs b/src/input/state/actions/action_ui.rs
index b72c88bb..cf71af17 100644
--- a/src/input/state/actions/action_ui.rs
+++ b/src/input/state/actions/action_ui.rs
@@ -59,6 +59,15 @@ impl InputState {
);
true
}
+ Action::ToggleRadialMenu => {
+ if self.is_radial_menu_open() {
+ self.close_radial_menu();
+ } else if !self.zoom_active() && matches!(self.state, DrawingState::Idle) {
+ let (x, y) = self.pointer_position();
+ self.open_radial_menu(x as f64, y as f64);
+ }
+ true
+ }
Action::OpenContextMenu => {
if !self.zoom_active() {
self.toggle_context_menu_via_keyboard();
diff --git a/src/input/state/actions/key_press/mod.rs b/src/input/state/actions/key_press/mod.rs
index 9a495e70..a7e0d1ef 100644
--- a/src/input/state/actions/key_press/mod.rs
+++ b/src/input/state/actions/key_press/mod.rs
@@ -9,6 +9,17 @@ use super::super::{DrawingState, InputState};
use bindings::{fallback_unshifted_label, key_to_action_label};
impl InputState {
+ fn handle_modifier_key_press(&mut self, key: Key) -> bool {
+ match key {
+ Key::Shift => self.modifiers.shift = true,
+ Key::Ctrl => self.modifiers.ctrl = true,
+ Key::Alt => self.modifiers.alt = true,
+ Key::Tab => self.modifiers.tab = true,
+ _ => return false,
+ }
+ true
+ }
+
/// Processes a key press event.
///
/// Handles all keyboard input including:
@@ -35,8 +46,27 @@ impl InputState {
}
if self.is_radial_menu_open() {
+ if self.handle_modifier_key_press(key) {
+ return;
+ }
+
if matches!(key, Key::Escape) {
self.close_radial_menu();
+ return;
+ }
+
+ if let Some(key_str) = key_to_action_label(key) {
+ let mapped_action = self.find_action(&key_str).or_else(|| {
+ if self.modifiers.shift {
+ fallback_unshifted_label(&key_str)
+ .and_then(|fallback| self.find_action(fallback))
+ } else {
+ None
+ }
+ });
+ if matches!(mapped_action, Some(Action::ToggleRadialMenu)) {
+ self.close_radial_menu();
+ }
}
return;
}
@@ -54,24 +84,8 @@ impl InputState {
}
// Handle modifier keys first
- match key {
- Key::Shift => {
- self.modifiers.shift = true;
- return;
- }
- Key::Ctrl => {
- self.modifiers.ctrl = true;
- return;
- }
- Key::Alt => {
- self.modifiers.alt = true;
- return;
- }
- Key::Tab => {
- self.modifiers.tab = true;
- return;
- }
- _ => {}
+ if self.handle_modifier_key_press(key) {
+ return;
}
if self.is_properties_panel_open() {
diff --git a/src/input/state/core/base/state/init.rs b/src/input/state/core/base/state/init.rs
index 2c504c07..d256f609 100644
--- a/src/input/state/core/base/state/init.rs
+++ b/src/input/state/core/base/state/init.rs
@@ -7,7 +7,7 @@ use super::super::types::{
PressureThicknessEditMode, PressureThicknessEntryMode, TextInputMode, ToolbarDrawerTab,
};
use super::structs::InputState;
-use crate::config::{Action, BoardsConfig, KeyBinding, PRESET_SLOTS_MAX};
+use crate::config::{Action, BoardsConfig, KeyBinding, PRESET_SLOTS_MAX, RadialMenuMouseBinding};
use crate::draw::{DirtyTracker, EraserKind, FontDescriptor};
use crate::input::state::highlight::{ClickHighlightSettings, ClickHighlightState};
use crate::input::{BoardManager, modifiers::Modifiers, tool::EraserMode};
@@ -159,6 +159,7 @@ impl InputState {
color_picker_popup_layout: None,
radial_menu_state: RadialMenuState::Hidden,
radial_menu_layout: None,
+ radial_menu_mouse_binding: RadialMenuMouseBinding::Middle,
hit_test_cache: HashMap::new(),
hit_test_tolerance: 6.0,
max_linear_hit_test: 400,
diff --git a/src/input/state/core/base/state/structs.rs b/src/input/state/core/base/state/structs.rs
index c7af0d09..69582d87 100644
--- a/src/input/state/core/base/state/structs.rs
+++ b/src/input/state/core/base/state/structs.rs
@@ -17,7 +17,9 @@ use super::super::types::{
PressureThicknessEntryMode, SelectionAxis, StatusChangeHighlight, TextClickState,
TextEditEntryFeedback, TextInputMode, ToolbarDrawerTab, UiToastState, ZoomAction,
};
-use crate::config::{Action, BoardsConfig, KeyBinding, PresenterModeConfig, ToolPresetConfig};
+use crate::config::{
+ Action, BoardsConfig, KeyBinding, PresenterModeConfig, RadialMenuMouseBinding, ToolPresetConfig,
+};
use crate::draw::frame::ShapeSnapshot;
use crate::draw::{Color, DirtyTracker, EraserKind, FontDescriptor, Shape, ShapeId};
use crate::input::BoardManager;
@@ -234,6 +236,8 @@ pub struct InputState {
pub radial_menu_state: RadialMenuState,
/// Cached layout details for the radial menu
pub radial_menu_layout: Option,
+ /// Mouse button used to toggle the radial menu.
+ pub radial_menu_mouse_binding: RadialMenuMouseBinding,
/// Cached hit-test bounds per shape id
pub(in crate::input::state::core) hit_test_cache: HashMap,
/// Hit test tolerance in pixels
diff --git a/src/input/state/mouse/press.rs b/src/input/state/mouse/press.rs
index bef0a975..729f8626 100644
--- a/src/input/state/mouse/press.rs
+++ b/src/input/state/mouse/press.rs
@@ -7,6 +7,21 @@ use super::super::core::MenuCommand;
use super::super::{ContextMenuKind, DrawingState, InputState};
impl InputState {
+ fn is_radial_menu_toggle_button(&self, button: MouseButton) -> bool {
+ use crate::config::RadialMenuMouseBinding;
+ match self.radial_menu_mouse_binding {
+ RadialMenuMouseBinding::Middle => matches!(button, MouseButton::Middle),
+ RadialMenuMouseBinding::Right => matches!(button, MouseButton::Right),
+ RadialMenuMouseBinding::Disabled => false,
+ }
+ }
+
+ fn should_toggle_radial_menu_from_mouse(&self, button: MouseButton) -> bool {
+ !self.zoom_active()
+ && matches!(self.state, DrawingState::Idle)
+ && self.is_radial_menu_toggle_button(button)
+ }
+
fn handle_right_click(&mut self, x: i32, y: i32) {
self.update_pointer_position(x, y);
self.last_text_click = None;
@@ -121,9 +136,12 @@ impl InputState {
self.radial_menu_select_hovered();
}
MouseButton::Right => {
- // Keep right-click context-menu flow consistent even when radial is visible.
self.close_radial_menu();
- self.handle_right_click(x, y);
+ if !self.is_radial_menu_toggle_button(MouseButton::Right) {
+ // Keep right-click context-menu flow when right button is not the
+ // configured radial-menu trigger.
+ self.handle_right_click(x, y);
+ }
}
MouseButton::Middle => {
self.close_radial_menu();
@@ -217,7 +235,11 @@ impl InputState {
self.close_properties_panel();
match button {
MouseButton::Right => {
- self.handle_right_click(x, y);
+ if self.should_toggle_radial_menu_from_mouse(MouseButton::Right) {
+ self.toggle_radial_menu(x as f64, y as f64);
+ } else {
+ self.handle_right_click(x, y);
+ }
}
MouseButton::Left => {
self.update_pointer_position(x, y);
@@ -368,7 +390,7 @@ impl InputState {
}
}
MouseButton::Middle => {
- if !self.zoom_active() && matches!(self.state, DrawingState::Idle) {
+ if self.should_toggle_radial_menu_from_mouse(MouseButton::Middle) {
self.toggle_radial_menu(x as f64, y as f64);
}
}
diff --git a/src/input/state/tests/helpers.rs b/src/input/state/tests/helpers.rs
index 882b2d87..115ba307 100644
--- a/src/input/state/tests/helpers.rs
+++ b/src/input/state/tests/helpers.rs
@@ -1,9 +1,12 @@
use super::*;
pub(super) fn create_test_input_state() -> InputState {
- use crate::config::KeybindingsConfig;
+ create_test_input_state_with_keybindings(crate::config::KeybindingsConfig::default())
+}
- let keybindings = KeybindingsConfig::default();
+pub(super) fn create_test_input_state_with_keybindings(
+ keybindings: crate::config::KeybindingsConfig,
+) -> InputState {
let action_map = keybindings.build_action_map().unwrap();
let action_bindings = keybindings.build_action_bindings().unwrap();
diff --git a/src/input/state/tests/radial_menu.rs b/src/input/state/tests/radial_menu.rs
index 2c55ffb5..a0675e33 100644
--- a/src/input/state/tests/radial_menu.rs
+++ b/src/input/state/tests/radial_menu.rs
@@ -1,3 +1,4 @@
+use super::helpers::create_test_input_state_with_keybindings;
use super::*;
use std::f64::consts::PI;
@@ -85,6 +86,57 @@ fn opening_radial_menu_closes_help_overlay() {
assert!(state.is_radial_menu_open());
}
+#[test]
+fn right_click_toggles_radial_when_configured() {
+ let mut state = create_test_input_state();
+ state.radial_menu_mouse_binding = crate::config::RadialMenuMouseBinding::Right;
+
+ state.on_mouse_press(MouseButton::Right, 200, 150);
+ assert!(state.is_radial_menu_open());
+ assert!(!state.is_context_menu_open());
+
+ state.on_mouse_press(MouseButton::Right, 200, 150);
+ assert!(!state.is_radial_menu_open());
+ assert!(!state.is_context_menu_open());
+}
+
+#[test]
+fn toggle_radial_menu_action_opens_and_closes_menu() {
+ let mut state = create_test_input_state();
+ state.update_pointer_position(320, 240);
+
+ state.handle_action(Action::ToggleRadialMenu);
+ assert!(state.is_radial_menu_open());
+
+ state.handle_action(Action::ToggleRadialMenu);
+ assert!(!state.is_radial_menu_open());
+
+ state.state = DrawingState::Selecting {
+ start_x: 10,
+ start_y: 20,
+ additive: false,
+ };
+ state.handle_action(Action::ToggleRadialMenu);
+ assert!(!state.is_radial_menu_open());
+}
+
+#[test]
+fn toggle_radial_menu_with_modifier_keybinding_closes_when_open() {
+ let mut keybindings = crate::config::KeybindingsConfig::default();
+ keybindings.ui.toggle_radial_menu = vec!["Ctrl+R".to_string()];
+ let mut state = create_test_input_state_with_keybindings(keybindings);
+ state.update_pointer_position(320, 240);
+
+ state.on_key_press(Key::Ctrl);
+ state.on_key_press(Key::Char('r'));
+ assert!(state.is_radial_menu_open());
+
+ state.on_key_release(Key::Ctrl);
+ state.on_key_press(Key::Ctrl);
+ state.on_key_press(Key::Char('r'));
+ assert!(!state.is_radial_menu_open());
+}
+
#[test]
fn right_click_when_radial_open_opens_context_menu() {
let mut state = create_test_input_state();
From dd0fff93037c53c15b6c650e4c28374141f8c3c6 Mon Sep 17 00:00:00 2001
From: devmobasa <4170275+devmobasa@users.noreply.github.com>
Date: Thu, 12 Feb 2026 22:08:43 +0100
Subject: [PATCH 5/5] Add radial menu entries and display configured shortcuts
---
src/input/state/core/menus/commands.rs | 4 +
src/input/state/core/menus/entries/canvas.rs | 7 ++
src/input/state/core/menus/entries/shape.rs | 7 ++
src/input/state/core/menus/shortcuts.rs | 25 ++++++
src/input/state/core/menus/types.rs | 1 +
src/input/state/tests/menus/context_menu.rs | 86 +++++++++++++++++++
src/input/state/tests/mod.rs | 2 +-
src/ui/help_overlay/sections/bindings.rs | 28 +++++-
.../help_overlay/sections/builder/sections.rs | 12 +++
src/ui/help_overlay/sections/tests.rs | 1 +
10 files changed, 170 insertions(+), 3 deletions(-)
diff --git a/src/input/state/core/menus/commands.rs b/src/input/state/core/menus/commands.rs
index a13b02d5..d32664a9 100644
--- a/src/input/state/core/menus/commands.rs
+++ b/src/input/state/core/menus/commands.rs
@@ -259,6 +259,10 @@ impl InputState {
self.switch_board(BOARD_ID_TRANSPARENT);
self.close_context_menu();
}
+ MenuCommand::OpenRadialMenu => {
+ self.close_context_menu();
+ self.handle_action(crate::config::Action::ToggleRadialMenu);
+ }
MenuCommand::ToggleHelp => {
self.toggle_help_overlay();
self.close_context_menu();
diff --git a/src/input/state/core/menus/entries/canvas.rs b/src/input/state/core/menus/entries/canvas.rs
index b10d4f96..8e580d45 100644
--- a/src/input/state/core/menus/entries/canvas.rs
+++ b/src/input/state/core/menus/entries/canvas.rs
@@ -112,6 +112,13 @@ impl InputState {
false,
Some(MenuCommand::OpenCommandPalette),
));
+ entries.push(ContextMenuEntry::new(
+ "Radial Menu",
+ self.shortcut_for_action(Action::ToggleRadialMenu),
+ false,
+ false,
+ Some(MenuCommand::OpenRadialMenu),
+ ));
entries.push(ContextMenuEntry::new(
"Help",
self.shortcut_for_action(Action::ToggleHelp),
diff --git a/src/input/state/core/menus/entries/shape.rs b/src/input/state/core/menus/entries/shape.rs
index bfafad60..b163ffab 100644
--- a/src/input/state/core/menus/entries/shape.rs
+++ b/src/input/state/core/menus/entries/shape.rs
@@ -75,6 +75,13 @@ impl InputState {
false,
Some(MenuCommand::Properties),
));
+ entries.push(ContextMenuEntry::new(
+ "Radial Menu",
+ self.shortcut_for_action(Action::ToggleRadialMenu),
+ false,
+ false,
+ Some(MenuCommand::OpenRadialMenu),
+ ));
if ids.len() == 1 {
let shape_id = ids[0];
diff --git a/src/input/state/core/menus/shortcuts.rs b/src/input/state/core/menus/shortcuts.rs
index 317e9420..888deb25 100644
--- a/src/input/state/core/menus/shortcuts.rs
+++ b/src/input/state/core/menus/shortcuts.rs
@@ -5,6 +5,10 @@ impl InputState {
/// Get the display string for the first keybinding of an action.
/// Returns None if no binding exists.
pub fn shortcut_for_action(&self, action: Action) -> Option {
+ if action == Action::ToggleRadialMenu {
+ return self.radial_menu_shortcut_label();
+ }
+
let mut labels = self.action_binding_labels(action);
if action == Action::ToggleHelp
&& let Some(idx) = labels.iter().position(|label| label == "F1")
@@ -13,4 +17,25 @@ impl InputState {
}
labels.into_iter().next()
}
+
+ fn radial_menu_shortcut_label(&self) -> Option {
+ use crate::config::RadialMenuMouseBinding;
+
+ let mouse_label = match self.radial_menu_mouse_binding {
+ RadialMenuMouseBinding::Middle => Some("Middle Click".to_string()),
+ RadialMenuMouseBinding::Right => Some("Right Click".to_string()),
+ RadialMenuMouseBinding::Disabled => None,
+ };
+ let key_label = self
+ .action_binding_labels(Action::ToggleRadialMenu)
+ .into_iter()
+ .next();
+
+ match (mouse_label, key_label) {
+ (Some(mouse), Some(key)) => Some(format!("{mouse} / {key}")),
+ (Some(mouse), None) => Some(mouse),
+ (None, Some(key)) => Some(key),
+ (None, None) => None,
+ }
+ }
}
diff --git a/src/input/state/core/menus/types.rs b/src/input/state/core/menus/types.rs
index 2d38632f..d97b0439 100644
--- a/src/input/state/core/menus/types.rs
+++ b/src/input/state/core/menus/types.rs
@@ -62,6 +62,7 @@ pub enum MenuCommand {
SwitchToWhiteboard,
SwitchToBlackboard,
ReturnToTransparent,
+ OpenRadialMenu,
ToggleHelp,
OpenCommandPalette,
OpenConfigFile,
diff --git a/src/input/state/tests/menus/context_menu.rs b/src/input/state/tests/menus/context_menu.rs
index edb61c50..d9b95a95 100644
--- a/src/input/state/tests/menus/context_menu.rs
+++ b/src/input/state/tests/menus/context_menu.rs
@@ -180,3 +180,89 @@ fn context_menu_help_entry_prefers_f1_shortcut_label() {
.expect("help entry should exist in context menu");
assert_eq!(help_entry.shortcut.as_deref(), Some("F1"));
}
+
+#[test]
+fn context_menu_includes_radial_menu_entry() {
+ let mut state = create_test_input_state();
+ state.toggle_context_menu_via_keyboard();
+
+ let entries = state.context_menu_entries();
+ let radial_entry = entries
+ .iter()
+ .find(|entry| entry.label == "Radial Menu")
+ .expect("radial menu entry should exist in context menu");
+ assert_eq!(radial_entry.command, Some(MenuCommand::OpenRadialMenu));
+}
+
+#[test]
+fn context_menu_radial_entry_shows_default_mouse_shortcut() {
+ let mut state = create_test_input_state();
+ state.toggle_context_menu_via_keyboard();
+
+ let entries = state.context_menu_entries();
+ let radial_entry = entries
+ .iter()
+ .find(|entry| entry.label == "Radial Menu")
+ .expect("radial menu entry should exist in context menu");
+ assert_eq!(radial_entry.shortcut.as_deref(), Some("Middle Click"));
+}
+
+#[test]
+fn context_menu_radial_entry_shows_mouse_and_keyboard_shortcut() {
+ let mut keybindings = crate::config::KeybindingsConfig::default();
+ keybindings.ui.toggle_radial_menu = vec!["Ctrl+R".to_string()];
+ let mut state = create_test_input_state_with_keybindings(keybindings);
+ state.toggle_context_menu_via_keyboard();
+
+ let entries = state.context_menu_entries();
+ let radial_entry = entries
+ .iter()
+ .find(|entry| entry.label == "Radial Menu")
+ .expect("radial menu entry should exist in context menu");
+ assert_eq!(
+ radial_entry.shortcut.as_deref(),
+ Some("Middle Click / Ctrl+R")
+ );
+}
+
+#[test]
+fn context_menu_radial_entry_shows_right_click_shortcut_when_configured() {
+ let mut state = create_test_input_state();
+ state.radial_menu_mouse_binding = crate::config::RadialMenuMouseBinding::Right;
+ state.toggle_context_menu_via_keyboard();
+
+ let entries = state.context_menu_entries();
+ let radial_entry = entries
+ .iter()
+ .find(|entry| entry.label == "Radial Menu")
+ .expect("radial menu entry should exist in context menu");
+ assert_eq!(radial_entry.shortcut.as_deref(), Some("Right Click"));
+}
+
+#[test]
+fn context_menu_radial_entry_shows_keyboard_shortcut_when_mouse_binding_disabled() {
+ let mut keybindings = crate::config::KeybindingsConfig::default();
+ keybindings.ui.toggle_radial_menu = vec!["Ctrl+R".to_string()];
+ let mut state = create_test_input_state_with_keybindings(keybindings);
+ state.radial_menu_mouse_binding = crate::config::RadialMenuMouseBinding::Disabled;
+ state.toggle_context_menu_via_keyboard();
+
+ let entries = state.context_menu_entries();
+ let radial_entry = entries
+ .iter()
+ .find(|entry| entry.label == "Radial Menu")
+ .expect("radial menu entry should exist in context menu");
+ assert_eq!(radial_entry.shortcut.as_deref(), Some("Ctrl+R"));
+}
+
+#[test]
+fn context_menu_open_radial_command_opens_radial_and_closes_context_menu() {
+ let mut state = create_test_input_state();
+ state.toggle_context_menu_via_keyboard();
+ assert!(state.is_context_menu_open());
+
+ state.execute_menu_command(MenuCommand::OpenRadialMenu);
+
+ assert!(state.is_radial_menu_open());
+ assert!(!state.is_context_menu_open());
+}
diff --git a/src/input/state/tests/mod.rs b/src/input/state/tests/mod.rs
index ecacec83..562fa9e7 100644
--- a/src/input/state/tests/mod.rs
+++ b/src/input/state/tests/mod.rs
@@ -6,7 +6,7 @@ use crate::input::{ClickHighlightSettings, EraserMode, Key, MouseButton, Tool};
use crate::util;
mod helpers;
-use helpers::create_test_input_state;
+use helpers::{create_test_input_state, create_test_input_state_with_keybindings};
mod arrow_labels;
mod basics;
diff --git a/src/ui/help_overlay/sections/bindings.rs b/src/ui/help_overlay/sections/bindings.rs
index 17ecee86..659c3d11 100644
--- a/src/ui/help_overlay/sections/bindings.rs
+++ b/src/ui/help_overlay/sections/bindings.rs
@@ -1,13 +1,23 @@
use std::collections::{HashMap, HashSet};
-use crate::config::{Action, action_meta_iter};
+use crate::config::{Action, RadialMenuMouseBinding, action_meta_iter};
use crate::input::InputState;
use crate::label_format::{format_binding_labels_or, join_binding_labels};
-#[derive(Default)]
pub struct HelpOverlayBindings {
labels: HashMap>,
cache_key: String,
+ radial_menu_mouse_label: Option,
+}
+
+impl Default for HelpOverlayBindings {
+ fn default() -> Self {
+ Self {
+ labels: HashMap::new(),
+ cache_key: String::new(),
+ radial_menu_mouse_label: Some("Middle Click".to_string()),
+ }
+ }
}
impl HelpOverlayBindings {
@@ -26,10 +36,20 @@ impl HelpOverlayBindings {
cache_parts.push(format!("{:?}={}", meta.action, values.join("/")));
}
}
+ let radial_menu_mouse_label = match state.radial_menu_mouse_binding {
+ RadialMenuMouseBinding::Middle => Some("Middle Click".to_string()),
+ RadialMenuMouseBinding::Right => Some("Right Click".to_string()),
+ RadialMenuMouseBinding::Disabled => None,
+ };
+ cache_parts.push(format!(
+ "radial_mouse={}",
+ radial_menu_mouse_label.as_deref().unwrap_or("")
+ ));
Self {
labels,
cache_key: cache_parts.join("|"),
+ radial_menu_mouse_label,
}
}
@@ -40,6 +60,10 @@ impl HelpOverlayBindings {
pub(crate) fn cache_key(&self) -> &str {
self.cache_key.as_str()
}
+
+ pub(crate) fn radial_menu_mouse_label(&self) -> Option<&str> {
+ self.radial_menu_mouse_label.as_deref()
+ }
}
fn collect_labels(bindings: &HelpOverlayBindings, actions: &[Action]) -> Vec {
diff --git a/src/ui/help_overlay/sections/builder/sections.rs b/src/ui/help_overlay/sections/builder/sections.rs
index dd4ed414..b100d4f0 100644
--- a/src/ui/help_overlay/sections/builder/sections.rs
+++ b/src/ui/help_overlay/sections/builder/sections.rs
@@ -318,6 +318,18 @@ pub(super) fn build_main_sections(
},
action_label(Action::OpenContextMenu),
),
+ row(
+ match (
+ bindings.radial_menu_mouse_label(),
+ joined_labels(bindings, &[Action::ToggleRadialMenu]),
+ ) {
+ (Some(mouse), Some(label)) => format!("{mouse} / {label}"),
+ (Some(mouse), None) => mouse.to_string(),
+ (None, Some(label)) => label,
+ (None, None) => NOT_BOUND_LABEL.to_string(),
+ },
+ action_label(Action::ToggleRadialMenu),
+ ),
row(
binding_or_fallback(bindings, Action::Exit, NOT_BOUND_LABEL),
action_label(Action::Exit),
diff --git a/src/ui/help_overlay/sections/tests.rs b/src/ui/help_overlay/sections/tests.rs
index e8db8b70..8bc283ee 100644
--- a/src/ui/help_overlay/sections/tests.rs
+++ b/src/ui/help_overlay/sections/tests.rs
@@ -20,6 +20,7 @@ fn gesture_hints_remain_present() {
("Ctrl+Shift+Alt+Left/Right", "Previous/next output"),
("Selection properties panel", "Text background"),
("Middle drag / arrow keys", "Pan view"),
+ ("Middle Click", action_label(Action::ToggleRadialMenu)),
];
for (key, action) in expected {