diff --git a/crates/text/src/layout.rs b/crates/text/src/layout.rs index e6c893b7..7fc6e11a 100644 --- a/crates/text/src/layout.rs +++ b/crates/text/src/layout.rs @@ -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 }; diff --git a/crates/text/src/tests.rs b/crates/text/src/tests.rs index e34f0110..f5eaab6b 100644 --- a/crates/text/src/tests.rs +++ b/crates/text/src/tests.rs @@ -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 { + 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()); +}