From acb5501fe4e46b811adeb38dfcca3814767da33c Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Tue, 7 Sep 2021 20:37:50 +0200 Subject: [PATCH] Add justified and/or center- and right-aligned text Label text will now be centered, right-aligned and/or justified based on the layout. Galleys are no longer always pivoted in the left top corner, so now have a Rect rather than just a size. --- CHANGELOG.md | 1 + egui/src/containers/collapsing_header.rs | 6 +- egui/src/containers/combo_box.rs | 6 +- egui/src/containers/window.rs | 10 +- egui/src/painter.rs | 4 +- egui/src/widgets/button.rs | 12 +- egui/src/widgets/label.rs | 28 +++- egui/src/widgets/plot/items.rs | 2 +- egui/src/widgets/plot/legend.rs | 8 +- egui/src/widgets/plot/mod.rs | 4 +- egui/src/widgets/selected_label.rs | 4 +- egui/src/widgets/text_edit.rs | 4 +- .../src/apps/demo/demo_app_windows.rs | 14 +- egui_demo_lib/src/apps/demo/layout_test.rs | 127 +++++++++++------- .../src/apps/demo/misc_demo_window.rs | 2 +- egui_demo_lib/src/apps/demo/tests.rs | 24 +++- .../src/apps/demo/window_with_panels.rs | 17 ++- egui_demo_lib/src/apps/http_app.rs | 2 +- epaint/src/shape.rs | 2 +- epaint/src/tessellator.rs | 2 +- epaint/src/text/fonts.rs | 5 + epaint/src/text/text_layout.rs | 113 +++++++++++++++- epaint/src/text/text_layout_types.rs | 31 ++++- 23 files changed, 316 insertions(+), 112 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f2117e8e..8af873ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ NOTE: [`eframe`](eframe/CHANGELOG.md), [`egui_web`](egui_web/CHANGELOG.md) and [ * `Fonts::layout_job*`: New text layout engine allowing mixing fonts, colors and styles, with underlining and strikethrough. ### Changed 🔧 +* Label text will now be centered, right-aligned and/or justified based on the layout. * `Hyperlink` will now word-wrap just like a `Label`. * All `Ui`:s must now have a finite `max_rect`. * Deprecated: `max_rect_finite`, `available_size_before_wrap_finite` and `available_rect_before_wrap_finite`. diff --git a/egui/src/containers/collapsing_header.rs b/egui/src/containers/collapsing_header.rs index 8681007f..117b7811 100644 --- a/egui/src/containers/collapsing_header.rs +++ b/egui/src/containers/collapsing_header.rs @@ -270,21 +270,21 @@ impl CollapsingHeader { let text_pos = available.min + vec2(ui.spacing().indent, 0.0); let galley = label.layout_width(ui, available.right() - text_pos.x, Color32::TEMPORARY_COLOR); - let text_max_x = text_pos.x + galley.size.x; + let text_max_x = text_pos.x + galley.size().x; let mut desired_width = text_max_x + button_padding.x - available.left(); if ui.visuals().collapsing_header_frame { desired_width = desired_width.max(available.width()); // fill full width } - let mut desired_size = vec2(desired_width, galley.size.y + 2.0 * button_padding.y); + let mut desired_size = vec2(desired_width, galley.size().y + 2.0 * button_padding.y); desired_size = desired_size.at_least(ui.spacing().interact_size); let (_, rect) = ui.allocate_space(desired_size); let mut header_response = ui.interact(rect, id, Sense::click()); let text_pos = pos2( text_pos.x, - header_response.rect.center().y - galley.size.y / 2.0, + header_response.rect.center().y - galley.size().y / 2.0, ); let mut state = State::from_memory_with_default_open(ui.ctx(), id, default_open); diff --git a/egui/src/containers/combo_box.rs b/egui/src/containers/combo_box.rs index 40480f5c..c088f251 100644 --- a/egui/src/containers/combo_box.rs +++ b/egui/src/containers/combo_box.rs @@ -162,9 +162,9 @@ fn combo_box( ui.fonts() .layout_delayed_color(selected.to_string(), TextStyle::Button, f32::INFINITY); - let width = galley.size.x + ui.spacing().item_spacing.x + icon_size.x; + let width = galley.size().x + ui.spacing().item_spacing.x + icon_size.x; let width = width.at_least(full_minimum_width); - let height = galley.size.y.max(icon_size.y); + let height = galley.size().y.max(icon_size.y); let (_, rect) = ui.allocate_space(Vec2::new(width, height)); let button_rect = ui.min_rect().expand2(ui.spacing().button_padding); @@ -179,7 +179,7 @@ fn combo_box( }; paint_icon(ui.painter(), icon_rect.expand(visuals.expansion), visuals); - let text_rect = Align2::LEFT_CENTER.align_size_within_rect(galley.size, rect); + let text_rect = Align2::LEFT_CENTER.align_size_within_rect(galley.size(), rect); ui.painter() .galley_with_color(text_rect.min, galley, visuals.text_color()); }); diff --git a/egui/src/containers/window.rs b/egui/src/containers/window.rs index 7ab69720..e9fa1e80 100644 --- a/egui/src/containers/window.rs +++ b/egui/src/containers/window.rs @@ -795,9 +795,9 @@ fn show_title_bar( let minimum_width = if collapsible || show_close_button { // If at least one button is shown we make room for both buttons (since title is centered): - 2.0 * (pad + button_size.x + item_spacing.x) + title_galley.size.x + 2.0 * (pad + button_size.x + item_spacing.x) + title_galley.size().x } else { - pad + title_galley.size.x + pad + pad + title_galley.size().x + pad }; let min_rect = Rect::from_min_size(ui.min_rect().min, vec2(minimum_width, height)); let id = ui.advance_cursor_after_rect(min_rect); @@ -846,8 +846,10 @@ impl TitleBar { self.title_label = self.title_label.text_color(style.fg_stroke.color); let full_top_rect = Rect::from_x_y_ranges(self.rect.x_range(), self.min_rect.y_range()); - let text_pos = emath::align::center_size_in_rect(self.title_galley.size, full_top_rect); - let text_pos = text_pos.left_top() - 1.5 * Vec2::Y; // HACK: center on x-height of text (looks better) + let text_pos = + emath::align::center_size_in_rect(self.title_galley.size(), full_top_rect).left_top(); + let text_pos = text_pos - self.title_galley.rect.min.to_vec2(); + let text_pos = text_pos - 1.5 * Vec2::Y; // HACK: center on x-height of text (looks better) let text_color = ui.visuals().text_color(); self.title_label .paint_galley(ui, text_pos, self.title_galley, false, text_color); diff --git a/egui/src/painter.rs b/egui/src/painter.rs index ad0a2117..c500fef2 100644 --- a/egui/src/painter.rs +++ b/egui/src/painter.rs @@ -221,7 +221,7 @@ impl Painter { text: impl ToString, ) -> Rect { let galley = self.layout_no_wrap(text.to_string(), TextStyle::Monospace, color); - let rect = anchor.anchor_rect(Rect::from_min_size(pos, galley.size)); + let rect = anchor.anchor_rect(Rect::from_min_size(pos, galley.size())); let frame_rect = rect.expand(2.0); self.add(Shape::Rect { rect: frame_rect, @@ -343,7 +343,7 @@ impl Painter { text_color: Color32, ) -> Rect { let galley = self.layout_no_wrap(text.to_string(), text_style, text_color); - let rect = anchor.anchor_rect(Rect::from_min_size(pos, galley.size)); + let rect = anchor.anchor_rect(Rect::from_min_size(pos, galley.size())); self.galley(rect.min, galley); rect } diff --git a/egui/src/widgets/button.rs b/egui/src/widgets/button.rs index 1fad074b..3d173b9e 100644 --- a/egui/src/widgets/button.rs +++ b/egui/src/widgets/button.rs @@ -164,7 +164,7 @@ impl Button { .fonts() .layout_delayed_color(text, text_style, wrap_width); - let mut desired_size = galley.size + 2.0 * button_padding; + let mut desired_size = galley.size() + 2.0 * button_padding; if !small { desired_size.y = desired_size.y.at_least(ui.spacing().interact_size.y); } @@ -177,7 +177,7 @@ impl Button { let visuals = ui.style().interact(&response); let text_pos = ui .layout() - .align_size_within_rect(galley.size, rect.shrink2(button_padding)) + .align_size_within_rect(galley.size(), rect.shrink2(button_padding)) .min; if frame { @@ -290,7 +290,7 @@ impl<'a> Widget for Checkbox<'a> { .fonts() .layout_delayed_color(text, text_style, wrap_width); - let mut desired_size = total_extra + galley.size; + let mut desired_size = total_extra + galley.size(); desired_size = desired_size.at_least(spacing.interact_size); desired_size.y = desired_size.y.max(icon_width); let (rect, mut response) = ui.allocate_exact_size(desired_size, Sense::click()); @@ -306,7 +306,7 @@ impl<'a> Widget for Checkbox<'a> { let visuals = ui.style().interact(&response); let text_pos = pos2( rect.min.x + button_padding.x + icon_width + icon_spacing, - rect.center().y - 0.5 * galley.size.y, + rect.center().y - 0.5 * galley.size().y, ); let (small_icon_rect, big_icon_rect) = ui.spacing().icon_rectangles(rect); ui.painter().add(Shape::Rect { @@ -414,7 +414,7 @@ impl Widget for RadioButton { .fonts() .layout_delayed_color(text, text_style, wrap_width); - let mut desired_size = total_extra + galley.size; + let mut desired_size = total_extra + galley.size(); desired_size = desired_size.at_least(ui.spacing().interact_size); desired_size.y = desired_size.y.max(icon_width); let (rect, response) = ui.allocate_exact_size(desired_size, Sense::click()); @@ -423,7 +423,7 @@ impl Widget for RadioButton { let text_pos = pos2( rect.min.x + button_padding.x + icon_width + icon_spacing, - rect.center().y - 0.5 * galley.size.y, + rect.center().y - 0.5 * galley.size().y, ); // let visuals = ui.style().interact_selectable(&response, checked); // too colorful diff --git a/egui/src/widgets/label.rs b/egui/src/widgets/label.rs index 82fcf1d9..69102b7a 100644 --- a/egui/src/widgets/label.rs +++ b/egui/src/widgets/label.rs @@ -171,9 +171,18 @@ impl Label { /// `line_color`: used for underline and strikethrough, if any. pub fn layout_width(&self, ui: &Ui, max_width: f32, line_color: Color32) -> Arc { - self.layout_impl(ui, 0.0, max_width, 0.0, line_color) + let (halign, justify) = if ui.is_grid() { + (Align::LEFT, false) // TODO: remove special Grid hacks like these + } else { + ( + ui.layout().horizontal_align(), + ui.layout().horizontal_justify(), + ) + }; + self.layout_impl(ui, 0.0, max_width, 0.0, line_color, halign, justify) } + #[allow(clippy::too_many_arguments)] fn layout_impl( &self, ui: &Ui, @@ -181,6 +190,8 @@ impl Label { max_width: f32, first_row_min_height: f32, line_color: Color32, + halign: Align, + justify: bool, ) -> Arc { let text_style = self.text_style_or_default(ui.style()); let wrap_width = if self.should_wrap(ui) { @@ -227,6 +238,8 @@ impl Label { }], wrap_width, first_row_min_height, + halign, + justify, ..Default::default() }; @@ -323,12 +336,16 @@ impl Label { let first_row_min_height = cursor.height(); let default_color = self.get_text_color(ui, ui.visuals().text_color()); + let halign = Align::Min; + let justify = false; let galley = self.layout_impl( ui, first_row_indentation, max_width, first_row_min_height, default_color, + halign, + justify, ); let pos = pos2(ui.max_rect().left(), ui.cursor().top()); @@ -343,8 +360,13 @@ impl Label { (pos, galley, response) } else { let galley = self.layout(ui); - let (rect, response) = ui.allocate_exact_size(galley.size, sense); - (rect.min, galley, response) + let (rect, response) = ui.allocate_exact_size(galley.size(), sense); + let pos = match galley.job.halign { + Align::LEFT => rect.left_top(), + Align::Center => rect.center_top(), + Align::RIGHT => rect.right_top(), + }; + (pos, galley, response) } } } diff --git a/egui/src/widgets/plot/items.rs b/egui/src/widgets/plot/items.rs index e3564e38..f7af7ca0 100644 --- a/egui/src/widgets/plot/items.rs +++ b/egui/src/widgets/plot/items.rs @@ -915,7 +915,7 @@ impl PlotItem for Text { .layout_no_wrap(self.text.clone(), self.style, color); let rect = self .anchor - .anchor_rect(Rect::from_min_size(pos, galley.size)); + .anchor_rect(Rect::from_min_size(pos, galley.size())); shapes.push(Shape::galley(rect.min, galley)); if self.highlight { shapes.push(Shape::rect_stroke( diff --git a/egui/src/widgets/plot/legend.rs b/egui/src/widgets/plot/legend.rs index 8b8e3316..239dd31f 100644 --- a/egui/src/widgets/plot/legend.rs +++ b/egui/src/widgets/plot/legend.rs @@ -94,11 +94,11 @@ impl LegendEntry { ui.fonts() .layout_delayed_color(text, ui.style().body_text_style, f32::INFINITY); - let icon_size = galley.size.y; + let icon_size = galley.size().y; let icon_spacing = icon_size / 5.0; let total_extra = vec2(icon_size + icon_spacing, 0.0); - let desired_size = total_extra + galley.size; + let desired_size = total_extra + galley.size(); let (rect, response) = ui.allocate_exact_size(desired_size, Sense::click()); response @@ -139,12 +139,12 @@ impl LegendEntry { } let text_position_x = if label_on_the_left { - rect.right() - icon_size - icon_spacing - galley.size.x + rect.right() - icon_size - icon_spacing - galley.size().x } else { rect.left() + icon_size + icon_spacing }; - let text_position = pos2(text_position_x, rect.center().y - 0.5 * galley.size.y); + let text_position = pos2(text_position_x, rect.center().y - 0.5 * galley.size().y); painter.galley_with_color(text_position, galley, visuals.text_color()); *checked ^= response.clicked_by(PointerButton::Primary); diff --git a/egui/src/widgets/plot/mod.rs b/egui/src/widgets/plot/mod.rs index 16dc2d38..637119d7 100644 --- a/egui/src/widgets/plot/mod.rs +++ b/egui/src/widgets/plot/mod.rs @@ -628,11 +628,11 @@ impl Prepared { let galley = ui.painter().layout_no_wrap(text, text_style, color); - let mut text_pos = pos_in_gui + vec2(1.0, -galley.size.y); + let mut text_pos = pos_in_gui + vec2(1.0, -galley.size().y); // Make sure we see the labels, even if the axis is off-screen: text_pos[1 - axis] = text_pos[1 - axis] - .at_most(transform.frame().max[1 - axis] - galley.size[1 - axis] - 2.0) + .at_most(transform.frame().max[1 - axis] - galley.size()[1 - axis] - 2.0) .at_least(transform.frame().min[1 - axis] + 1.0); shapes.push(Shape::galley(text_pos, galley)); diff --git a/egui/src/widgets/selected_label.rs b/egui/src/widgets/selected_label.rs index 8482cae2..2eef406a 100644 --- a/egui/src/widgets/selected_label.rs +++ b/egui/src/widgets/selected_label.rs @@ -69,7 +69,7 @@ impl Widget for SelectableLabel { .fonts() .layout_delayed_color(text, text_style, wrap_width); - let mut desired_size = total_extra + galley.size; + let mut desired_size = total_extra + galley.size(); desired_size.y = desired_size.y.at_least(ui.spacing().interact_size.y); let (rect, response) = ui.allocate_at_least(desired_size, Sense::click()); response.widget_info(|| { @@ -78,7 +78,7 @@ impl Widget for SelectableLabel { let text_pos = ui .layout() - .align_size_within_rect(galley.size, rect.shrink2(button_padding)) + .align_size_within_rect(galley.size(), rect.shrink2(button_padding)) .min; let visuals = ui.style().interact_selectable(&response, selected); diff --git a/egui/src/widgets/text_edit.rs b/egui/src/widgets/text_edit.rs index 393ea423..c1ee1f9f 100644 --- a/egui/src/widgets/text_edit.rs +++ b/egui/src/widgets/text_edit.rs @@ -521,7 +521,7 @@ impl<'t, S: TextBuffer> TextEdit<'t, S> { let mut galley = layouter(ui, text.as_ref(), wrap_width); let desired_height = (desired_height_rows.at_least(1) as f32) * row_height; - let desired_size = vec2(wrap_width, galley.size.y.max(desired_height)); + let desired_size = vec2(wrap_width, galley.size().y.max(desired_height)); let (auto_id, rect) = ui.allocate_space(desired_size); let id = id.unwrap_or_else(|| { @@ -804,7 +804,7 @@ impl<'t, S: TextBuffer> TextEdit<'t, S> { } offset_x = offset_x - .at_most(galley.size.x - desired_size.x) + .at_most(galley.size().x - desired_size.x) .at_least(0.0); state.singleline_offset = offset_x; diff --git a/egui_demo_lib/src/apps/demo/demo_app_windows.rs b/egui_demo_lib/src/apps/demo/demo_app_windows.rs index 5646aaf1..b5403d13 100644 --- a/egui_demo_lib/src/apps/demo/demo_app_windows.rs +++ b/egui_demo_lib/src/apps/demo/demo_app_windows.rs @@ -163,14 +163,14 @@ impl DemoWindows { ScrollArea::vertical().show(ui, |ui| { use egui::special_emojis::{GITHUB, OS_APPLE, OS_LINUX, OS_WINDOWS}; - ui.label("egui is an immediate mode GUI library written in Rust."); - - ui.label(format!( - "egui runs on the web, or natively on {}{}{}", - OS_APPLE, OS_LINUX, OS_WINDOWS, - )); - ui.vertical_centered(|ui| { + ui.label("egui is an immediate mode GUI library written in Rust."); + + ui.label(format!( + "egui runs on the web, or natively on {}{}{}", + OS_APPLE, OS_LINUX, OS_WINDOWS, + )); + ui.hyperlink_to( format!("{} egui home page", GITHUB), "https://github.com/emilk/egui", diff --git a/egui_demo_lib/src/apps/demo/layout_test.rs b/egui_demo_lib/src/apps/demo/layout_test.rs index e32972a7..e73044d5 100644 --- a/egui_demo_lib/src/apps/demo/layout_test.rs +++ b/egui_demo_lib/src/apps/demo/layout_test.rs @@ -4,10 +4,7 @@ use egui::*; #[cfg_attr(feature = "persistence", serde(default))] pub struct LayoutTest { // Identical to contents of `egui::Layout` - main_dir: Direction, - main_wrap: bool, - cross_align: Align, - cross_justify: bool, + layout: LayoutSettings, // Extra for testing wrapping: wrap_column_width: f32, @@ -16,15 +13,62 @@ pub struct LayoutTest { impl Default for LayoutTest { fn default() -> Self { + Self { + layout: LayoutSettings::top_down(), + wrap_column_width: 150.0, + wrap_row_height: 20.0, + } + } +} + +#[derive(Clone, Copy, PartialEq)] +#[cfg_attr(feature = "persistence", derive(serde::Deserialize, serde::Serialize))] +#[cfg_attr(feature = "persistence", serde(default))] +pub struct LayoutSettings { + // Similar to the contents of `egui::Layout` + main_dir: Direction, + main_wrap: bool, + cross_align: Align, + cross_justify: bool, +} + +impl Default for LayoutSettings { + fn default() -> Self { + Self::top_down() + } +} + +impl LayoutSettings { + fn top_down() -> Self { Self { main_dir: Direction::TopDown, main_wrap: false, cross_align: Align::Min, cross_justify: false, - wrap_column_width: 150.0, - wrap_row_height: 20.0, } } + fn top_down_justified_centered() -> Self { + Self { + main_dir: Direction::TopDown, + main_wrap: false, + cross_align: Align::Center, + cross_justify: true, + } + } + fn horizontal_wrapped() -> Self { + Self { + main_dir: Direction::LeftToRight, + main_wrap: true, + cross_align: Align::Center, + cross_justify: false, + } + } + + fn layout(&self) -> Layout { + Layout::from_main_dir_and_cross_align(self.main_dir, self.cross_align) + .with_main_wrap(self.main_wrap) + .with_cross_justify(self.cross_justify) + } } impl super::Demo for LayoutTest { @@ -48,22 +92,22 @@ impl super::View for LayoutTest { ui.label("Tests and demonstrates the limits of the egui layouts"); self.content_ui(ui); Resize::default() - .default_size([300.0, 200.0]) + .default_size([150.0, 200.0]) .show(ui, |ui| { - if self.main_wrap { - if self.main_dir.is_horizontal() { + if self.layout.main_wrap { + if self.layout.main_dir.is_horizontal() { ui.allocate_ui( vec2(ui.available_size_before_wrap().x, self.wrap_row_height), - |ui| ui.with_layout(self.layout(), demo_ui), + |ui| ui.with_layout(self.layout.layout(), demo_ui), ); } else { ui.allocate_ui( vec2(self.wrap_column_width, ui.available_size_before_wrap().y), - |ui| ui.with_layout(self.layout(), demo_ui), + |ui| ui.with_layout(self.layout.layout(), demo_ui), ); } } else { - ui.with_layout(self.layout(), demo_ui); + ui.with_layout(self.layout.layout(), demo_ui); } }); ui.label("Resize to see effect"); @@ -71,28 +115,19 @@ impl super::View for LayoutTest { } impl LayoutTest { - fn layout(&self) -> Layout { - Layout::from_main_dir_and_cross_align(self.main_dir, self.cross_align) - .with_main_wrap(self.main_wrap) - .with_cross_justify(self.cross_justify) - } - pub fn content_ui(&mut self, ui: &mut Ui) { ui.horizontal(|ui| { - if ui.button("Top-down").clicked() { - *self = Default::default(); - } - if ui.button("Top-down, centered and justified").clicked() { - *self = Default::default(); - self.cross_align = Align::Center; - self.cross_justify = true; - } - if ui.button("Horizontal wrapped").clicked() { - *self = Default::default(); - self.main_dir = Direction::LeftToRight; - self.cross_align = Align::Center; - self.main_wrap = true; - } + ui.selectable_value(&mut self.layout, LayoutSettings::top_down(), "Top-down"); + ui.selectable_value( + &mut self.layout, + LayoutSettings::top_down_justified_centered(), + "Top-down, centered and justified", + ); + ui.selectable_value( + &mut self.layout, + LayoutSettings::horizontal_wrapped(), + "Horizontal wrapped", + ); }); ui.horizontal(|ui| { @@ -103,16 +138,16 @@ impl LayoutTest { Direction::TopDown, Direction::BottomUp, ] { - ui.radio_value(&mut self.main_dir, dir, format!("{:?}", dir)); + ui.radio_value(&mut self.layout.main_dir, dir, format!("{:?}", dir)); } }); ui.horizontal(|ui| { - ui.checkbox(&mut self.main_wrap, "Main wrap") + ui.checkbox(&mut self.layout.main_wrap, "Main wrap") .on_hover_text("Wrap when next widget doesn't fit the current row/column"); - if self.main_wrap { - if self.main_dir.is_horizontal() { + if self.layout.main_wrap { + if self.layout.main_dir.is_horizontal() { ui.add(Slider::new(&mut self.wrap_row_height, 0.0..=200.0).text("Row height")); } else { ui.add( @@ -125,25 +160,19 @@ impl LayoutTest { ui.horizontal(|ui| { ui.label("Cross Align:"); for &align in &[Align::Min, Align::Center, Align::Max] { - ui.radio_value(&mut self.cross_align, align, format!("{:?}", align)); + ui.radio_value(&mut self.layout.cross_align, align, format!("{:?}", align)); } }); - ui.checkbox(&mut self.cross_justify, "Cross Justified") + ui.checkbox(&mut self.layout.cross_justify, "Cross Justified") .on_hover_text("Try to fill full width/height (e.g. buttons)"); } } fn demo_ui(ui: &mut Ui) { - ui.monospace("Example widgets:"); - for _ in 0..3 { - ui.label("label"); - } - for _ in 0..3 { - let mut dummy = false; - ui.checkbox(&mut dummy, "checkbox"); - } - for _ in 0..3 { - let _ = ui.button("button"); - } + ui.add(egui::Label::new("Wrapping text followed by example widgets:").wrap(true)); + let mut dummy = false; + ui.checkbox(&mut dummy, "checkbox"); + ui.radio_value(&mut dummy, false, "radio"); + let _ = ui.button("button"); } 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 4a5aa4fe..eaac01e2 100644 --- a/egui_demo_lib/src/apps/demo/misc_demo_window.rs +++ b/egui_demo_lib/src/apps/demo/misc_demo_window.rs @@ -569,7 +569,7 @@ fn text_layout_ui(ui: &mut egui::Ui) { let galley = ui.fonts().layout_job(job); - let (response, painter) = ui.allocate_painter(galley.size, Sense::hover()); + let (response, painter) = ui.allocate_painter(galley.size(), Sense::hover()); painter.add(Shape::galley(response.rect.min, galley)); ui.vertical_centered(|ui| { diff --git a/egui_demo_lib/src/apps/demo/tests.rs b/egui_demo_lib/src/apps/demo/tests.rs index 4552c872..cacba26a 100644 --- a/egui_demo_lib/src/apps/demo/tests.rs +++ b/egui_demo_lib/src/apps/demo/tests.rs @@ -373,7 +373,7 @@ pub struct WindowResizeTest { impl Default for WindowResizeTest { fn default() -> Self { Self { - text: crate::LOREM_IPSUM.to_owned(), + text: crate::LOREM_IPSUM_LONG.to_owned(), } } } @@ -393,7 +393,7 @@ impl super::Demo for WindowResizeTest { ui.label("This window will auto-size based on its contents."); ui.heading("Resize this area:"); Resize::default().show(ui, |ui| { - ui.code(crate::LOREM_IPSUM); + lorem_ipsum(ui, crate::LOREM_IPSUM); }); ui.heading("Resize the above area!"); }); @@ -405,10 +405,10 @@ impl super::Demo for WindowResizeTest { .default_height(300.0) .show(ctx, |ui| { ui.label( - "This window is resizable and has a scroll area. You can shrink it to any size", + "This window is resizable and has a scroll area. You can shrink it to any size.", ); ui.separator(); - ui.code(crate::LOREM_IPSUM_LONG); + lorem_ipsum(ui, crate::LOREM_IPSUM_LONG); }); Window::new("↔ resizable + embedded scroll") @@ -421,8 +421,9 @@ impl super::Demo for WindowResizeTest { ui.label("However, we have a sub-region with a scroll bar:"); ui.separator(); ScrollArea::vertical().show(ui, |ui| { - ui.code(crate::LOREM_IPSUM_LONG); - ui.code(crate::LOREM_IPSUM_LONG); + let lorem_ipsum_extra_long = + format!("{}\n\n{}", crate::LOREM_IPSUM_LONG, crate::LOREM_IPSUM_LONG); + lorem_ipsum(ui, &lorem_ipsum_extra_long); }); // ui.heading("Some additional text here, that should also be visible"); // this works, but messes with the resizing a bit }); @@ -435,7 +436,7 @@ impl super::Demo for WindowResizeTest { ui.label("This window is resizable but has no scroll area. This means it can only be resized to a size where all the contents is visible."); ui.label("egui will not clip the contents of a window, nor add whitespace to it."); ui.separator(); - ui.code(crate::LOREM_IPSUM); + lorem_ipsum(ui, crate::LOREM_IPSUM); }); Window::new("↔ resizable with TextEdit") @@ -459,3 +460,12 @@ impl super::Demo for WindowResizeTest { }); } } + +fn lorem_ipsum(ui: &mut egui::Ui, text: &str) { + ui.with_layout( + egui::Layout::top_down(egui::Align::LEFT).with_cross_justify(true), + |ui| { + ui.add(egui::Label::new(text).weak()); + }, + ); +} diff --git a/egui_demo_lib/src/apps/demo/window_with_panels.rs b/egui_demo_lib/src/apps/demo/window_with_panels.rs index 397f042e..1653d32f 100644 --- a/egui_demo_lib/src/apps/demo/window_with_panels.rs +++ b/egui_demo_lib/src/apps/demo/window_with_panels.rs @@ -30,7 +30,7 @@ impl super::View for WindowWithPanels { ui.vertical_centered(|ui| { ui.heading("Expandable Upper Panel"); }); - ui.add(egui::Label::new(crate::LOREM_IPSUM_LONG).small().weak()); + lorem_ipsum(ui); }); }); @@ -43,7 +43,7 @@ impl super::View for WindowWithPanels { ui.heading("Left Panel"); }); egui::ScrollArea::vertical().show(ui, |ui| { - ui.add(egui::Label::new(crate::LOREM_IPSUM_LONG).small().weak()); + lorem_ipsum(ui); }); }); @@ -56,7 +56,7 @@ impl super::View for WindowWithPanels { ui.heading("Right Panel"); }); egui::ScrollArea::vertical().show(ui, |ui| { - ui.add(egui::Label::new(crate::LOREM_IPSUM_LONG).small().weak()); + lorem_ipsum(ui); }); }); @@ -74,8 +74,17 @@ impl super::View for WindowWithPanels { ui.heading("Central Panel"); }); egui::ScrollArea::vertical().show(ui, |ui| { - ui.add(egui::Label::new(crate::LOREM_IPSUM_LONG).small().weak()); + lorem_ipsum(ui); }); }); } } + +fn lorem_ipsum(ui: &mut egui::Ui) { + ui.with_layout( + egui::Layout::top_down(egui::Align::LEFT).with_cross_justify(true), + |ui| { + ui.add(egui::Label::new(crate::LOREM_IPSUM_LONG).small().weak()); + }, + ); +} diff --git a/egui_demo_lib/src/apps/http_app.rs b/egui_demo_lib/src/apps/http_app.rs index 4e66ad09..79701233 100644 --- a/egui_demo_lib/src/apps/http_app.rs +++ b/egui_demo_lib/src/apps/http_app.rs @@ -361,7 +361,7 @@ impl ColoredText { let mut job = self.0.clone(); job.wrap_width = ui.available_width(); let galley = ui.fonts().layout_job(job); - let (response, painter) = ui.allocate_painter(galley.size, egui::Sense::hover()); + let (response, painter) = ui.allocate_painter(galley.size(), egui::Sense::hover()); painter.add(egui::Shape::galley(response.rect.min, galley)); } } diff --git a/epaint/src/shape.rs b/epaint/src/shape.rs index b20ac7bd..168e133b 100644 --- a/epaint/src/shape.rs +++ b/epaint/src/shape.rs @@ -161,7 +161,7 @@ impl Shape { color: Color32, ) -> Self { let galley = fonts.layout_no_wrap(text.to_string(), text_style, color); - let rect = anchor.anchor_rect(Rect::from_min_size(pos, galley.size)); + let rect = anchor.anchor_rect(Rect::from_min_size(pos, galley.size())); Self::galley(rect.min, galley) } diff --git a/epaint/src/tessellator.rs b/epaint/src/tessellator.rs index 6caceafd..2a70bdac 100644 --- a/epaint/src/tessellator.rs +++ b/epaint/src/tessellator.rs @@ -621,7 +621,7 @@ impl Tessellator { if options.debug_paint_text_rects { self.tessellate_rect( &PaintRect { - rect: Rect::from_min_size(text_shape.pos, text_shape.galley.size) + rect: Rect::from_min_size(text_shape.pos, text_shape.galley.size()) .expand(0.5), corner_radius: 2.0, fill: Default::default(), diff --git a/epaint/src/text/fonts.rs b/epaint/src/text/fonts.rs index 6ae235f3..c3f0dfeb 100644 --- a/epaint/src/text/fonts.rs +++ b/epaint/src/text/fonts.rs @@ -292,6 +292,11 @@ impl Fonts { (point * self.pixels_per_point).round() / self.pixels_per_point } + #[inline(always)] + pub fn floor_to_pixel(&self, point: f32) -> f32 { + (point * self.pixels_per_point).floor() / self.pixels_per_point + } + /// Call each frame to get the latest available font texture data. pub fn texture(&self) -> Arc { let atlas = self.atlas.lock(); diff --git a/epaint/src/text/text_layout.rs b/epaint/src/text/text_layout.rs index c9732506..90b51259 100644 --- a/epaint/src/text/text_layout.rs +++ b/epaint/src/text/text_layout.rs @@ -25,7 +25,18 @@ pub fn layout(fonts: &Fonts, job: Arc) -> Galley { layout_section(fonts, &job, section_index as u32, section, &mut paragraphs); } - let rows = rows_from_paragraphs(paragraphs, job.wrap_width); + let mut rows = rows_from_paragraphs(paragraphs, job.wrap_width); + + let justify = job.justify && job.wrap_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(fonts, row, job.halign, job.wrap_width, justify_row) + } + } galley_from_rows(fonts, job, rows) } @@ -133,7 +144,7 @@ fn line_break(paragraph: &Paragraph, wrap_width: f32, out_rows: &mut Vec) { let mut row_start_idx = 0; for (i, glyph) in paragraph.glyphs.iter().enumerate() { - let potential_row_width = glyph.pos.x - row_start_x; + 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() { @@ -200,10 +211,101 @@ fn line_break(paragraph: &Paragraph, wrap_width: f32, out_rows: &mut Vec) { } } +fn halign_and_jusitfy_row( + fonts: &Fonts, + row: &mut Row, + halign: Align, + wrap_width: f32, + justify: bool, +) { + if row.glyphs.is_empty() { + return; + } + + let num_leading_spaces = row + .glyphs + .iter() + .take_while(|glyph| glyph.chr.is_whitespace()) + .count(); + + let glyph_range = if num_leading_spaces == row.glyphs.len() { + // There is only whitespace + (0, row.glyphs.len()) + } else { + let num_trailing_spaces = row + .glyphs + .iter() + .rev() + .take_while(|glyph| glyph.chr.is_whitespace()) + .count(); + + (num_leading_spaces, row.glyphs.len() - num_trailing_spaces) + }; + let num_glyphs_in_range = glyph_range.1 - glyph_range.0; + assert!(num_glyphs_in_range > 0); + + let original_min_x = row.glyphs[glyph_range.0].logical_rect().min.x; + let original_max_x = row.glyphs[glyph_range.1 - 1].logical_rect().max.x; + let original_width = original_max_x - original_min_x; + + let target_width = if justify && num_glyphs_in_range > 1 { + wrap_width + } else { + original_width + }; + + let (target_min_x, target_max_x) = match halign { + Align::LEFT => (0.0, target_width), + Align::Center => (-target_width / 2.0, target_width / 2.0), + Align::RIGHT => (-target_width, 0.0), + }; + + let num_spaces_in_range = row.glyphs[glyph_range.0..glyph_range.1] + .iter() + .filter(|glyph| glyph.chr.is_whitespace()) + .count(); + + let mut extra_x_per_glyph = if num_glyphs_in_range == 1 { + 0.0 + } else { + (target_width - original_width) / (num_glyphs_in_range as f32 - 1.0) + }; + extra_x_per_glyph = extra_x_per_glyph.at_least(0.0); // Don't contract + + let mut extra_x_per_space = 0.0; + if 0 < num_spaces_in_range && num_spaces_in_range < num_glyphs_in_range { + // Add an integral number of pixels between each glyph, + // and add the balance to the spaces: + + extra_x_per_glyph = fonts.floor_to_pixel(extra_x_per_glyph); + + extra_x_per_space = (target_width + - original_width + - extra_x_per_glyph * (num_glyphs_in_range as f32 - 1.0)) + / (num_spaces_in_range as f32); + } + + let mut translate_x = target_min_x - original_min_x - extra_x_per_glyph * glyph_range.0 as f32; + + for glyph in &mut row.glyphs { + glyph.pos.x += translate_x; + glyph.pos.x = fonts.round_to_pixel(glyph.pos.x); + translate_x += extra_x_per_glyph; + if glyph.chr.is_whitespace() { + translate_x += extra_x_per_space; + } + } + + // Note we ignore the leading/trailing whitespace here! + row.rect.min.x = target_min_x; + row.rect.max.x = target_max_x; +} + /// Calculate the Y positions and tessellate the text. fn galley_from_rows(fonts: &Fonts, job: Arc, mut rows: Vec) -> 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; let mut max_x: f32 = 0.0; for row in &mut rows { let mut row_height = first_row_min_height.max(row.rect.height()); @@ -223,7 +325,8 @@ fn galley_from_rows(fonts: &Fonts, job: Arc, mut rows: Vec) -> G row.rect.min.y = cursor_y; row.rect.max.y = cursor_y + row_height; - max_x = max_x.max(row.rect.right()); + min_x = min_x.min(row.rect.min.x); + max_x = max_x.max(row.rect.max.x); cursor_y += row_height; cursor_y = fonts.round_to_pixel(cursor_y); } @@ -239,12 +342,12 @@ fn galley_from_rows(fonts: &Fonts, job: Arc, mut rows: Vec) -> G num_indices += row.visuals.mesh.indices.len(); } - let size = vec2(max_x, cursor_y); + let rect = Rect::from_min_max(pos2(min_x, 0.0), pos2(max_x, cursor_y)); Galley { job, rows, - size, + rect, num_vertices, num_indices, } diff --git a/epaint/src/text/text_layout_types.rs b/epaint/src/text/text_layout_types.rs index ded3d2c4..2d18d9ae 100644 --- a/epaint/src/text/text_layout_types.rs +++ b/epaint/src/text/text_layout_types.rs @@ -36,7 +36,12 @@ pub struct LayoutJob { /// and show up as the replacement character. /// Default: `true`. pub break_on_newline: bool, - // TODO: option to show whitespace characters + + /// 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 [`Self::wrap_width`] + pub justify: bool, } impl Default for LayoutJob { @@ -48,6 +53,8 @@ impl Default for LayoutJob { wrap_width: f32::INFINITY, first_row_min_height: 0.0, break_on_newline: true, + halign: Align::LEFT, + justify: false, } } } @@ -112,6 +119,8 @@ impl std::hash::Hash for LayoutJob { wrap_width, first_row_min_height, break_on_newline, + halign, + justify, } = self; text.hash(state); @@ -119,6 +128,8 @@ impl std::hash::Hash for LayoutJob { crate::f32_hash(state, *wrap_width); crate::f32_hash(state, *first_row_min_height); break_on_newline.hash(state); + halign.hash(state); + justify.hash(state); } } @@ -199,16 +210,24 @@ pub struct Galley { pub job: Arc, /// Rows of text, from top to bottom. - /// The number of chars in all rows sum up to text.chars().count(). + /// The number of characters in all rows sum up to `job.text.chars().count()`. /// Note that each paragraph (pieces of text separated with `\n`) /// can be split up into multiple rows. pub rows: Vec, - /// Bounding size (min is always `[0,0]`) - pub size: Vec2, + /// Bounding rect. + /// + /// `rect.top()` is always 0.0. + /// + /// With [`LayoutJob::halign`]: + /// * [`Align::LEFT`]: rect.left() == 0.0 + /// * [`Align::Center`]: rect.center() == 0.0 + /// * [`Align::RIGHT`]: rect.right() == 0.0 + pub rect: Rect, /// Total number of vertices in all the row meshes. pub num_vertices: usize, + /// Total number of indices in all the row meshes. pub num_indices: usize, } @@ -346,6 +365,10 @@ impl Galley { pub fn text(&self) -> &str { &self.job.text } + + pub fn size(&self) -> Vec2 { + self.rect.size() + } } // ----------------------------------------------------------------------------