diff --git a/assets/icons/ui/minus.svg b/assets/icons/ui/minus.svg new file mode 100644 index 000000000..0b3d6597a --- /dev/null +++ b/assets/icons/ui/minus.svg @@ -0,0 +1 @@ + diff --git a/src/app/mod.rs b/src/app/mod.rs index a143b732f..2b4ff4075 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -225,6 +225,11 @@ pub(super) struct OpenCADStudio { ribbon: Ribbon, /// Recently opened files, newest first — backs the Start page panel. recent_files: Vec, + /// How many recent files to keep (user-set on the Start page, persisted). + recent_limit: usize, + /// Live text of the recent-limit input box (may differ from `recent_limit` + /// mid-edit; applied on Enter). Kept in sync when the +/- buttons change it. + recent_limit_input: String, command_line: CommandLine, /// Paying Patreon supporters shown on the Start page (name, pledge cents), /// fetched once at boot, highest pledge first. @@ -1139,6 +1144,10 @@ pub enum Message { ScrollLayoutTabs(f32), /// Drop a path from the recent-documents list. RecentRemove(PathBuf), + /// Set how many recent documents to keep (persisted). + SetRecentLimit(usize), + /// Live edit of the recent-limit input box (not applied until Enter). + RecentLimitInput(String), /// User clicked Cancel on the loading overlay. The parser thread keeps /// running but its result is discarded. OpenCancel, @@ -1164,6 +1173,8 @@ pub enum Message { SetProjection(bool), /// Select a ribbon module tab by index. RibbonSelectTab(usize), + /// Change the ribbon tool-panel density (persisted). + SetRibbonCollapseMode(crate::ui::ribbon::CollapseMode), /// A ribbon tool button was clicked — highlights the tool and dispatches its event. RibbonToolClick { tool_id: String, @@ -1977,6 +1988,8 @@ impl OpenCADStudio { ribbon: Ribbon::new(), // Restore recents from disk so the Start page lists them across runs. recent_files: recent::load_recent_files(), + recent_limit: recent::load_recent_limit(), + recent_limit_input: recent::load_recent_limit().to_string(), command_line: CommandLine::new(), patrons: Vec::new(), start_section: StartSection::default(), diff --git a/src/app/recent.rs b/src/app/recent.rs index 4933bad17..6ce3948df 100644 --- a/src/app/recent.rs +++ b/src/app/recent.rs @@ -6,12 +6,17 @@ use super::OpenCADStudio; use std::path::{Path, PathBuf}; +/// Bounds and default for how many recent files are kept. +pub(super) const RECENT_MIN: usize = 5; +pub(super) const RECENT_MAX: usize = 100; +const RECENT_DEFAULT: usize = 20; + impl OpenCADStudio { /// Record a freshly opened file at the top of the recents list. pub(super) fn push_recent(&mut self, path: PathBuf) { self.recent_files.retain(|r| r != &path); self.recent_files.insert(0, path); - self.recent_files.truncate(20); + self.recent_files.truncate(self.recent_limit); // Best-effort persist; silent on failure (read-only home, full disk). let _ = save_recents(&self.recent_files); } @@ -21,9 +26,19 @@ impl OpenCADStudio { self.recent_files.retain(|r| r.as_path() != path); let _ = save_recents(&self.recent_files); } + + /// Set how many recent files are kept, trim the current list to fit, and + /// persist both the new limit and the trimmed list. + pub(super) fn set_recent_limit(&mut self, limit: usize) { + self.recent_limit = limit.clamp(RECENT_MIN, RECENT_MAX); + self.recent_files.truncate(self.recent_limit); + save_recent_limit(self.recent_limit); + let _ = save_recents(&self.recent_files); + } } -/// Rehydrate the recents list from disk. Call once at app boot. +/// Rehydrate the recents list from disk, trimmed to the saved limit. Call once +/// at app boot. pub(super) fn load_recent_files() -> Vec { let Some(path) = recents_file_path() else { return vec![]; @@ -31,32 +46,38 @@ pub(super) fn load_recent_files() -> Vec { let Ok(body) = std::fs::read_to_string(path) else { return vec![]; }; - body.lines() + let mut list: Vec = body + .lines() .filter(|l| !l.trim().is_empty()) .map(PathBuf::from) - .collect() + .collect(); + list.truncate(load_recent_limit()); + list +} + +/// Load the saved recent-file limit (clamped), defaulting to `RECENT_DEFAULT`. +pub(super) fn load_recent_limit() -> usize { + limit_file_path() + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|s| s.trim().parse::().ok()) + .map(|n| n.clamp(RECENT_MIN, RECENT_MAX)) + .unwrap_or(RECENT_DEFAULT) +} + +fn save_recent_limit(limit: usize) { + let Some(path) = limit_file_path() else { return }; + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + let _ = std::fs::write(path, limit.to_string()); } fn recents_file_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("recent.txt")) + Some(crate::config::config_dir()?.join("recent.txt")) +} + +fn limit_file_path() -> Option { + Some(crate::config::config_dir()?.join("recent_limit.txt")) } fn save_recents(list: &[PathBuf]) -> std::io::Result<()> { diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index af3eecdaa..6fe59cc6b 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -257,6 +257,19 @@ impl OpenCADStudio { Task::none() } + Message::SetRecentLimit(limit) => { + self.set_recent_limit(limit); + // Resync the input box to the clamped, applied value. + self.recent_limit_input = self.recent_limit.to_string(); + Task::none() + } + + Message::RecentLimitInput(s) => { + // Keep only digits while typing; applied on Enter (SetRecentLimit). + self.recent_limit_input = s.chars().filter(|c| c.is_ascii_digit()).collect(); + Task::none() + } + Message::OpenPathPicked(Some((path, size_bytes))) => { let name = path .file_name() @@ -608,6 +621,12 @@ impl OpenCADStudio { Task::none() } + Message::SetRibbonCollapseMode(mode) => { + self.ribbon.set_collapse_mode(mode); + self.ribbon.close_dropdown(); + Task::none() + } + Message::RibbonToolClick { tool_id, event } => { self.on_ribbon_tool_click(tool_id, event) } diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index b8c24c506..04ff8c424 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -81,6 +81,8 @@ impl OpenCADStudio { start_page_view( &self.patrons, &self.recent_files, + self.recent_limit, + &self.recent_limit_input, self.win_size.0, self.start_section, ) @@ -2084,6 +2086,8 @@ pub(super) fn collapse_bar<'a>(name: &str, on_press: Message) -> Element<'a, Mes pub(super) fn start_page_view<'a>( patrons: &'a [(String, i64)], recents: &'a [std::path::PathBuf], + recent_limit: usize, + recent_limit_input: &'a str, avail_w: f32, active: super::StartSection, ) -> Element<'a, Message> { @@ -2435,7 +2439,7 @@ pub(super) fn start_page_view<'a>( let avail = (avail_w - 16.0).max(0.0); // minus the page's l/r padding let fits_all = avail >= recent_w + welcome_min + sup_w; - let recent = recent_files_panel(recents); + let recent = recent_files_panel(recents, recent_limit, recent_limit_input); let welcome = container(content).width(Fill).height(Fill); // Right rail: Patreon supporters, fetched at boot. When the list is empty @@ -2468,9 +2472,12 @@ pub(super) fn start_page_view<'a>( } let support_btn = mouse_area( container( - text("♥ Support on Patreon") - .size(12) - .color(Color::WHITE), + iced::widget::row![ + crate::ui::icons::tinted(crate::ui::icons::HEART, 13.0, Color::WHITE), + text("Support on Patreon").size(12).color(Color::WHITE), + ] + .spacing(6) + .align_y(iced::Center), ) .padding([6, 10]) .width(Fill) @@ -2609,17 +2616,22 @@ pub(super) fn start_page_view<'a>( // Properties panel normally occupies, but only when the active tab is the // Start tab. The list is restored from disk at boot and re-saved on every // open — entries persist across sessions. -pub(super) fn recent_files_panel<'a>(recents: &'a [std::path::PathBuf]) -> Element<'a, Message> { +pub(super) fn recent_files_panel<'a>( + recents: &'a [std::path::PathBuf], + limit: usize, + limit_input: &'a str, +) -> Element<'a, Message> { + // Card chrome matches the Supporters rail (the canonical start-page card). const PANEL_BG: Color = Color { - r: 0.10, - g: 0.10, - b: 0.11, + r: 0.12, + g: 0.12, + b: 0.13, a: 1.0, }; const PANEL_BORDER: Color = Color { - r: 0.18, - g: 0.18, - b: 0.20, + r: 0.20, + g: 0.20, + b: 0.22, a: 1.0, }; const ITEM_HOVER: Color = Color { @@ -2629,9 +2641,9 @@ pub(super) fn recent_files_panel<'a>(recents: &'a [std::path::PathBuf]) -> Eleme a: 1.0, }; const TEXT: Color = Color { - r: 0.92, - g: 0.91, - b: 0.90, + r: 0.94, + g: 0.93, + b: 0.92, a: 1.0, }; const MUTED: Color = Color { @@ -2641,23 +2653,21 @@ pub(super) fn recent_files_panel<'a>(recents: &'a [std::path::PathBuf]) -> Eleme a: 1.0, }; - let header = container(text("Recent Documents").size(11).color(MUTED)).padding(iced::Padding { - top: 12.0, - right: 14.0, - bottom: 8.0, - left: 14.0, - }); + // Title mirrors the Supporters rail: size 15 in the bright text colour, + // followed by a 12px gap before the content. + let title = text("Recent Documents").size(15).color(TEXT); let body: Element<'a, Message> = if recents.is_empty() { - container( - text("Files you open will show up here.") - .size(11) - .color(MUTED), - ) - .padding([10, 14]) - .into() + container(text("Files you open will show up here.").size(12).color(MUTED)) + .height(Fill) + .into() } else { - let mut col = column![].spacing(0); + // Right padding reserves a gutter for the scrollbar so it doesn't sit on + // top of the row's ✕ remove button. + let mut col = column![].spacing(0).padding(iced::Padding { + right: 12.0, + ..iced::Padding::ZERO + }); for path in recents { let name = path .file_name() @@ -2722,21 +2732,75 @@ pub(super) fn recent_files_panel<'a>(recents: &'a [std::path::PathBuf]) -> Eleme col = col.push(row![open_btn, remove_btn].spacing(0).align_y(iced::Center)); } - iced::widget::scrollable(col).into() + iced::widget::scrollable(col).height(Fill).into() }; - container(column![header, body]) - .width(iced::Length::Fixed(280.0)) - .height(Fill) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(PANEL_BG)), - border: Border { - color: PANEL_BORDER, - width: 1.0, - radius: 0.0.into(), - }, - ..Default::default() - }) - .into() + // Footer: how many recent files to keep — [-] [editable count] [+] with the + // max shown. The count box takes keyboard input (applied on Enter); the + // update handler clamps to [MIN, MAX] and persists (see `set_recent_limit`), + // so an over-max entry snaps to the max. + const STEP: usize = 5; + let step_style = |_: &Theme, status: button::Status| button::Style { + background: Some(Background::Color(match status { + button::Status::Hovered => ITEM_HOVER, + _ => Color::TRANSPARENT, + })), + text_color: TEXT, + border: Border { + color: PANEL_BORDER, + width: 1.0, + radius: 4.0.into(), + }, + ..Default::default() + }; + // +/- step from whatever is currently shown in the box (mid-edit included). + let shown = limit_input.parse::().unwrap_or(limit); + let count_box = iced::widget::text_input("", limit_input) + .on_input(Message::RecentLimitInput) + .on_submit(Message::SetRecentLimit(shown)) + .size(13) + .padding([2, 6]) + .width(iced::Length::Fixed(46.0)); + let limit_row = row![ + text("Keep recent files").size(11).color(MUTED).width(Fill), + button(crate::ui::icons::tinted(crate::ui::icons::MINUS, 11.0, TEXT)) + .on_press(Message::SetRecentLimit(shown.saturating_sub(STEP))) + .padding([3, 6]) + .style(step_style), + count_box, + button(crate::ui::icons::tinted(crate::ui::icons::PLUS, 11.0, TEXT)) + .on_press(Message::SetRecentLimit(shown + STEP)) + .padding([3, 6]) + .style(step_style), + text(format!("/ {}", super::recent::RECENT_MAX)) + .size(11) + .color(MUTED), + ] + .spacing(6) + .align_y(iced::Center); + + container( + column![ + title, + Space::new().height(iced::Length::Fixed(12.0)), + body, + Space::new().height(iced::Length::Fixed(12.0)), + limit_row, + ] + .width(Fill), + ) + .width(iced::Length::Fixed(280.0)) + .height(Fill) + .padding(20) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(PANEL_BG)), + border: Border { + color: PANEL_BORDER, + width: 1.0, + radius: 8.0.into(), + }, + ..Default::default() + }) + .into() } diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 000000000..d7cd7487b --- /dev/null +++ b/src/config.rs @@ -0,0 +1,30 @@ +//! Shared per-user config directory used by the small settings stores (recent +//! files, status-bar layout, ribbon collapse mode, …). Everything lives under +//! `/OpenCADStudio` so the app keeps a single tidy folder. + +use std::path::PathBuf; + +/// The OpenCADStudio config directory (not created). `None` when the platform +/// config base can't be resolved (e.g. no `HOME`). Callers `join` their own +/// file name onto it and `create_dir_all` its parent before writing. +pub fn config_dir() -> 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) +} diff --git a/src/lib.rs b/src/lib.rs index 1641b7de4..b0dde0913 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,6 +1,7 @@ #![allow(non_snake_case)] pub mod app; +pub mod config; #[cfg(not(target_arch = "wasm32"))] pub mod cli; pub mod command; diff --git a/src/main.rs b/src/main.rs index 40f6b5fb0..fb18927a5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -5,6 +5,7 @@ #![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")] mod app; +mod config; #[cfg(not(target_arch = "wasm32"))] mod cli; mod command; diff --git a/src/ui/icons.rs b/src/ui/icons.rs index c4c04067b..f8a6569b3 100644 --- a/src/ui/icons.rs +++ b/src/ui/icons.rs @@ -48,6 +48,7 @@ const LAY_ULK: &[u8] = include_bytes!("../../assets/icons/layers/layulk.svg"); pub const CHECK: &[u8] = include_bytes!("../../assets/icons/ui/check.svg"); pub const CLOSE: &[u8] = include_bytes!("../../assets/icons/ui/close.svg"); pub const PLUS: &[u8] = include_bytes!("../../assets/icons/ui/plus.svg"); +pub const MINUS: &[u8] = include_bytes!("../../assets/icons/ui/minus.svg"); pub const TRASH: &[u8] = include_bytes!("../../assets/icons/ui/trash.svg"); pub const COPY: &[u8] = include_bytes!("../../assets/icons/ui/copy.svg"); pub const MENU: &[u8] = include_bytes!("../../assets/icons/ui/menu.svg"); diff --git a/src/ui/ribbon/collapse.rs b/src/ui/ribbon/collapse.rs index b97ed1e15..24d8fecb9 100644 --- a/src/ui/ribbon/collapse.rs +++ b/src/ui/ribbon/collapse.rs @@ -8,7 +8,7 @@ //! panel, so it shrinks as the panels do. use std::cell::RefCell; -use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::Arc; use iced::advanced::layout::{self, Layout}; @@ -51,6 +51,94 @@ const TIGHT: u8 = 3; /// before wrapping. const MAX_PANEL_SQUEEZE: f32 = 8.0; +/// How the ribbon tool panels are sized. `Auto` adapts to the window width (the +/// step-by-step degradation); the others pin every panel to one density so the +/// user can override the automatic choice. The selection is persisted. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum CollapseMode { + /// Size panels to the window: degrade from the right as space runs out. + #[default] + Auto, + /// Always full-size panels (large buttons), even if they overflow. + Full, + /// Always compact panels (small icon columns). + Compact, + /// Always collapsed to title buttons. + Collapsed, +} + +impl CollapseMode { + /// Every mode, in dropdown order. + pub const ALL: &'static [CollapseMode] = &[ + CollapseMode::Auto, + CollapseMode::Full, + CollapseMode::Compact, + CollapseMode::Collapsed, + ]; + + /// Label shown in the dropdown. + pub fn label(self) -> &'static str { + match self { + CollapseMode::Auto => "Auto", + CollapseMode::Full => "Full", + CollapseMode::Compact => "Compact", + CollapseMode::Collapsed => "Collapsed", + } + } + + /// Stable identifier used for persistence. + fn id(self) -> &'static str { + match self { + CollapseMode::Auto => "auto", + CollapseMode::Full => "full", + CollapseMode::Compact => "compact", + CollapseMode::Collapsed => "collapsed", + } + } + + fn from_id(s: &str) -> Option { + CollapseMode::ALL.iter().copied().find(|m| m.id() == s) + } + + /// The degradation level every panel is pinned to, or `None` for `Auto`. + fn forced_level(self) -> Option { + match self { + CollapseMode::Auto => None, + CollapseMode::Full => Some(FULL), + CollapseMode::Compact => Some(COMPACT), + CollapseMode::Collapsed => Some(COLLAPSED), + } + } + + /// Load the saved mode, defaulting to `Auto`. + pub fn load() -> Self { + config_path() + .and_then(|p| std::fs::read_to_string(p).ok()) + .and_then(|s| CollapseMode::from_id(s.trim())) + .unwrap_or_default() + } + + /// Persist this mode (best-effort; silent on failure). + pub fn save(self) { + let Some(path) = config_path() else { return }; + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + let _ = std::fs::write(path, self.id()); + } +} + +/// `/OpenCADStudio/ribbon.txt`, matching the other settings stores. +fn config_path() -> Option { + Some(crate::config::config_dir()?.join("ribbon.txt")) +} + +impl std::fmt::Display for CollapseMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.label()) + } +} + pub struct CollapsePanels<'a> { panels: Vec>, /// Title of the panel whose flyout is open (if any). @@ -64,6 +152,12 @@ pub struct CollapsePanels<'a> { /// If set, the measured row height is written here each layout (read when /// anchoring dropdowns below the ribbon). height_out: Option>, + /// If set, `true` is written here whenever the row is in the tight state + /// (some panel dropped to its small icon), so the tab bar can react. + tight_out: Option>, + /// User-chosen density. `Auto` runs the width-based degradation; the others + /// pin every panel to one level regardless of the window width. + mode: CollapseMode, } impl<'a> CollapsePanels<'a> { @@ -76,6 +170,8 @@ impl<'a> CollapsePanels<'a> { divider, levels: RefCell::new(vec![FULL; n]), height_out: None, + tight_out: None, + mode: CollapseMode::Auto, } } @@ -85,6 +181,18 @@ impl<'a> CollapsePanels<'a> { self } + /// Report whether the row is in the tight state into `out` on every layout. + pub fn report_tight(mut self, out: Arc) -> Self { + self.tight_out = Some(out); + self + } + + /// Pin the panels to a density (or `Auto` to size by window width). + pub fn mode(mut self, mode: CollapseMode) -> Self { + self.mode = mode; + self + } + fn shown(&self, i: usize, level: u8) -> &Element<'a, Message> { match level { FULL => &self.panels[i].full, @@ -200,34 +308,45 @@ impl<'a> Widget for CollapsePanels<'a> { }; let total = |levels: &[u8]| -> f32 { (0..n).map(|i| width_of(levels[i], i)).sum() }; - // Degrade from the RIGHT, one panel at a time (gradual): first FULL → - // COMPACT, then COMPACT → COLLAPSED, each phase only while the row still - // overflows. If even the all-collapsed row overflows, every button drops - // to its small icon (tight) together, then the buttons are squeezed. - let mut levels = vec![FULL; n]; - for degraded in [COMPACT, COLLAPSED] { - for i in (0..n).rev() { - if total(&levels) <= max_w { - break; + // A forced mode pins every panel to one level. Otherwise (Auto) degrade + // from the RIGHT, one panel at a time (gradual): first FULL → COMPACT, + // then COMPACT → COLLAPSED, each phase only while the row still overflows. + // If even the all-collapsed row overflows, every button drops to its + // small icon (tight) together, then the buttons are squeezed. + let levels = if let Some(level) = self.mode.forced_level() { + vec![level; n] + } else { + let mut levels = vec![FULL; n]; + for degraded in [COMPACT, COLLAPSED] { + for i in (0..n).rev() { + if total(&levels) <= max_w { + break; + } + levels[i] = degraded; } - levels[i] = degraded; } - } - if total(&levels) > max_w { + if total(&levels) > max_w { + levels + .iter_mut() + .filter(|l| **l == COLLAPSED) + .for_each(|l| *l = TIGHT); + } levels - .iter_mut() - .filter(|l| **l == COLLAPSED) - .for_each(|l| *l = TIGHT); + }; + // The row is "tight" once any panel has dropped to its small icon — the + // last, most cramped state. The tab bar hides its mode selector then. + if let Some(out) = &self.tight_out { + out.store(levels.iter().any(|&l| l == TIGHT), Ordering::Relaxed); } *self.levels.borrow_mut() = levels.clone(); // Same idea as the tab bar squeezing its gaps before wrapping: once every // panel is at its tightest and the row STILL overflows, pull the buttons // together (up to MAX_PANEL_SQUEEZE per gap, reclaiming their edge - // padding) so more of them stay on-screen before anything is clipped. - // `total(&levels) > max_w` only remains true when nothing more can be - // degraded, so this never overlaps a full/compact panel. - let squeeze = if n > 1 && total(&levels) > max_w { + // padding) so more of them stay on-screen before anything is clipped. In + // Auto this only fires when everything is already tight, so it never + // overlaps a full/compact panel; a forced density is left to overflow. + let squeeze = if self.mode == CollapseMode::Auto && n > 1 && total(&levels) > max_w { ((total(&levels) - max_w) / (n - 1) as f32).min(MAX_PANEL_SQUEEZE) } else { 0.0 diff --git a/src/ui/ribbon/mod.rs b/src/ui/ribbon/mod.rs index 123d44f54..8af3f7d1b 100644 --- a/src/ui/ribbon/mod.rs +++ b/src/ui/ribbon/mod.rs @@ -6,7 +6,7 @@ // // Dropdown items within a group are collected into columns of 3 rows. -use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::Arc; use rustc_hash::FxHashMap as HashMap; @@ -24,7 +24,8 @@ mod widgets; use widgets::{StyleContext, *}; mod collapse; use collapse::{CollapsePanels, Panel}; -use crate::ui::wrap_bar::{WrapBar, WrapFlow}; +pub use collapse::CollapseMode; +use crate::ui::wrap_bar::{PosReport, WrapBar, WrapFlow}; // ── Ribbon state ─────────────────────────────────────────────────────────── @@ -70,6 +71,11 @@ pub struct Ribbon { /// panel is collapsed). Written by `CollapsePanels`, read when anchoring /// dropdowns below the ribbon. tool_bar_h: Arc, + /// User-chosen panel density (persisted). `Auto` sizes by window width. + collapse_mode: CollapseMode, + /// Set by `CollapsePanels` when the tool row is in its tight state; the mode + /// selector hides itself then to give the cramped tab row its space back. + collapse_tight: Arc, } /// Per-layer display data shown in the ribbon layer dropdown. @@ -155,9 +161,17 @@ impl Ribbon { active_table_style: String::new(), tab_bar_h: Arc::new(AtomicU32::new(28.0f32.to_bits())), tool_bar_h: Arc::new(AtomicU32::new(TOOL_BAR_H.to_bits())), + collapse_mode: CollapseMode::load(), + collapse_tight: Arc::new(AtomicBool::new(false)), } } + /// Change the tool-panel density and persist the choice. + pub fn set_collapse_mode(&mut self, mode: CollapseMode) { + self.collapse_mode = mode; + mode.save(); + } + /// Current tab-bar height as last measured by the `WrapBar` widget. fn tab_bar_height(&self) -> f32 { f32::from_bits(self.tab_bar_h.load(Ordering::Relaxed)) @@ -436,16 +450,41 @@ impl Ribbon { .min_spacing_x(0.0) .row_h(28.0); - let tab_bar = container( + // Panel-density selector, pinned to the right edge of the tab row: a bare + // ▾ button that opens a list of modes (see `dropdown_overlay`). `Auto` + // sizes panels to the window; the others force one density. The choice is + // persisted (see `Ribbon::set_collapse_mode`). It hides itself once the + // tool row is tight, giving the cramped tab row its space back. + let dd_open = self.open_dropdown.as_deref() == Some(COLLAPSE_MODE_ID); + let mode_btn = button(crate::ui::icons::arrow_down(10.0, ARROW_COLOR)) + .on_press(Message::ToggleRibbonDropdown(COLLAPSE_MODE_ID.to_string())) + .style(move |_: &Theme, status| top_hist_btn_style(true, dd_open, status)) + .height(24) + .padding([2, 8]); + let mode_dd = PosReport::new(COLLAPSE_MODE_ID, mode_btn); + + let mut tab_row = row![container( WrapBar::new(lead.into(), tabs.into()) .spacing(6.0) .report_height(self.tab_bar_h.clone()), ) - .style(|_: &Theme| container::Style { - background: Some(Background::Color(TOPBAR_BG)), - ..Default::default() - }) - .width(Length::Fill); + .width(Length::Fill)] + .spacing(6.0) + .align_y(iced::Center); + if !self.collapse_tight.load(Ordering::Relaxed) { + tab_row = tab_row.push(mode_dd); + } + + let tab_bar = container(tab_row) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(TOPBAR_BG)), + ..Default::default() + }) + .padding(Padding { + right: 6.0, + ..Padding::ZERO + }) + .width(Length::Fill); // ── Tool area ───────────────────────────────────────────────────── let effective_active = if !is_paper @@ -572,6 +611,8 @@ impl Ribbon { .collect(); CollapsePanels::new(panels, self.collapsed_open.clone(), TOOL_BAR_H, BORDER_DARK) .report_height(self.tool_bar_h.clone()) + .report_tight(self.collapse_tight.clone()) + .mode(self.collapse_mode) .into() } else { text("").into() @@ -651,6 +692,56 @@ impl Ribbon { return Some(dropdown_backdrop(positioned)); } + if open_id == COLLAPSE_MODE_ID { + const W: f32 = 150.0; + let current = self.collapse_mode; + let rows: Vec> = CollapseMode::ALL + .iter() + .map(|&m| { + let mark: Element = if m == current { + crate::ui::icons::tinted(crate::ui::icons::CHECK, 11.0, CHECK_COLOR) + } else { + iced::widget::Space::new().width(0).into() + }; + button( + row![ + container(mark).width(Length::Fixed(16.0)), + text(m.label()).size(11).color(LABEL_ON), + ] + .spacing(4) + .align_y(iced::Center), + ) + .on_press(Message::SetRibbonCollapseMode(m)) + .style(|_: &Theme, status| button::Style { + background: Some(Background::Color(match status { + button::Status::Hovered | button::Status::Pressed => ROW_HOVER, + _ => Color::TRANSPARENT, + })), + ..Default::default() + }) + .width(Fill) + .padding([5, 10]) + .into() + }) + .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(W)); + + let (align_right, h_pad, top) = self.dd_anchor(open_id, W, win.0); + let positioned = position_ribbon_dropdown(panel.into(), align_right, h_pad, top); + return Some(dropdown_backdrop(positioned)); + } + if open_id == LAYER_COMBO_ID { return self.layer_combo_overlay(win); } diff --git a/src/ui/ribbon/widgets.rs b/src/ui/ribbon/widgets.rs index ff88d9989..9f036f41e 100644 --- a/src/ui/ribbon/widgets.rs +++ b/src/ui/ribbon/widgets.rs @@ -53,6 +53,8 @@ pub(super) const TOP_HIST_GAP: f32 = 4.0; pub(super) const UNDO_HISTORY_ID: &str = "UNDO_HISTORY"; pub(super) const REDO_HISTORY_ID: &str = "REDO_HISTORY"; pub(super) const LAYER_COMBO_ID: &str = "LAYER_COMBO"; +/// Dropdown id for the tab-bar panel-density selector. +pub(super) const COLLAPSE_MODE_ID: &str = "COLLAPSE_MODE"; pub(super) const PROP_COLOR_ID: &str = "PROP_COLOR"; pub(super) const PROP_LINETYPE_ID: &str = "PROP_LINETYPE"; pub(super) const PROP_LW_ID: &str = "PROP_LW"; diff --git a/src/ui/statusbar/statusbar_config.rs b/src/ui/statusbar/statusbar_config.rs index 2e6e3979f..213ed571c 100644 --- a/src/ui/statusbar/statusbar_config.rs +++ b/src/ui/statusbar/statusbar_config.rs @@ -155,23 +155,5 @@ impl StatusBarConfig { /// `/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")) + Some(crate::config::config_dir()?.join("statusbar.txt")) }