iris: add positioned text overflow
This commit is contained in:
1 parent
1f15125992
commit
a475ae772f
14 files changed
+735
-31
No files matched your search
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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<PaintId>,
|
||||
spans: Vec<SpanStyle>,
|
||||
shaped: Option<(TextAttrs, Option<f32>, f32)>,
|
||||
shaped: Option<(TextShapeAttrs, Option<f32>, f32)>,
|
||||
}
|
||||
|
||||
impl TextBuffer {
|
||||
@@ -671,7 +701,8 @@ impl TextBuffer {
|
||||
width: Option<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;
|
||||
}
|
||||
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<RenderedText>,
|
||||
ellipsis_rendered: Option<(TextShapeAttrs, f32, RenderedText)>,
|
||||
width: Option<f32>,
|
||||
density: f32,
|
||||
owner: Option<WidgetId>,
|
||||
@@ -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<RenderedText> {
|
||||
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)]
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ fn build_row<Rsc: UiRsc + 'static>(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: UiRsc + 'static>(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))
|
||||
|
||||
@@ -123,7 +123,7 @@ where
|
||||
.editable(EditMode::MultiLine)
|
||||
.size(30)
|
||||
.text_align(Align::LEFT)
|
||||
.wrap(true)
|
||||
.overflow(TextOverflow::Wrap)
|
||||
.attr::<Selectable>(());
|
||||
let fill = rsc
|
||||
.ui_mut()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
use iris::prelude::*;
|
||||
|
||||
#[path = "lib.rs"]
|
||||
mod app;
|
||||
|
||||
fn main() {
|
||||
DesktopApp::run_with(app::build);
|
||||
}
|
||||
@@ -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
@@ -484,7 +484,7 @@ fn composer_like_tree(rsc: &mut TestRsc) -> (WeakWidget<TextEdit>, StrongWidget)
|
||||
let field = wtext("")
|
||||
.editable(EditMode::MultiLine)
|
||||
.text_align(Align::LEFT)
|
||||
.wrap(true)
|
||||
.overflow(TextOverflow::Wrap)
|
||||
.size(18)
|
||||
.color(PaintId::WHITE)
|
||||
.add(rsc);
|
||||
|
||||
@@ -36,8 +36,12 @@ impl<State, O, H: WidgetOption<State>> TextBuilder<State, O, H> {
|
||||
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<Len>) -> Self {
|
||||
self.attrs.overflow_position = position.into();
|
||||
self
|
||||
}
|
||||
pub fn spans(mut self, spans: Vec<SpanStyle>) -> Self {
|
||||
|
||||
@@ -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<Selection>)>,
|
||||
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>> {
|
||||
self.view.text.layout()
|
||||
}
|
||||
|
||||
+387
-14
@@ -16,6 +16,25 @@ pub struct TextView {
|
||||
text: TextHandle,
|
||||
pub hint: Option<StrongWidget>,
|
||||
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 {
|
||||
@@ -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<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);
|
||||
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<Len>) {
|
||||
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<Len>) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<usize> {
|
||||
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| {
|
||||
(
|
||||
|
||||
Reference in new issue
Block a user