From d829f80a04e4447b125d93915a5ef82cdea9ee85 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Thu, 4 Jun 2026 19:14:04 +0300 Subject: [PATCH] feat(statusbar): customizable pills with coordinates and clean screen Add a customization menu (far-right handle) that lists every status-bar pill with a check mark; toggling a row shows or hides that pill, and the choice persists across sessions in the config dir. New pills: - Coordinates: live cursor readout (re-adds the model-space world offset so it shows true drawing coordinates), updated on every move rather than only while a command is active. - Clean Screen: hide the ribbon and side panels for a full canvas. Co-Authored-By: Claude Opus 4.8 --- src/app/mod.rs | 17 ++++ src/app/update.rs | 50 ++++++++++++ src/app/view.rs | 43 ++++++++-- src/ui/mod.rs | 2 + src/ui/statusbar.rs | 125 +++++++++++++++++++++------- src/ui/statusbar_config.rs | 161 +++++++++++++++++++++++++++++++++++++ src/ui/statusbar_menu.rs | 117 +++++++++++++++++++++++++++ 7 files changed, 482 insertions(+), 33 deletions(-) create mode 100644 src/ui/statusbar_config.rs create mode 100644 src/ui/statusbar_menu.rs diff --git a/src/app/mod.rs b/src/app/mod.rs index 11d80c305..76cb31614 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -171,6 +171,12 @@ pub(super) struct OpenCADStudio { snapper: Snapper, snap_popup_open: bool, scale_popup_open: bool, + /// True while the status-bar customization menu is open. + statusbar_menu_open: bool, + /// Clean-screen mode: hide ribbon and side panels for a full canvas. + clean_screen: bool, + /// Which status-bar pills the user has chosen to show (persisted). + statusbar_config: crate::ui::statusbar_config::StatusBarConfig, /// Whether Tangent snap was enabled before a tangent-pick command started. pre_cmd_tangent: Option, /// Orthogonal drawing constraint (F8): constrains picks to 0°/90°/180°/270°. @@ -732,6 +738,14 @@ pub enum Message { ToggleScalePopup, /// Close the scale picker popup. CloseScalePopup, + /// Toggle the status-bar customization menu open/closed. + ToggleStatusBarMenu, + /// Close the status-bar customization menu. + CloseStatusBarMenu, + /// Show/hide a single status-bar pill. + ToggleStatusPill(crate::ui::statusbar_config::StatusPill), + /// Toggle clean-screen mode (hide ribbon + side panels). + ToggleCleanScreen, /// Toggle dynamic input overlay (F12). ToggleDynInput, /// Toggle object snap tracking (F11). @@ -1188,6 +1202,9 @@ impl OpenCADStudio { snapper: Snapper::default(), snap_popup_open: false, scale_popup_open: false, + statusbar_menu_open: false, + statusbar_config: crate::ui::statusbar_config::StatusBarConfig::load(), + clean_screen: false, pre_cmd_tangent: None, ortho_mode: false, polar_mode: false, diff --git a/src/app/update.rs b/src/app/update.rs index c00828b7f..6fed06502 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -2130,6 +2130,39 @@ impl OpenCADStudio { return Task::none(); } + // Keep the coordinate readout live on every move, even with no + // active command. When a command is running the snap path below + // overwrites this with the snapped point. + { + let bounds = iced::Rectangle { + x: 0.0, + y: 0.0, + width: vp_size.0, + height: vp_size.1, + }; + let paper = if let Some(ref ucs) = self.tabs[i].active_ucs { + let origin = glam::Vec3::new( + ucs.origin.x as f32, + ucs.origin.y as f32, + ucs.origin.z as f32, + ); + let normal = ucs_z_axis(ucs); + self.tabs[i] + .scene + .camera + .borrow() + .pick_on_plane(p, bounds, normal, origin) + } else { + self.tabs[i] + .scene + .camera + .borrow() + .pick_on_target_plane(p, bounds) + }; + let world = self.tabs[i].scene.paper_to_model(paper); + self.tabs[i].last_cursor_world = world; + } + if self.tabs[i].active_cmd.is_some() { let (vw, vh) = vp_size; let bounds = iced::Rectangle { @@ -3530,6 +3563,23 @@ impl OpenCADStudio { self.scale_popup_open = false; Task::none() } + Message::ToggleStatusBarMenu => { + self.statusbar_menu_open ^= true; + Task::none() + } + Message::CloseStatusBarMenu => { + self.statusbar_menu_open = false; + Task::none() + } + Message::ToggleStatusPill(pill) => { + // Keep the menu open so several pills can be toggled in a row. + self.statusbar_config.toggle(pill); + Task::none() + } + Message::ToggleCleanScreen => { + self.clean_screen ^= true; + Task::none() + } Message::ToggleSnap(t) => { self.snapper.toggle(t); Task::none() diff --git a/src/app/view.rs b/src/app/view.rs index 111fbebc0..c432e2046 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -1057,7 +1057,7 @@ impl OpenCADStudio { // Replace the properties panel with a Recent Documents list there. let properties_el: Element<'_, Message> = if tab.is_start { recent_files_panel(&self.app_menu.recent) - } else if self.show_properties { + } else if self.show_properties && !self.clean_screen { tab.properties.view() } else { Space::new().into() @@ -1100,11 +1100,16 @@ impl OpenCADStudio { .height(Fill); let main_ui = container({ - let mut col = column![self.ribbon.view( - is_paper, - self.tabs[self.active_tab].history.undo_stack.len(), - self.tabs[self.active_tab].history.redo_stack.len(), - )]; + // Clean-screen mode drops the ribbon for a full-canvas view; the + // status bar stays so the mode can be toggled back off. + let mut col = column![]; + if !self.clean_screen { + col = col.push(self.ribbon.view( + is_paper, + self.tabs[self.active_tab].history.undo_stack.len(), + self.tabs[self.active_tab].history.redo_stack.len(), + )); + } if self.show_file_tabs { col = col.push(doc_tab_bar(&self.tabs, self.active_tab)); } @@ -1114,6 +1119,22 @@ impl OpenCADStudio { let scale_pill_enabled = is_model || tab.scene.active_viewport.is_some() || tab.scene.has_selected_viewport(); + // The cursor is tracked in local render space; re-add the + // model-space world offset so the readout shows true + // drawing coordinates (paper space carries no offset). + let cursor_coord = { + let lc = tab.last_cursor_world; + if is_model { + let wo = tab.scene.world_offset; + glam::Vec3::new( + lc.x + wo[0] as f32, + lc.y + wo[1] as f32, + lc.z + wo[2] as f32, + ) + } else { + lc + } + }; self.status_bar.view( &self.snapper, self.snap_popup_open, @@ -1134,6 +1155,9 @@ impl OpenCADStudio { self.scale_popup_open, scale_pill_enabled, tab.scene.document.header.lineweight_display, + cursor_coord, + self.clean_screen, + &self.statusbar_config, ) }) .width(Fill) @@ -1169,6 +1193,12 @@ impl OpenCADStudio { iced::widget::Space::new().width(0).height(0).into() }; + let statusbar_menu_layer: Element<'_, Message> = if self.statusbar_menu_open { + crate::ui::statusbar_menu::statusbar_menu_overlay(&self.statusbar_config) + } else { + iced::widget::Space::new().width(0).height(0).into() + }; + let dropdown_layer: Element<'_, Message> = self .ribbon .dropdown_overlay( @@ -1204,6 +1234,7 @@ impl OpenCADStudio { self.app_menu.view(), snap_layer, scale_layer, + statusbar_menu_layer, dropdown_layer, layout_ctx_layer, qselect_layer, diff --git a/src/ui/mod.rs b/src/ui/mod.rs index a3ce298cd..e1030114d 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -21,6 +21,8 @@ pub mod scale_popup; pub mod shortcuts; pub mod snap_popup; pub mod statusbar; +pub mod statusbar_config; +pub mod statusbar_menu; pub mod tablestyle; pub mod textstyle; diff --git a/src/ui/statusbar.rs b/src/ui/statusbar.rs index 29cc0932a..23cb3cfaa 100644 --- a/src/ui/statusbar.rs +++ b/src/ui/statusbar.rs @@ -6,6 +6,7 @@ use iced::{Background, Border, Color, Element, Length, Theme}; use crate::app::Message; use crate::snap::Snapper; +use crate::ui::statusbar_config::{StatusBarConfig, StatusPill}; #[derive(Clone, Default)] pub struct StatusBar { @@ -50,6 +51,12 @@ impl StatusBar { 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, + // Which pills the user has chosen to show on the bar. + config: &'a StatusBarConfig, ) -> Element<'a, Message> { let menu_btn = button(text("≡").size(14).color(ICON_COLOR)) .on_press(Message::Command("MENU".into())) @@ -92,46 +99,84 @@ impl StatusBar { } else { status_pill(scale_label).into() }; - let mut right_status = row![ - tip( + // 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::Snap) { + right_status = right_status.push(tip( toggle_pill("SNAP", snap_grid_on, Message::ToggleGridSnap), - "Snap to Grid\nF9" - ), - tip( + "Snap to Grid\nF9", + )); + } + if vis(StatusPill::Grid) { + right_status = right_status.push(tip( toggle_pill("GRID", show_grid, Message::ToggleGrid), - "Show Grid\nF7" - ), - tip( + "Show Grid\nF7", + )); + } + if vis(StatusPill::Ortho) { + right_status = right_status.push(tip( toggle_pill("ORTHO", ortho_mode, Message::ToggleOrtho), - "Orthogonal Mode\nF8" - ), - tip( + "Orthogonal Mode\nF8", + )); + } + if vis(StatusPill::Lwt) { + right_status = right_status.push(tip( toggle_pill("LWT", lineweight_display, Message::ToggleLineweightDisplay), - "Show Lineweight\nLWDISPLAY" - ), - polar_pill(polar_mode, polar_increment_deg), - tip( + "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" - ), - tip( + "Dynamic Input\nF12", + )); + } + if vis(StatusPill::Otrack) { + right_status = right_status.push(tip( toggle_pill("OTRACK", otrack, Message::ToggleOTrack), - "Object Snap Tracking\nF11" - ), - osnap_btn(osnap_active, snapper.snap_enabled, popup_open), - tip( + "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", - ), - scale_element, - ] - .spacing(2); - if !vp_label.is_empty() { + )); + } + if vis(StatusPill::Scale) { + right_status = right_status.push(scale_element); + } + 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); @@ -166,6 +211,32 @@ impl StatusBar { } } +// ── 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(text("≡").size(14).color(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> { diff --git a/src/ui/statusbar_config.rs b/src/ui/statusbar_config.rs new file mode 100644 index 000000000..483475454 --- /dev/null +++ b/src/ui/statusbar_config.rs @@ -0,0 +1,161 @@ +//! Status-bar customization — which toggle pills are shown on the bar. +//! +//! The customization menu (opened from the bar's far-right handle) lists every +//! pill with a check mark next to the ones currently shown. Toggling a row +//! adds or removes that pill from the bar. The choice is persisted so it +//! survives across sessions. + +use std::collections::HashSet; +use std::path::PathBuf; + +/// Identifies a toggleable status-bar pill. +#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)] +pub enum StatusPill { + Coords, + Snap, + Grid, + Ortho, + Lwt, + Polar, + Dyn, + Otrack, + Osnap, + Space, + Scale, + Vp, + CleanScreen, +} + +impl StatusPill { + /// Every pill, in status-bar display order. Drives both the bar layout and + /// the customization menu. + pub const ALL: &'static [StatusPill] = &[ + StatusPill::Coords, + StatusPill::Snap, + StatusPill::Grid, + StatusPill::Ortho, + StatusPill::Lwt, + StatusPill::Polar, + StatusPill::Dyn, + StatusPill::Otrack, + StatusPill::Osnap, + StatusPill::Space, + StatusPill::Scale, + StatusPill::Vp, + StatusPill::CleanScreen, + ]; + + /// Stable identifier used for persistence. + pub fn id(self) -> &'static str { + match self { + StatusPill::Coords => "coords", + StatusPill::Snap => "snap", + StatusPill::Grid => "grid", + StatusPill::Ortho => "ortho", + StatusPill::Lwt => "lwt", + StatusPill::Polar => "polar", + StatusPill::Dyn => "dyn", + StatusPill::Otrack => "otrack", + StatusPill::Osnap => "osnap", + StatusPill::Space => "space", + StatusPill::Scale => "scale", + StatusPill::Vp => "vp", + StatusPill::CleanScreen => "cleanscreen", + } + } + + /// Label shown in the customization menu. + pub fn label(self) -> &'static str { + match self { + StatusPill::Coords => "Coordinates", + StatusPill::Snap => "Snap Mode", + StatusPill::Grid => "Grid", + StatusPill::Ortho => "Ortho Mode", + StatusPill::Lwt => "Show Lineweight", + StatusPill::Polar => "Polar Tracking", + StatusPill::Dyn => "Dynamic Input", + StatusPill::Otrack => "Object Snap Tracking", + StatusPill::Osnap => "Object Snap", + StatusPill::Space => "Model/Paper Space", + StatusPill::Scale => "Annotation Scale", + StatusPill::Vp => "Viewport Count", + StatusPill::CleanScreen => "Clean Screen", + } + } + + fn from_id(s: &str) -> Option { + StatusPill::ALL.iter().copied().find(|p| p.id() == s) + } +} + +/// Tracks which pills the user has hidden. Empty = every pill visible. +#[derive(Clone, Default)] +pub struct StatusBarConfig { + hidden: HashSet, +} + +impl StatusBarConfig { + /// Load the saved customization, or all-visible when none exists. + pub fn load() -> Self { + let mut hidden = HashSet::new(); + if let Some(path) = config_path() { + if let Ok(body) = std::fs::read_to_string(path) { + for line in body.lines() { + if let Some(p) = StatusPill::from_id(line.trim()) { + hidden.insert(p); + } + } + } + } + Self { hidden } + } + + pub fn is_visible(&self, pill: StatusPill) -> bool { + !self.hidden.contains(&pill) + } + + /// Flip a pill's visibility and persist the change. + pub fn toggle(&mut self, pill: StatusPill) { + if !self.hidden.remove(&pill) { + self.hidden.insert(pill); + } + self.save(); + } + + fn save(&self) { + let Some(path) = config_path() else { return }; + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + let body: String = StatusPill::ALL + .iter() + .filter(|p| self.hidden.contains(p)) + .map(|p| p.id()) + .collect::>() + .join("\n"); + let _ = std::fs::write(path, body); + } +} + +/// `/OpenCADStudio/statusbar.txt`, matching the recent-files store. +fn config_path() -> Option { + let base: PathBuf = if cfg!(target_os = "windows") { + std::env::var_os("APPDATA").map(PathBuf::from)? + } else if cfg!(target_os = "macos") { + let home = std::env::var_os("HOME")?; + let mut p = PathBuf::from(home); + p.push("Library"); + p.push("Application Support"); + p + } else if let Some(d) = std::env::var_os("XDG_CONFIG_HOME") { + PathBuf::from(d) + } else { + let home = std::env::var_os("HOME")?; + let mut p = PathBuf::from(home); + p.push(".config"); + p + }; + let mut p = base; + p.push("OpenCADStudio"); + Some(p.join("statusbar.txt")) +} diff --git a/src/ui/statusbar_menu.rs b/src/ui/statusbar_menu.rs new file mode 100644 index 000000000..3c092d377 --- /dev/null +++ b/src/ui/statusbar_menu.rs @@ -0,0 +1,117 @@ +//! Status-bar customization menu — a checkmark list of every toggle pill, +//! opened from the bar's far-right handle. Rendered as a floating overlay +//! above the status bar, same pattern as the scale picker. + +use iced::widget::{button, column, container, mouse_area, row, text}; +use iced::{Background, Border, Color, Element, Fill, Length, Padding, Theme}; + +use crate::app::Message; +use crate::ui::statusbar_config::{StatusBarConfig, StatusPill}; + +/// Full-screen overlay: transparent click-catcher + the menu panel pinned to +/// the bottom-right, just above the status bar. +pub fn statusbar_menu_overlay(config: &StatusBarConfig) -> Element<'static, Message> { + let rows: Vec> = StatusPill::ALL + .iter() + .map(|&pill| { + menu_row( + pill.label(), + config.is_visible(pill), + Message::ToggleStatusPill(pill), + ) + }) + .collect(); + + 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(200.0)); + + let positioned = container(panel) + .align_right(Fill) + .align_bottom(Fill) + .padding(Padding { + bottom: 27.0, + right: 4.0, + top: 0.0, + left: 0.0, + }) + .width(Fill) + .height(Fill); + + mouse_area(positioned) + .on_press(Message::CloseStatusBarMenu) + .into() +} + +fn menu_row(label: &'static str, checked: bool, msg: Message) -> Element<'static, Message> { + let check = text(if checked { "✓" } else { " " }) + .size(11) + .color(if checked { CHECK_COLOR } else { Color::TRANSPARENT }) + .width(Length::Fixed(14.0)); + + let lbl = text(label) + .size(11) + .color(if checked { LABEL_ON } else { LABEL_OFF }); + + let content = row![check, lbl].spacing(6).align_y(iced::Center); + + button(content) + .on_press(msg) + .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, +};