diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index a690e5e5c5..58428c7798 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -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), }); } diff --git a/crates/text/src/layout/ruby.rs b/crates/text/src/layout/ruby.rs index 9bf97a4adc..9b8da39d80 100644 --- a/crates/text/src/layout/ruby.rs +++ b/crates/text/src/layout/ruby.rs @@ -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, } } diff --git a/crates/text/src/lib.rs b/crates/text/src/lib.rs index 3f5de9a309..2ff6b9ad5b 100644 --- a/crates/text/src/lib.rs +++ b/crates/text/src/lib.rs @@ -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, + /// 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) -> 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 = 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 = 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); diff --git a/crates/text/src/tests/bidi.rs b/crates/text/src/tests/bidi.rs index 5ce4d5cef9..431ec9e568 100644 --- a/crates/text/src/tests/bidi.rs +++ b/crates/text/src/tests/bidi.rs @@ -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; } diff --git a/crates/text/src/tests/harakat.rs b/crates/text/src/tests/harakat.rs index e7fc7c470c..ea84bcf346 100644 --- a/crates/text/src/tests/harakat.rs +++ b/crates/text/src/tests/harakat.rs @@ -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); diff --git a/crates/text/src/tests/warichu.rs b/crates/text/src/tests/warichu.rs index 8b07485242..d68cb8fccc 100644 --- a/crates/text/src/tests/warichu.rs +++ b/crates/text/src/tests/warichu.rs @@ -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]