Improve text truncation: always include elision character (#3366)

* Add Row::text

* Rename elide_at_width -> truncate_at_width

* Move text layout tests to own module

* Add test to check that elision character is always included

* Include elision character in more circumstances

* Append overflow character if we can't replace

* Always append … when eliding

* Add a secondary text to the text layout demo
This commit is contained in:
Emil Ernerfeldt 2023-09-21 10:41:49 +02:00 committed by GitHub
parent c07394b576
commit 33a0f50f6a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 300 additions and 129 deletions

View File

@ -7,16 +7,18 @@ pub struct TextLayoutDemo {
overflow_character: Option<char>, overflow_character: Option<char>,
extra_letter_spacing_pixels: i32, extra_letter_spacing_pixels: i32,
line_height_pixels: u32, line_height_pixels: u32,
lorem_ipsum: bool,
} }
impl Default for TextLayoutDemo { impl Default for TextLayoutDemo {
fn default() -> Self { fn default() -> Self {
Self { Self {
max_rows: 3, max_rows: 6,
break_anywhere: true, break_anywhere: true,
overflow_character: Some('…'), overflow_character: Some('…'),
extra_letter_spacing_pixels: 0, extra_letter_spacing_pixels: 0,
line_height_pixels: 0, line_height_pixels: 0,
lorem_ipsum: true,
} }
} }
} }
@ -45,6 +47,7 @@ impl super::View for TextLayoutDemo {
overflow_character, overflow_character,
extra_letter_spacing_pixels, extra_letter_spacing_pixels,
line_height_pixels, line_height_pixels,
lorem_ipsum,
} = self; } = self;
use egui::text::LayoutJob; use egui::text::LayoutJob;
@ -104,32 +107,55 @@ impl super::View for TextLayoutDemo {
} }
}); });
ui.end_row(); ui.end_row();
ui.label("Text:");
ui.horizontal(|ui| {
ui.selectable_value(lorem_ipsum, true, "Lorem Ipsum");
ui.selectable_value(lorem_ipsum, false, "La Pasionaria");
});
}); });
ui.add_space(12.0); ui.add_space(12.0);
egui::ScrollArea::vertical().show(ui, |ui| { let text = if *lorem_ipsum {
let extra_letter_spacing = points_per_pixel * *extra_letter_spacing_pixels as f32; crate::LOREM_IPSUM_LONG
let line_height = } else {
(*line_height_pixels != 0).then_some(points_per_pixel * *line_height_pixels as f32); TO_BE_OR_NOT_TO_BE
};
let mut job = LayoutJob::single_section( egui::ScrollArea::vertical()
crate::LOREM_IPSUM_LONG.to_owned(), .auto_shrink([false; 2])
egui::TextFormat { .show(ui, |ui| {
extra_letter_spacing, let extra_letter_spacing = points_per_pixel * *extra_letter_spacing_pixels as f32;
line_height, let line_height = (*line_height_pixels != 0)
.then_some(points_per_pixel * *line_height_pixels as f32);
let mut job = LayoutJob::single_section(
text.to_owned(),
egui::TextFormat {
extra_letter_spacing,
line_height,
..Default::default()
},
);
job.wrap = egui::text::TextWrapping {
max_rows: *max_rows,
break_anywhere: *break_anywhere,
overflow_character: *overflow_character,
..Default::default() ..Default::default()
}, };
);
job.wrap = egui::text::TextWrapping {
max_rows: *max_rows,
break_anywhere: *break_anywhere,
overflow_character: *overflow_character,
..Default::default()
};
// NOTE: `Label` overrides some of the wrapping settings, e.g. wrap width // NOTE: `Label` overrides some of the wrapping settings, e.g. wrap width
ui.label(job); ui.label(job);
}); });
} }
} }
/// Excerpt from Dolores Ibárruri's farwel speech to the International Brigades:
const TO_BE_OR_NOT_TO_BE: &str = "Mothers! Women!\n
When the years pass by and the wounds of war are stanched; when the memory of the sad and bloody days dissipates in a present of liberty, of peace and of wellbeing; when the rancor have died out and pride in a free country is felt equally by all Spaniards, speak to your children. Tell them of these men of the International Brigades.\n\
\n\
Recount for them how, coming over seas and mountains, crossing frontiers bristling with bayonets, sought by raving dogs thirsting to tear their flesh, these men reached our country as crusaders for freedom, to fight and die for Spains liberty and independence threatened by German and Italian fascism. \
They gave up everything their loves, their countries, home and fortune, fathers, mothers, wives, brothers, sisters and children and they came and said to us: We are here. Your cause, Spains cause, is ours. It is the cause of all advanced and progressive mankind.\n\
\n\
- Dolores Ibárruri, 1938";

View File

@ -3,7 +3,7 @@ use std::sync::Arc;
use emath::*; use emath::*;
use crate::{Color32, Mesh, Stroke, Vertex}; use crate::{text::font::Font, Color32, Mesh, Stroke, Vertex};
use super::{FontsImpl, Galley, Glyph, LayoutJob, LayoutSection, Row, RowVisuals}; use super::{FontsImpl, Galley, Glyph, LayoutJob, LayoutSection, Row, RowVisuals};
@ -40,17 +40,31 @@ impl PointScale {
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
/// Temporary storage before line-wrapping. /// Temporary storage before line-wrapping.
#[derive(Default, Clone)] #[derive(Clone)]
struct Paragraph { struct Paragraph {
/// Start of the next glyph to be added. /// Start of the next glyph to be added.
pub cursor_x: f32, pub cursor_x: f32,
/// This is included in case there are no glyphs
pub section_index_at_start: u32,
pub glyphs: Vec<Glyph>, pub glyphs: Vec<Glyph>,
/// In case of an empty paragraph ("\n"), use this as height. /// In case of an empty paragraph ("\n"), use this as height.
pub empty_paragraph_height: f32, pub empty_paragraph_height: f32,
} }
impl Paragraph {
pub fn from_section_index(section_index_at_start: u32) -> Self {
Self {
cursor_x: 0.0,
section_index_at_start,
glyphs: vec![],
empty_paragraph_height: 0.0,
}
}
}
/// Layout text into a [`Galley`]. /// Layout text into a [`Galley`].
/// ///
/// In most cases you should use [`crate::Fonts::layout_job`] instead /// In most cases you should use [`crate::Fonts::layout_job`] instead
@ -70,7 +84,9 @@ pub fn layout(fonts: &mut FontsImpl, job: Arc<LayoutJob>) -> Galley {
}; };
} }
let mut paragraphs = vec![Paragraph::default()]; // For most of this we ignore the y coordinate:
let mut paragraphs = vec![Paragraph::from_section_index(0)];
for (section_index, section) in job.sections.iter().enumerate() { for (section_index, section) in job.sections.iter().enumerate() {
layout_section(fonts, &job, section_index as u32, section, &mut paragraphs); layout_section(fonts, &job, section_index as u32, section, &mut paragraphs);
} }
@ -78,7 +94,12 @@ pub fn layout(fonts: &mut FontsImpl, job: Arc<LayoutJob>) -> Galley {
let point_scale = PointScale::new(fonts.pixels_per_point()); let point_scale = PointScale::new(fonts.pixels_per_point());
let mut elided = false; let mut elided = false;
let mut rows = rows_from_paragraphs(fonts, paragraphs, &job, &mut elided); let mut rows = rows_from_paragraphs(paragraphs, &job, &mut elided);
if elided {
if let Some(last_row) = rows.last_mut() {
replace_last_glyph_with_overflow_character(fonts, &job, last_row);
}
}
let justify = job.justify && job.wrap.max_width.is_finite(); let justify = job.justify && job.wrap.max_width.is_finite();
@ -97,9 +118,11 @@ pub fn layout(fonts: &mut FontsImpl, job: Arc<LayoutJob>) -> Galley {
} }
} }
// Calculate the Y positions and tessellate the text:
galley_from_rows(point_scale, job, rows, elided) galley_from_rows(point_scale, job, rows, elided)
} }
// Ignores the Y coordinate.
fn layout_section( fn layout_section(
fonts: &mut FontsImpl, fonts: &mut FontsImpl,
job: &LayoutJob, job: &LayoutJob,
@ -130,7 +153,7 @@ fn layout_section(
for chr in job.text[byte_range.clone()].chars() { for chr in job.text[byte_range.clone()].chars() {
if job.break_on_newline && chr == '\n' { if job.break_on_newline && chr == '\n' {
out_paragraphs.push(Paragraph::default()); out_paragraphs.push(Paragraph::from_section_index(section_index));
paragraph = out_paragraphs.last_mut().unwrap(); paragraph = out_paragraphs.last_mut().unwrap();
paragraph.empty_paragraph_height = line_height; // TODO(emilk): replace this hack with actually including `\n` in the glyphs? paragraph.empty_paragraph_height = line_height; // TODO(emilk): replace this hack with actually including `\n` in the glyphs?
} else { } else {
@ -163,8 +186,8 @@ fn rect_from_x_range(x_range: RangeInclusive<f32>) -> Rect {
Rect::from_x_y_ranges(x_range, 0.0..=0.0) Rect::from_x_y_ranges(x_range, 0.0..=0.0)
} }
// Ignores the Y coordinate.
fn rows_from_paragraphs( fn rows_from_paragraphs(
fonts: &mut FontsImpl,
paragraphs: Vec<Paragraph>, paragraphs: Vec<Paragraph>,
job: &LayoutJob, job: &LayoutJob,
elided: &mut bool, elided: &mut bool,
@ -183,6 +206,7 @@ fn rows_from_paragraphs(
if paragraph.glyphs.is_empty() { if paragraph.glyphs.is_empty() {
rows.push(Row { rows.push(Row {
section_index_at_start: paragraph.section_index_at_start,
glyphs: vec![], glyphs: vec![],
visuals: Default::default(), visuals: Default::default(),
rect: Rect::from_min_size( rect: Rect::from_min_size(
@ -197,13 +221,14 @@ fn rows_from_paragraphs(
// Early-out optimization: the whole paragraph fits on one row. // Early-out optimization: the whole paragraph fits on one row.
let paragraph_min_x = paragraph.glyphs[0].pos.x; let paragraph_min_x = paragraph.glyphs[0].pos.x;
rows.push(Row { rows.push(Row {
section_index_at_start: paragraph.section_index_at_start,
glyphs: paragraph.glyphs, glyphs: paragraph.glyphs,
visuals: Default::default(), visuals: Default::default(),
rect: rect_from_x_range(paragraph_min_x..=paragraph_max_x), rect: rect_from_x_range(paragraph_min_x..=paragraph_max_x),
ends_with_newline: !is_last_paragraph, ends_with_newline: !is_last_paragraph,
}); });
} else { } else {
line_break(fonts, &paragraph, job, &mut rows, elided); line_break(&paragraph, job, &mut rows, elided);
rows.last_mut().unwrap().ends_with_newline = !is_last_paragraph; rows.last_mut().unwrap().ends_with_newline = !is_last_paragraph;
} }
} }
@ -212,13 +237,7 @@ fn rows_from_paragraphs(
rows rows
} }
fn line_break( fn line_break(paragraph: &Paragraph, job: &LayoutJob, out_rows: &mut Vec<Row>, elided: &mut bool) {
fonts: &mut FontsImpl,
paragraph: &Paragraph,
job: &LayoutJob,
out_rows: &mut Vec<Row>,
elided: &mut bool,
) {
// Keeps track of good places to insert row break if we exceed `wrap_width`. // Keeps track of good places to insert row break if we exceed `wrap_width`.
let mut row_break_candidates = RowBreakCandidates::default(); let mut row_break_candidates = RowBreakCandidates::default();
@ -227,13 +246,13 @@ fn line_break(
let mut row_start_idx = 0; let mut row_start_idx = 0;
for i in 0..paragraph.glyphs.len() { for i in 0..paragraph.glyphs.len() {
let potential_row_width = paragraph.glyphs[i].max_x() - row_start_x;
if job.wrap.max_rows <= out_rows.len() { if job.wrap.max_rows <= out_rows.len() {
*elided = true; *elided = true;
break; break;
} }
let potential_row_width = paragraph.glyphs[i].max_x() - row_start_x;
if job.wrap.max_width < potential_row_width { if job.wrap.max_width < potential_row_width {
// Row break: // Row break:
@ -243,6 +262,7 @@ fn line_break(
// Allow the first row to be completely empty, because we know there will be more space on the next row: // Allow the first row to be completely empty, because we know there will be more space on the next row:
// TODO(emilk): this records the height of this first row as zero, though that is probably fine since first_row_indentation usually comes with a first_row_min_height. // TODO(emilk): this records the height of this first row as zero, though that is probably fine since first_row_indentation usually comes with a first_row_min_height.
out_rows.push(Row { out_rows.push(Row {
section_index_at_start: paragraph.section_index_at_start,
glyphs: vec![], glyphs: vec![],
visuals: Default::default(), visuals: Default::default(),
rect: rect_from_x_range(first_row_indentation..=first_row_indentation), rect: rect_from_x_range(first_row_indentation..=first_row_indentation),
@ -261,10 +281,12 @@ fn line_break(
}) })
.collect(); .collect();
let section_index_at_start = glyphs[0].section_index;
let paragraph_min_x = glyphs[0].pos.x; let paragraph_min_x = glyphs[0].pos.x;
let paragraph_max_x = glyphs.last().unwrap().max_x(); let paragraph_max_x = glyphs.last().unwrap().max_x();
out_rows.push(Row { out_rows.push(Row {
section_index_at_start,
glyphs, glyphs,
visuals: Default::default(), visuals: Default::default(),
rect: rect_from_x_range(paragraph_min_x..=paragraph_max_x), rect: rect_from_x_range(paragraph_min_x..=paragraph_max_x),
@ -287,10 +309,7 @@ fn line_break(
// Final row of text: // Final row of text:
if job.wrap.max_rows <= out_rows.len() { if job.wrap.max_rows <= out_rows.len() {
if let Some(last_row) = out_rows.last_mut() { *elided = true; // can't fit another row
replace_last_glyph_with_overflow_character(fonts, job, last_row);
*elided = true;
}
} else { } else {
let glyphs: Vec<Glyph> = paragraph.glyphs[row_start_idx..] let glyphs: Vec<Glyph> = paragraph.glyphs[row_start_idx..]
.iter() .iter()
@ -301,10 +320,12 @@ fn line_break(
}) })
.collect(); .collect();
let section_index_at_start = glyphs[0].section_index;
let paragraph_min_x = glyphs[0].pos.x; let paragraph_min_x = glyphs[0].pos.x;
let paragraph_max_x = glyphs.last().unwrap().max_x(); let paragraph_max_x = glyphs.last().unwrap().max_x();
out_rows.push(Row { out_rows.push(Row {
section_index_at_start,
glyphs, glyphs,
visuals: Default::default(), visuals: Default::default(),
rect: rect_from_x_range(paragraph_min_x..=paragraph_max_x), rect: rect_from_x_range(paragraph_min_x..=paragraph_max_x),
@ -315,76 +336,148 @@ fn line_break(
} }
/// Trims the last glyphs in the row and replaces it with an overflow character (e.g. `…`). /// Trims the last glyphs in the row and replaces it with an overflow character (e.g. `…`).
///
/// Called before we have any Y coordinates.
fn replace_last_glyph_with_overflow_character( fn replace_last_glyph_with_overflow_character(
fonts: &mut FontsImpl, fonts: &mut FontsImpl,
job: &LayoutJob, job: &LayoutJob,
row: &mut Row, row: &mut Row,
) { ) {
fn row_width(row: &Row) -> f32 {
if let (Some(first), Some(last)) = (row.glyphs.first(), row.glyphs.last()) {
last.max_x() - first.pos.x
} else {
0.0
}
}
fn row_height(section: &LayoutSection, font: &Font) -> f32 {
section
.format
.line_height
.unwrap_or_else(|| font.row_height())
}
let Some(overflow_character) = job.wrap.overflow_character else { let Some(overflow_character) = job.wrap.overflow_character else {
return; return;
}; };
// We always try to just append the character first:
if let Some(last_glyph) = row.glyphs.last() {
let section_index = last_glyph.section_index;
let section = &job.sections[section_index as usize];
let font = fonts.font(&section.format.font_id);
let line_height = row_height(section, font);
let (_, last_glyph_info) = font.font_impl_and_glyph_info(last_glyph.chr);
let mut x = last_glyph.pos.x + last_glyph.size.x;
let (font_impl, replacement_glyph_info) = font.font_impl_and_glyph_info(overflow_character);
{
// Kerning:
x += section.format.extra_letter_spacing;
if let Some(font_impl) = font_impl {
x += font_impl.pair_kerning(last_glyph_info.id, replacement_glyph_info.id);
}
}
row.glyphs.push(Glyph {
chr: overflow_character,
pos: pos2(x, f32::NAN),
size: vec2(replacement_glyph_info.advance_width, line_height),
ascent: font_impl.map_or(0.0, |font| font.ascent()), // Failure to find the font here would be weird
uv_rect: replacement_glyph_info.uv_rect,
section_index,
});
} else {
let section_index = row.section_index_at_start;
let section = &job.sections[section_index as usize];
let font = fonts.font(&section.format.font_id);
let line_height = row_height(section, font);
let x = 0.0; // TODO(emilk): heed paragraph leading_space 😬
let (font_impl, replacement_glyph_info) = font.font_impl_and_glyph_info(overflow_character);
row.glyphs.push(Glyph {
chr: overflow_character,
pos: pos2(x, f32::NAN),
size: vec2(replacement_glyph_info.advance_width, line_height),
ascent: font_impl.map_or(0.0, |font| font.ascent()), // Failure to find the font here would be weird
uv_rect: replacement_glyph_info.uv_rect,
section_index,
});
}
if row_width(row) <= job.wrap.max_width || row.glyphs.len() == 1 {
return; // we are done
}
// We didn't fit it. Remove it again…
row.glyphs.pop();
// …then go into a loop where we replace the last character with the overflow character
// until we fit within the max_width:
loop { loop {
let (prev_glyph, last_glyph) = match row.glyphs.as_mut_slice() { let (prev_glyph, last_glyph) = match row.glyphs.as_mut_slice() {
[.., prev, last] => (Some(prev), last), [.., prev, last] => (Some(prev), last),
[.., last] => (None, last), [.., last] => (None, last),
_ => break, _ => {
unreachable!("We've already explicitly handled the empty row");
}
}; };
let section = &job.sections[last_glyph.section_index as usize]; let section = &job.sections[last_glyph.section_index as usize];
let extra_letter_spacing = section.format.extra_letter_spacing; let extra_letter_spacing = section.format.extra_letter_spacing;
let font = fonts.font(&section.format.font_id); let font = fonts.font(&section.format.font_id);
let line_height = section let line_height = row_height(section, font);
.format
.line_height
.unwrap_or_else(|| font.row_height());
let prev_glyph_id = prev_glyph.map(|prev_glyph| { if let Some(prev_glyph) = prev_glyph {
let (_, prev_glyph_info) = font.font_impl_and_glyph_info(prev_glyph.chr); let prev_glyph_id = font.font_impl_and_glyph_info(prev_glyph.chr).1.id;
prev_glyph_info.id
});
// undo kerning with previous glyph // Undo kerning with previous glyph:
let (font_impl, glyph_info) = font.font_impl_and_glyph_info(last_glyph.chr); let (font_impl, glyph_info) = font.font_impl_and_glyph_info(last_glyph.chr);
last_glyph.pos.x -= extra_letter_spacing last_glyph.pos.x -= extra_letter_spacing;
+ font_impl if let Some(font_impl) = font_impl {
.zip(prev_glyph_id) last_glyph.pos.x -= font_impl.pair_kerning(prev_glyph_id, glyph_info.id);
.map(|(font_impl, prev_glyph_id)| { }
font_impl.pair_kerning(prev_glyph_id, glyph_info.id)
})
.unwrap_or_default();
// replace the glyph // Replace the glyph:
last_glyph.chr = overflow_character; last_glyph.chr = overflow_character;
let (font_impl, glyph_info) = font.font_impl_and_glyph_info(last_glyph.chr); let (font_impl, glyph_info) = font.font_impl_and_glyph_info(last_glyph.chr);
last_glyph.size = vec2(glyph_info.advance_width, line_height); last_glyph.size = vec2(glyph_info.advance_width, line_height);
last_glyph.uv_rect = glyph_info.uv_rect; last_glyph.uv_rect = glyph_info.uv_rect;
// reapply kerning // Reapply kerning:
last_glyph.pos.x += extra_letter_spacing last_glyph.pos.x += extra_letter_spacing;
+ font_impl if let Some(font_impl) = font_impl {
.zip(prev_glyph_id) last_glyph.pos.x += font_impl.pair_kerning(prev_glyph_id, glyph_info.id);
.map(|(font_impl, prev_glyph_id)| { }
font_impl.pair_kerning(prev_glyph_id, glyph_info.id)
})
.unwrap_or_default();
row.rect.max.x = last_glyph.max_x(); // Check if we're within width budget:
if row_width(row) <= job.wrap.max_width || row.glyphs.len() == 1 {
return; // We are done
}
// check if we're within width budget // We didn't fit - pop the last glyph and try again.
let row_end_x = last_glyph.max_x(); row.glyphs.pop();
let row_start_x = row.glyphs.first().unwrap().pos.x; // if `last_mut()` returned `Some`, then so will `first()` } else {
let row_width = row_end_x - row_start_x; // Just replace and be done with it.
if row_width <= job.wrap.max_width { last_glyph.chr = overflow_character;
return; // we are done let (_, glyph_info) = font.font_impl_and_glyph_info(last_glyph.chr);
last_glyph.size = vec2(glyph_info.advance_width, line_height);
last_glyph.uv_rect = glyph_info.uv_rect;
return;
} }
row.glyphs.pop();
} }
// We failed to insert `overflow_character` without exceeding `wrap_width`.
} }
/// Horizontally aligned the text on a row.
///
/// /// Ignores the Y coordinate.
fn halign_and_justify_row( fn halign_and_justify_row(
point_scale: PointScale, point_scale: PointScale,
row: &mut Row, row: &mut Row,
@ -879,49 +972,93 @@ fn is_cjk_break_allowed(c: char) -> bool {
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
#[test] #[cfg(test)]
fn test_zero_max_width() { mod tests {
let mut fonts = FontsImpl::new(1.0, 1024, super::FontDefinitions::default()); use super::{super::*, *};
let mut layout_job = LayoutJob::single_section("W".into(), super::TextFormat::default());
layout_job.wrap.max_width = 0.0;
let galley = super::layout(&mut fonts, layout_job.into());
assert_eq!(galley.rows.len(), 1);
}
#[test] #[test]
fn test_cjk() { fn test_zero_max_width() {
let mut fonts = FontsImpl::new(1.0, 1024, super::FontDefinitions::default()); let mut fonts = FontsImpl::new(1.0, 1024, FontDefinitions::default());
let mut layout_job = LayoutJob::single_section( let mut layout_job = LayoutJob::single_section("W".into(), TextFormat::default());
"日本語とEnglishの混在した文章".into(), layout_job.wrap.max_width = 0.0;
super::TextFormat::default(), let galley = layout(&mut fonts, layout_job.into());
); assert_eq!(galley.rows.len(), 1);
layout_job.wrap.max_width = 90.0; }
let galley = super::layout(&mut fonts, layout_job.into());
assert_eq!(
galley
.rows
.iter()
.map(|row| row.glyphs.iter().map(|g| g.chr).collect::<String>())
.collect::<Vec<_>>(),
vec!["日本語と", "Englishの混在", "した文章"]
);
}
#[test] #[test]
fn test_pre_cjk() { fn test_truncate_with_newline() {
let mut fonts = FontsImpl::new(1.0, 1024, super::FontDefinitions::default()); // No matter where we wrap, we should be appending the newline character.
let mut layout_job = LayoutJob::single_section(
"日本語とEnglishの混在した文章".into(), let mut fonts = FontsImpl::new(1.0, 1024, FontDefinitions::default());
super::TextFormat::default(), let text_format = TextFormat {
); font_id: FontId::monospace(12.0),
layout_job.wrap.max_width = 110.0; ..Default::default()
let galley = super::layout(&mut fonts, layout_job.into()); };
assert_eq!(
galley for text in ["Hello\nworld", "\nfoo"] {
.rows for break_anywhere in [false, true] {
.iter() for max_width in [0.0, 5.0, 10.0, 20.0, f32::INFINITY] {
.map(|row| row.glyphs.iter().map(|g| g.chr).collect::<String>()) let mut layout_job =
.collect::<Vec<_>>(), LayoutJob::single_section(text.into(), text_format.clone());
vec!["日本語とEnglish", "の混在した文章"] layout_job.wrap.max_width = max_width;
); layout_job.wrap.max_rows = 1;
layout_job.wrap.break_anywhere = break_anywhere;
let galley = layout(&mut fonts, layout_job.into());
assert!(galley.elided);
assert_eq!(galley.rows.len(), 1);
let row_text = galley.rows[0].text();
assert!(
row_text.ends_with('…'),
"Expected row to end with `…`, got {row_text:?} when line-breaking the text {text:?} with max_width {max_width} and break_anywhere {break_anywhere}.",
);
}
}
}
{
let mut layout_job = LayoutJob::single_section("Hello\nworld".into(), text_format);
layout_job.wrap.max_width = 50.0;
layout_job.wrap.max_rows = 1;
layout_job.wrap.break_anywhere = false;
let galley = layout(&mut fonts, layout_job.into());
assert!(galley.elided);
assert_eq!(galley.rows.len(), 1);
let row_text = galley.rows[0].text();
assert_eq!(row_text, "Hello…");
}
}
#[test]
fn test_cjk() {
let mut fonts = FontsImpl::new(1.0, 1024, FontDefinitions::default());
let mut layout_job = LayoutJob::single_section(
"日本語とEnglishの混在した文章".into(),
TextFormat::default(),
);
layout_job.wrap.max_width = 90.0;
let galley = layout(&mut fonts, layout_job.into());
assert_eq!(
galley.rows.iter().map(|row| row.text()).collect::<Vec<_>>(),
vec!["日本語と", "Englishの混在", "した文章"]
);
}
#[test]
fn test_pre_cjk() {
let mut fonts = FontsImpl::new(1.0, 1024, FontDefinitions::default());
let mut layout_job = LayoutJob::single_section(
"日本語とEnglishの混在した文章".into(),
TextFormat::default(),
);
layout_job.wrap.max_width = 110.0;
let galley = layout(&mut fonts, layout_job.into());
assert_eq!(
galley.rows.iter().map(|row| row.text()).collect::<Vec<_>>(),
vec!["日本語とEnglish", "の混在した文章"]
);
}
} }

View File

@ -394,7 +394,7 @@ impl Default for TextWrapping {
} }
impl TextWrapping { impl TextWrapping {
/// A row can be as long as it need to be /// A row can be as long as it need to be.
pub fn no_max_width() -> Self { pub fn no_max_width() -> Self {
Self { Self {
max_width: f32::INFINITY, max_width: f32::INFINITY,
@ -402,8 +402,8 @@ impl TextWrapping {
} }
} }
/// Elide text that doesn't fit within the given width. /// Elide text that doesn't fit within the given width, replaced with `…`.
pub fn elide_at_width(max_width: f32) -> Self { pub fn truncate_at_width(max_width: f32) -> Self {
Self { Self {
max_width, max_width,
max_rows: 1, max_rows: 1,
@ -475,6 +475,9 @@ pub struct Galley {
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
pub struct Row { pub struct Row {
/// This is included in case there are no glyphs
pub section_index_at_start: u32,
/// One for each `char`. /// One for each `char`.
pub glyphs: Vec<Glyph>, pub glyphs: Vec<Glyph>,
@ -561,6 +564,11 @@ impl Glyph {
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
impl Row { impl Row {
/// The text on this row, excluding the implicit `\n` if any.
pub fn text(&self) -> String {
self.glyphs.iter().map(|g| g.chr).collect()
}
/// Excludes the implicit `\n` after the [`Row`], if any. /// Excludes the implicit `\n` after the [`Row`], if any.
#[inline] #[inline]
pub fn char_count_excluding_newline(&self) -> usize { pub fn char_count_excluding_newline(&self) -> usize {