mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-14 19:26:56 -04:00
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:
1 parent
48a4720ece
commit
6a6e3d4bb1
10 files changed
+450
-7
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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, ""));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user