G: Preferences (⌘K), scale strokes, Dimensions Include Stroke Weight, Quick Apply (⌘Return)

prefs.set (application) and document.preferences (units, increments, grids; undoable).
Scaling applies to content: paths and placed graphics take the scale, stroke weights follow
with Include Stroke Weight. X/Y/W/H and transform.set measure the stroke's outer edge.
The command palette becomes Quick Apply and lists styles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-02 09:26:39 -04:00
co-authored by Claude Opus 5.5
parent b3679cbe63
commit 7143973f52
9 changed files with 422 additions and 43 deletions
+2 -1
View File
@@ -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), `<PDF>` 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.
+2
View File
@@ -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());
+104 -10
View File
@@ -34,7 +34,18 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
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<Value> {
})
}
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<Value> {
let ids = targets(s, p)?;
s.edit(|d, _| {
@@ -622,14 +680,20 @@ fn transform_rotate(s: &mut Session, p: &Value) -> Result<Value> {
fn transform_set(s: &mut Session, p: &Value) -> Result<Value> {
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<Rect> = 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<Value> {
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);
}
}
+117
View File
@@ -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<CommandSpec> {
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<String, Value> = 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<Value> {
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);
}
}
+12 -2
View File
@@ -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,
}
}
}
+81
View File
@@ -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<Value, String> {
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"));
+92 -20
View File
@@ -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<Result<Value,
app.ui.palette = Some(String::new());
Ok(Value::Null)
}
"app.preferences" => {
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<String> = 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<String>,
/// "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<QuickItem> {
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();
+3 -1
View File
@@ -52,10 +52,12 @@ pub fn sel_info(app: &DesignApp) -> Option<SelInfo> {
return None;
}
let d = &st.doc;
let with_stroke = app.session.prefs.dimensions_include_stroke;
let mut b: Option<Rect> = 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])?;
+9 -9
View File
@@ -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.
<!-- SUMMARY -->
**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 |