mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-24 10:16:59 -06:00
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:
co-authored by
Claude Opus 5.5
parent
b3679cbe63
commit
7143973f52
+2
-1
@@ -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.
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
@@ -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
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user