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:
parent
1023f937a6
commit
a3ae81cadb
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,25 +178,7 @@ impl View for MiscDemoWindow {
|
||||||
|
|
||||||
// ----------------------------------------------------------------------------
|
// ----------------------------------------------------------------------------
|
||||||
|
|
||||||
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
|
fn label_ui(ui: &mut egui::Ui) {
|
||||||
#[cfg_attr(feature = "serde", serde(default))]
|
|
||||||
pub struct Widgets {
|
|
||||||
angle: f32,
|
|
||||||
password: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for Widgets {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
angle: std::f32::consts::TAU / 3.0,
|
|
||||||
password: "hunter2".to_owned(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Widgets {
|
|
||||||
pub fn ui(&mut self, ui: &mut Ui) {
|
|
||||||
let Self { angle, password } = self;
|
|
||||||
ui.vertical_centered(|ui| {
|
ui.vertical_centered(|ui| {
|
||||||
ui.add(crate::egui_github_link_file_line!());
|
ui.add(crate::egui_github_link_file_line!());
|
||||||
});
|
});
|
||||||
|
|
@ -222,6 +205,39 @@ impl Widgets {
|
||||||
ui.monospace("There is also a monospace font.");
|
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", serde(default))]
|
||||||
|
pub struct Widgets {
|
||||||
|
angle: f32,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Widgets {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
angle: std::f32::consts::TAU / 3.0,
|
||||||
|
password: "hunter2".to_owned(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Widgets {
|
||||||
|
pub fn ui(&mut self, ui: &mut Ui) {
|
||||||
|
let Self { angle, password } = self;
|
||||||
|
ui.vertical_centered(|ui| {
|
||||||
|
ui.add(crate::egui_github_link_file_line!());
|
||||||
|
});
|
||||||
|
|
||||||
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,14 +643,47 @@ fn text_layout_ui(
|
||||||
);
|
);
|
||||||
|
|
||||||
ui.label(job);
|
ui.label(job);
|
||||||
|
}
|
||||||
|
|
||||||
ui.separator();
|
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
|
||||||
|
#[cfg_attr(feature = "serde", serde(default))]
|
||||||
|
struct TextBreakDemo {
|
||||||
|
break_anywhere: bool,
|
||||||
|
max_rows: usize,
|
||||||
|
overflow_character: Option<char>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for TextBreakDemo {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
max_rows: 1,
|
||||||
|
break_anywhere: true,
|
||||||
|
overflow_character: Some('…'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TextBreakDemo {
|
||||||
|
pub fn ui(&mut self, ui: &mut Ui) {
|
||||||
|
let Self {
|
||||||
|
break_anywhere,
|
||||||
|
max_rows,
|
||||||
|
overflow_character,
|
||||||
|
} = self;
|
||||||
|
|
||||||
|
use egui::text::LayoutJob;
|
||||||
|
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
ui.add(DragValue::new(max_rows));
|
ui.add(DragValue::new(max_rows));
|
||||||
ui.label("Max rows");
|
ui.label("Max rows");
|
||||||
});
|
});
|
||||||
ui.checkbox(break_anywhere, "Break anywhere");
|
|
||||||
|
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.horizontal(|ui| {
|
||||||
ui.selectable_value(overflow_character, None, "None");
|
ui.selectable_value(overflow_character, None, "None");
|
||||||
ui.selectable_value(overflow_character, Some('…'), "…");
|
ui.selectable_value(overflow_character, Some('…'), "…");
|
||||||
|
|
@ -648,16 +692,15 @@ fn text_layout_ui(
|
||||||
ui.label("Overflow character");
|
ui.label("Overflow character");
|
||||||
});
|
});
|
||||||
|
|
||||||
let mut job = LayoutJob::single_section(LOREM_IPSUM.to_owned(), TextFormat::default());
|
let mut job =
|
||||||
|
LayoutJob::single_section(crate::LOREM_IPSUM_LONG.to_owned(), TextFormat::default());
|
||||||
job.wrap = TextWrapping {
|
job.wrap = TextWrapping {
|
||||||
max_rows: *max_rows,
|
max_rows: *max_rows,
|
||||||
break_anywhere: *break_anywhere,
|
break_anywhere: *break_anywhere,
|
||||||
overflow_character: *overflow_character,
|
overflow_character: *overflow_character,
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
ui.label(job);
|
|
||||||
|
|
||||||
ui.vertical_centered(|ui| {
|
ui.label(job); // `Label` overrides some of the wrapping settings, e.g. wrap width
|
||||||
ui.add(crate::egui_github_link_file_line!());
|
}
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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, ¶graph, job, &mut rows);
|
line_break(fonts, ¶graph, 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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue