From ad982ed956bee9f2538f88351a3f9146c3657b9b Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Sat, 12 Aug 2023 15:41:09 +0200 Subject: [PATCH 01/15] Add option to clip text to wrap width --- .../src/demo/misc_demo_window.rs | 115 ++++++++++------- crates/epaint/src/text/text_layout.rs | 117 ++++++++++++------ crates/epaint/src/text/text_layout_types.rs | 31 ++++- 3 files changed, 174 insertions(+), 89 deletions(-) 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..38df00d503d 100644 --- a/crates/egui_demo_lib/src/demo/misc_demo_window.rs +++ b/crates/egui_demo_lib/src/demo/misc_demo_window.rs @@ -8,9 +8,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 +25,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(), @@ -70,12 +66,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") @@ -473,12 +469,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 +623,68 @@ fn text_layout_ui( ); ui.label(job); +} - ui.separator(); - - 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"); - }); - - 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); +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[cfg_attr(feature = "serde", serde(default))] +struct TextBreakDemo { + clip_to_max_width: bool, + break_anywhere: bool, + max_rows: usize, + overflow_character: Option, +} + +impl Default for TextBreakDemo { + fn default() -> Self { + Self { + clip_to_max_width: true, + max_rows: 2, + break_anywhere: false, + overflow_character: Some('…'), + } + } +} + +impl TextBreakDemo { + pub fn ui(&mut self, ui: &mut Ui) { + let Self { + clip_to_max_width, + break_anywhere, + max_rows, + overflow_character, + } = self; + + use egui::text::LayoutJob; + + ui.horizontal(|ui| { + ui.radio_value(clip_to_max_width, false, "Wrap"); + ui.radio_value(clip_to_max_width, true, "Clip"); + }); + + if !*clip_to_max_width { + ui.horizontal(|ui| { + ui.add(DragValue::new(max_rows)); + ui.label("Max rows"); + }); + ui.checkbox(break_anywhere, "Break anywhere"); + } - ui.vertical_centered(|ui| { - ui.add(crate::egui_github_link_file_line!()); - }); + 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(LOREM_IPSUM.to_owned(), TextFormat::default()); + job.wrap = TextWrapping { + max_rows: *max_rows, + clip_to_max_width: *clip_to_max_width, + break_anywhere: *break_anywhere, + overflow_character: *overflow_character, + ..Default::default() + }; + ui.label(job); + } } diff --git a/crates/epaint/src/text/text_layout.rs b/crates/epaint/src/text/text_layout.rs index c493f8f94ac..4ecafa6762c 100644 --- a/crates/epaint/src/text/text_layout.rs +++ b/crates/epaint/src/text/text_layout.rs @@ -166,7 +166,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, @@ -201,51 +201,82 @@ fn line_break( 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 != 0 && non_empty_rows >= job.wrap.max_rows { break; } if potential_row_width > job.wrap.max_width { - if first_row_indentation > 0.0 - && !row_break_candidates.has_good_candidate(job.wrap.break_anywhere) - { - // 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. - out_rows.push(Row { - glyphs: vec![], - visuals: Default::default(), - rect: rect_from_x_range(first_row_indentation..=first_row_indentation), - ends_with_newline: false, - }); - row_start_x += first_row_indentation; - first_row_indentation = 0.0; - } else if let Some(last_kept_index) = row_break_candidates.get(job.wrap.break_anywhere) - { - let glyphs: Vec = paragraph.glyphs[row_start_idx..=last_kept_index] - .iter() - .copied() - .map(|mut glyph| { - glyph.pos.x -= row_start_x; - glyph - }) - .collect(); - - let paragraph_min_x = glyphs[0].pos.x; - let paragraph_max_x = glyphs.last().unwrap().max_x(); - - out_rows.push(Row { + if job.wrap.clip_to_max_width { + assert_eq!(row_start_x, 0.0); + assert_eq!(row_start_idx, 0); + assert_eq!(non_empty_rows, 0); + + let glyphs: Vec = paragraph.glyphs[..i].to_vec(); + + let x_range = if let (Some(first), Some(last)) = (glyphs.first(), glyphs.last()) { + first.pos.x..=last.max_x() + } else { + 0.0..=0.0 + }; + + let mut row = Row { glyphs, visuals: Default::default(), - rect: rect_from_x_range(paragraph_min_x..=paragraph_max_x), + rect: rect_from_x_range(x_range), ends_with_newline: false, - }); + }; + + // Add the trailing overflow character (e.g. `…`): + replace_last_glyph_with_overflow_character(fonts, job, &mut row); + + out_rows.push(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; + return; } else { - // Found no place to break, so we have to overrun wrap_width. + // Row break: + + if first_row_indentation > 0.0 + && !row_break_candidates.has_good_candidate(job.wrap.break_anywhere) + { + // 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. + out_rows.push(Row { + glyphs: vec![], + visuals: Default::default(), + rect: rect_from_x_range(first_row_indentation..=first_row_indentation), + ends_with_newline: false, + }); + row_start_x += first_row_indentation; + first_row_indentation = 0.0; + } else if let Some(last_kept_index) = + row_break_candidates.get(job.wrap.break_anywhere) + { + let glyphs: Vec = paragraph.glyphs[row_start_idx..=last_kept_index] + .iter() + .copied() + .map(|mut glyph| { + glyph.pos.x -= row_start_x; + glyph + }) + .collect(); + + let paragraph_min_x = glyphs[0].pos.x; + let paragraph_max_x = glyphs.last().unwrap().max_x(); + + out_rows.push(Row { + glyphs, + visuals: Default::default(), + rect: rect_from_x_range(paragraph_min_x..=paragraph_max_x), + ends_with_newline: false, + }); + + 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,7 +284,7 @@ fn line_break( } if row_start_idx < paragraph.glyphs.len() { - if job.wrap.max_rows > 0 && non_empty_rows == job.wrap.max_rows { + if job.wrap.max_rows != 0 && non_empty_rows == job.wrap.max_rows { if let Some(last_row) = out_rows.last_mut() { replace_last_glyph_with_overflow_character(fonts, job, last_row); } @@ -280,6 +311,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 +350,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 +358,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( diff --git a/crates/epaint/src/text/text_layout_types.rs b/crates/epaint/src/text/text_layout_types.rs index 8ec829b8a67..65b67b2c51b 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. pub wrap: TextWrapping, /// The first row must be at least this high. @@ -66,7 +67,7 @@ pub struct LayoutJob { /// 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, } @@ -269,19 +270,36 @@ impl TextFormat { #[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. + /// Try to wrap or clip text so that no row is wider than this. + /// + /// Wether the text is wrapped or clipped depends on [`Self::clip_to_max_width`]. + /// + /// Set to [`f32::INFINITY`] to turn off wrapping/clipping. + /// + /// Note that `\n` always produces a new line + /// if [`LayoutJob::break_on_newline`] is `true`. pub max_width: f32, + /// If `true`, the text that doesn't fit within [`Self::max_width`] + /// will be ellided and replaced with [`Self::overflow_character`]. + /// + /// Default: `false`. + pub clip_to_max_width: bool, + /// Maximum amount of rows the text should have. + /// + /// If the text has more rows than this, the last row will be clipped, and [`Self::overflow_character`] appended. + /// /// Set to `0` to disable this. 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. pub break_anywhere: bool, /// Character to use to represent clipped text, `…` for example, which is the default. + /// + /// If not set, no character will be used (but the text will still be clipped). pub overflow_character: Option, } @@ -290,11 +308,13 @@ impl std::hash::Hash for TextWrapping { fn hash(&self, state: &mut H) { let Self { max_width, + clip_to_max_width, max_rows, break_anywhere, overflow_character, } = self; crate::f32_hash(state, *max_width); + clip_to_max_width.hash(state); max_rows.hash(state); break_anywhere.hash(state); overflow_character.hash(state); @@ -305,6 +325,7 @@ impl Default for TextWrapping { fn default() -> Self { Self { max_width: f32::INFINITY, + clip_to_max_width: false, max_rows: 0, break_anywhere: false, overflow_character: Some('…'), From 107f52b0748bcab996394d0f23c0fbf9b0df623f Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Sat, 12 Aug 2023 15:45:21 +0200 Subject: [PATCH 02/15] Spelling --- crates/epaint/src/text/text_layout_types.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/epaint/src/text/text_layout_types.rs b/crates/epaint/src/text/text_layout_types.rs index 65b67b2c51b..f85ec5a63a2 100644 --- a/crates/epaint/src/text/text_layout_types.rs +++ b/crates/epaint/src/text/text_layout_types.rs @@ -272,7 +272,7 @@ impl TextFormat { pub struct TextWrapping { /// Try to wrap or clip text so that no row is wider than this. /// - /// Wether the text is wrapped or clipped depends on [`Self::clip_to_max_width`]. + /// Whether the text is wrapped or clipped depends on [`Self::clip_to_max_width`]. /// /// Set to [`f32::INFINITY`] to turn off wrapping/clipping. /// @@ -281,7 +281,7 @@ pub struct TextWrapping { pub max_width: f32, /// If `true`, the text that doesn't fit within [`Self::max_width`] - /// will be ellided and replaced with [`Self::overflow_character`]. + /// will be elided and replaced with [`Self::overflow_character`]. /// /// Default: `false`. pub clip_to_max_width: bool, From 4410fd66bdf19c3abdd0f102191f7f05804e0f7c Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Sun, 13 Aug 2023 09:53:25 +0200 Subject: [PATCH 03/15] Better naming, and report back wether the text was elided --- .../src/demo/misc_demo_window.rs | 14 +++--- crates/epaint/src/text/text_layout.rs | 45 +++++++++++++++---- crates/epaint/src/text/text_layout_types.rs | 30 ++++++++----- 3 files changed, 63 insertions(+), 26 deletions(-) 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 38df00d503d..0e959056850 100644 --- a/crates/egui_demo_lib/src/demo/misc_demo_window.rs +++ b/crates/egui_demo_lib/src/demo/misc_demo_window.rs @@ -628,7 +628,7 @@ fn text_layout_demo(ui: &mut Ui) { #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "serde", serde(default))] struct TextBreakDemo { - clip_to_max_width: bool, + elide_at_max_width: bool, break_anywhere: bool, max_rows: usize, overflow_character: Option, @@ -637,7 +637,7 @@ struct TextBreakDemo { impl Default for TextBreakDemo { fn default() -> Self { Self { - clip_to_max_width: true, + elide_at_max_width: true, max_rows: 2, break_anywhere: false, overflow_character: Some('…'), @@ -648,7 +648,7 @@ impl Default for TextBreakDemo { impl TextBreakDemo { pub fn ui(&mut self, ui: &mut Ui) { let Self { - clip_to_max_width, + elide_at_max_width, break_anywhere, max_rows, overflow_character, @@ -657,11 +657,11 @@ impl TextBreakDemo { use egui::text::LayoutJob; ui.horizontal(|ui| { - ui.radio_value(clip_to_max_width, false, "Wrap"); - ui.radio_value(clip_to_max_width, true, "Clip"); + ui.radio_value(elide_at_max_width, false, "Wrap"); + ui.radio_value(elide_at_max_width, true, "Clip"); }); - if !*clip_to_max_width { + if !*elide_at_max_width { ui.horizontal(|ui| { ui.add(DragValue::new(max_rows)); ui.label("Max rows"); @@ -680,7 +680,7 @@ impl TextBreakDemo { let mut job = LayoutJob::single_section(LOREM_IPSUM.to_owned(), TextFormat::default()); job.wrap = TextWrapping { max_rows: *max_rows, - clip_to_max_width: *clip_to_max_width, + elide_at_max_width: *elide_at_max_width, break_anywhere: *break_anywhere, overflow_character: *overflow_character, ..Default::default() diff --git a/crates/epaint/src/text/text_layout.rs b/crates/epaint/src/text/text_layout.rs index 4ecafa6762c..d253122d1e9 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,6 +162,7 @@ fn rows_from_paragraphs( fonts: &mut FontsImpl, paragraphs: Vec, job: &LayoutJob, + elided: &mut bool, ) -> Vec { let num_paragraphs = paragraphs.len(); @@ -175,7 +193,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 +207,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(); @@ -201,12 +220,12 @@ fn line_break( 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 non_empty_rows >= job.wrap.max_rows { break; } if potential_row_width > job.wrap.max_width { - if job.wrap.clip_to_max_width { + if job.wrap.elide_at_max_width { assert_eq!(row_start_x, 0.0); assert_eq!(row_start_idx, 0); assert_eq!(non_empty_rows, 0); @@ -228,6 +247,7 @@ fn line_break( // Add the trailing overflow character (e.g. `…`): replace_last_glyph_with_overflow_character(fonts, job, &mut row); + *elided = true; out_rows.push(row); @@ -284,9 +304,10 @@ fn line_break( } if row_start_idx < paragraph.glyphs.len() { - if job.wrap.max_rows != 0 && non_empty_rows == job.wrap.max_rows { + if non_empty_rows == job.wrap.max_rows { 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..] @@ -465,7 +486,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; @@ -526,6 +552,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 f85ec5a63a2..d51302386ad 100644 --- a/crates/epaint/src/text/text_layout_types.rs +++ b/crates/epaint/src/text/text_layout_types.rs @@ -272,7 +272,7 @@ impl TextFormat { pub struct TextWrapping { /// Try to wrap or clip text so that no row is wider than this. /// - /// Whether the text is wrapped or clipped depends on [`Self::clip_to_max_width`]. + /// Whether the text is wrapped or clipped depends on [`Self::elide_at_max_width`]. /// /// Set to [`f32::INFINITY`] to turn off wrapping/clipping. /// @@ -284,13 +284,16 @@ pub struct TextWrapping { /// will be elided and replaced with [`Self::overflow_character`]. /// /// Default: `false`. - pub clip_to_max_width: bool, + pub elide_at_max_width: bool, /// Maximum amount of rows the text should have. /// - /// If the text has more rows than this, the last row will be clipped, and [`Self::overflow_character`] appended. + /// If the full text requires more rows than this, + /// the last rows will be clipped, and [`Self::overflow_character`] appended. /// - /// Set to `0` to disable this. + /// If set to `0`, no text will be shown. + /// + /// Default value: `usize::MAX`. pub max_rows: usize, /// If `true`: Allow breaking between any characters. @@ -308,13 +311,13 @@ impl std::hash::Hash for TextWrapping { fn hash(&self, state: &mut H) { let Self { max_width, - clip_to_max_width, + elide_at_max_width, max_rows, break_anywhere, overflow_character, } = self; crate::f32_hash(state, *max_width); - clip_to_max_width.hash(state); + elide_at_max_width.hash(state); max_rows.hash(state); break_anywhere.hash(state); overflow_character.hash(state); @@ -325,8 +328,8 @@ impl Default for TextWrapping { fn default() -> Self { Self { max_width: f32::INFINITY, - clip_to_max_width: false, - max_rows: 0, + elide_at_max_width: false, + max_rows: usize::MAX, break_anywhere: false, overflow_character: Some('…'), } @@ -354,11 +357,18 @@ 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 if some text was elided due to either + /// [`TextWrapping::elide_at_max_width`] or [`TextWrapping::max_rows`]. + pub elided: bool, + /// Bounding rect. /// /// `rect.top()` is always 0.0. From 7d41c0a6c1ee6514520a19459742b6e502eb97a1 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Sun, 13 Aug 2023 09:57:05 +0200 Subject: [PATCH 04/15] Improve docstrings --- crates/epaint/src/text/text_layout_types.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/epaint/src/text/text_layout_types.rs b/crates/epaint/src/text/text_layout_types.rs index d51302386ad..96b29475f69 100644 --- a/crates/epaint/src/text/text_layout_types.rs +++ b/crates/epaint/src/text/text_layout_types.rs @@ -282,14 +282,16 @@ pub struct TextWrapping { /// If `true`, the text that doesn't fit within [`Self::max_width`] /// will be elided and replaced with [`Self::overflow_character`]. + /// You can detect this by checking [`Galley::elided`]. /// /// Default: `false`. pub elide_at_max_width: bool, - /// Maximum amount of rows the text should have. + /// Maximum amount of rows the text galley should have. /// - /// If the full text requires more rows than this, - /// the last rows will be clipped, and [`Self::overflow_character`] appended. + /// If this limit is reach, text will be elided 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 shown. /// From 6b491731f95773cb570c308063661fcd588668a2 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Sun, 13 Aug 2023 10:23:15 +0200 Subject: [PATCH 05/15] Simplify --- .../src/demo/misc_demo_window.rs | 28 ++--- crates/epaint/src/text/text_layout.rs | 102 +++++++----------- crates/epaint/src/text/text_layout_types.rs | 49 ++++++--- 3 files changed, 80 insertions(+), 99 deletions(-) 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 0e959056850..78be9b7fd26 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 @@ -628,7 +627,6 @@ fn text_layout_demo(ui: &mut Ui) { #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "serde", serde(default))] struct TextBreakDemo { - elide_at_max_width: bool, break_anywhere: bool, max_rows: usize, overflow_character: Option, @@ -637,9 +635,8 @@ struct TextBreakDemo { impl Default for TextBreakDemo { fn default() -> Self { Self { - elide_at_max_width: true, - max_rows: 2, - break_anywhere: false, + max_rows: 1, + break_anywhere: true, overflow_character: Some('…'), } } @@ -648,7 +645,6 @@ impl Default for TextBreakDemo { impl TextBreakDemo { pub fn ui(&mut self, ui: &mut Ui) { let Self { - elide_at_max_width, break_anywhere, max_rows, overflow_character, @@ -657,17 +653,15 @@ impl TextBreakDemo { use egui::text::LayoutJob; ui.horizontal(|ui| { - ui.radio_value(elide_at_max_width, false, "Wrap"); - ui.radio_value(elide_at_max_width, true, "Clip"); + ui.add(DragValue::new(max_rows)); + ui.label("Max rows"); }); - if !*elide_at_max_width { - ui.horizontal(|ui| { - ui.add(DragValue::new(max_rows)); - ui.label("Max rows"); - }); - ui.checkbox(break_anywhere, "Break anywhere"); - } + ui.horizontal(|ui| { + ui.label("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"); @@ -677,10 +671,10 @@ impl TextBreakDemo { 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 { max_rows: *max_rows, - elide_at_max_width: *elide_at_max_width, break_anywhere: *break_anywhere, overflow_character: *overflow_character, ..Default::default() diff --git a/crates/epaint/src/text/text_layout.rs b/crates/epaint/src/text/text_layout.rs index d253122d1e9..9fe0fae3408 100644 --- a/crates/epaint/src/text/text_layout.rs +++ b/crates/epaint/src/text/text_layout.rs @@ -225,78 +225,48 @@ fn line_break( } if potential_row_width > job.wrap.max_width { - if job.wrap.elide_at_max_width { - assert_eq!(row_start_x, 0.0); - assert_eq!(row_start_idx, 0); - assert_eq!(non_empty_rows, 0); + // Row break: - let glyphs: Vec = paragraph.glyphs[..i].to_vec(); - - let x_range = if let (Some(first), Some(last)) = (glyphs.first(), glyphs.last()) { - first.pos.x..=last.max_x() - } else { - 0.0..=0.0 - }; - - let mut row = Row { + if first_row_indentation > 0.0 + && !row_break_candidates.has_good_candidate(job.wrap.break_anywhere) + { + // 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. + out_rows.push(Row { + glyphs: vec![], + visuals: Default::default(), + rect: rect_from_x_range(first_row_indentation..=first_row_indentation), + ends_with_newline: false, + }); + row_start_x += first_row_indentation; + first_row_indentation = 0.0; + } else if let Some(last_kept_index) = row_break_candidates.get(job.wrap.break_anywhere) + { + let glyphs: Vec = paragraph.glyphs[row_start_idx..=last_kept_index] + .iter() + .copied() + .map(|mut glyph| { + glyph.pos.x -= row_start_x; + glyph + }) + .collect(); + + let paragraph_min_x = glyphs[0].pos.x; + let paragraph_max_x = glyphs.last().unwrap().max_x(); + + out_rows.push(Row { glyphs, visuals: Default::default(), - rect: rect_from_x_range(x_range), + rect: rect_from_x_range(paragraph_min_x..=paragraph_max_x), ends_with_newline: false, - }; - - // Add the trailing overflow character (e.g. `…`): - replace_last_glyph_with_overflow_character(fonts, job, &mut row); - *elided = true; - - out_rows.push(row); + }); - return; + 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 { - // Row break: - - if first_row_indentation > 0.0 - && !row_break_candidates.has_good_candidate(job.wrap.break_anywhere) - { - // 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. - out_rows.push(Row { - glyphs: vec![], - visuals: Default::default(), - rect: rect_from_x_range(first_row_indentation..=first_row_indentation), - ends_with_newline: false, - }); - row_start_x += first_row_indentation; - first_row_indentation = 0.0; - } else if let Some(last_kept_index) = - row_break_candidates.get(job.wrap.break_anywhere) - { - let glyphs: Vec = paragraph.glyphs[row_start_idx..=last_kept_index] - .iter() - .copied() - .map(|mut glyph| { - glyph.pos.x -= row_start_x; - glyph - }) - .collect(); - - let paragraph_min_x = glyphs[0].pos.x; - let paragraph_max_x = glyphs.last().unwrap().max_x(); - - out_rows.push(Row { - glyphs, - visuals: Default::default(), - rect: rect_from_x_range(paragraph_min_x..=paragraph_max_x), - ends_with_newline: false, - }); - - 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. - } + // Found no place to break, so we have to overrun wrap_width. } } diff --git a/crates/epaint/src/text/text_layout_types.rs b/crates/epaint/src/text/text_layout_types.rs index 96b29475f69..5a71f124914 100644 --- a/crates/epaint/src/text/text_layout_types.rs +++ b/crates/epaint/src/text/text_layout_types.rs @@ -270,9 +270,10 @@ impl TextFormat { #[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct TextWrapping { - /// Try to wrap or clip text so that no row is wider than this. + /// Wrap text so that no row is wider than this. /// - /// Whether the text is wrapped or clipped depends on [`Self::elide_at_max_width`]. + /// If you would rather elide text that is too wide, + /// set [`Self::max_rows`] to `1`. /// /// Set to [`f32::INFINITY`] to turn off wrapping/clipping. /// @@ -280,26 +281,26 @@ pub struct TextWrapping { /// if [`LayoutJob::break_on_newline`] is `true`. pub max_width: f32, - /// If `true`, the text that doesn't fit within [`Self::max_width`] - /// will be elided and replaced with [`Self::overflow_character`]. - /// You can detect this by checking [`Galley::elided`]. - /// - /// Default: `false`. - pub elide_at_max_width: bool, - /// Maximum amount of rows the text galley should have. /// - /// If this limit is reach, text will be elided and + /// If this limit is reached, text will be elided 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 shown. + /// If set to `0`, no text will be outputted. + /// + /// If set to `1`, a single row will be outputted, + /// eliding the text at [`Self::max_width`]. + /// + /// Wether or not a word can be cut in half is decided by [`Self::break_anywhere`]. /// /// Default value: `usize::MAX`. pub max_rows: usize, /// If `true`: Allow breaking between any characters. /// If `false` (default): prefer breaking between words, etc. + /// + /// This also aplies to elision: when `true`, a word may be cut in half. pub break_anywhere: bool, /// Character to use to represent clipped text, `…` for example, which is the default. @@ -313,13 +314,11 @@ impl std::hash::Hash for TextWrapping { fn hash(&self, state: &mut H) { let Self { max_width, - elide_at_max_width, max_rows, break_anywhere, overflow_character, } = self; crate::f32_hash(state, *max_width); - elide_at_max_width.hash(state); max_rows.hash(state); break_anywhere.hash(state); overflow_character.hash(state); @@ -330,7 +329,6 @@ impl Default for TextWrapping { fn default() -> Self { Self { max_width: f32::INFINITY, - elide_at_max_width: false, max_rows: usize::MAX, break_anywhere: false, overflow_character: Some('…'), @@ -338,6 +336,26 @@ impl Default for TextWrapping { } } +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 ellide_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. @@ -367,8 +385,7 @@ pub struct Galley { /// can be split up into multiple rows. pub rows: Vec, - /// Set to true if some text was elided due to either - /// [`TextWrapping::elide_at_max_width`] or [`TextWrapping::max_rows`]. + /// Set to true if some text was elided due to [`TextWrapping::max_rows`]. pub elided: bool, /// Bounding rect. From 3af3b40b471c3c2c428784c1eff8be465be543c1 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Sun, 13 Aug 2023 14:43:35 +0200 Subject: [PATCH 06/15] Fix max_rows with multiple paragraphs --- crates/epaint/src/text/text_layout.rs | 17 ++++++++++++----- crates/epaint/src/text/text_layout_types.rs | 21 +++++++++++++-------- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/crates/epaint/src/text/text_layout.rs b/crates/epaint/src/text/text_layout.rs index 9fe0fae3408..5aab349e2cd 100644 --- a/crates/epaint/src/text/text_layout.rs +++ b/crates/epaint/src/text/text_layout.rs @@ -169,6 +169,11 @@ fn rows_from_paragraphs( 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() { @@ -215,16 +220,16 @@ 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 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 @@ -261,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. } @@ -274,7 +279,9 @@ fn line_break( } if row_start_idx < paragraph.glyphs.len() { - if 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; diff --git a/crates/epaint/src/text/text_layout_types.rs b/crates/epaint/src/text/text_layout_types.rs index 5a71f124914..ebba34a4a8d 100644 --- a/crates/epaint/src/text/text_layout_types.rs +++ b/crates/epaint/src/text/text_layout_types.rs @@ -49,7 +49,7 @@ pub struct LayoutJob { /// The different section, which can have different fonts, colors, etc. pub sections: Vec, - /// Controls the text wrapping. + /// Controls the text wrapping and elision. pub wrap: TextWrapping, /// The first row must be at least this high. @@ -59,8 +59,12 @@ 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, @@ -154,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 { @@ -267,17 +271,18 @@ 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 { /// Wrap text so that no row is wider than this. /// - /// If you would rather elide text that is too wide, + /// If you would rather elide text that doesn't fit, /// set [`Self::max_rows`] to `1`. /// - /// Set to [`f32::INFINITY`] to turn off wrapping/clipping. + /// Set `max_width` to [`f32::INFINITY`] to turn off wrapping/clipping. /// - /// Note that `\n` always produces a new line + /// Note that `\n` always produces a new row /// if [`LayoutJob::break_on_newline`] is `true`. pub max_width: f32, @@ -290,7 +295,7 @@ pub struct TextWrapping { /// If set to `0`, no text will be outputted. /// /// If set to `1`, a single row will be outputted, - /// eliding the text at [`Self::max_width`]. + /// eliding the text after [`Self::max_width`] is reached. /// /// Wether or not a word can be cut in half is decided by [`Self::break_anywhere`]. /// @@ -303,7 +308,7 @@ pub struct TextWrapping { /// This also aplies to elision: when `true`, a word may be cut in half. pub break_anywhere: bool, - /// Character to use to represent clipped text, `…` for example, which is the default. + /// Character to use to represent elided text, `…` for example, which is the default. /// /// If not set, no character will be used (but the text will still be clipped). pub overflow_character: Option, From a9cde693668c5128aecfad91bb64423e5fbb4d66 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Sun, 13 Aug 2023 14:49:35 +0200 Subject: [PATCH 07/15] Add note --- crates/egui_demo_lib/src/demo/misc_demo_window.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 78be9b7fd26..ae99abcc44f 100644 --- a/crates/egui_demo_lib/src/demo/misc_demo_window.rs +++ b/crates/egui_demo_lib/src/demo/misc_demo_window.rs @@ -679,6 +679,7 @@ impl TextBreakDemo { overflow_character: *overflow_character, ..Default::default() }; - ui.label(job); + + ui.label(job); // `Label` overrides some of the wrapping settings, e.g. wrap width } } From 016e65cab54714cbbf9495634d070f767e8451aa Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Sun, 13 Aug 2023 14:53:48 +0200 Subject: [PATCH 08/15] Typos --- crates/epaint/src/text/text_layout_types.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/epaint/src/text/text_layout_types.rs b/crates/epaint/src/text/text_layout_types.rs index ebba34a4a8d..2e8b7262a0b 100644 --- a/crates/epaint/src/text/text_layout_types.rs +++ b/crates/epaint/src/text/text_layout_types.rs @@ -297,7 +297,7 @@ pub struct TextWrapping { /// If set to `1`, a single row will be outputted, /// eliding the text after [`Self::max_width`] is reached. /// - /// Wether or not a word can be cut in half is decided by [`Self::break_anywhere`]. + /// Whether or not a word can be cut in half is decided by [`Self::break_anywhere`]. /// /// Default value: `usize::MAX`. pub max_rows: usize, @@ -305,7 +305,7 @@ pub struct TextWrapping { /// If `true`: Allow breaking between any characters. /// If `false` (default): prefer breaking between words, etc. /// - /// This also aplies to elision: when `true`, a word may be cut in half. + /// This also applies to elision: when `true`, a word may be cut in half. pub break_anywhere: bool, /// Character to use to represent elided text, `…` for example, which is the default. @@ -351,7 +351,7 @@ impl TextWrapping { } /// Elide text that doesn't fit within the given width. - pub fn ellide_at_width(max_width: f32) -> Self { + pub fn elide_at_width(max_width: f32) -> Self { Self { max_width, max_rows: 1, From 8e0118ba043eb333fb15e3b5a9751ef1d32c5c3f Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Sun, 13 Aug 2023 20:10:04 +0200 Subject: [PATCH 09/15] fix doclink --- crates/epaint/src/text/text_layout_types.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/epaint/src/text/text_layout_types.rs b/crates/epaint/src/text/text_layout_types.rs index 2e8b7262a0b..9e21e4f482c 100644 --- a/crates/epaint/src/text/text_layout_types.rs +++ b/crates/epaint/src/text/text_layout_types.rs @@ -384,7 +384,7 @@ pub struct Galley { /// Rows of text, from top to bottom. /// /// The number of characters in all rows sum up to `job.text.chars().count()` - /// unless [`self::elided`] is `true`. + /// unless [`Self::elided`] is `true`. /// /// Note that a paragraph (a piece of text separated with `\n`) /// can be split up into multiple rows. From df94e073aed74d96445af3b819bf326e240d2e4f Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Mon, 14 Aug 2023 10:21:35 +0200 Subject: [PATCH 10/15] Add `Label::elide` --- crates/egui/src/widgets/label.rs | 33 +++++++++++++++++++-- crates/epaint/src/text/text_layout_types.rs | 11 +++---- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/crates/egui/src/widgets/label.rs b/crates/egui/src/widgets/label.rs index 3e387fca806..9e7a12eae4c 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, + elide: bool, sense: Option, } @@ -24,6 +28,7 @@ impl Label { Self { text: text.into(), wrap: None, + elide: 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::elide`]. + /// /// 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,21 @@ impl Label { #[inline] pub fn wrap(mut self, wrap: bool) -> Self { self.wrap = Some(wrap); + self.elide = 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 `…`. + /// + /// Default is `false`, which means the text will expand the parent [`Ui`], + /// or wrap if [`Self::wrap`] is set. + /// + /// Calling `elide` will override [`Self::wrap`]. + #[inline] + pub fn elide(mut self, elide: bool) -> Self { + self.wrap = None; + self.elide = elide; self } @@ -98,10 +120,11 @@ impl Label { .text .into_text_job(ui.style(), FontSelection::Default, valign); - let should_wrap = self.wrap.unwrap_or_else(|| ui.wrap_text()); + let elide = self.elide; + let wrap = !elide && 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 +161,11 @@ impl Label { } (pos, text_galley, response) } else { - if should_wrap { + if elide { + 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; diff --git a/crates/epaint/src/text/text_layout_types.rs b/crates/epaint/src/text/text_layout_types.rs index 9e21e4f482c..78ec26b98f7 100644 --- a/crates/epaint/src/text/text_layout_types.rs +++ b/crates/epaint/src/text/text_layout_types.rs @@ -277,10 +277,9 @@ impl TextFormat { pub struct TextWrapping { /// Wrap text so that no row is wider than this. /// - /// If you would rather elide text that doesn't fit, - /// set [`Self::max_rows`] to `1`. + /// If you would rather elide text that doesn't fit, set [`Self::max_rows`] to `1`. /// - /// Set `max_width` to [`f32::INFINITY`] to turn off wrapping/clipping. + /// 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`. @@ -308,9 +307,11 @@ pub struct TextWrapping { /// This also applies to elision: when `true`, a word may be cut in half. pub break_anywhere: bool, - /// Character to use to represent elided text, `…` for example, which is the default. + /// Character to use to represent elided text. /// - /// If not set, no character will be used (but the text will still be clipped). + /// The default is `…`. + /// + /// If not set, no character will be used (but the text will still be elided). pub overflow_character: Option, } From b3a1f1a7d3db5483d9e286676848729b0060eddf Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Mon, 14 Aug 2023 10:24:14 +0200 Subject: [PATCH 11/15] Label: show full non-elided text on hover --- crates/egui/src/widget_text.rs | 2 +- crates/egui/src/widgets/label.rs | 6 +++++- crates/epaint/src/text/text_layout_types.rs | 1 + 3 files changed, 7 insertions(+), 2 deletions(-) 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 9e7a12eae4c..bd4a568bceb 100644 --- a/crates/egui/src/widgets/label.rs +++ b/crates/egui/src/widgets/label.rs @@ -194,9 +194,13 @@ 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 { + 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/epaint/src/text/text_layout_types.rs b/crates/epaint/src/text/text_layout_types.rs index 78ec26b98f7..bd414f8ea44 100644 --- a/crates/epaint/src/text/text_layout_types.rs +++ b/crates/epaint/src/text/text_layout_types.rs @@ -561,6 +561,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 From 54d4c0aa163aedd4eda9016e5d336e5f973a3a70 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Mon, 14 Aug 2023 10:30:11 +0200 Subject: [PATCH 12/15] Add demo of `Label::elide` --- crates/egui/src/widgets/label.rs | 1 + .../src/demo/misc_demo_window.rs | 67 ++++++++++++------- 2 files changed, 45 insertions(+), 23 deletions(-) diff --git a/crates/egui/src/widgets/label.rs b/crates/egui/src/widgets/label.rs index bd4a568bceb..658ef75eed1 100644 --- a/crates/egui/src/widgets/label.rs +++ b/crates/egui/src/widgets/label.rs @@ -198,6 +198,7 @@ impl Widget for Label { 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()); } 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 ae99abcc44f..34ace282a34 100644 --- a/crates/egui_demo_lib/src/demo/misc_demo_window.rs +++ b/crates/egui_demo_lib/src/demo/misc_demo_window.rs @@ -56,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); }); @@ -172,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.", + ) + .elide(true), + ); +} + +// ---------------------------------------------------------------------------- + #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "serde", serde(default))] pub struct Widgets { @@ -195,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| { From cfa5edda02fd31013d950ea36ec10fa4b227668f Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Mon, 14 Aug 2023 10:58:09 +0200 Subject: [PATCH 13/15] Call it `Label::truncate` --- crates/egui/src/widgets/label.rs | 22 ++++++++++--------- .../src/demo/misc_demo_window.rs | 2 +- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/crates/egui/src/widgets/label.rs b/crates/egui/src/widgets/label.rs index 658ef75eed1..288fcb422e8 100644 --- a/crates/egui/src/widgets/label.rs +++ b/crates/egui/src/widgets/label.rs @@ -19,7 +19,7 @@ use crate::{widget_text::WidgetTextGalley, *}; pub struct Label { text: WidgetText, wrap: Option, - elide: bool, + truncate: bool, sense: Option, } @@ -28,7 +28,7 @@ impl Label { Self { text: text.into(), wrap: None, - elide: false, + truncate: false, sense: None, } } @@ -39,7 +39,7 @@ impl Label { /// If `true`, the text will wrap to stay within the max width of the [`Ui`]. /// - /// Calling `wrap` will override [`Self::elide`]. + /// Calling `wrap` will override [`Self::truncate`]. /// /// By default [`Self::wrap`] will be `true` in vertical layouts /// and horizontal layouts with wrapping, @@ -51,21 +51,23 @@ impl Label { #[inline] pub fn wrap(mut self, wrap: bool) -> Self { self.wrap = Some(wrap); - self.elide = false; + 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 `elide` will override [`Self::wrap`]. + /// Calling `truncate` will override [`Self::wrap`]. #[inline] - pub fn elide(mut self, elide: bool) -> Self { + pub fn truncate(mut self, truncate: bool) -> Self { self.wrap = None; - self.elide = elide; + self.truncate = truncate; self } @@ -120,8 +122,8 @@ impl Label { .text .into_text_job(ui.style(), FontSelection::Default, valign); - let elide = self.elide; - let wrap = !elide && 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 wrap @@ -161,7 +163,7 @@ impl Label { } (pos, text_galley, response) } else { - if elide { + if truncate { text_job.job.wrap.max_width = available_width; text_job.job.wrap.max_rows = 1; text_job.job.wrap.break_anywhere = true; 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 34ace282a34..6faaf24b7ee 100644 --- a/crates/egui_demo_lib/src/demo/misc_demo_window.rs +++ b/crates/egui_demo_lib/src/demo/misc_demo_window.rs @@ -209,7 +209,7 @@ fn label_ui(ui: &mut egui::Ui) { 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.", ) - .elide(true), + .truncate(true), ); } From a6fd25ee4511854bb1f706ac6ff14b6ea73ac2bb Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Mon, 14 Aug 2023 11:07:43 +0200 Subject: [PATCH 14/15] Clarify limitations of `break_anywhere` --- crates/egui_demo_lib/src/demo/misc_demo_window.rs | 2 +- crates/epaint/src/text/text_layout_types.rs | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) 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 6faaf24b7ee..b48b85c2ac3 100644 --- a/crates/egui_demo_lib/src/demo/misc_demo_window.rs +++ b/crates/egui_demo_lib/src/demo/misc_demo_window.rs @@ -679,7 +679,7 @@ impl TextBreakDemo { }); ui.horizontal(|ui| { - ui.label("Break:"); + ui.label("Line-break:"); ui.radio_value(break_anywhere, false, "word boundaries"); ui.radio_value(break_anywhere, true, "anywhere"); }); diff --git a/crates/epaint/src/text/text_layout_types.rs b/crates/epaint/src/text/text_layout_types.rs index bd414f8ea44..8db66cc9b22 100644 --- a/crates/epaint/src/text/text_layout_types.rs +++ b/crates/epaint/src/text/text_layout_types.rs @@ -295,8 +295,7 @@ pub struct TextWrapping { /// /// If set to `1`, a single row will be outputted, /// eliding the text after [`Self::max_width`] is reached. - /// - /// Whether or not a word can be cut in half is decided by [`Self::break_anywhere`]. + /// 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, @@ -304,7 +303,11 @@ pub struct TextWrapping { /// If `true`: Allow breaking between any characters. /// If `false` (default): prefer breaking between words, etc. /// - /// This also applies to elision: when `true`, a word may be cut in half. + /// 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 elided text. From 737059aaed5cf71ac153309b2820e1b59e5d2856 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Mon, 14 Aug 2023 11:12:28 +0200 Subject: [PATCH 15/15] Better docstrings --- crates/epaint/src/text/text_layout_types.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/epaint/src/text/text_layout_types.rs b/crates/epaint/src/text/text_layout_types.rs index 8db66cc9b22..2a73bd60f8b 100644 --- a/crates/epaint/src/text/text_layout_types.rs +++ b/crates/epaint/src/text/text_layout_types.rs @@ -277,7 +277,7 @@ impl TextFormat { pub struct TextWrapping { /// Wrap text so that no row is wider than this. /// - /// If you would rather elide text that doesn't fit, set [`Self::max_rows`] to `1`. + /// 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. /// @@ -287,7 +287,7 @@ pub struct TextWrapping { /// Maximum amount of rows the text galley should have. /// - /// If this limit is reached, text will be elided and + /// 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`]. /// @@ -394,7 +394,7 @@ pub struct Galley { /// can be split up into multiple rows. pub rows: Vec, - /// Set to true if some text was elided due to [`TextWrapping::max_rows`]. + /// Set to true the text was truncated due to [`TextWrapping::max_rows`]. pub elided: bool, /// Bounding rect.