diff --git a/apps/vectorcraft/src/native_menu.rs b/apps/vectorcraft/src/native_menu.rs index 2f9cd23c..bafcce01 100644 --- a/apps/vectorcraft/src/native_menu.rs +++ b/apps/vectorcraft/src/native_menu.rs @@ -95,6 +95,9 @@ impl NativeMenu { | "view.transparencyGrid" | "window.workspace.reset" ) || cmd.starts_with("file.openRecent") + || cmd.starts_with("type.recentFont") + || cmd.starts_with("view.goto") + || cmd == "view.cornerWidget" { i.set_text(menus::dynamic_label(app, cmd, "")); } diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 2fdca4a8..3dba194f 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -213,6 +213,16 @@ pub struct Artboard { pub show_cross_hairs: bool, } +/// A saved view (View → New View…): zoom, centre and rotation, listed at the bottom of the View menu. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct SavedView { + pub name: String, + pub center: Point, + pub zoom: f64, + #[serde(default)] + pub rotation: f64, +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Guide { /// true = vertical guide at `pos` (x), false = horizontal at `pos` (y). @@ -288,6 +298,9 @@ pub struct Document { pub symbols: Vec, #[serde(default)] pub guides: Vec, + /// View → New View… (up to 25, like Illustrator). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub views: Vec, #[serde(default)] pub grid: GridPrefs, #[serde(default = "ppi72")] @@ -338,6 +351,7 @@ impl Document { text_threads: vec![], symbols: vec![], guides: vec![], + views: vec![], grid: GridPrefs::default(), raster_effects_ppi: 72.0, images: BTreeMap::new(), diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 42912b11..f8ab4a62 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -36,6 +36,7 @@ pub(crate) mod textwrap; pub(crate) mod threads; pub(crate) mod typecmd; mod typemenu; +pub(crate) mod views; pub mod wand; mod xform; @@ -165,6 +166,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(textwrap::specs()); v.extend(graph::specs()); v.extend(rasterfx::specs()); + v.extend(views::specs()); v.extend(docmenu::specs()); v.extend(docinfo::specs()); v.extend(panelcmds::specs()); diff --git a/crates/engine/src/cmd/views.rs b/crates/engine/src/cmd/views.rs new file mode 100644 index 00000000..6a678bb2 --- /dev/null +++ b/crates/engine/src/cmd/views.rs @@ -0,0 +1,114 @@ +//! View → New View… / Edit Views…: saved views stored in the document. + +use serde_json::{Value, json}; +use vectorcraft_doc::SavedView; +use vectorcraft_geom::Point; + +use super::*; + +/// Illustrator keeps up to 25 views per document. +pub const MAX_VIEWS: usize = 25; + +pub fn specs() -> Vec { + vec![ + cmd!( + "view.saved.new", + "New View…", + ["View"], + None, + "{name?, center: [x, y], zoom: (1 = 100%), rotation?: deg} save a view (the app fills in the current one) → {name, index}", + has_doc, + new_view + ), + cmd!( + "view.saved.edit", + "Edit Views…", + ["View"], + None, + "{name, newName?, delete?: bool} rename or delete a saved view; no params → {views}", + has_doc, + edit_views + ), + cmd!(query "view.saved.list", "Saved Views", [], None, "{} → {views: [{name, center, zoom, rotation}]}", has_doc, list_views), + ] +} + +fn list_views(s: &mut Session, _: &Value) -> Result { + Ok(json!({ "views": s.doc()?.doc.views })) +} + +fn new_view(s: &mut Session, p: &Value) -> Result { + const C: &str = "view.saved.new"; + let center = p.get("center").and_then(Value::as_array).and_then(|a| Some(Point::new(a.first()?.as_f64()?, a.get(1)?.as_f64()?))); + let center = center.ok_or_else(|| bad(C, "missing center [x, y]"))?; + let zoom = f64_req(p, "zoom", C)?; + if !(zoom.is_finite() && zoom > 0.0) { + return Err(bad(C, "zoom must be positive")); + } + let st = s.doc()?; + if st.doc.views.len() >= MAX_VIEWS { + return Err(bad(C, format!("a document keeps at most {MAX_VIEWS} views"))); + } + let name = match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) { + Some(n) => n.to_string(), + None => (1..).map(|i| format!("View {i}")).find(|n| !st.doc.views.iter().any(|v| &v.name == n)).unwrap_or_default(), + }; + if st.doc.views.iter().any(|v| v.name == name) { + return Err(bad(C, format!("a view named `{name}` exists"))); + } + let view = SavedView { name: name.clone(), center, zoom, rotation: f64_or(p, "rotation", 0.0) }; + let index = s.edit("New View", |d, _| { + d.views.push(view); + Ok(d.views.len() - 1) + })?; + Ok(json!({ "name": name, "index": index })) +} + +fn edit_views(s: &mut Session, p: &Value) -> Result { + const C: &str = "view.saved.edit"; + let Some(name) = str_param(p, "name") else { return list_views(s, p) }; + let i = s.doc()?.doc.views.iter().position(|v| v.name == name).ok_or_else(|| bad(C, format!("no view named `{name}`")))?; + if bool_or(p, "delete", false) { + s.edit("Delete View", |d, _| { + d.views.remove(i); + Ok(()) + })?; + return Ok(json!({ "deleted": name })); + } + let new = str_param(p, "newName").map(str::trim).filter(|n| !n.is_empty()).ok_or_else(|| bad(C, "give newName or delete"))?.to_string(); + if s.doc()?.doc.views.iter().any(|v| v.name == new) { + return Err(bad(C, format!("a view named `{new}` exists"))); + } + s.edit("Rename View", |d, _| { + d.views[i].name = new.clone(); + Ok(()) + })?; + Ok(json!({ "name": new })) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn save_rename_delete_views() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let v = s.execute("view.saved.new", &json!({"center": [100, 200], "zoom": 2.0})).unwrap(); + assert_eq!(v["name"], json!("View 1")); + s.execute("view.saved.new", &json!({"name": "Logo", "center": [10, 20], "zoom": 4.0, "rotation": 15})).unwrap(); + assert!(s.execute("view.saved.new", &json!({"name": "Logo", "center": [0, 0], "zoom": 1.0})).is_err()); + assert!(s.execute("view.saved.new", &json!({"center": [0, 0], "zoom": 0})).is_err()); + s.execute("view.saved.edit", &json!({"name": "View 1", "newName": "Overview"})).unwrap(); + let l = s.execute("view.saved.list", &json!({})).unwrap(); + assert_eq!(l["views"][0]["name"], json!("Overview")); + assert_eq!(l["views"][1]["rotation"], json!(15.0)); + s.execute("view.saved.edit", &json!({"name": "Logo", "delete": true})).unwrap(); + assert_eq!(s.doc().unwrap().doc.views.len(), 1); + // Views travel with the file. + let bytes = vectorcraft_format::save_file(&s.doc().unwrap().doc); + assert_eq!(vectorcraft_format::load(&bytes).unwrap().views[0].name, "Overview"); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a3f2d0c3..624b8930 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -60,6 +60,16 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("file.openRecent8", "Open Recent File 8", "", "{}"), ("file.openRecent9", "Open Recent File 9", "", "{}"), ("file.openRecent10", "Open Recent File 10", "", "{}"), + ("view.goto1", "Saved View 1", "", "{} go to the 1. saved view"), + ("view.goto2", "Saved View 2", "", "{} go to the 2. saved view"), + ("view.goto3", "Saved View 3", "", "{} go to the 3. saved view"), + ("view.goto4", "Saved View 4", "", "{} go to the 4. saved view"), + ("view.goto5", "Saved View 5", "", "{} go to the 5. saved view"), + ("view.goto6", "Saved View 6", "", "{} go to the 6. saved view"), + ("view.goto7", "Saved View 7", "", "{} go to the 7. saved view"), + ("view.goto8", "Saved View 8", "", "{} go to the 8. saved view"), + ("view.goto9", "Saved View 9", "", "{} go to the 9. saved view"), + ("view.goto10", "Saved View 10", "", "{} go to the 10. saved view"), ("type.recentFont1", "Recent Font 1", "", "{} apply the 1. most recently used font"), ("type.recentFont2", "Recent Font 2", "", "{} apply the 2. most recently used font"), ("type.recentFont3", "Recent Font 3", "", "{} apply the 3. most recently used font"), @@ -507,6 +517,10 @@ pub fn dynamic_label(app: &VectorcraftApp, id: &str, label: &str) -> String { let n: usize = id["type.recentFont".len()..].parse().unwrap_or(0); n.checked_sub(1).and_then(|i| app.ui.recent_fonts.get(i)).cloned().unwrap_or_else(|| "—".into()) } + id if id.starts_with("view.goto") => { + let n: usize = id["view.goto".len()..].parse().unwrap_or(0); + app.session.active().and_then(|d| n.checked_sub(1).and_then(|i| d.doc.views.get(i)).map(|v| v.name.clone())).unwrap_or_else(|| "—".into()) + } "view.artboards" => if v.artboards { "Hide Artboards" } else { "Show Artboards" }.into(), "view.rulers" => if v.rulers { "Hide Rulers" } else { "Show Rulers" }.into(), "view.boundingBox" => if v.bounding_box { "Hide Bounding Box" } else { "Show Bounding Box" }.into(), @@ -587,6 +601,9 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { id if id.starts_with("type.recentFont") => { id["type.recentFont".len()..].parse::().is_ok_and(|n| n >= 1 && n <= app.ui.recent_fonts.len()) && app.session.active().is_some() } + id if id.starts_with("view.goto") => { + id["view.goto".len()..].parse::().is_ok_and(|n| n >= 1 && app.session.active().is_some_and(|d| n <= d.doc.views.len())) + } "effect.dialog" | "ui.recolorDialog" => app.session.active().is_some_and(|d| !d.selection.is_empty()), "effect.applyLast" => app.last_effect.is_some() && app.session.active().is_some_and(|d| !d.selection.is_empty()), "file.export.pdf" => app.session.active().is_some(), @@ -1243,6 +1260,10 @@ fn render_items(app: &VectorcraftApp, ui: &mut egui::Ui, items: &[Item], clicked } Item::Cmd(label, id, p) => { let en = enabled(app, id); + // Unused saved-view slots are hidden (Illustrator lists only the saved views). + if !en && id.starts_with("view.goto") { + continue; + } let label = dynamic_label(app, id, label); let sc = if p.is_null() { shortcut_of(id).map(pretty_shortcut).unwrap_or_default() } else { String::new() }; let chk = checked(app, id, p); @@ -1330,6 +1351,23 @@ pub fn invoke(app: &mut VectorcraftApp, id: &str, p: Value) { } return; } + // New View… / Edit Views…: name dialogs. + if id == "view.saved.new" && p.as_object().is_none_or(|o| o.is_empty()) { + let n = app.session.active().map_or(0, |d| d.doc.views.len()) + 1; + let _ = app.run("ui.paramDialog", json!({"command": id, "label": "New View", "params": {"name": format!("View {n}")}})); + return; + } + if id == "view.saved.edit" && p.as_object().is_none_or(|o| o.is_empty()) { + let first = app.session.active().and_then(|d| d.doc.views.first().map(|v| v.name.clone())); + match first { + Some(name) => { + let _ = app + .run("ui.paramDialog", json!({"command": id, "label": "Edit Views", "params": {"name": name, "newName": "", "delete": false}})); + } + None => app.status("No saved views (View → New View…)"), + } + return; + } // Document Raster Effects Settings: a dialog with the current resolution. if id == "document.rasterEffectsSettings" && p.as_object().is_none_or(|o| o.is_empty()) { match app.session.execute(id, &json!({})) { @@ -1402,6 +1440,7 @@ pub fn menu_entries(app: &VectorcraftApp) -> Vec { fn walk(app: &VectorcraftApp, path: Vec, items: &[Item], out: &mut Vec) { for it in items { match it { + Item::Cmd(_, id, _) if id.starts_with("view.goto") && !enabled(app, id) => {} Item::Cmd(l, id, p) => out.push(MenuEntry { path: path.clone(), label: dynamic_label(app, id, l), @@ -1563,6 +1602,26 @@ fn effect_menu() -> Vec { mod tests { use super::*; + #[test] + fn saved_views_store_and_restore_the_view() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + { + let v = app.view_mut().unwrap(); + v.zoom = 3.0; + v.center = vectorcraft_geom::Point::new(120.0, 80.0); + } + app.run("view.saved.new", json!({"name": "Detail"})).unwrap(); + assert!(enabled(&app, "view.goto1") && !enabled(&app, "view.goto2")); + assert_eq!(dynamic_label(&app, "view.goto1", ""), "Detail"); + app.view_mut().unwrap().zoom = 0.5; + app.run("view.goto1", json!({})).unwrap(); + let v = app.view().unwrap(); + assert_eq!((v.zoom, v.center.x, v.center.y), (3.0, 120.0, 80.0)); + // Unused slots stay out of the menu listing agents see. + assert!(!menu_entries(&app).iter().any(|e| e.command.as_deref() == Some("view.goto2"))); + } + #[test] fn recent_fonts_and_corner_widget_toggle() { let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default());