mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
fix(terminal): shape Thai SARA AM with the base it belongs to
SARA AM (ำ U+0E33) is `Lo` and width 1, so the grid gives it its own column — but it is not atomic to the shaper. The Thai shaper decomposes it into NIKHAHIT + SARA AA and moves the nikhahit backwards over any above-base marks onto the base consonant. Shaped in a run of its own it has no base to reorder onto, so `น้ำ` came out as `น้` plus a dotted circle, losing the vowel entirely. Absorb a following SARA AM into the preceding cell's cluster, so base, tone mark and SARA AM reach `shape_line` in one string. Lao SARA AM (U+0EB3) takes the same shaper path and is handled with it. That makes `cells == 2` ambiguous, so `Cluster` now records why: a wide base is one glyph spanning two columns and pins at `2 × cell_width`, while an absorbed SARA AM is two base glyphs of one column each and pins like a `Run`. `apply_force_width_to_layout` classifies glyphs by advance rather than by count, so the marks ride their base under either pinning. Two deliberate limits, both pinned by tests: A SARA AM is not a base for another one. Absorbing there would pin the second one's glyphs past the cluster's two-cell clip and swallow it, so `ำำ` stays two `Solo`s and both remain visible. A SARA AM with nothing before it likewise paints alone — a dotted circle is the shaper's honest answer for an orphaned mark, and inventing a base would be worse. An absorbed SARA AM takes its base's style rather than its own, so a colour change mid-syllable (`grep --color` landing between a consonant and its vowel) recolours the vowel. Unlike `Run` and `Wide`, the cluster cannot break on a style change: split off, the vowel renders as a dotted circle. A recoloured vowel beats a broken one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0030b98faa
commit
a7835a0b8c
+149
-15
@@ -490,18 +490,51 @@ enum RowSeg {
|
|||||||
/// drawing, accented Latin, …) that may route to a fallback face whose
|
/// drawing, accented Latin, …) that may route to a fallback face whose
|
||||||
/// advance isn't the cell width.
|
/// advance isn't the cell width.
|
||||||
Solo { col: usize },
|
Solo { col: usize },
|
||||||
/// A base plus the combining marks stacked on it, shaped as one string so
|
/// A base with everything that has to shape alongside it — the combining
|
||||||
/// the marks reach the shaper. Never batched with neighbours: the marks add
|
/// marks stacked on it, and a following SARA AM — as one string, so the
|
||||||
|
/// shaper sees the whole cluster. Never batched with neighbours: marks add
|
||||||
/// characters without adding columns, which is exactly the correspondence
|
/// characters without adding columns, which is exactly the correspondence
|
||||||
/// `force_width` relies on in a [`RowSeg::Run`] or [`RowSeg::Wide`].
|
/// `force_width` relies on in a [`RowSeg::Run`] or [`RowSeg::Wide`].
|
||||||
|
///
|
||||||
|
/// An absorbed SARA AM takes the base's style rather than its own. Unlike
|
||||||
|
/// a [`RowSeg::Run`], the cluster can't break on a style change: split off,
|
||||||
|
/// SARA AM has no base to reorder its nikhahit onto and renders as a dotted
|
||||||
|
/// circle. A recoloured vowel beats a broken one.
|
||||||
Cluster {
|
Cluster {
|
||||||
col: usize,
|
col: usize,
|
||||||
/// Columns the base occupies — 2 once the grid marked it wide.
|
/// Columns the whole cluster occupies — 2 for a wide base, or for a
|
||||||
|
/// narrow base that absorbed a following SARA AM.
|
||||||
cells: usize,
|
cells: usize,
|
||||||
text: String,
|
text: String,
|
||||||
|
/// Whether `cells == 2` because the *base* is wide, rather than because
|
||||||
|
/// a spacing character joined it. The two need opposite pinning: a wide
|
||||||
|
/// base is one glyph across two columns, an absorbed SARA AM is two
|
||||||
|
/// glyphs of one column each.
|
||||||
|
wide_base: bool,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Append a cell's character followed by any combining marks riding on it.
|
||||||
|
fn push_cell(text: &mut String, cell: &RenderCell) {
|
||||||
|
text.push(cell.c);
|
||||||
|
text.extend(cell.marks.iter().flat_map(|marks| marks.iter()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SARA AM (Thai U+0E33, Lao U+0EB3) is `Lo` and owns a column, but it is not
|
||||||
|
/// atomic to the shaper: the Thai shaper decomposes it into NIKHAHIT + SARA AA
|
||||||
|
/// and moves the nikhahit backwards over any above-base marks onto the base
|
||||||
|
/// consonant. Shaped in a run of its own it has no base to reorder onto, and
|
||||||
|
/// comes out as a dotted circle.
|
||||||
|
fn is_sara_am(c: char) -> bool {
|
||||||
|
matches!(c, '\u{0E33}' | '\u{0EB3}')
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Does `col` hold a SARA AM that should join the preceding cell's cluster?
|
||||||
|
fn sara_am_at(row: &[RenderCell], col: usize) -> Option<&RenderCell> {
|
||||||
|
row.get(col)
|
||||||
|
.filter(|cell| !cell.spacer && is_sara_am(cell.c))
|
||||||
|
}
|
||||||
|
|
||||||
/// Split one grid row into paintable segments.
|
/// Split one grid row into paintable segments.
|
||||||
///
|
///
|
||||||
/// ASCII-graphic cells batch into [`RowSeg::Run`]s: they always come from the
|
/// ASCII-graphic cells batch into [`RowSeg::Run`]s: they always come from the
|
||||||
@@ -530,15 +563,26 @@ fn segment_row(row: &[RenderCell]) -> Vec<RowSeg> {
|
|||||||
// Combining marks come first: they can sit on an ASCII base too, and
|
// Combining marks come first: they can sit on an ASCII base too, and
|
||||||
// either way the whole cluster has to reach the shaper in one string.
|
// either way the whole cluster has to reach the shaper in one string.
|
||||||
if let Some(marks) = &cell.marks {
|
if let Some(marks) = &cell.marks {
|
||||||
let cells = if col + 1 < row.len() && row[col + 1].spacer {
|
let wide_base = col + 1 < row.len() && row[col + 1].spacer;
|
||||||
2
|
let mut cells = if wide_base { 2 } else { 1 };
|
||||||
} else {
|
|
||||||
1
|
|
||||||
};
|
|
||||||
let mut text = String::with_capacity(1 + marks.len());
|
let mut text = String::with_capacity(1 + marks.len());
|
||||||
text.push(cell.c);
|
push_cell(&mut text, cell);
|
||||||
text.extend(marks.iter());
|
// A wide base already owns both columns, so only a narrow one has a
|
||||||
segs.push(RowSeg::Cluster { col, cells, text });
|
// column spare for SARA AM to join it in. A SARA AM is not itself a
|
||||||
|
// base to absorb onto — two in a row stay separate.
|
||||||
|
if !wide_base
|
||||||
|
&& !is_sara_am(cell.c)
|
||||||
|
&& let Some(am) = sara_am_at(row, col + 1)
|
||||||
|
{
|
||||||
|
push_cell(&mut text, am);
|
||||||
|
cells = 2;
|
||||||
|
}
|
||||||
|
segs.push(RowSeg::Cluster {
|
||||||
|
col,
|
||||||
|
cells,
|
||||||
|
text,
|
||||||
|
wide_base,
|
||||||
|
});
|
||||||
col += cells;
|
col += cells;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -569,6 +613,23 @@ fn segment_row(row: &[RenderCell]) -> Vec<RowSeg> {
|
|||||||
cells: col - start,
|
cells: col - start,
|
||||||
text,
|
text,
|
||||||
});
|
});
|
||||||
|
} else if !is_sara_am(cell.c)
|
||||||
|
&& let Some(am) = sara_am_at(row, col + 1)
|
||||||
|
{
|
||||||
|
// An unmarked base still has to shape with its SARA AM. A
|
||||||
|
// baseless SARA AM is not a base for the next one: absorbing
|
||||||
|
// there would pin the second one's glyphs outside the cluster's
|
||||||
|
// clip, so two in a row stay separate and both stay visible.
|
||||||
|
let mut text = String::with_capacity(2);
|
||||||
|
push_cell(&mut text, cell);
|
||||||
|
push_cell(&mut text, am);
|
||||||
|
segs.push(RowSeg::Cluster {
|
||||||
|
col,
|
||||||
|
cells: 2,
|
||||||
|
text,
|
||||||
|
wide_base: false,
|
||||||
|
});
|
||||||
|
col += 2;
|
||||||
} else {
|
} else {
|
||||||
segs.push(RowSeg::Solo { col });
|
segs.push(RowSeg::Solo { col });
|
||||||
col += 1;
|
col += 1;
|
||||||
@@ -848,11 +909,22 @@ fn paint_glyphs(
|
|||||||
// Same pinning as the batched runs, just for one base: two
|
// Same pinning as the batched runs, just for one base: two
|
||||||
// columns get `force_width` so a fallback emoji face can't
|
// columns get `force_width` so a fallback emoji face can't
|
||||||
// drift, one column paints at the origin like `Solo`.
|
// drift, one column paints at the origin like `Solo`.
|
||||||
RowSeg::Cluster { col, cells, text } => (
|
// Two columns pin per *base glyph*, and which that is depends
|
||||||
|
// on why the cluster is two cells wide: a wide base is one
|
||||||
|
// glyph spanning both, an absorbed SARA AM is two glyphs of one
|
||||||
|
// column each. `force_width` classifies by advance, so the
|
||||||
|
// marks ride their base under either. One column paints at the
|
||||||
|
// origin like `Solo`.
|
||||||
|
RowSeg::Cluster {
|
||||||
|
col,
|
||||||
|
cells,
|
||||||
|
text,
|
||||||
|
wide_base,
|
||||||
|
} => (
|
||||||
col,
|
col,
|
||||||
cells,
|
cells,
|
||||||
SharedString::from(text),
|
SharedString::from(text),
|
||||||
(cells == 2).then(|| geom.cell_width * 2.),
|
(cells == 2).then(|| geom.cell_width * if wide_base { 2. } else { 1. }),
|
||||||
cells == 1,
|
cells == 1,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
@@ -2127,6 +2199,16 @@ mod tests {
|
|||||||
col,
|
col,
|
||||||
cells,
|
cells,
|
||||||
text: text.to_string(),
|
text: text.to_string(),
|
||||||
|
wide_base: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wide_cluster(col: usize, cells: usize, text: &str) -> RowSeg {
|
||||||
|
RowSeg::Cluster {
|
||||||
|
col,
|
||||||
|
cells,
|
||||||
|
text: text.to_string(),
|
||||||
|
wide_base: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2145,7 +2227,7 @@ mod tests {
|
|||||||
// spacer too (❤ + U+FE0F).
|
// spacer too (❤ + U+FE0F).
|
||||||
let mut row = wide_cells("\u{2764}");
|
let mut row = wide_cells("\u{2764}");
|
||||||
row[0].marks = Some(Box::from(['\u{FE0F}']));
|
row[0].marks = Some(Box::from(['\u{FE0F}']));
|
||||||
assert_eq!(segment_row(&row), [cluster(0, 2, "\u{2764}\u{FE0F}")]);
|
assert_eq!(segment_row(&row), [wide_cluster(0, 2, "\u{2764}\u{FE0F}")]);
|
||||||
|
|
||||||
// Several marks on one base: an above-base vowel and a tone mark both
|
// Several marks on one base: an above-base vowel and a tone mark both
|
||||||
// sit on the consonant (ที่ = ท U+0E17 + ◌ี U+0E35 + ◌่ U+0E48).
|
// sit on the consonant (ที่ = ท U+0E17 + ◌ี U+0E35 + ◌่ U+0E48).
|
||||||
@@ -2157,6 +2239,58 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// SARA AM (U+0E33) is the awkward Thai vowel: `Lo`, width 1, so the grid
|
||||||
|
/// gives it its own column — but the shaper decomposes it into NIKHAHIT +
|
||||||
|
/// SARA AA and reorders the nikhahit backwards onto the base consonant.
|
||||||
|
/// Shaped in its own run it has no base to reorder onto and comes out as a
|
||||||
|
/// dotted circle, so it has to join the preceding cell's cluster.
|
||||||
|
#[test]
|
||||||
|
fn segment_row_absorbs_sara_am_into_its_base() {
|
||||||
|
// น + ้ (tone) + ำ — the base already carries a mark.
|
||||||
|
let mut row = vec![cell('\u{0E19}'), cell('\u{0E33}'), cell('a')];
|
||||||
|
row[0].marks = Some(Box::from(['\u{0E49}']));
|
||||||
|
assert_eq!(
|
||||||
|
segment_row(&row),
|
||||||
|
[cluster(0, 2, "\u{0E19}\u{0E49}\u{0E33}"), run(2, 1, "a")]
|
||||||
|
);
|
||||||
|
|
||||||
|
// ก + ำ — an unmarked base still has to shape with it.
|
||||||
|
let row = vec![cell('\u{0E01}'), cell('\u{0E33}')];
|
||||||
|
assert_eq!(segment_row(&row), [cluster(0, 2, "\u{0E01}\u{0E33}")]);
|
||||||
|
|
||||||
|
// Lao SARA AM (U+0EB3) takes the same shaper path.
|
||||||
|
let row = vec![cell('\u{0E81}'), cell('\u{0EB3}')];
|
||||||
|
assert_eq!(segment_row(&row), [cluster(0, 2, "\u{0E81}\u{0EB3}")]);
|
||||||
|
|
||||||
|
// A style change does not break the cluster, unlike a `Run` or `Wide`
|
||||||
|
// batch: split off, the vowel has no base and paints a dotted circle,
|
||||||
|
// so it takes the base's style instead.
|
||||||
|
let mut row = vec![cell('\u{0E01}'), cell('\u{0E33}')];
|
||||||
|
row[1].fg = gpui::red();
|
||||||
|
assert_eq!(segment_row(&row), [cluster(0, 2, "\u{0E01}\u{0E33}")]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// With nothing to attach to, SARA AM paints alone — a dotted circle is the
|
||||||
|
/// shaper's honest answer for an orphaned mark, and inventing a base would
|
||||||
|
/// be worse.
|
||||||
|
#[test]
|
||||||
|
fn segment_row_leaves_a_baseless_sara_am_alone() {
|
||||||
|
let row = vec![cell('\u{0E33}'), cell('a')];
|
||||||
|
assert_eq!(segment_row(&row), [RowSeg::Solo { col: 0 }, run(1, 1, "a")]);
|
||||||
|
|
||||||
|
// A blank before it is not a base either.
|
||||||
|
let row = vec![cell(' '), cell('\u{0E33}')];
|
||||||
|
assert_eq!(segment_row(&row), [RowSeg::Solo { col: 1 }]);
|
||||||
|
|
||||||
|
// Nor is another SARA AM: absorbing would pin the second one's glyphs
|
||||||
|
// past the cluster's two-cell clip and swallow it entirely.
|
||||||
|
let row = vec![cell('\u{0E33}'), cell('\u{0E33}')];
|
||||||
|
assert_eq!(
|
||||||
|
segment_row(&row),
|
||||||
|
[RowSeg::Solo { col: 0 }, RowSeg::Solo { col: 1 }]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// A marked cell never joins a batch: marks add characters without adding
|
/// A marked cell never joins a batch: marks add characters without adding
|
||||||
/// columns, which would desync `force_width`'s glyph-per-column pinning.
|
/// columns, which would desync `force_width`'s glyph-per-column pinning.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -2176,7 +2310,7 @@ mod tests {
|
|||||||
segment_row(&row),
|
segment_row(&row),
|
||||||
[
|
[
|
||||||
wide(0, 2, "你"),
|
wide(0, 2, "你"),
|
||||||
cluster(2, 2, "好\u{FE0F}"),
|
wide_cluster(2, 2, "好\u{FE0F}"),
|
||||||
wide(4, 2, "世"),
|
wide(4, 2, "世"),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user