//! Bottom status bar — Model/Layout tabs + OSNAP toggle + status info use iced::widget::tooltip::Position as TipPos; use iced::widget::{button, container, mouse_area, row, text, text_input, tooltip, Row}; use iced::{Background, Border, Color, Element, Length, Theme}; /// 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_config::{StatusBarConfig, StatusPill}; #[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, dyn_input: bool, otrack: bool, layouts: Vec, current_layout: String, // 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::Vec3, // 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> { let menu_btn = button(crate::ui::icons::tinted(crate::ui::icons::MENU, 14.0, ICON_COLOR)) .on_press(Message::Command("MENU".into())) .style(|_: &Theme, _| button::Style { background: Some(Background::Color(Color::TRANSPARENT)), ..Default::default() }) .padding([2, 8]); let add_btn = button(text("+").size(12).color(ICON_COLOR)) .on_press(Message::LayoutCreate) .style(|_: &Theme, _| button::Style { background: Some(Background::Color(Color::TRANSPARENT)), ..Default::default() }) .padding([3, 7]); // ── 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( scale_popup_btn(&scale_label, scale_popup_open), "Annotation / Viewport Scale\nClick to change", ) } else { status_pill(scale_label).into() }; // Build the right-side pills, honouring the user's per-pill visibility. let vis = |p: StatusPill| config.is_visible(p); let mut right_status = Row::new().spacing(2).align_y(iced::Center); if vis(StatusPill::Coords) { right_status = right_status.push(tip( status_pill(format_coords(cursor_world)).into(), "Cursor coordinates (X, Y, Z)", )); } if vis(StatusPill::Ortho) { right_status = right_status.push(tip( toggle_pill("ORTHO", ortho_mode, Message::ToggleOrtho), "Orthogonal Mode\nF8", )); } if vis(StatusPill::Lwt) { right_status = right_status.push(tip( toggle_pill("LWT", lineweight_display, Message::ToggleLineweightDisplay), "Show Lineweight\nLWDISPLAY", )); } if vis(StatusPill::Polar) { right_status = right_status.push(polar_pill(polar_mode, polar_increment_deg)); } if vis(StatusPill::Dyn) { right_status = right_status.push(tip( toggle_pill("DYN", dyn_input, Message::ToggleDynInput), "Dynamic Input\nF12", )); } if vis(StatusPill::Otrack) { right_status = right_status.push(tip( toggle_pill("OTRACK", otrack, Message::ToggleOTrack), "Object Snap Tracking\nF11", )); } if vis(StatusPill::Osnap) { right_status = right_status.push(osnap_btn(osnap_active, snapper.snap_enabled, popup_open)); } if vis(StatusPill::Space) { right_status = right_status.push(tip( space_mode_btn(¤t_layout, in_mspace), "PAPER: double-click viewport to enter MSPACE\nMODEL: click to switch to Model Space", )); } if vis(StatusPill::Scale) { right_status = right_status.push(scale_element); } if vis(StatusPill::Units) { right_status = right_status.push(tip( units_btn( crate::ui::units_popup::unit_short(insertion_units), units_popup_open, ), "Drawing Units (INSUNITS)\nClick to change", )); } if vis(StatusPill::Transparency) { right_status = right_status.push(tip( toggle_pill( "TPY", transparency_display, Message::ToggleTransparencyDisplay, ), "Show Transparency\nForce opaque when off", )); } if vis(StatusPill::Isolate) { right_status = right_status.push(tip( toggle_pill("ISO", isolation_active, Message::ToggleIsolatePopup), "Isolate Objects\nClick for Isolate / Hide / End", )); } if vis(StatusPill::QuickProps) { right_status = right_status.push(tip( toggle_pill("QP", quick_properties, Message::ToggleQuickProperties), "Quick Properties\nFloating panel on selection", )); } if vis(StatusPill::SelFilter) { right_status = right_status.push(tip( toggle_pill( "FILTER", selection_filter_active, Message::ToggleSelectionFilterPopup, ), "Selection Filtering\nLimit which object types can be picked", )); } if vis(StatusPill::SelCycle) { right_status = right_status.push(tip( toggle_pill("SC", selection_cycling, Message::ToggleSelectionCycling), "Selection Cycling\nRepeat-click to step through overlapping objects", )); } if vis(StatusPill::Vp) && !vp_label.is_empty() { right_status = right_status.push(tip( status_pill(vp_label).into(), "Viewport count in active layout", )); } if vis(StatusPill::CleanScreen) { right_status = right_status.push(tip( toggle_pill("CLEAN", clean_screen, Message::ToggleCleanScreen), "Clean Screen\nHide ribbon and panels", )); } // Customization handle: opens the pill show/hide menu. right_status = right_status.push(tip( customize_btn(), "Customization\nShow or hide status-bar items", )); let right_status = right_status; let mut bar = Row::new().align_y(iced::Center).spacing(0); bar = bar.push(menu_btn); 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()); bar = bar.push(space_tab(name, is_active, renaming)); } bar = bar.push(add_btn); } bar = bar.push(iced::widget::Space::new().width(Length::Fill)); bar = bar.push(right_status); container(bar) .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) .height(26) .padding([0, 4]) .into() } } // ── Coordinate readout ──────────────────────────────────────────────────── fn format_coords(p: glam::Vec3) -> String { format!("{:.4}, {:.4}, {:.4}", p.x, p.y, p.z) } // ── Customization handle ────────────────────────────────────────────────── fn customize_btn() -> Element<'static, Message> { button(crate::ui::icons::tinted(crate::ui::icons::MENU, 14.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([2, 7]) .into() } // ── Tooltip helper ──────────────────────────────────────────────────────── fn tip<'a>(content: Element<'a, Message>, label: &'static str) -> Element<'a, Message> { tooltip( content, container(text(label).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.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 ──────────────────────────────────────────────────── fn toggle_pill(label: &'static str, active: bool, msg: Message) -> Element<'static, Message> { button(text(label).size(10).color(if active { OSNAP_ON_TEXT } else { OSNAP_OFF_TEXT })) .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: if active { OSNAP_ON_TEXT } else { OSNAP_OFF_TEXT }, shadow: iced::Shadow::default(), snap: false, }) .padding([2, 6]) .into() } // ── Polar tracking pill ─────────────────────────────────────────────────── // // Left-click toggles polar on/off. // Right-click cycles through common angle increments: 15 → 30 → 45 → 90 → 15 … fn polar_pill(active: bool, increment_deg: f32) -> Element<'static, Message> { let label = format!("POLAR {:.0}°", increment_deg); let tooltip_text = format!( "Polar Tracking ({}°)\nF10 — left-click on/off\nRight-click to change angle", increment_deg as u32 ); 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, }; // 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 inner = container(text(label).size(10).color(if active { OSNAP_ON_TEXT } else { OSNAP_OFF_TEXT })) .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([2, 6]); let pill = mouse_area(inner) .on_press(Message::TogglePolar) .on_right_press(Message::SetPolarAngle(next_angle)); tooltip( pill, 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 split button ──────────────────────────────────────────────────── // // Left part ("⚡ OSNAP"): toggles the global snap on/off. // Right part ("▾"): 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( row![ crate::ui::icons::tinted(crate::ui::icons::BOLT, 11.0, text_color), text("OSNAP").size(10).color(text_color), ] .spacing(3) .align_y(iced::Center), ) .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([2, 6]); let right = button(crate::ui::icons::arrow_down(9.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([2, 4]); row![ tip(left.into(), "Object Snap: toggle on/off\nF3"), tip(right.into(), "Object Snap settings\nClick ▾"), ] .spacing(0) .into() } // ── 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>, ) -> 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 is_active { Color::WHITE } else { Color { r: 0.65, g: 0.65, b: 0.65, a: 1.0, } }; 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(11) .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([2, 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([2, 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(11).color(text_color)) .style(move |_: &Theme| container::Style { background: Some(Background::Color(bg(is_active, false))), border, ..Default::default() }) .padding([3, 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. 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(10).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([2, 6]); 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(10).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([2, 6]) .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 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 ──────────────────────────────────────────────────── fn units_btn(label: &str, open: bool) -> Element<'static, Message> { let label = label.to_string(); button( text(label) .size(10) .color(if open { SNAP_BORDER_ON } else { OSNAP_OFF_TEXT }), ) .on_press(Message::ToggleUnitsPopup) .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([2, 6]) .into() } fn scale_popup_btn(label: &str, open: bool) -> Element<'static, Message> { let label = label.to_string(); button( text(label) .size(10) .color(if open { SNAP_BORDER_ON } else { OSNAP_OFF_TEXT }), ) .on_press(Message::ToggleScalePopup) .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([2, 6]) .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() }