feat(ui): ribbon collapse-mode selector + start-page recent controls

Ribbon:
- A ▾ selector at the right of the tab row picks the tool-panel density —
  Auto (size to window), Full, Compact, or Collapsed. The choice is persisted.
  It hides itself when the tool row is already tight, giving the cramped tab
  row its space back.

Start page:
- The Recent Documents card is restyled to match the Supporters card
  (rounded corners, 20px padding, matching title + colours).
- Its scrollbar no longer sits on top of the row ✕ remove buttons (a gutter
  is reserved).
- A footer control sets how many recent files to keep: [-] an editable count
  [+] with the max shown. Typing applies on Enter; an over-max entry snaps to
  the max. The limit is persisted and enforced on load and on every open.
- The "Support on Patreon" heart is now the SVG icon instead of a font glyph.

Config:
- Add a shared `config::config_dir()` helper; the recent-files and status-bar
  stores now go through it instead of each re-deriving the path. New minus.svg
  icon for the stepper.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-06 01:06:01 +03:00
1 parent 8e6146fd91
commit f4ed263425
13 files changed
+457 -112

No files matched your search

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#000000" stroke-width="2.2" stroke-linecap="round"><path d="M5 12 L19 12"/></svg>

After

Width:  |  Height:  |  Size: 162 B

+13
View File
@@ -225,6 +225,11 @@ pub(super) struct OpenCADStudio {
ribbon: Ribbon,
/// Recently opened files, newest first — backs the Start page panel.
recent_files: Vec<std::path::PathBuf>,
/// 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(),
+44 -23
View File
@@ -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<PathBuf> {
let Some(path) = recents_file_path() else {
return vec![];
@@ -31,32 +46,38 @@ pub(super) fn load_recent_files() -> Vec<PathBuf> {
let Ok(body) = std::fs::read_to_string(path) else {
return vec![];
};
body.lines()
let mut list: Vec<PathBuf> = 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::<usize>().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<PathBuf> {
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<PathBuf> {
Some(crate::config::config_dir()?.join("recent_limit.txt"))
}
fn save_recents(list: &[PathBuf]) -> std::io::Result<()> {
+19
View File
@@ -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)
}
+106 -42
View File
@@ -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::<usize>().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()
}
+30
View File
@@ -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
//! `<platform-config>/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<PathBuf> {
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)
}
+1
View File
@@ -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;
+1
View File
@@ -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;
+1
View File
@@ -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");
+139 -20
View File
@@ -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<Self> {
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<u8> {
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());
}
}
/// `<config-dir>/OpenCADStudio/ribbon.txt`, matching the other settings stores.
fn config_path() -> Option<std::path::PathBuf> {
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<Panel<'a>>,
/// 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<Arc<AtomicU32>>,
/// 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<Arc<AtomicBool>>,
/// 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<AtomicBool>) -> 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<Message, Theme, Renderer> 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
+99 -8
View File
@@ -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<AtomicU32>,
/// 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<AtomicBool>,
}
/// 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<Element<Message>> = CollapseMode::ALL
.iter()
.map(|&m| {
let mark: Element<Message> = 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);
}
+2
View File
@@ -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";
+1 -19
View File
@@ -155,23 +155,5 @@ impl StatusBarConfig {
/// `<config-dir>/OpenCADStudio/statusbar.txt`, matching the recent-files store.
fn config_path() -> Option<PathBuf> {
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"))
}