iris: add positioned text overflow

This commit is contained in:
iris committed 2026-09-12 19:58:02 -04:00
1 parent 1f15125992
commit a475ae772f
14 files changed
+735 -31

No files matched your search

+4
View File
@@ -57,6 +57,10 @@ path = "examples/tabs/desktop.rs"
name = "task" name = "task"
path = "examples/task/desktop.rs" path = "examples/task/desktop.rs"
[[example]]
name = "text"
path = "examples/text/desktop.rs"
[[example]] [[example]]
name = "view" name = "view"
path = "examples/view/desktop.rs" path = "examples/view/desktop.rs"
+4 -2
View File
@@ -22,7 +22,7 @@ typical phone column width.";
fn build_row(rsc: &mut BenchRsc, i: usize, image_every: usize) -> StrongWidget { fn build_row(rsc: &mut BenchRsc, i: usize, image_every: usize) -> StrongWidget {
let text = wtext(format!("Message {i}: {BODY}")) let text = wtext(format!("Message {i}: {BODY}"))
.wrap(true) .overflow(TextOverflow::Wrap)
.add_strong(rsc) .add_strong(rsc)
.any(); .any();
@@ -322,7 +322,9 @@ fn bench_redraw_big_text(chars: usize, redraws: usize) {
let content: String = (0..chars) let content: String = (0..chars)
.map(|i| char::from(b'a' + (i % 26) as u8)) .map(|i| char::from(b'a' + (i % 26) as u8))
.collect(); .collect();
let text = wtext(content).wrap(true).add_strong(&mut rsc); let text = wtext(content)
.overflow(TextOverflow::Wrap)
.add_strong(&mut rsc);
let handle = text.weak(); let handle = text.weak();
let root = text.any(); let root = text.any();
+90 -6
View File
@@ -1,5 +1,5 @@
use crate::{ use crate::{
Align, GlyphAtlas, GlyphKey, PaintId, PlacedGlyph, RegionAlign, Textures, WidgetId, Align, GlyphAtlas, GlyphKey, Len, PaintId, PlacedGlyph, RegionAlign, Textures, WidgetId,
util::{Resources, RscHandle, StrongRscId, Vec2, WeakRscId}, util::{Resources, RscHandle, StrongRscId, Vec2, WeakRscId},
}; };
use parley::{ use parley::{
@@ -584,10 +584,39 @@ pub struct TextAttrs {
pub font_size: f32, pub font_size: f32,
pub line_height: f32, pub line_height: f32,
pub family: String, pub family: String,
pub wrap: bool, pub overflow: TextOverflow,
pub overflow_position: Len,
pub align: RegionAlign, pub align: RegionAlign,
} }
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub enum TextOverflow {
#[default]
Visible,
Wrap,
Hidden,
Ellipsis,
}
#[derive(Clone, PartialEq)]
struct TextShapeAttrs {
color: PaintId,
font_size: f32,
line_height: f32,
family: String,
}
impl From<&TextAttrs> for TextShapeAttrs {
fn from(attrs: &TextAttrs) -> Self {
Self {
color: attrs.color.clone(),
font_size: attrs.font_size,
line_height: attrs.line_height,
family: attrs.family.clone(),
}
}
}
pub const LINE_HEIGHT_MULT: f32 = 1.1; pub const LINE_HEIGHT_MULT: f32 = 1.1;
impl Default for TextAttrs { impl Default for TextAttrs {
@@ -598,7 +627,8 @@ impl Default for TextAttrs {
font_size: size, font_size: size,
line_height: size * LINE_HEIGHT_MULT, line_height: size * LINE_HEIGHT_MULT,
family: SANS_SERIF.to_owned(), family: SANS_SERIF.to_owned(),
wrap: false, overflow: TextOverflow::Visible,
overflow_position: Len::ZERO,
align: Align::CENTER_LEFT, align: Align::CENTER_LEFT,
} }
} }
@@ -611,7 +641,7 @@ pub struct TextBuffer {
text: String, text: String,
layout: Layout<PaintId>, layout: Layout<PaintId>,
spans: Vec<SpanStyle>, spans: Vec<SpanStyle>,
shaped: Option<(TextAttrs, Option<f32>, f32)>, shaped: Option<(TextShapeAttrs, Option<f32>, f32)>,
} }
impl TextBuffer { impl TextBuffer {
@@ -671,7 +701,8 @@ impl TextBuffer {
width: Option<f32>, width: Option<f32>,
density: f32, density: f32,
) { ) {
if self.shaped.as_ref() == Some(&(attrs.clone(), width, density)) { let shape_attrs = TextShapeAttrs::from(attrs);
if self.shaped.as_ref() == Some(&(shape_attrs.clone(), width, density)) {
return; return;
} }
let base_family = data.resolve_family(&attrs.family); let base_family = data.resolve_family(&attrs.family);
@@ -719,7 +750,7 @@ impl TextBuffer {
self.layout.break_all_lines(width); self.layout.break_all_lines(width);
self.layout self.layout
.align(Alignment::Start, AlignmentOptions::default()); .align(Alignment::Start, AlignmentOptions::default());
self.shaped = Some((attrs.clone(), width, density)); self.shaped = Some((shape_attrs, width, density));
} }
fn invalidate(&mut self) { fn invalidate(&mut self) {
@@ -762,8 +793,10 @@ pub struct RenderedText {
pub struct TextRsc { pub struct TextRsc {
buffer: TextBuffer, buffer: TextBuffer,
ellipsis_buffer: TextBuffer,
attrs: TextAttrs, attrs: TextAttrs,
rendered: Option<RenderedText>, rendered: Option<RenderedText>,
ellipsis_rendered: Option<(TextShapeAttrs, f32, RenderedText)>,
width: Option<f32>, width: Option<f32>,
density: f32, density: f32,
owner: Option<WidgetId>, owner: Option<WidgetId>,
@@ -773,8 +806,10 @@ impl TextRsc {
fn new(buffer: TextBuffer, attrs: TextAttrs) -> Self { fn new(buffer: TextBuffer, attrs: TextAttrs) -> Self {
Self { Self {
buffer, buffer,
ellipsis_buffer: TextBuffer::new(""),
attrs, attrs,
rendered: None, rendered: None,
ellipsis_rendered: None,
width: None, width: None,
density: 0.0, density: 0.0,
owner: None, owner: None,
@@ -784,6 +819,8 @@ impl TextRsc {
fn invalidate(&mut self) { fn invalidate(&mut self) {
self.buffer.invalidate(); self.buffer.invalidate();
self.rendered = None; self.rendered = None;
self.ellipsis_buffer.invalidate();
self.ellipsis_rendered = None;
} }
} }
@@ -885,6 +922,34 @@ impl TextResources {
resource.rendered = Some(rendered.clone()); resource.rendered = Some(rendered.clone());
(rendered, true) (rendered, true)
} }
fn render_ellipsis(
&mut self,
resource: &mut TextRsc,
owner: WidgetId,
textures: &mut Textures,
density: f32,
) -> (RenderedText, bool) {
let shape_attrs = TextShapeAttrs::from(&resource.attrs);
let atlas_generation = self.data.atlas.generation();
resource.owner = Some(owner);
if let Some((cached_attrs, cached_density, rendered)) = &resource.ellipsis_rendered
&& cached_attrs == &shape_attrs
&& *cached_density == density
&& rendered.generation == atlas_generation
{
return (rendered.clone(), false);
}
let rendered = self.data.render(
&mut resource.ellipsis_buffer,
&resource.attrs,
None,
textures,
density,
);
resource.ellipsis_rendered = Some((shape_attrs, density, rendered.clone()));
(rendered, true)
}
} }
impl Default for TextResources { impl Default for TextResources {
@@ -961,6 +1026,14 @@ impl TextHandle {
RefMut::map(resource, |resource| &mut resource.attrs) RefMut::map(resource, |resource| &mut resource.attrs)
} }
pub fn set_overflow_position(&mut self, position: Len) {
self.rsc.get_mut().attrs.overflow_position = position;
}
pub fn set_overflow(&mut self, overflow: TextOverflow) {
self.rsc.get_mut().attrs.overflow = overflow;
}
pub fn rendered(&self) -> Option<RenderedText> { pub fn rendered(&self) -> Option<RenderedText> {
self.rsc.get().rendered.clone() self.rsc.get().rendered.clone()
} }
@@ -996,6 +1069,17 @@ impl TextHandle {
let mut resource = self.rsc.get_mut_shared(); let mut resource = self.rsc.get_mut_shared();
resources.render(&mut resource, width, owner, textures, density) resources.render(&mut resource, width, owner, textures, density)
} }
pub fn render_ellipsis(
&self,
owner: WidgetId,
textures: &mut Textures,
density: f32,
) -> (RenderedText, bool) {
let mut resources = self.resources.borrow_mut();
let mut resource = self.rsc.get_mut_shared();
resources.render_ellipsis(&mut resource, owner, textures, density)
}
} }
#[cfg(test)] #[cfg(test)]
+8
View File
@@ -422,6 +422,14 @@ impl<'a> Painter<'a> {
rendered rendered
} }
pub fn render_ellipsis(&mut self, text: &TextHandle) -> RenderedText {
let density = self.render_state.density;
let ui: &mut UiData = self.rsc.ui_mut();
let (rendered, prepared) = text.render_ellipsis(self.id, &mut ui.textures, density);
self.render_state.shape_count += u64::from(prepared);
rendered
}
fn atlas_generation(&self) -> u64 { fn atlas_generation(&self) -> u64 {
self.rsc.ui().text.borrow().atlas.generation() self.rsc.ui().text.borrow().atlas.generation()
} }
+2 -2
View File
@@ -24,7 +24,7 @@ fn build_row<Rsc: UiRsc + 'static>(rsc: &mut Rsc, i: usize) -> StrongWidget {
let text_color = PaintId::BLACK; let text_color = PaintId::BLACK;
if i.is_multiple_of(IMAGE_EVERY) { if i.is_multiple_of(IMAGE_EVERY) {
let text = wtext(row_text(i)) let text = wtext(row_text(i))
.wrap(true) .overflow(TextOverflow::Wrap)
.color(text_color) .color(text_color)
.add_strong(rsc) .add_strong(rsc)
.any(); .any();
@@ -39,7 +39,7 @@ fn build_row<Rsc: UiRsc + 'static>(rsc: &mut Rsc, i: usize) -> StrongWidget {
.any() .any()
} else { } else {
wtext(row_text(i)) wtext(row_text(i))
.wrap(true) .overflow(TextOverflow::Wrap)
.color(text_color) .color(text_color)
.pad(dp(8.0)) .pad(dp(8.0))
.background(rect(tint)) .background(rect(tint))
+1 -1
View File
@@ -123,7 +123,7 @@ where
.editable(EditMode::MultiLine) .editable(EditMode::MultiLine)
.size(30) .size(30)
.text_align(Align::LEFT) .text_align(Align::LEFT)
.wrap(true) .overflow(TextOverflow::Wrap)
.attr::<Selectable>(()); .attr::<Selectable>(());
let fill = rsc let fill = rsc
.ui_mut() .ui_mut()
+9
View File
@@ -0,0 +1,9 @@
use iris::prelude::*;
#[path = "lib.rs"]
mod app;
#[iris::android_init]
fn create(ui_state: &mut AndroidUiState, rsc: &mut StdRsc<AndroidUiState>) {
app::build(rsc, ui_state);
}
+8
View File
@@ -0,0 +1,8 @@
use iris::prelude::*;
#[path = "lib.rs"]
mod app;
fn main() {
DesktopApp::run_with(app::build);
}
+160
View File
@@ -0,0 +1,160 @@
use iris::prelude::*;
const SAMPLE: &str = "The quick brown fox jumps over the lazy dog";
fn overflow_row<Rsc: UiRsc + 'static>(
rsc: &mut Rsc,
label: &str,
overflow: TextOverflow,
position: Len,
fill: PaintId,
) -> WeakWidget<Sized> {
(
wtext(label).size(14).color(PaintId::GRAY).width(dp(76)),
wtext(SAMPLE)
.size(20)
.overflow(overflow)
.overflow_position(position)
.width(dp(260))
.background(rect(fill)),
)
.span(Dir::RIGHT)
.gap(dp(8))
.width(dp(344))
.add(rsc)
}
pub(crate) fn build<Rsc: HasEvents + 'static>(rsc: &mut Rsc, ui_state: &mut impl HasRoot<Rsc>)
where
Rsc::State: FocusHost,
{
let panel = rsc
.ui_mut()
.paints
.add(Srgba8::new(34, 36, 42, 255).to_linear());
let field = rsc
.ui_mut()
.paints
.add(Srgba8::new(53, 57, 66, 255).to_linear());
let styled = "Bold, italic, underlined, and colored spans";
let styled = wtext(styled)
.size(20)
.spans(vec![
SpanStyle::new(0..4).bold(),
SpanStyle::new(6..12).italic(),
SpanStyle::new(14..24).underline(),
SpanStyle::new(30..37).color(PaintId::SKY),
])
.width(rest(1));
let aligned = (
wtext("Left aligned").text_align(Align::CENTER_LEFT),
wtext("Centered").text_align(Align::CENTER),
wtext("Right aligned").text_align(Align::CENTER_RIGHT),
)
.span(Dir::DOWN)
.gap(dp(4))
.width(rest(1))
.background(rect(field.clone()));
let wrapped = wtext(
"Wrapping shapes the same source into as many lines as its container needs. Resize the window to see it reflow.",
)
.overflow(TextOverflow::Wrap)
.size(18)
.width(dp(340))
.background(rect(field.clone()));
let editable = wtext(SAMPLE)
.overflow(TextOverflow::Ellipsis)
.editable(EditMode::SingleLine)
.size(20)
.attr::<Selectable>(())
.width(dp(344))
.background(rect(field.clone()));
let intro = (
wtext("Iris text")
.size(30)
.spans(vec![SpanStyle::new(0..9).bold()]),
wtext("Drag across display text to select it. The overflow markers select hidden source text, but are never copied.")
.overflow(TextOverflow::Wrap)
.color(PaintId::GRAY)
.width(rest(1)),
)
.span(Dir::DOWN)
.gap(dp(6))
.add(rsc);
let styles = (
wtext("Styles and alignment").size(16).color(PaintId::SKY),
styled,
aligned,
)
.span(Dir::DOWN)
.gap(dp(6))
.add(rsc);
let wrapping = (wtext("Wrapping").size(16).color(PaintId::SKY), wrapped)
.span(Dir::DOWN)
.gap(dp(6))
.add(rsc);
let overflow = (
wtext("Overflow treatment and position")
.size(16)
.color(PaintId::SKY),
overflow_row(rsc, "hidden", TextOverflow::Hidden, rel(0), field.clone()),
overflow_row(
rsc,
"ellipsis 0",
TextOverflow::Ellipsis,
rel(0),
field.clone(),
),
overflow_row(
rsc,
"ellipsis .5",
TextOverflow::Ellipsis,
rel(0.5),
field.clone(),
),
overflow_row(
rsc,
"ellipsis 1",
TextOverflow::Ellipsis,
rel(1),
field.clone(),
),
)
.span(Dir::DOWN)
.gap(dp(6))
.add(rsc);
let editing = (
wtext("Editable ellipsis (move the caret through the text)")
.size(16)
.color(PaintId::SKY),
editable,
)
.span(Dir::DOWN)
.gap(dp(6))
.add(rsc);
let content = (intro, styles, wrapping, overflow, editing)
.span(Dir::DOWN)
.gap(dp(14))
.controller(SelectionController::new().separator("\n"))
.add(rsc);
content
.on(CursorSense::drag_senses(), move |ctx, rsc: &mut Rsc| {
let input = &ctx.data;
rsc.with_nearest_controller::<SelectionController, _>(content, |id, selection, rsc| {
selection.drag(id, rsc, input)
});
})
.add(rsc);
content
.pad(dp(24))
.width(rest(1))
.background(rect(panel))
.set_root(rsc, ui_state);
}
+1 -1
View File
@@ -484,7 +484,7 @@ fn composer_like_tree(rsc: &mut TestRsc) -> (WeakWidget<TextEdit>, StrongWidget)
let field = wtext("") let field = wtext("")
.editable(EditMode::MultiLine) .editable(EditMode::MultiLine)
.text_align(Align::LEFT) .text_align(Align::LEFT)
.wrap(true) .overflow(TextOverflow::Wrap)
.size(18) .size(18)
.color(PaintId::WHITE) .color(PaintId::WHITE)
.add(rsc); .add(rsc);
+6 -2
View File
@@ -36,8 +36,12 @@ impl<State, O, H: WidgetOption<State>> TextBuilder<State, O, H> {
self.attrs.align = Align::CENTER; self.attrs.align = Align::CENTER;
self self
} }
pub fn wrap(mut self, wrap: bool) -> Self { pub fn overflow(mut self, overflow: TextOverflow) -> Self {
self.attrs.wrap = wrap; self.attrs.overflow = overflow;
self
}
pub fn overflow_position(mut self, position: impl Into<Len>) -> Self {
self.attrs.overflow_position = position.into();
self self
} }
pub fn spans(mut self, spans: Vec<SpanStyle>) -> Self { pub fn spans(mut self, spans: Vec<SpanStyle>) -> Self {
+9 -1
View File
@@ -24,7 +24,7 @@ pub enum Motion {
} }
pub struct TextEdit { pub struct TextEdit {
view: TextView, pub(super) view: TextView,
#[cfg_attr(target_os = "android", allow(dead_code))] #[cfg_attr(target_os = "android", allow(dead_code))]
history: Vec<(String, Option<Selection>)>, history: Vec<(String, Option<Selection>)>,
pub(crate) press_origin: Option<Vec2>, pub(crate) press_origin: Option<Vec2>,
@@ -97,6 +97,14 @@ impl TextEdit {
} }
} }
pub fn set_overflow(&mut self, overflow: TextOverflow) {
self.view.set_overflow(overflow);
}
pub fn set_overflow_position(&mut self, position: impl Into<Len>) {
self.view.set_overflow_position(position);
}
fn layout(&self) -> Ref<'_, Layout<iris_core::PaintId>> { fn layout(&self) -> Ref<'_, Layout<iris_core::PaintId>> {
self.view.text.layout() self.view.text.layout()
} }
+387 -14
View File
@@ -16,6 +16,25 @@ pub struct TextView {
text: TextHandle, text: TextHandle,
pub hint: Option<StrongWidget>, pub hint: Option<StrongWidget>,
selection: TextSelection, selection: TextSelection,
overflow: OverflowState,
}
#[derive(Default)]
struct OverflowState {
active: bool,
viewport: Vec2,
pan: f32,
leading_marker: f32,
trailing_marker: f32,
suppress_content: bool,
leading_omitted: Option<OmittedText>,
trailing_omitted: Option<OmittedText>,
}
#[derive(Clone)]
struct OmittedText {
range: std::ops::Range<usize>,
far_edge: usize,
} }
impl TextView { impl TextView {
@@ -32,20 +51,22 @@ impl TextView {
text, text,
hint, hint,
selection: TextSelection::default(), selection: TextSelection::default(),
overflow: OverflowState::default(),
} }
} }
pub fn region(&self) -> UiRegion { pub fn region(&self) -> UiRegion {
let align = self.text.attrs().align; let align = self.text.attrs().align;
self.text let size = if self.overflow.active {
.rendered() self.overflow.viewport
.map(|t| t.size) } else {
.unwrap_or(Vec2::ZERO) self.text.rendered().map(|t| t.size).unwrap_or(Vec2::ZERO)
.align(align) };
size.align(align)
} }
fn render(&mut self, painter: &mut Painter) -> RenderedText { fn render(&mut self, painter: &mut Painter) -> RenderedText {
let width = if self.text.attrs().wrap { let width = if self.text.attrs().overflow == TextOverflow::Wrap {
Some(painter.px_size().x) Some(painter.px_size().x)
} else { } else {
None None
@@ -63,23 +84,256 @@ impl TextView {
tex tex
} }
pub fn draw(&mut self, painter: &mut Painter) -> Size { fn omitted_range(&self, start_x: f32, end_x: f32, far_x: f32) -> OmittedText {
self.text.with_layout(|layout, text| {
let at = |x| {
parley::Selection::from_point(layout, x, self.overflow.viewport.y * 0.5)
.focus()
.index()
};
let a = at(start_x).min(text.len());
let b = at(end_x).min(text.len());
// Parley selections are contiguous source ranges. That is exact
// for an ordinary LTR or RTL run; a clipped side of pathological
// mixed-bidi text can cover discontiguous logical ranges, which
// this selection model cannot express.
OmittedText {
range: a.min(b)..a.max(b),
far_edge: at(far_x).min(text.len()),
}
})
}
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;
pos
}
fn omitted_at(&self, pos: Vec2, size: Vec2) -> Option<OmittedText> {
let pos = pos - self.region().top_left().to_abs(size);
if self.overflow.suppress_content && pos.x >= 0.0 && pos.x <= self.overflow.viewport.x {
let len = self.text.text().len();
Some(OmittedText {
range: 0..len,
far_edge: if pos.x < self.overflow.viewport.x * 0.5 {
0
} else {
len
},
})
} else if pos.x >= 0.0 && pos.x <= self.overflow.leading_marker {
self.overflow.leading_omitted.clone()
} else if pos.x >= self.overflow.viewport.x - self.overflow.trailing_marker
&& pos.x <= self.overflow.viewport.x
{
self.overflow.trailing_omitted.clone()
} else {
None
}
}
fn omitted_is_selected(&self, omitted: &OmittedText) -> bool {
self.selection.range.is_some_and(|selection| {
let selected = selection.text_range();
selected.start < omitted.range.end && omitted.range.start < selected.end
})
}
fn prepare_overflow(
&mut self,
painter: &mut Painter,
tex: &RenderedText,
follow_caret: bool,
) -> Option<RenderedText> {
self.overflow = OverflowState::default();
let available = painter.px_size().x.max(0.0);
let excess = (tex.size.x - available).max(0.0);
let attrs = self.text.attrs().clone();
if excess <= 0.0 || matches!(attrs.overflow, TextOverflow::Visible | TextOverflow::Wrap) {
return None;
}
let marker =
(attrs.overflow == TextOverflow::Ellipsis).then(|| painter.render_ellipsis(&self.text));
let mut pan = attrs
.overflow_position
.resolve(painter.density())
.to_abs(excess)
.clamp(0.0, excess);
if follow_caret && let Some(selection) = self.selection.range {
let caret = self
.text
.with_layout(|layout, _| selection.focus().geometry(layout, CARET_WIDTH));
for _ in 0..2 {
let marker_width = marker.as_ref().map_or(0.0, |marker| marker.size.x);
let leading = pan > 0.0;
let trailing = pan < excess;
let markers_fit = marker_width
* (usize::from(leading) + usize::from(trailing)) as f32
<= available;
let leading_inset = if markers_fit && leading {
marker_width
} else {
0.0
};
let trailing_inset = if markers_fit && trailing {
marker_width
} else {
0.0
};
if caret.x0 as f32 <= pan + leading_inset {
pan = caret.x0 as f32 - leading_inset;
} else if caret.x1 as f32 >= pan + available - trailing_inset {
pan = caret.x1 as f32 - available + trailing_inset;
}
pan = pan.clamp(0.0, excess);
}
let old_pan = attrs
.overflow_position
.resolve(painter.density())
.to_abs(excess);
if pan != old_pan {
self.text.set_overflow_position(Len::abs(pan));
}
}
self.overflow.active = true;
self.overflow.viewport = vec2(available, tex.size.y);
self.overflow.pan = pan;
if attrs.overflow != TextOverflow::Ellipsis {
return None;
}
let marker = marker.expect("ellipsis mode prepared no ellipsis glyph");
let leading = pan > 0.0;
let trailing = pan < excess;
let markers_fit =
marker.size.x * (usize::from(leading) + usize::from(trailing)) as f32 <= available;
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 };
if leading {
self.overflow.leading_omitted =
Some(self.omitted_range(-tex.size.x, pan + marker.size.x, -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,
));
}
} else {
self.overflow.suppress_content = true;
}
Some(marker)
}
fn draw_inner(&mut self, painter: &mut Painter, follow_caret: bool) -> Size {
let tex = self.render(painter); let tex = self.render(painter);
if self.is_blank() if self.is_blank()
&& let Some(hint) = &self.hint && let Some(hint) = &self.hint
{ {
self.overflow = OverflowState::default();
return painter.widget(hint).size(); return painter.widget(hint).size();
} }
let region = tex.size.align(self.text.attrs().align); let marker = self.prepare_overflow(painter, &tex, follow_caret);
let within = region.within(&painter.region()); let attrs = self.text.attrs().clone();
painter.glyphs(&tex, within); if !self.overflow.active {
Size::abs(tex.size) let within = tex.size.align(attrs.align).within(&painter.region());
painter.glyphs(&tex, within);
return Size::abs(tex.size);
}
let viewport = self.overflow.viewport.align(attrs.align);
let marker_highlight = painter.paint(&PaintId::SKY);
if self.overflow.suppress_content
&& self
.selection
.range
.is_some_and(|selection| !selection.is_collapsed())
{
painter.primitive_within(
RectPrimitive::color(marker_highlight),
viewport.within(&painter.region()),
);
} else {
if self
.overflow
.leading_omitted
.as_ref()
.is_some_and(|omitted| self.omitted_is_selected(omitted))
{
let marker_region = vec2(self.overflow.leading_marker, self.overflow.viewport.y)
.align(Align::TOP_LEFT)
.within(&viewport)
.within(&painter.region());
painter.primitive_within(RectPrimitive::color(marker_highlight), marker_region);
}
if self
.overflow
.trailing_omitted
.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());
painter.primitive_within(RectPrimitive::color(marker_highlight), marker_region);
}
}
if let Some(marker) = marker {
if self.overflow.leading_marker > 0.0 {
painter.glyphs(&marker, viewport.within(&painter.region()));
}
if self.overflow.trailing_marker > 0.0 {
let offset = vec2(
self.overflow.viewport.x - self.overflow.trailing_marker,
0.0,
);
painter.glyphs(&marker, viewport.offset(offset).within(&painter.region()));
}
}
painter.child_layer();
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)
};
let mask = vec2(content_width, self.overflow.viewport.y)
.align(Align::TOP_LEFT)
.offset(vec2(self.overflow.leading_marker, 0.0))
.within(&viewport)
.within(&painter.region());
painter.set_mask(mask);
let origin = tex
.size
.align(Align::TOP_LEFT)
.offset(vec2(-self.overflow.pan, 0.0))
.within(&viewport)
.within(&painter.region());
painter.glyphs(&tex, origin);
Size::abs(self.overflow.viewport)
}
pub fn draw(&mut self, painter: &mut Painter) -> Size {
self.draw_inner(painter, false)
} }
pub(super) fn draw_selectable(&mut self, painter: &mut Painter, caret: bool) -> Size { pub(super) fn draw_selectable(&mut self, painter: &mut Painter, caret: bool) -> Size {
let base = painter.layer; let base = painter.layer;
painter.child_layer(); painter.child_layer();
let used = self.draw(painter); let used = self.draw_inner(painter, caret);
painter.layer = base; painter.layer = base;
let region = self.region(); let region = self.region();
@@ -91,7 +345,7 @@ impl TextView {
.with_layout(|layout, _| selection.geometry(layout)); .with_layout(|layout, _| selection.geometry(layout));
for (rect, _) in geometry { for (rect, _) in geometry {
let size = vec2(rect.width() as f32, rect.height() as f32); let size = vec2(rect.width() as f32, rect.height() as f32);
let top_left = vec2(rect.x0 as f32, rect.y0 as f32); let top_left = vec2(rect.x0 as f32 - self.overflow.pan, rect.y0 as f32);
let paint = painter.paint(&PaintId::SKY); let paint = painter.paint(&PaintId::SKY);
painter.primitive_within( painter.primitive_within(
RectPrimitive::color(paint), RectPrimitive::color(paint),
@@ -103,7 +357,7 @@ 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, caret.y0 as f32); let top_left = vec2(caret.x0 as f32 - self.overflow.pan, 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),
@@ -120,6 +374,14 @@ impl TextView {
pub fn attrs_mut(&mut self) -> std::cell::RefMut<'_, TextAttrs> { pub fn attrs_mut(&mut self) -> std::cell::RefMut<'_, TextAttrs> {
self.text.attrs_mut() self.text.attrs_mut()
} }
fn set_overflow(&mut self, overflow: TextOverflow) {
self.text.set_overflow(overflow);
}
fn set_overflow_position(&mut self, position: impl Into<Len>) {
self.text.set_overflow_position(position.into());
}
} }
impl Text { impl Text {
@@ -150,6 +412,14 @@ impl Text {
pub fn attrs_mut(&mut self) -> std::cell::RefMut<'_, TextAttrs> { pub fn attrs_mut(&mut self) -> std::cell::RefMut<'_, TextAttrs> {
self.view.attrs_mut() self.view.attrs_mut()
} }
pub fn set_overflow(&mut self, overflow: TextOverflow) {
self.view.set_overflow(overflow);
}
pub fn set_overflow_position(&mut self, position: impl Into<Len>) {
self.view.set_overflow_position(position);
}
} }
impl Widget for Text { impl Widget for Text {
@@ -280,4 +550,107 @@ mod tests {
re-rendering them against the fresh atlas" re-rendering them against the fresh atlas"
); );
} }
#[test]
fn ellipsis_position_moves_without_reshaping_and_is_not_a_source_hit() {
let mut rsc = TestRsc { ui: Ui::default() };
let text = wtext("one two three four five")
.overflow(TextOverflow::Ellipsis)
.size(18)
.add(&mut rsc);
let root = text.width(abs(90)).add_strong(&mut rsc).any();
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
render.update(&root, &mut rsc);
render.take_counters();
let height = rsc.ui.widgets[text].view.overflow.viewport.y;
assert!(rsc.ui.widgets[text].view.overflow.trailing_marker > 0.0);
assert_eq!(
text.selection(&mut rsc)
.source_byte_at(vec2(89.0, height * 0.5), vec2(90.0, height)),
None,
);
rsc.ui.widgets[text].set_overflow_position(rel(0.5));
render.update(&root, &mut rsc);
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);
rsc.ui.widgets[text].set_overflow_position(rel(1));
render.update(&root, &mut rsc);
assert_eq!(render.take_counters().shapes, 0);
assert!(rsc.ui.widgets[text].view.overflow.leading_marker > 0.0);
assert_eq!(rsc.ui.widgets[text].view.overflow.trailing_marker, 0.0);
}
#[test]
fn selecting_an_ellipsis_selects_the_omitted_source_text() {
let mut rsc = TestRsc { ui: Ui::default() };
let text = wtext("one two three four five")
.overflow(TextOverflow::Ellipsis)
.size(18)
.add(&mut rsc);
let root = text.width(abs(90)).add_strong(&mut rsc).any();
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
render.update(&root, &mut rsc);
let height = rsc.ui.widgets[text].view.overflow.viewport.y;
text.selection(&mut rsc)
.select(vec2(89.0, height * 0.5), vec2(90.0, height), false, false);
let selected = rsc.ui.widgets[text].selected_text().unwrap();
assert!(!selected.is_empty());
assert!("one two three four five".ends_with(&selected));
assert!(!selected.contains('…'));
}
#[test]
fn a_viewport_too_narrow_for_its_markers_exposes_no_hidden_source_hit() {
let mut rsc = TestRsc { ui: Ui::default() };
let text = wtext("too wide")
.overflow(TextOverflow::Ellipsis)
.size(18)
.add(&mut rsc);
let root = text.width(abs(1)).add_strong(&mut rsc).any();
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
render.update(&root, &mut rsc);
let height = rsc.ui.widgets[text].view.overflow.viewport.y;
assert_eq!(
text.selection(&mut rsc)
.source_byte_at(vec2(0.5, height * 0.5), vec2(1.0, height)),
None,
);
text.selection(&mut rsc)
.select(vec2(0.5, height * 0.5), vec2(1.0, height), false, false);
assert_eq!(
rsc.ui.widgets[text].selected_text().as_deref(),
Some("too wide")
);
}
#[test]
fn an_editable_caret_updates_the_overflow_position_it_exposes() {
let mut rsc = TestRsc { ui: Ui::default() };
let edit = wtext("one two three four five")
.overflow(TextOverflow::Hidden)
.editable(EditMode::SingleLine)
.size(18)
.add(&mut rsc);
let root = edit.width(abs(90)).add_strong(&mut rsc).any();
let mut render = UiRenderState::new();
render.resize((800.0, 600.0));
render.update(&root, &mut rsc);
let len = rsc.ui.widgets[edit].content().len();
edit(&mut rsc).set_cursor_byte(len);
render.update(&root, &mut rsc);
let attrs = rsc.ui.widgets[edit].view.text.attrs();
assert!(attrs.overflow_position.abs > 0.0);
assert_eq!(attrs.overflow_position.rel, 0.0);
}
} }
+46 -2
View File
@@ -55,7 +55,10 @@ impl TextSelectionCtx<'_> {
/// The byte offset in the text nearest `pos`. Positions and `size` use /// The byte offset in the text nearest `pos`. Positions and `size` use
/// the same widget-local coordinates as a `CursorSense` event. /// the same widget-local coordinates as a `CursorSense` event.
pub fn byte_at(&mut self, pos: Vec2, size: Vec2) -> usize { pub fn byte_at(&mut self, pos: Vec2, size: Vec2) -> usize {
let pos = pos - self.view.region().top_left().to_abs(size); if let Some(omitted) = self.view.omitted_at(pos, size) {
return omitted.far_edge;
}
let pos = self.view.local_text_point(pos, size);
self.view.text.with_layout(|layout, _| { self.view.text.with_layout(|layout, _| {
ParleySelection::from_point(layout, pos.x, pos.y) ParleySelection::from_point(layout, pos.x, pos.y)
.focus() .focus()
@@ -63,6 +66,17 @@ impl TextSelectionCtx<'_> {
}) })
} }
/// Like [`byte_at`](Self::byte_at), except an ellipsis is presentation
/// rather than source text and therefore returns `None` instead of a
/// nearby byte. Use this for links and other source-span hit testing.
pub fn source_byte_at(&mut self, pos: Vec2, size: Vec2) -> Option<usize> {
if self.view.omitted_at(pos, size).is_some() {
None
} else {
Some(self.byte_at(pos, size))
}
}
pub fn select_all(&mut self) { pub fn select_all(&mut self) {
let len = self.view.text.text().len(); let len = self.view.text.text().len();
if len == 0 { if len == 0 {
@@ -76,11 +90,41 @@ impl TextSelectionCtx<'_> {
} }
pub fn select(&mut self, pos: Vec2, size: Vec2, drag: bool, recent: bool) { pub fn select(&mut self, pos: Vec2, size: Vec2, drag: bool, recent: bool) {
let pos = pos - self.view.region().top_left().to_abs(size); let omitted = self.view.omitted_at(pos, size);
let pos = self.view.local_text_point(pos, size);
let previous = self.view.selection.range; let previous = self.view.selection.range;
let previous_hit = self.view.selection.double_hit; let previous_hit = self.view.selection.double_hit;
let outcome = self.view.text.with_layout(|layout, _| { let outcome = self.view.text.with_layout(|layout, _| {
if let Some(omitted) = omitted {
let anchor = if drag {
previous
.map(|selection| selection.anchor())
.unwrap_or_else(|| {
parley::Cursor::from_byte_index(
layout,
omitted.range.start,
Affinity::default(),
)
})
} else {
parley::Cursor::from_byte_index(
layout,
omitted.range.start,
Affinity::default(),
)
};
let focus = parley::Cursor::from_byte_index(
layout,
if drag {
omitted.far_edge
} else {
omitted.range.end
},
Affinity::default(),
);
return Some((Some(ParleySelection::new(anchor, focus)), None));
}
if drag { if drag {
previous.map(|selection| { previous.map(|selection| {
( (