Three root causes, all fixed and verified on the Pixel 9 Pro: 1. Present phase (backend + renderer): VSYNC_LOCKED_RENDER defers the render to the vsync wake; RENDER_AHEAD presents the previous frame at the start of render() (phase = wake + ~1.1 ms) and holds the new frame one slot. Crash fix: a held wgpu frame must be PRESENTED, never merely dropped, before the next acquire (AlreadyAcquired panic at launch otherwise); discard_pending_frame presents too. 2. Full-res prefetch churn (src/lib.rs): the 8-slot full cache cycled through a ~20-cell viewport forever (137 decodes/20 s) and each eviction free()'d a 50 MB Vec on the UI thread (20-52 ms CPU spikes, ~7/s = the residual SLOWEV NonMain jank). Fix: 60 s tombstone cooldown on evicted paths (idle prefetch skips, urgent tap bypasses) + off-thread reaper for the freed blobs (insert: 21 ms -> 4 us). 3. (earlier) Skia final flush + Mailbox present mode (vendor, M3). Instrumentation (all behind --features perf): OTHR/INVK/INVK_TAG/FLCK lines for cross-thread closure timing and push-site tagging. Warm measured swipe, 8580-file grid: cadence median 8.33 ms (exact 120 Hz), p99 15.5 ms, 5/552 gaps >16.7 ms (0.9%, max 28.3 ms, zero >33 ms); PRES phase median ~994 us, zero late frames. Docs updated (docs/BUGS/scroll-jank-120hz.md, full scorecard + line reference). Also: vendored i-slint-core + wgpu-hal (vkshim interposition patch), vkshim profiling tooling, backend made a non-optional dep, debuggable flag for simpleperf (REMOVE before shipping).
1118 lines
41 KiB
Rust
1118 lines
41 KiB
Rust
// Copyright © SixtyFPS GmbH <info@slint.dev>
|
|
// SPDX-License-Identifier: GPL-3.0-only OR LicenseRef-Slint-Royalty-free-2.0 OR LicenseRef-Slint-Software-3.0
|
|
|
|
// cspell:ignore longestword nlongestword
|
|
|
|
// cSpell: ignore sharedparley
|
|
//! module for basic text layout
|
|
//!
|
|
//! The basic algorithm for breaking text into multiple lines:
|
|
//! 1. First we determine the boundaries for text shaping. As shaping happens based on a single font and we know that different fonts cater different
|
|
//! writing systems, we split up the text into chunks that maximize our chances of finding a font that covers all glyphs in the chunk. This way for
|
|
//! example arabic text can be covered by a font that has excellent arabic coverage while latin text is rendered using a different font.
|
|
//! Shaping boundaries are always also grapheme boundaries.
|
|
//! 2. Then we shape the text at shaping boundaries, to determine the metrics of glyphs and glyph clusters
|
|
//! 3. Loop over all glyph clusters as well as the line break opportunities produced by the unicode line break algorithm:
|
|
//! Sum up the width of all glyph clusters until the next line break opportunity (encapsulated in FragmentIterator), record separately the width of
|
|
//! trailing space within the fragment.
|
|
//! ```text
|
|
//! If the width of the current line (including trailing whitespace) and the new fragment of glyph clusters (without trailing whitespace) is less or
|
|
//! equal to the available width:
|
|
//! Add fragment of glyph clusters to the current line
|
|
//! Else:
|
|
//! Emit current line as new line
|
|
//! If encountering a mandatory line break opportunity:
|
|
//! Emit current line as new line
|
|
//! ```
|
|
|
|
use alloc::vec::Vec;
|
|
|
|
use euclid::num::{One, Zero};
|
|
|
|
use crate::items::{TextHorizontalAlignment, TextOverflow, TextVerticalAlignment, TextWrap};
|
|
|
|
/// The font size to lay text out with when neither the `.slint` code nor the platform
|
|
/// provide one. Last level of the precedence chain in
|
|
/// [`crate::items::WindowItem::resolved_default_font_size`].
|
|
pub const DEFAULT_FONT_SIZE: crate::lengths::LogicalLength =
|
|
crate::lengths::LogicalLength::new(12 as crate::Coord);
|
|
|
|
#[cfg(feature = "unicode-linebreak")]
|
|
mod linebreak_unicode;
|
|
#[cfg(feature = "unicode-linebreak")]
|
|
use linebreak_unicode::{BreakOpportunity, LineBreakIterator};
|
|
|
|
#[cfg(not(feature = "unicode-linebreak"))]
|
|
mod linebreak_simple;
|
|
#[cfg(not(feature = "unicode-linebreak"))]
|
|
use linebreak_simple::{BreakOpportunity, LineBreakIterator};
|
|
|
|
mod fragments;
|
|
mod glyphclusters;
|
|
mod shaping;
|
|
#[cfg(feature = "shared-parley")]
|
|
/// cbindgen:ignore
|
|
pub mod sharedparley;
|
|
use shaping::ShapeBuffer;
|
|
pub use shaping::{AbstractFont, CheckedAdd, DivCount, FontMetrics, Glyph, TextShaper};
|
|
|
|
mod linebreaker;
|
|
pub use linebreaker::TextLine;
|
|
|
|
pub use linebreaker::TextLineBreaker;
|
|
|
|
pub struct TextLayout<'a, Font: AbstractFont> {
|
|
pub font: &'a Font,
|
|
pub letter_spacing: Option<<Font as TextShaper>::Length>,
|
|
/// The absolute line height, or `None` to use [`FontMetrics::height`].
|
|
pub line_height: Option<<Font as TextShaper>::Length>,
|
|
}
|
|
|
|
impl<Font: AbstractFont> TextLayout<'_, Font> {
|
|
fn line_height(&self) -> Font::Length {
|
|
self.line_height.unwrap_or_else(|| self.font.height())
|
|
}
|
|
|
|
/// The offset from the top of a line box to the top of the glyph box within it: half of
|
|
/// the leading, following the CSS convention of centering the glyphs in the line box,
|
|
/// like the parley based layout. Negative when the line height is smaller than the
|
|
/// natural font height.
|
|
pub fn half_leading(&self) -> Font::Length {
|
|
let two = Font::LengthPrimitive::one() + Font::LengthPrimitive::one();
|
|
(self.line_height() - self.font.height()) / two
|
|
}
|
|
|
|
/// The origin (relative to the top of the line box) and height of cursor and selection
|
|
/// rectangles: the full line box, but never smaller than the glyph box, mirroring
|
|
/// parley's clamping for negative leading.
|
|
pub fn cursor_band(&self) -> (Font::Length, Font::Length) {
|
|
(
|
|
euclid::approxord::min(Font::Length::zero(), self.half_leading()),
|
|
euclid::approxord::max(self.line_height(), self.font.height()),
|
|
)
|
|
}
|
|
|
|
// Measures the size of the given text when rendered with the specified font and optionally constrained
|
|
// by the provided `max_width`.
|
|
// Returns a tuple of the width of the longest line as well as height of all lines.
|
|
pub fn text_size(
|
|
&self,
|
|
text: &str,
|
|
max_width: Option<Font::Length>,
|
|
text_wrap: TextWrap,
|
|
max_lines: Option<usize>,
|
|
) -> (Font::Length, Font::Length)
|
|
where
|
|
Font::Length: core::fmt::Debug,
|
|
{
|
|
let mut max_line_width = Font::Length::zero();
|
|
let mut line_count: i16 = 0;
|
|
let shape_buffer = ShapeBuffer::new(self, text);
|
|
|
|
for line in
|
|
TextLineBreaker::<Font>::new(text, &shape_buffer, max_width, max_lines, text_wrap)
|
|
{
|
|
max_line_width = euclid::approxord::max(max_line_width, line.text_width);
|
|
line_count += 1;
|
|
}
|
|
|
|
(max_line_width, self.line_height() * line_count.into())
|
|
}
|
|
|
|
// The min- and max-content width: the width of the widest chunk that cannot be broken
|
|
// up (the longest word), and the width the text takes without wrapping. Both are
|
|
// measured from a single shaping pass.
|
|
//
|
|
// `max_lines` drops the paragraphs that are not drawn, from both widths, so the minimum
|
|
// never asks for room that a word on a dropped line would need.
|
|
pub fn content_widths(
|
|
&self,
|
|
text: &str,
|
|
max_lines: Option<usize>,
|
|
) -> (Font::Length, Font::Length)
|
|
where
|
|
Font::Length: core::fmt::Debug,
|
|
{
|
|
let shape_buffer = ShapeBuffer::new(self, text);
|
|
|
|
// Fragments end at line break opportunities, and their width excludes the
|
|
// trailing whitespace, so the widest one is the longest word.
|
|
let mut min = Font::Length::zero();
|
|
let mut lines = 0;
|
|
for fragment in fragments::TextFragmentIterator::new(text, &shape_buffer) {
|
|
if max_lines.is_some_and(|max_lines| lines >= max_lines) {
|
|
break;
|
|
}
|
|
min = euclid::approxord::max(min, fragment.width);
|
|
if fragment.trailing_mandatory_break {
|
|
lines += 1;
|
|
}
|
|
}
|
|
|
|
// Without wrapping every paragraph is one line, so this is the max-content width.
|
|
let mut max = Font::Length::zero();
|
|
for line in
|
|
TextLineBreaker::<Font>::new(text, &shape_buffer, None, max_lines, TextWrap::NoWrap)
|
|
{
|
|
max = euclid::approxord::max(max, line.text_width);
|
|
}
|
|
|
|
(min, max)
|
|
}
|
|
}
|
|
|
|
pub struct PositionedGlyph<Length> {
|
|
pub x: Length,
|
|
pub y: Length,
|
|
pub advance: Length,
|
|
pub glyph_id: core::num::NonZeroU16,
|
|
pub text_byte_offset: usize,
|
|
}
|
|
|
|
pub struct TextParagraphLayout<'a, Font: AbstractFont> {
|
|
pub string: &'a str,
|
|
pub layout: TextLayout<'a, Font>,
|
|
pub max_width: Font::Length,
|
|
pub max_height: Font::Length,
|
|
pub horizontal_alignment: TextHorizontalAlignment,
|
|
pub vertical_alignment: TextVerticalAlignment,
|
|
pub wrap: TextWrap,
|
|
pub overflow: TextOverflow,
|
|
pub single_line: bool,
|
|
pub max_lines: Option<usize>,
|
|
}
|
|
|
|
impl<Font: AbstractFont> TextParagraphLayout<'_, Font> {
|
|
/// Layout the given string in lines, and call the `layout_line` callback with the line to draw at position y.
|
|
/// The signature of the `layout_line` function is: `(glyph_iterator, line_x, line_y, text_line, selection)`.
|
|
/// Returns the baseline y coordinate as Ok, or the break value if `line_callback` returns `core::ops::ControlFlow::Break`.
|
|
pub fn layout_lines<R>(
|
|
&self,
|
|
mut line_callback: impl FnMut(
|
|
&mut dyn Iterator<Item = PositionedGlyph<Font::Length>>,
|
|
Font::Length,
|
|
Font::Length,
|
|
&TextLine<Font::Length>,
|
|
Option<core::ops::Range<Font::Length>>,
|
|
) -> core::ops::ControlFlow<R>,
|
|
selection: Option<core::ops::Range<usize>>,
|
|
) -> Result<Font::Length, R> {
|
|
let wrap = self.wrap != TextWrap::NoWrap;
|
|
let elide = self.overflow == TextOverflow::Elide;
|
|
let elide_glyph = if elide {
|
|
self.layout.font.glyph_for_char('…').filter(|glyph| glyph.glyph_id.is_some())
|
|
} else {
|
|
None
|
|
};
|
|
let elide_width = elide_glyph.as_ref().map_or(Font::Length::zero(), |g| g.advance);
|
|
let max_width_without_elision = self.max_width - elide_width;
|
|
|
|
let shape_buffer = ShapeBuffer::new(&self.layout, self.string);
|
|
|
|
// When eliding, always keep at least the first line: when it is taller than the box,
|
|
// dropping it would render nothing at all, which is more confusing than a clipped line.
|
|
// The software renderer already clips glyphs to the Text geometry, so the vertical
|
|
// overflow is trimmed; horizontal elision still places an ellipsis if it is too
|
|
// wide. Mirrors the parley path, which always keeps line index 0.
|
|
let line_height = self.layout.line_height();
|
|
let max_lines_from_height = elide.then(|| self.max_lines_that_fit(line_height).max(1));
|
|
let max_lines = [self.max_lines, max_lines_from_height].into_iter().flatten().min();
|
|
|
|
let new_line_break_iter = || {
|
|
TextLineBreaker::<Font>::new(
|
|
self.string,
|
|
&shape_buffer,
|
|
if wrap { Some(self.max_width) } else { None },
|
|
max_lines,
|
|
self.wrap,
|
|
)
|
|
};
|
|
let mut text_lines = None;
|
|
|
|
let mut text_height = || {
|
|
if self.single_line {
|
|
line_height
|
|
} else {
|
|
text_lines = Some(new_line_break_iter().collect::<Vec<_>>());
|
|
line_height * (text_lines.as_ref().unwrap().len() as i16).into()
|
|
}
|
|
};
|
|
|
|
let two = Font::LengthPrimitive::one() + Font::LengthPrimitive::one();
|
|
|
|
let baseline_y = match self.vertical_alignment {
|
|
TextVerticalAlignment::Top => Font::Length::zero(),
|
|
TextVerticalAlignment::Center => self.max_height / two - text_height() / two,
|
|
TextVerticalAlignment::Bottom => self.max_height - text_height(),
|
|
};
|
|
|
|
let mut y = baseline_y;
|
|
let mut line_index = 0usize;
|
|
|
|
let mut process_line = |line: &TextLine<Font::Length>, glyphs: &[Glyph<Font::Length>]| {
|
|
let elide_long_line =
|
|
elide && (self.single_line || !wrap) && line.text_width > self.max_width;
|
|
// The last line before the line limit carries the ellipsis just like the last line
|
|
// that fits the height, so `max-lines` truncation is signalled the same way.
|
|
let reached_line_limit = max_lines.is_some_and(|max_lines| line_index + 1 == max_lines);
|
|
let elide_last_line = elide
|
|
&& line.glyph_range.end < glyphs.len()
|
|
&& (y + line_height * two > self.max_height || reached_line_limit);
|
|
|
|
// On a vertically truncated line the ellipsis is anchored right after the text by
|
|
// ignoring trailing whitespace, so it reads "please…" rather than "please …". The
|
|
// line breaker already excludes trailing whitespace from `byte_range`, so the glyphs
|
|
// past its end are exactly that whitespace to trim. This trimmed range is what gets
|
|
// measured (for alignment) and drawn; other lines keep the full range.
|
|
let glyph_range = if elide_last_line {
|
|
let trailing_whitespace_glyphs = glyphs[line.glyph_range.clone()]
|
|
.iter()
|
|
.rev()
|
|
.take_while(|glyph| glyph.text_byte_offset >= line.byte_range.end)
|
|
.count();
|
|
line.glyph_range.start..line.glyph_range.end - trailing_whitespace_glyphs
|
|
} else {
|
|
line.glyph_range.clone()
|
|
};
|
|
|
|
let text_width = || {
|
|
if elide_long_line || elide_last_line {
|
|
let mut text_width = Font::Length::zero();
|
|
for glyph in &glyphs[glyph_range.clone()] {
|
|
if text_width + glyph.advance > max_width_without_elision {
|
|
break;
|
|
}
|
|
text_width += glyph.advance;
|
|
}
|
|
return text_width + elide_width;
|
|
}
|
|
euclid::approxord::min(self.max_width, line.text_width)
|
|
};
|
|
|
|
let x = match self.horizontal_alignment {
|
|
TextHorizontalAlignment::Start | TextHorizontalAlignment::Left => {
|
|
Font::Length::zero()
|
|
}
|
|
TextHorizontalAlignment::Center => self.max_width / two - text_width() / two,
|
|
TextHorizontalAlignment::End | TextHorizontalAlignment::Right => {
|
|
self.max_width - text_width()
|
|
}
|
|
};
|
|
|
|
let mut elide_glyph = elide_glyph.as_ref();
|
|
|
|
let selection = selection
|
|
.as_ref()
|
|
.filter(|selection| {
|
|
line.byte_range.start < selection.end && selection.start < line.byte_range.end
|
|
})
|
|
.map(|selection| {
|
|
let mut begin = Font::Length::zero();
|
|
let mut end = Font::Length::zero();
|
|
for glyph in glyphs[line.glyph_range.clone()].iter() {
|
|
if glyph.text_byte_offset < selection.start {
|
|
begin += glyph.advance;
|
|
}
|
|
if glyph.text_byte_offset >= selection.end {
|
|
break;
|
|
}
|
|
end += glyph.advance;
|
|
}
|
|
begin..end
|
|
});
|
|
|
|
let glyph_it = glyphs[glyph_range.clone()].iter();
|
|
let mut glyph_x = Font::Length::zero();
|
|
// Up to two output glyphs per input glyph: the glyph itself, plus -- on the last glyph
|
|
// of a vertically truncated line that still fits the width -- an ellipsis appended
|
|
// after it (trailing whitespace was already trimmed from `glyph_range`).
|
|
let mut positioned_glyph_it = glyph_it.enumerate().flat_map(|(index, glyph)| {
|
|
let mut output: [Option<PositionedGlyph<Font::Length>>; 2] = [None, None];
|
|
// TODO: cut off at grapheme boundaries
|
|
if glyph_x > self.max_width {
|
|
return output.into_iter().flatten();
|
|
}
|
|
// A line that is too wide gets the ellipsis placed *instead of* the first glyph
|
|
// that no longer fits (horizontal elision); the remaining glyphs are dropped.
|
|
let elide_long_line = (elide_long_line || elide_last_line)
|
|
&& x + glyph_x + glyph.advance > max_width_without_elision;
|
|
if elide_long_line {
|
|
if let Some(elide_glyph) = elide_glyph.take() {
|
|
let x = glyph_x;
|
|
glyph_x += elide_glyph.advance;
|
|
output[0] = Some(PositionedGlyph {
|
|
x,
|
|
y: Font::Length::zero(),
|
|
advance: elide_glyph.advance,
|
|
glyph_id: elide_glyph.glyph_id.unwrap(), // checked earlier when initializing elide_glyph
|
|
text_byte_offset: glyph.text_byte_offset,
|
|
});
|
|
}
|
|
return output.into_iter().flatten();
|
|
}
|
|
|
|
let glyph_pos = glyph_x;
|
|
glyph_x += glyph.advance;
|
|
output[0] = glyph.glyph_id.map(|existing_glyph_id| PositionedGlyph {
|
|
x: glyph_pos,
|
|
y: Font::Length::zero(),
|
|
advance: glyph.advance,
|
|
glyph_id: existing_glyph_id,
|
|
text_byte_offset: glyph.text_byte_offset,
|
|
});
|
|
|
|
// A line that only overflows vertically (it fits the width) keeps all its glyphs
|
|
// and gets the ellipsis *appended* after the last one rather than overwriting it,
|
|
// matching the parley path which renders e.g. "Line Two..." not "Line Tw...".
|
|
let last_glyph = glyph_range.start + index == glyph_range.end - 1;
|
|
if elide_last_line
|
|
&& last_glyph
|
|
&& let Some(elide_glyph) = elide_glyph.take()
|
|
{
|
|
let x = glyph_x;
|
|
glyph_x += elide_glyph.advance;
|
|
output[1] = Some(PositionedGlyph {
|
|
x,
|
|
y: Font::Length::zero(),
|
|
advance: elide_glyph.advance,
|
|
glyph_id: elide_glyph.glyph_id.unwrap(), // checked earlier when initializing elide_glyph
|
|
text_byte_offset: glyph.text_byte_offset,
|
|
});
|
|
}
|
|
|
|
output.into_iter().flatten()
|
|
});
|
|
|
|
if let core::ops::ControlFlow::Break(break_val) =
|
|
line_callback(&mut positioned_glyph_it, x, y, line, selection)
|
|
{
|
|
return core::ops::ControlFlow::Break(break_val);
|
|
}
|
|
y += line_height;
|
|
line_index += 1;
|
|
|
|
core::ops::ControlFlow::Continue(())
|
|
};
|
|
|
|
if let Some(lines_vec) = text_lines.take() {
|
|
for line in lines_vec {
|
|
if let core::ops::ControlFlow::Break(break_val) =
|
|
process_line(&line, &shape_buffer.glyphs)
|
|
{
|
|
return Err(break_val);
|
|
}
|
|
}
|
|
} else {
|
|
for line in new_line_break_iter() {
|
|
if let core::ops::ControlFlow::Break(break_val) =
|
|
process_line(&line, &shape_buffer.glyphs)
|
|
{
|
|
return Err(break_val);
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(baseline_y)
|
|
}
|
|
|
|
/// How many lines of `line_height` fit within `self.max_height`, rounded down. A line
|
|
/// height of zero (from `line-height-factor: 0`) collapses all lines onto each other,
|
|
/// so any number of them fit.
|
|
fn max_lines_that_fit(&self, line_height: Font::Length) -> usize {
|
|
if line_height <= Font::Length::zero() {
|
|
return usize::MAX;
|
|
}
|
|
self.max_height.div_count(line_height)
|
|
}
|
|
|
|
/// Returns the leading edge of the glyph at the given byte offset
|
|
pub fn cursor_pos_for_byte_offset(&self, byte_offset: usize) -> (Font::Length, Font::Length) {
|
|
let mut last_glyph_right_edge = Font::Length::zero();
|
|
let mut last_line_y = Font::Length::zero();
|
|
|
|
match self.layout_lines(
|
|
|glyphs, line_x, line_y, line, _| {
|
|
last_glyph_right_edge = euclid::approxord::min(
|
|
self.max_width,
|
|
line_x + line.width_including_trailing_whitespace(),
|
|
);
|
|
last_line_y = line_y;
|
|
if byte_offset >= line.byte_range.end + line.trailing_whitespace_bytes {
|
|
return core::ops::ControlFlow::Continue(());
|
|
}
|
|
|
|
for positioned_glyph in glyphs {
|
|
if positioned_glyph.text_byte_offset == byte_offset {
|
|
return core::ops::ControlFlow::Break((
|
|
euclid::approxord::min(self.max_width, line_x + positioned_glyph.x),
|
|
last_line_y,
|
|
));
|
|
}
|
|
}
|
|
|
|
core::ops::ControlFlow::Break((last_glyph_right_edge, last_line_y))
|
|
},
|
|
None,
|
|
) {
|
|
Ok(_) => (last_glyph_right_edge, last_line_y),
|
|
Err(position) => position,
|
|
}
|
|
}
|
|
|
|
/// Returns the bytes offset for the given position
|
|
pub fn byte_offset_for_position(&self, (pos_x, pos_y): (Font::Length, Font::Length)) -> usize {
|
|
let mut byte_offset = 0;
|
|
let two = Font::LengthPrimitive::one() + Font::LengthPrimitive::one();
|
|
|
|
match self.layout_lines(
|
|
|glyphs, line_x, line_y, line, _| {
|
|
if pos_y >= line_y + self.layout.line_height() {
|
|
byte_offset = line.byte_range.end;
|
|
return core::ops::ControlFlow::Continue(());
|
|
}
|
|
|
|
if line.is_empty() {
|
|
return core::ops::ControlFlow::Break(line.byte_range.start);
|
|
}
|
|
|
|
while let Some(positioned_glyph) = glyphs.next() {
|
|
if pos_x >= line_x + positioned_glyph.x
|
|
&& pos_x <= line_x + positioned_glyph.x + positioned_glyph.advance
|
|
{
|
|
if pos_x < line_x + positioned_glyph.x + positioned_glyph.advance / two {
|
|
return core::ops::ControlFlow::Break(
|
|
positioned_glyph.text_byte_offset,
|
|
);
|
|
} else if let Some(next_glyph) = glyphs.next() {
|
|
return core::ops::ControlFlow::Break(next_glyph.text_byte_offset);
|
|
}
|
|
}
|
|
}
|
|
|
|
core::ops::ControlFlow::Break(line.byte_range.end)
|
|
},
|
|
None,
|
|
) {
|
|
Ok(_) => byte_offset,
|
|
Err(position) => position,
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_no_linebreak_opportunity_at_eot() {
|
|
let mut it = LineBreakIterator::new("Hello World");
|
|
assert_eq!(it.next(), Some((6, BreakOpportunity::Allowed)));
|
|
assert_eq!(it.next(), None);
|
|
}
|
|
|
|
// All glyphs are 10 pixels wide, break on ascii rules
|
|
#[cfg(test)]
|
|
pub struct FixedTestFont;
|
|
|
|
#[cfg(test)]
|
|
impl TextShaper for FixedTestFont {
|
|
type LengthPrimitive = f32;
|
|
type Length = f32;
|
|
fn shape_text<GlyphStorage: std::iter::Extend<Glyph<f32>>>(
|
|
&self,
|
|
text: &str,
|
|
glyphs: &mut GlyphStorage,
|
|
) {
|
|
let glyph_iter = text.char_indices().map(|(byte_offset, char)| {
|
|
let mut utf16_buf = [0; 2];
|
|
let utf16_char_as_glyph_id = char.encode_utf16(&mut utf16_buf)[0];
|
|
|
|
Glyph {
|
|
offset_x: 0.,
|
|
offset_y: 0.,
|
|
glyph_id: core::num::NonZeroU16::new(utf16_char_as_glyph_id),
|
|
advance: 10.,
|
|
text_byte_offset: byte_offset,
|
|
}
|
|
});
|
|
glyphs.extend(glyph_iter);
|
|
}
|
|
|
|
fn glyph_for_char(&self, ch: char) -> Option<Glyph<f32>> {
|
|
let mut utf16_buf = [0; 2];
|
|
let utf16_char_as_glyph_id = ch.encode_utf16(&mut utf16_buf)[0];
|
|
|
|
Glyph {
|
|
offset_x: 0.,
|
|
offset_y: 0.,
|
|
glyph_id: core::num::NonZeroU16::new(utf16_char_as_glyph_id),
|
|
advance: 10.,
|
|
text_byte_offset: 0,
|
|
}
|
|
.into()
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
impl FontMetrics<f32> for FixedTestFont {
|
|
fn ascent(&self) -> f32 {
|
|
5.
|
|
}
|
|
|
|
fn descent(&self) -> f32 {
|
|
-5.
|
|
}
|
|
|
|
fn x_height(&self) -> f32 {
|
|
3.
|
|
}
|
|
|
|
fn cap_height(&self) -> f32 {
|
|
4.
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_elision() {
|
|
let font = FixedTestFont;
|
|
let text = "This is a longer piece of text";
|
|
|
|
let mut lines = Vec::new();
|
|
|
|
let paragraph = TextParagraphLayout {
|
|
string: text,
|
|
layout: TextLayout { font: &font, letter_spacing: None, line_height: None },
|
|
max_width: 13. * 10.,
|
|
max_height: 10.,
|
|
horizontal_alignment: TextHorizontalAlignment::Left,
|
|
vertical_alignment: TextVerticalAlignment::Top,
|
|
wrap: TextWrap::NoWrap,
|
|
overflow: TextOverflow::Elide,
|
|
single_line: true,
|
|
max_lines: None,
|
|
};
|
|
paragraph
|
|
.layout_lines::<()>(
|
|
|glyphs, _, _, _, _| {
|
|
lines.push(
|
|
glyphs.map(|positioned_glyph| positioned_glyph.glyph_id).collect::<Vec<_>>(),
|
|
);
|
|
core::ops::ControlFlow::Continue(())
|
|
},
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(lines.len(), 1);
|
|
let rendered_text = lines[0]
|
|
.iter()
|
|
.flat_map(|glyph_id| {
|
|
core::char::decode_utf16(core::iter::once(glyph_id.get()))
|
|
.map(|r| r.unwrap())
|
|
.collect::<Vec<char>>()
|
|
})
|
|
.collect::<std::string::String>();
|
|
debug_assert_eq!(rendered_text, "This is a lo…")
|
|
}
|
|
|
|
#[test]
|
|
fn test_elision_vertical_truncation() {
|
|
// A line that only overflows vertically (more lines below it were dropped for the height) but
|
|
// fits the width keeps all its glyphs and gets the ellipsis appended -- "AB…", not "A…". The
|
|
// box is one line tall, so the second line ("CD") is dropped.
|
|
let font = FixedTestFont;
|
|
let text = "AB\nCD";
|
|
|
|
let mut lines = Vec::new();
|
|
|
|
let paragraph = TextParagraphLayout {
|
|
string: text,
|
|
layout: TextLayout { font: &font, letter_spacing: None, line_height: None },
|
|
max_width: 4. * 10., // room for "AB…" (3 glyphs) and more
|
|
max_height: 10., // one line tall
|
|
horizontal_alignment: TextHorizontalAlignment::Left,
|
|
vertical_alignment: TextVerticalAlignment::Top,
|
|
wrap: TextWrap::NoWrap,
|
|
overflow: TextOverflow::Elide,
|
|
single_line: false,
|
|
max_lines: None,
|
|
};
|
|
paragraph
|
|
.layout_lines::<()>(
|
|
|glyphs, _, _, _, _| {
|
|
lines.push(
|
|
glyphs.map(|positioned_glyph| positioned_glyph.glyph_id).collect::<Vec<_>>(),
|
|
);
|
|
core::ops::ControlFlow::Continue(())
|
|
},
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
// Only the first line is drawn (the box is one line tall).
|
|
assert_eq!(lines.len(), 1);
|
|
let rendered_text = lines[0]
|
|
.iter()
|
|
.flat_map(|glyph_id| {
|
|
core::char::decode_utf16(core::iter::once(glyph_id.get()))
|
|
.map(|r| r.unwrap())
|
|
.collect::<Vec<char>>()
|
|
})
|
|
.collect::<std::string::String>();
|
|
assert_eq!(rendered_text, "AB…");
|
|
}
|
|
|
|
#[test]
|
|
fn test_exact_fit() {
|
|
let font = FixedTestFont;
|
|
let text = "Fits";
|
|
|
|
let mut lines = Vec::new();
|
|
|
|
let paragraph = TextParagraphLayout {
|
|
string: text,
|
|
layout: TextLayout { font: &font, letter_spacing: None, line_height: None },
|
|
max_width: 4. * 10.,
|
|
max_height: 10.,
|
|
horizontal_alignment: TextHorizontalAlignment::Left,
|
|
vertical_alignment: TextVerticalAlignment::Top,
|
|
wrap: TextWrap::NoWrap,
|
|
overflow: TextOverflow::Elide,
|
|
single_line: true,
|
|
max_lines: None,
|
|
};
|
|
paragraph
|
|
.layout_lines::<()>(
|
|
|glyphs, _, _, _, _| {
|
|
lines.push(
|
|
glyphs.map(|positioned_glyph| positioned_glyph.glyph_id).collect::<Vec<_>>(),
|
|
);
|
|
core::ops::ControlFlow::Continue(())
|
|
},
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(lines.len(), 1);
|
|
let rendered_text = lines[0]
|
|
.iter()
|
|
.flat_map(|glyph_id| {
|
|
core::char::decode_utf16(core::iter::once(glyph_id.get()))
|
|
.map(|r| r.unwrap())
|
|
.collect::<Vec<char>>()
|
|
})
|
|
.collect::<std::string::String>();
|
|
debug_assert_eq!(rendered_text, "Fits")
|
|
}
|
|
|
|
#[test]
|
|
fn test_no_line_separators_characters_rendered() {
|
|
let font = FixedTestFont;
|
|
let text = "Hello\nWorld\n";
|
|
|
|
let mut lines = Vec::new();
|
|
|
|
let paragraph = TextParagraphLayout {
|
|
string: text,
|
|
layout: TextLayout { font: &font, letter_spacing: None, line_height: None },
|
|
max_width: 13. * 10.,
|
|
max_height: 10.,
|
|
horizontal_alignment: TextHorizontalAlignment::Left,
|
|
vertical_alignment: TextVerticalAlignment::Top,
|
|
wrap: TextWrap::NoWrap,
|
|
overflow: TextOverflow::Clip,
|
|
single_line: true,
|
|
max_lines: None,
|
|
};
|
|
paragraph
|
|
.layout_lines::<()>(
|
|
|glyphs, _, _, _, _| {
|
|
lines.push(
|
|
glyphs.map(|positioned_glyph| positioned_glyph.glyph_id).collect::<Vec<_>>(),
|
|
);
|
|
core::ops::ControlFlow::Continue(())
|
|
},
|
|
None,
|
|
)
|
|
.unwrap();
|
|
|
|
assert_eq!(lines.len(), 2);
|
|
let rendered_text = lines
|
|
.iter()
|
|
.map(|glyphs_per_line| {
|
|
glyphs_per_line
|
|
.iter()
|
|
.flat_map(|glyph_id| {
|
|
core::char::decode_utf16(core::iter::once(glyph_id.get()))
|
|
.map(|r| r.unwrap())
|
|
.collect::<Vec<char>>()
|
|
})
|
|
.collect::<std::string::String>()
|
|
})
|
|
.collect::<Vec<_>>();
|
|
debug_assert_eq!(rendered_text, std::vec!["Hello", "World"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_max_lines_limits_visible_lines() {
|
|
let font = FixedTestFont;
|
|
let text = "Hello\nWorld\nAgain";
|
|
|
|
let paragraph = TextParagraphLayout {
|
|
string: text,
|
|
layout: TextLayout { font: &font, letter_spacing: None, line_height: None },
|
|
max_width: 100. * 10.,
|
|
max_height: 100.,
|
|
horizontal_alignment: TextHorizontalAlignment::Left,
|
|
vertical_alignment: TextVerticalAlignment::Top,
|
|
wrap: TextWrap::NoWrap,
|
|
overflow: TextOverflow::Clip,
|
|
single_line: false,
|
|
max_lines: Some(2),
|
|
};
|
|
assert_eq!(render_lines(¶graph), std::vec!["Hello", "World"]);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
fn render_lines(paragraph: &TextParagraphLayout<'_, FixedTestFont>) -> Vec<std::string::String> {
|
|
let mut lines = Vec::new();
|
|
paragraph
|
|
.layout_lines::<()>(
|
|
|glyphs, _, _, _, _| {
|
|
lines.push(
|
|
glyphs
|
|
.flat_map(|positioned_glyph| {
|
|
core::char::decode_utf16(core::iter::once(
|
|
positioned_glyph.glyph_id.get(),
|
|
))
|
|
.map(|r| r.unwrap())
|
|
.collect::<Vec<char>>()
|
|
})
|
|
.collect::<std::string::String>(),
|
|
);
|
|
core::ops::ControlFlow::Continue(())
|
|
},
|
|
None,
|
|
)
|
|
.unwrap();
|
|
lines
|
|
}
|
|
|
|
#[test]
|
|
fn test_max_lines_with_word_wrap() {
|
|
let font = FixedTestFont;
|
|
// Wraps to one word per line at 60px; the line limit counts the wrapped lines.
|
|
let text = "Hello World Again";
|
|
|
|
let paragraph = TextParagraphLayout {
|
|
string: text,
|
|
layout: TextLayout { font: &font, letter_spacing: None, line_height: None },
|
|
max_width: 6. * 10.,
|
|
max_height: 100.,
|
|
horizontal_alignment: TextHorizontalAlignment::Left,
|
|
vertical_alignment: TextVerticalAlignment::Top,
|
|
wrap: TextWrap::WordWrap,
|
|
overflow: TextOverflow::Clip,
|
|
single_line: false,
|
|
max_lines: Some(2),
|
|
};
|
|
assert_eq!(render_lines(¶graph), std::vec!["Hello ", "World "]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_max_lines_elide_marks_cut_line() {
|
|
let font = FixedTestFont;
|
|
let text = "Hello\nWorld\nAgain";
|
|
|
|
// The box is tall enough for all three lines, but the line limit cuts after the second:
|
|
// with `overflow: elide` the ellipsis goes on the last kept line.
|
|
let paragraph = TextParagraphLayout {
|
|
string: text,
|
|
layout: TextLayout { font: &font, letter_spacing: None, line_height: None },
|
|
max_width: 100. * 10.,
|
|
max_height: 100.,
|
|
horizontal_alignment: TextHorizontalAlignment::Left,
|
|
vertical_alignment: TextVerticalAlignment::Top,
|
|
wrap: TextWrap::NoWrap,
|
|
overflow: TextOverflow::Elide,
|
|
single_line: false,
|
|
max_lines: Some(2),
|
|
};
|
|
assert_eq!(render_lines(¶graph), std::vec!["Hello", "World…"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_text_size_with_max_lines() {
|
|
let font = FixedTestFont;
|
|
let layout = TextLayout { font: &font, letter_spacing: None, line_height: None };
|
|
let text = "On\nFour\nLonger";
|
|
|
|
let (width, height) = layout.text_size(text, None, TextWrap::NoWrap, None);
|
|
assert_eq!((width, height), (6. * 10., 3. * 10.));
|
|
|
|
// With a line limit, both the height and the longest-line width only cover the kept lines.
|
|
let (width, height) = layout.text_size(text, None, TextWrap::NoWrap, Some(2));
|
|
assert_eq!((width, height), (4. * 10., 2. * 10.));
|
|
}
|
|
|
|
#[test]
|
|
fn test_line_height() {
|
|
let font = FixedTestFont;
|
|
let layout = TextLayout { font: &font, letter_spacing: None, line_height: Some(15.) };
|
|
let text = "One\nTwo";
|
|
|
|
assert_eq!(layout.text_size(text, None, TextWrap::NoWrap, None), (3. * 10., 2. * 15.));
|
|
|
|
// The extra leading is split half above, half below the glyphs, like the parley path.
|
|
assert_eq!(layout.half_leading(), 2.5);
|
|
assert_eq!(layout.cursor_band(), (0., 15.));
|
|
|
|
// With negative leading the cursor band is clamped to the glyph box.
|
|
let tight = TextLayout { font: &font, letter_spacing: None, line_height: Some(6.) };
|
|
assert_eq!(tight.half_leading(), -2.);
|
|
assert_eq!(tight.cursor_band(), (-2., 10.));
|
|
|
|
let paragraph = TextParagraphLayout {
|
|
string: text,
|
|
layout,
|
|
max_width: 100. * 10.,
|
|
max_height: 100.,
|
|
horizontal_alignment: TextHorizontalAlignment::Left,
|
|
vertical_alignment: TextVerticalAlignment::Top,
|
|
wrap: TextWrap::NoWrap,
|
|
overflow: TextOverflow::Clip,
|
|
single_line: false,
|
|
max_lines: None,
|
|
};
|
|
|
|
assert_eq!(paragraph.cursor_pos_for_byte_offset(4), (0., 15.));
|
|
assert_eq!(paragraph.byte_offset_for_position((0., 16.)), 4);
|
|
|
|
let paragraph = TextParagraphLayout {
|
|
string: "One\nTwo\nThree",
|
|
layout: TextLayout { font: &font, letter_spacing: None, line_height: Some(15.) },
|
|
max_width: 100. * 10.,
|
|
max_height: 29.,
|
|
horizontal_alignment: TextHorizontalAlignment::Left,
|
|
vertical_alignment: TextVerticalAlignment::Top,
|
|
wrap: TextWrap::NoWrap,
|
|
overflow: TextOverflow::Elide,
|
|
single_line: false,
|
|
max_lines: None,
|
|
};
|
|
|
|
assert_eq!(render_lines(¶graph), std::vec!["One…"]);
|
|
}
|
|
|
|
#[test]
|
|
fn test_cursor_position() {
|
|
let font = FixedTestFont;
|
|
let text = "Hello World";
|
|
|
|
let paragraph = TextParagraphLayout {
|
|
string: text,
|
|
layout: TextLayout { font: &font, letter_spacing: None, line_height: None },
|
|
max_width: 10. * 10.,
|
|
max_height: 10.,
|
|
horizontal_alignment: TextHorizontalAlignment::Left,
|
|
vertical_alignment: TextVerticalAlignment::Top,
|
|
wrap: TextWrap::WordWrap,
|
|
overflow: TextOverflow::Clip,
|
|
single_line: false,
|
|
max_lines: None,
|
|
};
|
|
|
|
assert_eq!(paragraph.cursor_pos_for_byte_offset(0), (0., 0.));
|
|
|
|
let e_offset = text
|
|
.char_indices()
|
|
.find_map(|(offset, ch)| if ch == 'e' { Some(offset) } else { None })
|
|
.unwrap();
|
|
assert_eq!(paragraph.cursor_pos_for_byte_offset(e_offset), (10., 0.));
|
|
|
|
let w_offset = text
|
|
.char_indices()
|
|
.find_map(|(offset, ch)| if ch == 'W' { Some(offset) } else { None })
|
|
.unwrap();
|
|
assert_eq!(paragraph.cursor_pos_for_byte_offset(w_offset + 1), (10., 10.));
|
|
|
|
assert_eq!(paragraph.cursor_pos_for_byte_offset(text.len()), (10. * 5., 10.));
|
|
|
|
let first_space_offset =
|
|
text.char_indices().find_map(|(offset, ch)| ch.is_whitespace().then_some(offset)).unwrap();
|
|
assert_eq!(paragraph.cursor_pos_for_byte_offset(first_space_offset), (5. * 10., 0.));
|
|
assert_eq!(paragraph.cursor_pos_for_byte_offset(first_space_offset + 15), (10. * 10., 0.));
|
|
assert_eq!(paragraph.cursor_pos_for_byte_offset(first_space_offset + 16), (10. * 10., 0.));
|
|
}
|
|
|
|
#[test]
|
|
fn test_cursor_position_with_newline() {
|
|
let font = FixedTestFont;
|
|
let text = "Hello\nWorld";
|
|
|
|
let paragraph = TextParagraphLayout {
|
|
string: text,
|
|
layout: TextLayout { font: &font, letter_spacing: None, line_height: None },
|
|
max_width: 100. * 10.,
|
|
max_height: 10.,
|
|
horizontal_alignment: TextHorizontalAlignment::Left,
|
|
vertical_alignment: TextVerticalAlignment::Top,
|
|
wrap: TextWrap::WordWrap,
|
|
overflow: TextOverflow::Clip,
|
|
single_line: false,
|
|
max_lines: None,
|
|
};
|
|
|
|
assert_eq!(paragraph.cursor_pos_for_byte_offset(5), (5. * 10., 0.));
|
|
}
|
|
|
|
#[test]
|
|
fn byte_offset_for_empty_line() {
|
|
let font = FixedTestFont;
|
|
let text = "Hello\n\nWorld";
|
|
|
|
let paragraph = TextParagraphLayout {
|
|
string: text,
|
|
layout: TextLayout { font: &font, letter_spacing: None, line_height: None },
|
|
max_width: 100. * 10.,
|
|
max_height: 10.,
|
|
horizontal_alignment: TextHorizontalAlignment::Left,
|
|
vertical_alignment: TextVerticalAlignment::Top,
|
|
wrap: TextWrap::WordWrap,
|
|
overflow: TextOverflow::Clip,
|
|
single_line: false,
|
|
max_lines: None,
|
|
};
|
|
|
|
assert_eq!(paragraph.byte_offset_for_position((0., 10.)), 6);
|
|
}
|
|
|
|
#[test]
|
|
fn test_byte_offset() {
|
|
let font = FixedTestFont;
|
|
let text = "Hello World";
|
|
let mut end_helper_text = std::string::String::from(text);
|
|
end_helper_text.push('!');
|
|
|
|
let paragraph = TextParagraphLayout {
|
|
string: text,
|
|
layout: TextLayout { font: &font, letter_spacing: None, line_height: None },
|
|
max_width: 10. * 10.,
|
|
max_height: 10.,
|
|
horizontal_alignment: TextHorizontalAlignment::Left,
|
|
vertical_alignment: TextVerticalAlignment::Top,
|
|
wrap: TextWrap::WordWrap,
|
|
overflow: TextOverflow::Clip,
|
|
single_line: false,
|
|
max_lines: None,
|
|
};
|
|
|
|
assert_eq!(paragraph.byte_offset_for_position((0., 0.)), 0);
|
|
|
|
let e_offset = text
|
|
.char_indices()
|
|
.find_map(|(offset, ch)| if ch == 'e' { Some(offset) } else { None })
|
|
.unwrap();
|
|
|
|
assert_eq!(paragraph.byte_offset_for_position((14., 0.)), e_offset);
|
|
|
|
let l_offset = text
|
|
.char_indices()
|
|
.find_map(|(offset, ch)| if ch == 'l' { Some(offset) } else { None })
|
|
.unwrap();
|
|
assert_eq!(paragraph.byte_offset_for_position((15., 0.)), l_offset);
|
|
|
|
let w_offset = text
|
|
.char_indices()
|
|
.find_map(|(offset, ch)| if ch == 'W' { Some(offset) } else { None })
|
|
.unwrap();
|
|
|
|
assert_eq!(paragraph.byte_offset_for_position((10., 10.)), w_offset + 1);
|
|
|
|
let o_offset = text
|
|
.char_indices()
|
|
.rev()
|
|
.find_map(|(offset, ch)| if ch == 'o' { Some(offset) } else { None })
|
|
.unwrap();
|
|
|
|
assert_eq!(paragraph.byte_offset_for_position((15., 10.)), o_offset + 1);
|
|
|
|
let d_offset = text
|
|
.char_indices()
|
|
.rev()
|
|
.find_map(|(offset, ch)| if ch == 'd' { Some(offset) } else { None })
|
|
.unwrap();
|
|
|
|
assert_eq!(paragraph.byte_offset_for_position((40., 10.)), d_offset);
|
|
|
|
let end_offset = end_helper_text
|
|
.char_indices()
|
|
.rev()
|
|
.find_map(|(offset, ch)| if ch == '!' { Some(offset) } else { None })
|
|
.unwrap();
|
|
|
|
assert_eq!(paragraph.byte_offset_for_position((45., 10.)), end_offset);
|
|
assert_eq!(paragraph.byte_offset_for_position((0., 20.)), end_offset);
|
|
}
|
|
|
|
#[test]
|
|
fn test_content_widths() {
|
|
// FixedTestFont: every glyph is 10 pixels wide.
|
|
let font = FixedTestFont;
|
|
let layout = TextLayout { font: &font, letter_spacing: None, line_height: None };
|
|
let min = |text| layout.content_widths(text, None).0;
|
|
|
|
// The longest word wins. The space after it is not part of its width,
|
|
// otherwise this would be 80.
|
|
assert_eq!(min("a bb longest cc"), 70.);
|
|
// A single word: the whole string.
|
|
assert_eq!(min("Hello"), 50.);
|
|
// Equal words.
|
|
assert_eq!(min("Hello World"), 50.);
|
|
// Whitespace at the end of the string doesn't count either.
|
|
assert_eq!(min("Hello World "), 50.);
|
|
// Mandatory breaks are break opportunities too.
|
|
assert_eq!(min("short\nlongestword"), 110.);
|
|
assert_eq!(min(""), 0.);
|
|
assert_eq!(min(" "), 0.);
|
|
// A no-break space is not a break opportunity, so both words are one chunk.
|
|
assert_eq!(min("aa\u{00a0}bb cc"), 50.);
|
|
// Without spaces there is nowhere to break, so the minimum is the whole string.
|
|
assert_eq!(min("abcdefgh"), 80.);
|
|
|
|
// The max-content width is the width of the text on a single line.
|
|
assert_eq!(layout.content_widths("a bb longest cc", None).1, 150.);
|
|
assert_eq!(layout.content_widths("short\nlongestword", None).1, 110.);
|
|
}
|
|
|
|
#[test]
|
|
fn test_content_widths_max_lines() {
|
|
let font = FixedTestFont;
|
|
let layout = TextLayout { font: &font, letter_spacing: None, line_height: None };
|
|
|
|
// Only the first line is drawn, so neither width may account for the second one.
|
|
let (min, max) = layout.content_widths("short\nlongestword", Some(1));
|
|
assert_eq!(max, 50.);
|
|
assert_eq!(min, 50.);
|
|
|
|
// The minimum still comes from the words of the lines that are kept.
|
|
let (min, max) = layout.content_widths("aa bb\nlongestword", Some(1));
|
|
assert_eq!(max, 50.);
|
|
assert_eq!(min, 20.);
|
|
|
|
// Without a limit both lines count again.
|
|
let (min, max) = layout.content_widths("aa bb\nlongestword", None);
|
|
assert_eq!(max, 110.);
|
|
assert_eq!(min, 110.);
|
|
}
|
|
|
|
#[test]
|
|
fn test_content_widths_min_never_exceeds_max() {
|
|
let font = FixedTestFont;
|
|
let layout = TextLayout { font: &font, letter_spacing: None, line_height: None };
|
|
// A minimum above the preferred width makes preferred_bounded() clamp the preferred
|
|
// width back up, silently widening the item.
|
|
for text in ["", " ", "Hello", "a bb longest cc", "short\nlongestword", "aa\u{00a0}bb cc"] {
|
|
for max_lines in [None, Some(1), Some(2)] {
|
|
let (min, max) = layout.content_widths(text, max_lines);
|
|
assert!(min <= max, "min {min} > max {max} for {text:?} with {max_lines:?}");
|
|
}
|
|
}
|
|
}
|