M4.1/M11: File → Open Recent Files (persisted, native menu too), Document Info panel (document.info), Object → Make Pixel Perfect; agent guide: control-port clashes

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-01 10:09:19 -04:00
1 parent 48a4720ece
commit 6a6e3d4bb1
10 files changed
+450 -7

No files matched your search

+1 -1
View File
@@ -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).
+1 -1
View File
@@ -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).
+2 -1
View File
@@ -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, ""));
}
}
+216
View File
@@ -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<CommandSpec> {
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<Value> {
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<String, Value> = 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::<Vec<_>>(),
"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<Value> {
let ids: Vec<NodeId> = 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));
}
}
+2
View File
@@ -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());
+12 -1
View File
@@ -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<String>, 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<String>, scale: f64) -> Result<String, String> {
let st = app.session.active().ok_or("no document")?;
+85 -3
View File
@@ -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<Res
None => 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::<usize>().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<Item>)> {
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<Item> = 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<Item>)> {
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<Item>)> {
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<Item> {
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<String>) {
+123
View File
@@ -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();
}
}
+3
View File
@@ -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),
+5
View File
@@ -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<crate::workspaces::Workspace>,
/// File → Open Recent Files, most recent first.
#[serde(default)]
pub recent_files: Vec<String>,
/// 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,
}
}