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"))
}