diff --git a/crates/egui/src/widget_text.rs b/crates/egui/src/widget_text.rs index bc71c2b6bab..3d252cb21bb 100644 --- a/crates/egui/src/widget_text.rs +++ b/crates/egui/src/widget_text.rs @@ -676,7 +676,7 @@ impl WidgetTextGalley { self.galley.size() } - /// Size of the laid out text. + /// The full, non-elided text of the input job. #[inline] pub fn text(&self) -> &str { self.galley.text() diff --git a/crates/egui/src/widgets/label.rs b/crates/egui/src/widgets/label.rs index 3e387fca806..288fcb422e8 100644 --- a/crates/egui/src/widgets/label.rs +++ b/crates/egui/src/widgets/label.rs @@ -12,10 +12,14 @@ use crate::{widget_text::WidgetTextGalley, *}; /// 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);`"] pub struct Label { text: WidgetText, wrap: Option, + truncate: bool, sense: Option, } @@ -24,6 +28,7 @@ impl Label { Self { text: text.into(), wrap: None, + truncate: false, sense: None, } } @@ -34,6 +39,8 @@ impl Label { /// 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 /// and horizontal layouts with wrapping, /// and `false` on non-wrapping horizontal layouts. @@ -44,6 +51,23 @@ impl Label { #[inline] pub fn wrap(mut self, wrap: bool) -> Self { 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 } @@ -98,10 +122,11 @@ impl Label { .text .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(); - if should_wrap + if wrap && ui.layout().main_dir() == Direction::LeftToRight && ui.layout().main_wrap() && available_width.is_finite() @@ -138,7 +163,11 @@ impl Label { } (pos, text_galley, response) } 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; } else { text_job.job.wrap.max_width = f32::INFINITY; @@ -167,9 +196,14 @@ impl Label { impl Widget for Label { 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())); + 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) { let response_color = ui.style().interact(&response).text_color(); diff --git a/crates/egui_demo_lib/src/demo/misc_demo_window.rs b/crates/egui_demo_lib/src/demo/misc_demo_window.rs index e4cf92644d4..b48b85c2ac3 100644 --- a/crates/egui_demo_lib/src/demo/misc_demo_window.rs +++ b/crates/egui_demo_lib/src/demo/misc_demo_window.rs @@ -1,5 +1,4 @@ use super::*; -use crate::LOREM_IPSUM; use egui::{epaint::text::TextWrapping, *}; /// Showcase some ui code @@ -8,9 +7,7 @@ use egui::{epaint::text::TextWrapping, *}; pub struct MiscDemoWindow { num_columns: usize, - break_anywhere: bool, - max_rows: usize, - overflow_character: Option, + text_break: TextBreakDemo, widgets: Widgets, colors: ColorWidgets, @@ -27,9 +24,7 @@ impl Default for MiscDemoWindow { MiscDemoWindow { num_columns: 2, - max_rows: 2, - break_anywhere: false, - overflow_character: Some('…'), + text_break: Default::default(), widgets: Default::default(), colors: Default::default(), @@ -61,8 +56,14 @@ impl View for MiscDemoWindow { fn ui(&mut self, ui: &mut Ui) { ui.set_min_width(250.0); - CollapsingHeader::new("Widgets") + CollapsingHeader::new("Label") .default_open(true) + .show(ui, |ui| { + label_ui(ui); + }); + + CollapsingHeader::new("Misc widgets") + .default_open(false) .show(ui, |ui| { self.widgets.ui(ui); }); @@ -70,12 +71,12 @@ impl View for MiscDemoWindow { CollapsingHeader::new("Text layout") .default_open(false) .show(ui, |ui| { - text_layout_ui( - ui, - &mut self.max_rows, - &mut self.break_anywhere, - &mut self.overflow_character, - ); + text_layout_demo(ui); + ui.separator(); + self.text_break.ui(ui); + ui.vertical_centered(|ui| { + ui.add(crate::egui_github_link_file_line!()); + }); }); 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", serde(default))] pub struct Widgets { @@ -200,28 +238,6 @@ impl Widgets { 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| { ui.heading("The name of the tooltip"); ui.horizontal(|ui| { @@ -473,12 +489,7 @@ impl Tree { // ---------------------------------------------------------------------------- -fn text_layout_ui( - ui: &mut egui::Ui, - max_rows: &mut usize, - break_anywhere: &mut bool, - overflow_character: &mut Option, -) { +fn text_layout_demo(ui: &mut Ui) { use egui::text::LayoutJob; let mut job = LayoutJob::default(); @@ -632,32 +643,64 @@ fn text_layout_ui( ); 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, +} - ui.horizontal(|ui| { - ui.add(DragValue::new(max_rows)); - ui.label("Max rows"); - }); - ui.checkbox(break_anywhere, "Break 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"); - }); +impl Default for TextBreakDemo { + fn default() -> Self { + Self { + 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, - ..Default::default() - }; - ui.label(job); +impl TextBreakDemo { + pub fn ui(&mut self, ui: &mut Ui) { + let Self { + break_anywhere, + max_rows, + overflow_character, + } = self; - ui.vertical_centered(|ui| { - ui.add(crate::egui_github_link_file_line!()); - }); + 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 + } } diff --git a/crates/epaint/src/text/text_layout.rs b/crates/epaint/src/text/text_layout.rs index c493f8f94ac..5aab349e2cd 100644 --- a/crates/epaint/src/text/text_layout.rs +++ b/crates/epaint/src/text/text_layout.rs @@ -1,10 +1,12 @@ use std::ops::RangeInclusive; use std::sync::Arc; -use super::{FontsImpl, Galley, Glyph, LayoutJob, LayoutSection, Row, RowVisuals}; -use crate::{Color32, Mesh, Stroke, Vertex}; 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. @@ -54,6 +56,20 @@ struct Paragraph { /// 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. pub fn layout(fonts: &mut FontsImpl, job: Arc) -> 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()]; for (section_index, section) in job.sections.iter().enumerate() { layout_section(fonts, &job, section_index as u32, section, &mut paragraphs); @@ -61,7 +77,8 @@ pub fn layout(fonts: &mut FontsImpl, job: Arc) -> Galley { 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(); @@ -80,7 +97,7 @@ pub fn layout(fonts: &mut FontsImpl, job: Arc) -> Galley { } } - galley_from_rows(point_scale, job, rows) + galley_from_rows(point_scale, job, rows, elided) } fn layout_section( @@ -145,12 +162,18 @@ fn rows_from_paragraphs( fonts: &mut FontsImpl, paragraphs: Vec, job: &LayoutJob, + elided: &mut bool, ) -> Vec { let num_paragraphs = paragraphs.len(); let mut rows = vec![]; 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; if paragraph.glyphs.is_empty() { @@ -166,7 +189,7 @@ fn rows_from_paragraphs( } else { let paragraph_max_x = paragraph.glyphs.last().unwrap().max_x(); 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; rows.push(Row { glyphs: paragraph.glyphs, @@ -175,7 +198,7 @@ fn rows_from_paragraphs( ends_with_newline: !is_last_paragraph, }); } 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; } } @@ -189,6 +212,7 @@ fn line_break( paragraph: &Paragraph, job: &LayoutJob, out_rows: &mut Vec, + elided: &mut bool, ) { // Keeps track of good places to insert row break if we exceed `wrap_width`. 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 row_start_x = 0.0; let mut row_start_idx = 0; - let mut non_empty_rows = 0; for i in 0..paragraph.glyphs.len() { 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; } - if potential_row_width > job.wrap.max_width { + if job.wrap.max_width < potential_row_width { + // Row break: + if first_row_indentation > 0.0 && !row_break_candidates.has_good_candidate(job.wrap.break_anywhere) { @@ -240,10 +266,10 @@ fn line_break( ends_with_newline: false, }); + // Start a new row: row_start_idx = last_kept_index + 1; row_start_x = paragraph.glyphs[row_start_idx].pos.x; row_break_candidates = Default::default(); - non_empty_rows += 1; } else { // 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 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() { replace_last_glyph_with_overflow_character(fonts, job, last_row); + *elided = true; } } else { let glyphs: Vec = 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( fonts: &mut FontsImpl, 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); last_glyph.size = vec2(glyph_info.advance_width, font_height); last_glyph.uv_rect = glyph_info.uv_rect; + last_glyph.ascent = glyph_info.ascent; // reapply kerning 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)) .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_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; if row_width <= job.wrap.max_width { - break; + return; // we are done } row.glyphs.pop(); } + + // We failed to insert `overflow_character` without exceeding `wrap_width`. } fn halign_and_justify_row( @@ -428,7 +463,12 @@ fn halign_and_justify_row( } /// Calculate the Y positions and tessellate the text. -fn galley_from_rows(point_scale: PointScale, job: Arc, mut rows: Vec) -> Galley { +fn galley_from_rows( + point_scale: PointScale, + job: Arc, + mut rows: Vec, + elided: bool, +) -> Galley { let mut first_row_min_height = job.first_row_min_height; let mut cursor_y = 0.0; let mut min_x: f32 = 0.0; @@ -489,6 +529,7 @@ fn galley_from_rows(point_scale: PointScale, job: Arc, mut rows: Vec< Galley { job, rows, + elided, rect, mesh_bounds, num_vertices, diff --git a/crates/epaint/src/text/text_layout_types.rs b/crates/epaint/src/text/text_layout_types.rs index 8ec829b8a67..2a73bd60f8b 100644 --- a/crates/epaint/src/text/text_layout_types.rs +++ b/crates/epaint/src/text/text_layout_types.rs @@ -49,6 +49,7 @@ pub struct LayoutJob { /// The different section, which can have different fonts, colors, etc. pub sections: Vec, + /// Controls the text wrapping and elision. pub wrap: TextWrapping, /// The first row must be at least this high. @@ -58,15 +59,19 @@ pub struct LayoutJob { /// In other cases, set this to `0.0`. 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. + /// /// Default: `true`. pub break_on_newline: bool, /// How to horizontally align the text (`Align::LEFT`, `Align::Center`, `Align::RIGHT`). 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, } @@ -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 { let mut max_height = 0.0_f32; for section in &self.sections { @@ -266,22 +271,50 @@ impl TextFormat { // ---------------------------------------------------------------------------- +/// Controls the text wrapping and elision of a [`LayoutJob`]. #[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct TextWrapping { - /// Try to break 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. + /// Wrap text so that no row is wider than this. + /// + /// 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, - /// Maximum amount of rows the text should have. - /// Set to `0` to disable this. + /// Maximum amount of rows the text galley should have. + /// + /// 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, - /// 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, - /// 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, } @@ -305,13 +338,33 @@ impl Default for TextWrapping { fn default() -> Self { Self { max_width: f32::INFINITY, - max_rows: 0, + max_rows: usize::MAX, break_anywhere: false, 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. @@ -333,11 +386,17 @@ pub struct Galley { pub job: Arc, /// 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. pub rows: Vec, + /// Set to true the text was truncated due to [`TextWrapping::max_rows`]. + pub elided: bool, + /// Bounding rect. /// /// `rect.top()` is always 0.0. @@ -505,6 +564,7 @@ impl Galley { self.job.is_empty() } + /// The full, non-elided text of the input job. #[inline(always)] pub fn text(&self) -> &str { &self.job.text