Add option to truncate text at wrap width (#3244)

* Add option to clip text to wrap width

* Spelling

* Better naming, and report back wether the text was elided

* Improve docstrings

* Simplify

* Fix max_rows with multiple paragraphs

* Add note

* Typos

* fix doclink

* Add `Label::elide`

* Label: show full non-elided text on hover

* Add demo of `Label::elide`

* Call it `Label::truncate`

* Clarify limitations of `break_anywhere`

* Better docstrings
This commit is contained in:
Emil Ernerfeldt 2023-08-14 11:22:04 +02:00 committed by GitHub
parent 1023f937a6
commit a3ae81cadb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 280 additions and 102 deletions

View File

@ -676,7 +676,7 @@ impl WidgetTextGalley {
self.galley.size() self.galley.size()
} }
/// Size of the laid out text. /// The full, non-elided text of the input job.
#[inline] #[inline]
pub fn text(&self) -> &str { pub fn text(&self) -> &str {
self.galley.text() self.galley.text()

View File

@ -12,10 +12,14 @@ use crate::{widget_text::WidgetTextGalley, *};
/// ui.label(egui::RichText::new("With formatting").underline()); /// ui.label(egui::RichText::new("With formatting").underline());
/// # }); /// # });
/// ``` /// ```
///
/// For full control of the text you can use [`crate::text::LayoutJob`]
/// as argument to [`Self::new`].
#[must_use = "You should put this widget in an ui with `ui.add(widget);`"] #[must_use = "You should put this widget in an ui with `ui.add(widget);`"]
pub struct Label { pub struct Label {
text: WidgetText, text: WidgetText,
wrap: Option<bool>, wrap: Option<bool>,
truncate: bool,
sense: Option<Sense>, sense: Option<Sense>,
} }
@ -24,6 +28,7 @@ impl Label {
Self { Self {
text: text.into(), text: text.into(),
wrap: None, wrap: None,
truncate: false,
sense: None, sense: None,
} }
} }
@ -34,6 +39,8 @@ impl Label {
/// If `true`, the text will wrap to stay within the max width of the [`Ui`]. /// If `true`, the text will wrap to stay within the max width of the [`Ui`].
/// ///
/// Calling `wrap` will override [`Self::truncate`].
///
/// By default [`Self::wrap`] will be `true` in vertical layouts /// By default [`Self::wrap`] will be `true` in vertical layouts
/// and horizontal layouts with wrapping, /// and horizontal layouts with wrapping,
/// and `false` on non-wrapping horizontal layouts. /// and `false` on non-wrapping horizontal layouts.
@ -44,6 +51,23 @@ impl Label {
#[inline] #[inline]
pub fn wrap(mut self, wrap: bool) -> Self { pub fn wrap(mut self, wrap: bool) -> Self {
self.wrap = Some(wrap); self.wrap = Some(wrap);
self.truncate = false;
self
}
/// If `true`, the text will stop at the max width of the [`Ui`],
/// and what doesn't fit will be elided, replaced with `…`.
///
/// If the text is truncated, the full text will be shown on hover as a tool-tip.
///
/// Default is `false`, which means the text will expand the parent [`Ui`],
/// or wrap if [`Self::wrap`] is set.
///
/// Calling `truncate` will override [`Self::wrap`].
#[inline]
pub fn truncate(mut self, truncate: bool) -> Self {
self.wrap = None;
self.truncate = truncate;
self self
} }
@ -98,10 +122,11 @@ impl Label {
.text .text
.into_text_job(ui.style(), FontSelection::Default, valign); .into_text_job(ui.style(), FontSelection::Default, valign);
let should_wrap = self.wrap.unwrap_or_else(|| ui.wrap_text()); let truncate = self.truncate;
let wrap = !truncate && self.wrap.unwrap_or_else(|| ui.wrap_text());
let available_width = ui.available_width(); let available_width = ui.available_width();
if should_wrap if wrap
&& ui.layout().main_dir() == Direction::LeftToRight && ui.layout().main_dir() == Direction::LeftToRight
&& ui.layout().main_wrap() && ui.layout().main_wrap()
&& available_width.is_finite() && available_width.is_finite()
@ -138,7 +163,11 @@ impl Label {
} }
(pos, text_galley, response) (pos, text_galley, response)
} else { } else {
if should_wrap { if truncate {
text_job.job.wrap.max_width = available_width;
text_job.job.wrap.max_rows = 1;
text_job.job.wrap.break_anywhere = true;
} else if wrap {
text_job.job.wrap.max_width = available_width; text_job.job.wrap.max_width = available_width;
} else { } else {
text_job.job.wrap.max_width = f32::INFINITY; text_job.job.wrap.max_width = f32::INFINITY;
@ -167,9 +196,14 @@ impl Label {
impl Widget for Label { impl Widget for Label {
fn ui(self, ui: &mut Ui) -> Response { fn ui(self, ui: &mut Ui) -> Response {
let (pos, text_galley, response) = self.layout_in_ui(ui); let (pos, text_galley, mut response) = self.layout_in_ui(ui);
response.widget_info(|| WidgetInfo::labeled(WidgetType::Label, text_galley.text())); response.widget_info(|| WidgetInfo::labeled(WidgetType::Label, text_galley.text()));
if text_galley.galley.elided {
// Show the full (non-elided) text on hover:
response = response.on_hover_text(text_galley.text());
}
if ui.is_rect_visible(response.rect) { if ui.is_rect_visible(response.rect) {
let response_color = ui.style().interact(&response).text_color(); let response_color = ui.style().interact(&response).text_color();

View File

@ -1,5 +1,4 @@
use super::*; use super::*;
use crate::LOREM_IPSUM;
use egui::{epaint::text::TextWrapping, *}; use egui::{epaint::text::TextWrapping, *};
/// Showcase some ui code /// Showcase some ui code
@ -8,9 +7,7 @@ use egui::{epaint::text::TextWrapping, *};
pub struct MiscDemoWindow { pub struct MiscDemoWindow {
num_columns: usize, num_columns: usize,
break_anywhere: bool, text_break: TextBreakDemo,
max_rows: usize,
overflow_character: Option<char>,
widgets: Widgets, widgets: Widgets,
colors: ColorWidgets, colors: ColorWidgets,
@ -27,9 +24,7 @@ impl Default for MiscDemoWindow {
MiscDemoWindow { MiscDemoWindow {
num_columns: 2, num_columns: 2,
max_rows: 2, text_break: Default::default(),
break_anywhere: false,
overflow_character: Some('…'),
widgets: Default::default(), widgets: Default::default(),
colors: Default::default(), colors: Default::default(),
@ -61,8 +56,14 @@ impl View for MiscDemoWindow {
fn ui(&mut self, ui: &mut Ui) { fn ui(&mut self, ui: &mut Ui) {
ui.set_min_width(250.0); ui.set_min_width(250.0);
CollapsingHeader::new("Widgets") CollapsingHeader::new("Label")
.default_open(true) .default_open(true)
.show(ui, |ui| {
label_ui(ui);
});
CollapsingHeader::new("Misc widgets")
.default_open(false)
.show(ui, |ui| { .show(ui, |ui| {
self.widgets.ui(ui); self.widgets.ui(ui);
}); });
@ -70,12 +71,12 @@ impl View for MiscDemoWindow {
CollapsingHeader::new("Text layout") CollapsingHeader::new("Text layout")
.default_open(false) .default_open(false)
.show(ui, |ui| { .show(ui, |ui| {
text_layout_ui( text_layout_demo(ui);
ui, ui.separator();
&mut self.max_rows, self.text_break.ui(ui);
&mut self.break_anywhere, ui.vertical_centered(|ui| {
&mut self.overflow_character, ui.add(crate::egui_github_link_file_line!());
); });
}); });
CollapsingHeader::new("Colors") CollapsingHeader::new("Colors")
@ -177,6 +178,43 @@ impl View for MiscDemoWindow {
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
fn label_ui(ui: &mut egui::Ui) {
ui.vertical_centered(|ui| {
ui.add(crate::egui_github_link_file_line!());
});
ui.horizontal_wrapped(|ui| {
// Trick so we don't have to add spaces in the text below:
let width = ui.fonts(|f|f.glyph_width(&TextStyle::Body.resolve(ui.style()), ' '));
ui.spacing_mut().item_spacing.x = width;
ui.label(RichText::new("Text can have").color(Color32::from_rgb(110, 255, 110)));
ui.colored_label(Color32::from_rgb(128, 140, 255), "color"); // Shortcut version
ui.label("and tooltips.").on_hover_text(
"This is a multiline tooltip that demonstrates that you can easily add tooltips to any element.\nThis is the second line.\nThis is the third.",
);
ui.label("You can mix in other widgets into text, like");
let _ = ui.small_button("this button");
ui.label(".");
ui.label("The default font supports all latin and cyrillic characters (ИÅđ…), common math symbols (∫√∞²⅓…), and many emojis (💓🌟🖩…).")
.on_hover_text("There is currently no support for right-to-left languages.");
ui.label("See the 🔤 Font Book for more!");
ui.monospace("There is also a monospace font.");
});
ui.add(
egui::Label::new(
"Labels containing long text can be set to elide the text that doesn't fit on a single line using `Label::elide`. When hovered, the label will show the full text.",
)
.truncate(true),
);
}
// ----------------------------------------------------------------------------
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
#[cfg_attr(feature = "serde", serde(default))] #[cfg_attr(feature = "serde", serde(default))]
pub struct Widgets { pub struct Widgets {
@ -200,28 +238,6 @@ impl Widgets {
ui.add(crate::egui_github_link_file_line!()); ui.add(crate::egui_github_link_file_line!());
}); });
ui.horizontal_wrapped(|ui| {
// Trick so we don't have to add spaces in the text below:
let width = ui.fonts(|f|f.glyph_width(&TextStyle::Body.resolve(ui.style()), ' '));
ui.spacing_mut().item_spacing.x = width;
ui.label(RichText::new("Text can have").color(Color32::from_rgb(110, 255, 110)));
ui.colored_label(Color32::from_rgb(128, 140, 255), "color"); // Shortcut version
ui.label("and tooltips.").on_hover_text(
"This is a multiline tooltip that demonstrates that you can easily add tooltips to any element.\nThis is the second line.\nThis is the third.",
);
ui.label("You can mix in other widgets into text, like");
let _ = ui.small_button("this button");
ui.label(".");
ui.label("The default font supports all latin and cyrillic characters (ИÅđ…), common math symbols (∫√∞²⅓…), and many emojis (💓🌟🖩…).")
.on_hover_text("There is currently no support for right-to-left languages.");
ui.label("See the 🔤 Font Book for more!");
ui.monospace("There is also a monospace font.");
});
let tooltip_ui = |ui: &mut Ui| { let tooltip_ui = |ui: &mut Ui| {
ui.heading("The name of the tooltip"); ui.heading("The name of the tooltip");
ui.horizontal(|ui| { ui.horizontal(|ui| {
@ -473,12 +489,7 @@ impl Tree {
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
fn text_layout_ui( fn text_layout_demo(ui: &mut Ui) {
ui: &mut egui::Ui,
max_rows: &mut usize,
break_anywhere: &mut bool,
overflow_character: &mut Option<char>,
) {
use egui::text::LayoutJob; use egui::text::LayoutJob;
let mut job = LayoutJob::default(); let mut job = LayoutJob::default();
@ -632,32 +643,64 @@ fn text_layout_ui(
); );
ui.label(job); ui.label(job);
}
ui.separator();
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
ui.horizontal(|ui| { #[cfg_attr(feature = "serde", serde(default))]
ui.add(DragValue::new(max_rows)); struct TextBreakDemo {
ui.label("Max rows"); break_anywhere: bool,
}); max_rows: usize,
ui.checkbox(break_anywhere, "Break anywhere"); overflow_character: Option<char>,
ui.horizontal(|ui| { }
ui.selectable_value(overflow_character, None, "None");
ui.selectable_value(overflow_character, Some('…'), ""); impl Default for TextBreakDemo {
ui.selectable_value(overflow_character, Some('—'), ""); fn default() -> Self {
ui.selectable_value(overflow_character, Some('-'), " - "); Self {
ui.label("Overflow character"); max_rows: 1,
}); break_anywhere: true,
overflow_character: Some('…'),
let mut job = LayoutJob::single_section(LOREM_IPSUM.to_owned(), TextFormat::default()); }
job.wrap = TextWrapping { }
max_rows: *max_rows, }
break_anywhere: *break_anywhere,
overflow_character: *overflow_character, impl TextBreakDemo {
..Default::default() pub fn ui(&mut self, ui: &mut Ui) {
}; let Self {
ui.label(job); break_anywhere,
max_rows,
ui.vertical_centered(|ui| { overflow_character,
ui.add(crate::egui_github_link_file_line!()); } = self;
});
use egui::text::LayoutJob;
ui.horizontal(|ui| {
ui.add(DragValue::new(max_rows));
ui.label("Max rows");
});
ui.horizontal(|ui| {
ui.label("Line-break:");
ui.radio_value(break_anywhere, false, "word boundaries");
ui.radio_value(break_anywhere, true, "anywhere");
});
ui.horizontal(|ui| {
ui.selectable_value(overflow_character, None, "None");
ui.selectable_value(overflow_character, Some('…'), "");
ui.selectable_value(overflow_character, Some('—'), "");
ui.selectable_value(overflow_character, Some('-'), " - ");
ui.label("Overflow character");
});
let mut job =
LayoutJob::single_section(crate::LOREM_IPSUM_LONG.to_owned(), TextFormat::default());
job.wrap = TextWrapping {
max_rows: *max_rows,
break_anywhere: *break_anywhere,
overflow_character: *overflow_character,
..Default::default()
};
ui.label(job); // `Label` overrides some of the wrapping settings, e.g. wrap width
}
} }

View File

@ -1,10 +1,12 @@
use std::ops::RangeInclusive; use std::ops::RangeInclusive;
use std::sync::Arc; use std::sync::Arc;
use super::{FontsImpl, Galley, Glyph, LayoutJob, LayoutSection, Row, RowVisuals};
use crate::{Color32, Mesh, Stroke, Vertex};
use emath::*; use emath::*;
use crate::{Color32, Mesh, Stroke, Vertex};
use super::{FontsImpl, Galley, Glyph, LayoutJob, LayoutSection, Row, RowVisuals};
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
/// Represents GUI scale and convenience methods for rounding to pixels. /// Represents GUI scale and convenience methods for rounding to pixels.
@ -54,6 +56,20 @@ struct Paragraph {
/// In most cases you should use [`crate::Fonts::layout_job`] instead /// In most cases you should use [`crate::Fonts::layout_job`] instead
/// since that memoizes the input, making subsequent layouting of the same text much faster. /// since that memoizes the input, making subsequent layouting of the same text much faster.
pub fn layout(fonts: &mut FontsImpl, job: Arc<LayoutJob>) -> Galley { pub fn layout(fonts: &mut FontsImpl, job: Arc<LayoutJob>) -> Galley {
if job.wrap.max_rows == 0 {
// Early-out: no text
return Galley {
job,
rows: Default::default(),
rect: Rect::from_min_max(Pos2::ZERO, Pos2::ZERO),
mesh_bounds: Rect::NOTHING,
num_vertices: 0,
num_indices: 0,
pixels_per_point: fonts.pixels_per_point(),
elided: true,
};
}
let mut paragraphs = vec![Paragraph::default()]; let mut paragraphs = vec![Paragraph::default()];
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);
@ -61,7 +77,8 @@ 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 rows = rows_from_paragraphs(fonts, paragraphs, &job); let mut elided = false;
let mut rows = rows_from_paragraphs(fonts, paragraphs, &job, &mut elided);
let justify = job.justify && job.wrap.max_width.is_finite(); let justify = job.justify && job.wrap.max_width.is_finite();
@ -80,7 +97,7 @@ pub fn layout(fonts: &mut FontsImpl, job: Arc<LayoutJob>) -> Galley {
} }
} }
galley_from_rows(point_scale, job, rows) galley_from_rows(point_scale, job, rows, elided)
} }
fn layout_section( fn layout_section(
@ -145,12 +162,18 @@ fn rows_from_paragraphs(
fonts: &mut FontsImpl, fonts: &mut FontsImpl,
paragraphs: Vec<Paragraph>, paragraphs: Vec<Paragraph>,
job: &LayoutJob, job: &LayoutJob,
elided: &mut bool,
) -> Vec<Row> { ) -> Vec<Row> {
let num_paragraphs = paragraphs.len(); let num_paragraphs = paragraphs.len();
let mut rows = vec![]; let mut rows = vec![];
for (i, paragraph) in paragraphs.into_iter().enumerate() { for (i, paragraph) in paragraphs.into_iter().enumerate() {
if job.wrap.max_rows <= rows.len() {
*elided = true;
break;
}
let is_last_paragraph = (i + 1) == num_paragraphs; let is_last_paragraph = (i + 1) == num_paragraphs;
if paragraph.glyphs.is_empty() { if paragraph.glyphs.is_empty() {
@ -166,7 +189,7 @@ fn rows_from_paragraphs(
} else { } else {
let paragraph_max_x = paragraph.glyphs.last().unwrap().max_x(); let paragraph_max_x = paragraph.glyphs.last().unwrap().max_x();
if paragraph_max_x <= job.wrap.max_width { if paragraph_max_x <= job.wrap.max_width {
// early-out optimization // 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 {
glyphs: paragraph.glyphs, glyphs: paragraph.glyphs,
@ -175,7 +198,7 @@ fn rows_from_paragraphs(
ends_with_newline: !is_last_paragraph, ends_with_newline: !is_last_paragraph,
}); });
} else { } else {
line_break(fonts, &paragraph, job, &mut rows); line_break(fonts, &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;
} }
} }
@ -189,6 +212,7 @@ fn line_break(
paragraph: &Paragraph, paragraph: &Paragraph,
job: &LayoutJob, job: &LayoutJob,
out_rows: &mut Vec<Row>, 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();
@ -196,16 +220,18 @@ fn line_break(
let mut first_row_indentation = paragraph.glyphs[0].pos.x; let mut first_row_indentation = paragraph.glyphs[0].pos.x;
let mut row_start_x = 0.0; let mut row_start_x = 0.0;
let mut row_start_idx = 0; let mut row_start_idx = 0;
let mut non_empty_rows = 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; let potential_row_width = paragraph.glyphs[i].max_x() - row_start_x;
if job.wrap.max_rows > 0 && non_empty_rows >= job.wrap.max_rows { if job.wrap.max_rows <= out_rows.len() {
*elided = true;
break; break;
} }
if potential_row_width > job.wrap.max_width { if job.wrap.max_width < potential_row_width {
// Row break:
if first_row_indentation > 0.0 if first_row_indentation > 0.0
&& !row_break_candidates.has_good_candidate(job.wrap.break_anywhere) && !row_break_candidates.has_good_candidate(job.wrap.break_anywhere)
{ {
@ -240,10 +266,10 @@ fn line_break(
ends_with_newline: false, ends_with_newline: false,
}); });
// Start a new row:
row_start_idx = last_kept_index + 1; row_start_idx = last_kept_index + 1;
row_start_x = paragraph.glyphs[row_start_idx].pos.x; row_start_x = paragraph.glyphs[row_start_idx].pos.x;
row_break_candidates = Default::default(); row_break_candidates = Default::default();
non_empty_rows += 1;
} else { } else {
// Found no place to break, so we have to overrun wrap_width. // Found no place to break, so we have to overrun wrap_width.
} }
@ -253,9 +279,12 @@ fn line_break(
} }
if row_start_idx < paragraph.glyphs.len() { if row_start_idx < paragraph.glyphs.len() {
if job.wrap.max_rows > 0 && non_empty_rows == job.wrap.max_rows { // Final row of text:
if job.wrap.max_rows <= out_rows.len() {
if let Some(last_row) = out_rows.last_mut() { if let Some(last_row) = out_rows.last_mut() {
replace_last_glyph_with_overflow_character(fonts, job, last_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..]
@ -280,6 +309,7 @@ fn line_break(
} }
} }
/// Trims the last glyphs in the row and replaces it with an overflow character (e.g. `…`).
fn replace_last_glyph_with_overflow_character( fn replace_last_glyph_with_overflow_character(
fonts: &mut FontsImpl, fonts: &mut FontsImpl,
job: &LayoutJob, job: &LayoutJob,
@ -318,6 +348,7 @@ fn replace_last_glyph_with_overflow_character(
let (font_impl, glyph_info) = font.glyph_info_and_font_impl(last_glyph.chr); let (font_impl, glyph_info) = font.glyph_info_and_font_impl(last_glyph.chr);
last_glyph.size = vec2(glyph_info.advance_width, font_height); last_glyph.size = vec2(glyph_info.advance_width, font_height);
last_glyph.uv_rect = glyph_info.uv_rect; last_glyph.uv_rect = glyph_info.uv_rect;
last_glyph.ascent = glyph_info.ascent;
// reapply kerning // reapply kerning
last_glyph.pos.x += font_impl last_glyph.pos.x += font_impl
@ -325,16 +356,20 @@ fn replace_last_glyph_with_overflow_character(
.map(|(font_impl, prev_glyph_id)| 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(); .unwrap_or_default();
// check if we're still within width budget row.rect.max.x = last_glyph.max_x();
// check if we're within width budget
let row_end_x = last_glyph.max_x(); let row_end_x = last_glyph.max_x();
let row_start_x = row.glyphs.first().unwrap().pos.x; // if `last_mut()` returned `Some`, then so will `first()` let row_start_x = row.glyphs.first().unwrap().pos.x; // if `last_mut()` returned `Some`, then so will `first()`
let row_width = row_end_x - row_start_x; let row_width = row_end_x - row_start_x;
if row_width <= job.wrap.max_width { if row_width <= job.wrap.max_width {
break; return; // we are done
} }
row.glyphs.pop(); row.glyphs.pop();
} }
// We failed to insert `overflow_character` without exceeding `wrap_width`.
} }
fn halign_and_justify_row( fn halign_and_justify_row(
@ -428,7 +463,12 @@ fn halign_and_justify_row(
} }
/// Calculate the Y positions and tessellate the text. /// Calculate the Y positions and tessellate the text.
fn galley_from_rows(point_scale: PointScale, job: Arc<LayoutJob>, mut rows: Vec<Row>) -> Galley { fn galley_from_rows(
point_scale: PointScale,
job: Arc<LayoutJob>,
mut rows: Vec<Row>,
elided: bool,
) -> Galley {
let mut first_row_min_height = job.first_row_min_height; let mut first_row_min_height = job.first_row_min_height;
let mut cursor_y = 0.0; let mut cursor_y = 0.0;
let mut min_x: f32 = 0.0; let mut min_x: f32 = 0.0;
@ -489,6 +529,7 @@ fn galley_from_rows(point_scale: PointScale, job: Arc<LayoutJob>, mut rows: Vec<
Galley { Galley {
job, job,
rows, rows,
elided,
rect, rect,
mesh_bounds, mesh_bounds,
num_vertices, num_vertices,

View File

@ -49,6 +49,7 @@ pub struct LayoutJob {
/// The different section, which can have different fonts, colors, etc. /// The different section, which can have different fonts, colors, etc.
pub sections: Vec<LayoutSection>, pub sections: Vec<LayoutSection>,
/// Controls the text wrapping and elision.
pub wrap: TextWrapping, pub wrap: TextWrapping,
/// The first row must be at least this high. /// The first row must be at least this high.
@ -58,15 +59,19 @@ pub struct LayoutJob {
/// In other cases, set this to `0.0`. /// In other cases, set this to `0.0`.
pub first_row_min_height: f32, pub first_row_min_height: f32,
/// If `false`, all newlines characters will be ignored /// If `true`, all `\n` characters will result in a new _paragraph_,
/// starting on a new row.
///
/// If `false`, all `\n` characters will be ignored
/// and show up as the replacement character. /// and show up as the replacement character.
///
/// Default: `true`. /// Default: `true`.
pub break_on_newline: bool, pub break_on_newline: bool,
/// How to horizontally align the text (`Align::LEFT`, `Align::Center`, `Align::RIGHT`). /// How to horizontally align the text (`Align::LEFT`, `Align::Center`, `Align::RIGHT`).
pub halign: Align, pub halign: Align,
/// Justify text so that word-wrapped rows fill the whole [`TextWrapping::max_width`] /// Justify text so that word-wrapped rows fill the whole [`TextWrapping::max_width`].
pub justify: bool, pub justify: bool,
} }
@ -153,7 +158,7 @@ impl LayoutJob {
}); });
} }
/// The height of the tallest used font in the job. /// The height of the tallest font used in the job.
pub fn font_height(&self, fonts: &crate::Fonts) -> f32 { pub fn font_height(&self, fonts: &crate::Fonts) -> f32 {
let mut max_height = 0.0_f32; let mut max_height = 0.0_f32;
for section in &self.sections { for section in &self.sections {
@ -266,22 +271,50 @@ impl TextFormat {
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
/// Controls the text wrapping and elision of a [`LayoutJob`].
#[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 TextWrapping { pub struct TextWrapping {
/// Try to break text so that no row is wider than this. /// Wrap text so that no row is wider than this.
/// Set to [`f32::INFINITY`] to turn off wrapping. ///
/// Note that `\n` always produces a new line. /// If you would rather truncate text that doesn't fit, set [`Self::max_rows`] to `1`.
///
/// Set `max_width` to [`f32::INFINITY`] to turn off wrapping and elision.
///
/// Note that `\n` always produces a new row
/// if [`LayoutJob::break_on_newline`] is `true`.
pub max_width: f32, pub max_width: f32,
/// Maximum amount of rows the text should have. /// Maximum amount of rows the text galley should have.
/// Set to `0` to disable this. ///
/// If this limit is reached, text will be truncated and
/// and [`Self::overflow_character`] appended to the final row.
/// You can detect this by checking [`Galley::elided`].
///
/// If set to `0`, no text will be outputted.
///
/// If set to `1`, a single row will be outputted,
/// eliding the text after [`Self::max_width`] is reached.
/// When you set `max_rows = 1`, it is recommended you also set [`Self::break_anywhere`] to `true`.
///
/// Default value: `usize::MAX`.
pub max_rows: usize, pub max_rows: usize,
/// Don't try to break text at an appropriate place. /// If `true`: Allow breaking between any characters.
/// If `false` (default): prefer breaking between words, etc.
///
/// NOTE: Due to limitations in the current implementation,
/// when truncating text using [`Self::max_rows`] the text may be truncated
/// in the middle of a word even if [`Self::break_anywhere`] is `false`.
/// Therefore it is recommended to set [`Self::break_anywhere`] to `true`
/// whenever [`Self::max_rows`] is set to `1`.
pub break_anywhere: bool, pub break_anywhere: bool,
/// Character to use to represent clipped text, `…` for example, which is the default. /// Character to use to represent elided text.
///
/// The default is `…`.
///
/// If not set, no character will be used (but the text will still be elided).
pub overflow_character: Option<char>, pub overflow_character: Option<char>,
} }
@ -305,13 +338,33 @@ impl Default for TextWrapping {
fn default() -> Self { fn default() -> Self {
Self { Self {
max_width: f32::INFINITY, max_width: f32::INFINITY,
max_rows: 0, max_rows: usize::MAX,
break_anywhere: false, break_anywhere: false,
overflow_character: Some('…'), overflow_character: Some('…'),
} }
} }
} }
impl TextWrapping {
/// A row can be as long as it need to be
pub fn no_max_width() -> Self {
Self {
max_width: f32::INFINITY,
..Default::default()
}
}
/// Elide text that doesn't fit within the given width.
pub fn elide_at_width(max_width: f32) -> Self {
Self {
max_width,
max_rows: 1,
break_anywhere: true,
..Default::default()
}
}
}
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
/// Text that has been laid out, ready for painting. /// Text that has been laid out, ready for painting.
@ -333,11 +386,17 @@ pub struct Galley {
pub job: Arc<LayoutJob>, pub job: Arc<LayoutJob>,
/// Rows of text, from top to bottom. /// Rows of text, from top to bottom.
/// The number of characters in all rows sum up to `job.text.chars().count()`. ///
/// Note that each paragraph (pieces of text separated with `\n`) /// The number of characters in all rows sum up to `job.text.chars().count()`
/// unless [`Self::elided`] is `true`.
///
/// Note that a paragraph (a piece of text separated with `\n`)
/// can be split up into multiple rows. /// can be split up into multiple rows.
pub rows: Vec<Row>, pub rows: Vec<Row>,
/// Set to true the text was truncated due to [`TextWrapping::max_rows`].
pub elided: bool,
/// Bounding rect. /// Bounding rect.
/// ///
/// `rect.top()` is always 0.0. /// `rect.top()` is always 0.0.
@ -505,6 +564,7 @@ impl Galley {
self.job.is_empty() self.job.is_empty()
} }
/// The full, non-elided text of the input job.
#[inline(always)] #[inline(always)]
pub fn text(&self) -> &str { pub fn text(&self) -> &str {
&self.job.text &self.job.text