diff --git a/src/app/document.rs b/src/app/document.rs index 7ea550713..8edc0e1d0 100644 --- a/src/app/document.rs +++ b/src/app/document.rs @@ -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 diff --git a/src/app/mod.rs b/src/app/mod.rs index bbe4b5c93..e3858131b 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -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, diff --git a/src/app/update.rs b/src/app/update.rs index 4a0b47233..04f4e1458 100644 --- a/src/app/update.rs +++ b/src/app/update.rs @@ -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); diff --git a/src/app/view.rs b/src/app/view.rs index 93d066c7c..2b1db796a 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -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() +} diff --git a/src/modules/home/about.rs b/src/modules/home/about.rs index 854cf2f57..cd6e19ffb 100644 --- a/src/modules/home/about.rs +++ b/src/modules/home/about.rs @@ -1,5 +1,6 @@ use crate::modules::{IconKind, ModuleEvent, ToolDef}; +#[allow(dead_code)] pub fn tool() -> ToolDef { ToolDef { id: "ABOUT", diff --git a/src/modules/home/changelog.rs b/src/modules/home/changelog.rs index 8987560f4..7f883e6bb 100644 --- a/src/modules/home/changelog.rs +++ b/src/modules/home/changelog.rs @@ -1,5 +1,6 @@ use crate::modules::{IconKind, ModuleEvent, ToolDef}; +#[allow(dead_code)] pub fn tool() -> ToolDef { ToolDef { id: "CHANGELOG", diff --git a/src/modules/home/donate.rs b/src/modules/home/donate.rs index 1f4ab59b8..238297613 100644 --- a/src/modules/home/donate.rs +++ b/src/modules/home/donate.rs @@ -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", diff --git a/src/modules/home/mod.rs b/src/modules/home/mod.rs index df233a106..b97060369 100644 --- a/src/modules/home/mod.rs +++ b/src/modules/home/mod.rs @@ -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. ] } } diff --git a/src/modules/home/report.rs b/src/modules/home/report.rs index 48424c302..6dfca904d 100644 --- a/src/modules/home/report.rs +++ b/src/modules/home/report.rs @@ -1,5 +1,6 @@ use crate::modules::{IconKind, ModuleEvent, ToolDef}; +#[allow(dead_code)] pub fn tool() -> ToolDef { ToolDef { id: "REPORT", diff --git a/src/ui/app_menu.rs b/src/ui/app_menu.rs index 03a349104..fa7861eff 100644 --- a/src/ui/app_menu.rs +++ b/src/ui/app_menu.rs @@ -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 { + 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::>() + .join("\n"); + std::fs::write(path, body) +} + +fn load_recents() -> Vec { + 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() +}