//! Bottom status bar — Model/Layout tabs + OSNAP toggle + status info pub mod statusbar_config; pub mod statusbar_menu; use iced::widget::tooltip::Position as TipPos; use iced::widget::{ button, container, mouse_area, row, text, text_input, tooltip, }; use iced::{Background, Border, Color, Element, Length, Theme}; /// Scrollable id of the status-bar layout-tab strip (retained so the existing /// `Message::ScrollLayoutTabs` handler still resolves; the strip now flex-wraps /// instead of scrolling). pub const LAYOUT_TABS_SCROLL_ID: &str = "statusbar-layout-tabs"; /// Widget id of the inline layout-rename text input, so the rename can grab /// keyboard focus the moment it opens (issue #86). pub const LAYOUT_RENAME_INPUT_ID: &str = "layout_rename_input"; use crate::app::Message; use crate::snap::Snapper; use crate::ui::statusbar::statusbar_config::{StatusBarConfig, StatusPill}; use crate::ui::wrap_bar::{PosReport, WrapBar, WrapFlow}; // PosReport ids for anchoring each status-bar popup directly to its pill. pub const SB_OSNAP_ID: &str = "SB_OSNAP"; pub const SB_SCALE_ID: &str = "SB_SCALE"; pub const SB_UNITS_ID: &str = "SB_UNITS"; 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 { #[allow(dead_code)] pub coord_display: String, } impl StatusBar { pub fn new() -> Self { Self { coord_display: "MODEL".into(), } } pub fn view<'a>( &'a self, snapper: &'a Snapper, popup_open: bool, 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, current_layout: String, // Start/welcome view has no drawing to own layouts. is_start: bool, // If `Some((original, edit_value))`, the named tab shows a text input. rename_state: Option<&'a (String, String)>, // Scale of the first user viewport in the active paper layout. viewport_scale: Option, // Number of user viewports in the current paper layout (0 = model space). viewport_count: usize, // True when the user is editing inside a paper-space viewport (MSPACE). in_mspace: bool, // Whether the layout tabs (Model/Paper) are visible (LAYOUTTAB). show_layout_tabs: bool, // Current annotation scale for model space (1.0 = 1:1, 50.0 = 1:50, etc.). annotation_scale: f32, // True when the scale picker popup is open. scale_popup_open: bool, // True when the scale pill is interactive (always model space; paper space only when a viewport is active/selected). scale_pill_enabled: bool, // LWDISPLAY header flag — controls lineweight visibility in the viewport. lineweight_display: bool, // Live cursor position in model coordinates, for the coordinate readout. cursor_world: glam::DVec3, // $COORDS readout mode: 0 = static (updates only on a pick), 1 = live // absolute, 2 = polar (distance, // True while a command is prompting for a point (enables the polar readout). picking: bool, // True while clean-screen mode hides the ribbon and side panels. clean_screen: bool, // Drawing units (INSUNITS) for the units pill. insertion_units: i16, // True while the drawing-units picker is open. units_popup_open: bool, // True when objects are hidden by Isolate / Hide. isolation_active: bool, // Whether entity transparency is shown (Transparency pill state). transparency_display: bool, // Whether the Quick Properties floating panel is enabled. quick_properties: bool, // True when the selection filter is excluding at least one type. selection_filter_active: bool, // Whether selection cycling is enabled. selection_cycling: bool, // Which pills the user has chosen to show on the bar. config: &'a StatusBarConfig, ) -> Element<'a, Message> { // Leftmost hamburger: opens a dropdown listing Model + every layout, so // a layout can be picked directly even when the tab strip is scrolled. let menu_button = button(crate::ui::icons::tinted( crate::ui::icons::MENU, 16.0, if is_start { DISABLED_COLOR } else { ICON_COLOR }, )) .style(|_: &Theme, status| button::Style { background: Some(Background::Color(match status { button::Status::Hovered => PILL_BG, _ => Color::TRANSPARENT, })), border: Border { radius: 3.0.into(), ..Default::default() }, ..Default::default() }) .padding([4, 8]); let menu_btn = if is_start { tip( menu_button.into(), "Open or create a drawing to manage layouts.", ) } else { menu_button.on_press(Message::ToggleLayoutList).into() }; let add_button = button(text("+").size(12).color(if is_start { DISABLED_COLOR } else { ICON_COLOR })) .style(|_: &Theme, _| button::Style { background: Some(Background::Color(Color::TRANSPARENT)), ..Default::default() }) .padding([4, 8]); let add_btn = if is_start { tip( add_button.into(), "Open or create a drawing to add a layout.", ) } else { add_button.on_press(Message::LayoutCreate).into() }; // ── Right side ──────────────────────────────────────────────────── let osnap_active = snapper.is_active(); let vp_label = if viewport_count > 0 { format!("{} VP", viewport_count) } else { String::new() }; // Scale pill: opens the scale picker popup. // Model space: always interactive, shows annotation scale. // Paper space: interactive only when a viewport is active/selected. let scale_label = if current_layout == "Model" { format_scale(Some(1.0 / annotation_scale as f64)) } else { format_scale(viewport_scale) }; let scale_element: Element<'_, Message> = if scale_pill_enabled { tip( popup_pill(&scale_label, scale_popup_open, Message::ToggleScalePopup), "Annotation / Viewport Scale\nClick to change", ) } else { status_pill(scale_label).into() }; // Build the right-side pills, honouring the user's per-pill visibility. // They live in a flex-wrap flow (WrapFlow) so they spill onto extra rows // when the width can't hold them all on one line. let vis = |p: StatusPill| config.is_visible(p); let mut pills: Vec> = Vec::new(); if vis(StatusPill::Coords) { let coords_label = format_coords(cursor_world, last_point, coords_mode, picking); pills.push( tip( popup_pill(&coords_label, false, Message::CycleCoordsMode), "Cursor coordinates ($COORDS)\nClick to cycle: static / live / polar", ) .into(), ); } if vis(StatusPill::Ortho) { pills.push( tip( toggle_pill(crate::ui::icons::ST_ORTHO, ortho_mode, Message::ToggleOrtho), "Orthogonal Mode\nF8", ) .into(), ); } if vis(StatusPill::Lwt) { pills.push( tip( toggle_pill(crate::ui::icons::ST_LWT, lineweight_display, Message::ToggleLineweightDisplay), "Show Lineweight\nLWDISPLAY", ) .into(), ); } if vis(StatusPill::Polar) { pills.push( PosReport::new( SB_POLAR_ID, polar_pill(polar_mode, polar_increment_deg, polar_popup_open), ) .into(), ); } if vis(StatusPill::Dyn) { pills.push( tip( toggle_pill(crate::ui::icons::ST_DYN, dyn_input, Message::ToggleDynInput), "Dynamic Input\nF12", ) .into(), ); } if vis(StatusPill::Otrack) { pills.push( tip( toggle_pill(crate::ui::icons::ST_OTRACK, otrack, Message::ToggleOTrack), "Object Snap Tracking\nF11", ) .into(), ); } if vis(StatusPill::Osnap) { pills.push( PosReport::new( SB_OSNAP_ID, osnap_btn(osnap_active, snapper.snap_enabled, popup_open), ) .into(), ); } if vis(StatusPill::Space) { pills.push( tip( space_mode_btn(¤t_layout, in_mspace), "PAPER: double-click viewport to enter MSPACE\nMODEL: click to switch to Model Space", ) .into(), ); } if vis(StatusPill::Scale) { pills.push(PosReport::new(SB_SCALE_ID, scale_element).into()); } if vis(StatusPill::Units) { pills.push( PosReport::new( SB_UNITS_ID, tip( popup_pill( crate::ui::popup::units_popup::unit_short(insertion_units), units_popup_open, Message::ToggleUnitsPopup, ), "Drawing Units (INSUNITS)\nClick to change", ), ) .into(), ); } if vis(StatusPill::Transparency) { pills.push( tip( toggle_pill( crate::ui::icons::ST_TRANSPARENCY, transparency_display, Message::ToggleTransparencyDisplay, ), "Show Transparency\nForce opaque when off", ) .into(), ); } if vis(StatusPill::Isolate) { pills.push( PosReport::new( SB_ISOLATE_ID, tip( toggle_pill(crate::ui::icons::ST_ISOLATE, isolation_active, Message::ToggleIsolatePopup), "Isolate Objects\nClick for Isolate / Hide / End", ), ) .into(), ); } if vis(StatusPill::QuickProps) { pills.push( tip( toggle_pill(crate::ui::icons::ST_QUICKPROPS, quick_properties, Message::ToggleQuickProperties), "Quick Properties\nFloating panel on selection", ) .into(), ); } if vis(StatusPill::SelFilter) { pills.push( PosReport::new( SB_FILTER_ID, tip( toggle_pill( crate::ui::icons::ST_FILTER, selection_filter_active, Message::ToggleSelectionFilterPopup, ), "Selection Filtering\nLimit which object types can be picked", ), ) .into(), ); } if vis(StatusPill::SelCycle) { pills.push( tip( toggle_pill(crate::ui::icons::ST_SELCYCLE, selection_cycling, Message::ToggleSelectionCycling), "Selection Cycling\nRepeat-click to step through overlapping objects", ) .into(), ); } if vis(StatusPill::Vp) && !vp_label.is_empty() { pills.push( tip( status_pill(vp_label).into(), "Viewport count in active layout", ) .into(), ); } if vis(StatusPill::CleanScreen) { pills.push( tip( toggle_pill(crate::ui::icons::ST_CLEANSCREEN, clean_screen, Message::ToggleCleanScreen), "Clean Screen\nHide ribbon and panels", ) .into(), ); } // Customization handle: opens the pill show/hide menu. pills.push( PosReport::new( SB_MENU_ID, tip( customize_btn(), "Customization\nShow or hide status-bar items", ), ) .into(), ); let right_status = WrapFlow::new(pills) .spacing_x(2.0) .row_h(30.0) .justify_end(true); // Left area: hamburger menu + Model/layout tabs in a flex-wrap flow, so // they spill onto lower rows when narrow (no scroll arrows). The pills // use the remaining space on the final tab row when they fit; otherwise // WrapBar adds another right-aligned row. let mut left: Vec> = Vec::new(); left.push(PosReport::new(SB_LAYOUTLIST_ID, menu_btn).into()); if show_layout_tabs { for name in layouts { let is_active = name == current_layout; let renaming = rename_state .filter(|(orig, _)| *orig == name) .map(|(_, edit)| edit.as_str()); left.push(space_tab(name, is_active, renaming, !is_start).into()); } left.push(add_btn.into()); } let left_area = WrapFlow::new(left).spacing_x(2.0).row_h(30.0); let wrap = WrapBar::new(left_area.into(), right_status.into()) .min_row_h(30.0) .justify_end(true); container(wrap) .style(|_: &Theme| container::Style { background: Some(Background::Color(BAR_BG)), border: Border { color: BORDER_COLOR, width: 1.0, radius: 0.0.into(), }, ..Default::default() }) .width(Length::Fill) // One row matches the drawing (document) tab bar height so the three // horizontal strips — tabs, status bar, command line — line up. // WrapBar vertically centres every pill (issue #216) and grows to a // second row when the width can't hold both blocks. .padding([0, 4]) .into() } } // ── Coordinate readout ──────────────────────────────────────────────────── fn format_coords(cursor: glam::DVec3, last: Option, mode: i16, picking: bool) -> String { let abs = |p: glam::DVec3| format!("{:.4}, {:.4}, {:.4}", p.x, p.y, p.z); match mode { // Static: show the last picked point; the readout freezes between picks. 0 => abs(last.unwrap_or(cursor)), // Polar: distance < angle relative to the last point while a command is // prompting for a point; absolute otherwise. 2 => match (picking, last) { (true, Some(l)) => { let d = cursor - l; let dist = (d.x * d.x + d.y * d.y).sqrt(); let mut ang = d.y.atan2(d.x).to_degrees(); if ang < 0.0 { ang += 360.0; } format!("{dist:.4} < {ang:.2}\u{b0}") } _ => abs(cursor), }, // 1 (default) and anything else: live absolute. _ => abs(cursor), } } // ── Customization handle ────────────────────────────────────────────────── fn customize_btn() -> Element<'static, Message> { button(crate::ui::icons::tinted(crate::ui::icons::MENU, 16.0, ICON_COLOR)) .on_press(Message::ToggleStatusBarMenu) .style(|_: &Theme, status| button::Style { background: Some(Background::Color(match status { button::Status::Hovered => PILL_BG, _ => Color::TRANSPARENT, })), border: Border { radius: 3.0.into(), ..Default::default() }, ..Default::default() }) .padding([4, 8]) .into() } // ── Tooltip helper ──────────────────────────────────────────────────────── fn tip<'a>(content: Element<'a, Message>, label: &'static str) -> Element<'a, Message> { tip_node(content, text(label).size(11).color(Color::WHITE).into()) } /// Like [`tip`] but the tooltip body is any element — used to embed an SVG /// glyph (e.g. the dropdown caret) instead of a Unicode character that renders /// as tofu on the web. (#138) fn tip_node<'a>(content: Element<'a, Message>, body: Element<'a, Message>) -> Element<'a, Message> { tooltip( content, container(body) .style(|_: &Theme| container::Style { background: Some(Background::Color(Color { r: 0.13, g: 0.13, b: 0.13, a: 0.97, })), border: Border { color: Color { r: 0.35, g: 0.35, b: 0.35, a: 1.0, }, width: 1.0, radius: 3.0.into(), }, text_color: Some(Color::WHITE), ..Default::default() }) .padding([4, 8]), TipPos::Top, ) .into() } // ── Simple toggle pill ──────────────────────────────────────────────────── /// A status-bar toggle, drawn as a tinted icon (issue #216: the old size-10 /// text labels were too small to read). The name lives in the tooltip each /// call site already wraps it with. fn toggle_pill(icon: &'static [u8], active: bool, msg: Message) -> Element<'static, Message> { let color = if active { OSNAP_ON_TEXT } else { OSNAP_OFF_TEXT }; button(crate::ui::icons::tinted(icon, 17.0, color)) .on_press(msg) .style(move |_: &Theme, status| button::Style { background: Some(Background::Color(match (active, status) { (true, button::Status::Hovered) => SNAP_ON_HOVER, (true, _) => SNAP_ON_BG, (false, button::Status::Hovered) => SNAP_OFF_HOVER, (false, _) => SNAP_OFF_BG, })), border: Border { color: if active { SNAP_BORDER_ON } else { BORDER_COLOR }, width: 1.0, radius: 2.0.into(), }, text_color: color, shadow: iced::Shadow::default(), snap: false, }) .padding([4, 7]) .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 ─────────────────────────────────────────────────── // // 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, open: bool) -> Element<'static, Message> { let angle = crate::ui::popup::polar_popup::angle_label(increment_deg); let tooltip_text = format!( "Polar Tracking ({angle})\nF10 — left-click on/off\nRight-click cycles · ▾ picks angle", ); let color = pill_text_color(active); // 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, }; let main = mouse_area( row![ crate::ui::icons::tinted(crate::ui::icons::ST_POLAR, 17.0, color), text(angle).size(11).color(color), ] .spacing(2) .align_y(iced::Center), ) .on_press(Message::TogglePolar) .on_right_press(Message::SetPolarAngle(next_angle)); tooltip( 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 { r: 0.13, g: 0.13, b: 0.13, a: 0.95, })), border: Border { color: Color { r: 0.35, g: 0.35, b: 0.35, a: 1.0, }, width: 1.0, radius: 3.0.into(), }, ..Default::default() }) .padding([4, 8]), TipPos::Top, ) .into() } // ── OSNAP pill ───────────────────────────────────────────────────────────── // // 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 on = active || snap_enabled; let main = mouse_area(crate::ui::icons::tinted( crate::ui::icons::ST_OSNAP, 17.0, pill_text_color(on), )) .on_press(Message::ToggleSnapEnabled); tip( split_pill(main.into(), Message::ToggleSnapPopup, on, open), "Object Snap: toggle on/off · ▾ opens the snap list\nF3", ) } // ── Helpers ─────────────────────────────────────────────────────────────── /// A layout tab button. /// /// When `rename_edit` is `Some(value)` the tab shows an inline text input /// instead of the normal button. The tab is not renameable when it is the /// "Model" tab (callers simply never pass `Some` for that name). fn space_tab<'a>( label: String, is_active: bool, rename_edit: Option<&'a str>, enabled: bool, ) -> Element<'a, Message> { let bg = move |is_active: bool, hovered: bool| { if is_active { TAB_ACTIVE } else if hovered { TAB_HOVER } else { Color::TRANSPARENT } }; let border = Border { color: if is_active { ACCENT } else { Color::TRANSPARENT }, width: if is_active { 1.0 } else { 0.0 }, radius: 2.0.into(), }; let text_color = if !enabled { DISABLED_COLOR } else if is_active { Color::WHITE } else { Color { r: 0.65, g: 0.65, b: 0.65, a: 1.0, } }; if !enabled { let display = container(text(label.clone()).size(12).color(text_color)) .style(move |_: &Theme| container::Style { background: Some(Background::Color(bg(is_active, false))), border, ..Default::default() }) .padding([4, 10]); crate::ui::wrap_bar::PosReport::owned( format!("SB_LAYOUT_TAB:{label}"), tip( display.into(), "Open or create a drawing to switch layouts.", ), ) .into() } else if let Some(edit_val) = rename_edit { // Inline rename text input with a cancel (✕) button. let input = text_input("", edit_val) .id(iced::widget::Id::new(LAYOUT_RENAME_INPUT_ID)) .on_input(Message::LayoutRenameEdit) .on_submit(Message::LayoutRenameCommit) .size(12) .style(|_: &Theme, _| text_input::Style { background: Background::Color(TAB_ACTIVE), border: Border { color: ACCENT, width: 1.0, radius: 2.0.into(), }, icon: Color::WHITE, placeholder: Color { r: 0.5, g: 0.5, b: 0.5, a: 1.0, }, value: Color::WHITE, selection: Color { r: 0.20, g: 0.55, b: 0.90, a: 0.4, }, }) .padding([3, 6]) .width(Length::Fixed(90.0)); let cancel_btn = button(crate::ui::icons::tinted( crate::ui::icons::CLOSE, 10.0, Color { r: 0.65, g: 0.65, b: 0.65, a: 1.0, }, )) .on_press(Message::LayoutRenameCancel) .style(|_: &Theme, _| button::Style { background: Some(Background::Color(Color::TRANSPARENT)), border: Border::default(), shadow: iced::Shadow::default(), snap: false, ..Default::default() }) .padding([4, 4]); row![input, cancel_btn] .spacing(0) .align_y(iced::Center) .into() } else { // Normal clickable tab — left click switches, right click opens context menu. let display = container(text(label.clone()).size(12).color(text_color)) .style(move |_: &Theme| container::Style { background: Some(Background::Color(bg(is_active, false))), border, ..Default::default() }) .padding([4, 10]); let switch_msg = Message::LayoutSwitch(label.clone()); let ctx_msg = Message::LayoutContextMenu(label.clone()); // Use mouse_area so we can capture right-click for the context menu. // PosReport records the tab's screen bounds so the context menu can // anchor next to the clicked tab instead of the screen's left edge // (#428). crate::ui::wrap_bar::PosReport::owned( format!("SB_LAYOUT_TAB:{label}"), mouse_area(display) .on_press(switch_msg) .on_right_press(ctx_msg), ) .into() } } /// Space-mode toggle button in the status bar. /// /// - Model tab → "MODEL" (non-clickable, informational) /// - Layout, PSPACE → "PAPER" (click → MspaceCommand: enter MSPACE) /// - Layout, MSPACE → "MODEL" (click → ExitViewport: return to PSPACE) fn space_mode_btn(current_layout: &str, in_mspace: bool) -> Element<'static, Message> { let is_model_tab = current_layout == "Model"; // Labels and styling follow AutoCAD convention: // PAPER = currently in paper-space editing // MODEL = currently in model-space editing (either the Model tab or MSPACE) let (label, active, on_press) = if is_model_tab { ("MODEL", false, None::) } else if in_mspace { ("MODEL", true, Some(Message::ExitViewport)) } else { ("PAPER", false, Some(Message::MspaceCommand)) }; let text_color = if active { SNAP_BORDER_ON } else { OSNAP_OFF_TEXT }; let bg_normal = if active { SNAP_ON_BG } else { SNAP_OFF_BG }; let bg_hover = if active { SNAP_ON_HOVER } else { SNAP_OFF_HOVER }; let border_color = if active { SNAP_BORDER_ON } else { BORDER_COLOR }; let clickable = on_press.is_some(); let mut btn = button(text(label).size(12).color(text_color)) .style(move |_: &Theme, status| button::Style { background: Some(Background::Color(match status { button::Status::Hovered if clickable => bg_hover, _ => bg_normal, })), border: Border { color: border_color, width: 1.0, radius: 2.0.into(), }, text_color, shadow: iced::Shadow::default(), snap: false, }) .padding([4, 7]); if let Some(msg) = on_press { btn = btn.on_press(msg); } btn.into() } fn status_pill(label: impl Into) -> Element<'static, Message> { container(text(label.into()).size(12).color(Color { r: 0.65, g: 0.65, b: 0.65, a: 1.0, })) .style(|_: &Theme| container::Style { background: Some(Background::Color(PILL_BG)), border: Border { color: BORDER_COLOR, width: 1.0, radius: 2.0.into(), }, ..Default::default() }) .padding([4, 8]) .into() } // ── Colours ─────────────────────────────────────────────────────────────── const BAR_BG: Color = Color { r: 0.14, g: 0.14, b: 0.14, a: 1.0, }; const TAB_ACTIVE: Color = Color { r: 0.25, g: 0.25, b: 0.25, a: 1.0, }; const TAB_HOVER: Color = Color { r: 0.20, g: 0.20, b: 0.20, a: 1.0, }; const PILL_BG: Color = Color { r: 0.19, g: 0.19, b: 0.19, a: 1.0, }; const BORDER_COLOR: Color = Color { r: 0.28, g: 0.28, b: 0.28, a: 1.0, }; const ICON_COLOR: Color = Color { r: 0.70, g: 0.70, b: 0.70, a: 1.0, }; const DISABLED_COLOR: Color = Color { r: 0.35, g: 0.35, b: 0.35, a: 1.0, }; const ACCENT: Color = Color { r: 0.20, g: 0.55, b: 0.90, a: 1.0, }; const OSNAP_ON_TEXT: Color = Color { r: 0.35, g: 0.75, b: 1.00, a: 1.0, }; const OSNAP_OFF_TEXT: Color = Color { r: 0.42, g: 0.42, b: 0.42, a: 1.0, }; const SNAP_ON_BG: Color = Color { r: 0.10, g: 0.20, b: 0.32, a: 1.0, }; const SNAP_ON_HOVER: Color = Color { r: 0.14, g: 0.27, b: 0.42, a: 1.0, }; const SNAP_BORDER_ON: Color = Color { r: 0.20, g: 0.50, b: 0.85, a: 1.0, }; const SNAP_OFF_BG: Color = Color { r: 0.17, g: 0.17, b: 0.17, a: 1.0, }; const SNAP_OFF_HOVER: Color = Color { r: 0.22, g: 0.22, b: 0.22, a: 1.0, }; // ── Scale popup button ──────────────────────────────────────────────────── /// A labelled status-bar pill that opens a picker popup on click (units, /// scale, …). Lit + accent-bordered while its popup is `open`. Shared so the /// popup-opening pills don't each re-declare the same button chrome. fn popup_pill(label: &str, open: bool, msg: Message) -> Element<'static, Message> { let label = label.to_string(); button( text(label) .size(12) .color(if open { SNAP_BORDER_ON } else { OSNAP_OFF_TEXT }), ) .on_press(msg) .style(move |_: &Theme, status| button::Style { background: Some(Background::Color(match (open, status) { (true, button::Status::Hovered) => SNAP_ON_HOVER, (true, _) => SNAP_ON_BG, (false, button::Status::Hovered) => SNAP_OFF_HOVER, (false, _) => SNAP_OFF_BG, })), border: Border { color: if open { SNAP_BORDER_ON } else { BORDER_COLOR }, width: 1.0, radius: 2.0.into(), }, text_color: if open { SNAP_BORDER_ON } else { OSNAP_OFF_TEXT }, shadow: iced::Shadow::default(), snap: false, }) .padding([4, 7]) .into() } // ── Scale display ───────────────────────────────────────────────────────── /// Formats a viewport scale factor as a human-readable ratio string. /// /// - `None` → "1:1" (model space or no viewport yet) /// - `1.0` → "1:1" /// - `0.02` → "1:50" /// - `2.0` → "2:1" fn format_scale(scale: Option) -> String { let s = match scale { None => return "1:1".to_string(), Some(v) if v <= 0.0 => return "1:1".to_string(), Some(v) => v, }; // Try to express as a clean integer ratio. if s >= 1.0 { let n = s.round() as u32; if (s - n as f64).abs() < 0.01 * s { return if n == 1 { "1:1".to_string() } else { format!("{}:1", n) }; } } else { let inv = (1.0 / s).round() as u32; if (s - 1.0 / inv as f64).abs() < 0.01 * s { return format!("1:{}", inv); } } // Fall back to a decimal string. format!("{:.4}", s) .trim_end_matches('0') .trim_end_matches('.') .to_string() }