From 25e4900fcda8bc44e4fff883cfb3cba4d421a9a3 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo <103679419+marcoarnulfo@users.noreply.github.com> Date: Tue, 13 Oct 2026 10:44:44 +0200 Subject: [PATCH] =?UTF-8?q?Recent=20files:=20the=20Start=20page=20and=20Fi?= =?UTF-8?q?le=20=E2=96=B8=20Open=20Recent=20list=20the=20drawings=20opened?= =?UTF-8?q?=20last=20(fixes=20#858)=20(#864)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- apps/cadcraft/src/native_menu.rs | 24 ++++- crates/ui-egui/src/chrome.rs | 49 +++++++--- crates/ui-egui/src/i18n/uk.tsv | 2 + crates/ui-egui/src/lib.rs | 35 ++++++- crates/ui-egui/src/menus.rs | 37 ++++++++ crates/ui-egui/tests/recent_files.rs | 136 +++++++++++++++++++++++++++ 6 files changed, 263 insertions(+), 20 deletions(-) create mode 100644 crates/ui-egui/tests/recent_files.rs diff --git a/apps/cadcraft/src/native_menu.rs b/apps/cadcraft/src/native_menu.rs index 8d41f83..7270435 100644 --- a/apps/cadcraft/src/native_menu.rs +++ b/apps/cadcraft/src/native_menu.rs @@ -16,6 +16,9 @@ pub struct NativeMenu { predefined: Vec<(&'static str, PredefinedMenuItem)>, language: &'static str, last_refresh: f64, + /// The recent files File ▸ Open Recent lists now (it is rebuilt when they change). + recent: Vec, + counter: usize, } /// Accelerator for "Cmd+Shift+S" (modifier-less shortcuts stay in the app so typing works). @@ -63,7 +66,25 @@ impl NativeMenu { submenus.push((title, sub)); } menu.init_for_nsapp(); - Self { _menu: menu, items, submenus, predefined, language, last_refresh: 0.0 } + Self { _menu: menu, items, submenus, predefined, language, last_refresh: 0.0, recent: app.ui.recent_files.clone(), counter } + } + + /// Rebuild File ▸ Open Recent after a drawing was opened or the list was cleared: the native + /// menu is built once, and its items name the files by position. + fn refresh_recent(&mut self, app: &CadApp) { + if self.recent == app.ui.recent_files { + return; + } + self.recent = app.ui.recent_files.clone(); + let Some(sub) = self.submenus.iter().find(|(label, _)| label == menus::OPEN_RECENT).map(|(_, s)| s.clone()) else { return }; + for _ in sub.items() { + sub.remove_at(0); + } + let ids: Vec = self.items.iter().filter(|(_, (cmd, _, _))| cmd.starts_with("ui.recent.")).map(|(id, _)| id.clone()).collect(); + for id in ids { + self.items.remove(&id); + } + build(&sub, &menus::recent_entries(app), &mut self.items, &mut self.submenus, &mut self.counter, self.language); } pub fn poll(&mut self, app: &mut CadApp, ctx: &egui::Context) { @@ -77,6 +98,7 @@ impl NativeMenu { ctx.request_repaint(); } } + self.refresh_recent(app); let language = app.language(); if self.language != language { self.language = language; diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 085f46a..3e0860e 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -545,20 +545,41 @@ pub fn start_page(app: &mut CadApp, ui: &mut egui::Ui) { } let main = Rect::from_min_max(pos2(left.right() + 36.0, r.top() + 40.0), r.max); p.text(main.min, egui::Align2::LEFT_TOP, crate::tl!("Recent"), egui::FontId::proportional(22.0), t.text); - p.text( - pos2(main.center().x, main.center().y), - egui::Align2::CENTER_CENTER, - crate::tl!("There is nothing here yet."), - egui::FontId::proportional(18.0), - t.text, - ); - p.text( - pos2(main.center().x, main.center().y + 26.0), - egui::Align2::CENTER_CENTER, - crate::tl!("To get started, create or open a drawing."), - crate::theme::body(), - t.text_dim, - ); + let mut recent = None; + let mut y = main.top() + 44.0; + for (i, path) in app.ui.recent_files.iter().enumerate() { + let row = Rect::from_min_size(pos2(main.left(), y), vec2((r.right() - main.left() - 36.0).clamp(0.0, 640.0), 42.0)); + let resp = ui.interact(row, ui.id().with(("recent", i)), Sense::click()); + if resp.hovered() { + p.rect_filled(row, 2.0, t.control_hover.gamma_multiply(0.5)); + } + let dir = std::path::Path::new(path).parent().map(|d| d.display().to_string()).unwrap_or_default(); + let clip = p.with_clip_rect(row.shrink(2.0)); + clip.text(pos2(row.left() + 10.0, row.top() + 13.0), egui::Align2::LEFT_CENTER, crate::menus::file_name(path), crate::theme::body(), t.text); + clip.text(pos2(row.left() + 10.0, row.top() + 30.0), egui::Align2::LEFT_CENTER, dir, crate::theme::small(), t.text_faint); + if resp.on_hover_text(path).clicked() { + recent = Some(path.clone()); + } + y += 46.0; + } + if let Some(path) = recent { + app.open_path(&path); + } else if app.ui.recent_files.is_empty() { + p.text( + pos2(main.center().x, main.center().y), + egui::Align2::CENTER_CENTER, + crate::tl!("There is nothing here yet."), + egui::FontId::proportional(18.0), + t.text, + ); + p.text( + pos2(main.center().x, main.center().y + 26.0), + egui::Align2::CENTER_CENTER, + crate::tl!("To get started, create or open a drawing."), + crate::theme::body(), + t.text_dim, + ); + } match action { Some("open") => app.start("ui.open"), Some("new") => { diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 55d8869..0e084d5 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -591,6 +591,8 @@ Open sample drawing Відкрити зразок креслення Open Xref Відкрити зовнішнє посилання Open... Відкрити... + Open Recent Відкрити нещодавні + Clear Recent Files Очистити список нещодавніх файлів Operator: Оператор: Orbit Орбіта Ordinate Ординатний diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1a42e5e..f5c3c49 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -56,6 +56,9 @@ pub use control::{ControlRequest, ControlResponse, PendingReply}; /// The host storage key for [`CadApp::prefs_json`]. pub const PREFS_KEY: &str = "cadcraft.prefs"; +/// How many recently opened drawings are kept (AutoCAD's default for its file history). +pub const RECENT_FILES: usize = 9; + /// Persisted UI state (serde, so automation can read and set it). #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -96,6 +99,8 @@ pub struct UiState { /// The version Save As writes for a drawing that has no file yet ("2000" … "2018"); a /// drawing opened from a file keeps that file's version. pub save_version: String, + /// Drawings opened from a path, newest first (File ▸ Open Recent and the Start page). + pub recent_files: Vec, } /// A DXF/DWG file version (2000 … 2018). @@ -165,6 +170,7 @@ impl Default for UiState { theme: theme::ThemePref::default(), save_format: SaveFormat::default(), save_version: SaveVersion::default().year().to_string(), + recent_files: Vec::new(), } } } @@ -341,6 +347,9 @@ impl CadApp { if self.spacemouse.settings != spacemouse::Settings::default() { prefs["spacemouse"] = json!(self.spacemouse.settings); } + if !self.ui.recent_files.is_empty() { + prefs["recentFiles"] = json!(self.ui.recent_files); + } prefs.to_string() } @@ -362,6 +371,9 @@ impl CadApp { if let Some(v) = v.get("saveVersion").and_then(Value::as_str).and_then(SaveVersion::parse) { self.ui.save_version = v.year().to_string(); } + if let Some(files) = v.get("recentFiles").and_then(Value::as_array) { + self.ui.recent_files = files.iter().filter_map(Value::as_str).filter(|p| !p.is_empty()).take(RECENT_FILES).map(str::to_string).collect(); + } } pub fn set_status(&mut self, s: impl Into) { @@ -435,14 +447,27 @@ impl CadApp { } fn opened(&mut self, r: Result) { - if let Err(e) = r { - self.set_status(e); - } else { - self.ui.start_tab = false; - self.canvas.zoom_pending = true; + match r { + Err(e) => self.set_status(e), + Ok(v) => { + self.ui.start_tab = false; + self.canvas.zoom_pending = true; + let doc = v.get("index").and_then(Value::as_u64).and_then(|i| self.session.docs.get(usize::try_from(i).ok()?)); + if let Some(path) = doc.and_then(|d| d.path.clone()) { + self.remember_recent(path); + } + } } } + /// Put `path` first in File ▸ Open Recent and on the Start page. + fn remember_recent(&mut self, path: String) { + let recent = &mut self.ui.recent_files; + recent.retain(|p| *p != path); + recent.insert(0, path); + recent.truncate(RECENT_FILES); + } + /// Per-frame logic before layout. pub fn logic(&mut self, ctx: &egui::Context) { // Closing the window (title bar, File ▸ Exit, Cmd+Q) with unsaved changes asks first. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 635c824..8837de3 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -8,6 +8,11 @@ use crate::CadApp; /// The top-level menus, in order. pub const MENUS: &[&str] = &["File", "Edit", "View", "Insert", "Format", "Tools", "Draw", "Dimension", "Modify", "Window", "Help"]; +/// File ▸ Open Recent: one `ui.recent.` item per recent file, then Clear Recent Files. +pub const OPEN_RECENT: &str = "Open Recent"; +const RECENT_ITEM: &str = "ui.recent."; +const RECENT_CLEAR: &str = "ui.recent.clear"; + /// UI-only commands: (id, label, menu path, shortcut). pub const UI_COMMANDS: &[(&str, &str, &[&str], Option<&str>)] = &[ ("ui.spacemouse", "3D Mouse...", &["Tools", "3D Mouse..."], None), @@ -108,6 +113,15 @@ fn run_ui_command_inner(app: &mut CadApp, id: &str, params: &Value) -> Option().ok()) { + let Some(path) = app.ui.recent_files.get(i).cloned() else { return Some(Err(format!("no recent file {i}"))) }; + app.open_path(&path); + return Some(Ok(Value::Null)); + } let r = match id { "ui.language" => { let Some(preference) = params.get("lang").and_then(Value::as_str).and_then(crate::i18n::Preference::parse) else { @@ -451,9 +465,32 @@ pub fn tree(app: &CadApp) -> Vec<(String, Vec)> { add(menu, label, id, *sc, true); } } + if let Some((_, file)) = out.iter_mut().find(|(m, _)| m == "File") { + let at = file.iter().position(|e| matches!(e, Entry::Item { id, .. } if id == "open")).map_or(0, |i| i + 1); + file.insert(at, Entry::Sub { label: OPEN_RECENT.into(), children: recent_entries(app) }); + } out } +/// The items of File ▸ Open Recent: the recent files by name, newest first, then Clear Recent Files. +pub fn recent_entries(app: &CadApp) -> Vec { + let mut entries: Vec = app + .ui + .recent_files + .iter() + .enumerate() + .map(|(i, path)| Entry::Item { label: file_name(path), id: format!("{RECENT_ITEM}{i}"), shortcut: None, enabled: true }) + .collect(); + let enabled = !app.ui.recent_files.is_empty(); + entries.push(Entry::Item { label: "Clear Recent Files".into(), id: RECENT_CLEAR.into(), shortcut: None, enabled }); + entries +} + +/// The file name of `path` (the whole path when it has none). +pub(crate) fn file_name(path: &str) -> String { + std::path::Path::new(path).file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().to_string()) +} + /// Turn a registry shortcut ("Cmd+Shift+Z") into the form shown on this platform: macOS symbols ("⇧⌘Z") or /// Windows/Linux text ("Ctrl+Shift+Z"). `mac` comes from the runtime OS (`ctx.os()`), so the web build is right too. pub(crate) fn shortcut_label(s: &str, mac: bool) -> String { diff --git a/crates/ui-egui/tests/recent_files.rs b/crates/ui-egui/tests/recent_files.rs new file mode 100644 index 0000000..6211ab8 --- /dev/null +++ b/crates/ui-egui/tests/recent_files.rs @@ -0,0 +1,136 @@ +//! Recently opened files (#858): File ▸ Open Recent and the Start page list the drawings opened +//! from a path, newest first, and keep them across restarts. + +use cadcraft_engine::Session; +use cadcraft_engine::cmd::file::{IoHooks, set_io}; +use cadcraft_engine::doc::Drawing; +use cadcraft_ui_egui::menus::{self, Entry}; +use cadcraft_ui_egui::{CadApp, Services}; +use egui::{Rect, Shape, vec2}; +use egui_kittest::Harness; + +/// Two drawing files on disk (the reader below accepts anything, so their contents don't matter). +fn drawings(test: &str) -> (String, String) { + set_io(IoHooks { read: |_, _| Ok(Drawing::new_metric()), write: |_, _| Ok(Vec::new()), plot: None }); + let dir = std::env::temp_dir().join(format!("cadcraft-recent-{test}-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = |name: &str| { + let p = dir.join(name); + std::fs::write(&p, b"0\nEOF\n").unwrap(); + p.to_string_lossy().to_string() + }; + (path("plan.dxf"), path("section.dwg")) +} + +/// Wait for the drawings being read in the background to be added (or reported). +fn settle(app: &mut CadApp) { + let t = std::time::Instant::now(); + while app.opening.busy() && t.elapsed() < std::time::Duration::from_secs(10) { + app.finish_opening(); + std::thread::sleep(std::time::Duration::from_millis(5)); + } + app.finish_opening(); +} + +/// File ▸ Open Recent: (label, command id) of each item. +fn open_recent(app: &CadApp) -> Vec<(String, String)> { + let file = menus::tree(app).into_iter().find(|(m, _)| m == "File").map(|(_, e)| e).unwrap_or_default(); + let Some(Entry::Sub { children, .. }) = file.into_iter().find(|e| matches!(e, Entry::Sub { label, .. } if label == "Open Recent")) else { + return Vec::new(); + }; + children + .into_iter() + .filter_map(|e| match e { + Entry::Item { label, id, .. } => Some((label, id)), + Entry::Sub { .. } => None, + }) + .collect() +} + +#[test] +fn opened_drawings_are_listed_newest_first_and_survive_a_restart() { + let (plan, section) = drawings("menu"); + let mut app = CadApp::new(Session::empty(), Services::default()); + assert!(app.ui.recent_files.is_empty()); + + app.open_path(&plan); + settle(&mut app); + app.open_path(§ion); + settle(&mut app); + app.open_path(&plan); + settle(&mut app); + assert_eq!(app.ui.recent_files, [plan.clone(), section.clone()], "newest first, each file once"); + // A file that could not be opened is not listed. + app.open_path("/nonexistent/missing.dxf"); + settle(&mut app); + assert_eq!(app.ui.recent_files, [plan.clone(), section.clone()]); + + let items = open_recent(&app); + let labels: Vec<_> = items.iter().map(|(l, _)| l.as_str()).collect(); + assert_eq!(labels, ["plan.dxf", "section.dwg", "Clear Recent Files"]); + + // Choosing an item opens that file and moves it to the top. + let before = app.session.docs.len(); + menus::activate(&mut app, &items[1].1); + settle(&mut app); + assert_eq!(app.session.docs.len(), before + 1); + assert_eq!(app.session.state().ok().and_then(|s| s.path.clone()), Some(section.clone())); + assert_eq!(app.ui.recent_files, [section.clone(), plan.clone()]); + + // Kept with the preferences the host stores across restarts. + let mut restarted = CadApp::new(Session::empty(), Services::default()); + restarted.load_prefs(&app.prefs_json()); + assert_eq!(restarted.ui.recent_files, [section.clone(), plan.clone()]); + + let clear = items.iter().find(|(l, _)| l == "Clear Recent Files").map(|(_, id)| id.clone()).unwrap(); + menus::activate(&mut app, &clear); + assert!(app.ui.recent_files.is_empty()); + assert_eq!(open_recent(&app).iter().map(|(l, _)| l.as_str()).collect::>(), ["Clear Recent Files"]); +} + +/// Painted texts and where they are. +fn texts(shape: &Shape, out: &mut Vec<(String, Rect)>) { + match shape { + Shape::Text(t) => out.push((t.galley.text().to_string(), t.visual_bounding_rect())), + Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } +} + +#[test] +fn start_page_lists_recent_files_and_opens_one_on_click() { + let (plan, section) = drawings("start"); + let mut app = CadApp::new(Session::empty(), Services::default()); + app.ui.recent_files = vec![section.clone(), plan.clone()]; + let mut h = Harness::builder().with_size(vec2(1200.0, 760.0)).build_ui_state( + |ui, app: &mut CadApp| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + let mut found = Vec::new(); + for s in &h.output().shapes { + texts(&s.shape, &mut found); + } + assert!(!found.iter().any(|(t, _)| t == "There is nothing here yet."), "{found:?}"); + let names: Vec<_> = found.iter().filter(|(t, _)| t == "section.dwg" || t == "plan.dxf").collect(); + assert_eq!(names.iter().map(|(t, _)| t.as_str()).collect::>(), ["section.dwg", "plan.dxf"], "newest first"); + + let at = names[1].1.center(); + h.hover_at(at); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); + let t = std::time::Instant::now(); + while h.state().opening.busy() && t.elapsed() < std::time::Duration::from_secs(10) { + std::thread::sleep(std::time::Duration::from_millis(5)); + h.run_steps(1); + } + h.run_steps(1); + let app = h.state(); + assert_eq!(app.session.state().ok().and_then(|s| s.path.clone()), Some(plan.clone())); + assert_eq!(app.ui.recent_files, [plan, section]); +}