feat(app): add fixed Start tab with welcome page and recent documents

A pinned "Start" tab now lives at index 0. It cannot be closed, opens
on launch, and replaces the model-space viewport with a welcome page —
similar to Thunderbird's first-run screen.

- Boot opens with only the Start tab; Drawing1 is no longer auto-spawned.
- The Properties left panel becomes a Recent Documents rail on the
  Start tab. Recents persist to disk
  (%APPDATA%/H7CAD/recent.txt on Windows, $XDG_CONFIG_HOME/H7CAD/recent.txt
  on Linux, ~/Library/Application Support/H7CAD/recent.txt on macOS), up
  to 20 entries, saved on every open and rehydrated at boot.
  Each row opens or removes the file in place.
- The Support ribbon group (Donate / Report / About / Changelog) is
  retired; the same actions surface on the welcome page instead. The
  Donate button is the primary call-to-action — solid #B03020 brand
  fill with a soft brand-tinted ambient glow halo behind the action row
  (Thunderbird-style coloured glow against the neutral page).
- Drawing-only overlays (mouse-area, viewcube, navbar, dynamic input,
  selection markers) are suppressed on the Start tab so its clicks
  don't trigger CAD commands. OpenFile's "current_is_empty" check
  refuses to replace the Start tab, and TabClose ignores requests on
  it.

Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
HakanandClaude Opus 4.7 committed 2026-05-21 16:19:31 +03:00
1 parent 3fb23ae31b
commit 35acdb2dc8
10 files changed
+515 -72

No files matched your search

+16
View File
@@ -44,6 +44,11 @@ pub(super) struct DocumentTab {
pub(super) active_mleader_style: String,
/// Last camera_generation value written back to the document.
pub(super) last_synced_camera_gen: u64,
/// Sentinel "Welcome / Start" tab. Always at index 0 when present.
/// Cannot be closed; the viewport area renders a welcome page instead
/// of the model-space shader. The scene is still constructed so the
/// rest of the code can treat it as a normal tab when reading.
pub(super) is_start: bool,
}
impl DocumentTab {
@@ -86,9 +91,20 @@ impl DocumentTab {
refedit_session: None,
active_mleader_style: "Standard".to_string(),
last_synced_camera_gen: 0,
is_start: false,
}
}
/// Welcome / Start tab. Carries a dummy Scene so the rest of the app
/// can read tab state uniformly; the viewport renderer detects
/// `is_start` and shows a welcome page instead.
pub(super) fn new_start() -> Self {
let mut t = Self::new_drawing(0);
t.tab_title = "Start".to_string();
t.is_start = true;
t
}
pub(super) fn tab_display_name(&self) -> String {
match &self.current_path {
Some(p) => p
+14 -4
View File
@@ -282,6 +282,11 @@ pub enum Message {
/// File picker returned. `Some((path, size_in_bytes))` → start loading;
/// `None` → user cancelled the dialog (no overlay shown).
OpenPathPicked(Option<(PathBuf, u64)>),
/// Open a path from the Start tab's recent-documents list (skips the
/// file picker; the path is already known).
OpenRecent(PathBuf),
/// Drop a path from the recent-documents list.
RecentRemove(PathBuf),
/// User clicked Cancel on the loading overlay. The parser thread keeps
/// running but its result is discarded.
OpenCancel,
@@ -688,14 +693,19 @@ pub enum Message {
impl H7CAD {
fn new() -> Self {
let first_tab = DocumentTab::new_drawing(1);
// Boot with only the Welcome/Start tab. The user creates drawings
// explicitly (File → New); we never auto-spawn Drawing1.
let start_tab = DocumentTab::new_start();
let mut app_menu = AppMenu::new();
// Restore recents from disk so the Start page lists them across runs.
app_menu.load_persistent_recents();
let mut app = Self {
start: Instant::now(),
tabs: vec![first_tab],
tabs: vec![start_tab],
active_tab: 0,
tab_counter: 1,
tab_counter: 0,
ribbon: Ribbon::new(),
app_menu: AppMenu::new(),
app_menu,
command_line: CommandLine::new(),
status_bar: StatusBar::new(),
cursor_pos: Point::ORIGIN,
+32 -2
View File
@@ -57,6 +57,27 @@ impl H7CAD {
Message::OpenPathPicked(None) => Task::none(),
Message::OpenRecent(path) => {
// Recents are read from disk every save → the path may be
// stale. Skip silently if the file no longer exists; the
// entry stays in the list so the user can clean it up.
match std::fs::metadata(&path) {
Ok(m) => self.update(Message::OpenPathPicked(Some((path, m.len())))),
Err(_) => {
self.command_line.push_error(&format!(
"Recent file no longer exists: {}",
path.display()
));
Task::none()
}
}
}
Message::RecentRemove(path) => {
self.app_menu.remove_recent(&path);
Task::none()
}
Message::OpenPathPicked(Some((path, size_bytes))) => {
let name = path
.file_name()
@@ -107,7 +128,8 @@ impl H7CAD {
let current_is_empty = {
let t = &self.tabs[self.active_tab];
t.current_path.is_none()
!t.is_start
&& t.current_path.is_none()
&& !t.dirty
&& self.tabs[self.active_tab].scene.document.entities().count() == 0
};
@@ -754,13 +776,21 @@ impl H7CAD {
}
Message::TabClose(idx) => {
// Start tab is fixed — close requests on it are no-ops.
if self.tabs.get(idx).map_or(false, |t| t.is_start) {
return Task::none();
}
if self.tabs.get(idx).map_or(false, |t| t.dirty) {
self.pending_close = Some(super::PendingClose::Tab(idx));
return self.open_unsaved_dialog_window();
}
// Only-tab case: when the lone non-start tab closes, fall
// back to the Start tab if it exists; otherwise spawn a
// fresh blank drawing (legacy behaviour).
if self.tabs.len() == 1 {
self.tab_counter += 1;
self.tabs[0] = super::document::DocumentTab::new_drawing(self.tab_counter);
self.tabs[0] =
super::document::DocumentTab::new_drawing(self.tab_counter);
self.active_tab = 0;
} else {
self.tabs.remove(idx);
+384 -52
View File
@@ -226,7 +226,13 @@ impl H7CAD {
let i = self.active_tab;
let tab = &self.tabs[i];
let is_paper = tab.scene.current_layout != "Model";
let viewport_3d: Element<'_, Message> = if is_paper {
// Start tab: render welcome page in place of the model/paper canvas.
// Surrounding chrome (tab bar, status bar) stays; the welcome widget
// returned here also flags the rest of `view` to skip drawing-only
// overlays via `tab.is_start`.
let viewport_3d: Element<'_, Message> = if tab.is_start {
start_page_view()
} else if is_paper {
paper_canvas_view(tab)
} else {
shader(ViewportPane::model(&tab.scene, self.show_viewcube))
@@ -414,22 +420,37 @@ impl H7CAD {
None
};
let mut viewport_stack = stack![
container(viewport_3d)
let mut viewport_stack = if tab.is_start {
// Start tab: only the welcome widget over a flat background.
// Skip every drawing-only overlay (selection markers, snap info,
// mouse-area capturing draw clicks, viewcube, nav toolbar, …).
stack![container(viewport_3d)
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(bg_color)),
..Default::default()
})
.width(Fill)
.height(Fill),
container(info).width(Fill).height(Fill),
selection_overlay,
viewport_mouse,
]
.width(Fill)
.height(Fill);
.height(Fill)]
.width(Fill)
.height(Fill)
} else {
stack![
container(viewport_3d)
.style(move |_: &Theme| container::Style {
background: Some(Background::Color(bg_color)),
..Default::default()
})
.width(Fill)
.height(Fill),
container(info).width(Fill).height(Fill),
selection_overlay,
viewport_mouse,
]
.width(Fill)
.height(Fill)
};
if self.show_navbar {
if self.show_navbar && !tab.is_start {
let nav = container(overlay::nav_toolbar())
.align_right(Fill)
.align_top(Fill)
@@ -442,7 +463,7 @@ impl H7CAD {
viewport_stack = viewport_stack.push(nav);
}
if self.show_viewcube && !is_paper {
if self.show_viewcube && !is_paper && !tab.is_start {
let cube_click: Element<'_, Message> = container(
mouse_area(container(
iced::widget::Space::new()
@@ -467,10 +488,16 @@ impl H7CAD {
}
if let Some(dyn_ol) = dyn_input_overlay {
viewport_stack = viewport_stack.push(dyn_ol);
if !tab.is_start {
viewport_stack = viewport_stack.push(dyn_ol);
}
}
let properties_el: Element<'_, Message> = if self.show_properties {
// Properties / layers panels carry no useful state on the Start tab.
// Replace the properties panel with a Recent Documents list there.
let properties_el: Element<'_, Message> = if tab.is_start {
recent_files_panel(&self.app_menu.recent)
} else if self.show_properties {
tab.properties.view()
} else {
Space::new().into()
@@ -877,47 +904,47 @@ pub(super) fn doc_tab_bar<'a>(tabs: &'a [DocumentTab], active_tab: usize) -> Ele
snap: false,
});
let close_btn = button(text("×").size(11).color(Color {
r: 0.55,
g: 0.55,
b: 0.55,
a: 1.0,
}))
.on_press(Message::TabClose(idx))
.padding([3, 5])
.style(move |_: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered => CLOSE_HOVER,
_ => {
if is_active {
TAB_ACTIVE
} else {
TAB_INACTIVE
}
}
})),
border: Border {
radius: 3.0.into(),
..Default::default()
},
..Default::default()
});
bar = bar.push(
container(row![title_btn, close_btn].spacing(0).align_y(iced::Center)).style(
move |_: &Theme| container::Style {
border: Border {
color: if is_active {
BORDER_COLOR
// Start tab is fixed — no close button. Every other tab gets a close.
let row_inner: Row<'_, Message> = if tab.is_start {
row![title_btn].spacing(0).align_y(iced::Center)
} else {
let close_btn = button(text("×").size(11).color(Color {
r: 0.55,
g: 0.55,
b: 0.55,
a: 1.0,
}))
.on_press(Message::TabClose(idx))
.padding([3, 5])
.style(move |_: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered => CLOSE_HOVER,
_ => {
if is_active {
TAB_ACTIVE
} else {
Color::TRANSPARENT
},
width: if is_active { 1.0 } else { 0.0 },
radius: 0.0.into(),
},
TAB_INACTIVE
}
}
})),
border: Border {
radius: 3.0.into(),
..Default::default()
},
),
..Default::default()
});
row![title_btn, close_btn].spacing(0).align_y(iced::Center)
};
bar = bar.push(
container(row_inner).style(move |_: &Theme| container::Style {
border: Border {
color: if is_active { BORDER_COLOR } else { Color::TRANSPARENT },
width: if is_active { 1.0 } else { 0.0 },
radius: 0.0.into(),
},
..Default::default()
}),
);
}
@@ -1557,3 +1584,308 @@ fn unsaved_changes_dialog_window(name: &str) -> Element<'static, Message> {
.padding([24, 28])
.into()
}
// ── Start / Welcome page ──────────────────────────────────────────────────
//
// Renders in place of the model-space viewport when the active tab is the
// fixed Start tab (`DocumentTab::is_start`). English-only by design — this
// is the public welcome screen and stays consistent across locales.
//
// The page picks up the application icon's red-brown (#B03020) as a tint so
// it visually belongs to H7CAD without overpowering the dark workspace.
const BRAND: Color = Color { r: 0.690, g: 0.188, b: 0.125, a: 1.0 }; // #B03020
const BRAND_DARK: Color = Color { r: 0.45, g: 0.12, b: 0.08, a: 1.0 };
const BRAND_TINT_BG: Color = Color { r: 0.14, g: 0.085, b: 0.085, a: 1.0 }; // dark, faint red wash
pub(super) fn start_page_view<'a>() -> Element<'a, Message> {
const TEXT: Color = Color { r: 0.94, g: 0.93, b: 0.92, a: 1.0 };
const MUTED: Color = Color { r: 0.62, g: 0.62, b: 0.62, a: 1.0 };
const CARD_BG: Color = Color { r: 0.12, g: 0.12, b: 0.13, a: 1.0 };
const CARD_BORDER: Color = Color { r: 0.20, g: 0.20, b: 0.22, a: 1.0 };
// Brand-tinted "Welcome to" — the "H7CAD" word takes the accent colour
// (Thunderbird-style coloured headline split).
let headline = row![
text("Welcome to ").size(40).color(TEXT),
text("H7CAD").size(40).color(BRAND),
]
.align_y(iced::Center);
let subtitle = text(
"H7CAD is an open-source CAD viewer and editor — a gift from contributors like you. \
Open a DWG/DXF file, start a new drawing, or help shape what comes next.",
)
.size(13)
.color(MUTED);
// Plain outlined button (Open / New / Help / Contribute).
let outline_btn = |label: &'static str, msg: Message| {
button(text(label).size(14).color(TEXT))
.on_press(msg)
.padding([10, 22])
.style(move |_: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered => Color { r: 0.18, g: 0.18, b: 0.20, a: 1.0 },
_ => Color { r: 0.13, g: 0.13, b: 0.15, a: 1.0 },
})),
text_color: TEXT,
border: Border {
color: Color { r: 0.30, g: 0.30, b: 0.33, a: 1.0 },
width: 1.0,
radius: 6.0.into(),
},
..Default::default()
})
};
// Donate — the prominent call-to-action. Solid brand fill, white text.
let donate_btn = {
button(
row![
text("♥ ").size(15).color(Color::WHITE),
text("Donate").size(14).color(Color::WHITE),
]
.align_y(iced::Center),
)
.on_press(Message::RibbonToolClick {
tool_id: "DONATE".to_string(),
event: crate::modules::ModuleEvent::Command("DONATE".to_string()),
})
.padding([12, 28])
.style(|_: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered => BRAND_DARK,
_ => BRAND,
})),
text_color: Color::WHITE,
border: Border {
color: BRAND_DARK,
width: 1.0,
radius: 6.0.into(),
},
shadow: iced::Shadow {
color: Color { r: 0.0, g: 0.0, b: 0.0, a: 0.4 },
offset: iced::Vector::new(0.0, 2.0),
blur_radius: 6.0,
},
..Default::default()
})
};
let primary_row = row![
outline_btn("New Drawing", Message::TabNew),
outline_btn("Open File…", Message::OpenFile),
donate_btn,
]
.spacing(12)
.align_y(iced::Center);
let secondary_row = row![
outline_btn(
"Contribute",
Message::RibbonToolClick {
tool_id: "REPORT".to_string(),
event: crate::modules::ModuleEvent::Command("REPORT".to_string()),
},
),
outline_btn(
"Release Notes",
Message::RibbonToolClick {
tool_id: "CHANGELOG".to_string(),
event: crate::modules::ModuleEvent::Command("CHANGELOG".to_string()),
},
),
outline_btn("About", Message::AboutOpen),
]
.spacing(12)
.align_y(iced::Center);
let card_section = |heading: &'static str, sub: &'static str, body: &'static str| {
container(
column![
text(heading).size(20).color(TEXT),
text(sub).size(13).color(MUTED),
Space::new().height(iced::Length::Fixed(10.0)),
text(body).size(12).color(MUTED),
]
.spacing(6),
)
.padding(20)
.width(Fill)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(CARD_BG)),
border: Border {
color: CARD_BORDER,
width: 1.0,
radius: 8.0.into(),
},
..Default::default()
})
};
let cards = row![
card_section(
"Free and open source",
"H7CAD belongs to its users.",
"Source is openly developed and distributed under a permissive licence. \
You are free to use, study, modify, and share H7CAD.",
),
card_section(
"Community driven",
"Be part of the story.",
"Anyone can contribute — translations, bug reports, feature ideas, code, \
or simply spreading the word. Your involvement shapes the project.",
),
]
.spacing(16);
// Buttons sit on a transparent container with a large, brand-tinted
// ambient shadow (offset = 0, big blur) — produces a soft halo behind
// the action row, matching the Thunderbird coloured-glow look against
// the dark page.
let primary_glow = container(primary_row)
.padding(iced::Padding { top: 4.0, right: 8.0, bottom: 4.0, left: 8.0 })
.style(|_: &Theme| container::Style {
background: Some(Background::Color(Color::TRANSPARENT)),
shadow: iced::Shadow {
color: Color { r: BRAND.r, g: BRAND.g, b: BRAND.b, a: 0.45 },
offset: iced::Vector::ZERO,
blur_radius: 80.0,
},
..Default::default()
});
let content = column![
Space::new().height(iced::Length::Fixed(28.0)),
container(headline).center_x(Fill),
container(subtitle).center_x(Fill).padding([10, 60]),
Space::new().height(iced::Length::Fixed(22.0)),
container(primary_glow).center_x(Fill),
Space::new().height(iced::Length::Fixed(10.0)),
container(secondary_row).center_x(Fill),
Space::new().height(iced::Length::Fixed(40.0)),
cards,
Space::new().height(Fill),
]
.spacing(0)
.width(Fill)
.height(Fill);
// Page background reverts to plain dark — the glow alone provides the
// brand colour cue, the rest of the page stays neutral so it reads as
// "workspace area" not "advertising banner".
const PAGE_BG: Color = Color { r: 0.08, g: 0.08, b: 0.085, a: 1.0 };
container(content)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(PAGE_BG)),
..Default::default()
})
.padding(iced::Padding { top: 40.0, right: 60.0, bottom: 40.0, left: 60.0 })
.width(Fill)
.height(Fill)
.into()
}
// ── Recent Documents panel (Start tab left rail) ──────────────────────────
//
// Slots into the same `row![properties_el, viewport_stack]` position the
// 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> {
const PANEL_BG: Color = Color { r: 0.10, g: 0.10, b: 0.11, a: 1.0 };
const PANEL_BORDER: Color = Color { r: 0.18, g: 0.18, b: 0.20, a: 1.0 };
const ITEM_HOVER: Color = Color { r: 0.16, g: 0.16, b: 0.18, a: 1.0 };
const TEXT: Color = Color { r: 0.92, g: 0.91, b: 0.90, a: 1.0 };
const MUTED: Color = Color { r: 0.60, g: 0.60, b: 0.62, 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 });
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()
} else {
let mut col = column![].spacing(0);
for path in recents {
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string_lossy().into_owned());
let dir = path
.parent()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default();
let path_for_open = path.clone();
let open_btn = button(
column![
text(name).size(12).color(TEXT),
text(dir).size(10).color(MUTED),
]
.spacing(2),
)
.on_press(Message::OpenRecent(path_for_open))
.padding([6, 12])
.width(Fill)
.style(move |_: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered => ITEM_HOVER,
_ => Color::TRANSPARENT,
})),
text_color: TEXT,
border: Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: 0.0.into(),
},
..Default::default()
});
let path_for_remove = path.clone();
let remove_btn = button(text("×").size(12).color(MUTED))
.on_press(Message::RecentRemove(path_for_remove))
.padding([4, 8])
.style(|_: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered => Color { r: 0.45, g: 0.15, b: 0.15, a: 1.0 },
_ => Color::TRANSPARENT,
})),
text_color: MUTED,
border: Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: 3.0.into(),
},
..Default::default()
});
col = col.push(
row![open_btn, remove_btn]
.spacing(0)
.align_y(iced::Center),
);
}
iced::widget::scrollable(col).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()
}
+1
View File
@@ -1,5 +1,6 @@
use crate::modules::{IconKind, ModuleEvent, ToolDef};
#[allow(dead_code)]
pub fn tool() -> ToolDef {
ToolDef {
id: "ABOUT",
+1
View File
@@ -1,5 +1,6 @@
use crate::modules::{IconKind, ModuleEvent, ToolDef};
#[allow(dead_code)]
pub fn tool() -> ToolDef {
ToolDef {
id: "CHANGELOG",
+3
View File
@@ -1,5 +1,8 @@
use crate::modules::{IconKind, ModuleEvent, ToolDef};
// Kept for the command-line route ("DONATE" cmd) and possible future ribbon
// re-introduction; currently the Start tab invokes the same command directly.
#[allow(dead_code)]
pub fn tool() -> ToolDef {
ToolDef {
id: "DONATE",
+2 -13
View File
@@ -31,10 +31,7 @@ impl CadModule for HomeModule {
angular_dim, leader_cmd, linear_dim, mleader_cmd, mtext, radius_dim, text,
};
use crate::modules::insert::{create_block, insert_block};
use about;
use changelog;
use clipboard::{copy_clip, cut, paste};
use donate;
use draw::{arc, circle, ellipse, hatch, line, polyline, shapes};
use groups::{group, ungroup};
use layers::{
@@ -45,7 +42,6 @@ impl CadModule for HomeModule {
translate, trim,
};
use properties::match_prop;
use report;
vec![
RibbonGroup {
@@ -223,15 +219,8 @@ impl CadModule for HomeModule {
cut::tool().into(),
],
},
RibbonGroup {
title: "Support",
tools: vec![
RibbonItem::LargeTool(donate::tool()),
report::tool().into(),
about::tool().into(),
changelog::tool().into(),
],
},
// Support group lives on the Start tab now (see view.rs:
// start_page_view). Removed from the Home ribbon to declutter.
]
}
}
+1
View File
@@ -1,5 +1,6 @@
use crate::modules::{IconKind, ModuleEvent, ToolDef};
#[allow(dead_code)]
pub fn tool() -> ToolDef {
ToolDef {
id: "REPORT",
+61 -1
View File
@@ -92,7 +92,20 @@ impl AppMenu {
pub fn push_recent(&mut self, path: PathBuf) {
self.recent.retain(|r| r != &path);
self.recent.insert(0, path);
self.recent.truncate(10);
self.recent.truncate(20);
// Best-effort persist; silent on failure (read-only home, full disk).
let _ = save_recents(&self.recent);
}
/// Drop a path from the recents list (manual removal from Start page).
pub fn remove_recent(&mut self, path: &std::path::Path) {
self.recent.retain(|r| r.as_path() != path);
let _ = save_recents(&self.recent);
}
/// Rehydrate the recents list from disk. Call once at app boot.
pub fn load_persistent_recents(&mut self) {
self.recent = load_recents();
}
pub fn toggle(&mut self) {
@@ -484,3 +497,50 @@ const EXIT_BTN: Color = Color {
b: 0.10,
a: 1.0,
};
// ── Recent-files persistence ─────────────────────────────────────────────
//
// Plain-text format, one path per line, newest first. Lives next to other
// per-user config so we don't pull in a TOML/JSON crate just for this.
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("H7CAD");
Some(p.join("recent.txt"))
}
fn save_recents(list: &[PathBuf]) -> std::io::Result<()> {
let Some(path) = recents_file_path() else { return Ok(()); };
if let Some(dir) = path.parent() { std::fs::create_dir_all(dir)?; }
let body: String = list
.iter()
.map(|p| p.to_string_lossy().into_owned())
.collect::<Vec<_>>()
.join("\n");
std::fs::write(path, body)
}
fn load_recents() -> Vec<PathBuf> {
let Some(path) = recents_file_path() else { return vec![]; };
let Ok(body) = std::fs::read_to_string(path) else { return vec![]; };
body.lines()
.filter(|l| !l.trim().is_empty())
.map(PathBuf::from)
.collect()
}