Set a text selection as warichu inside the parent line (#280)

* Set warichu as smaller lines inside the parent line.

Type › Warichu stacks the selected run after the line is broken, closes up the rest of the line, and round-trips the IDML warichu properties. The line break itself still sees the full-size run.

* Keep clicks, the caret, and Up/Down on each warichu row.

The glyphs were already stacked inside the parent line, but hit testing, the caret, and vertical movement still used that parent line. Break minimums, row widths, spacing, and tab leaders now follow the small lines, and underlines sit on the small baseline.

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Vikram Bhaskaran
2026-10-10 03:47:14 -04:00
committed by GitHub
co-authored by Brandon Thomas Claude Opus 5.5
parent 74a81f9fb0
commit d0d4812ada
19 changed files with 935 additions and 43 deletions
+104 -2
View File
@@ -206,6 +206,15 @@ pub fn specs() -> Vec<CommandSpec> {
format_chars(s, &json!({"kenten": on}))
}
),
cmd!(
"type.warichu",
"Warichu",
[],
None,
"{on?: bool, lines?: int, size?: number, lineSpacing?: number, align?: auto|left|center|right|justify, charsBeforeBreak?: int, charsAfterBreak?: int} — set the selection as an inline note in smaller lines inside one line height (toggles by default)",
has_text_or_frames,
warichu_cmd
),
cmd!(
"type.storyDirection",
"Story Direction",
@@ -714,9 +723,18 @@ fn line_of(cs: &compose::ComposedStory, pos: usize) -> Option<(usize, usize)> {
}
fn vertical(cs: &compose::ComposedStory, pos: usize, up: bool) -> Option<usize> {
let (fi, x, baseline, _, _) = compose::caret(cs, pos)?;
let (fi, x, caret_y, _, _) = compose::caret(cs, pos)?;
let lines: Vec<(usize, &compose::Line)> = cs.frames.iter().enumerate().flat_map(|(i, f)| f.lines.iter().map(move |l| (i, l))).collect();
let cur = lines.iter().position(|(i, l)| *i == fi && (l.baseline - baseline).abs() < 0.01 && pos >= l.range.start && pos <= l.range.end)?;
// Match the line by the bytes it owns. A warichu caret sits on a small-line baseline, so it
// does not equal the parent line's baseline.
let cur = lines.iter().position(|(i, l)| *i == fi && pos >= l.range.start && pos <= l.range.end && (pos < l.range.end || l.last_in_para))?;
let (_, l) = lines.get(cur)?;
if let Some(y) = compose::adjacent_row(l, x, caret_y, up)
&& let Some(p) = compose::hit(cs, fi, Point::new(x, y))
&& p != pos
{
return Some(p);
}
let target = if up { cur.checked_sub(1)? } else { cur + 1 };
let (tf, tl) = lines.get(target)?;
compose::hit(cs, *tf, Point::new(x, tl.baseline - 1.0))
@@ -764,6 +782,43 @@ pub(crate) fn format_targets(s: &Session) -> Vec<Target> {
v
}
fn warichu_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let has_opts = ["lines", "size", "lineSpacing", "align", "charsBeforeBreak", "charsAfterBreak"].iter().any(|k| p.get(k).is_some());
let on = match p.get("on").and_then(Value::as_bool) {
Some(v) => v,
None if has_opts => true,
None => !format_targets(s).first().is_some_and(|t| {
s.doc()
.ok()
.and_then(|d| d.doc.text_story(t.story, t.cell))
.is_some_and(|st| st.char_format_at(if t.range.is_empty() { t.range.start } else { t.range.start + 1 }).over.warichu == Some(true))
}),
};
let mut body = serde_json::Map::new();
body.insert("warichu".into(), json!(on));
if on {
if let Some(v) = p.get("lines").and_then(Value::as_u64) {
body.insert("warichuLines".into(), json!(v.clamp(2, 16)));
}
if let Some(v) = p.get("size").and_then(Value::as_f64).filter(|v| v.is_finite()) {
body.insert("warichuSize".into(), json!(v.clamp(1.0, 1000.0)));
}
if let Some(v) = p.get("lineSpacing").and_then(Value::as_f64).filter(|v| v.is_finite()) {
body.insert("warichuLineSpacing".into(), json!(v.clamp(-10_000.0, 10_000.0)));
}
if let Some(v) = p.get("align").and_then(Value::as_str) {
body.insert("warichuAlignment".into(), json!(v));
}
if let Some(v) = p.get("charsBeforeBreak").and_then(Value::as_u64) {
body.insert("warichuCharsBeforeBreak".into(), json!(v.min(100)));
}
if let Some(v) = p.get("charsAfterBreak").and_then(Value::as_u64) {
body.insert("warichuCharsAfterBreak".into(), json!(v.min(100)));
}
}
format_chars(s, &Value::Object(body))
}
pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result<Value> {
let mut a = CharAttrs::default();
if let Some(o) = attrs.as_object() {
@@ -1564,6 +1619,53 @@ mod tcy_tests {
assert_eq!(f(&s).kenten, Some(false));
assert_eq!(f(&s).ruby.as_deref(), Some(""));
}
#[test]
fn warichu_toggles_on_the_selection_and_undoes() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 200], "content": "text", "text": "注釈です"})).unwrap();
let sid = r["story"].as_u64().unwrap();
let n = "注釈".len();
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": n})).unwrap();
s.execute("type.warichu", &json!({"lines": 2, "size": 40, "align": "left", "lineSpacing": 1, "charsBeforeBreak": 2, "charsAfterBreak": 2}))
.unwrap();
let f = |s: &Session| s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].char_format_at(1).over.clone();
let a = f(&s);
assert_eq!(a.warichu, Some(true));
assert_eq!(a.warichu_lines, Some(2));
assert_eq!(a.warichu_size, Some(40.0));
assert_eq!(a.warichu_line_spacing, Some(1.0));
assert_eq!(a.warichu_alignment, Some(designcraft_doc::cjk::WarichuAlignment::Left));
assert_eq!(a.warichu_chars_before_break, Some(2));
assert_eq!(a.warichu_chars_after_break, Some(2));
// The characters after the selection are not part of the note.
assert_eq!(s.doc().unwrap().doc.stories[&designcraft_doc::StoryId(sid)].char_format_at(n + 1).over.warichu, None);
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(f(&s).warichu, None);
s.execute("edit.redo", &json!({})).unwrap();
assert_eq!(f(&s).warichu, Some(true));
s.execute("type.warichu", &json!({})).unwrap();
assert_eq!(f(&s).warichu, Some(false));
}
#[test]
fn warichu_up_and_down_move_between_the_rows() {
let mut s = Session::new();
s.execute("file.new", &json!({})).unwrap();
let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "ABCDEFGHZ"})).unwrap();
let sid = r["story"].as_u64().unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 8})).unwrap();
s.execute("type.warichu", &json!({"lines": 2, "align": "left"})).unwrap();
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap();
let down = s.execute("text.move", &json!({"dir": "down"})).unwrap()["pos"].as_u64().unwrap() as usize;
assert!((4..8).contains(&down), "down enters the second row: {down}");
let up = s.execute("text.move", &json!({"dir": "up"})).unwrap()["pos"].as_u64().unwrap() as usize;
assert!(up < 4, "up returns to the first row: {up}");
s.execute("text.select", &json!({"story": sid, "anchor": 8, "focus": 8})).unwrap();
let stayed = s.execute("text.move", &json!({"dir": "down"})).unwrap()["pos"].as_u64().unwrap() as usize;
assert_eq!(stayed, 8, "down from the character after the note does not fall back into a row");
}
}
#[cfg(test)]