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
+96 -25
+96 -25
View File
@@ -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<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 {
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);