iris: add positioned text overflow

This commit is contained in:
iris committed 2026-09-12 19:58:02 -04:00
1 parent cb5f11d21a
commit 1f0a7b5f94
22 files changed
+771 -43

No files matched your search

+1 -1
View File
@@ -83,7 +83,7 @@ impl DesktopAppState for Client {
Err(message) => { Err(message) => {
let text = wtext(format!("Couldn't fold the bench fixture: {message}")) let text = wtext(format!("Couldn't fold the bench fixture: {message}"))
.color(PaintId::WHITE) .color(PaintId::WHITE)
.wrap(true) .overflow(TextOverflow::Wrap)
.pad(dp(16)) .pad(dp(16))
.add_strong(rsc) .add_strong(rsc)
.any(); .any();
+2 -2
View File
@@ -69,7 +69,7 @@ struct ImeState {
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget { fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
wtext(message.to_string()) wtext(message.to_string())
.color(PaintId::WHITE) .color(PaintId::WHITE)
.wrap(true) .overflow(TextOverflow::Wrap)
.pad(16) .pad(16)
.add_strong(rsc) .add_strong(rsc)
.any() .any()
@@ -122,7 +122,7 @@ impl BenchClient {
let report_display = wtext("") let report_display = wtext("")
.editable(EditMode::MultiLine) .editable(EditMode::MultiLine)
.text_align(Align::LEFT) .text_align(Align::LEFT)
.wrap(true) .overflow(TextOverflow::Wrap)
.size(14) .size(14)
.color(PaintId::WHITE) .color(PaintId::WHITE)
.attr::<Selectable>(()) .attr::<Selectable>(())
+1 -1
View File
@@ -37,7 +37,7 @@ fn build_transport() -> Result<UreqTransport, String> {
fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget { fn placeholder<Rsc: HasEvents>(rsc: &mut Rsc, message: &str) -> StrongWidget {
wtext(message.to_string()) wtext(message.to_string())
.color(PaintId::WHITE) .color(PaintId::WHITE)
.wrap(true) .overflow(TextOverflow::Wrap)
.pad(16) .pad(16)
.add_strong(rsc) .add_strong(rsc)
.any() .any()
+2 -2
View File
@@ -325,7 +325,7 @@ fn session_row(rsc: &mut StdRsc<Client>, session: &SessionSummary, selected: boo
let label = format!("{}\n{}", session.title, session.status); let label = format!("{}\n{}", session.title, session.status);
wtext(label) wtext(label)
.color(PaintId::WHITE) .color(PaintId::WHITE)
.wrap(true) .overflow(TextOverflow::Wrap)
.pad(10) .pad(10)
.width(rest(1)) .width(rest(1))
.background(rect(bg)) .background(rect(bg))
@@ -342,7 +342,7 @@ fn session_row(rsc: &mut StdRsc<Client>, session: &SessionSummary, selected: boo
fn placeholder(rsc: &mut StdRsc<Client>, message: &str) -> StrongWidget { fn placeholder(rsc: &mut StdRsc<Client>, message: &str) -> StrongWidget {
wtext(message.to_string()) wtext(message.to_string())
.color(PaintId::WHITE) .color(PaintId::WHITE)
.wrap(true) .overflow(TextOverflow::Wrap)
.pad(16) .pad(16)
.add_strong(rsc) .add_strong(rsc)
.any() .any()
+1 -1
View File
@@ -53,7 +53,7 @@ where
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(theme.text.clone()) .color(theme.text.clone())
.attr::<Selectable>(()) .attr::<Selectable>(())
+7 -3
View File
@@ -197,7 +197,11 @@ where
let field = wtext(rendered.text) let field = wtext(rendered.text)
.spans(rendered.spans) .spans(rendered.spans)
.text_align(Align::LEFT) .text_align(Align::LEFT)
.wrap(!verbatim) .overflow(if verbatim {
TextOverflow::Visible
} else {
TextOverflow::Wrap
})
.family(if verbatim { MONOSPACE } else { SANS_SERIF }) .family(if verbatim { MONOSPACE } else { SANS_SERIF })
.size(BASE_SIZE) .size(BASE_SIZE)
.color(match frame { .color(match frame {
@@ -218,8 +222,8 @@ where
.unwrap_or(SelectionInput::Tapped); .unwrap_or(SelectionInput::Tapped);
// Panning or selecting across a link must not open it. // Panning or selecting across a link must not open it.
if outcome == SelectionInput::Tapped { if outcome == SelectionInput::Tapped {
let byte = field.selection(rsc).byte_at(pos, size); let byte = field.selection(rsc).source_byte_at(pos, size);
let url = tap_links.url_at(byte); let url = byte.and_then(|byte| tap_links.url_at(byte));
if let Some(url) = url { if let Some(url) = url {
log::info!("iris link: opening {url}"); log::info!("iris link: opening {url}");
<Rsc::State as OpenUrl>::open_url(ctx.state, &url); <Rsc::State as OpenUrl>::open_url(ctx.state, &url);
+1 -2
View File
@@ -87,7 +87,7 @@ fn raw_block<Rsc: HasEvents>(rsc: &mut Rsc, body: TextBuilder<Rsc>, theme: &Them
where where
Rsc::State: FocusHost, Rsc::State: FocusHost,
{ {
let field = body.family(MONOSPACE).size(BODY_SIZE).wrap(false).add(rsc); let field = body.family(MONOSPACE).size(BODY_SIZE).add(rsc);
field field
.scrollable(Axis::X, Pin::Start) .scrollable(Axis::X, Pin::Start)
.pad(dp(RAW_PAD_DP)) .pad(dp(RAW_PAD_DP))
@@ -281,7 +281,6 @@ where
} }
(false, Some(title)) => header.push( (false, Some(title)) => header.push(
text(title.to_string(), BODY_SIZE, shared.theme.muted.clone()) text(title.to_string(), BODY_SIZE, shared.theme.muted.clone())
.wrap(false)
.masked() .masked()
.width(rest(1)) .width(rest(1))
.add_strong(rsc) .add_strong(rsc)
+21
View File
@@ -847,6 +847,27 @@ such as corner radius and text's position within horizontal overflow use
`Len`. Flexible padding participates in the same proportional allocation as a `Len`. Flexible padding participates in the same proportional allocation as a
span rather than silently discarding its `rest` component. span rather than silently discarding its `rest` component.
**Text overflow is treatment plus position, not alignment** (2026-09-12).
`TextOverflow` selects `Visible`, `Wrap`, `Hidden`, or the single `Ellipsis`
treatment. Unwrapped hidden and ellipsized text retains one canonical shaped
layout and is translated behind a retained mask; the ellipsis is a separately
shaped presentation glyph, never a byte in the source buffer. Consequently it
cannot be copied or activate a source span. Selecting the marker selects the
omitted source range. `overflow_position: Len` places the canonical text only
when it exceeds its viewport: zero keeps the text start at the viewport start,
`rel(1)` keeps the text end at the viewport end, and intermediate or animated
values expose both ends. This is independent of alignment when the viewport is
spacious. An editable caret that enters clipped content writes the needed
absolute position back into this same public field; callers animating it own
stopping their animation when editing begins. Position changes and changes
among the unwrapped treatments reuse shaping; entering or leaving `Wrap`
reshapes at the viewport width, while content, font, spans, or density retain
the normal shape invalidation path.
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
discontiguous logical ranges on one visual side, which Iris's deliberately
single-range selection model cannot represent exactly.
`cargo-iris` is an installable Cargo subcommand, rather than a script callers `cargo-iris` is an installable Cargo subcommand, rather than a script callers
must find inside an Iris checkout. `cargo iris apk` builds the Rust `cdylib` must find inside an Iris checkout. `cargo iris apk` builds the Rust `cdylib`
with cargo-ndk and packages it directly with the installed Android SDK tools: with cargo-ndk and packages it directly with the installed Android SDK tools:
+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| {
( (