From 6a6e3d4bb112be828514d6bb03440c440a628c99 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Thu, 1 Oct 2026 10:09:19 -0400 Subject: [PATCH] =?UTF-8?q?M4.1/M11:=20File=20=E2=86=92=20Open=20Recent=20?= =?UTF-8?q?Files=20(persisted,=20native=20menu=20too),=20Document=20Info?= =?UTF-8?q?=20panel=20(document.info),=20Object=20=E2=86=92=20Make=20Pixel?= =?UTF-8?q?=20Perfect;=20agent=20guide:=20control-port=20clashes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- CLAUDE.md | 2 +- apps/drawcraft/src/native_menu.rs | 3 +- crates/engine/src/cmd/docinfo.rs | 216 ++++++++++++++++++++++++++ crates/engine/src/cmd/mod.rs | 2 + crates/ui-egui/src/io.rs | 13 +- crates/ui-egui/src/menus.rs | 88 ++++++++++- crates/ui-egui/src/panels/doc_info.rs | 123 +++++++++++++++ crates/ui-egui/src/panels/mod.rs | 3 + crates/ui-egui/src/state.rs | 5 + 10 files changed, 450 insertions(+), 7 deletions(-) create mode 100644 crates/engine/src/cmd/docinfo.rs create mode 100644 crates/ui-egui/src/panels/doc_info.rs diff --git a/AGENTS.md b/AGENTS.md index 8df13b0b..f6592c38 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ DrawCraft is a clean-room, open-source, Rust-native vector illustration app targ - **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, wasm). One task id per commit (`M2.1: pen tool`). ## Running and looking at the app -- `cargo run --release -p drawcraft -- --control 7979 [file.svg|file.drawcraft]` +- `cargo run --release -p drawcraft -- --control 7979 [file.svg|file.drawcraft]` (sibling apps' agents use the same default port: if the log says it failed to bind, pick another port — otherwise your requests reach a different app). - Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":10,"y":10,"width":100,"height":50}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`. - **For UI work, look at the result** (take `ui.screenshot`, read the PNG) and compare with `plan/illustrator/02-ui-ux.md` / `10-observed-ui.md`. `ui.screenshot` needs a presented frame: if it errors (screen locked), check the art with `ui.render` / `drawcraft-cli run … --export x.png`, and cover panels with a headless egui frame test (see `panels/transparency.rs` tests). - **Performance:** `drawcraft-cli perf` checks the budgets (render/pan/zoom, hit test, save/open, SVG, Pathfinder) on a synthetic 50k-path document; `drawcraft-cli bench FILE` times one file. Run them before and after renderer, format or geometry changes, on an idle machine (the report warns when the load average makes timings noise). diff --git a/CLAUDE.md b/CLAUDE.md index 8df13b0b..f6592c38 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,7 +28,7 @@ DrawCraft is a clean-room, open-source, Rust-native vector illustration app targ - **Quality gates** before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, layers, wasm). One task id per commit (`M2.1: pen tool`). ## Running and looking at the app -- `cargo run --release -p drawcraft -- --control 7979 [file.svg|file.drawcraft]` +- `cargo run --release -p drawcraft -- --control 7979 [file.svg|file.drawcraft]` (sibling apps' agents use the same default port: if the log says it failed to bind, pick another port — otherwise your requests reach a different app). - Drive it: JSON lines on `127.0.0.1:7979`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"shape.rectangle","params":{"x":10,"y":10,"width":100,"height":50}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`. - **For UI work, look at the result** (take `ui.screenshot`, read the PNG) and compare with `plan/illustrator/02-ui-ux.md` / `10-observed-ui.md`. `ui.screenshot` needs a presented frame: if it errors (screen locked), check the art with `ui.render` / `drawcraft-cli run … --export x.png`, and cover panels with a headless egui frame test (see `panels/transparency.rs` tests). - **Performance:** `drawcraft-cli perf` checks the budgets (render/pan/zoom, hit test, save/open, SVG, Pathfinder) on a synthetic 50k-path document; `drawcraft-cli bench FILE` times one file. Run them before and after renderer, format or geometry changes, on an idle machine (the report warns when the load average makes timings noise). diff --git a/apps/drawcraft/src/native_menu.rs b/apps/drawcraft/src/native_menu.rs index d93c8346..7c25b418 100644 --- a/apps/drawcraft/src/native_menu.rs +++ b/apps/drawcraft/src/native_menu.rs @@ -94,7 +94,8 @@ impl NativeMenu { | "view.boundingBox" | "view.transparencyGrid" | "window.workspace.reset" - ) { + ) || cmd.starts_with("file.openRecent") + { i.set_text(menus::dynamic_label(app, cmd, "")); } } diff --git a/crates/engine/src/cmd/docinfo.rs b/crates/engine/src/cmd/docinfo.rs new file mode 100644 index 00000000..3315ff40 --- /dev/null +++ b/crates/engine/src/cmd/docinfo.rs @@ -0,0 +1,216 @@ +//! Document Info panel data and Object → Make Pixel Perfect. + +use std::collections::{BTreeMap, BTreeSet}; + +use drawcraft_color::Paint; +use drawcraft_doc::{ColorMode, Document, Node, NodeId, NodeKind}; +use drawcraft_geom::{Affine, Rect}; +use serde_json::{Value, json}; + +use super::*; + +pub fn specs() -> Vec { + vec![ + cmd!( + query "document.info", + "Document Info", + ["Window", "Document Info"], + None, + "{selectionOnly?} → {document, objects: {paths, compoundPaths, groups, …}, fonts, images, swatches, …}", + has_doc, + info + ), + cmd!( + "object.makePixelPerfect", + "Make Pixel Perfect", + ["Object"], + None, + "{ids?} snap each object's edges to the pixel grid (odd stroke weights on half pixels) and round stroke weights", + has_selection, + pixel_perfect + ), + ] +} + +fn paint_kind(p: &Paint) -> Option<&'static str> { + match p { + Paint::Gradient(_) => Some("gradients"), + Paint::Pattern { .. } => Some("patterns"), + _ => None, + } +} + +fn info(s: &mut Session, p: &Value) -> Result { + let st = s.doc()?; + let d = &st.doc; + let roots: Vec<&Node> = if bool_or(p, "selectionOnly", false) { + st.selection.objects.iter().filter_map(|id| d.node(*id)).collect() + } else { + d.layers.iter().map(|l| &**l).collect() + }; + let mut counts: BTreeMap<&str, usize> = BTreeMap::new(); + let mut fonts = BTreeSet::new(); + let mut images: BTreeMap = BTreeMap::new(); + let mut symbols = BTreeSet::new(); + let mut spot = BTreeSet::new(); + for r in roots { + r.walk(&mut |n| { + let k = match &n.kind { + NodeKind::Layer { .. } => None, + NodeKind::Group { clip: true, .. } => Some("clipGroups"), + NodeKind::Group { .. } => Some("groups"), + NodeKind::Path { guide: true, .. } => Some("guides"), + NodeKind::Path { .. } => Some("paths"), + NodeKind::Compound { .. } => Some("compoundPaths"), + NodeKind::Text(t) => { + for run in &t.runs { + fonts.insert(format!("{} {}", run.style.font_family, run.style.font_style)); + } + Some("textObjects") + } + NodeKind::Image(im) => { + images.insert(im.key.clone(), json!({ "width": im.width, "height": im.height, "linked": im.link.is_some(), "link": im.link })); + Some("images") + } + NodeKind::SymbolInstance { symbol, .. } => { + symbols.insert(symbol.clone()); + Some("symbolInstances") + } + NodeKind::Blend { .. } => Some("blends"), + NodeKind::Envelope { .. } => Some("envelopes"), + NodeKind::Mesh(_) => Some("meshes"), + NodeKind::Repeat(_) => Some("repeats"), + }; + if let Some(k) = k { + *counts.entry(k).or_default() += 1; + } + for item in &n.appearance.items { + let paint = match item { + drawcraft_doc::AppearanceItem::Fill(f) => &f.paint, + drawcraft_doc::AppearanceItem::Stroke(s) => &s.paint, + }; + if let Some(k) = paint_kind(paint) { + *counts.entry(k).or_default() += 1; + } + if let Paint::Solid { swatch: Some(name), .. } = paint + && d.swatch(name).is_some_and(|s| s.spot) + { + spot.insert(name.clone()); + } + } + if n.mask.is_some() { + *counts.entry("opacityMasks").or_default() += 1; + } + if !n.appearance.effects.is_empty() { + *counts.entry("liveEffects").or_default() += 1; + } + }); + } + Ok(json!({ + "document": document_summary(d), + "objects": counts, + "fonts": fonts, + "images": images, + "symbols": symbols, + "spotColors": spot, + "swatches": d.swatches.len(), + "graphicStyles": d.graphic_styles.len(), + "characterStyles": d.char_styles.len(), + "paragraphStyles": d.para_styles.len(), + "patterns": d.patterns.len(), + })) +} + +fn document_summary(d: &Document) -> Value { + let u = d.units.points(); + json!({ + "title": d.title, + "colorMode": match d.color_mode { ColorMode::Rgb => "RGB", ColorMode::Cmyk => "CMYK" }, + "units": d.units.label(), + "artboards": d.artboards.iter().map(|a| json!({ "name": a.name, "width": a.rect.width() / u, "height": a.rect.height() / u })).collect::>(), + "rasterEffectsPpi": d.raster_effects_ppi, + }) +} + +/// The affine snapping `b` to whole pixels (`half`: edges on half pixels, for odd stroke weights). +fn snap_xf(b: Rect, half: bool) -> Affine { + let off = if half { 0.5 } else { 0.0 }; + let snap = |v: f64| (v - off).round() + off; + let (x0, y0) = (snap(b.x0), snap(b.y0)); + // Keep at least one pixel of size; widths snap to whole pixels. + let (w, h) = (b.width().round().max(if b.width() > 0.0 { 1.0 } else { 0.0 }), b.height().round().max(if b.height() > 0.0 { 1.0 } else { 0.0 })); + let sx = if b.width() > 1e-9 { w / b.width() } else { 1.0 }; + let sy = if b.height() > 1e-9 { h / b.height() } else { 1.0 }; + Affine::translate((x0, y0)) * Affine::scale_non_uniform(sx, sy) * Affine::translate((-b.x0, -b.y0)) +} + +fn pixel_perfect(s: &mut Session, p: &Value) -> Result { + let ids: Vec = if p.get("ids").is_some() { targets(s, p)? } else { super::edit::selected_roots(s)? }; + let n = ids.len(); + s.edit("Make Pixel Perfect", |d, _| { + for id in &ids { + let Some(node) = d.node_mut(*id) else { continue }; + // Whole-pixel stroke weights; odd ones centre on half pixels. + let mut odd = false; + for item in &mut node.appearance.items { + if let drawcraft_doc::AppearanceItem::Stroke(st) = item + && st.width > 0.0 + { + st.width = st.width.round().max(1.0); + odd |= st.width as i64 % 2 == 1 && st.align == drawcraft_doc::StrokeAlign::Center; + } + } + if let Some(b) = node.geometric_bounds() { + node.transform(snap_xf(b, odd), false); + } + } + Ok(()) + })?; + Ok(json!({ "count": n })) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn info_counts_objects_fonts_and_selection_only() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + let r = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + s.execute( + "paint.setFill", + &json!({"gradient": {"kind": "linear", "stops": [{"offset": 0, "color": "#000"}, {"offset": 1, "color": "#fff"}]}}), + ) + .unwrap(); + s.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi"})).unwrap(); + let i = s.execute("document.info", &json!({})).unwrap(); + assert_eq!( + (i["objects"]["paths"].as_u64(), i["objects"]["textObjects"].as_u64(), i["objects"]["gradients"].as_u64()), + (Some(1), Some(1), Some(1)) + ); + assert_eq!(i["fonts"][0], "Source Sans 3 Regular"); + assert_eq!(i["document"]["artboards"][0]["width"], 200.0); + s.execute("select.set", &json!({"ids": [r]})).unwrap(); + let i = s.execute("document.info", &json!({"selectionOnly": true})).unwrap(); + assert!(i["objects"]["textObjects"].is_null() && i["objects"]["paths"] == 1); + } + + #[test] + fn pixel_perfect_snaps_edges_and_strokes() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 200, "height": 100})).unwrap(); + let id = s.execute("shape.rectangle", &json!({"x": 10.3, "y": 20.6, "width": 30.4, "height": 9.7})).unwrap()["id"].as_u64().unwrap(); + s.execute("stroke.set", &json!({"weight": 0.8})).unwrap(); + s.execute("object.makePixelPerfect", &json!({})).unwrap(); + let n = s.doc().unwrap().doc.node(NodeId(id)).unwrap().clone(); + let b = n.geometric_bounds().unwrap(); + // 1 pt (odd) stroke → edges on half pixels, whole-pixel size. + assert_eq!((b.x0, b.y0, b.width(), b.height()), (10.5, 20.5, 30.0, 10.0)); + assert_eq!(n.appearance.stroke_width(), 1.0); + s.execute("stroke.set", &json!({"weight": 2})).unwrap(); + s.execute("object.makePixelPerfect", &json!({})).unwrap(); + let b = s.doc().unwrap().doc.node(NodeId(id)).unwrap().geometric_bounds().unwrap(); + assert_eq!((b.x0.fract(), b.y0.fract()), (0.0, 0.0)); + } +} diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 54c74147..bf592c7b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -7,6 +7,7 @@ mod colorcmds; pub mod colormgmt; mod create; pub(crate) mod distortcmds; +mod docinfo; mod docmenu; mod draw2; mod edit; @@ -153,6 +154,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(textedit::specs()); v.extend(textstyles::specs()); v.extend(docmenu::specs()); + v.extend(docinfo::specs()); v.extend(panelcmds::specs()); v.extend(buildcmds::specs()); v.extend(brushsym::specs()); diff --git a/crates/ui-egui/src/io.rs b/crates/ui-egui/src/io.rs index 05500db5..a659a2a8 100644 --- a/crates/ui-egui/src/io.rs +++ b/crates/ui-egui/src/io.rs @@ -126,7 +126,9 @@ pub fn open_path(app: &mut DrawcraftApp, path: &str) -> Result<(), String> { let read = app.services.read.as_ref().ok_or("no file reader")?; let bytes = read(path)?; let name = std::path::Path::new(path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or(path.to_string()); - open_bytes(app, &name, &bytes, Some(path.to_string())) + open_bytes(app, &name, &bytes, Some(path.to_string()))?; + note_recent(app, path); + Ok(()) } fn write_out(app: &mut DrawcraftApp, path: &str, bytes: &[u8]) -> Result<(), String> { @@ -164,9 +166,18 @@ pub fn save(app: &mut DrawcraftApp, path: Option, save_as: bool) -> Resu st.saved_revision = st.revision; } app.status(format!("Saved {path}")); + note_recent(app, &path); Ok(path) } +/// Put `path` at the top of File → Open Recent Files (capped by Preferences → Recent Files). +pub fn note_recent(app: &mut DrawcraftApp, path: &str) { + let r = &mut app.ui.recent_files; + r.retain(|p| p != path); + r.insert(0, path.to_string()); + r.truncate((app.session.prefs.recent_files_count as usize).clamp(1, 10)); +} + /// Export the document as SVG / PDF / PNG / JPEG / WebP. pub fn export(app: &mut DrawcraftApp, format: &str, path: Option, scale: f64) -> Result { let st = app.session.active().ok_or("no document")?; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index a873b13c..593e217c 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -49,6 +49,18 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ ("file.saveCopy", "Save a Copy…", "Cmd+Alt+S", "{path?}"), ("file.revert", "Revert", "F12", "{}"), ("file.place", "Place…", "Cmd+Shift+P", "{path?}"), + ("file.openRecent1", "Open Recent File 1", "", "{}"), + ("file.openRecent2", "Open Recent File 2", "", "{}"), + ("file.openRecent3", "Open Recent File 3", "", "{}"), + ("file.openRecent4", "Open Recent File 4", "", "{}"), + ("file.openRecent5", "Open Recent File 5", "", "{}"), + ("file.openRecent6", "Open Recent File 6", "", "{}"), + ("file.openRecent7", "Open Recent File 7", "", "{}"), + ("file.openRecent8", "Open Recent File 8", "", "{}"), + ("file.openRecent9", "Open Recent File 9", "", "{}"), + ("file.openRecent10", "Open Recent File 10", "", "{}"), + ("file.clearRecent", "Clear Recent Files", "", "{}"), + ("file.recentFiles", "Recent Files", "", "{} → [path…] most recent first"), ("file.export.svg", "Export As SVG…", "", "{path?}"), ("file.export.png", "Export As PNG…", "", "{path?, scale?: 1}"), ("file.exportForScreens", "Export for Screens…", "Cmd+Alt+E", "{path?, scale?}"), @@ -150,6 +162,18 @@ pub fn run_ui_command(app: &mut DrawcraftApp, id: &str, p: &Value) -> Option Err("document has never been saved".into()), } } + id if id.starts_with("file.openRecent") => { + let n: usize = id["file.openRecent".len()..].parse().unwrap_or(0); + match n.checked_sub(1).and_then(|i| app.ui.recent_files.get(i)).cloned() { + Some(path) => io::open_path(app, &path).map(|_| Value::Null), + None => Err("no such recent file".into()), + } + } + "file.clearRecent" => { + app.ui.recent_files.clear(); + Ok(Value::Null) + } + "file.recentFiles" => Ok(json!(app.ui.recent_files)), "file.place" => { let path = match s("path") { Some(p) => Some(p), @@ -413,12 +437,33 @@ pub fn dynamic_label(app: &DrawcraftApp, id: &str, label: &str) -> String { "view.guides" => if v.guides { "Hide Guides" } else { "Show Guides" }.into(), "view.grid" => if v.grid { "Hide Grid" } else { "Show Grid" }.into(), "view.guides.lock" => if app.session.guides_locked() { "Unlock Guides" } else { "Lock Guides" }.into(), + id if id.starts_with("file.openRecent") => { + let n: usize = id["file.openRecent".len()..].parse().unwrap_or(0); + n.checked_sub(1) + .and_then(|i| app.ui.recent_files.get(i)) + .map(|p| std::path::Path::new(p).file_name().map_or(p.clone(), |f| f.to_string_lossy().to_string())) + .unwrap_or_else(|| "—".into()) + } "edit.undo" => app.session.active().and_then(|d| d.history.undo.last()).map(|h| format!("Undo {}", h.label)).unwrap_or_else(|| "Undo".into()), "edit.redo" => app.session.active().and_then(|d| d.history.redo.last()).map(|h| format!("Redo {}", h.label)).unwrap_or_else(|| "Redo".into()), _ => label.into(), } } +/// File → Open Recent Files slots. +const RECENT_IDS: [&str; 10] = [ + "file.openRecent1", + "file.openRecent2", + "file.openRecent3", + "file.openRecent4", + "file.openRecent5", + "file.openRecent6", + "file.openRecent7", + "file.openRecent8", + "file.openRecent9", + "file.openRecent10", +]; + /// Effective shortcut of a command: the user's override (Edit → Keyboard Shortcuts) or the default. pub fn shortcut_of(id: &str) -> Option<&'static str> { crate::shortcut_editor::command_shortcut(id) @@ -444,6 +489,10 @@ pub fn enabled(app: &DrawcraftApp, id: &str) -> bool { | "view.fitAll" | "view.actualSize" => app.session.active().is_some(), "file.revert" => app.session.active().is_some_and(|d| d.path.is_some() && d.is_dirty()), + id if id.starts_with("file.openRecent") => { + id["file.openRecent".len()..].parse::().is_ok_and(|n| n >= 1 && n <= app.ui.recent_files.len()) + } + "file.clearRecent" => !app.ui.recent_files.is_empty(), "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(), @@ -472,7 +521,12 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("New…", "file.newDialog"), todos("New from Template…", "Cmd+Shift+N"), c("Open…", "file.open"), - todo("Open Recent Files"), + sub("Open Recent Files", { + let mut v: Vec = RECENT_IDS.iter().map(|id| c("Recent File", id)).collect(); + v.push(Sep); + v.push(c("Clear Recent Files", "file.clearRecent")); + v + }), Sep, c("Close", "file.close"), c("Close All", "file.closeAll"), @@ -629,7 +683,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { c("Create Trim Marks", "object.createTrimMarks"), todo("Flatten Transparency…"), Sep, - todo("Make Pixel Perfect"), + c("Make Pixel Perfect", "object.makePixelPerfect"), Sep, sub("Slice", vec![todo("Make"), todo("Release"), todo("Create from Guides"), todo("Create from Selection")]), Sep, @@ -951,7 +1005,7 @@ pub fn menu_tree() -> Vec<(&'static str, Vec)> { panel("Brushes", "brushes"), panel("Color", "color"), panel("Color Guide", "colorGuide"), - todo("Document Info"), + panel("Document Info", "docInfo"), panel("Gradient", "gradient"), panel("Graphic Styles", "graphicStyles"), panel("History", "history"), @@ -1315,6 +1369,34 @@ fn effect_menu() -> Vec { mod tests { use super::*; + #[test] + fn recent_files_track_opens_and_saves() { + let dir = std::env::temp_dir().join(format!("dc-recent-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let mut app = DrawcraftApp::new( + drawcraft_engine::Session::new(), + crate::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() + }, + ); + app.run("file.new", json!({"width": 100, "height": 100})).unwrap(); + let (a, b) = (dir.join("a.drawcraft"), dir.join("b.drawcraft")); + for p in [&a, &b, &a] { + app.run("file.saveAs", json!({"path": p.to_string_lossy()})).unwrap(); + } + assert_eq!(app.ui.recent_files, [a.to_string_lossy(), b.to_string_lossy()]); + assert_eq!(dynamic_label(&app, "file.openRecent2", ""), "b.drawcraft"); + assert!(enabled(&app, "file.openRecent2") && !enabled(&app, "file.openRecent3")); + app.run("file.openRecent2", json!({})).unwrap(); + assert_eq!(app.session.documents().len(), 2); + assert_eq!(app.ui.recent_files[0], b.to_string_lossy(), "reopening moves it to the top"); + app.run("file.clearRecent", json!({})).unwrap(); + assert!(app.ui.recent_files.is_empty() && app.run("file.openRecent1", json!({})).is_err()); + let _ = std::fs::remove_dir_all(dir); + } + #[test] fn every_bound_menu_command_exists() { fn walk(items: &[Item], bad: &mut Vec) { diff --git a/crates/ui-egui/src/panels/doc_info.rs b/crates/ui-egui/src/panels/doc_info.rs new file mode 100644 index 00000000..a30aef09 --- /dev/null +++ b/crates/ui-egui/src/panels/doc_info.rs @@ -0,0 +1,123 @@ +//! Document Info panel: the document's setup and what it contains (objects by kind, fonts, +//! images, symbols, spot colours…), for the whole document or the selection only. + +use egui::Ui; +use serde_json::{Value, json}; + +use super::{pstate, set_pstate}; +use crate::DrawcraftApp; +use crate::theme::Tokens; +use crate::widgets::{self, menu_item}; + +const OBJECT_LABELS: [(&str, &str); 16] = [ + ("paths", "Paths"), + ("compoundPaths", "Compound Paths"), + ("groups", "Groups"), + ("clipGroups", "Clipping Masks"), + ("textObjects", "Text Objects"), + ("images", "Images"), + ("symbolInstances", "Symbol Instances"), + ("gradients", "Gradient Objects"), + ("patterns", "Pattern Objects"), + ("meshes", "Gradient Meshes"), + ("blends", "Blends"), + ("envelopes", "Envelopes"), + ("repeats", "Repeats"), + ("opacityMasks", "Opacity Masks"), + ("liveEffects", "Objects with Effects"), + ("guides", "Guides"), +]; + +fn row(ui: &mut Ui, label: &str, value: String) { + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(label).size(12.0).color(t.text_dim)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(egui::RichText::new(value).size(12.0).color(t.text)); + }); + }); +} + +pub fn show(app: &mut DrawcraftApp, ui: &mut Ui) { + let sel_only: bool = pstate(ui.ctx(), "docinfo-sel"); + let Ok(i) = app.session.execute("document.info", &json!({ "selectionOnly": sel_only })) else { + widgets::dim_label(ui, "No document"); + return; + }; + egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| { + widgets::subheader(ui, "Document"); + let d = &i["document"]; + row(ui, "Color Mode", d["colorMode"].as_str().unwrap_or("").into()); + row(ui, "Units", d["units"].as_str().unwrap_or("").into()); + row(ui, "Raster Effects", format!("{} ppi", d["rasterEffectsPpi"])); + for a in d["artboards"].as_array().into_iter().flatten() { + row( + ui, + a["name"].as_str().unwrap_or("Artboard"), + format!("{:.2} × {:.2}", a["width"].as_f64().unwrap_or(0.0), a["height"].as_f64().unwrap_or(0.0)), + ); + } + widgets::divider(ui); + widgets::subheader(ui, if sel_only { "Objects (selection)" } else { "Objects" }); + let objs = &i["objects"]; + let mut any = false; + for (k, label) in OBJECT_LABELS { + if let Some(n) = objs[k].as_u64() { + row(ui, label, n.to_string()); + any = true; + } + } + if !any { + widgets::dim_label(ui, "None"); + } + widgets::divider(ui); + for (key, title) in [("fonts", "Fonts"), ("symbols", "Symbols"), ("spotColors", "Spot Colors")] { + let list: Vec<&str> = i[key].as_array().map(|a| a.iter().filter_map(Value::as_str).collect()).unwrap_or_default(); + widgets::subheader(ui, &format!("{title} ({})", list.len())); + for f in list { + widgets::dim_label(ui, f); + } + } + let imgs = i["images"].as_object().cloned().unwrap_or_default(); + widgets::subheader(ui, &format!("Images ({})", imgs.len())); + for (_, im) in imgs { + let kind = if im["linked"].as_bool() == Some(true) { im["link"].as_str().unwrap_or("Linked").to_string() } else { "Embedded".into() }; + widgets::dim_label(ui, &format!("{} × {} px — {kind}", im["width"], im["height"])); + } + widgets::divider(ui); + for (key, label) in [ + ("swatches", "Swatches"), + ("graphicStyles", "Graphic Styles"), + ("characterStyles", "Character Styles"), + ("paragraphStyles", "Paragraph Styles"), + ("patterns", "Pattern Swatches"), + ] { + row(ui, label, i[key].to_string()); + } + }); +} + +pub fn menu(_app: &mut DrawcraftApp, ui: &mut Ui) { + let sel: bool = pstate(ui.ctx(), "docinfo-sel"); + if menu_item(ui, "Selection Only", true, sel) { + set_pstate(ui.ctx(), "docinfo-sel", !sel); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn draws_headless() { + let mut app = DrawcraftApp::new(drawcraft_engine::Session::new(), Default::default()); + app.session.execute("file.new", &json!({"width": 100, "height": 100})).unwrap(); + app.session.execute("text.create", &json!({"x": 10, "y": 40, "text": "Hi"})).unwrap(); + let ctx = egui::Context::default(); + let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { + show(&mut app, ui); + menu(&mut app, ui); + }); + out.textures_delta.clear(); + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 2a832567..d5edc04d 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -11,6 +11,7 @@ pub mod brushes; pub mod character; pub mod color; pub mod color_guide; +pub mod doc_info; pub mod glyphs; pub mod gradient; pub mod graphic_styles; @@ -78,6 +79,7 @@ pub fn show_icon_panel(app: &mut DrawcraftApp, ui: &mut Ui, id: &str) { "symbols" => symbols::show(app, ui), "patternOptions" => pattern_options::show(app, ui), "imageTrace" => image_trace::show(app, ui), + "docInfo" => doc_info::show(app, ui), "charStyles" => text_styles::show(app, ui, text_styles::Kind::Char), "paraStyles" => text_styles::show(app, ui, text_styles::Kind::Para), "magicWand" => magic_wand::show(app, ui), @@ -113,6 +115,7 @@ pub fn panel_menu_items(app: &mut DrawcraftApp, ui: &mut Ui, id: &str) { "symbols" => symbols::menu(app, ui), "patternOptions" => pattern_options::menu(app, ui), "imageTrace" => image_trace::menu(app, ui), + "docInfo" => doc_info::menu(app, ui), "charStyles" => text_styles::menu(app, ui, text_styles::Kind::Char), "paraStyles" => text_styles::menu(app, ui, text_styles::Kind::Para), "magicWand" => magic_wand::menu(app, ui), diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index 25623fe9..a2364c82 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -81,6 +81,7 @@ pub const ICON_PANELS: &[(&str, &str, &str)] = &[ ("history", "History", "history"), ("actions", "Actions", "dc-actions"), ("info", "Info", "info"), + ("docInfo", "Document Info", "dc-list-view"), ("navigator", "Navigator", "map"), ("separations", "Separations Preview", "printer"), ("imageTrace", "Image Trace", "image"), @@ -216,6 +217,9 @@ pub struct UiState { /// User-saved workspaces (New Workspace…). #[serde(default)] pub custom_workspaces: Vec, + /// File → Open Recent Files, most recent first. + #[serde(default)] + pub recent_files: Vec, /// Engine preferences (Edit → Preferences), persisted alongside the UI state. #[serde(default)] pub engine_prefs: Value, @@ -267,6 +271,7 @@ impl Default for UiState { shortcut_set: default_shortcut_set(), workspace: default_workspace(), custom_workspaces: vec![], + recent_files: vec![], engine_prefs: Value::Null, } }