iris: clip ellipsized text at cluster boundaries

This commit is contained in:
iris committed 2026-09-12 20:54:26 -04:00
1 parent a475ae772f
commit ab6d3fcdc8
1 file changed
+93 -22
+93 -22
View File
@@ -26,6 +26,8 @@ struct OverflowState {
pan: f32, pan: f32,
leading_marker: f32, leading_marker: f32,
trailing_marker: f32, trailing_marker: f32,
content_start: f32,
content_end: f32,
suppress_content: bool, suppress_content: bool,
leading_omitted: Option<OmittedText>, leading_omitted: Option<OmittedText>,
trailing_omitted: Option<OmittedText>, trailing_omitted: Option<OmittedText>,
@@ -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 { fn local_text_point(&self, pos: Vec2, size: Vec2) -> Vec2 {
let mut pos = pos - self.region().top_left().to_abs(size); let mut pos = pos - self.region().top_left().to_abs(size);
pos.x += self.overflow.pan; pos.x += self.overflow.pan;
@@ -122,11 +163,9 @@ impl TextView {
len 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() self.overflow.leading_omitted.clone()
} else if pos.x >= self.overflow.viewport.x - self.overflow.trailing_marker } else if pos.x >= self.overflow.content_end && pos.x <= self.overflow.viewport.x {
&& pos.x <= self.overflow.viewport.x
{
self.overflow.trailing_omitted.clone() self.overflow.trailing_omitted.clone()
} else { } else {
None None
@@ -202,6 +241,7 @@ impl TextView {
self.overflow.active = true; self.overflow.active = true;
self.overflow.viewport = vec2(available, tex.size.y); self.overflow.viewport = vec2(available, tex.size.y);
self.overflow.pan = pan; self.overflow.pan = pan;
self.overflow.content_end = available;
if attrs.overflow != TextOverflow::Ellipsis { if attrs.overflow != TextOverflow::Ellipsis {
return None; return None;
} }
@@ -214,16 +254,23 @@ impl TextView {
if markers_fit { if markers_fit {
self.overflow.leading_marker = if leading { marker.size.x } else { 0.0 }; self.overflow.leading_marker = if leading { marker.size.x } else { 0.0 };
self.overflow.trailing_marker = if trailing { 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 { if leading {
self.overflow.leading_omitted = 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 { if trailing {
self.overflow.trailing_omitted = Some(self.omitted_range( self.overflow.trailing_omitted =
pan + available - marker.size.x, Some(self.omitted_range(clip_end, tex.size.x * 2.0, tex.size.x * 2.0));
tex.size.x * 2.0,
tex.size.x * 2.0,
));
} }
} else { } else {
self.overflow.suppress_content = true; self.overflow.suppress_content = true;
@@ -266,7 +313,7 @@ impl TextView {
.as_ref() .as_ref()
.is_some_and(|omitted| self.omitted_is_selected(omitted)) .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) .align(Align::TOP_LEFT)
.within(&viewport) .within(&viewport)
.within(&painter.region()); .within(&painter.region());
@@ -278,12 +325,12 @@ impl TextView {
.as_ref() .as_ref()
.is_some_and(|omitted| self.omitted_is_selected(omitted)) .is_some_and(|omitted| self.omitted_is_selected(omitted))
{ {
let marker_region = vec2(self.overflow.trailing_marker, self.overflow.viewport.y) let marker_region = vec2(
self.overflow.viewport.x - self.overflow.content_end,
self.overflow.viewport.y,
)
.align(Align::TOP_LEFT) .align(Align::TOP_LEFT)
.offset(vec2( .offset(vec2(self.overflow.content_end, 0.0))
self.overflow.viewport.x - self.overflow.trailing_marker,
0.0,
))
.within(&viewport) .within(&viewport)
.within(&painter.region()); .within(&painter.region());
painter.primitive_within(RectPrimitive::color(marker_highlight), marker_region); painter.primitive_within(RectPrimitive::color(marker_highlight), marker_region);
@@ -305,14 +352,11 @@ impl TextView {
let content_width = if self.overflow.suppress_content { let content_width = if self.overflow.suppress_content {
0.0 0.0
} else { } else {
(self.overflow.viewport.x (self.overflow.content_end - self.overflow.content_start).max(0.0)
- self.overflow.leading_marker
- self.overflow.trailing_marker)
.max(0.0)
}; };
let mask = vec2(content_width, self.overflow.viewport.y) let mask = vec2(content_width, self.overflow.viewport.y)
.align(Align::TOP_LEFT) .align(Align::TOP_LEFT)
.offset(vec2(self.overflow.leading_marker, 0.0)) .offset(vec2(self.overflow.content_start, 0.0))
.within(&viewport) .within(&viewport)
.within(&painter.region()); .within(&painter.region());
painter.set_mask(mask); painter.set_mask(mask);
@@ -357,7 +401,14 @@ impl TextView {
.text .text
.with_layout(|layout, _| selection.focus().geometry(layout, CARET_WIDTH)); .with_layout(|layout, _| selection.focus().geometry(layout, CARET_WIDTH));
let size = vec2(caret.width() as f32, caret.height() as f32); 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); let paint = painter.paint(&PaintId::WHITE);
painter.primitive_within( painter.primitive_within(
RectPrimitive::color(paint), RectPrimitive::color(paint),
@@ -577,6 +628,26 @@ mod tests {
assert_eq!(render.take_counters().shapes, 0); 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.leading_marker > 0.0);
assert!(rsc.ui.widgets[text].view.overflow.trailing_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)); rsc.ui.widgets[text].set_overflow_position(rel(1));
render.update(&root, &mut rsc); render.update(&root, &mut rsc);