diff --git a/ROADMAP.md b/ROADMAP.md index c2aa759c..e14b2080 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -8,7 +8,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Document model: facing/non-facing spreads, parent pages with page-number markers, sections, layers, frames (text / graphic / unassigned), threaded stories, paragraph/character/object styles with based-on, swatches (process/spot/tints/gradients), corner options, text wrap, drop shadow, opacity/blend. - Text engine: Knuth–Plass paragraph composer and single-line composer, dictionary hyphenation (public-domain Moby list + our own trained Liang patterns), justification with word/letter/glyph-scaling ranges, keep options (keep with next, keep lines together, widows/orphans), balance ragged lines, optical margin alignment, hyphenation zone/limit, columns, threading across frames and spreads, tabs, bullets/numbering, rules, shading, baseline grid, first-baseline options, vertical justification, wrap exclusions, overset detection, caret/hit testing. - Rendering: vello_cpu (SIMD, multithreaded) — pages, items, images with mip levels, gradients, composed text. -- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts, document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document dialog, ⌘K palette, 4 brightness themes. +- UI (egui): application bar + menus, Control panel (object and text modes), Tools panel with flyouts, document tabs, rulers, pasteboard, guides (margins, columns, bleed, baseline grid), frame edges, selection handles, text ports and threads, Properties / Pages / Layers dock, Swatches, Styles, Character, Paragraph, Stroke, Text Wrap panels, New Document and Preferences dialogs, Quick Apply (⌘Return), 4 brightness themes. - Pen tool, Direct Selection anchor/handle editing, rotate from corners, Rotate/Scale/Shear tools, Eyedropper, snapping & smart guides, Align/Distribute, Find/Change (text + GREP), Story Editor, Paragraph Style Options, Effects panel, hidden characters, native macOS menu bar, measured InDesign 2026 look (Medium Dark): Properties sections per selection state, 21 pt spinner fields with arithmetic, Contextual Task Bar, inverse (black) text selection, Pages panel with drag-and-drop reorder / parent apply, Layers panel with per-object rows. - Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type), Rectangle/Ellipse/Polygon (+ frame variants), Line, Hand, Zoom. - PDF export (krilla): real selectable text with embedded font subsets, DeviceCMYK/RGB + spot Separations, bleed boxes, crop/bleed marks + page info, pages or spreads, PDF/A-2b (PDF/X-4 output intent pending) — `file.exportPdf`, File › Export PDF…, `designcraft-cli run --export out.pdf`. @@ -30,6 +30,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). - Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`. - Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`). +- Preferences (⌘K: General, Type, Units & Increments) backed by `prefs.set` (application) and `document.preferences` (units, increments, grids — undoable, saved with the document); scaling applies to content and scales stroke weights (Include Stroke Weight), and X/Y/W/H measure the stroke's outer edge (Dimensions Include Stroke Weight). Quick Apply (⌘Return) finds paragraph, character and object styles as well as commands. - Parent item overrides: Cmd+Shift-click a parent item on a page (or Override All Parent Page Items, ⌥⇧⌘L) makes an editable local copy that hides the parent's; Remove All Local Overrides and Detach All Objects from Parent in the Pages panel menu. - Place PDF: placed PDF pages are sized by their crop box, drawn on screen through hayro (with mip levels) and embedded in exported PDFs as vector form XObjects (krilla), `` in IDML. - Menus expose the engine's features (TOC, numbering & sections, text variables, hyperlinks, cross-references, footnotes, step and repeat, spelling, fitting/content, effects, Data Merge, Preflight, EPUB); any menu command with parameters gets a dialog generated from its parameter documentation. diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index e7221dd0..3c8ea80b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -19,6 +19,7 @@ mod object; mod overrides; mod place_text; pub mod preflight; +mod prefs; pub mod spelling; mod style; pub mod table; @@ -128,6 +129,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(table::specs()); v.extend(layout::specs()); v.extend(overrides::specs()); + v.extend(prefs::specs()); v.extend(inspect::specs()); v.extend(find::specs()); v.extend(variables::specs()); diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index eaea8c63..b1561563 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -34,7 +34,18 @@ pub fn specs() -> Vec { if sx.abs() < 1e-4 || sy.abs() < 1e-4 { return Err(bad("transform.scale", "scale too small")); } - apply_about_center(s, p, Affine::scale_non_uniform(sx, sy)) + let strokes = s.prefs.scale_strokes; + let ids = targets(s, p)?; + s.edit(|d, _| { + let c = union_bounds(d, &ids).center(); + let m = Affine::translate(c.to_vec2()) * Affine::scale_non_uniform(sx, sy) * Affine::translate(-c.to_vec2()); + for id in &ids { + if let Some(it) = d.item_mut(*id) { + scale_item(it, m, (sx * sy).abs().sqrt(), strokes); + } + } + ok() + }) }), cmd!("transform.shear", "Shear", ["Object", "Transform"], None, "{angle (degrees), ids?}", has_selection, |s, p| { let a = f64_or(p, "angle", 0.0).clamp(-85.0, 85.0).to_radians().tan(); @@ -576,13 +587,16 @@ fn transform_resize(s: &mut Session, p: &Value) -> Result { let sx = if sx.abs() < 1e-3 { 1e-3f64.copysign(sx) } else { sx }; let sy = if sy.abs() < 1e-3 { 1e-3f64.copysign(sy) } else { sy }; let m = Affine::translate((to.x0, to.y0)) * Affine::scale_non_uniform(sx, sy) * Affine::translate((-from.x0, -from.y0)); + let strokes = s.prefs.scale_strokes; s.edit(|d, _| { for id in &ids { let it = d.item_mut(*id).ok_or(designcraft_doc::DocError::NoItem(*id))?; if it.locked { continue; } - if scale_content || matches!(it.content, Content::Group { .. }) { + if scale_content { + scale_item(it, m, (sx * sy).abs().sqrt(), strokes); + } else if matches!(it.content, Content::Group { .. }) { it.xf = m * it.xf; } else { // Resize the frame's path (in inner space); content keeps its size (InDesign's default). @@ -594,6 +608,50 @@ fn transform_resize(s: &mut Session, p: &Value) -> Result { }) } +fn union_bounds(d: &Document, ids: &[ItemId]) -> Rect { + ids.iter().filter_map(|i| d.item(*i)).map(Item::bounds).reduce(|a, b| a.union(b)).unwrap_or(Rect::ZERO) +} + +/// Multiply the stroke weights of an item and everything nested in it. +fn scale_weights(it: &mut Item, k: f64) { + it.stroke.weight *= k; + if let Content::Group { items } = &mut it.content { + for c in items.iter_mut() { + scale_weights(Arc::make_mut(c), k); + } + } +} + +/// Scale an item and its content by the spread-space transform `m` (InDesign's "Apply to +/// Content"): paths and placed graphics take the scale into their geometry, so the stroke weight +/// is scaled by `k` (the mean scale factor) only with Scale Strokes on; text frames and groups are +/// transformed as a whole, so with Scale Strokes off their weights are divided by `k` to keep +/// their look. +pub(crate) fn scale_item(it: &mut Item, m: Affine, k: f64, strokes: bool) { + let inner = it.xf.inverse() * m * it.xf; + match &mut it.content { + Content::Unassigned => { + it.path.transform(inner); + if strokes { + it.stroke.weight *= k; + } + } + Content::Graphic(g) => { + g.xf = inner * g.xf; + it.path.transform(inner); + if strokes { + it.stroke.weight *= k; + } + } + Content::Text(_) | Content::Group { .. } => { + it.xf = m * it.xf; + if !strokes && k > 1e-9 { + scale_weights(it, 1.0 / k); + } + } + } +} + fn apply_about_center(s: &mut Session, p: &Value, a: Affine) -> Result { let ids = targets(s, p)?; s.edit(|d, _| { @@ -622,14 +680,20 @@ fn transform_rotate(s: &mut Session, p: &Value) -> Result { fn transform_set(s: &mut Session, p: &Value) -> Result { let ids = targets(s, p)?; let rf = p.get("ref").and_then(Value::as_u64).unwrap_or(0) as usize; + let with_stroke = s.prefs.dimensions_include_stroke; s.edit(|d, _| { - let mut b: Option = None; - for id in &ids { - if let Some(it) = d.item(*id) { - b = Some(b.map_or(it.bounds(), |r| r.union(it.bounds()))); - } + if !ids.iter().any(|i| d.item(*i).is_some()) { + return ok(); } - let Some(from) = b else { return ok() }; + // The measured box (with Dimensions Include Stroke Weight, the stroke's outer edge) and + // the geometry it pads. + let geom = union_bounds(d, &ids); + let from = if with_stroke { + ids.iter().filter_map(|i| d.item(*i)).map(Item::visible_bounds).reduce(|a, b| a.union(b)).unwrap_or(geom) + } else { + geom + }; + let pad = (geom.x0 - from.x0, geom.y0 - from.y0, from.x1 - geom.x1, from.y1 - geom.y1); let w = f64_or(p, "width", from.width()).max(0.01); let h = f64_or(p, "height", from.height()).max(0.01); let anchor = designcraft_geom::reference_point(from, rf); @@ -654,9 +718,10 @@ fn transform_set(s: &mut Session, p: &Value) -> Result { if let Some(y) = p.get("y").and_then(Value::as_f64) { to = to + Vec2::new(0.0, y - anchor.y); } + let to = Rect::new(to.x0 + pad.0, to.y0 + pad.1, (to.x1 - pad.2).max(to.x0 + pad.0 + 0.01), (to.y1 - pad.3).max(to.y0 + pad.1 + 0.01)); let m = Affine::translate((to.x0, to.y0)) - * Affine::scale_non_uniform(to.width() / from.width().max(1e-9), to.height() / from.height().max(1e-9)) - * Affine::translate((-from.x0, -from.y0)); + * Affine::scale_non_uniform(to.width() / geom.width().max(1e-9), to.height() / geom.height().max(1e-9)) + * Affine::translate((-geom.x0, -geom.y0)); for id in &ids { if let Some(it) = d.item_mut(*id) { if matches!(it.content, Content::Group { .. }) { @@ -1155,3 +1220,32 @@ mod polygon_tests { assert_eq!(s.doc().unwrap().doc.item(ItemId(id)).unwrap().path.subpaths[0].anchors.len(), 3); } } + +#[cfg(test)] +mod scale_tests { + use super::*; + + #[test] + fn scale_strokes_and_dimensions_with_stroke() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [100, 100, 200, 150]})).unwrap(); + let id = ItemId(r["id"].as_u64().unwrap()); + s.execute("object.stroke", &json!({"weight": 4, "swatch": "Black"})).unwrap(); + let weight = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().stroke.weight; + let bounds = |s: &Session| s.doc().unwrap().doc.item(id).unwrap().bounds(); + s.execute("transform.scale", &json!({"sx": 2.0})).unwrap(); + assert_eq!(weight(&s), 8.0); + assert_eq!(bounds(&s), Rect::new(50.0, 75.0, 250.0, 175.0)); + s.execute("prefs.set", &json!({"scaleStrokes": false})).unwrap(); + s.execute("transform.scale", &json!({"sx": 0.5})).unwrap(); + assert_eq!(weight(&s), 8.0); + assert!(s.execute("prefs.set", &json!({"nope": 1})).is_err()); + // Width 108 with an 8 pt centred stroke: the path is 100 wide. + s.execute("transform.set", &json!({"width": 108})).unwrap(); + assert!((bounds(&s).width() - 100.0).abs() < 1e-9, "{:?}", bounds(&s)); + s.execute("prefs.set", &json!({"dimensionsIncludeStroke": false})).unwrap(); + s.execute("transform.set", &json!({"width": 120})).unwrap(); + assert!((bounds(&s).width() - 120.0).abs() < 1e-9); + } +} diff --git a/crates/engine/src/cmd/prefs.rs b/crates/engine/src/cmd/prefs.rs new file mode 100644 index 00000000..141aab34 --- /dev/null +++ b/crates/engine/src/cmd/prefs.rs @@ -0,0 +1,117 @@ +//! Preferences: the application's (`prefs.set`, kept by the session) and the ones InDesign stores +//! with the document (`document.preferences`: units, keyboard increment, grids, guide colours). + +use serde_json::{Map, Value}; + +use super::{CommandSpec, bad, cmd, has_doc}; +use crate::Result; + +/// Document settings that Preferences edits (Document Setup edits the rest). +const DOC_KEYS: &[&str] = &[ + "horizontalUnits", + "verticalUnits", + "keyboardIncrement", + "baselineGrid", + "grid", + "pasteboard", + "marginColor", + "columnColor", + "bleedColor", + "slugColor", +]; + +pub fn specs() -> Vec { + vec![ + cmd!( + noundo "prefs.set", + "Preferences", + [], + None, + "{showHiddenCharacters?, typographersQuotes?, polygonSides?, starInset?, scaleStrokes?, dimensionsIncludeStroke?} → all application preferences", + super::always, + |s, p| { + let cur = serde_json::to_value(&s.prefs).map_err(|e| bad("prefs.set", e.to_string()))?; + let new = merge("prefs.set", cur, p, None)?; + s.prefs = serde_json::from_value(new.clone()).map_err(|e| bad("prefs.set", e.to_string()))?; + Ok(new) + } + ), + cmd!( + "document.preferences", + "Document Preferences", + [], + None, + "{horizontalUnits?, verticalUnits?: points|picas|inches|millimeters|…, keyboardIncrement? (pt), baselineGrid?: {start, increment, relativeTo, color, viewThreshold}, grid?: {horizontal, vertical, subdivisions, color, inBack}, pasteboard?: [h, v], marginColor?, columnColor?, bleedColor?, slugColor?: [r, g, b]} → those settings", + has_doc, + |s, p| { + let cur = serde_json::to_value(&s.doc()?.doc.settings).map_err(|e| bad("document.preferences", e.to_string()))?; + let new = merge("document.preferences", cur, p, Some(DOC_KEYS))?; + let settings = serde_json::from_value(new.clone()).map_err(|e| bad("document.preferences", e.to_string()))?; + if p.as_object().is_some_and(|o| !o.is_empty()) { + s.edit(|d, _| { + d.settings = settings; + Ok(Value::Null) + })?; + } + let out: Map = new + .as_object() + .into_iter() + .flatten() + .filter(|(k, _)| DOC_KEYS.contains(&k.as_str())) + .map(|(k, v)| (k.clone(), v.clone())) + .collect(); + Ok(Value::Object(out)) + } + ), + ] +} + +/// `cur` with the keys of `p` replaced (objects merged one level deep); unknown keys are errors. +fn merge(cmd: &str, mut cur: Value, p: &Value, allowed: Option<&[&str]>) -> Result { + let (Some(c), Some(new)) = (cur.as_object_mut(), p.as_object()) else { return Ok(cur) }; + for (k, v) in new { + if !c.contains_key(k) || allowed.is_some_and(|a| !a.contains(&k.as_str())) { + return Err(bad(cmd, format!("unknown preference {k}"))); + } + match (c.get_mut(k), v) { + (Some(Value::Object(old)), Value::Object(v)) => { + for (k2, v2) in v { + old.insert(k2.clone(), v2.clone()); + } + } + _ => { + c.insert(k.clone(), v.clone()); + } + } + } + Ok(cur) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn application_and_document_preferences() { + let mut s = Session::new(); + let r = s.execute("prefs.set", &json!({"scaleStrokes": false})).unwrap(); + assert_eq!(r["scaleStrokes"], false); + assert!(!s.prefs.scale_strokes); + assert!(s.execute("prefs.set", &json!({"nope": 1})).is_err()); + s.execute("file.new", &json!({})).unwrap(); + let r = s + .execute("document.preferences", &json!({"horizontalUnits": "millimeters", "keyboardIncrement": 2.5, "grid": {"subdivisions": 4}})) + .unwrap(); + assert_eq!(r["horizontalUnits"], "millimeters"); + let st = &s.doc().unwrap().doc.settings; + assert_eq!(st.horizontal_units, designcraft_geom::Unit::Millimeters); + assert_eq!(st.keyboard_increment, 2.5); + assert_eq!(st.grid.subdivisions, 4); + assert_eq!(st.grid.horizontal, 72.0, "the rest of the grid is kept"); + assert!(s.execute("document.preferences", &json!({"pageWidth": 100})).is_err(), "Document Setup's, not Preferences'"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.doc().unwrap().doc.settings.horizontal_units, designcraft_geom::Unit::Picas); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 65a3c98b..c2d62890 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -122,17 +122,27 @@ impl DocState { #[derive(Clone, Debug, serde::Serialize, serde::Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct Prefs { - pub keyboard_increment: f64, pub show_hidden_characters: bool, pub typographers_quotes: bool, /// Polygon Settings (double-click the Polygon tool): number of sides, star inset (0–1). pub polygon_sides: u32, pub star_inset: f64, + /// Scaling objects scales their stroke weights (Preferences › General › When Scaling). + pub scale_strokes: bool, + /// X/Y/W/H in the Control and Properties panels measure the stroke's outer edge. + pub dimensions_include_stroke: bool, } impl Default for Prefs { fn default() -> Self { - Prefs { keyboard_increment: 1.0, show_hidden_characters: false, typographers_quotes: true, polygon_sides: 6, star_inset: 0.0 } + Prefs { + show_hidden_characters: false, + typographers_quotes: true, + polygon_sides: 6, + star_inset: 0.0, + scale_strokes: true, + dimensions_include_stroke: true, + } } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 816fc78c..198fa284 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -102,6 +102,71 @@ fn text_field(ui: &mut egui::Ui, d: &mut Dialog, key: &str, w: f32) { } } +/// Preferences: a section list and the section's options. Application options always; units and +/// increments when a document is open (InDesign keeps those with the document). +fn preferences(ui: &mut egui::Ui, d: &mut Dialog) { + let has_doc = d.fields.contains_key("horizontalUnits"); + let sections: &[(&str, &str)] = if has_doc { + &[("general", "General"), ("type", "Type"), ("units", "Units & Increments")] + } else { + &[("general", "General"), ("type", "Type")] + }; + let cur = d.s("section"); + ui.horizontal_top(|ui| { + // A fixed height: the separator would otherwise take the whole window. + ui.set_min_height(240.0); + ui.set_max_height(240.0); + ui.vertical(|ui| { + ui.set_width(150.0); + for (id, label) in sections { + if ui.selectable_label(cur == *id, *label).clicked() { + d.fields.insert("section".into(), json!(id)); + } + } + }); + ui.separator(); + ui.vertical(|ui| { + ui.set_min_width(300.0); + match cur.as_str() { + "type" => { + ui.label(egui::RichText::new("Type Options").font(semibold(12.0))); + check(ui, d, "typographersQuotes", "Use Typographer's Quotes"); + } + "units" => { + ui.label(egui::RichText::new("Ruler Units").font(semibold(12.0))); + egui::Grid::new("pref_units").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + for (key, label) in [("horizontalUnits", "Horizontal:"), ("verticalUnits", "Vertical:")] { + ui.label(label); + let cur: Unit = d.fields.get(key).and_then(|v| serde_json::from_value(v.clone()).ok()).unwrap_or_default(); + egui::ComboBox::from_id_salt(key).selected_text(cur.label()).width(140.0).show_ui(ui, |ui| { + for u in Unit::ALL { + if ui.selectable_label(u == cur, u.label()).clicked() { + d.fields.insert(key.into(), json!(u)); + } + } + }); + ui.end_row(); + } + }); + ui.add_space(6.0); + ui.label(egui::RichText::new("Keyboard Increments").font(semibold(12.0))); + ui.horizontal(|ui| { + ui.label("Cursor Key:"); + text_field(ui, d, "keyboardIncrement", 80.0); + }); + } + _ => { + ui.label(egui::RichText::new("When Scaling").font(semibold(12.0))); + check(ui, d, "scaleStrokes", "Include Stroke Weight"); + ui.add_space(6.0); + ui.label(egui::RichText::new("Transform").font(semibold(12.0))); + check(ui, d, "dimensionsIncludeStroke", "Dimensions Include Stroke Weight"); + } + } + }); + }); +} + fn check(ui: &mut egui::Ui, d: &mut Dialog, key: &str, label: &str) { let mut b = d.b(key); if ui.checkbox(&mut b, label).changed() { @@ -125,6 +190,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "insertXref" => "New Cross-Reference", "findFont" => "Find/Replace Font", "polygonSettings" => "Polygon Settings", + "preferences" => "Preferences", "colorPicker" => "Color Picker", id => match id.strip_prefix("cmd:").and_then(designcraft_engine::find_command) { Some(c) => c.label.trim_end_matches('…'), @@ -261,6 +327,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "insertXref" => insert_xref(app, ui, &mut d), "findFont" => find_font(app, ui, &mut d), "colorPicker" => color_picker(ui, &mut d), + "preferences" => preferences(ui, &mut d), "polygonSettings" => { egui::Grid::new("poly").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { ui.label("Number of Sides:"); @@ -427,6 +494,20 @@ pub fn confirm(app: &mut DesignApp) -> Result { let hex = d.s("hex"); app.run("object.color", json!({"color": hex, "target": d.s("target")})) } + "preferences" => { + app.run( + "prefs.set", + json!({"scaleStrokes": d.b("scaleStrokes"), "dimensionsIncludeStroke": d.b("dimensionsIncludeStroke"), "typographersQuotes": d.b("typographersQuotes")}), + )?; + if !d.fields.contains_key("horizontalUnits") { + return Ok(Value::Null); + } + let mut doc = json!({"horizontalUnits": d.fields["horizontalUnits"], "verticalUnits": d.fields["verticalUnits"]}); + if let Some(v) = d.fields.get("keyboardIncrement").and_then(Value::as_str).and_then(|s| parse_measure(s, Unit::Points).ok()) { + doc["keyboardIncrement"] = json!(v.max(0.001)); + } + app.run("document.preferences", doc) + } "polygonSettings" => app.run("tool.polygonSettings", json!({"sides": d.n("sides").unwrap_or(6.0) as u64, "starInset": d.n("starInset").unwrap_or(0.0)})), "findFont" => { let (f, st) = (d.s("family"), d.s("style")); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 2e848630..e0a5c54e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1,4 +1,4 @@ -//! The menu bar tree (InDesign order), UI-only commands, keyboard shortcuts and the ⌘K palette. +//! The menu bar tree (InDesign order), UI-only commands, keyboard shortcuts and Quick Apply. use serde_json::{Value, json}; @@ -15,7 +15,8 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.exportPng", "Export Page as PNG…", Some("Cmd+E"), "{}"), ("app.exportIdml", "Export IDML…", None, "{path?} — InDesign Markup (IDML) package"), ("app.exportPdf", "Export PDF…", None, "{path?, …file.exportPdf options} — asks for a path when none is given"), - ("app.palette", "Command Palette…", Some("Cmd+K"), "{}"), + ("app.palette", "Quick Apply…", Some("Cmd+Return"), "{} — search styles and commands"), + ("app.preferences", "Preferences…", Some("Cmd+K"), "{}"), ("app.findChange", "Find/Change…", Some("Cmd+F"), "{}"), ("app.insertTableDialog", "Create Table…", None, "{} — Insert Table dialog (body/header/footer rows, columns)"), ("app.footnoteOptionsDialog", "Document Footnote Options…", None, "{} — numbering, formatting and layout of footnotes"), @@ -124,6 +125,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "<", "-", "ui:app.palette", + "ui:app.preferences", ], ), ( @@ -510,6 +512,19 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option { + let mut f = app.session.execute("prefs.set", &json!({})).unwrap_or_default(); + if app.session.active().is_some() { + let doc = app.session.execute("document.preferences", &json!({})).unwrap_or_default(); + f["horizontalUnits"] = doc["horizontalUnits"].clone(); + f["verticalUnits"] = doc["verticalUnits"].clone(); + let inc = doc["keyboardIncrement"].as_f64().unwrap_or(1.0); + f["keyboardIncrement"] = json!(designcraft_geom::format_measure(inc, designcraft_geom::Unit::Points)); + } + f["section"] = json!("general"); + app.ui.dialog = Some(crate::dialogs::Dialog::new("preferences", f)); + Ok(Value::Null) + } "view.zoomIn" | "view.zoomOut" => { if let Some(r) = rect { crate::canvas::zoom_at(app, r.center(), if id == "view.zoomIn" { 1.5 } else { 1.0 / 1.5 }); @@ -983,32 +998,26 @@ pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) { } } -/// ⌘K command palette. +/// Quick Apply (⌘Return): the document's styles and every command, searched by name. pub fn palette(app: &mut DesignApp, ctx: &egui::Context) { let Some(mut q) = app.ui.palette.clone() else { return }; let mut close = false; - let mut run: Option = None; + let mut run: Option<(String, Value)> = None; egui::Modal::new(egui::Id::new("palette")).show(ctx, |ui| { ui.set_width(480.0); - let r = ui.add(egui::TextEdit::singleline(&mut q).hint_text("Search commands…").desired_width(f32::INFINITY)); + let r = ui.add(egui::TextEdit::singleline(&mut q).hint_text("Search styles and commands…").desired_width(f32::INFINITY)); r.request_focus(); - let ql = q.to_lowercase(); - let mut items: Vec<(String, String, Option<&str>)> = designcraft_engine::command_specs() - .iter() - .filter(|c| !c.menu.is_empty() || c.shortcut.is_some()) - .map(|c| (c.id.to_string(), c.label.to_string(), c.shortcut)) - .chain(UI_COMMANDS.iter().map(|c| (c.0.to_string(), c.1.to_string(), c.2))) - .filter(|(id, l, _)| ql.is_empty() || l.to_lowercase().contains(&ql) || id.to_lowercase().contains(&ql)) - .collect(); - items.truncate(14); + let items = quick_apply_items(&app.session, &q); egui::ScrollArea::vertical().max_height(340.0).show(ui, |ui| { - for (i, (id, l, sc)) in items.iter().enumerate() { - let mut b = egui::Button::new(l.as_str()).frame(false); - if let Some(sc) = sc { + for (i, it) in items.iter().enumerate() { + let mut b = egui::Button::new(it.label.as_str()).frame(false); + if let Some(sc) = &it.shortcut { b = b.shortcut_text(shortcut_text(sc)); + } else if !it.kind.is_empty() { + b = b.shortcut_text(it.kind); } if ui.add_sized([460.0, 22.0], b).clicked() || (i == 0 && ui.input(|i| i.key_pressed(egui::Key::Enter))) { - run = Some(id.clone()); + run = Some((it.id.clone(), it.params.clone())); } } }); @@ -1017,11 +1026,61 @@ pub fn palette(app: &mut DesignApp, ctx: &egui::Context) { } }); app.ui.palette = if close || run.is_some() { None } else { Some(q) }; - if let Some(id) = run { - let _ = app.run(&id, json!({})); + if let Some((id, params)) = run { + let _ = app.run(&id, params); } } +/// One Quick Apply entry: a style to apply or a command to run. +#[derive(Clone, Debug, PartialEq)] +pub struct QuickItem { + pub label: String, + pub id: String, + pub params: Value, + pub shortcut: Option, + /// "Paragraph Style", "Character Style", "Object Style", or "" for commands. + pub kind: &'static str, +} + +/// Quick Apply matches (styles first, as InDesign lists them), at most 14. +pub fn quick_apply_items(session: &designcraft_engine::Session, query: &str) -> Vec { + let ql = query.to_lowercase(); + let hit = |label: &str, id: &str| ql.is_empty() || label.to_lowercase().contains(&ql) || id.to_lowercase().contains(&ql); + let mut out = Vec::new(); + if let Some(st) = session.active() { + let styles = &st.doc.styles; + let mut add = |name: &str, id: &str, kind: &'static str| { + if name.starts_with('[') || !hit(name, "") { + return; + } + out.push(QuickItem { label: name.to_string(), id: id.into(), params: json!({"name": name}), shortcut: None, kind }); + }; + for p in &styles.paragraph { + add(&p.name, "style.paragraph.apply", "Paragraph Style"); + } + for c in &styles.character { + add(&c.name, "style.character.apply", "Character Style"); + } + for o in &styles.object { + add(&o.name, "style.object.apply", "Object Style"); + } + } + // Styles need a query, else they would crowd out the commands. + if ql.is_empty() { + out.clear(); + } + let commands = designcraft_engine::command_specs() + .iter() + .filter(|c| !c.menu.is_empty() || c.shortcut.is_some()) + .map(|c| (c.id, c.label, c.shortcut)) + .chain(UI_COMMANDS.iter().map(|c| (c.0, c.1, c.2))) + .filter(|(id, l, _)| hit(l, id)) + .map(|(id, l, sc)| QuickItem { label: l.to_string(), id: id.to_string(), params: json!({}), shortcut: sc.map(str::to_string), kind: "" }); + out.extend(commands); + out.truncate(14); + out +} + #[cfg(test)] mod tests { use super::*; @@ -1036,6 +1095,19 @@ mod tests { } } + #[test] + fn quick_apply_lists_styles_then_commands() { + let mut s = designcraft_engine::Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Body Copy"})).unwrap(); + let items = quick_apply_items(&s, "body"); + assert_eq!(items[0].id, "style.paragraph.apply"); + assert_eq!(items[0].params, json!({"name": "Body Copy"})); + assert_eq!(items[0].kind, "Paragraph Style"); + assert!(quick_apply_items(&s, "").iter().all(|i| i.kind.is_empty())); + assert!(quick_apply_items(&s, "preferences").iter().any(|i| i.id == "app.preferences")); + } + #[test] fn every_menu_entry_is_a_command() { let mut all = Vec::new(); diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index b9133144..2dea8c5b 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -52,10 +52,12 @@ pub fn sel_info(app: &DesignApp) -> Option { return None; } let d = &st.doc; + let with_stroke = app.session.prefs.dimensions_include_stroke; let mut b: Option = None; for id in &st.selection.items { let it = d.item(*id)?; - b = Some(b.map_or(it.bounds(), |r| r.union(it.bounds()))); + let ib = if with_stroke { it.visible_bounds() } else { it.bounds() }; + b = Some(b.map_or(ib, |r| r.union(ib))); } let b = b?; let first = d.item(st.selection.items[0])?; diff --git a/docs/parity.md b/docs/parity.md index 1a17d157..7333267c 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -5,12 +5,12 @@ The summary is generated by `cargo xtask parity` from the table at the bottom of Each row of the InDesign feature catalogue is scored **D** (done), **P** (partial: works but lacks options or UI) or **M** (missing), weighted by priority (**P0** core = 3, **P1** = 2, **P2** = 1). Out-of-scope rows (Adobe cloud services, plug-ins, Typekit, Flash) are excluded. -**Weighted breadth parity: 66%** over 275 features. +**Weighted breadth parity: 67%** over 275 features. | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| -| P0 | 102 | 88 | 12 | 2 | 92% | -| P1 | 97 | 32 | 24 | 41 | 45% | +| P0 | 102 | 90 | 12 | 0 | 94% | +| P1 | 97 | 33 | 23 | 41 | 46% | | P2 | 76 | 8 | 7 | 61 | 15% | | Area | Parity | @@ -19,13 +19,13 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Documents, pages, spreads | 69% | | Layers | 74% | | Frames, shapes & paths | 74% | -| Transform | 56% | +| Transform | 78% | | Fill, stroke, colour | 62% | | Effects & transparency | 50% | | Placing & links | 57% | | Type & text frames | 77% | | Typography | 76% | -| Styles | 55% | +| Styles | 61% | | Tables | 73% | | Long documents | 58% | | Interactivity & digital | 30% | @@ -35,7 +35,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Undo, history, saving | 100% | | Accessibility | 20% | -Open P0 items: Dockable panels, Preferences, Document Setup, Pen, add/delete anchor, convert direction, Scale strokes / content options, Dimensions Include Stroke Weight, Gradient panel & tool, Stroke panel, Raster formats, Vector/page formats (PDF/AI/SVG/EPS place), Display performance, OpenType features, Export to PDF (print), Display performance. +Open P0 items: Dockable panels, Preferences, Document Setup, Pen, add/delete anchor, convert direction, Gradient panel & tool, Stroke panel, Raster formats, Vector/page formats (PDF/AI/SVG/EPS place), Display performance, OpenType features, Export to PDF (print), Display performance. ## Rows @@ -135,9 +135,9 @@ Open P0 items: Dockable panels, Preferences, Document Setup, Pen, add/delete anc | Transform | Transform Again family | P1 | M | | Transform | Clear Transformations | P1 | M | | Transform | Transformations are Totals pref | P1 | M | -| Transform | Scale strokes / content options | P0 | M | +| Transform | Scale strokes / content options | P0 | D | | Transform | Align & Distribute | P0 | D | -| Transform | Dimensions Include Stroke Weight | P0 | M | +| Transform | Dimensions Include Stroke Weight | P0 | D | | Transform | Rotate 90 / Flip buttons | P0 | D | | Fill, stroke, colour | Swatches panel | P0 | D | | Fill, stroke, colour | Color panel | P0 | D | @@ -241,7 +241,7 @@ Open P0 items: Dockable panels, Preferences, Document Setup, Pen, add/delete anc | Styles | Object styles | P1 | D | | Styles | Table & cell styles | P1 | M | | Styles | Style groups | P1 | M | -| Styles | Quick Apply | P1 | P | +| Styles | Quick Apply | P1 | D | | Styles | Break link to style | P1 | M | | Styles | Style mapping on import | P1 | P | | Styles | Style export tagging | P2 | P |