Merge fix/671 into batch 13

This commit is contained in:
bflatastic
2026-10-17 17:19:52 +07:00
2 changed files with 64 additions and 3 deletions
+20 -3
View File
@@ -1191,6 +1191,12 @@ impl TextLayout {
Some(next.unwrap_or(if right != line.rtl { index + 1 } else { index.saturating_sub(1) }))
}
/// Left edge of a left-to-right line's first char (its end when it has none).
fn line_start_x(&self, line: &Line) -> f64 {
let gl = self.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]);
gl.iter().find(|g| g.char_end > g.char_start).map_or_else(|| self.line_end_x(line), |g| g.x)
}
/// Where a line's text ends: its right edge, or its left edge for a right-to-left paragraph.
fn line_end_x(&self, line: &Line) -> f64 {
let glyphs = self.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]).iter().filter(|g| g.char_end > g.char_start);
@@ -1251,7 +1257,16 @@ impl TextLayout {
let (a, b) = (start.min(end), start.max(end));
let mut out = Vec::new();
for (li, line) in self.lines.iter().enumerate() {
if b <= line.char_start || a > line.char_end || (a == line.char_end && li + 1 < self.lines.len()) {
let last = li + 1 == self.lines.len();
// The empty line after a trailing break has no chars, but a selection reaching the
// end of the text covers it: it shows a paragraph-break mark at the line's start.
let trailing_empty = last && a < b && b >= line.char_start && line.char_start == line.char_end;
if (b <= line.char_start && !trailing_empty) || a > line.char_end || (a == line.char_end && !last) {
continue;
}
if trailing_empty {
let x = self.line_end_x(line);
out.push(Rect::new(x, line.top, x + 4.0, line.top + line.height));
continue;
}
let gl = self.glyphs.get(line.glyph_start..line.glyph_end).unwrap_or(&[]);
@@ -1259,10 +1274,12 @@ impl TextLayout {
out.extend(Self::mixed_selection(gl, a, b, line));
continue;
}
let (x0, _) = self.caret(a.max(line.char_start));
// From the line's own start: `caret(char_start)` after a hard break is the end of the
// line above.
let x0 = if a <= line.char_start { self.line_start_x(line) } else { self.caret(a).0 };
let x1 = if b >= line.char_end {
// The last line has no paragraph break to show, so it ends at its glyphs.
self.line_end_x(line) + if line.para_end && li + 1 < self.lines.len() && b > line.char_end.saturating_sub(1) { 4.0 } else { 0.0 }
self.line_end_x(line) + if line.para_end && !last && b > line.char_end.saturating_sub(1) { 4.0 } else { 0.0 }
} else {
self.caret(b).0
};
+44
View File
@@ -437,3 +437,47 @@ fn selection_through_text_end_stays_inside_auto_width_box() {
let first = r.first().map_or(f64::NAN, |r| r.x1);
assert!(first > l.caret(5).0 + 1.0, "{first}");
}
/// The selection rect drawn on line `li` of `l`, if any.
fn rect_on(l: &TextLayout, rects: &[figcraft_geom::Rect], li: usize) -> Option<figcraft_geom::Rect> {
let top = l.lines.get(li)?.top;
rects.iter().find(|r| (r.y0 - top).abs() < 1e-6).copied()
}
#[test]
fn select_all_highlights_an_empty_last_line() {
if !have_fonts() {
return;
}
// "Hello\n": the trailing break leaves an empty second line (#665).
let l = layout(&TextData::new("Hello\n"), None, None);
assert_eq!(l.lines.len(), 2);
let Some(r) = rect_on(&l, &l.selection_rects(0, 6), 1) else { panic!("no highlight on the empty last line") };
// A paragraph-break mark at the line's start, not at the end of "Hello" above.
assert!(r.x0.abs() < 1e-6 && (r.width() - 4.0).abs() < 1e-6, "{r:?}");
assert!(rect_on(&l, &l.selection_rects(5, 6), 1).is_some());
// Selecting part of the first line, or a caret, leaves the empty line alone.
assert!(l.selection_rects(0, 1).iter().all(|r| r.y0 < l.lines[1].top - 1e-6), "{:?}", l.selection_rects(0, 1));
assert!(l.selection_rects(6, 6).is_empty());
// A right-to-left paragraph marks its empty last line too.
let rtl = layout(&TextData::new("\u{5E9}\u{5DC}\u{5D5}\u{5DD}\n"), None, None);
let Some(r) = rect_on(&rtl, &rtl.selection_rects(0, 5), 1) else { panic!("no highlight on the empty right-to-left line") };
assert!(r.x0.abs() < 1e-6 && (r.width() - 4.0).abs() < 1e-6, "{r:?}");
}
#[test]
fn selection_after_a_line_break_starts_at_the_line_start() {
if !have_fonts() {
return;
}
// Not at the end of the line above, where `caret` puts the index after the break.
let l = layout(&TextData::new("Hello\nworld"), None, None);
let Some(r) = rect_on(&l, &l.selection_rects(0, 11), 1) else { panic!("no highlight on the second line") };
assert!(r.x0.abs() < 1e-6 && r.x1 > l.caret(10).0, "{r:?}");
// An empty line in the middle shows its break mark at its start.
let m = layout(&TextData::new("Hello\n\nworld"), None, None);
let Some(r) = rect_on(&m, &m.selection_rects(0, 12), 1) else { panic!("no highlight on the empty line") };
assert!(r.x0.abs() < 1e-6 && (r.width() - 4.0).abs() < 1e-6, "{r:?}");
// A break followed by text still doesn't highlight the next line's start.
assert!(rect_on(&l, &l.selection_rects(0, 6), 1).is_none());
}