From 363ff614ec567d799576383267a4bf60ba274de1 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 9 Jul 2026 21:35:47 +0300 Subject: [PATCH] feat(polar): angle picker dropdown on the POLAR pill (#264) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The polar-tracking pill only right-click-cycled 15→30→45→90. Add an AutoCAD-style angle picker opened from a caret on the pill: presets 90/45/30/22.5/18/15/10/5/1 plus a free-text field for any custom angle, mirroring the existing units / scale status-bar popups. The chosen angle persists like the other user settings. Factor the shared chrome into one `split_pill(main, caret_msg, active, open)` helper (+ `pill_text_color`) so the OSNAP and POLAR pills stop hand-rolling the same outer border, background, caret and sizing — each pill now only supplies its own main region. The caret is sized and aligned to sit inside the single pill border on both. Co-Authored-By: Claude Opus 4.8 --- src/app/mod.rs | 16 ++- src/app/update/mod.rs | 31 +++++ src/app/view/mod.rs | 13 +++ src/ui/popup/mod.rs | 1 + src/ui/popup/polar_popup.rs | 138 ++++++++++++++++++++++ src/ui/statusbar/mod.rs | 227 +++++++++++++++--------------------- 6 files changed, 289 insertions(+), 137 deletions(-) create mode 100644 src/ui/popup/polar_popup.rs diff --git a/src/app/mod.rs b/src/app/mod.rs index 9557c2aae..2acdb1729 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -252,6 +252,10 @@ pub(super) struct OpenCADStudio { snapper: Snapper, snap_popup_open: bool, scale_popup_open: bool, + /// True while the polar-tracking angle picker is open. + polar_popup_open: bool, + /// Live text of the polar picker's custom-angle field. + polar_custom_input: String, /// True while the status-bar customization menu is open. statusbar_menu_open: bool, /// True while the leftmost hamburger's Model/layout list dropdown is open. @@ -1404,8 +1408,16 @@ pub enum Message { ToggleLineweightDisplay, /// Toggle polar-angle constraint — F10 / POLAR status-bar button. TogglePolar, - /// Set polar tracking angle increment (right-click POLAR button). + /// Set polar tracking angle increment (POLAR picker / right-click cycle). SetPolarAngle(f32), + /// Open/close the polar-tracking angle picker (POLAR pill caret). + TogglePolarPopup, + /// Close the polar-tracking angle picker. + ClosePolarPopup, + /// Live text edit of the polar picker's custom-angle field. + PolarCustomInput(String), + /// Apply the typed custom polar angle (Enter in the picker's field). + SubmitPolarCustom, /// Set the model-space annotation scale (CANNOSCALE equivalent). SetAnnotationScale(f32), /// Set the active viewport's custom_scale (paper space). @@ -2045,6 +2057,8 @@ impl OpenCADStudio { snapper: Snapper::default(), snap_popup_open: false, scale_popup_open: false, + polar_popup_open: false, + polar_custom_input: String::new(), statusbar_menu_open: false, layout_list_open: false, units_popup_open: false, diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index 2d8ed7f4e..ee5515bbf 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -1561,6 +1561,37 @@ impl OpenCADStudio { self.polar_increment_deg = deg; self.polar_mode = true; self.ortho_mode = false; + self.polar_popup_open = false; + Task::none() + } + Message::TogglePolarPopup => { + self.polar_popup_open ^= true; + if self.polar_popup_open { + // Start the custom field empty each time; picking a preset or + // typing a value is what actually enables polar tracking. + self.polar_custom_input.clear(); + } + Task::none() + } + Message::ClosePolarPopup => { + self.polar_popup_open = false; + Task::none() + } + Message::PolarCustomInput(s) => { + self.polar_custom_input = s; + Task::none() + } + Message::SubmitPolarCustom => { + // Accept any positive angle up to a full turn; ignore garbage. + if let Ok(v) = self.polar_custom_input.trim().parse::() { + if v > 0.0 && v <= 360.0 { + self.polar_increment_deg = v; + self.polar_mode = true; + self.ortho_mode = false; + } + } + self.polar_custom_input.clear(); + self.polar_popup_open = false; Task::none() } Message::SetAnnotationScale(scale) => { diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index b4154f0e9..04b182fdb 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1253,6 +1253,7 @@ impl OpenCADStudio { self.ortho_mode, self.polar_mode, self.polar_increment_deg, + self.polar_popup_open, self.dyn_input, self.snapper.otrack_enabled, tab.scene.layout_names(), @@ -1320,6 +1321,17 @@ impl OpenCADStudio { iced::widget::Space::new().width(0).height(0).into() }; + let polar_layer: Element<'_, Message> = if self.polar_popup_open { + crate::ui::popup::polar_popup::polar_popup_overlay( + self.polar_increment_deg, + &self.polar_custom_input, + sb_pill(crate::ui::statusbar::SB_POLAR_ID), + win, + ) + } else { + iced::widget::Space::new().width(0).height(0).into() + }; + let statusbar_menu_layer: Element<'_, Message> = if self.statusbar_menu_open { crate::ui::statusbar::statusbar_menu::statusbar_menu_overlay( &self.statusbar_config, @@ -1412,6 +1424,7 @@ impl OpenCADStudio { main_ui, snap_layer, scale_layer, + polar_layer, statusbar_menu_layer, layout_list_layer, units_layer, diff --git a/src/ui/popup/mod.rs b/src/ui/popup/mod.rs index 17186fd28..8d7c7ea91 100644 --- a/src/ui/popup/mod.rs +++ b/src/ui/popup/mod.rs @@ -1,5 +1,6 @@ pub mod cycle_popup; pub mod isolate_popup; +pub mod polar_popup; pub mod scale_popup; pub mod selection_filter_popup; pub mod snap_popup; diff --git a/src/ui/popup/polar_popup.rs b/src/ui/popup/polar_popup.rs new file mode 100644 index 000000000..7ec7d1939 --- /dev/null +++ b/src/ui/popup/polar_popup.rs @@ -0,0 +1,138 @@ +//! Polar-tracking angle picker — sets the polar increment used by POLAR +//! tracking. Rendered as a floating overlay above the status bar, same pattern +//! as the units / scale pickers. Offers the common AutoCAD-style increments +//! plus a free-text field for any custom angle (#264). + +use iced::widget::{button, column, container, mouse_area, row, text, text_input}; +use iced::{Background, Border, Color, Element, Fill, Length, Rectangle, Theme}; + +use crate::app::Message; + +/// Angle increments offered in the picker, in degrees. Matches the common +/// drafting set and adds the fine 1° step requested in #264. +const PRESETS: &[f32] = &[90.0, 45.0, 30.0, 22.5, 18.0, 15.0, 10.0, 5.0, 1.0]; + +/// Format an angle without a trailing `.0` (so `22.5°` but `15°`). +pub fn angle_label(deg: f32) -> String { + if (deg.fract()).abs() < 1e-3 { + format!("{:.0}°", deg) + } else { + format!("{deg}°") + } +} + +/// Full-screen overlay: transparent click-catcher + angle list pinned +/// bottom-right, above the status bar. `custom` is the live text of the +/// free-entry field. +pub fn polar_popup_overlay<'a>( + current: f32, + custom: &'a str, + pill: Option, + win: (f32, f32), +) -> Element<'a, Message> { + let mut rows: Vec> = PRESETS + .iter() + .map(|°| { + let active = (current - deg).abs() < 1e-3; + angle_row(deg, active) + }) + .collect(); + + // Free-entry custom angle: type a value and press Enter to apply. + let custom_field = text_input("Custom…", custom) + .on_input(Message::PolarCustomInput) + .on_submit(Message::SubmitPolarCustom) + .size(11) + .padding([2, 6]) + .width(Length::Fixed(58.0)); + let custom_row = container( + row![ + custom_field, + text("°").size(11).color(LABEL_OFF), + ] + .spacing(4) + .align_y(iced::Center), + ) + .padding([5, 10]); + rows.push(custom_row.into()); + + let panel = container(column(rows)) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(PANEL_BG)), + border: Border { + color: PANEL_BORDER, + width: 1.0, + radius: 3.0.into(), + }, + ..Default::default() + }) + .width(Length::Fixed(120.0)); + + let positioned = super::position_statusbar_popup(panel.into(), pill, win, 120.0, true); + + mouse_area(positioned) + .on_press(Message::ClosePolarPopup) + .into() +} + +fn angle_row<'a>(deg: f32, active: bool) -> Element<'a, Message> { + let check = crate::ui::icons::check_cell(active, CHECK_COLOR); + + let lbl = text(angle_label(deg)) + .size(11) + .color(if active { LABEL_ON } else { LABEL_OFF }); + + let content = row![check, lbl].spacing(6).align_y(iced::Center); + + button(content) + .on_press(Message::SetPolarAngle(deg)) + .style(|_: &Theme, status| button::Style { + background: Some(Background::Color(match status { + button::Status::Hovered => ROW_HOVER, + _ => Color::TRANSPARENT, + })), + ..Default::default() + }) + .width(Fill) + .padding([4, 10]) + .into() +} + +// ── Colours ─────────────────────────────────────────────────────────────── + +const PANEL_BG: Color = Color { + r: 0.15, + g: 0.15, + b: 0.15, + a: 1.0, +}; +const PANEL_BORDER: Color = Color { + r: 0.32, + g: 0.32, + b: 0.32, + a: 1.0, +}; +const ROW_HOVER: Color = Color { + r: 0.22, + g: 0.22, + b: 0.22, + a: 1.0, +}; +const CHECK_COLOR: Color = Color { + r: 0.35, + g: 0.75, + b: 1.00, + a: 1.0, +}; +const LABEL_ON: Color = Color { + r: 0.92, + g: 0.92, + b: 0.92, + a: 1.0, +}; +const LABEL_OFF: Color = Color { + r: 0.65, + g: 0.65, + b: 0.65, + a: 1.0, +}; diff --git a/src/ui/statusbar/mod.rs b/src/ui/statusbar/mod.rs index 5a2163bf1..59a4fbaf7 100644 --- a/src/ui/statusbar/mod.rs +++ b/src/ui/statusbar/mod.rs @@ -5,7 +5,7 @@ pub mod statusbar_menu; use iced::widget::tooltip::Position as TipPos; use iced::widget::{ - button, column, container, mouse_area, row, text, text_input, tooltip, + button, container, mouse_area, row, text, text_input, tooltip, }; use iced::{Background, Border, Color, Element, Length, Theme}; @@ -31,6 +31,7 @@ pub const SB_ISOLATE_ID: &str = "SB_ISOLATE"; pub const SB_FILTER_ID: &str = "SB_FILTER"; pub const SB_MENU_ID: &str = "SB_MENU"; pub const SB_LAYOUTLIST_ID: &str = "SB_LAYOUTLIST"; +pub const SB_POLAR_ID: &str = "SB_POLAR"; #[derive(Clone, Default)] pub struct StatusBar { @@ -52,6 +53,8 @@ impl StatusBar { ortho_mode: bool, polar_mode: bool, polar_increment_deg: f32, + // True while the polar-angle picker popup is open. + polar_popup_open: bool, dyn_input: bool, otrack: bool, layouts: Vec, @@ -177,7 +180,13 @@ impl StatusBar { ); } if vis(StatusPill::Polar) { - pills.push(polar_pill(polar_mode, polar_increment_deg).into()); + pills.push( + PosReport::new( + SB_POLAR_ID, + polar_pill(polar_mode, polar_increment_deg, polar_popup_open), + ) + .into(), + ); } if vis(StatusPill::Dyn) { pills.push( @@ -459,35 +468,81 @@ fn toggle_pill(icon: &'static [u8], active: bool, msg: Message) -> Element<'stat .into() } +// ── Split pill (toggle + dropdown caret) ────────────────────────────────── +// +// Shared chrome for every status-bar pill that pairs a toggle with a picker +// popup (OSNAP, POLAR, …): one outer border wraps the caller's `main` region +// and a dropdown caret, so both halves read as a single integrated control and +// the border / background / caret / sizing live in exactly ONE place. + +/// Pill text/icon tint for the lit vs. dim state — shared so `main` (built by +/// the caller) and the caret always match. +fn pill_text_color(active: bool) -> Color { + if active { + OSNAP_ON_TEXT + } else { + OSNAP_OFF_TEXT + } +} + +/// Wrap a caller-built, already-click-wired `main` element together with a +/// dropdown caret that emits `caret_msg`. `active` lights the pill; `open` +/// highlights the border while its popup is showing. +fn split_pill( + main: Element<'static, Message>, + caret_msg: Message, + active: bool, + open: bool, +) -> Element<'static, Message> { + let color = pill_text_color(active); + let bg = if active { SNAP_ON_BG } else { SNAP_OFF_BG }; + let border_color = if open { + ACCENT + } else if active { + SNAP_BORDER_ON + } else { + BORDER_COLOR + }; + + let caret = mouse_area( + container(crate::ui::icons::arrow_down(9.0, color)).padding([0, 1]), + ) + .on_press(caret_msg); + + container(row![main, caret].spacing(3).align_y(iced::Center)) + .style(move |_: &Theme| container::Style { + background: Some(Background::Color(bg)), + border: Border { + color: border_color, + width: 1.0, + radius: 2.0.into(), + }, + ..Default::default() + }) + .padding([4, 6]) + .into() +} + // ── Polar tracking pill ─────────────────────────────────────────────────── // -// Left-click toggles polar on/off. -// Right-click cycles through common angle increments: 15 → 30 → 45 → 90 → 15 … +// Main: left-click toggles polar on/off; right-click cycles the increment. +// Caret: opens the angle picker. -fn polar_pill(active: bool, increment_deg: f32) -> Element<'static, Message> { - let angle = format!("{:.0}°", increment_deg); +fn polar_pill(active: bool, increment_deg: f32, open: bool) -> Element<'static, Message> { + let angle = crate::ui::popup::polar_popup::angle_label(increment_deg); let tooltip_text = format!( - "Polar Tracking ({}°)\nF10 — left-click on/off\nRight-click to change angle", - increment_deg as u32 + "Polar Tracking ({angle})\nF10 — left-click on/off\nRight-click cycles · ▾ picks angle", ); + let color = pill_text_color(active); - let bg_color = move |hovered: bool| match (active, hovered) { - (true, true) => SNAP_ON_HOVER, - (true, false) => SNAP_ON_BG, - (false, true) => SNAP_OFF_HOVER, - (false, false) => SNAP_OFF_BG, + // Right-click quick-cycles through the same increments the picker lists. + const CYCLE: &[f32] = &[90.0, 45.0, 30.0, 22.5, 18.0, 15.0, 10.0, 5.0, 1.0]; + let next_angle = match CYCLE.iter().position(|&a| (a - increment_deg).abs() < 1e-3) { + Some(i) => CYCLE[(i + 1) % CYCLE.len()], + None => 45.0, }; - // Cycle to the next common angle on right-click. - let next_angle = match increment_deg as u32 { - 15 => 30.0_f32, - 30 => 45.0, - 45 => 90.0, - _ => 15.0, - }; - - let color = if active { OSNAP_ON_TEXT } else { OSNAP_OFF_TEXT }; - let inner = container( + let main = mouse_area( row![ crate::ui::icons::tinted(crate::ui::icons::ST_POLAR, 17.0, color), text(angle).size(11).color(color), @@ -495,23 +550,11 @@ fn polar_pill(active: bool, increment_deg: f32) -> Element<'static, Message> { .spacing(2) .align_y(iced::Center), ) - .style(move |_: &Theme| container::Style { - background: Some(Background::Color(bg_color(false))), - border: Border { - color: if active { SNAP_BORDER_ON } else { BORDER_COLOR }, - width: 1.0, - radius: 2.0.into(), - }, - ..Default::default() - }) - .padding([4, 6]); - - let pill = mouse_area(inner) - .on_press(Message::TogglePolar) - .on_right_press(Message::SetPolarAngle(next_angle)); + .on_press(Message::TogglePolar) + .on_right_press(Message::SetPolarAngle(next_angle)); tooltip( - pill, + split_pill(main.into(), Message::TogglePolarPopup, active, open), container(text(tooltip_text).size(11).color(Color::WHITE)) .style(|_: &Theme| container::Style { background: Some(Background::Color(Color { @@ -538,111 +581,23 @@ fn polar_pill(active: bool, increment_deg: f32) -> Element<'static, Message> { .into() } -// ── OSNAP split button ──────────────────────────────────────────────────── +// ── OSNAP pill ───────────────────────────────────────────────────────────── // -// Left part ("⚡ OSNAP"): toggles the global snap on/off. -// Right part ("▾"): opens the snap-type dropdown. +// Main: toggles the global snap on/off. Caret: opens the snap-type dropdown. fn osnap_btn(active: bool, snap_enabled: bool, open: bool) -> Element<'static, Message> { - let bg = match (active || snap_enabled, open) { - (true, true) => SNAP_ON_HOVER, - (true, false) => SNAP_ON_BG, - (false, _) => SNAP_OFF_BG, - }; - let border_color = if open { - ACCENT - } else if active { - SNAP_BORDER_ON - } else { - BORDER_COLOR - }; - let text_color = if active { - OSNAP_ON_TEXT - } else { - OSNAP_OFF_TEXT - }; - - let left = button(crate::ui::icons::tinted( + let on = active || snap_enabled; + let main = mouse_area(crate::ui::icons::tinted( crate::ui::icons::ST_OSNAP, 17.0, - text_color, + pill_text_color(on), )) - .on_press(Message::ToggleSnapEnabled) - .style(move |_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered => { - if active || snap_enabled { - SNAP_ON_HOVER - } else { - SNAP_OFF_HOVER - } - } - _ => bg, - })), - border: Border { - color: border_color, - width: 1.0, - radius: iced::border::Radius { - top_left: 2.0, - top_right: 0.0, - bottom_right: 0.0, - bottom_left: 2.0, - }, - }, - text_color, - shadow: iced::Shadow::default(), - snap: false, - }) - .padding([4, 6]); + .on_press(Message::ToggleSnapEnabled); - let right = button(crate::ui::icons::arrow_down(11.0, text_color)) - .on_press(Message::ToggleSnapPopup) - .style(move |_: &Theme, status| button::Style { - background: Some(Background::Color(match status { - button::Status::Hovered => { - if active || snap_enabled { - SNAP_ON_HOVER - } else { - SNAP_OFF_HOVER - } - } - _ => bg, - })), - border: Border { - color: border_color, - width: 1.0, - radius: iced::border::Radius { - top_left: 0.0, - top_right: 2.0, - bottom_right: 2.0, - bottom_left: 0.0, - }, - }, - text_color, - shadow: iced::Shadow::default(), - snap: false, - }) - .padding([4, 4]); - - row![ - tip(left.into(), "Object Snap: toggle on/off\nF3"), - tip_node( - right.into(), - column![ - text("Object Snap settings").size(11).color(Color::WHITE), - row![ - text("Click").size(11).color(Color::WHITE), - crate::ui::icons::arrow_down(9.0, Color::WHITE), - text("to open the list").size(11).color(Color::WHITE), - ] - .spacing(3) - .align_y(iced::Center), - ] - .into(), - ), - ] - .spacing(0) - .into() + tip( + split_pill(main.into(), Message::ToggleSnapPopup, on, open), + "Object Snap: toggle on/off · ▾ opens the snap list\nF3", + ) } // ── Helpers ───────────────────────────────────────────────────────────────