diff --git a/ROADMAP.md b/ROADMAP.md index d60a6bf49..31bdd97f8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -347,6 +347,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-15** · A kerning value chosen in the Character panel or Properties at a caret kerns the pair at the caret (the character before it takes the value, in one undo step), and the field shows that pair's kerning; at the start of a story or paragraph it is kept for the next typing, and a selection is kerned as before (`type.kernPair`). No readiness percentage change. - **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change. - **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change. - **2026-10-14** · Pathfinder keeps the frontmost object, as InDesign does: a photo placed over shapes and intersected with them shows through the shapes (it was dropped, and the shapes kept their black fill); Add, Exclude Overlap and Minus Back take the front object's fill too, Subtract still keeps the back one. No readiness percentage change. diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 432297f35..64142cdd9 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -155,6 +155,15 @@ pub fn specs() -> Vec { has_text_or_frames, |s, p| format_chars(s, p.get("attrs").unwrap_or(p)) ), + cmd!( + "type.kernPair", + "Kerning", + [], + None, + "{kerning: {kind: metrics|optical|none}|{kind: manual, value: 1/1000 em}|null} — at a caret in text, the pair at the caret (the character before the caret takes it); at the start of a story or paragraph, the next typing; a selection or frames take it like `type.char`", + has_text_or_frames, + kern_pair + ), cmd!( "type.para", "Paragraph Formatting", @@ -1122,6 +1131,44 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result { }) } +/// Kerning spaces a character from the next one, so the pair at a caret is kerned through the +/// character before it. With no such pair (story or paragraph start, a selection) this is +/// `type.char`. +fn kern_pair(s: &mut Session, p: &Value) -> Result { + const ID: &str = "type.kernPair"; + let kerning = p.get("kerning").ok_or_else(|| bad(ID, "missing `kerning`"))?; + let targets = format_targets(s); + if let [t] = targets.as_slice() + && t.range.is_empty() + && s.doc()?.selection.text.is_some() + && let Some(start) = s.doc()?.doc.text_story(t.story, t.cell).and_then(|st| pair_start(&st.text, t.range.start)) + { + let mut a = CharAttrs::default(); + a.set_json("kerning", kerning).map_err(|e| bad(ID, e))?; + let cleared = if kerning.is_null() { vec!["kerning".to_string()] } else { vec![] }; + let pair = Target { story: t.story, cell: t.cell, range: start..t.range.start }; + // Kerning chosen earlier for typing at this caret would hide the pair's value. + if typing_format(s).is_some() + && let Some(f) = &mut s.typing_format + { + f.attrs.kerning = None; + f.cleared.retain(|k| k != "kerning"); + } + return s.edit(|d, _| { + apply_char_attrs(d, &[pair], &a, &cleared); + ok() + }); + } + format_chars(s, &json!({ "kerning": kerning })) +} + +/// Byte offset of the character before `pos` when it starts a kerning pair with the text at `pos` +/// (not a paragraph break). +fn pair_start(text: &str, pos: usize) -> Option { + let c = text.get(..pos)?.chars().next_back().filter(|c| *c != '\n')?; + pos.checked_sub(c.len_utf8()) +} + /// Character attributes `a` (and the overrides `cleared` removed) over each target's text. pub(crate) fn apply_char_attrs(d: &mut designcraft_doc::Document, targets: &[Target], a: &CharAttrs, cleared: &[String]) { for t in targets { @@ -3045,3 +3092,55 @@ mod placeholder_tests { assert!(text.len() > 1500, "the frame is filled: {}", text.len()); } } + +#[cfg(test)] +mod kern_pair_tests { + use designcraft_doc::{Kerning, StoryId}; + use serde_json::json; + + use crate::Session; + + fn avatar() -> (Session, StoryId) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "AVATAR"})).unwrap(); + (s, StoryId(r["story"].as_u64().unwrap())) + } + + fn kern_at(s: &Session, story: StoryId, pos: usize) -> Option { + s.doc().unwrap().doc.story(story).unwrap().format_after(pos).over.kerning + } + + #[test] + fn kerning_at_a_caret_kerns_the_pair_in_one_undo_step() { + let (mut s, sid) = avatar(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 1, "focus": 1})).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + s.execute("type.kernPair", &json!({"kerning": {"kind": "manual", "value": -80}})).unwrap(); + assert_eq!(kern_at(&s, sid, 0), Some(Kerning::Manual(-80.0)), "A, before the caret, spaces the A|V pair"); + assert_eq!(kern_at(&s, sid, 1), None); + assert_eq!(s.doc().unwrap().history.undo.len(), steps + 1); + let shown = s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["kerning"].clone(); + assert_eq!(shown, json!({"kind": "manual", "value": -80.0}), "the caret shows the pair's kerning"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(kern_at(&s, sid, 0), None); + } + + #[test] + fn kerning_a_selection_or_at_the_story_start_works_like_type_char() { + let (mut s, sid) = avatar(); + s.execute("text.select", &json!({"story": sid.0, "anchor": 2, "focus": 4})).unwrap(); + s.execute("type.kernPair", &json!({"kerning": {"kind": "manual", "value": 20}})).unwrap(); + let kerned: Vec<_> = (0..6).map(|i| kern_at(&s, sid, i)).collect(); + let m = Some(Kerning::Manual(20.0)); + assert_eq!(kerned, [None, None, m, m, None, None]); + // No character before the caret: the value is for the next typing. + s.execute("text.select", &json!({"story": sid.0, "anchor": 0, "focus": 0})).unwrap(); + let steps = s.doc().unwrap().history.undo.len(); + s.execute("type.kernPair", &json!({"kerning": {"kind": "none"}})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), steps); + assert_eq!(kern_at(&s, sid, 0), None); + s.execute("text.insert", &json!({"text": "X"})).unwrap(); + assert_eq!(kern_at(&s, sid, 0), Some(Kerning::None)); + } +} diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 52ce7e688..49db7c131 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1283,8 +1283,9 @@ fn language_menu(app: &mut DesignApp, ui: &mut Ui, cur: &str) { } } -/// Kerning for the text selection: the label shows Metrics, Optical, 0 or the manual value; its -/// menu sets one of the first three or a manual value in 1/1000 em (typed or stepped). +/// Kerning for the text selection, or at a caret for the pair at the caret: the label shows +/// Metrics, Optical, 0 or the manual value; its menu sets one of the first three or a manual value +/// in 1/1000 em (typed or stepped). fn kerning_control(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, k: &Value, width: f32) { let kind = k["kind"].as_str().unwrap_or("metrics"); let manual = k["value"].as_f64().filter(|v| kind == "manual" && v.is_finite()); @@ -1300,16 +1301,16 @@ fn kerning_control(app: &mut DesignApp, ui: &mut egui::Ui, id: &str, k: &Value, egui::Popup::menu(&resp).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { for (text, v) in [("Metrics", json!({"kind": "metrics"})), ("Optical", json!({"kind": "optical"})), ("0", json!({"kind": "none"}))] { if ui.selectable_label(label == text, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, text))).clicked() { - let _ = app.run("type.char", json!({"attrs": {"kerning": v}})); + let _ = app.run("type.kernPair", json!({"kerning": v})); ui.close(); } } ui.separator(); - // A manual value: between the selected character and the next, in 1/1000 em. + // A manual value in 1/1000 em: after each selected character, or between the two at a caret. let field = format!("{id}_manual"); if let Some(v) = NumField::number(&field, Some(manual.unwrap_or(0.0)), "", 0).width(90.0).spinner().step(5.0).range(-1000.0, 10000.0).show(ui) { - let _ = app.run("type.char", json!({"attrs": {"kerning": {"kind": "manual", "value": v}}})); + let _ = app.run("type.kernPair", json!({"kerning": {"kind": "manual", "value": v}})); } }); } @@ -2668,6 +2669,26 @@ mod tests { assert_eq!(kerning(h.state_mut()), json!({"kind": "manual", "value": -40.0})); } + #[test] + fn a_manual_kerning_value_at_a_caret_kerns_the_pair() { + let mut app = text_app(); + let story = app.session.active().unwrap().selection.text.unwrap().story; + app.run("text.select", json!({"story": story.0, "anchor": 1, "focus": 1})).unwrap(); + let mut h = panel_harness(app, character_panel); + h.run_steps(3); + h.get_by_role_and_label(egui::accesskit::Role::ComboBox, "Kerning").click(); + h.run_steps(2); + let field = h.get_all_by_role(egui::accesskit::Role::TextInput).last().unwrap(); + field.focus(); + field.type_text("-80"); + h.run_steps(1); + h.key_press(egui::Key::Enter); + h.run_steps(3); + let st = h.state_mut().session.active().unwrap().doc.story(story).unwrap().format_after(0).over.kerning; + assert_eq!(st, Some(designcraft_doc::Kerning::Manual(-80.0)), "A of the A|V pair"); + assert_eq!(kerning(h.state_mut()), json!({"kind": "manual", "value": -80.0})); + } + #[test] fn clicking_a_swatch_chip_in_the_appearance_fill_menu_applies_it() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());