iris: clip ellipsized text at cluster boundaries
This commit is contained in:
1 parent
1f0a7b5f94
commit
93c106e2f8
2 files changed
+100
-26
No files matched your search
+4
-1
@@ -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
@@ -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);
|
||||||
|
|||||||
Reference in new issue
Block a user