Type: a selection in a warichu block highlights its own block line

A selection inside a warichu block was highlighted across the whole
line of text, so selecting a character of the upper block line
highlighted the lower one too, and a selection from one block line to
the next spanned the wrong characters. Each block line now gets its own
band, out to halfway to the block lines next to it, with one highlight
over each run of selected characters drawn side by side on it. A
selection that runs past the line still covers the line break.

Glyphs now carry their block line, so the characters of one block line
stay together for the highlight and for a click whatever their own
Character Alignment.
This commit is contained in:
dotCooCoo
2026-11-12 23:47:30 -08:00
parent 06a283232e
commit f278364816
6 changed files with 218 additions and 24 deletions
+1
View File
@@ -622,6 +622,7 @@ impl Ctx<'_> {
width,
width_start,
warichu: g.wari.map(|w| w.start),
warichu_line: g.wari.map_or(0.0, |w| w.across),
});
}
+1
View File
@@ -378,5 +378,6 @@ fn ruby_glyph(g: &PositionedGlyph, m: Affine, run: usize, byte: usize, line: usi
width: 0.0,
width_start: 0.0,
warichu: None,
warichu_line: 0.0,
}
}
+129 -24
View File
@@ -184,6 +184,10 @@ pub struct PositionedGlyph {
/// block. The block's lines share its line of text, so hit testing takes the block line nearest
/// the point before the nearest caret stop along it. `None` for any other glyph.
pub warichu: Option<usize>,
/// A glyph of a warichu block: the baseline of its block line across the line, from the
/// line's baseline before Character Alignment moves the glyph (y down). It names the block
/// line whatever the alignment of each character on it. 0 for any other glyph.
pub warichu_line: f64,
}
/// How a glyph on a path is bent along it: a point of its pen space (x along its advance from its
@@ -664,22 +668,25 @@ fn byte_near(layout: &TextLayout, li: usize, x: f64, y: Option<f64>) -> usize {
_ => clusters.push((left, right, g)),
}
}
// Each warichu block's distance from `y` to the nearest baseline of its lines.
let mut rows: Vec<(usize, f64)> = Vec::new();
// Each warichu block's line whose baseline is nearest `y`: the block, the block line
// ([`PositionedGlyph::warichu_line`]) and its distance, sorted by block.
let mut rows: Vec<(usize, f64, f64)> = Vec::new();
if let Some(y) = y {
for g in glyphs {
let Some(block) = g.warichu else { continue };
let d = (layout.logical_point(g.origin).y - y).abs();
match rows.iter_mut().find(|(b, _)| *b == block) {
Some((_, best)) => *best = best.min(d),
None => rows.push((block, d)),
for (block, row, baseline) in warichu_rows(layout, line) {
let d = (baseline - y).abs();
match rows.last_mut() {
Some((b, best_row, best)) if *b == block => {
if d < *best {
(*best_row, *best) = (row, d);
}
}
_ => rows.push((block, row, d)),
}
}
}
let on_row = |g: &PositionedGlyph| match (g.warichu, y) {
(Some(block), Some(y)) => {
let d = (layout.logical_point(g.origin).y - y).abs();
rows.iter().find(|(b, _)| *b == block).is_none_or(|(_, best)| d <= best + 1e-9)
(Some(block), Some(_)) => {
rows.binary_search_by_key(&block, |r| r.0).ok().and_then(|k| rows.get(k)).is_none_or(|(_, row, _)| (g.warichu_line - row).abs() < 1e-9)
}
_ => true,
};
@@ -750,6 +757,46 @@ pub fn caret_vertical(layout: &TextLayout, byte: usize, delta: i32, goal_x: f64)
byte_in_line(layout, li as usize, goal_x)
}
/// The warichu blocks' lines on line `l`: one entry per block line, as the byte that names its
/// block, its [`PositionedGlyph::warichu_line`] and its baseline across the line in text space,
/// sorted by block and then from the top. Each block's lines are measured from where Character
/// Alignment sets its characters on average, so the characters of a block line share one
/// baseline whatever their own alignment.
fn warichu_rows(layout: &TextLayout, l: &LineInfo) -> Vec<(usize, f64, f64)> {
let glyphs = layout.glyphs.get(l.glyph_start..l.glyph_end).unwrap_or_default();
// Where each block is set across the line: its characters' baselines less their block lines,
// summed, and how many there are.
let mut sets: Vec<(usize, f64, f64)> =
glyphs.iter().filter_map(|g| g.warichu.map(|block| (block, layout.logical_point(g.origin).y - g.warichu_line, 1.0))).collect();
sets.sort_by_key(|s| s.0);
sets.dedup_by(|next, kept| {
let same = next.0 == kept.0;
if same {
(kept.1, kept.2) = (kept.1 + next.1, kept.2 + next.2);
}
same
});
let mut rows: Vec<(usize, f64, f64)> = glyphs.iter().filter_map(|g| g.warichu.map(|block| (block, g.warichu_line, 0.0))).collect();
rows.sort_by(|x, y| x.0.cmp(&y.0).then(x.1.total_cmp(&y.1)));
rows.dedup_by(|x, y| x.0 == y.0 && (x.1 - y.1).abs() < 1e-9);
for row in &mut rows {
let set = sets.binary_search_by_key(&row.0, |s| s.0).ok().and_then(|k| sets.get(k)).map_or(0.0, |s| s.1 / s.2);
row.2 = set + row.1;
}
rows
}
/// A glyph of a warichu block line, for [`selection_quads`]: its block and block line, its extent
/// along the line and its band across it (text space), and whether the selection covers it.
struct BlockPiece {
row: (usize, usize),
x0: f64,
x1: f64,
top: f64,
bottom: f64,
selected: bool,
}
/// Highlight quads (text space, clockwise from top-left) covering the selected bytes `a..b`.
pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4]> {
let (a, b) = (a.min(b), a.max(b));
@@ -768,6 +815,47 @@ pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4
let (f0, f1) = if g.rtl { (g.advance - fb, g.advance - fa) } else { (fa, fb) };
(f0.min(f1), f0.max(f1))
};
// A list marker's glyphs stand for no characters.
let selected = |i: usize, g: &PositionedGlyph| g.byte < b && g.byte + g.len > a && layout.marker_of(i).is_none();
let quad = |x0: f64, x1: f64, top: f64, bottom: f64| {
[Point::new(x0, top), Point::new(x1, top), Point::new(x1, bottom), Point::new(x0, bottom)].map(|q| layout.physical_point(q))
};
// The quads over the selected characters of the warichu blocks on line `l`. The block lines
// share the line of text: each takes the band out to halfway to the block lines next to it,
// and gets one quad over each run of selected characters drawn side by side on it, over the
// space a justified block line sets between them too.
let block_quads = |l: &LineInfo| {
let rows = warichu_rows(layout, l);
let mut pieces: Vec<BlockPiece> = Vec::new();
for (i, g) in layout.glyphs.iter().enumerate().take(l.glyph_end).skip(l.glyph_start) {
let Some(block) = g.warichu else { continue };
let lines = rows.get(rows.partition_point(|r| r.0 < block)..rows.partition_point(|r| r.0 <= block)).unwrap_or_default();
let row = lines.partition_point(|r| r.1 < g.warichu_line - 1e-9);
let Some(own) = lines.get(row).filter(|r| (r.1 - g.warichu_line).abs() < 1e-9) else { continue };
let top = row.checked_sub(1).and_then(|k| lines.get(k)).map_or(l.baseline - l.ascent, |above| (above.2 + own.2) * 0.5);
let bottom = lines.get(row + 1).map_or(l.baseline + l.descent, |below| (below.2 + own.2) * 0.5);
let x = layout.logical_point(g.origin).x;
let selected = selected(i, g);
let (f0, f1) = if selected { covered(g) } else { (0.0, g.advance) };
pieces.push(BlockPiece { row: (block, row), x0: x + f0, x1: x + f1, top, bottom, selected });
}
pieces.sort_by(|p, q| p.row.cmp(&q.row).then(p.x0.total_cmp(&q.x0)));
let mut quads = Vec::new();
let mut open: Option<BlockPiece> = None;
for piece in pieces {
match &mut open {
Some(run) if run.row == piece.row && piece.selected => run.x1 = run.x1.max(piece.x1),
_ => {
quads.extend(open.take().map(|run| quad(run.x0, run.x1, run.top, run.bottom)));
if piece.selected {
open = Some(piece);
}
}
}
}
quads.extend(open.map(|run| quad(run.x0, run.x1, run.top, run.bottom)));
quads
};
if layout.on_path {
// A list marker's glyphs stand for no characters.
for g in layout.glyphs.iter().filter(|g| g.byte < b && g.byte + g.len > a && g.len > 0) {
@@ -781,22 +869,20 @@ pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4
return out;
}
if layout.glyphs.iter().any(|g| g.rtl) {
// A list marker's glyphs stand for no characters.
let glyphs = layout.glyphs.iter().enumerate().filter(|(i, g)| g.byte < b && g.byte + g.len > a && layout.marker_of(*i).is_none());
for (_, g) in glyphs {
// The last line whose warichu blocks were highlighted (glyphs come line by line).
let mut blocks_done = usize::MAX;
for (_, g) in layout.glyphs.iter().enumerate().filter(|(i, g)| selected(*i, g)) {
let Some(l) = layout.lines.get(g.line) else { continue };
if g.warichu.is_some() {
if blocks_done != g.line {
blocks_done = g.line;
out.extend(block_quads(l));
}
continue;
}
let p = layout.logical_point(g.origin);
let (f0, f1) = covered(g);
let (x0, x1) = (p.x + f0, p.x + f1);
out.push(
[
Point::new(x0, l.baseline - l.ascent),
Point::new(x1, l.baseline - l.ascent),
Point::new(x1, l.baseline + l.descent),
Point::new(x0, l.baseline + l.descent),
]
.map(|p| layout.physical_point(p)),
);
out.push(quad(p.x + f0, p.x + f1, l.baseline - l.ascent, l.baseline + l.descent));
}
return out;
}
@@ -804,6 +890,25 @@ pub fn selection_quads(layout: &TextLayout, a: usize, b: usize) -> Vec<[Point; 4
if l.start > b || l.end < a || (l.end == a && l.start < a && layout.lines.get(li + 1).is_some_and(|n| n.start == l.end)) {
continue;
}
// A line with selected characters of a warichu block: each selected glyph outside the
// blocks is highlighted across the line, and the blocks' characters on their own lines.
let line_glyphs = || layout.glyphs.iter().enumerate().take(l.glyph_end).skip(l.glyph_start).filter(|(i, g)| selected(*i, g));
if line_glyphs().any(|(_, g)| g.warichu.is_some()) {
let (t, bt) = (l.baseline - l.ascent, l.baseline + l.descent);
for (_, g) in line_glyphs().filter(|(_, g)| g.warichu.is_none()) {
let p = layout.logical_point(g.origin);
let (f0, f1) = covered(g);
out.push(quad(p.x + f0, p.x + f1, t, bt));
}
out.extend(block_quads(l));
if b > l.end {
// The selection continues past the line: include the line break / trailing space.
let glyphs = layout.glyphs.get(l.glyph_start..l.glyph_end).unwrap_or_default();
let last = glyphs.iter().map(|g| layout.logical_point(g.origin).x + g.advance).fold(l.x0, f64::max);
out.push(quad(last, last.max(l.x1) + (l.ascent + l.descent) * 0.25, t, bt));
}
continue;
}
// A list marker before the line's first character is not part of the selection.
let next = layout.markers.partition_point(|m| m.glyphs.start < l.glyph_start);
let marked = layout.markers.get(next).is_some_and(|m| m.glyphs.start < l.glyph_end);
+1
View File
@@ -169,6 +169,7 @@ fn lines_of_clusters(lines: &[&[(usize, usize, f64)]], rtl: bool) -> TextLayout
width: advance,
width_start: 0.0,
warichu: None,
warichu_line: 0.0,
});
x += advance;
}
+1
View File
@@ -610,6 +610,7 @@ fn duplicate_authored_glyphs_keep_occurrence_order() {
width: 0.0,
width_start: 0.0,
warichu: None,
warichu_line: 0.0,
};
let before = TextLayout { glyphs: vec![glyph(0), glyph(1)], ..TextLayout::default() };
assert_eq!(assert_kashida_ownership(&text, &before, &before, &red(), "duplicate glyphs", false), 0);
+85
View File
@@ -448,6 +448,91 @@ fn a_click_on_a_block_line_goes_to_its_own_characters() {
assert_eq!(byte_in_line(&l, 0, l.glyphs[0].origin.x), 0);
}
/// Selecting characters of a block highlights each on its own line of the block: a character of
/// the upper line does not highlight the lower one, and a selection that runs from the upper line
/// to the lower one covers just the selected characters on each. A character with its own
/// Character Alignment stays on its block line for the highlight and for a click.
#[test]
fn a_selection_in_a_block_highlights_its_own_block_line() {
let l = layout(db(), &with_block("cdef", marked(20.0)));
let (c, d, e) = (&l.glyphs[2], &l.glyphs[3], &l.glyphs[4]);
let middle = (c.origin.y + e.origin.y) * 0.5;
let extent = |quads: &[[Point; 4]]| {
let ys = quads.iter().flatten().map(|p| p.y);
let xs = quads.iter().flatten().map(|p| p.x);
(
xs.clone().fold(f64::INFINITY, f64::min),
xs.fold(f64::NEG_INFINITY, f64::max),
ys.clone().fold(f64::INFINITY, f64::min),
ys.fold(f64::NEG_INFINITY, f64::max),
)
};
// "c", on the upper line.
let (x0, x1, y0, y1) = extent(&selection_quads(&l, 2, 3));
assert!(y0 < c.origin.y && y1 <= middle + 1e-9, "{y0} {y1} {middle}");
assert!((x0 - c.origin.x).abs() < 1e-6 && (x1 - (c.origin.x + c.advance)).abs() < 1e-6, "{x0} {x1}");
// "e", on the lower line.
let (_, _, y0, y1) = extent(&selection_quads(&l, 4, 5));
assert!(y0 >= middle - 1e-9 && y1 > e.origin.y, "{y0} {y1} {middle}");
// "de": d on the upper line and e on the lower, nothing of c.
let quads = selection_quads(&l, 3, 5);
assert_eq!(quads.len(), 2, "{quads:?}");
let (x0, _, _, _) = extent(&quads[..1]);
assert!((x0 - d.origin.x).abs() < 1e-6, "{x0} {:?}", d.origin);
// A selection that runs on past the line's break covers the break after the line's text too.
let mut t = with_block("cdef", marked(20.0));
t.runs.push(TextRun::new("\u{2028}y", style(20.0)));
let l = layout(db(), &t);
let z = &l.glyphs[6];
let first: Vec<[Point; 4]> = selection_quads(&l, 2, 11).into_iter().filter(|q| q[0].y < l.lines[0].baseline).collect();
let (_, x1, _, _) = extent(&first);
assert!(x1 > z.origin.x + z.advance + 1.0, "{x1} {:?} {}", z.origin, z.advance);
// A character of the upper line with its own Character Alignment stays on that line: on a line
// of 40 pt text, where Character Alignment moves a block of 20 pt, "d" is set lower than "c".
let mut t = with_block("", marked(20.0));
t.runs = vec![
TextRun::new("ab", style(40.0)),
TextRun::new("c", marked(20.0)),
TextRun::new("d", CharStyle { char_align: vectorcraft_doc::CharAlign::EmBoxBottom, ..marked(20.0) }),
TextRun::new("ef", marked(20.0)),
TextRun::new("z", style(40.0)),
];
let l = layout(db(), &t);
assert!((l.glyphs[3].origin.y - l.glyphs[2].origin.y).abs() > 0.5, "d is set lower: {:?} {:?}", l.glyphs[2].origin, l.glyphs[3].origin);
// "c" and "d" alone take the same band, and "cd" one highlight over both.
let band = |a: usize, b: usize| {
let (_, _, y0, y1) = extent(&selection_quads(&l, a, b));
(y0, y1)
};
let ((c0, c1), (d0, d1)) = (band(2, 3), band(3, 4));
assert!((c0 - d0).abs() < 1e-9 && (c1 - d1).abs() < 1e-9, "{c0} {c1} {d0} {d1}");
let quads = selection_quads(&l, 2, 4);
assert!(quads.len() == 1 && (quads[0][2].y - c1).abs() < 1e-9, "{quads:?} {c1}");
// The lower line's band starts where the upper line's ends.
let (e0, _) = band(4, 5);
assert!((e0 - c1).abs() < 1e-9, "{e0} {c1}");
// A click on the upper line at the end of "d" goes there too, though "d" is set lower than "c".
let (c, d) = (&l.glyphs[2], &l.glyphs[3]);
assert_eq!(hit_byte(&l, Point::new(d.origin.x + d.advance * 0.75, c.origin.y - 2.0)), 4);
// A justified block line's selected characters are highlighted as one piece, over the space
// between them too: "im" spread to the length of "mm".
let l = layout(db(), &with_block("immm", CharStyle { warichu_align: WarichuAlign::JustifyAll, ..marked(20.0) }));
let (i, m) = (&l.glyphs[2], &l.glyphs[3]);
assert!(m.origin.x > i.origin.x + i.advance + 1.0, "the line is spread: {:?} {} {:?}", i.origin, i.advance, m.origin);
let quads = selection_quads(&l, 2, 4);
let (x0, x1, _, _) = extent(&quads);
assert!(quads.len() == 1 && (x0 - i.origin.x).abs() < 1e-6 && (x1 - (m.origin.x + m.advance)).abs() < 1e-6, "{quads:?}");
// Characters selected together that the bidi order draws apart keep apart: "a" and "א" of
// "aאבb", drawn "aבאb", leave "ב" between them out.
let l = layout(db(), &with_block("aאבb", CharStyle { warichu_lines: 1, ..marked(20.0) }));
let bet = l.glyphs.iter().find(|g| g.byte == 5).map(|g| (l.logical_point(g.origin).x, g.advance)).unwrap();
let quads = selection_quads(&l, 2, 5);
assert_eq!(quads.len(), 2, "{quads:?}");
for q in &quads {
assert!(q[1].x <= bet.0 + 1e-6 || q[0].x >= bet.0 + bet.1 - 1e-6, "{q:?} {bet:?}");
}
}
/// Digits marked Warichu in vertical type on a path stand across the column as digits without the
/// mark do: type on a path does not stack warichu.
#[test]