Type: Alt+Left/Right kerns at the caret and tracks a selection

With the Type tool, Option/Alt+Left/Right with a caret changes the manual kerning of the pair at the caret by the Kerning/Tracking increment (a new document preference, 20/1000 em by default, in Preferences > Units & Increments); with a selection it changes the tracking of the selected text. Cmd (Ctrl on Windows and Linux) multiplies the step by five. A pair without manual kerning starts from 0. The new type.kernStep command does the work, one undo step per press.

Moving by words is Cmd+Left/Right on macOS and Ctrl+Left/Right on Windows and Linux; Option/Alt+Shift+Left/Right still extends the selection by words. Fixes #621.
This commit is contained in:
aiongg
2026-10-12 20:48:14 +00:00
parent 17cf5b4e91
commit 86a0ce2cf7
9 changed files with 138 additions and 4 deletions
+1
View File
@@ -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.
+3
View File
@@ -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<NumberedList>,
@@ -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,
+2 -1
View File
@@ -11,6 +11,7 @@ const DOC_KEYS: &[&str] = &[
"horizontalUnits",
"verticalUnits",
"keyboardIncrement",
"kerningIncrement",
"baselineGrid",
"grid",
"pasteboard",
@@ -62,7 +63,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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()))?;
+62
View File
@@ -224,6 +224,15 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
})
}
/// 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<Value> {
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(&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(&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<Value> {
let cur = selection_attrs(s, &json!({}))?;
let size = cur["chars"]["size"].as_f64().unwrap_or(12.0);
+49
View File
@@ -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));
}
+11 -2
View File
@@ -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"),
+8
View File
@@ -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<Value, String> {
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}")) {
+1
View File
@@ -1025,6 +1025,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option<Result<Value,
json!(words.as_array().map(|a| a.iter().filter_map(Value::as_str).collect::<Vec<_>>().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"]);
+1 -1
View File
@@ -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.