Files
bflatasticandClaude Opus 5.5 489bb84755 M4.17: File menu details: converted files, 30 recent files, one colour-mode command, Show in Folder, saved view
Older native files (.drawcraft or an older format version) open as
"<name> [Converted]" and Save asks for a .vectorcraft name instead of
overwriting them (preference appendConverted). Open Recent Files honours the
0-30 preference; empty slots are hidden (the native menu rebuilds when they,
saved views or User Defined libraries change). File > Document Color Mode is
one command, file.documentColorMode, converting through the colour settings;
choosing the current mode is not an edit, and object.convertDocumentColorMode
stays as an alias the command palette leaves out. New File > Show in Folder
(file.reveal; paths with spaces work on Windows). Native files store the view
they were saved with (Document.last_view, written at save time so it never
dirties the document) and reopen there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 11:11:19 +07:00

169 lines
7.2 KiB
Rust

//! The File menu around saving, in the app: Revert's confirmation, recent files, Show in Folder,
//! the single Document Color Mode entry and documents reopening at their saved view.
use std::cell::RefCell;
use std::rc::Rc;
use serde_json::json;
use vectorcraft_engine::Session;
use vectorcraft_geom::Point;
use crate::{Services, VectorcraftApp, dialogs, io, menus, palette, theme};
/// An app that reads and writes real files.
fn app() -> VectorcraftApp {
let services = Services {
read: Some(Box::new(|p: &str| std::fs::read(p).map_err(|e| e.to_string()))),
write: Some(Box::new(|p: &str, b: &[u8]| std::fs::write(p, b).map_err(|e| e.to_string()))),
..Default::default()
};
VectorcraftApp::new(Session::new(), services)
}
/// A fresh folder for one test.
fn dir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("vc-ui-save-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
fn rect(app: &mut VectorcraftApp) {
app.run("shape.rectangle", json!({"x": 10, "y": 10, "width": 30, "height": 20})).unwrap();
}
fn objects(app: &VectorcraftApp) -> usize {
app.session.doc().unwrap().doc.node_count()
}
/// One headless frame of the dialog layer with Enter pressed (OK).
fn press_ok(app: &mut VectorcraftApp) {
let ctx = egui::Context::default();
theme::install_fonts(&ctx);
let key = egui::Event::Key { key: egui::Key::Enter, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };
let mut out = ctx.run_ui(egui::RawInput { events: vec![key], ..Default::default() }, |ui| dialogs::show(app, ui.ctx()));
out.textures_delta.clear();
}
#[test]
fn revert_asks_then_reloads_in_the_same_tab_keeping_every_zoom() {
let d = dir("revert");
let path = d.join("a.vectorcraft").to_string_lossy().to_string();
let mut app = app();
app.run("file.new", json!({"width": 100, "height": 100})).unwrap();
rect(&mut app);
app.run("file.saveAs", json!({"path": path})).unwrap();
assert!(!menus::enabled(&app, "file.revert"), "nothing changed since the save");
app.run("file.new", json!({"width": 50, "height": 50})).unwrap();
app.views[0].zoom = 2.0;
app.views[1].zoom = 4.0;
app.session.set_active(0);
rect(&mut app);
assert!(menus::enabled(&app, "file.revert"));
let r = app.run("file.revert", json!({})).unwrap();
assert_eq!(r["pending"], dialogs::confirm::KIND);
assert_eq!(objects(&app), 3, "nothing happens before OK");
press_ok(&mut app);
assert!(app.ui.dialog.is_none());
assert_eq!((objects(&app), app.session.active_index(), app.session.documents().len()), (2, Some(0), 2));
assert!(!app.session.active().unwrap().is_dirty());
assert_eq!((app.views[0].zoom, app.views[1].zoom), (2.0, 4.0));
// Cancel leaves the changes.
rect(&mut app);
app.run("file.revert", json!({})).unwrap();
app.ui.dialog = None;
assert_eq!(objects(&app), 3);
// An untitled document can't revert.
app.session.set_active(1);
rect(&mut app);
assert!(app.run("file.revert", json!({})).is_err() && app.ui.dialog.is_none());
let _ = std::fs::remove_dir_all(d);
}
#[test]
fn new_from_template_takes_bytes_from_agents() {
let mut app = app();
app.run("file.new", json!({})).unwrap();
let bytes = app.session.execute("document.serialize", &json!({"format": "template"})).unwrap();
let r = app.run("file.newFromTemplate", json!({"name": "flyer.vctemplate", "dataBase64": bytes["dataBase64"]})).unwrap();
assert!(r["title"].as_str().is_some_and(|t| t.starts_with("Untitled-")), "{r}");
assert_eq!((app.session.documents().len(), app.views.len()), (2, 2));
assert_eq!(app.session.active().unwrap().path, None);
}
#[test]
fn recent_files_show_as_many_as_the_preference_keeps() {
let mut app = app();
for i in 0..35 {
io::note_recent(&mut app, &format!("/art/{i}.svg"));
}
let listed = |app: &VectorcraftApp| {
menus::menu_entries(app).iter().filter(|e| e.command.as_deref().is_some_and(|c| c.starts_with("file.openRecent"))).count()
};
assert_eq!((io::recent_files(&app).len(), listed(&app)), (20, 20), "the default");
assert_eq!(menus::dynamic_label(&app, "file.openRecent1", ""), "34.svg");
app.session.prefs.recent_files_count = 30;
assert_eq!((io::recent_files(&app).len(), listed(&app)), (30, 30));
assert!(menus::enabled(&app, "file.openRecent30"));
app.session.prefs.recent_files_count = 0;
assert_eq!((io::recent_files(&app).len(), listed(&app)), (0, 0), "0 hides the list");
assert_eq!(menus::listed_slots(&app), 0, "the native menu rebuilds without them");
assert!(app.run("file.openRecent1", json!({})).is_err());
app.session.prefs.recent_files_count = 5;
assert_eq!(io::recent_files(&app)[0], "/art/34.svg", "hiding them didn't forget them");
}
#[test]
fn show_in_folder_needs_a_saved_document_and_a_file_manager() {
let shown = Rc::new(RefCell::new(vec![]));
let s = shown.clone();
let mut app = app();
app.run("file.new", json!({})).unwrap();
assert!(!menus::enabled(&app, "file.reveal"));
app.session.doc_mut().unwrap().path = Some("/art/a.vectorcraft".into());
assert!(!menus::enabled(&app, "file.reveal"), "no file manager (web)");
app.services.reveal = Some(Box::new(move |p: &str| {
s.borrow_mut().push(p.to_string());
Ok(())
}));
assert!(menus::enabled(&app, "file.reveal"));
assert_eq!(app.run("file.reveal", json!({})).unwrap()["path"], "/art/a.vectorcraft");
assert_eq!(*shown.borrow(), ["/art/a.vectorcraft"]);
}
#[test]
fn the_palette_lists_document_color_mode_once() {
let items = palette::items();
let ids: Vec<&str> = items.iter().map(|(_, id, _)| id.as_str()).filter(|id| id.to_ascii_lowercase().contains("documentcolormode")).collect();
assert_eq!(ids, ["file.documentColorMode"]);
let mut app = app();
app.run("file.new", json!({})).unwrap();
let entries = menus::menu_entries(&app);
let modes: Vec<&str> =
entries.iter().filter(|e| e.path.last().is_some_and(|p| p == "Document Color Mode")).filter_map(|e| e.command.as_deref()).collect();
assert_eq!(modes, ["file.documentColorMode", "file.documentColorMode"], "CMYK and RGB run the one command");
}
#[test]
fn a_document_reopens_at_its_saved_view() {
let d = dir("view");
let path = d.join("v.vectorcraft").to_string_lossy().to_string();
let mut app = app();
app.run("file.new", json!({"width": 300, "height": 200})).unwrap();
{
let v = app.view_mut().unwrap();
(v.zoom, v.center, v.rotation, v.fitted) = (3.0, Point::new(70.0, 40.0), 30.0, true);
}
app.run("file.saveAs", json!({"path": path})).unwrap();
// Moving around afterwards isn't an edit.
app.view_mut().unwrap().zoom = 0.5;
assert!(!app.session.active().unwrap().is_dirty());
io::open_path(&mut app, &path).unwrap();
let v = *app.view().unwrap();
assert_eq!((v.zoom, v.center, v.rotation, v.fitted), (3.0, Point::new(70.0, 40.0), 30.0, true));
// A document without a saved view is fitted on first display.
app.run("file.new", json!({})).unwrap();
assert!(!app.view().unwrap().fitted);
let _ = std::fs::remove_dir_all(d);
}