diff --git a/egui/src/widget_text.rs b/egui/src/widget_text.rs index 0376ab24..1fc9ebdf 100644 --- a/egui/src/widget_text.rs +++ b/egui/src/widget_text.rs @@ -563,14 +563,14 @@ impl WidgetText { Self::RichText(text) => { let valign = ui.layout().vertical_align(); let mut text_job = text.into_text_job(ui.style(), fallback_font.into(), valign); - text_job.job.wrap_width = wrap_width; + text_job.job.wrap.max_width = wrap_width; WidgetTextGalley { galley: ui.fonts().layout_job(text_job.job), galley_has_color: text_job.job_has_color, } } Self::LayoutJob(mut job) => { - job.wrap_width = wrap_width; + job.wrap.max_width = wrap_width; WidgetTextGalley { galley: ui.fonts().layout_job(job), galley_has_color: true, diff --git a/egui/src/widgets/label.rs b/egui/src/widgets/label.rs index 0f757203..36254146 100644 --- a/egui/src/widgets/label.rs +++ b/egui/src/widgets/label.rs @@ -103,7 +103,7 @@ impl Label { let first_row_indentation = available_width - ui.available_size_before_wrap().x; egui_assert!(first_row_indentation.is_finite()); - text_job.job.wrap_width = available_width; + text_job.job.wrap.max_width = available_width; text_job.job.first_row_min_height = cursor.height(); text_job.job.halign = Align::Min; text_job.job.justify = false; @@ -129,9 +129,9 @@ impl Label { (pos, text_galley, response) } else { if should_wrap { - text_job.job.wrap_width = available_width; + text_job.job.wrap.max_width = available_width; } else { - text_job.job.wrap_width = f32::INFINITY; + text_job.job.wrap.max_width = f32::INFINITY; }; if ui.is_grid() { diff --git a/egui/src/widgets/text_edit/builder.rs b/egui/src/widgets/text_edit/builder.rs index fb037de1..30285146 100644 --- a/egui/src/widgets/text_edit/builder.rs +++ b/egui/src/widgets/text_edit/builder.rs @@ -182,7 +182,7 @@ impl<'t> TextEdit<'t> { /// # fn my_memoized_highlighter(s: &str) -> egui::text::LayoutJob { Default::default() } /// let mut layouter = |ui: &egui::Ui, string: &str, wrap_width: f32| { /// let mut layout_job: egui::text::LayoutJob = my_memoized_highlighter(string); - /// layout_job.wrap_width = wrap_width; + /// layout_job.wrap.max_width = wrap_width; /// ui.fonts().layout_job(layout_job) /// }; /// ui.add(egui::TextEdit::multiline(&mut my_code).layouter(&mut layouter)); diff --git a/egui_demo_lib/src/apps/demo/code_editor.rs b/egui_demo_lib/src/apps/demo/code_editor.rs index 107d66b8..5c75baf3 100644 --- a/egui_demo_lib/src/apps/demo/code_editor.rs +++ b/egui_demo_lib/src/apps/demo/code_editor.rs @@ -78,7 +78,7 @@ impl super::View for CodeEditor { let mut layouter = |ui: &egui::Ui, string: &str, wrap_width: f32| { let mut layout_job = crate::syntax_highlighting::highlight(ui.ctx(), &theme, string, language); - layout_job.wrap_width = wrap_width; + layout_job.wrap.max_width = wrap_width; ui.fonts().layout_job(layout_job) }; diff --git a/egui_demo_lib/src/apps/demo/misc_demo_window.rs b/egui_demo_lib/src/apps/demo/misc_demo_window.rs index f4e672ed..949dedc2 100644 --- a/egui_demo_lib/src/apps/demo/misc_demo_window.rs +++ b/egui_demo_lib/src/apps/demo/misc_demo_window.rs @@ -1,5 +1,6 @@ use super::*; -use egui::{color::*, *}; +use crate::LOREM_IPSUM; +use egui::{color::*, epaint::text::TextWrapping, *}; /// Showcase some ui code #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] @@ -7,6 +8,10 @@ use egui::{color::*, *}; pub struct MiscDemoWindow { num_columns: usize, + break_anywhere: bool, + max_rows: usize, + overflow_character: Option, + widgets: Widgets, colors: ColorWidgets, tree: Tree, @@ -18,6 +23,10 @@ impl Default for MiscDemoWindow { MiscDemoWindow { num_columns: 2, + max_rows: 2, + break_anywhere: false, + overflow_character: Some('…'), + widgets: Default::default(), colors: Default::default(), tree: Tree::demo(), @@ -53,7 +62,12 @@ impl View for MiscDemoWindow { CollapsingHeader::new("Text layout") .default_open(false) .show(ui, |ui| { - text_layout_ui(ui); + text_layout_ui( + ui, + &mut self.max_rows, + &mut self.break_anywhere, + &mut self.overflow_character, + ); }); CollapsingHeader::new("Colors") @@ -401,7 +415,12 @@ impl SubTree { // ---------------------------------------------------------------------------- -fn text_layout_ui(ui: &mut egui::Ui) { +fn text_layout_ui( + ui: &mut egui::Ui, + max_rows: &mut usize, + break_anywhere: &mut bool, + overflow_character: &mut Option, +) { use egui::text::LayoutJob; let mut job = LayoutJob::default(); @@ -556,6 +575,30 @@ fn text_layout_ui(ui: &mut egui::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_string(), TextFormat::default()); + job.wrap = TextWrapping { + max_rows: *max_rows, + break_anywhere: *break_anywhere, + overflow_character: *overflow_character, + ..Default::default() + }; + ui.label(job); + ui.vertical_centered(|ui| { ui.add(crate::__egui_github_link_file_line!()); }); diff --git a/egui_demo_lib/src/apps/http_app.rs b/egui_demo_lib/src/apps/http_app.rs index db81a517..bd16b8a9 100644 --- a/egui_demo_lib/src/apps/http_app.rs +++ b/egui_demo_lib/src/apps/http_app.rs @@ -244,7 +244,7 @@ impl ColoredText { // Selectable text: let mut layouter = |ui: &egui::Ui, _string: &str, wrap_width: f32| { let mut layout_job = self.0.clone(); - layout_job.wrap_width = wrap_width; + layout_job.wrap.max_width = wrap_width; ui.fonts().layout_job(layout_job) }; @@ -257,7 +257,7 @@ impl ColoredText { ); } else { let mut job = self.0.clone(); - job.wrap_width = ui.available_width(); + job.wrap.max_width = ui.available_width(); let galley = ui.fonts().layout_job(job); let (response, painter) = ui.allocate_painter(galley.size(), egui::Sense::hover()); painter.add(egui::Shape::galley(response.rect.min, galley)); diff --git a/egui_demo_lib/src/easy_mark/easy_mark_editor.rs b/egui_demo_lib/src/easy_mark/easy_mark_editor.rs index c9374cfe..262f1eb0 100644 --- a/egui_demo_lib/src/easy_mark/easy_mark_editor.rs +++ b/egui_demo_lib/src/easy_mark/easy_mark_editor.rs @@ -85,7 +85,7 @@ impl EasyMarkEditor { let response = if self.highlight_editor { let mut layouter = |ui: &egui::Ui, easymark: &str, wrap_width: f32| { let mut layout_job = highlighter.highlight(ui.style(), easymark); - layout_job.wrap_width = wrap_width; + layout_job.wrap.max_width = wrap_width; ui.fonts().layout_job(layout_job) }; diff --git a/egui_demo_lib/src/syntax_highlighting.rs b/egui_demo_lib/src/syntax_highlighting.rs index e1aca332..2bf78167 100644 --- a/egui_demo_lib/src/syntax_highlighting.rs +++ b/egui_demo_lib/src/syntax_highlighting.rs @@ -7,7 +7,7 @@ pub fn code_view_ui(ui: &mut egui::Ui, mut code: &str) { let mut layouter = |ui: &egui::Ui, string: &str, _wrap_width: f32| { let layout_job = highlight(ui.ctx(), &theme, string, language); - // layout_job.wrap_width = wrap_width; // no wrapping + // layout_job.wrap.max_width = wrap_width; // no wrapping ui.fonts().layout_job(layout_job) }; diff --git a/epaint/CHANGELOG.md b/epaint/CHANGELOG.md index 231bccd5..56fd4b7b 100644 --- a/epaint/CHANGELOG.md +++ b/epaint/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to the epaint crate will be documented in this file. ## Unreleased * Add `Shape::Callback` for backend-specific painting ([#1351](https://github.com/emilk/egui/pull/1351)). +* Added more text wrapping options ([#1291](https://github.com/emilk/egui/pull/1291)): + * Added `TextWrapping` struct containing all wrapping options. + * Added `LayoutJob::wrap` field containing these options. + * Moved `LayoutJob::wrap_width` to `TextWrapping::max_width`. + * Added `TextWrapping::max_rows` to limit amount of rows the text should have. + * Added `TextWrapping::break_anywhere` to control should the text break at appropriate places or not. + * Added `TextWrapping::overflow_character` to specify what character should be used to represent clipped text. * Removed the `single_threaded/multi_threaded` flags - epaint is now always thread-safe ([#1390](https://github.com/emilk/egui/pull/1390)). * `Tessellator::from_options` is now `Tessellator::new` ([#1408](https://github.com/emilk/egui/pull/1408)). * Renamed `TessellationOptions::anti_alias` to `feathering` ([#1408](https://github.com/emilk/egui/pull/1408)). diff --git a/epaint/src/text/text_layout.rs b/epaint/src/text/text_layout.rs index bac97e17..9294d1af 100644 --- a/epaint/src/text/text_layout.rs +++ b/epaint/src/text/text_layout.rs @@ -58,16 +58,22 @@ pub fn layout(fonts: &mut FontsImpl, job: Arc) -> Galley { let point_scale = PointScale::new(fonts.pixels_per_point()); - let mut rows = rows_from_paragraphs(paragraphs, job.wrap_width); + let mut rows = rows_from_paragraphs(fonts, paragraphs, &job); - let justify = job.justify && job.wrap_width.is_finite(); + let justify = job.justify && job.wrap.max_width.is_finite(); if justify || job.halign != Align::LEFT { let num_rows = rows.len(); for (i, row) in rows.iter_mut().enumerate() { let is_last_row = i + 1 == num_rows; let justify_row = justify && !row.ends_with_newline && !is_last_row; - halign_and_jusitfy_row(point_scale, row, job.halign, job.wrap_width, justify_row); + halign_and_jusitfy_row( + point_scale, + row, + job.halign, + job.wrap.max_width, + justify_row, + ); } } @@ -131,7 +137,11 @@ fn rect_from_x_range(x_range: RangeInclusive) -> Rect { Rect::from_x_y_ranges(x_range, 0.0..=0.0) } -fn rows_from_paragraphs(paragraphs: Vec, wrap_width: f32) -> Vec { +fn rows_from_paragraphs( + fonts: &mut FontsImpl, + paragraphs: Vec, + job: &LayoutJob, +) -> Vec { let num_paragraphs = paragraphs.len(); let mut rows = vec![]; @@ -151,7 +161,7 @@ fn rows_from_paragraphs(paragraphs: Vec, wrap_width: f32) -> Vec }); } else { let paragraph_max_x = paragraph.glyphs.last().unwrap().max_x(); - if paragraph_max_x <= wrap_width { + if paragraph_max_x <= job.wrap.max_width { // early-out optimization let paragraph_min_x = paragraph.glyphs[0].pos.x; rows.push(Row { @@ -161,7 +171,7 @@ fn rows_from_paragraphs(paragraphs: Vec, wrap_width: f32) -> Vec ends_with_newline: !is_last_paragraph, }); } else { - line_break(¶graph, wrap_width, &mut rows); + line_break(fonts, ¶graph, job, &mut rows); rows.last_mut().unwrap().ends_with_newline = !is_last_paragraph; } } @@ -170,19 +180,31 @@ fn rows_from_paragraphs(paragraphs: Vec, wrap_width: f32) -> Vec rows } -fn line_break(paragraph: &Paragraph, wrap_width: f32, out_rows: &mut Vec) { +fn line_break( + fonts: &mut FontsImpl, + paragraph: &Paragraph, + job: &LayoutJob, + out_rows: &mut Vec, +) { // Keeps track of good places to insert row break if we exceed `wrap_width`. let mut row_break_candidates = RowBreakCandidates::default(); 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, glyph) in paragraph.glyphs.iter().enumerate() { let potential_row_width = glyph.max_x() - row_start_x; - if potential_row_width > wrap_width { - if first_row_indentation > 0.0 && !row_break_candidates.has_word_boundary() { + 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: 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 { @@ -193,7 +215,8 @@ fn line_break(paragraph: &Paragraph, wrap_width: f32, out_rows: &mut Vec) { }); row_start_x += first_row_indentation; first_row_indentation = 0.0; - } else if let Some(last_kept_index) = row_break_candidates.get() { + } 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() @@ -216,6 +239,7 @@ fn line_break(paragraph: &Paragraph, wrap_width: f32, out_rows: &mut Vec) { 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. } @@ -225,24 +249,90 @@ fn line_break(paragraph: &Paragraph, wrap_width: f32, out_rows: &mut Vec) { } if row_start_idx < paragraph.glyphs.len() { - let glyphs: Vec = paragraph.glyphs[row_start_idx..] - .iter() - .copied() - .map(|mut glyph| { - glyph.pos.x -= row_start_x; - glyph - }) - .collect(); + if non_empty_rows == job.wrap.max_rows { + replace_last_glyph_with_overflow_character(fonts, job, out_rows); + } else { + let glyphs: Vec = paragraph.glyphs[row_start_idx..] + .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(); + 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, + out_rows.push(Row { + glyphs, + visuals: Default::default(), + rect: rect_from_x_range(paragraph_min_x..=paragraph_max_x), + ends_with_newline: false, + }); + } + } +} + +fn replace_last_glyph_with_overflow_character( + fonts: &mut FontsImpl, + job: &LayoutJob, + out_rows: &mut Vec, +) { + let overflow_character = match job.wrap.overflow_character { + Some(c) => c, + None => return, + }; + + let row = match out_rows.last_mut() { + Some(r) => r, + None => return, + }; + + loop { + let (prev_glyph, last_glyph) = match row.glyphs.as_mut_slice() { + [.., prev, last] => (Some(prev), last), + [.., last] => (None, last), + _ => break, + }; + + let section = &job.sections[last_glyph.section_index as usize]; + let font = fonts.font(§ion.format.font_id); + let font_height = font.row_height(); + + let prev_glyph_id = prev_glyph.map(|prev_glyph| { + let (_, prev_glyph_info) = font.glyph_info_and_font_impl(prev_glyph.chr); + prev_glyph_info.id }); + + // undo kerning with previous glyph + let (font_impl, glyph_info) = font.glyph_info_and_font_impl(last_glyph.chr); + last_glyph.pos.x -= font_impl + .zip(prev_glyph_id) + .map(|(font_impl, prev_glyph_id)| font_impl.pair_kerning(prev_glyph_id, glyph_info.id)) + .unwrap_or_default(); + + // replace the glyph + last_glyph.chr = 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; + + // reapply kerning + last_glyph.pos.x += font_impl + .zip(prev_glyph_id) + .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 + 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; + } + + row.glyphs.pop(); } } @@ -651,21 +741,32 @@ impl RowBreakCandidates { self.dash = Some(index); } else if chr.is_ascii_punctuation() { self.punctuation = Some(index); - } else { - self.any = Some(index); } + self.any = Some(index); } fn has_word_boundary(&self) -> bool { self.space.is_some() || self.logogram.is_some() } - fn get(&self) -> Option { - self.space - .or(self.logogram) - .or(self.dash) - .or(self.punctuation) - .or(self.any) + fn has_good_candidate(&self, break_anywhere: bool) -> bool { + if break_anywhere { + self.any.is_some() + } else { + self.has_word_boundary() + } + } + + fn get(&self, break_anywhere: bool) -> Option { + if break_anywhere { + self.any + } else { + self.space + .or(self.logogram) + .or(self.dash) + .or(self.punctuation) + .or(self.any) + } } } diff --git a/epaint/src/text/text_layout_types.rs b/epaint/src/text/text_layout_types.rs index b9a4395d..866aeb16 100644 --- a/epaint/src/text/text_layout_types.rs +++ b/epaint/src/text/text_layout_types.rs @@ -48,10 +48,7 @@ pub struct LayoutJob { /// The different section, which can have different fonts, colors, etc. pub sections: Vec, - /// 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. - pub wrap_width: f32, + pub wrap: TextWrapping, /// The first row must be at least this high. /// This is in case we lay out text that is the continuation @@ -78,7 +75,7 @@ impl Default for LayoutJob { Self { text: Default::default(), sections: Default::default(), - wrap_width: f32::INFINITY, + wrap: Default::default(), first_row_min_height: 0.0, break_on_newline: true, halign: Align::LEFT, @@ -98,7 +95,10 @@ impl LayoutJob { format: TextFormat::simple(font_id, color), }], text, - wrap_width, + wrap: TextWrapping { + max_width: wrap_width, + ..Default::default() + }, break_on_newline: true, ..Default::default() } @@ -114,7 +114,7 @@ impl LayoutJob { format: TextFormat::simple(font_id, color), }], text, - wrap_width: f32::INFINITY, + wrap: Default::default(), break_on_newline: false, ..Default::default() } @@ -129,7 +129,7 @@ impl LayoutJob { format, }], text, - wrap_width: f32::INFINITY, + wrap: Default::default(), break_on_newline: true, ..Default::default() } @@ -168,7 +168,7 @@ impl std::hash::Hash for LayoutJob { let Self { text, sections, - wrap_width, + wrap, first_row_min_height, break_on_newline, halign, @@ -177,7 +177,7 @@ impl std::hash::Hash for LayoutJob { text.hash(state); sections.hash(state); - crate::f32_hash(state, *wrap_width); + wrap.hash(state); crate::f32_hash(state, *first_row_min_height); break_on_newline.hash(state); halign.hash(state); @@ -257,6 +257,54 @@ 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. + pub max_width: f32, + + /// Maximum amount of rows the text should have. + /// Set to `0` to disable this. + pub max_rows: usize, + + /// Don't try to break text at an appropriate place. + pub break_anywhere: bool, + + /// Character to use to represent clipped text, `…` for example, which is the default. + pub overflow_character: Option, +} + +impl std::hash::Hash for TextWrapping { + #[inline] + fn hash(&self, state: &mut H) { + let Self { + max_width, + max_rows, + break_anywhere, + overflow_character, + } = self; + crate::f32_hash(state, *max_width); + max_rows.hash(state); + break_anywhere.hash(state); + overflow_character.hash(state); + } +} + +impl Default for TextWrapping { + fn default() -> Self { + Self { + max_width: f32::INFINITY, + max_rows: 0, + break_anywhere: false, + overflow_character: Some('…'), + } + } +} + +// ---------------------------------------------------------------------------- + /// Text that has been layed out, ready for painting. /// /// You can create a [`Galley`] using [`crate::Fonts::layout_job`];