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 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/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/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/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/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/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 7d089614..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: @@ -34,6 +45,32 @@ impl InputState { return; } + 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; + } + if self.is_color_picker_popup_open() && self.handle_color_picker_popup_key(key) { return; } @@ -47,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 0d38f5a1..d256f609 100644 --- a/src/input/state/core/base/state/init.rs +++ b/src/input/state/core/base/state/init.rs @@ -1,13 +1,13 @@ 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, 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}; @@ -157,6 +157,9 @@ 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, + 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 620d1bf3..69582d87 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::{ @@ -16,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; @@ -229,6 +232,12 @@ 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, + /// 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/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 1d1001f6..888deb25 100644 --- a/src/input/state/core/menus/shortcuts.rs +++ b/src/input/state/core/menus/shortcuts.rs @@ -5,9 +5,37 @@ 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()) + 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") + { + return Some(labels.swap_remove(idx)); + } + 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/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..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; @@ -111,6 +126,30 @@ 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 => { + self.close_radial_menu(); + 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(); + } + } + return; + } + if self.is_color_picker_popup_open() { self.update_pointer_position(x, y); match button { @@ -196,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); @@ -346,7 +389,11 @@ impl InputState { | DrawingState::ResizingSelection { .. } => {} } } - MouseButton::Middle => {} + MouseButton::Middle => { + 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/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/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/menus/context_menu.rs b/src/input/state/tests/menus/context_menu.rs index beca9af6..d9b95a95 100644 --- a/src/input/state/tests/menus/context_menu.rs +++ b/src/input/state/tests/menus/context_menu.rs @@ -167,3 +167,102 @@ 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")); +} + +#[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 c359cf93..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; @@ -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..a0675e33 --- /dev/null +++ b/src/input/state/tests/radial_menu.rs @@ -0,0 +1,206 @@ +use super::helpers::create_test_input_state_with_keybindings; +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_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(); + 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/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 { 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); 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]