M11.5: View → New View… / Edit Views… (saved views in the document, up to 25, listed at the bottom of the View menu; view.saved.* commands)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 08:16:36 -04:00
parent 8e735f1de9
commit 5026dad446
5 changed files with 192 additions and 0 deletions

View File

@ -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, ""));
}

View File

@ -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<Symbol>,
#[serde(default)]
pub guides: Vec<Guide>,
/// View → New View… (up to 25, like Illustrator).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub views: Vec<SavedView>,
#[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(),

View File

@ -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());

View File

@ -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<CommandSpec> {
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<Value> {
Ok(json!({ "views": s.doc()?.doc.views }))
}
fn new_view(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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");
}
}

View File

@ -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::<usize>().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::<usize>().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<MenuEntry> {
fn walk(app: &VectorcraftApp, path: Vec<String>, items: &[Item], out: &mut Vec<MenuEntry>) {
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<Item> {
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());