mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-29 20:56:47 -06:00
fix(type): kerning at a caret kerns the pair at the caret
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user