mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-02 06:36:51 -06:00
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:
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()))?;
|
||||
|
||||
@@ -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(¶).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<Value> {
|
||||
let cur = selection_attrs(s, &json!({}))?;
|
||||
let size = cur["chars"]["size"].as_f64().unwrap_or(12.0);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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}")) {
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user