forked from 1b-agent/opencadstudio-upstream
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:
1 parent
8e6146fd91
commit
f4ed263425
13 files changed
+457
-112
No files matched your search
@@ -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
@@ -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<()> {
|
||||
|
||||
@@ -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
@@ -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()
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user