mirror of
https://github.com/storytold/cadcraft.git
synced 2026-10-14 14:26:45 -04:00
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:
6 files changed
+263
-20
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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") => {
|
||||
|
||||
@@ -591,6 +591,8 @@
|
||||
Open sample drawing Відкрити зразок креслення
|
||||
Open Xref Відкрити зовнішнє посилання
|
||||
Open... Відкрити...
|
||||
Open Recent Відкрити нещодавні
|
||||
Clear Recent Files Очистити список нещодавніх файлів
|
||||
Operator: Оператор:
|
||||
Orbit Орбіта
|
||||
Ordinate Ординатний
|
||||
|
||||
|
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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::<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]);
|
||||
}
|
||||
Reference in new issue
Block a user