//! 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"); } }