iris: clip ellipsized text at cluster boundaries

This commit is contained in:
iris committed 2026-09-12 20:54:26 -04:00
1 parent 1f0a7b5f94
commit 93c106e2f8
2 files changed
+100 -26

No files matched your search

+4 -1
View File
@@ -862,7 +862,10 @@ absolute position back into this same public field; callers animating it own
stopping their animation when editing begins. Position changes and changes stopping their animation when editing begins. Position changes and changes
among the unwrapped treatments reuse shaping; entering or leaving `Wrap` among the unwrapped treatments reuse shaping; entering or leaving `Wrap`
reshapes at the viewport width, while content, font, spans, or density retain reshapes at the viewport width, while content, font, spans, or density retain
the normal shape invalidation path. the normal shape invalidation path. `Hidden` clips at the exact pixel position
for continuous scrolling. `Ellipsis` keeps that same continuous position but
snaps each content-mask edge outward past any shaped cluster or ligature it
crosses, so a marker never leaves a partial shaped cluster behind.
An ellipsis marker maps its visually omitted side to one contiguous source An ellipsis marker maps its visually omitted side to one contiguous source
range. This is exact for ordinary LTR and RTL runs; mixed-bidi text can place range. This is exact for ordinary LTR and RTL runs; mixed-bidi text can place
discontiguous logical ranges on one visual side, which Iris's deliberately discontiguous logical ranges on one visual side, which Iris's deliberately
+96 -25
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,14 +325,14 @@ 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(
.align(Align::TOP_LEFT) self.overflow.viewport.x - self.overflow.content_end,
.offset(vec2( self.overflow.viewport.y,
self.overflow.viewport.x - self.overflow.trailing_marker, )
0.0, .align(Align::TOP_LEFT)
)) .offset(vec2(self.overflow.content_end, 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);