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:
parent
8e735f1de9
commit
5026dad446
|
|
@ -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, ""));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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());
|
||||
|
|
|
|||
Loading…
Reference in New Issue