From a475ae772fb2e74f3eed2dc3f62ea04bec7f5066 Mon Sep 17 00:00:00 2001 From: iris <2+iris@noreply.localhost> Date: Sat, 12 Sep 2026 19:58:02 -0400 Subject: [PATCH] iris: add positioned text overflow --- Cargo.toml | 4 + benches/message_list.rs | 6 +- core/src/primitive/text.rs | 96 ++++++++- core/src/ui/painter.rs | 8 + examples/message_list/lib.rs | 4 +- examples/tabs/lib.rs | 2 +- examples/text/android.rs | 9 + examples/text/desktop.rs | 8 + examples/text/lib.rs | 160 ++++++++++++++ src/layout_tests.rs | 2 +- src/widget/text/build.rs | 8 +- src/widget/text/edit.rs | 10 +- src/widget/text/mod.rs | 401 +++++++++++++++++++++++++++++++++-- src/widget/text/selection.rs | 48 ++++- 14 files changed, 735 insertions(+), 31 deletions(-) create mode 100644 examples/text/android.rs create mode 100644 examples/text/desktop.rs create mode 100644 examples/text/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 5ddcbd7..a973caa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,10 @@ path = "examples/tabs/desktop.rs" name = "task" path = "examples/task/desktop.rs" +[[example]] +name = "text" +path = "examples/text/desktop.rs" + [[example]] name = "view" path = "examples/view/desktop.rs" diff --git a/benches/message_list.rs b/benches/message_list.rs index b675081..46a2117 100644 --- a/benches/message_list.rs +++ b/benches/message_list.rs @@ -22,7 +22,7 @@ typical phone column width."; fn build_row(rsc: &mut BenchRsc, i: usize, image_every: usize) -> StrongWidget { let text = wtext(format!("Message {i}: {BODY}")) - .wrap(true) + .overflow(TextOverflow::Wrap) .add_strong(rsc) .any(); @@ -322,7 +322,9 @@ fn bench_redraw_big_text(chars: usize, redraws: usize) { let content: String = (0..chars) .map(|i| char::from(b'a' + (i % 26) as u8)) .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 root = text.any(); diff --git a/core/src/primitive/text.rs b/core/src/primitive/text.rs index 11e4353..1b44c5e 100644 --- a/core/src/primitive/text.rs +++ b/core/src/primitive/text.rs @@ -1,5 +1,5 @@ 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}, }; use parley::{ @@ -584,10 +584,39 @@ pub struct TextAttrs { pub font_size: f32, pub line_height: f32, pub family: String, - pub wrap: bool, + pub overflow: TextOverflow, + pub overflow_position: Len, 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; impl Default for TextAttrs { @@ -598,7 +627,8 @@ impl Default for TextAttrs { font_size: size, line_height: size * LINE_HEIGHT_MULT, family: SANS_SERIF.to_owned(), - wrap: false, + overflow: TextOverflow::Visible, + overflow_position: Len::ZERO, align: Align::CENTER_LEFT, } } @@ -611,7 +641,7 @@ pub struct TextBuffer { text: String, layout: Layout, spans: Vec, - shaped: Option<(TextAttrs, Option, f32)>, + shaped: Option<(TextShapeAttrs, Option, f32)>, } impl TextBuffer { @@ -671,7 +701,8 @@ impl TextBuffer { width: Option, 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; } let base_family = data.resolve_family(&attrs.family); @@ -719,7 +750,7 @@ impl TextBuffer { self.layout.break_all_lines(width); self.layout .align(Alignment::Start, AlignmentOptions::default()); - self.shaped = Some((attrs.clone(), width, density)); + self.shaped = Some((shape_attrs, width, density)); } fn invalidate(&mut self) { @@ -762,8 +793,10 @@ pub struct RenderedText { pub struct TextRsc { buffer: TextBuffer, + ellipsis_buffer: TextBuffer, attrs: TextAttrs, rendered: Option, + ellipsis_rendered: Option<(TextShapeAttrs, f32, RenderedText)>, width: Option, density: f32, owner: Option, @@ -773,8 +806,10 @@ impl TextRsc { fn new(buffer: TextBuffer, attrs: TextAttrs) -> Self { Self { buffer, + ellipsis_buffer: TextBuffer::new("…"), attrs, rendered: None, + ellipsis_rendered: None, width: None, density: 0.0, owner: None, @@ -784,6 +819,8 @@ impl TextRsc { fn invalidate(&mut self) { self.buffer.invalidate(); self.rendered = None; + self.ellipsis_buffer.invalidate(); + self.ellipsis_rendered = None; } } @@ -885,6 +922,34 @@ impl TextResources { resource.rendered = Some(rendered.clone()); (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 { @@ -961,6 +1026,14 @@ impl TextHandle { 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 { self.rsc.get().rendered.clone() } @@ -996,6 +1069,17 @@ impl TextHandle { let mut resource = self.rsc.get_mut_shared(); 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)] diff --git a/core/src/ui/painter.rs b/core/src/ui/painter.rs index 0d74380..9a1dc06 100644 --- a/core/src/ui/painter.rs +++ b/core/src/ui/painter.rs @@ -422,6 +422,14 @@ impl<'a> Painter<'a> { 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 { self.rsc.ui().text.borrow().atlas.generation() } diff --git a/examples/message_list/lib.rs b/examples/message_list/lib.rs index 0038bb6..bfdf231 100644 --- a/examples/message_list/lib.rs +++ b/examples/message_list/lib.rs @@ -24,7 +24,7 @@ fn build_row(rsc: &mut Rsc, i: usize) -> StrongWidget { let text_color = PaintId::BLACK; if i.is_multiple_of(IMAGE_EVERY) { let text = wtext(row_text(i)) - .wrap(true) + .overflow(TextOverflow::Wrap) .color(text_color) .add_strong(rsc) .any(); @@ -39,7 +39,7 @@ fn build_row(rsc: &mut Rsc, i: usize) -> StrongWidget { .any() } else { wtext(row_text(i)) - .wrap(true) + .overflow(TextOverflow::Wrap) .color(text_color) .pad(dp(8.0)) .background(rect(tint)) diff --git a/examples/tabs/lib.rs b/examples/tabs/lib.rs index e435fb3..1bd6728 100644 --- a/examples/tabs/lib.rs +++ b/examples/tabs/lib.rs @@ -123,7 +123,7 @@ where .editable(EditMode::MultiLine) .size(30) .text_align(Align::LEFT) - .wrap(true) + .overflow(TextOverflow::Wrap) .attr::(()); let fill = rsc .ui_mut() diff --git a/examples/text/android.rs b/examples/text/android.rs new file mode 100644 index 0000000..8b188f7 --- /dev/null +++ b/examples/text/android.rs @@ -0,0 +1,9 @@ +use iris::prelude::*; + +#[path = "lib.rs"] +mod app; + +#[iris::android_init] +fn create(ui_state: &mut AndroidUiState, rsc: &mut StdRsc) { + app::build(rsc, ui_state); +} diff --git a/examples/text/desktop.rs b/examples/text/desktop.rs new file mode 100644 index 0000000..63fade8 --- /dev/null +++ b/examples/text/desktop.rs @@ -0,0 +1,8 @@ +use iris::prelude::*; + +#[path = "lib.rs"] +mod app; + +fn main() { + DesktopApp::run_with(app::build); +} diff --git a/examples/text/lib.rs b/examples/text/lib.rs new file mode 100644 index 0000000..32d689f --- /dev/null +++ b/examples/text/lib.rs @@ -0,0 +1,160 @@ +use iris::prelude::*; + +const SAMPLE: &str = "The quick brown fox jumps over the lazy dog"; + +fn overflow_row( + rsc: &mut Rsc, + label: &str, + overflow: TextOverflow, + position: Len, + fill: PaintId, +) -> WeakWidget { + ( + 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: &mut Rsc, ui_state: &mut impl HasRoot) +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::(()) + .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::(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); +} diff --git a/src/layout_tests.rs b/src/layout_tests.rs index d9849f7..ef1a50b 100644 --- a/src/layout_tests.rs +++ b/src/layout_tests.rs @@ -484,7 +484,7 @@ fn composer_like_tree(rsc: &mut TestRsc) -> (WeakWidget, StrongWidget) let field = wtext("") .editable(EditMode::MultiLine) .text_align(Align::LEFT) - .wrap(true) + .overflow(TextOverflow::Wrap) .size(18) .color(PaintId::WHITE) .add(rsc); diff --git a/src/widget/text/build.rs b/src/widget/text/build.rs index 1211327..0385db4 100644 --- a/src/widget/text/build.rs +++ b/src/widget/text/build.rs @@ -36,8 +36,12 @@ impl> TextBuilder { self.attrs.align = Align::CENTER; self } - pub fn wrap(mut self, wrap: bool) -> Self { - self.attrs.wrap = wrap; + pub fn overflow(mut self, overflow: TextOverflow) -> Self { + self.attrs.overflow = overflow; + self + } + pub fn overflow_position(mut self, position: impl Into) -> Self { + self.attrs.overflow_position = position.into(); self } pub fn spans(mut self, spans: Vec) -> Self { diff --git a/src/widget/text/edit.rs b/src/widget/text/edit.rs index 46dc012..72946f9 100644 --- a/src/widget/text/edit.rs +++ b/src/widget/text/edit.rs @@ -24,7 +24,7 @@ pub enum Motion { } pub struct TextEdit { - view: TextView, + pub(super) view: TextView, #[cfg_attr(target_os = "android", allow(dead_code))] history: Vec<(String, Option)>, pub(crate) press_origin: Option, @@ -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) { + self.view.set_overflow_position(position); + } + fn layout(&self) -> Ref<'_, Layout> { self.view.text.layout() } diff --git a/src/widget/text/mod.rs b/src/widget/text/mod.rs index aedb8c9..64f2441 100644 --- a/src/widget/text/mod.rs +++ b/src/widget/text/mod.rs @@ -16,6 +16,25 @@ pub struct TextView { text: TextHandle, pub hint: Option, 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, + trailing_omitted: Option, +} + +#[derive(Clone)] +struct OmittedText { + range: std::ops::Range, + far_edge: usize, } impl TextView { @@ -32,20 +51,22 @@ impl TextView { text, hint, selection: TextSelection::default(), + overflow: OverflowState::default(), } } pub fn region(&self) -> UiRegion { let align = self.text.attrs().align; - self.text - .rendered() - .map(|t| t.size) - .unwrap_or(Vec2::ZERO) - .align(align) + let size = if self.overflow.active { + self.overflow.viewport + } else { + self.text.rendered().map(|t| t.size).unwrap_or(Vec2::ZERO) + }; + size.align(align) } 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) } else { None @@ -63,23 +84,256 @@ impl TextView { 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 { + 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 { + 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); if self.is_blank() && let Some(hint) = &self.hint { + self.overflow = OverflowState::default(); return painter.widget(hint).size(); } - let region = tex.size.align(self.text.attrs().align); - let within = region.within(&painter.region()); - painter.glyphs(&tex, within); - Size::abs(tex.size) + let marker = self.prepare_overflow(painter, &tex, follow_caret); + let attrs = self.text.attrs().clone(); + if !self.overflow.active { + 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 { let base = painter.layer; painter.child_layer(); - let used = self.draw(painter); + let used = self.draw_inner(painter, caret); painter.layer = base; let region = self.region(); @@ -91,7 +345,7 @@ impl TextView { .with_layout(|layout, _| selection.geometry(layout)); for (rect, _) in geometry { 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); painter.primitive_within( RectPrimitive::color(paint), @@ -103,7 +357,7 @@ 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, 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); painter.primitive_within( RectPrimitive::color(paint), @@ -120,6 +374,14 @@ impl TextView { pub fn attrs_mut(&mut self) -> std::cell::RefMut<'_, TextAttrs> { 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) { + self.text.set_overflow_position(position.into()); + } } impl Text { @@ -150,6 +412,14 @@ impl Text { pub fn attrs_mut(&mut self) -> std::cell::RefMut<'_, TextAttrs> { 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) { + self.view.set_overflow_position(position); + } } impl Widget for Text { @@ -280,4 +550,107 @@ mod tests { 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); + } } diff --git a/src/widget/text/selection.rs b/src/widget/text/selection.rs index ad05316..c3e5c28 100644 --- a/src/widget/text/selection.rs +++ b/src/widget/text/selection.rs @@ -55,7 +55,10 @@ impl TextSelectionCtx<'_> { /// The byte offset in the text nearest `pos`. Positions and `size` use /// the same widget-local coordinates as a `CursorSense` event. 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, _| { ParleySelection::from_point(layout, pos.x, pos.y) .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 { + if self.view.omitted_at(pos, size).is_some() { + None + } else { + Some(self.byte_at(pos, size)) + } + } + pub fn select_all(&mut self) { let len = self.view.text.text().len(); if len == 0 { @@ -76,11 +90,41 @@ impl TextSelectionCtx<'_> { } 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_hit = self.view.selection.double_hit; 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 { previous.map(|selection| { (