diff --git a/ROADMAP.md b/ROADMAP.md index f826a3767..7dec1eee4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-12** · Type tool: Option/Alt+Left/Right kerns the pair at the caret (manual kerning) or tracks a selection by the Kerning/Tracking increment (Preferences › Units & Increments, 20/1000 em), five times with Cmd/Ctrl; moving by words is Cmd+Left/Right on macOS and Ctrl+Left/Right elsewhere, and Option/Alt+Shift+Left/Right still selects by words (#621). No readiness percentage change. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. - **2026-10-12** · Book numbering and style updates prepare every chapter before saving. Only closed native chapters are changed; imported IDML remains a read-only style/PDF source. If a later disk write fails, the error names the completed chapters; the batch is not a whole-book transaction. No readiness percentage change. diff --git a/crates/doc/src/lib.rs b/crates/doc/src/lib.rs index 2c90d8b95..b378a9fae 100644 --- a/crates/doc/src/lib.rs +++ b/crates/doc/src/lib.rs @@ -328,6 +328,8 @@ pub struct DocSettings { pub bleed_color: [u8; 3], pub slug_color: [u8; 3], pub keyboard_increment: f64, + /// Kerning/Tracking increment (Alt+Left/Right in text), in 1/1000 em. + pub kerning_increment: f64, /// Named lists (Type › Bulleted and Numbered Lists › Define Lists). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub lists: Vec, @@ -376,6 +378,7 @@ impl Default for DocSettings { bleed_color: [255, 72, 103], slug_color: [100, 188, 221], keyboard_increment: 1.0, + kerning_increment: 20.0, primary_story: None, lists: Vec::new(), chapter_number: 1, diff --git a/crates/engine/src/cmd/prefs.rs b/crates/engine/src/cmd/prefs.rs index 68a5cf152..51995b200 100644 --- a/crates/engine/src/cmd/prefs.rs +++ b/crates/engine/src/cmd/prefs.rs @@ -11,6 +11,7 @@ const DOC_KEYS: &[&str] = &[ "horizontalUnits", "verticalUnits", "keyboardIncrement", + "kerningIncrement", "baselineGrid", "grid", "pasteboard", @@ -62,7 +63,7 @@ pub fn specs() -> Vec { "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], advancedType?: {superscriptSize, superscriptPosition, subscriptSize, subscriptPosition} (%), overprintBlack?, glyphFallback? (draw characters the font lacks from fallback fonts; off: as the font's missing-glyph box)} → those settings", + "{horizontalUnits?, verticalUnits?: points|picas|inches|millimeters|…, keyboardIncrement? (pt), kerningIncrement? (1/1000 em: Alt+Left/Right in text), baselineGrid?: {start, increment, relativeTo, color, viewThreshold}, grid?: {horizontal, vertical, subdivisions, color, inBack}, pasteboard?: [h, v], marginColor?, columnColor?, bleedColor?, slugColor?: [r, g, b], advancedType?: {superscriptSize, superscriptPosition, subscriptSize, subscriptPosition} (%), overprintBlack?, glyphFallback? (draw characters the font lacks from fallback fonts; off: as the font's missing-glyph box)} → 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()))?; diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 71d390d02..e1d8eaf9b 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -224,6 +224,15 @@ pub fn specs() -> Vec { has_text_or_frames, story_direction ), + cmd!( + "type.kernStep", + "Kerning/Tracking Step", + [], + None, + "{steps?: n (default 1; negative tightens), amount?: 1/1000 em (in place of steps × the document's kerningIncrement)} — with a caret, the manual kerning between the characters at the caret (from the pair's manual value, else 0); with a selection, the tracking of the selected text", + has_text_or_frames, + kern_step + ), cmd!("type.sizeUp", "Increase Point Size", [], Some("Cmd+Shift+."), "{}", has_text_or_frames, |s, _| step_size(s, 2.0)), cmd!("type.sizeDown", "Decrease Point Size", [], Some("Cmd+Shift+,"), "{}", has_text_or_frames, |s, _| step_size(s, -2.0)), cmd!( @@ -900,6 +909,59 @@ pub(crate) fn format_paras(s: &mut Session, attrs: &Value) -> Result { }) } +/// Alt+Left/Right in text: kerning at a caret, tracking of a selection, by the Kerning/Tracking +/// increment. +fn kern_step(s: &mut Session, p: &Value) -> Result { + use designcraft_doc::Kerning; + let inc = s.doc()?.doc.settings.kerning_increment; + let inc = if inc.is_finite() { inc.clamp(1.0, 1000.0) } else { 20.0 }; + let amount = match p.get("amount").and_then(Value::as_f64) { + Some(a) => a, + None => p.get("steps").and_then(Value::as_f64).unwrap_or(1.0) * inc, + }; + if !amount.is_finite() { + return Err(bad("type.kernStep", "`amount` must be a number")); + } + let amount = amount.clamp(-11000.0, 11000.0); + // Kerning and tracking range, in 1/1000 em. + let clamp = |v: f64| v.clamp(-1000.0, 10000.0); + let targets = format_targets(s); + s.edit(|d, _| { + let styles = d.styles.clone(); + let mut out = json!({}); + for t in &targets { + let Some(st) = d.text_story_mut(t.story, t.cell) else { continue }; + if t.range.is_empty() { + // Manual kerning sits on the character before the caret (it applies after it). + let pos = t.range.start; + let Some(c) = st.text.get(..pos).and_then(|before| before.chars().next_back()) else { continue }; + let at = pos - c.len_utf8(); + let para = st.paras.get(st.para_at(at)).cloned().unwrap_or_default(); + let current = styles.resolve_char(&styles.resolve_para(¶).1, st.format_after(at)).kerning; + // The font's metrics or optical value isn't known here: those start from 0. + let v = clamp(if let Kerning::Manual(v) = current { v } else { 0.0 } + amount); + st.format_chars(at..pos, |f| f.over.kerning = Some(Kerning::Manual(v))); + out = json!({"kerning": v}); + } else { + for (i, pr) in st.para_ranges().into_iter().enumerate() { + let r = pr.start.max(t.range.start)..pr.end.min(t.range.end); + let Some(para) = st.paras.get(i).cloned() else { break }; + if r.is_empty() { + continue; + } + let pc = styles.resolve_para(¶).1; + st.format_chars(r, |f| { + let v = clamp(styles.resolve_char(&pc, f).tracking + amount); + f.over.tracking = Some(v); + out = json!({"tracking": v}); + }); + } + } + } + Ok(out) + }) +} + fn step_size(s: &mut Session, delta: f64) -> Result { let cur = selection_attrs(s, &json!({}))?; let size = cur["chars"]["size"].as_f64().unwrap_or(12.0); diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 17d1ed098..9fa5776b6 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -569,3 +569,52 @@ fn commands_take_non_object_params_without_panicking() { assert!(r.is_ok(), "{p}: {r:?}"); } } + +#[test] +fn alt_arrows_kern_at_the_caret_and_track_a_selection() { + use designcraft_doc::{Kerning, StoryId}; + use designcraft_tools::{Mods, ToolKey}; + let mut s = session(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Hello world"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.set_tool("type"); + let v = ViewInfo::at_zoom(1.0); + let alt = Mods { alt: true, ..Default::default() }; + let story = |s: &Session| s.doc().unwrap().doc.story(StoryId(sid)).unwrap().clone(); + let caret = |s: &mut Session, at: usize| s.execute("text.select", &json!({"story": sid, "anchor": at, "focus": at})).unwrap(); + // Caret between "e" and "l": Alt+Right kerns the pair by the increment; the caret stays. + caret(&mut s, 2); + assert!(s.tool_key(ToolKey::Right, alt, v).unwrap()); + assert_eq!(story(&s).format_after(1).over.kerning, Some(Kerning::Manual(20.0))); + assert_eq!(s.doc().unwrap().selection.text.unwrap().focus, 2); + s.tool_key(ToolKey::Right, alt, v).unwrap(); + assert_eq!(story(&s).format_after(1).over.kerning, Some(Kerning::Manual(40.0))); + // One undo step per press. + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(story(&s).format_after(1).over.kerning, Some(Kerning::Manual(20.0))); + // Alt+Cmd (Alt+Ctrl elsewhere) steps five increments. + caret(&mut s, 4); + s.tool_key(ToolKey::Left, Mods { cmd: true, ..alt }, v).unwrap(); + assert_eq!(story(&s).format_after(3).over.kerning, Some(Kerning::Manual(-100.0))); + // The increment is the document's Kerning/Tracking preference. + s.execute("document.preferences", &json!({"kerningIncrement": 10})).unwrap(); + caret(&mut s, 2); + s.tool_key(ToolKey::Right, alt, v).unwrap(); + assert_eq!(story(&s).format_after(1).over.kerning, Some(Kerning::Manual(30.0))); + // A selection: tracking of the selected text. + s.execute("text.select", &json!({"story": sid, "anchor": 6, "focus": 11})).unwrap(); + s.tool_key(ToolKey::Right, alt, v).unwrap(); + let st = story(&s); + assert_eq!((st.format_after(6).over.tracking, st.format_after(10).over.tracking), (Some(10.0), Some(10.0))); + assert_eq!(st.format_after(4).over.tracking, None); + s.tool_key(ToolKey::Left, Mods { cmd: true, ctrl: true, ..alt }, v).unwrap(); + assert_eq!(story(&s).format_after(8).over.tracking, Some(-40.0)); + // Words: Cmd+Right (Ctrl+Right elsewhere); Alt+Shift+Right extends by a word. + caret(&mut s, 0); + s.tool_key(ToolKey::Right, Mods { cmd: true, ..Default::default() }, v).unwrap(); + assert_eq!(s.doc().unwrap().selection.text.unwrap().focus, 5); + caret(&mut s, 0); + s.tool_key(ToolKey::Right, Mods { alt: true, shift: true, ..Default::default() }, v).unwrap(); + let t = s.doc().unwrap().selection.text.unwrap(); + assert_eq!((t.anchor, t.focus), (0, 5)); +} diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index 13a93f23a..a80d77dd3 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -126,10 +126,19 @@ impl Tool for TypeTool { if cx.selection.text.is_none() { return vec![]; } - let mv = |dir: &str| { - vec![Action::Exec("text.move".into(), json!({"dir": dir, "extend": mods.shift, "word": mods.alt || mods.ctrl, "far": mods.cmd}))] + // Words: Cmd+arrow on macOS, Ctrl+arrow elsewhere (`cmd`); Option/Alt+Shift+arrow extends + // by words too. + let mv = + |dir: &str| vec![Action::Exec("text.move".into(), json!({"dir": dir, "extend": mods.shift, "word": mods.cmd || mods.ctrl || mods.alt}))]; + // Option/Alt+Left/Right: kerning at the caret, tracking of a selection; with Cmd (Ctrl + // elsewhere), five times the increment. + let kern = |sign: f64| { + let steps = if mods.cmd || mods.ctrl { 5.0 } else { 1.0 }; + vec![Action::Exec("type.kernStep".into(), json!({"steps": sign * steps}))] }; match key { + ToolKey::Left if mods.alt && !mods.shift => kern(-1.0), + ToolKey::Right if mods.alt && !mods.shift => kern(1.0), ToolKey::Left => mv("left"), ToolKey::Right => mv("right"), ToolKey::Up => mv("up"), diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 14d2552e8..ce27e392c 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1081,6 +1081,11 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_he crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Cursor Key:")); text_field(ui, d, "keyboardIncrement", 80.0); }); + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Kerning/Tracking:")); + text_field(ui, d, "kerningIncrement", 60.0); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "/1000 em")); + }); } _ => { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "When Scaling")).font(semibold(12.0))); @@ -2063,6 +2068,9 @@ pub fn confirm(app: &mut DesignApp) -> Result { 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)); } + if let Some(v) = d.n("kerningIncrement").filter(|v| v.is_finite()) { + doc["kerningIncrement"] = json!(v.clamp(1.0, 1000.0)); + } let mut adv = json!({}); for k in ["superscriptSize", "superscriptPosition", "subscriptSize", "subscriptPosition"] { if let Some(v) = d.n(&format!("adv.{k}")) { diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index cb1422394..34c1bc951 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1025,6 +1025,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option>().join("\n")).unwrap_or_default()); let inc = doc["keyboardIncrement"].as_f64().unwrap_or(1.0); f["keyboardIncrement"] = json!(designcraft_geom::format_measure(inc, designcraft_geom::Unit::Points)); + f["kerningIncrement"] = json!(format!("{}", doc["kerningIncrement"].as_f64().unwrap_or(20.0))); let units = app.session.active().map(|d| d.doc.settings.horizontal_units).unwrap_or_default(); let m = |v: &Value| json!(designcraft_geom::format_measure(v.as_f64().unwrap_or(0.0), units)); let (bg, g) = (&doc["baselineGrid"], &doc["grid"]); diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 9095faf50..937369a58 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -270,7 +270,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - 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 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. +- Tools: Selection (click, marquee, move, Alt-duplicate, resize handles), Direct Selection, Type (draw frame, click caret, select, type; Option/Alt+Left/Right kerns at the caret or tracks a selection by the Kerning/Tracking increment, ×5 with Cmd/Ctrl; words move with Cmd+Left/Right on macOS and Ctrl+Left/Right elsewhere), 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`. File › Export PDF… now opens an "Export PDF" options dialog (General / Compression / Marks and Bleeds / Advanced) and remembers the last-used settings across exports and sessions. - IDML interchange (`designcraft-idml`): export and import of swatches, styles, fonts, preferences, parent spreads, spreads/pages, frames, groups, images (embedded or linked), formatted threaded stories — opens in InDesign 2026 and round-trips InDesign-exported files (`file.exportIdml`, `file.openIdml`, File → Export IDML…, CLI `--in x.idml` / `--export x.idml`). - Tables (M8): tables anchored in stories (header/footer/body rows, merged cells, per-edge strokes, fills + alternating fills, insets, vertical justification, at-least/exact row heights); composed into the text column (columns scale to fit, rows break across columns/frames with repeating headers/footers, overset); rendered, exported to PDF (real text) and IDML (export + import); Table menu, Table panel, Create Table dialog, caret/typing/Tab navigation and cell selection in cells; `table.*` commands.