diff --git a/src/widget/text/mod.rs b/src/widget/text/mod.rs index 64f2441..c2f11d6 100644 --- a/src/widget/text/mod.rs +++ b/src/widget/text/mod.rs @@ -26,6 +26,8 @@ struct OverflowState { pan: f32, leading_marker: f32, trailing_marker: f32, + content_start: f32, + content_end: f32, suppress_content: bool, leading_omitted: Option, trailing_omitted: Option, @@ -104,6 +106,45 @@ impl TextView { }) } + /// Snaps a pair of canonical-layout clip edges outward from any shaped + /// cluster or ligature they cross. The text itself stays at its exact + /// (possibly animated) pan; only the opaque ellipsis boundary snaps. + fn ellipsis_clip(&self, left: f32, right: f32) -> (f32, f32) { + self.text.with_layout(|layout, _| { + let y = self.overflow.viewport.y * 0.5; + let cluster_group = |x| { + let (mut first, _) = parley::Cluster::from_point(layout, x, y)?; + while first.is_ligature_continuation() { + first = first.previous_visual()?; + } + let start = first.visual_offset()?; + let mut end = start + first.advance(); + let mut last = first; + while let Some(next) = last.next_visual() { + if !next.is_ligature_continuation() { + break; + } + end += next.advance(); + last = next; + } + Some((start, end)) + }; + let left = cluster_group(left).map_or( + left, + |(start, end)| { + if left <= start { start } else { end } + }, + ); + let right = cluster_group(right).map_or( + right, + |(start, end)| { + if right >= end { end } else { start } + }, + ); + (left, right) + }) + } + fn local_text_point(&self, pos: Vec2, size: Vec2) -> Vec2 { let mut pos = pos - self.region().top_left().to_abs(size); pos.x += self.overflow.pan; @@ -122,11 +163,9 @@ impl TextView { len }, }) - } else if pos.x >= 0.0 && pos.x <= self.overflow.leading_marker { + } else if pos.x >= 0.0 && pos.x <= self.overflow.content_start { self.overflow.leading_omitted.clone() - } else if pos.x >= self.overflow.viewport.x - self.overflow.trailing_marker - && pos.x <= self.overflow.viewport.x - { + } else if pos.x >= self.overflow.content_end && pos.x <= self.overflow.viewport.x { self.overflow.trailing_omitted.clone() } else { None @@ -202,6 +241,7 @@ impl TextView { self.overflow.active = true; self.overflow.viewport = vec2(available, tex.size.y); self.overflow.pan = pan; + self.overflow.content_end = available; if attrs.overflow != TextOverflow::Ellipsis { return None; } @@ -214,16 +254,23 @@ impl TextView { if markers_fit { self.overflow.leading_marker = if leading { marker.size.x } else { 0.0 }; self.overflow.trailing_marker = if trailing { marker.size.x } else { 0.0 }; + let (clip_start, clip_end) = self.ellipsis_clip( + pan + self.overflow.leading_marker, + pan + available - self.overflow.trailing_marker, + ); + if clip_start >= clip_end { + self.overflow.suppress_content = true; + return Some(marker); + } + self.overflow.content_start = (clip_start - pan).clamp(0.0, available); + self.overflow.content_end = (clip_end - pan).clamp(0.0, available); if leading { self.overflow.leading_omitted = - Some(self.omitted_range(-tex.size.x, pan + marker.size.x, -tex.size.x)); + Some(self.omitted_range(-tex.size.x, clip_start, -tex.size.x)); } if trailing { - self.overflow.trailing_omitted = Some(self.omitted_range( - pan + available - marker.size.x, - tex.size.x * 2.0, - tex.size.x * 2.0, - )); + self.overflow.trailing_omitted = + Some(self.omitted_range(clip_end, tex.size.x * 2.0, tex.size.x * 2.0)); } } else { self.overflow.suppress_content = true; @@ -266,7 +313,7 @@ impl TextView { .as_ref() .is_some_and(|omitted| self.omitted_is_selected(omitted)) { - let marker_region = vec2(self.overflow.leading_marker, self.overflow.viewport.y) + let marker_region = vec2(self.overflow.content_start, self.overflow.viewport.y) .align(Align::TOP_LEFT) .within(&viewport) .within(&painter.region()); @@ -278,14 +325,14 @@ impl TextView { .as_ref() .is_some_and(|omitted| self.omitted_is_selected(omitted)) { - let marker_region = vec2(self.overflow.trailing_marker, self.overflow.viewport.y) - .align(Align::TOP_LEFT) - .offset(vec2( - self.overflow.viewport.x - self.overflow.trailing_marker, - 0.0, - )) - .within(&viewport) - .within(&painter.region()); + let marker_region = vec2( + self.overflow.viewport.x - self.overflow.content_end, + self.overflow.viewport.y, + ) + .align(Align::TOP_LEFT) + .offset(vec2(self.overflow.content_end, 0.0)) + .within(&viewport) + .within(&painter.region()); painter.primitive_within(RectPrimitive::color(marker_highlight), marker_region); } } @@ -305,14 +352,11 @@ impl TextView { let content_width = if self.overflow.suppress_content { 0.0 } else { - (self.overflow.viewport.x - - self.overflow.leading_marker - - self.overflow.trailing_marker) - .max(0.0) + (self.overflow.content_end - self.overflow.content_start).max(0.0) }; let mask = vec2(content_width, self.overflow.viewport.y) .align(Align::TOP_LEFT) - .offset(vec2(self.overflow.leading_marker, 0.0)) + .offset(vec2(self.overflow.content_start, 0.0)) .within(&viewport) .within(&painter.region()); painter.set_mask(mask); @@ -357,7 +401,14 @@ impl TextView { .text .with_layout(|layout, _| selection.focus().geometry(layout, CARET_WIDTH)); let size = vec2(caret.width() as f32, caret.height() as f32); - let top_left = vec2(caret.x0 as f32 - self.overflow.pan, caret.y0 as f32); + let mut x = caret.x0 as f32 - self.overflow.pan; + if self.overflow.active && !self.overflow.suppress_content { + x = x.clamp( + self.overflow.content_start, + (self.overflow.content_end - size.x).max(self.overflow.content_start), + ); + } + let top_left = vec2(x, caret.y0 as f32); let paint = painter.paint(&PaintId::WHITE); painter.primitive_within( RectPrimitive::color(paint), @@ -577,6 +628,26 @@ mod tests { assert_eq!(render.take_counters().shapes, 0); assert!(rsc.ui.widgets[text].view.overflow.leading_marker > 0.0); assert!(rsc.ui.widgets[text].view.overflow.trailing_marker > 0.0); + { + let view = &rsc.ui.widgets[text].view; + let edges = [ + view.overflow.pan + view.overflow.content_start, + view.overflow.pan + view.overflow.content_end, + ]; + view.text.with_layout(|layout, _| { + for edge in edges { + let (cluster, _) = + parley::Cluster::from_point(layout, edge, view.overflow.viewport.y * 0.5) + .unwrap(); + let start = cluster.visual_offset().unwrap(); + let end = start + cluster.advance(); + assert!( + (edge - start).abs() < 0.01 || (edge - end).abs() < 0.01, + "ellipsis clip {edge} crossed cluster {start}..{end}", + ); + } + }); + } rsc.ui.widgets[text].set_overflow_position(rel(1)); render.update(&root, &mut rsc);