fix(type): kerning at a caret kerns the pair at the caret

This commit is contained in:
aiongg
2026-10-15 06:15:08 +00:00
parent a930276fd7
commit 34a87ff42d
3 changed files with 126 additions and 5 deletions
+1
View File
@@ -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.
+99
View File
@@ -155,6 +155,15 @@ pub fn specs() -> Vec<CommandSpec> {
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<Value> {
})
}
/// 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<Value> {
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<usize> {
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<Kerning> {
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));
}
}
+26 -5
View File
@@ -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());