Recent files: the Start page and File ▸ Open Recent list the drawings opened last (fixes #858) (#864)

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-13 04:44:44 -04:00
1 parent 4efb01332e
commit 25e4900fcd
6 files changed
+263 -20

No files matched your search

+23 -1
View File
@@ -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<String>,
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<String> = 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;
+35 -14
View File
@@ -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") => {
+2
View File
@@ -591,6 +591,8 @@
Open sample drawing Відкрити зразок креслення
Open Xref Відкрити зовнішнє посилання
Open... Відкрити...
Open Recent Відкрити нещодавні
Clear Recent Files Очистити список нещодавніх файлів
Operator: Оператор:
Orbit Орбіта
Ordinate Ординатний
1 # CADCraft Ukrainian interface catalog — original translations by @dmatviichuk.
591
592
593
594
595
596
597
598
+30 -5
View File
@@ -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<String>,
}
/// 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<String>) {
@@ -435,14 +447,27 @@ impl CadApp {
}
fn opened(&mut self, r: Result<Value, String>) {
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.
+37
View File
@@ -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.<n>` 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<Re
// never open a picker.
let no_path =
if id.starts_with("ui.") { params.is_null() || (params.get("path").is_none() && params.get("data").is_none()) } else { params.is_null() };
if id == RECENT_CLEAR {
app.ui.recent_files.clear();
return Some(Ok(Value::Null));
}
if let Some(i) = id.strip_prefix(RECENT_ITEM).and_then(|n| n.parse::<usize>().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<Entry>)> {
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<Entry> {
let mut entries: Vec<Entry> = 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 {
+136
View File
@@ -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(&section);
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::<Vec<_>>(), ["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::<Vec<_>>(), ["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]);
}