diff --git a/CHANGELOG.md b/CHANGELOG.md index bb6c7c41..9dab0a26 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,10 +9,17 @@ NOTE: [`eframe`](eframe/CHANGELOG.md), [`egui_web`](egui_web/CHANGELOG.md) and [ ### Added ⭐ * Add horizontal scrolling support to `ScrollArea` and `Window` (opt-in). +* `TextEdit::layouter`: Add custom text layout for e.g. syntax highlighting or WYSIWYG. +* `Fonts::layout_job*`: New text layout engine allowing mixing fonts, colors and styles, with underlining and strikethrough. ### Changed 🔧 +* `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`. +* `Painter`/`Fonts`: text layout now expect color when creating a `Galley`. You may override that color with `Painter::galley_with_color`. + +### Fixed 🐛 +* Fix wrongly sized multiline `TextEdit` in justified layouts. ## 0.14.2 - 2021-08-28 - Window resize fix diff --git a/Cargo.lock b/Cargo.lock index 2c3a27ef..a60eace9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -885,7 +885,6 @@ dependencies = [ "atomic_refcell", "cint", "emath", - "ordered-float", "parking_lot", "serde", ] @@ -1631,15 +1630,6 @@ version = "11.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ab1bc2a289d34bd04a330323ac98a1b4bc82c9d9fcb1e66b63caa84da26b575" -[[package]] -name = "ordered-float" -version = "2.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "039f02eb0f69271f26abe3202189275d7aa2258b903cb0281b5de710a2570ff3" -dependencies = [ - "num-traits", -] - [[package]] name = "osmesa-sys" version = "0.1.2" diff --git a/README.md b/README.md index 3b1ee563..7a0b1321 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ ui.label(format!("Hello '{}', age {}", name, age)); * Extensible: [easy to write your own widgets for egui](https://github.com/emilk/egui/blob/master/egui_demo_lib/src/apps/demo/toggle_switch.rs) * Modular: You should be able to use small parts of egui and combine them in new ways * Safe: there is no `unsafe` code in egui -* Minimal dependencies: [`ab_glyph`](https://crates.io/crates/ab_glyph) [`ahash`](https://crates.io/crates/ahash) [`atomic_refcell`](https://crates.io/crates/atomic_refcell) [`ordered-float`](https://crates.io/crates/ordered-float). +* Minimal dependencies: [`ab_glyph`](https://crates.io/crates/ab_glyph) [`ahash`](https://crates.io/crates/ahash) [`atomic_refcell`](https://crates.io/crates/atomic_refcell) egui is *not* a framework. egui is a library you call into, not an environment you program for. diff --git a/egui/src/containers/collapsing_header.rs b/egui/src/containers/collapsing_header.rs index 582356d9..8681007f 100644 --- a/egui/src/containers/collapsing_header.rs +++ b/egui/src/containers/collapsing_header.rs @@ -268,7 +268,8 @@ impl CollapsingHeader { let available = ui.available_rect_before_wrap(); let text_pos = available.min + vec2(ui.spacing().indent, 0.0); - let galley = label.layout_width(ui, available.right() - text_pos.x); + 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 mut desired_width = text_max_x + button_padding.x - available.left(); @@ -292,7 +293,7 @@ impl CollapsingHeader { header_response.mark_changed(); } header_response - .widget_info(|| WidgetInfo::labeled(WidgetType::CollapsingHeader, &galley.text)); + .widget_info(|| WidgetInfo::labeled(WidgetType::CollapsingHeader, galley.text())); let visuals = ui .style() @@ -337,7 +338,7 @@ impl CollapsingHeader { paint_icon(ui, openness, &icon_response); } - ui.painter().galley(text_pos, galley, text_color); + ui.painter().galley_with_color(text_pos, galley, text_color); Prepared { id, diff --git a/egui/src/containers/combo_box.rs b/egui/src/containers/combo_box.rs index 868e341e..40480f5c 100644 --- a/egui/src/containers/combo_box.rs +++ b/egui/src/containers/combo_box.rs @@ -158,9 +158,9 @@ fn combo_box( let full_minimum_width = ui.spacing().slider_width; let icon_size = Vec2::splat(ui.spacing().icon_width); - let galley = ui - .fonts() - .layout_no_wrap(TextStyle::Button, selected.to_string()); + let galley = + 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 = width.at_least(full_minimum_width); @@ -181,7 +181,7 @@ fn combo_box( let text_rect = Align2::LEFT_CENTER.align_size_within_rect(galley.size, rect); ui.painter() - .galley(text_rect.min, galley, visuals.text_color()); + .galley_with_color(text_rect.min, galley, visuals.text_color()); }); if button_response.clicked() { diff --git a/egui/src/containers/window.rs b/egui/src/containers/window.rs index 374a07ca..7ab69720 100644 --- a/egui/src/containers/window.rs +++ b/egui/src/containers/window.rs @@ -848,8 +848,9 @@ impl TitleBar { 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_color = ui.visuals().text_color(); self.title_label - .paint_galley(ui, text_pos, self.title_galley); + .paint_galley(ui, text_pos, self.title_galley, false, text_color); if let Some(content_response) = &content_response { // paint separator between title and content: diff --git a/egui/src/lib.rs b/egui/src/lib.rs index 2ba1d4dc..8451d12b 100644 --- a/egui/src/lib.rs +++ b/egui/src/lib.rs @@ -377,6 +377,10 @@ pub use { widgets::*, }; +pub mod text { + pub use epaint::text::{Galley, LayoutJob, LayoutSection, TextFormat, TAB_SIZE}; +} + // ---------------------------------------------------------------------------- /// Helper function that adds a label when compiling with debug assertions enabled. diff --git a/egui/src/painter.rs b/egui/src/painter.rs index f62e652c..9c638eb2 100644 --- a/egui/src/painter.rs +++ b/egui/src/painter.rs @@ -219,9 +219,7 @@ impl Painter { color: Color32, text: impl ToString, ) -> Rect { - let galley = self - .fonts() - .layout_no_wrap(TextStyle::Monospace, text.to_string()); + 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 frame_rect = rect.expand(2.0); self.add(Shape::Rect { @@ -231,7 +229,7 @@ impl Painter { // stroke: Stroke::new(1.0, color), stroke: Default::default(), }); - self.galley(rect.min, galley, color); + self.galley(rect.min, galley); frame_rect } } @@ -331,7 +329,7 @@ impl Painter { /// To center the text at the given position, use `anchor: (Center, Center)`. /// /// To find out the size of text before painting it, use - /// [`Self::layout_no_wrap`] or [`Self::layout_multiline`]. + /// [`Self::layout`] or [`Self::layout_no_wrap`]. /// /// Returns where the text ended up. #[allow(clippy::needless_pass_by_value)] @@ -343,57 +341,69 @@ impl Painter { text_style: TextStyle, text_color: Color32, ) -> Rect { - let galley = self.layout_no_wrap(text_style, text.to_string()); + 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)); - self.galley(rect.min, galley, text_color); + self.galley(rect.min, galley); rect } - /// Will line break at `\n`. - /// - /// Paint the results with [`Self::galley`]. - /// Always returns at least one row. - #[inline(always)] - pub fn layout_no_wrap(&self, text_style: TextStyle, text: String) -> std::sync::Arc { - self.layout_multiline(text_style, text, f32::INFINITY) - } - /// Will wrap text at the given width and line break at `\n`. /// /// Paint the results with [`Self::galley`]. - /// Always returns at least one row. #[inline(always)] - pub fn layout_multiline( + pub fn layout( &self, - text_style: TextStyle, text: String, - max_width_in_points: f32, + text_style: TextStyle, + color: crate::Color32, + wrap_width: f32, ) -> std::sync::Arc { - self.fonts() - .layout_multiline(text_style, text, max_width_in_points) + self.fonts().layout(text, text_style, color, wrap_width) + } + + /// Will line break at `\n`. + /// + /// Paint the results with [`Self::galley`]. + #[inline(always)] + pub fn layout_no_wrap( + &self, + text: String, + text_style: TextStyle, + color: crate::Color32, + ) -> std::sync::Arc { + self.fonts().layout(text, text_style, color, f32::INFINITY) } /// Paint text that has already been layed out in a [`Galley`]. /// - /// You can create the `Galley` with [`Self::layout_no_wrap`] or [`Self::layout_multiline`]. + /// You can create the `Galley` with [`Self::layout`]. + /// + /// If you want to change the color of the text, use [`Self::galley_with_color`]. #[inline(always)] - pub fn galley(&self, pos: Pos2, galley: std::sync::Arc, color: Color32) { - self.galley_with_italics(pos, galley, color, false) + pub fn galley(&self, pos: Pos2, galley: std::sync::Arc) { + if !galley.is_empty() { + self.add(Shape::galley(pos, galley)); + } } - pub fn galley_with_italics( + /// Paint text that has already been layed out in a [`Galley`]. + /// + /// You can create the `Galley` with [`Self::layout`]. + /// + /// The text color in the [`Galley`] will be replaced with the given color. + #[inline(always)] + pub fn galley_with_color( &self, pos: Pos2, galley: std::sync::Arc, - color: Color32, - fake_italics: bool, + text_color: Color32, ) { if !galley.is_empty() { self.add(Shape::Text { pos, galley, - color, - fake_italics, + underline: Stroke::none(), + override_text_color: Some(text_color), }); } } diff --git a/egui/src/style.rs b/egui/src/style.rs index 95936b86..f35687d4 100644 --- a/egui/src/style.rs +++ b/egui/src/style.rs @@ -233,6 +233,7 @@ pub struct Visuals { } impl Visuals { + #[inline(always)] pub fn noninteractive(&self) -> &WidgetVisuals { &self.widgets.noninteractive } @@ -246,14 +247,17 @@ impl Visuals { crate::color::tint_color_towards(self.text_color(), self.window_fill()) } + #[inline(always)] pub fn strong_text_color(&self) -> Color32 { self.widgets.active.text_color() } + #[inline(always)] pub fn window_fill(&self) -> Color32 { self.widgets.noninteractive.bg_fill } + #[inline(always)] pub fn window_stroke(&self) -> Stroke { self.widgets.noninteractive.bg_stroke } @@ -325,6 +329,7 @@ pub struct WidgetVisuals { } impl WidgetVisuals { + #[inline(always)] pub fn text_color(&self) -> Color32 { self.fg_stroke.color } diff --git a/egui/src/widgets/button.rs b/egui/src/widgets/button.rs index 93a4aa3c..1fad074b 100644 --- a/egui/src/widgets/button.rs +++ b/egui/src/widgets/button.rs @@ -1,5 +1,14 @@ use crate::*; +/// For those of us who miss `a ? yes : no`. +fn select(b: bool, if_true: T, if_false: T) -> T { + if b { + if_true + } else { + if_false + } +} + /// Clickable button with text. /// /// See also [`Ui::button`]. @@ -150,12 +159,10 @@ impl Button { let total_extra = button_padding + button_padding; let wrap = wrap.unwrap_or_else(|| ui.wrap_text()); - let galley = if wrap { - ui.fonts() - .layout_multiline(text_style, text, ui.available_width() - total_extra.x) - } else { - ui.fonts().layout_no_wrap(text_style, text) - }; + let wrap_width = select(wrap, ui.available_width() - total_extra.x, f32::INFINITY); + let galley = ui + .fonts() + .layout_delayed_color(text, text_style, wrap_width); let mut desired_size = galley.size + 2.0 * button_padding; if !small { @@ -164,7 +171,7 @@ impl Button { desired_size = desired_size.at_least(min_size); let (rect, response) = ui.allocate_at_least(desired_size, sense); - response.widget_info(|| WidgetInfo::labeled(WidgetType::Button, &galley.text)); + response.widget_info(|| WidgetInfo::labeled(WidgetType::Button, galley.text())); if ui.clip_rect().intersects(rect) { let visuals = ui.style().interact(&response); @@ -187,7 +194,7 @@ impl Button { let text_color = text_color .or(ui.visuals().override_text_color) .unwrap_or_else(|| visuals.text_color()); - ui.painter().galley(text_pos, galley, text_color); + ui.painter().galley_with_color(text_pos, galley, text_color); } response @@ -274,12 +281,14 @@ impl<'a> Widget for Checkbox<'a> { let button_padding = spacing.button_padding; let total_extra = button_padding + vec2(icon_width + icon_spacing, 0.0) + button_padding; - let galley = if ui.wrap_text() { - ui.fonts() - .layout_multiline(text_style, text, ui.available_width() - total_extra.x) - } else { - ui.fonts().layout_no_wrap(text_style, text) - }; + let wrap_width = select( + ui.wrap_text(), + ui.available_width() - total_extra.x, + f32::INFINITY, + ); + let galley = ui + .fonts() + .layout_delayed_color(text, text_style, wrap_width); let mut desired_size = total_extra + galley.size; desired_size = desired_size.at_least(spacing.interact_size); @@ -290,7 +299,8 @@ impl<'a> Widget for Checkbox<'a> { *checked = !*checked; response.mark_changed(); } - response.widget_info(|| WidgetInfo::selected(WidgetType::Checkbox, *checked, &galley.text)); + response + .widget_info(|| WidgetInfo::selected(WidgetType::Checkbox, *checked, galley.text())); // let visuals = ui.style().interact_selectable(&response, *checked); // too colorful let visuals = ui.style().interact(&response); @@ -321,7 +331,7 @@ impl<'a> Widget for Checkbox<'a> { let text_color = text_color .or(ui.visuals().override_text_color) .unwrap_or_else(|| visuals.text_color()); - ui.painter().galley(text_pos, galley, text_color); + ui.painter().galley_with_color(text_pos, galley, text_color); response } } @@ -395,19 +405,21 @@ impl Widget for RadioButton { let button_padding = ui.spacing().button_padding; let total_extra = button_padding + vec2(icon_width + icon_spacing, 0.0) + button_padding; - let galley = if ui.wrap_text() { - ui.fonts() - .layout_multiline(text_style, text, ui.available_width() - total_extra.x) - } else { - ui.fonts().layout_no_wrap(text_style, text) - }; + let wrap_width = select( + ui.wrap_text(), + ui.available_width() - total_extra.x, + f32::INFINITY, + ); + let galley = ui + .fonts() + .layout_delayed_color(text, text_style, wrap_width); 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()); response - .widget_info(|| WidgetInfo::selected(WidgetType::RadioButton, checked, &galley.text)); + .widget_info(|| WidgetInfo::selected(WidgetType::RadioButton, checked, galley.text())); let text_pos = pos2( rect.min.x + button_padding.x + icon_width + icon_spacing, @@ -441,7 +453,7 @@ impl Widget for RadioButton { let text_color = text_color .or(ui.visuals().override_text_color) .unwrap_or_else(|| visuals.text_color()); - painter.galley(text_pos, galley, text_color); + painter.galley_with_color(text_pos, galley, text_color); response } } diff --git a/egui/src/widgets/hyperlink.rs b/egui/src/widgets/hyperlink.rs index 32b12609..0b45e5e7 100644 --- a/egui/src/widgets/hyperlink.rs +++ b/egui/src/widgets/hyperlink.rs @@ -21,7 +21,7 @@ impl Hyperlink { let url = url.to_string(); Self { url: url.clone(), - label: Label::new(url), + label: Label::new(url).sense(Sense::click()), } } @@ -54,9 +54,8 @@ impl Hyperlink { impl Widget for Hyperlink { fn ui(self, ui: &mut Ui) -> Response { let Hyperlink { url, label } = self; - let galley = label.layout(ui); - let (rect, response) = ui.allocate_exact_size(galley.size, Sense::click()); - response.widget_info(|| WidgetInfo::labeled(WidgetType::Hyperlink, &galley.text)); + let (pos, galley, response) = label.layout_in_ui(ui); + response.widget_info(|| WidgetInfo::labeled(WidgetType::Hyperlink, galley.text())); if response.hovered() { ui.ctx().output().cursor_icon = CursorIcon::PointingHand; @@ -78,19 +77,18 @@ impl Widget for Hyperlink { let color = ui.visuals().hyperlink_color; let visuals = ui.style().interact(&response); - if response.hovered() || response.has_focus() { - // Underline: - for row in &galley.rows { - let rect = row.rect().translate(rect.min.to_vec2()); - ui.painter().line_segment( - [rect.left_bottom(), rect.right_bottom()], - (visuals.fg_stroke.width, color), - ); - } - } + let underline = if response.hovered() || response.has_focus() { + Stroke::new(visuals.fg_stroke.width, color) + } else { + Stroke::none() + }; - let label = label.text_color(color); - label.paint_galley(ui, rect.min, galley); + ui.painter().add(Shape::Text { + pos, + galley, + override_text_color: Some(color), + underline, + }); response.on_hover_text(url) } diff --git a/egui/src/widgets/label.rs b/egui/src/widgets/label.rs index 59a9ae0e..28c5f16d 100644 --- a/egui/src/widgets/label.rs +++ b/egui/src/widgets/label.rs @@ -1,5 +1,8 @@ use crate::*; -use epaint::Galley; +use epaint::{ + text::{LayoutJob, LayoutSection, TextFormat}, + Galley, +}; use std::sync::Arc; /// Static text. @@ -162,20 +165,111 @@ impl Label { impl Label { pub fn layout(&self, ui: &Ui) -> Arc { let max_width = ui.available_width(); - self.layout_width(ui, max_width) + let line_color = self.get_text_color(ui, ui.visuals().text_color()); + self.layout_width(ui, max_width, line_color) } - pub fn layout_width(&self, ui: &Ui, max_width: f32) -> Arc { + /// `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) + } + + fn layout_impl( + &self, + ui: &Ui, + leading_space: f32, + max_width: f32, + first_row_min_height: f32, + line_color: Color32, + ) -> Arc { let text_style = self.text_style_or_default(ui.style()); let wrap_width = if self.should_wrap(ui) { max_width } else { f32::INFINITY }; - let galley = ui - .fonts() - .layout_multiline(text_style, self.text.clone(), wrap_width); // TODO: avoid clone - self.valign_galley(ui, text_style, galley) + + let mut background_color = self.background_color; + if self.code { + background_color = ui.visuals().code_bg_color; + } + let underline = if self.underline { + Stroke::new(1.0, line_color) + } else { + Stroke::none() + }; + let strikethrough = if self.strikethrough { + Stroke::new(1.0, line_color) + } else { + Stroke::none() + }; + + let valign = if self.raised { + Align::TOP + } else { + ui.layout().vertical_align() + }; + + let job = LayoutJob { + text: self.text.clone(), // TODO: avoid clone + sections: vec![LayoutSection { + leading_space, + byte_range: 0..self.text.len(), + format: TextFormat { + style: text_style, + color: Color32::TEMPORARY_COLOR, + background: background_color, + italics: self.italics, + underline, + strikethrough, + valign, + }, + }], + wrap_width, + first_row_min_height, + ..Default::default() + }; + + ui.fonts().layout_job(job) + } + + /// `has_focus`: the item is selected with the keyboard, so highlight with underline. + /// `response_color`: Unless we have a special color set, use this. + pub(crate) fn paint_galley( + &self, + ui: &mut Ui, + pos: Pos2, + galley: Arc, + has_focus: bool, + response_color: Color32, + ) { + let text_color = self.get_text_color(ui, response_color); + + let underline = if has_focus { + Stroke::new(1.0, text_color) + } else { + Stroke::none() + }; + + ui.painter().add(Shape::Text { + pos, + galley, + override_text_color: Some(text_color), + underline, + }); + } + + /// `response_color`: Unless we have a special color set, use this. + fn get_text_color(&self, ui: &Ui, response_color: Color32) -> Color32 { + if let Some(text_color) = self.text_color { + text_color + } else if self.strong { + ui.visuals().strong_text_color() + } else if self.weak { + ui.visuals().weak_text_color() + } else { + response_color + } } pub fn font_height(&self, fonts: &epaint::text::Fonts, style: &Style) -> f32 { @@ -191,79 +285,6 @@ impl Label { // TODO: a paint method for painting anywhere in a ui. // This should be the easiest method of putting text anywhere. - pub fn paint_galley(&self, ui: &mut Ui, pos: Pos2, galley: Arc) { - self.paint_galley_impl(ui, pos, galley, false, ui.visuals().text_color()) - } - - fn paint_galley_impl( - &self, - ui: &mut Ui, - pos: Pos2, - galley: Arc, - has_focus: bool, - response_color: Color32, - ) { - let Self { - mut background_color, - code, - strong, - weak, - strikethrough, - underline, - italics, - raised: _, - .. - } = *self; - - let underline = underline || has_focus; - - let text_color = if let Some(text_color) = self.text_color { - text_color - } else if strong { - ui.visuals().strong_text_color() - } else if weak { - ui.visuals().weak_text_color() - } else { - response_color - }; - - if code { - background_color = ui.visuals().code_bg_color; - } - - let mut lines = vec![]; - - if strikethrough || underline || background_color != Color32::TRANSPARENT { - for row in &galley.rows { - let rect = row.rect().translate(pos.to_vec2()); - - if background_color != Color32::TRANSPARENT { - let rect = rect.expand(1.0); // looks better - ui.painter().rect_filled(rect, 0.0, background_color); - } - - let stroke_width = 1.0; - if strikethrough { - lines.push(Shape::line_segment( - [rect.left_center(), rect.right_center()], - (stroke_width, text_color), - )); - } - if underline { - lines.push(Shape::line_segment( - [rect.left_bottom(), rect.right_bottom()], - (stroke_width, text_color), - )); - } - } - } - - ui.painter() - .galley_with_italics(pos, galley, text_color, italics); - - ui.painter().extend(lines); - } - /// Read the text style, or get the default for the current style pub fn text_style_or_default(&self, style: &Style) -> TextStyle { self.text_style @@ -282,38 +303,9 @@ impl Label { }) } - fn valign_galley( - &self, - ui: &Ui, - text_style: TextStyle, - mut galley: Arc, - ) -> Arc { - if text_style == TextStyle::Small { - // Hacky McHackface strikes again: - let dy = if self.raised { - -2.0 - } else { - let normal_text_height = ui.fonts()[TextStyle::Body].row_height(); - let font_height = ui.fonts().row_height(text_style); - (normal_text_height - font_height) / 2.0 - 1.0 // center - - // normal_text_height - font_height // align bottom - }; - - if dy != 0.0 { - for row in &mut Arc::make_mut(&mut galley).rows { - row.translate_y(dy); - } - } - } - galley - } -} - -impl Widget for Label { - fn ui(self, ui: &mut Ui) -> Response { + /// Do layout and place the galley in the ui, without painting it or adding widget info. + pub(crate) fn layout_in_ui(&self, ui: &mut Ui) -> (Pos2, Arc, Response) { let sense = self.sense; - let max_width = ui.available_width(); if self.should_wrap(ui) @@ -328,59 +320,44 @@ impl Widget for Label { let first_row_indentation = max_width - ui.available_size_before_wrap().x; egui_assert!(first_row_indentation.is_finite()); - let text_style = self.text_style_or_default(ui.style()); - let galley = ui.fonts().layout_multiline_with_indentation_and_max_width( - text_style, - self.text.clone(), + let first_row_min_height = cursor.height(); + let default_color = self.get_text_color(ui, ui.visuals().text_color()); + let galley = self.layout_impl( + ui, first_row_indentation, max_width, + first_row_min_height, + default_color, ); - let mut galley: Galley = (*galley).clone(); let pos = pos2(ui.max_rect().left(), ui.cursor().top()); - assert!(!galley.rows.is_empty(), "Galleys are never empty"); - - // Center first row within the cursor: - let dy = 0.5 * (cursor.height() - galley.rows[0].height()); - galley.rows[0].translate_y(dy); - - // We could be sharing the first row with e.g. a button which is higher than text. - // So we need to compensate for that: - if let Some(row) = galley.rows.get_mut(1) { - if pos.y + row.y_min < cursor.bottom() { - let y_translation = cursor.bottom() - row.y_min - pos.y; - if y_translation != 0.0 { - for row in galley.rows.iter_mut().skip(1) { - row.translate_y(y_translation); - } - } - } - } - - let galley = self.valign_galley(ui, text_style, Arc::new(galley)); - - let rect = galley.rows[0].rect().translate(vec2(pos.x, pos.y)); + // collect a response from many rows: + let rect = galley.rows[0].rect.translate(vec2(pos.x, pos.y)); let mut response = ui.allocate_rect(rect, sense); for row in galley.rows.iter().skip(1) { - let rect = row.rect().translate(vec2(pos.x, pos.y)); + let rect = row.rect.translate(vec2(pos.x, pos.y)); response |= ui.allocate_rect(rect, sense); } - response.widget_info(|| WidgetInfo::labeled(WidgetType::Label, &galley.text)); - let response_color = ui.style().interact(&response).text_color(); - self.paint_galley_impl(ui, pos, galley, response.has_focus(), response_color); - response + (pos, galley, response) } else { let galley = self.layout(ui); let (rect, response) = ui.allocate_exact_size(galley.size, sense); - response.widget_info(|| WidgetInfo::labeled(WidgetType::Label, &galley.text)); - let response_color = ui.style().interact(&response).text_color(); - self.paint_galley_impl(ui, rect.min, galley, response.has_focus(), response_color); - response + (rect.min, galley, response) } } } +impl Widget for Label { + fn ui(self, ui: &mut Ui) -> Response { + let (pos, galley, response) = self.layout_in_ui(ui); + response.widget_info(|| WidgetInfo::labeled(WidgetType::Label, galley.text())); + let response_color = ui.style().interact(&response).text_color(); + self.paint_galley(ui, pos, galley, response.has_focus(), response_color); + response + } +} + impl From<&str> for Label { fn from(s: &str) -> Label { Label::new(s) diff --git a/egui/src/widgets/plot/items.rs b/egui/src/widgets/plot/items.rs index fffd15e8..e3564e38 100644 --- a/egui/src/widgets/plot/items.rs +++ b/egui/src/widgets/plot/items.rs @@ -912,16 +912,11 @@ impl PlotItem for Text { let pos = transform.position_from_value(&self.position); let galley = ui .fonts() - .layout_multiline(self.style, self.text.clone(), f32::INFINITY); + .layout_no_wrap(self.text.clone(), self.style, color); let rect = self .anchor .anchor_rect(Rect::from_min_size(pos, galley.size)); - shapes.push(Shape::Text { - pos: rect.min, - galley, - color, - fake_italics: false, - }); + shapes.push(Shape::galley(rect.min, galley)); if self.highlight { shapes.push(Shape::rect_stroke( rect.expand(2.0), diff --git a/egui/src/widgets/plot/legend.rs b/egui/src/widgets/plot/legend.rs index 5077e522..8b8e3316 100644 --- a/egui/src/widgets/plot/legend.rs +++ b/egui/src/widgets/plot/legend.rs @@ -90,7 +90,9 @@ impl LegendEntry { hovered, } = self; - let galley = ui.fonts().layout_no_wrap(ui.style().body_text_style, text); + let galley = + ui.fonts() + .layout_delayed_color(text, ui.style().body_text_style, f32::INFINITY); let icon_size = galley.size.y; let icon_spacing = icon_size / 5.0; @@ -99,7 +101,8 @@ impl LegendEntry { let desired_size = total_extra + galley.size; let (rect, response) = ui.allocate_exact_size(desired_size, Sense::click()); - response.widget_info(|| WidgetInfo::selected(WidgetType::Checkbox, *checked, &galley.text)); + response + .widget_info(|| WidgetInfo::selected(WidgetType::Checkbox, *checked, galley.text())); let visuals = ui.style().interact(&response); let label_on_the_left = ui.layout().horizontal_align() == Align::RIGHT; @@ -142,7 +145,7 @@ impl LegendEntry { }; let text_position = pos2(text_position_x, rect.center().y - 0.5 * galley.size.y); - painter.galley(text_position, galley, visuals.text_color()); + painter.galley_with_color(text_position, galley, visuals.text_color()); *checked ^= response.clicked_by(PointerButton::Primary); *hovered = response.hovered(); diff --git a/egui/src/widgets/plot/mod.rs b/egui/src/widgets/plot/mod.rs index 585123f6..16dc2d38 100644 --- a/egui/src/widgets/plot/mod.rs +++ b/egui/src/widgets/plot/mod.rs @@ -626,7 +626,7 @@ impl Prepared { let color = color_from_alpha(ui, text_alpha); let text = emath::round_to_decimals(value_main, 5).to_string(); // hack - let galley = ui.fonts().layout_single_line(text_style, text); + let galley = ui.painter().layout_no_wrap(text, text_style, color); let mut text_pos = pos_in_gui + vec2(1.0, -galley.size.y); @@ -635,12 +635,7 @@ impl Prepared { .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::Text { - pos: text_pos, - galley, - color, - fake_italics: false, - }); + shapes.push(Shape::galley(text_pos, galley)); } } diff --git a/egui/src/widgets/selected_label.rs b/egui/src/widgets/selected_label.rs index ef3fe6eb..8482cae2 100644 --- a/egui/src/widgets/selected_label.rs +++ b/egui/src/widgets/selected_label.rs @@ -59,18 +59,21 @@ impl Widget for SelectableLabel { let button_padding = ui.spacing().button_padding; let total_extra = button_padding + button_padding; - let galley = if ui.wrap_text() { - ui.fonts() - .layout_multiline(text_style, text, ui.available_width() - total_extra.x) + let wrap_width = if ui.wrap_text() { + ui.available_width() - total_extra.x } else { - ui.fonts().layout_no_wrap(text_style, text) + f32::INFINITY }; + let galley = ui + .fonts() + .layout_delayed_color(text, text_style, wrap_width); + 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(|| { - WidgetInfo::selected(WidgetType::SelectableLabel, selected, &galley.text) + WidgetInfo::selected(WidgetType::SelectableLabel, selected, galley.text()) }); let text_pos = ui @@ -93,7 +96,7 @@ impl Widget for SelectableLabel { .visuals .override_text_color .unwrap_or_else(|| visuals.text_color()); - ui.painter().galley(text_pos, galley, text_color); + ui.painter().galley_with_color(text_pos, galley, text_color); response } } diff --git a/egui/src/widgets/text_edit.rs b/egui/src/widgets/text_edit.rs index 8bf91186..da8e6e5c 100644 --- a/egui/src/widgets/text_edit.rs +++ b/egui/src/widgets/text_edit.rs @@ -1,6 +1,7 @@ use crate::{output::OutputEvent, util::undoer::Undoer, *}; -use epaint::{text::cursor::*, *}; +use epaint::text::{cursor::*, Galley, LayoutJob}; use std::ops::Range; +use std::sync::Arc; #[derive(Clone, Debug, Default)] #[cfg_attr(feature = "persistence", derive(serde::Deserialize, serde::Serialize))] @@ -222,7 +223,6 @@ impl TextBuffer for String { /// ``` /// #[must_use = "You should put this widget in an ui with `ui.add(widget);`"] -#[derive(Debug)] pub struct TextEdit<'t, S: TextBuffer = String> { text: &'t mut S, hint_text: String, @@ -230,6 +230,7 @@ pub struct TextEdit<'t, S: TextBuffer = String> { id_source: Option, text_style: Option, text_color: Option, + layouter: Option<&'t mut dyn FnMut(&Ui, &str, f32) -> Arc>, password: bool, frame: bool, multiline: bool, @@ -239,6 +240,7 @@ pub struct TextEdit<'t, S: TextBuffer = String> { lock_focus: bool, cursor_at_end: bool, } + impl<'t, S: TextBuffer> TextEdit<'t, S> { pub fn cursor(ui: &Ui, id: Id) -> Option { ui.memory() @@ -251,33 +253,23 @@ impl<'t, S: TextBuffer> TextEdit<'t, S> { impl<'t, S: TextBuffer> TextEdit<'t, S> { /// No newlines (`\n`) allowed. Pressing enter key will result in the `TextEdit` losing focus (`response.lost_focus`). pub fn singleline(text: &'t mut S) -> Self { - TextEdit { - text, - hint_text: Default::default(), - id: None, - id_source: None, - text_style: None, - text_color: None, - password: false, - frame: true, - multiline: false, - enabled: true, - desired_width: None, + Self { desired_height_rows: 1, - lock_focus: false, - cursor_at_end: true, + multiline: false, + ..Self::multiline(text) } } /// A `TextEdit` for multiple lines. Pressing enter key will create a new line. pub fn multiline(text: &'t mut S) -> Self { - TextEdit { + Self { text, hint_text: Default::default(), id: None, id_source: None, text_style: None, text_color: None, + layouter: None, password: false, frame: true, multiline: true, @@ -337,6 +329,34 @@ impl<'t, S: TextBuffer> TextEdit<'t, S> { self } + /// Override how text is being shown inside the `TextEdit`. + /// + /// This can be used to implement things like syntax highlighting. + /// + /// This function will be called at least once per frame, + /// so it is strongly suggested that you cache the results of any syntax highlighter + /// so as not to waste CPU highlighting the same string every frame. + /// + /// The arguments is the enclosing [`Ui`] (so you can access e.g. [`Ui::fonts`]), + /// the text and the wrap width. + /// + /// ``` + /// # let ui = &mut egui::Ui::__test(); + /// # let mut my_code = String::new(); + /// # 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; + /// ui.fonts().layout_job(layout_job) + /// }; + /// ui.add(egui::TextEdit::multiline(&mut my_code).layouter(&mut layouter)); + /// ``` + pub fn layouter(mut self, layouter: &'t mut dyn FnMut(&Ui, &str, f32) -> Arc) -> Self { + self.layouter = Some(layouter); + + self + } + /// Default is `true`. If set to `false` then you cannot edit the text. pub fn enabled(mut self, enabled: bool) -> Self { self.enabled = enabled; @@ -349,7 +369,8 @@ impl<'t, S: TextBuffer> TextEdit<'t, S> { self } - /// Set to 0.0 to keep as small as possible + /// Set to 0.0 to keep as small as possible. + /// Set to [`f32::INFINITY`] to take up all available space. pub fn desired_width(mut self, desired_width: f32) -> Self { self.desired_width = Some(desired_width); self @@ -433,6 +454,14 @@ fn mask_massword(text: &str) -> String { .collect::() } +fn mask_if_password(is_password: bool, text: &str) -> String { + if is_password { + mask_massword(text) + } else { + text.to_owned() + } +} + impl<'t, S: TextBuffer> TextEdit<'t, S> { fn content_ui(self, ui: &mut Ui) -> Response { let TextEdit { @@ -442,6 +471,7 @@ impl<'t, S: TextBuffer> TextEdit<'t, S> { id_source, text_style, text_color, + layouter, password, frame: _, multiline, @@ -452,19 +482,16 @@ impl<'t, S: TextBuffer> TextEdit<'t, S> { cursor_at_end, } = self; - let mask_if_password = |text: &str| { - if password { - mask_massword(text) - } else { - text.to_owned() - } - }; + let text_color = text_color + .or(ui.visuals().override_text_color) + // .unwrap_or_else(|| ui.style().interact(&response).text_color()); // too bright + .unwrap_or_else(|| ui.visuals().widgets.inactive.text_color()); let prev_text = text.as_ref().to_owned(); let text_style = text_style .or(ui.style().override_text_style) .unwrap_or_else(|| ui.style().body_text_style); - let line_spacing = ui.fonts().row_height(text_style); + let row_height = ui.fonts().row_height(text_style); const MIN_WIDTH: f32 = 24.0; // Never make a `TextEdit` more narrow than this. let available_width = ui.available_width().at_least(MIN_WIDTH); let desired_width = desired_width.unwrap_or_else(|| ui.spacing().text_edit_width); @@ -474,24 +501,26 @@ impl<'t, S: TextBuffer> TextEdit<'t, S> { desired_width.min(available_width) }; - let make_galley = |ui: &Ui, wrap_width: f32, text: &str| { - let text = mask_if_password(text); - if multiline { - ui.fonts().layout_multiline(text_style, text, wrap_width) + let mut default_layouter = move |ui: &Ui, text: &str, wrap_width: f32| { + let text = mask_if_password(password, text); + ui.fonts().layout_job(if multiline { + LayoutJob::simple(text, text_style, text_color, wrap_width) } else { - ui.fonts().layout_single_line(text_style, text) - } + LayoutJob::simple_singleline(text, text_style, text_color) + }) }; + let layouter = layouter.unwrap_or(&mut default_layouter); + let copy_if_not_password = |ui: &Ui, text: String| { if !password { ui.ctx().output().copied_text = text; } }; - let mut galley = make_galley(ui, wrap_width, text.as_ref()); + let mut galley = layouter(ui, text.as_ref(), wrap_width); - let desired_height = (desired_height_rows.at_least(1) as f32) * line_spacing; + 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 (auto_id, rect) = ui.allocate_space(desired_size); @@ -525,7 +554,14 @@ impl<'t, S: TextBuffer> TextEdit<'t, S> { && ui.input().pointer.is_moving() { // preview: - paint_cursor_end(ui, &painter, response.rect.min, &galley, &cursor_at_pointer); + paint_cursor_end( + ui, + row_height, + &painter, + response.rect.min, + &galley, + &cursor_at_pointer, + ); } if response.double_clicked() { @@ -729,7 +765,7 @@ impl<'t, S: TextBuffer> TextEdit<'t, S> { response.mark_changed(); // Layout again to avoid frame delay, and to keep `text` and `galley` in sync. - galley = make_galley(ui, wrap_width, text.as_ref()); + galley = layouter(ui, text.as_ref(), wrap_width); // Set cursorp using new galley: cursorp = CursorPair { @@ -778,7 +814,14 @@ impl<'t, S: TextBuffer> TextEdit<'t, S> { if ui.memory().has_focus(id) { if let Some(cursorp) = state.cursorp { paint_cursor_selection(ui, &painter, text_draw_pos, &galley, &cursorp); - paint_cursor_end(ui, &painter, text_draw_pos, &galley, &cursorp.primary); + paint_cursor_end( + ui, + row_height, + &painter, + text_draw_pos, + &galley, + &cursorp.primary, + ); if enabled { ui.ctx().output().text_cursor_pos = Some( @@ -791,21 +834,15 @@ impl<'t, S: TextBuffer> TextEdit<'t, S> { } } - let text_color = text_color - .or(ui.visuals().override_text_color) - // .unwrap_or_else(|| ui.style().interact(&response).text_color()); // too bright - .unwrap_or_else(|| ui.visuals().widgets.inactive.text_color()); - - painter.galley(text_draw_pos, galley, text_color); + painter.galley(text_draw_pos, galley); if text.as_ref().is_empty() && !hint_text.is_empty() { - let galley = if multiline { - ui.fonts() - .layout_multiline(text_style, hint_text, desired_size.x) - } else { - ui.fonts().layout_single_line(text_style, hint_text) - }; let hint_text_color = ui.visuals().weak_text_color(); - painter.galley(response.rect.min, galley, hint_text_color); + let galley = ui.fonts().layout_job(if multiline { + LayoutJob::simple(hint_text, text_style, hint_text_color, desired_size.x) + } else { + LayoutJob::simple_singleline(hint_text, text_style, hint_text_color) + }); + painter.galley(response.rect.min, galley); } ui.memory().id_data.insert(id, state); @@ -822,16 +859,18 @@ impl<'t, S: TextBuffer> TextEdit<'t, S> { if response.changed { response.widget_info(|| { WidgetInfo::text_edit( - mask_if_password(prev_text.as_str()), - mask_if_password(text.as_str()), + mask_if_password(password, prev_text.as_str()), + mask_if_password(password, text.as_str()), ) }); } else if selection_changed { let text_cursor = text_cursor.unwrap(); let char_range = text_cursor.primary.ccursor.index..=text_cursor.secondary.ccursor.index; - let info = - WidgetInfo::text_selection_changed(char_range, mask_if_password(text.as_str())); + let info = WidgetInfo::text_selection_changed( + char_range, + mask_if_password(password, text.as_str()), + ); response .ctx .output() @@ -840,8 +879,8 @@ impl<'t, S: TextBuffer> TextEdit<'t, S> { } else { response.widget_info(|| { WidgetInfo::text_edit( - mask_if_password(prev_text.as_str()), - mask_if_password(text.as_str()), + mask_if_password(password, prev_text.as_str()), + mask_if_password(password, text.as_str()), ) }); } @@ -871,7 +910,7 @@ fn paint_cursor_selection( let left = if ri == min.row { row.x_offset(min.column) } else { - row.min_x() + row.rect.left() }; let right = if ri == max.row { row.x_offset(max.column) @@ -881,18 +920,29 @@ fn paint_cursor_selection( } else { 0.0 }; - row.max_x() + newline_size + row.rect.right() + newline_size }; - let rect = Rect::from_min_max(pos + vec2(left, row.y_min), pos + vec2(right, row.y_max)); + let rect = Rect::from_min_max( + pos + vec2(left, row.min_y()), + pos + vec2(right, row.max_y()), + ); painter.rect_filled(rect, 0.0, color); } } -fn paint_cursor_end(ui: &mut Ui, painter: &Painter, pos: Pos2, galley: &Galley, cursor: &Cursor) { +fn paint_cursor_end( + ui: &mut Ui, + row_height: f32, + painter: &Painter, + pos: Pos2, + galley: &Galley, + cursor: &Cursor, +) { let stroke = ui.visuals().selection.stroke; - let cursor_pos = galley.pos_from_cursor(cursor).translate(pos.to_vec2()); - let cursor_pos = cursor_pos.expand(1.5); // slightly above/below row + let mut cursor_pos = galley.pos_from_cursor(cursor).translate(pos.to_vec2()); + cursor_pos.max.y = cursor_pos.max.y.at_least(cursor_pos.min.y + row_height); // Handle completely empty galleys + cursor_pos = cursor_pos.expand(1.5); // slightly above/below row let top = cursor_pos.center_top(); let bottom = cursor_pos.center_bottom(); @@ -1102,7 +1152,7 @@ fn move_single_cursor(cursor: &mut Cursor, galley: &Galley, key: Key, modifiers: Key::ArrowLeft => { if modifiers.alt || modifiers.ctrl { // alt on mac, ctrl on windows - *cursor = galley.from_ccursor(ccursor_previous_word(&galley.text, cursor.ccursor)); + *cursor = galley.from_ccursor(ccursor_previous_word(galley.text(), cursor.ccursor)); } else if modifiers.mac_cmd { *cursor = galley.cursor_begin_of_row(cursor); } else { @@ -1112,7 +1162,7 @@ fn move_single_cursor(cursor: &mut Cursor, galley: &Galley, key: Key, modifiers: Key::ArrowRight => { if modifiers.alt || modifiers.ctrl { // alt on mac, ctrl on windows - *cursor = galley.from_ccursor(ccursor_next_word(&galley.text, cursor.ccursor)); + *cursor = galley.from_ccursor(ccursor_next_word(galley.text(), cursor.ccursor)); } else if modifiers.mac_cmd { *cursor = galley.cursor_end_of_row(cursor); } else { diff --git a/egui_demo_lib/benches/benchmark.rs b/egui_demo_lib/benches/benchmark.rs index b0ce26e7..0f3290ea 100644 --- a/egui_demo_lib/benches/benchmark.rs +++ b/egui_demo_lib/benches/benchmark.rs @@ -68,30 +68,39 @@ pub fn criterion_benchmark(c: &mut Criterion) { let pixels_per_point = 1.0; let wrap_width = 512.0; let text_style = egui::TextStyle::Body; + let color = egui::Color32::WHITE; let fonts = egui::epaint::text::Fonts::from_definitions( pixels_per_point, egui::FontDefinitions::default(), ); - let font = &fonts[text_style]; c.bench_function("text_layout_uncached", |b| { - b.iter(|| font.layout_multiline(LOREM_IPSUM_LONG.to_owned(), wrap_width)) + b.iter(|| { + use egui::epaint::text::{layout, LayoutJob}; + + let job = LayoutJob::simple( + LOREM_IPSUM_LONG.to_owned(), + egui::TextStyle::Body, + color, + wrap_width, + ); + layout(&fonts, job.into()) + }) }); c.bench_function("text_layout_cached", |b| { - b.iter(|| fonts.layout_multiline(text_style, LOREM_IPSUM_LONG.to_owned(), wrap_width)) + b.iter(|| fonts.layout(LOREM_IPSUM_LONG.to_owned(), text_style, color, wrap_width)) }); - let galley = font.layout_multiline(LOREM_IPSUM_LONG.to_owned(), wrap_width); + let galley = fonts.layout(LOREM_IPSUM_LONG.to_owned(), text_style, color, wrap_width); let mut tessellator = egui::epaint::Tessellator::from_options(Default::default()); let mut mesh = egui::epaint::Mesh::default(); c.bench_function("tessellate_text", |b| { b.iter(|| { - let fake_italics = false; tessellator.tessellate_text( fonts.texture().size(), egui::Pos2::ZERO, &galley, - egui::Color32::WHITE, - fake_italics, + Default::default(), + None, &mut mesh, ); mesh.clear(); diff --git a/egui_demo_lib/src/apps/demo/code_editor.rs b/egui_demo_lib/src/apps/demo/code_editor.rs new file mode 100644 index 00000000..c23c11f1 --- /dev/null +++ b/egui_demo_lib/src/apps/demo/code_editor.rs @@ -0,0 +1,357 @@ +use egui::text::LayoutJob; + +#[cfg_attr(feature = "persistence", derive(serde::Deserialize, serde::Serialize))] +#[cfg_attr(feature = "persistence", serde(default))] +pub struct CodeEditor { + code: String, + language: String, + #[cfg_attr(feature = "persistence", serde(skip))] + highlighter: MemoizedSyntaxHighlighter, +} + +impl Default for CodeEditor { + fn default() -> Self { + Self { + code: "// A very simple example\n\ +fn main() {\n\ +\tprintln!(\"Hello world!\");\n\ +}\n\ +" + .into(), + language: "rs".into(), + highlighter: Default::default(), + } + } +} + +impl super::Demo for CodeEditor { + fn name(&self) -> &'static str { + "🖮 Code Editor" + } + + fn show(&mut self, ctx: &egui::CtxRef, open: &mut bool) { + use super::View; + egui::Window::new(self.name()) + .open(open) + .show(ctx, |ui| self.ui(ui)); + } +} + +impl super::View for CodeEditor { + fn ui(&mut self, ui: &mut egui::Ui) { + let Self { + code, + language, + highlighter, + } = self; + + ui.horizontal(|ui| { + ui.set_height(0.0); + ui.label("An example of syntax highlighting in a TextEdit."); + ui.add(crate::__egui_github_link_file!()); + }); + + if cfg!(feature = "syntect") { + ui.horizontal(|ui| { + ui.label("Language:"); + ui.text_edit_singleline(language); + }); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 0.0; + ui.label("Syntax highlighting powered by "); + ui.hyperlink_to("syntect", "https://github.com/trishume/syntect"); + ui.label("."); + }); + } else { + ui.horizontal_wrapped(|ui|{ + ui.spacing_mut().item_spacing.x = 0.0; + ui.label("Compile the demo with the 'syntect' feature to enable much nicer syntax highlighting using "); + ui.hyperlink_to("syntect", "https://github.com/trishume/syntect"); + ui.label("."); + }); + } + + let mut layouter = |ui: &egui::Ui, string: &str, wrap_width: f32| { + let mut layout_job = highlighter.highlight(ui.visuals().dark_mode, string, language); + layout_job.wrap_width = wrap_width; + ui.fonts().layout_job(layout_job) + }; + + egui::ScrollArea::vertical().show(ui, |ui| { + ui.add( + egui::TextEdit::multiline(code) + .text_style(egui::TextStyle::Monospace) // for cursor height + .code_editor() + .lock_focus(true) + .desired_width(f32::INFINITY) + .layouter(&mut layouter), + ); + }); + } +} + +// ---------------------------------------------------------------------------- + +#[derive(Default)] +struct MemoizedSyntaxHighlighter { + is_dark_mode: bool, + code: String, + language: String, + output: LayoutJob, + highligher: Highligher, +} + +impl MemoizedSyntaxHighlighter { + fn highlight(&mut self, is_dark_mode: bool, code: &str, language: &str) -> LayoutJob { + if ( + self.is_dark_mode, + self.code.as_str(), + self.language.as_str(), + ) != (is_dark_mode, code, language) + { + self.is_dark_mode = is_dark_mode; + self.code = code.to_owned(); + self.language = language.to_owned(); + self.output = self + .highligher + .highlight(is_dark_mode, code, language) + .unwrap_or_else(|| { + LayoutJob::simple( + code.into(), + egui::TextStyle::Monospace, + if is_dark_mode { + egui::Color32::LIGHT_GRAY + } else { + egui::Color32::DARK_GRAY + }, + f32::INFINITY, + ) + }); + } + self.output.clone() + } +} + +// ---------------------------------------------------------------------------- + +#[cfg(feature = "syntect")] +struct Highligher { + ps: syntect::parsing::SyntaxSet, + ts: syntect::highlighting::ThemeSet, +} + +#[cfg(feature = "syntect")] +impl Default for Highligher { + fn default() -> Self { + Self { + ps: syntect::parsing::SyntaxSet::load_defaults_newlines(), + ts: syntect::highlighting::ThemeSet::load_defaults(), + } + } +} + +#[cfg(feature = "syntect")] +impl Highligher { + fn highlight(&self, is_dark_mode: bool, text: &str, language: &str) -> Option { + use syntect::easy::HighlightLines; + use syntect::highlighting::FontStyle; + use syntect::util::LinesWithEndings; + + let syntax = self + .ps + .find_syntax_by_name(language) + .or_else(|| self.ps.find_syntax_by_extension(language))?; + + let theme = if is_dark_mode { + "base16-mocha.dark" + } else { + "base16-ocean.light" + }; + let mut h = HighlightLines::new(syntax, &self.ts.themes[theme]); + + use egui::text::{LayoutSection, TextFormat}; + + let mut job = LayoutJob { + text: text.into(), + ..Default::default() + }; + + for line in LinesWithEndings::from(text) { + for (style, range) in h.highlight(line, &self.ps) { + let fg = style.foreground; + let text_color = egui::Color32::from_rgb(fg.r, fg.g, fg.b); + let italics = style.font_style.contains(FontStyle::ITALIC); + let underline = style.font_style.contains(FontStyle::ITALIC); + let underline = if underline { + egui::Stroke::new(1.0, text_color) + } else { + egui::Stroke::none() + }; + job.sections.push(LayoutSection { + leading_space: 0.0, + byte_range: as_byte_range(text, range), + format: TextFormat { + style: egui::TextStyle::Monospace, + color: text_color, + italics, + underline, + ..Default::default() + }, + }); + } + } + + Some(job) + } +} + +#[cfg(feature = "syntect")] +fn as_byte_range(whole: &str, range: &str) -> std::ops::Range { + let whole_start = whole.as_ptr() as usize; + let range_start = range.as_ptr() as usize; + assert!(whole_start <= range_start); + assert!(range_start + range.len() <= whole_start + whole.len()); + let offset = range_start - whole_start; + offset..(offset + range.len()) +} + +// ---------------------------------------------------------------------------- + +#[cfg(not(feature = "syntect"))] +#[derive(Default)] +struct Highligher {} + +#[cfg(not(feature = "syntect"))] +impl Highligher { + fn highlight(&self, is_dark_mode: bool, mut text: &str, _language: &str) -> Option { + // Extremely simple syntax highlighter for when we compile without syntect + + use egui::text::TextFormat; + use egui::Color32; + let monospace = egui::TextStyle::Monospace; + + let comment_format = TextFormat::simple(monospace, Color32::GRAY); + let quoted_string_format = TextFormat::simple( + monospace, + if is_dark_mode { + Color32::KHAKI + } else { + Color32::BROWN + }, + ); + let keyword_format = TextFormat::simple( + monospace, + if is_dark_mode { + Color32::LIGHT_RED + } else { + Color32::DARK_RED + }, + ); + let literal_format = TextFormat::simple( + monospace, + if is_dark_mode { + Color32::LIGHT_GREEN + } else { + Color32::DARK_GREEN + }, + ); + let whitespace_format = TextFormat::simple(monospace, Color32::WHITE); + let punctuation_format = TextFormat::simple( + monospace, + if is_dark_mode { + Color32::LIGHT_GRAY + } else { + Color32::DARK_GRAY + }, + ); + + let mut job = LayoutJob::default(); + + while !text.is_empty() { + if text.starts_with("//") { + let end = text.find('\n').unwrap_or(text.len()); + job.append(&text[..end], 0.0, comment_format); + text = &text[end..]; + } else if text.starts_with('"') { + let end = text[1..] + .find('"') + .map(|i| i + 2) + .or_else(|| text.find('\n')) + .unwrap_or(text.len()); + job.append(&text[..end], 0.0, quoted_string_format); + text = &text[end..]; + } else if text.starts_with(|c: char| c.is_ascii_alphanumeric()) { + let end = text[1..] + .find(|c: char| !c.is_ascii_alphanumeric()) + .map(|i| i + 1) + .unwrap_or(text.len()); + let word = &text[..end]; + if is_keyword(word) { + job.append(word, 0.0, keyword_format); + } else { + job.append(word, 0.0, literal_format); + }; + text = &text[end..]; + } else if text.starts_with(|c: char| c.is_ascii_whitespace()) { + let end = text[1..] + .find(|c: char| !c.is_ascii_whitespace()) + .map(|i| i + 1) + .unwrap_or(text.len()); + job.append(&text[..end], 0.0, whitespace_format); + text = &text[end..]; + } else { + let mut it = text.char_indices(); + it.next(); + let end = it.next().map_or(text.len(), |(idx, _chr)| idx); + job.append(&text[..end], 0.0, punctuation_format); + text = &text[end..]; + } + } + + Some(job) + } +} + +#[cfg(not(feature = "syntect"))] +fn is_keyword(word: &str) -> bool { + matches!( + word, + "as" | "async" + | "await" + | "break" + | "const" + | "continue" + | "crate" + | "dyn" + | "else" + | "enum" + | "extern" + | "false" + | "fn" + | "for" + | "if" + | "impl" + | "in" + | "let" + | "loop" + | "match" + | "mod" + | "move" + | "mut" + | "pub" + | "ref" + | "return" + | "self" + | "Self" + | "static" + | "struct" + | "super" + | "trait" + | "true" + | "type" + | "unsafe" + | "use" + | "where" + | "while" + ) +} 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 eb64a543..5646aaf1 100644 --- a/egui_demo_lib/src/apps/demo/demo_app_windows.rs +++ b/egui_demo_lib/src/apps/demo/demo_app_windows.rs @@ -16,6 +16,7 @@ struct Demos { impl Default for Demos { fn default() -> Self { Self::from_demos(vec![ + Box::new(super::code_editor::CodeEditor::default()), Box::new(super::dancing_strings::DancingStrings::default()), Box::new(super::drag_and_drop::DragAndDropDemo::default()), Box::new(super::font_book::FontBook::default()), 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 a3477d3f..4a5aa4fe 100644 --- a/egui_demo_lib/src/apps/demo/misc_demo_window.rs +++ b/egui_demo_lib/src/apps/demo/misc_demo_window.rs @@ -50,6 +50,12 @@ impl View for MiscDemoWindow { self.widgets.ui(ui); }); + CollapsingHeader::new("Text layout") + .default_open(false) + .show(ui, |ui| { + text_layout_ui(ui); + }); + CollapsingHeader::new("Colors") .default_open(false) .show(ui, |ui| { @@ -114,8 +120,6 @@ impl View for MiscDemoWindow { pub struct Widgets { angle: f32, password: String, - lock_focus: bool, - code_snippet: String, } impl Default for Widgets { @@ -123,25 +127,13 @@ impl Default for Widgets { Self { angle: std::f32::consts::TAU / 3.0, password: "hunter2".to_owned(), - lock_focus: true, - code_snippet: "\ -fn main() { -\tprintln!(\"Hello world!\"); -} -" - .to_owned(), } } } impl Widgets { pub fn ui(&mut self, ui: &mut Ui) { - let Self { - angle, - password, - lock_focus, - code_snippet, - } = self; + let Self { angle, password } = self; ui.vertical_centered(|ui| { ui.add(crate::__egui_github_link_file_line!()); }); @@ -196,24 +188,6 @@ impl Widgets { .on_hover_text("See the example code for how to use egui to store UI state"); ui.add(super::password::password(password)); }); - - ui.separator(); - - ui.horizontal(|ui| { - ui.label("Code editor:"); - - ui.separator(); - - ui.checkbox(lock_focus, "Lock focus").on_hover_text( - "When checked, pressing TAB will insert a tab instead of moving focus", - ); - }); - - ui.add( - TextEdit::multiline(code_snippet) - .code_editor() - .lock_focus(*lock_focus), - ); } } @@ -423,3 +397,182 @@ impl SubTree { Action::Keep } } + +// ---------------------------------------------------------------------------- + +fn text_layout_ui(ui: &mut egui::Ui) { + use egui::epaint::text::{LayoutJob, TextFormat}; + + let mut job = LayoutJob::default(); + + let first_row_indentation = 10.0; + + let (default_color, strong_color) = if ui.visuals().dark_mode { + (Color32::LIGHT_GRAY, Color32::WHITE) + } else { + (Color32::DARK_GRAY, Color32::BLACK) + }; + + job.append( + "This is a demonstration of ", + first_row_indentation, + TextFormat { + style: TextStyle::Body, + color: default_color, + ..Default::default() + }, + ); + job.append( + "the egui text layout engine. ", + 0.0, + TextFormat { + style: TextStyle::Body, + color: strong_color, + ..Default::default() + }, + ); + job.append( + "It supports ", + 0.0, + TextFormat { + style: TextStyle::Body, + color: default_color, + ..Default::default() + }, + ); + job.append( + "different ", + 0.0, + TextFormat { + style: TextStyle::Body, + color: Color32::from_rgb(110, 255, 110), + ..Default::default() + }, + ); + job.append( + "colors, ", + 0.0, + TextFormat { + style: TextStyle::Body, + color: Color32::from_rgb(128, 140, 255), + ..Default::default() + }, + ); + job.append( + "backgrounds, ", + 0.0, + TextFormat { + style: TextStyle::Body, + color: default_color, + background: Color32::from_rgb(128, 32, 32), + ..Default::default() + }, + ); + job.append( + "mixing ", + 0.0, + TextFormat { + style: TextStyle::Heading, + color: default_color, + ..Default::default() + }, + ); + job.append( + "fonts, ", + 0.0, + TextFormat { + style: TextStyle::Monospace, + color: default_color, + ..Default::default() + }, + ); + job.append( + "raised text, ", + 0.0, + TextFormat { + style: TextStyle::Small, + color: default_color, + valign: Align::TOP, + ..Default::default() + }, + ); + job.append( + "with ", + 0.0, + TextFormat { + style: TextStyle::Body, + color: default_color, + ..Default::default() + }, + ); + job.append( + "underlining", + 0.0, + TextFormat { + style: TextStyle::Body, + color: default_color, + underline: Stroke::new(1.0, Color32::LIGHT_BLUE), + ..Default::default() + }, + ); + job.append( + " and ", + 0.0, + TextFormat { + style: TextStyle::Body, + color: default_color, + ..Default::default() + }, + ); + job.append( + "strikethrough", + 0.0, + TextFormat { + style: TextStyle::Body, + color: default_color, + strikethrough: Stroke::new(2.0, Color32::RED.linear_multiply(0.5)), + ..Default::default() + }, + ); + job.append( + ". Of course, ", + 0.0, + TextFormat { + style: TextStyle::Body, + color: default_color, + ..Default::default() + }, + ); + job.append( + "you can", + 0.0, + TextFormat { + style: TextStyle::Body, + color: default_color, + strikethrough: Stroke::new(1.0, strong_color), + ..Default::default() + }, + ); + job.append( + " mix these!", + 0.0, + TextFormat { + style: TextStyle::Small, + color: Color32::LIGHT_BLUE, + background: Color32::from_rgb(128, 0, 0), + underline: Stroke::new(1.0, strong_color), + ..Default::default() + }, + ); + + job.wrap_width = ui.available_width(); + + let galley = ui.fonts().layout_job(job); + + let (response, painter) = ui.allocate_painter(galley.size, Sense::hover()); + painter.add(Shape::galley(response.rect.min, galley)); + + ui.vertical_centered(|ui| { + ui.add(crate::__egui_github_link_file_line!()); + }); +} diff --git a/egui_demo_lib/src/apps/demo/mod.rs b/egui_demo_lib/src/apps/demo/mod.rs index b607ff05..08c0caee 100644 --- a/egui_demo_lib/src/apps/demo/mod.rs +++ b/egui_demo_lib/src/apps/demo/mod.rs @@ -5,6 +5,7 @@ // ---------------------------------------------------------------------------- mod app; +pub mod code_editor; pub mod dancing_strings; pub mod demo_app_windows; pub mod drag_and_drop; diff --git a/egui_demo_lib/src/apps/http_app.rs b/egui_demo_lib/src/apps/http_app.rs index 250d3260..8737fbf0 100644 --- a/egui_demo_lib/src/apps/http_app.rs +++ b/egui_demo_lib/src/apps/http_app.rs @@ -292,13 +292,14 @@ fn syntax_highlighting(response: &Response, text: &str) -> Option { /// Lines of text fragments #[cfg(feature = "syntect")] -struct ColoredText(Vec>); +struct ColoredText(egui::text::LayoutJob); #[cfg(feature = "syntect")] impl ColoredText { /// e.g. `text_with_extension("fn foo() {}", "rs")` pub fn text_with_extension(text: &str, extension: &str) -> Option { use syntect::easy::HighlightLines; + use syntect::highlighting::FontStyle; use syntect::highlighting::ThemeSet; use syntect::parsing::SyntaxSet; use syntect::util::LinesWithEndings; @@ -308,36 +309,67 @@ impl ColoredText { let syntax = ps.find_syntax_by_extension(extension)?; - let mut h = HighlightLines::new(syntax, &ts.themes["base16-mocha.dark"]); + let dark_mode = true; + let theme = if dark_mode { + "base16-mocha.dark" + } else { + "base16-ocean.light" + }; + let mut h = HighlightLines::new(syntax, &ts.themes[theme]); - let lines = LinesWithEndings::from(text) - .map(|line| { - h.highlight(line, &ps) - .into_iter() - .map(|(style, range)| (style, range.trim_end_matches('\n').to_owned())) - .collect() - }) - .collect(); + use egui::text::{LayoutJob, LayoutSection, TextFormat}; - Some(ColoredText(lines)) + let mut job = LayoutJob { + text: text.into(), + ..Default::default() + }; + + for line in LinesWithEndings::from(text) { + for (style, range) in h.highlight(line, &ps) { + let fg = style.foreground; + let text_color = egui::Color32::from_rgb(fg.r, fg.g, fg.b); + let italics = style.font_style.contains(FontStyle::ITALIC); + let underline = style.font_style.contains(FontStyle::ITALIC); + let underline = if underline { + egui::Stroke::new(1.0, text_color) + } else { + egui::Stroke::none() + }; + job.sections.push(LayoutSection { + leading_space: 0.0, + byte_range: as_byte_range(text, range), + format: TextFormat { + style: egui::TextStyle::Monospace, + color: text_color, + italics, + underline, + ..Default::default() + }, + }); + } + } + + Some(ColoredText(job)) } pub fn ui(&self, ui: &mut egui::Ui) { - for line in &self.0 { - ui.horizontal_wrapped(|ui| { - ui.spacing_mut().item_spacing = egui::Vec2::ZERO; - ui.set_row_height(ui.fonts()[egui::TextStyle::Body].row_height()); - - for (style, range) in line { - let fg = style.foreground; - let text_color = egui::Color32::from_rgb(fg.r, fg.g, fg.b); - ui.add(egui::Label::new(range).monospace().text_color(text_color)); - } - }); - } + 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()); + painter.add(egui::Shape::galley(response.rect.min, galley)); } } +fn as_byte_range(whole: &str, range: &str) -> std::ops::Range { + let whole_start = whole.as_ptr() as usize; + let range_start = range.as_ptr() as usize; + assert!(whole_start <= range_start); + assert!(range_start + range.len() <= whole_start + whole.len()); + let offset = range_start - whole_start; + offset..(offset + range.len()) +} + #[cfg(not(feature = "syntect"))] fn syntax_highlighting(_: &Response, _: &str) -> Option { None diff --git a/egui_demo_lib/src/easy_mark/easy_mark_parser.rs b/egui_demo_lib/src/easy_mark/easy_mark_parser.rs index 29b17a20..304a57eb 100644 --- a/egui_demo_lib/src/easy_mark/easy_mark_parser.rs +++ b/egui_demo_lib/src/easy_mark/easy_mark_parser.rs @@ -45,7 +45,7 @@ pub struct Style { pub strong: bool, /// _underline_ pub underline: bool, - /// -strikethrough- + /// ~strikethrough~ pub strikethrough: bool, /// /italics/ pub italics: bool, @@ -67,7 +67,7 @@ pub struct Style { /// /// ``` pub struct Parser<'a> { - /// The remainer of the input text + /// The remainder of the input text s: &'a str, /// Are we at the start of a line? start_of_line: bool, diff --git a/egui_demo_lib/src/easy_mark/easy_mark_viewer.rs b/egui_demo_lib/src/easy_mark/easy_mark_viewer.rs index 96ae022a..0247027f 100644 --- a/egui_demo_lib/src/easy_mark/easy_mark_viewer.rs +++ b/egui_demo_lib/src/easy_mark/easy_mark_viewer.rs @@ -7,7 +7,16 @@ pub fn easy_mark(ui: &mut Ui, easy_mark: &str) { } pub fn easy_mark_it<'em>(ui: &mut Ui, items: impl Iterator>) { - ui.horizontal_wrapped(|ui| { + let initial_size = vec2( + ui.available_width(), + ui.spacing().interact_size.y, // Assume there will be + ); + + let layout = Layout::left_to_right() + .with_main_wrap(true) + .with_cross_align(Align::BOTTOM); + + ui.allocate_ui_with_layout(initial_size, layout, |ui| { ui.spacing_mut().item_spacing.x = 0.0; ui.set_row_height(ui.fonts()[TextStyle::Body].row_height()); diff --git a/emath/src/align.rs b/emath/src/align.rs index 5cc86fd0..144a6eae 100644 --- a/emath/src/align.rs +++ b/emath/src/align.rs @@ -3,7 +3,7 @@ use crate::*; /// left/center/right or top/center/bottom alignment for e.g. anchors and layouts. -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] pub enum Align { @@ -100,7 +100,7 @@ impl Default for Align { // ---------------------------------------------------------------------------- /// Two-dimension alignment, e.g. [`Align2::LEFT_TOP`]. -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))] pub struct Align2(pub [Align; 2]); diff --git a/emath/src/pos2.rs b/emath/src/pos2.rs index ba9f85d4..f30d3f4e 100644 --- a/emath/src/pos2.rs +++ b/emath/src/pos2.rs @@ -109,6 +109,7 @@ impl Pos2 { /// Same as `Pos2::default()`. pub const ZERO: Self = Self { x: 0.0, y: 0.0 }; + #[inline(always)] pub const fn new(x: f32, y: f32) -> Self { Self { x, y } } diff --git a/emath/src/rect.rs b/emath/src/rect.rs index adf660fb..8fa7e690 100644 --- a/emath/src/rect.rs +++ b/emath/src/rect.rs @@ -56,6 +56,7 @@ impl Rect { Rect { min, max } } + #[inline(always)] pub fn from_min_size(min: Pos2, size: Vec2) -> Self { Rect { min, @@ -63,6 +64,7 @@ impl Rect { } } + #[inline(always)] pub fn from_center_size(center: Pos2, size: Vec2) -> Self { Rect { min: center - size * 0.5, @@ -70,6 +72,7 @@ impl Rect { } } + #[inline(always)] pub fn from_x_y_ranges(x_range: RangeInclusive, y_range: RangeInclusive) -> Self { Rect { min: pos2(*x_range.start(), *y_range.start()), @@ -77,6 +80,7 @@ impl Rect { } } + #[inline] pub fn from_two_pos(a: Pos2, b: Pos2) -> Self { Rect { min: pos2(a.x.min(b.x), a.y.min(b.y)), @@ -85,6 +89,7 @@ impl Rect { } /// A `Rect` that contains every point to the right of the given X coordinate. + #[inline] pub fn everything_right_of(left_x: f32) -> Self { let mut rect = Self::EVERYTHING; rect.set_left(left_x); @@ -92,6 +97,7 @@ impl Rect { } /// A `Rect` that contains every point to the left of the given X coordinate. + #[inline] pub fn everything_left_of(right_x: f32) -> Self { let mut rect = Self::EVERYTHING; rect.set_right(right_x); @@ -99,6 +105,7 @@ impl Rect { } /// A `Rect` that contains every point below a certain y coordinate + #[inline] pub fn everything_below(top_y: f32) -> Self { let mut rect = Self::EVERYTHING; rect.set_top(top_y); @@ -106,6 +113,7 @@ impl Rect { } /// A `Rect` that contains every point above a certain y coordinate + #[inline] pub fn everything_above(bottom_y: f32) -> Self { let mut rect = Self::EVERYTHING; rect.set_bottom(bottom_y); @@ -137,6 +145,7 @@ impl Rect { } #[must_use] + #[inline] pub fn translate(self, amnt: Vec2) -> Self { Rect::from_min_size(self.min + amnt, self.size()) } @@ -151,6 +160,7 @@ impl Rect { } #[must_use] + #[inline] pub fn intersects(self, other: Rect) -> bool { self.min.x <= other.max.x && other.min.x <= self.max.x @@ -191,23 +201,27 @@ impl Rect { p.clamp(self.min, self.max) } + #[inline(always)] pub fn extend_with(&mut self, p: Pos2) { self.min = self.min.min(p); self.max = self.max.max(p); } + #[inline(always)] /// Expand to include the given x coordinate pub fn extend_with_x(&mut self, x: f32) { self.min.x = self.min.x.min(x); self.max.x = self.max.x.max(x); } + #[inline(always)] /// Expand to include the given y coordinate pub fn extend_with_y(&mut self, y: f32) { self.min.y = self.min.y.min(y); self.max.y = self.max.y.max(y); } + #[inline(always)] pub fn union(self, other: Rect) -> Rect { Rect { min: self.min.min(other.min), @@ -260,16 +274,22 @@ impl Rect { } } + #[inline(always)] pub fn area(&self) -> f32 { self.width() * self.height() } + #[inline(always)] pub fn x_range(&self) -> RangeInclusive { self.min.x..=self.max.x } + + #[inline(always)] pub fn y_range(&self) -> RangeInclusive { self.min.y..=self.max.y } + + #[inline(always)] pub fn bottom_up_range(&self) -> RangeInclusive { self.max.y..=self.min.y } diff --git a/emath/src/vec2.rs b/emath/src/vec2.rs index 9900cb71..9593434f 100644 --- a/emath/src/vec2.rs +++ b/emath/src/vec2.rs @@ -127,7 +127,10 @@ impl Vec2 { /// `v.to_pos2()` is equivalent to `Pos2::default() + v`. #[inline(always)] pub fn to_pos2(self) -> crate::Pos2 { - crate::Pos2::new(self.x, self.y) + crate::Pos2 { + x: self.x, + y: self.y, + } } /// Safe normalize: returns zero if input is zero. diff --git a/epaint/Cargo.toml b/epaint/Cargo.toml index 4bfa2582..622f9095 100644 --- a/epaint/Cargo.toml +++ b/epaint/Cargo.toml @@ -28,7 +28,6 @@ ab_glyph = "0.2.11" ahash = { version = "0.7", features = ["std"], default-features = false } atomic_refcell = { version = "0.1", optional = true } # Used instead of parking_lot when you are always using epaint in a single thread. About as fast as parking_lot. Panics on multi-threaded use. cint = { version = "^0.2.2", optional = true } -ordered-float = { version = "2", default-features = false } parking_lot = { version = "0.11", optional = true } # Using parking_lot over std::sync::Mutex gives 50% speedups in some real-world scenarios. serde = { version = "1", features = ["derive"], optional = true } diff --git a/epaint/src/color.rs b/epaint/src/color.rs index 4699bdfb..5d390fc4 100644 --- a/epaint/src/color.rs +++ b/epaint/src/color.rs @@ -34,20 +34,39 @@ impl std::ops::IndexMut for Color32 { } impl Color32 { + // Mostly follows CSS names: + pub const TRANSPARENT: Color32 = Color32::from_rgba_premultiplied(0, 0, 0, 0); pub const BLACK: Color32 = Color32::from_rgb(0, 0, 0); - pub const LIGHT_GRAY: Color32 = Color32::from_rgb(220, 220, 220); + pub const DARK_GRAY: Color32 = Color32::from_rgb(96, 96, 96); pub const GRAY: Color32 = Color32::from_rgb(160, 160, 160); + pub const LIGHT_GRAY: Color32 = Color32::from_rgb(220, 220, 220); pub const WHITE: Color32 = Color32::from_rgb(255, 255, 255); + + pub const BROWN: Color32 = Color32::from_rgb(165, 42, 42); + pub const DARK_RED: Color32 = Color32::from_rgb(0x8B, 0, 0); pub const RED: Color32 = Color32::from_rgb(255, 0, 0); + pub const LIGHT_RED: Color32 = Color32::from_rgb(255, 128, 128); + pub const YELLOW: Color32 = Color32::from_rgb(255, 255, 0); + pub const LIGHT_YELLOW: Color32 = Color32::from_rgb(255, 255, 0xE0); + pub const KHAKI: Color32 = Color32::from_rgb(240, 230, 140); + + pub const DARK_GREEN: Color32 = Color32::from_rgb(0, 0x64, 0); pub const GREEN: Color32 = Color32::from_rgb(0, 255, 0); + pub const LIGHT_GREEN: Color32 = Color32::from_rgb(0x90, 0xEE, 0x90); + + pub const DARK_BLUE: Color32 = Color32::from_rgb(0, 0, 0x8B); pub const BLUE: Color32 = Color32::from_rgb(0, 0, 255); - pub const LIGHT_BLUE: Color32 = Color32::from_rgb(140, 160, 255); + pub const LIGHT_BLUE: Color32 = Color32::from_rgb(0xAD, 0xD8, 0xE6); + pub const GOLD: Color32 = Color32::from_rgb(255, 215, 0); pub const DEBUG_COLOR: Color32 = Color32::from_rgba_premultiplied(0, 200, 0, 128); + /// An ugly color that is planned to be replaced before making it to the screen. + pub const TEMPORARY_COLOR: Color32 = Color32::from_rgb(64, 254, 0); + #[inline(always)] pub const fn from_rgb(r: u8, g: u8, b: u8) -> Self { Self([r, g, b, 255]) diff --git a/epaint/src/lib.rs b/epaint/src/lib.rs index dc377d66..290c1c3f 100644 --- a/epaint/src/lib.rs +++ b/epaint/src/lib.rs @@ -175,3 +175,26 @@ macro_rules! epaint_assert { } } } + +// ---------------------------------------------------------------------------- + +#[inline(always)] +pub(crate) fn f32_hash(state: &mut H, f: f32) { + if f == 0.0 { + state.write_u8(0) + } else if f.is_nan() { + state.write_u8(1) + } else { + use std::hash::Hash; + f.to_bits().hash(state) + } +} + +#[inline(always)] +pub(crate) fn f32_eq(a: f32, b: f32) -> bool { + if a.is_nan() && b.is_nan() { + true + } else { + a == b + } +} diff --git a/epaint/src/mesh.rs b/epaint/src/mesh.rs index 771f9917..68b9f5fc 100644 --- a/epaint/src/mesh.rs +++ b/epaint/src/mesh.rs @@ -35,6 +35,7 @@ pub struct Mesh { /// The texture to use when drawing these triangles. pub texture_id: TextureId, + // TODO: bounding rectangle } impl Mesh { @@ -72,6 +73,15 @@ impl Mesh { self.indices.is_empty() && self.vertices.is_empty() } + /// Calculate a bounding rectangle. + pub fn calc_bounds(&self) -> Rect { + let mut bounds = Rect::NOTHING; + for v in &self.vertices { + bounds.extend_with(v.pos); + } + bounds + } + /// Append all the indices and vertices of `other` to `self`. pub fn append(&mut self, other: Mesh) { crate::epaint_assert!(other.is_valid()); @@ -85,9 +95,8 @@ impl Mesh { ); let index_offset = self.vertices.len() as u32; - for index in &other.indices { - self.indices.push(index_offset + index); - } + self.indices + .extend(other.indices.iter().map(|index| index + index_offset)); self.vertices.extend(other.vertices.iter()); } } diff --git a/epaint/src/shape.rs b/epaint/src/shape.rs index 17c6f98b..b34c2d0e 100644 --- a/epaint/src/shape.rs +++ b/epaint/src/shape.rs @@ -43,12 +43,18 @@ pub enum Shape { Text { /// Top left corner of the first character.. pos: Pos2, + /// The layed out text. galley: std::sync::Arc, - /// Text color (foreground). - color: Color32, - /// If true, tilt the letters for a hacky italics effect. - fake_italics: bool, + + /// Add this underline to the whole text. + /// You can also set an underline when creating the galley. + underline: Stroke, + + /// If set, the text color in the galley will be ignored and replaced + /// with the given color. + /// This will NOT replace background color nor strikethrough/underline color. + override_text_color: Option, }, Mesh(Mesh), } @@ -169,13 +175,17 @@ impl Shape { text_style: TextStyle, color: Color32, ) -> Self { - let galley = fonts.layout_multiline(text_style, text.to_string(), f32::INFINITY); + let galley = fonts.layout_no_wrap(text.to_string(), text_style, color); let rect = anchor.anchor_rect(Rect::from_min_size(pos, galley.size)); + Self::galley(rect.min, galley) + } + + pub fn galley(pos: Pos2, galley: std::sync::Arc) -> Self { Self::Text { - pos: rect.min, + pos, galley, - color, - fake_italics: false, + override_text_color: None, + underline: Stroke::none(), } } } diff --git a/epaint/src/shape_transform.rs b/epaint/src/shape_transform.rs index 971a95f7..d2ba20f3 100644 --- a/epaint/src/shape_transform.rs +++ b/epaint/src/shape_transform.rs @@ -24,8 +24,23 @@ pub fn adjust_colors(shape: &mut Shape, adjust_color: &impl Fn(&mut Color32)) { adjust_color(fill); adjust_color(&mut stroke.color); } - Shape::Text { color, .. } => { - adjust_color(color); + Shape::Text { + galley, + override_text_color, + .. + } => { + if let Some(override_text_color) = override_text_color { + adjust_color(override_text_color); + } + + if !galley.is_empty() { + let galley = std::sync::Arc::make_mut(galley); + for row in &mut galley.rows { + for vertex in &mut row.visuals.mesh.vertices { + adjust_color(&mut vertex.color); + } + } + } } Shape::Mesh(mesh) => { for v in &mut mesh.vertices { diff --git a/epaint/src/stats.rs b/epaint/src/stats.rs index ed16f56b..0e69dd38 100644 --- a/epaint/src/stats.rs +++ b/epaint/src/stats.rs @@ -85,13 +85,13 @@ impl AllocInfo { // } pub fn from_galley(galley: &Galley) -> Self { - Self::from_slice(galley.text.as_bytes()) + Self::from_slice(galley.text().as_bytes()) + Self::from_slice(&galley.rows) + galley.rows.iter().map(Self::from_galley_row).sum() } fn from_galley_row(row: &crate::text::Row) -> Self { - Self::from_slice(&row.x_offsets) + Self::from_slice(&row.uv_rects) + Self::from_mesh(&row.visuals.mesh) + Self::from_slice(&row.glyphs) } pub fn from_mesh(mesh: &Mesh) -> Self { diff --git a/epaint/src/stroke.rs b/epaint/src/stroke.rs index ee7faf3a..c60bc411 100644 --- a/epaint/src/stroke.rs +++ b/epaint/src/stroke.rs @@ -3,7 +3,7 @@ use super::*; /// Describes the width and color of a line. /// /// The default stroke is the same as [`Stroke::none`]. -#[derive(Clone, Copy, Debug, Default, PartialEq)] +#[derive(Clone, Copy, Debug, Default)] #[cfg_attr(feature = "persistence", derive(serde::Deserialize, serde::Serialize))] pub struct Stroke { pub width: f32, @@ -12,10 +12,12 @@ pub struct Stroke { impl Stroke { /// Same as [`Stroke::default`]. + #[inline(always)] pub fn none() -> Self { Self::new(0.0, Color32::TRANSPARENT) } + #[inline] pub fn new(width: impl Into, color: impl Into) -> Self { Self { width: width.into(), @@ -28,7 +30,26 @@ impl From<(f32, Color)> for Stroke where Color: Into, { + #[inline(always)] fn from((width, color): (f32, Color)) -> Stroke { Stroke::new(width, color) } } + +impl std::hash::Hash for Stroke { + #[inline(always)] + fn hash(&self, state: &mut H) { + let Self { width, color } = *self; + crate::f32_hash(state, width); + color.hash(state); + } +} + +impl PartialEq for Stroke { + #[inline(always)] + fn eq(&self, other: &Self) -> bool { + self.color == other.color && crate::f32_eq(self.width, other.width) + } +} + +impl std::cmp::Eq for Stroke {} diff --git a/epaint/src/tessellator.rs b/epaint/src/tessellator.rs index 4c775a09..ce5b17fb 100644 --- a/epaint/src/tessellator.rs +++ b/epaint/src/tessellator.rs @@ -12,7 +12,7 @@ use std::f32::consts::TAU; // ---------------------------------------------------------------------------- #[derive(Clone, Debug, Default)] -pub struct PathPoint { +struct PathPoint { pos: Pos2, /// For filled paths the normal is used for anti-aliasing (both strokes and filled areas). @@ -31,7 +31,7 @@ pub struct PathPoint { /// to either to a stroke (with thickness) or a filled convex area. /// Used as a scratch-pad during tessellation. #[derive(Clone, Debug, Default)] -struct Path(Vec); +pub struct Path(Vec); impl Path { #[inline(always)] @@ -150,6 +150,31 @@ impl Path { n0 = n1; } } + + /// Open-ended. + pub fn stroke_open(&self, stroke: Stroke, options: TessellationOptions, out: &mut Mesh) { + stroke_path(&self.0, PathType::Open, stroke, options, out) + } + + /// A closed path (returning to the first point). + pub fn stroke_closed(&self, stroke: Stroke, options: TessellationOptions, out: &mut Mesh) { + stroke_path(&self.0, PathType::Closed, stroke, options, out) + } + + pub fn stroke( + &self, + path_type: PathType, + stroke: Stroke, + options: TessellationOptions, + out: &mut Mesh, + ) { + stroke_path(&self.0, path_type, stroke, options, out) + } + + /// The path is taken to be closed (i.e. returning to the start again). + pub fn fill(&self, color: Color32, options: TessellationOptions, out: &mut Mesh) { + fill_closed_path(&self.0, color, options, out) + } } pub mod path { @@ -226,7 +251,6 @@ pub enum PathType { Open, Closed, } -use self::PathType::{Closed, Open}; /// Tessellation quality options #[derive(Clone, Copy, Debug, PartialEq)] @@ -265,6 +289,16 @@ impl Default for TessellationOptions { } } +impl TessellationOptions { + pub fn from_pixels_per_point(pixels_per_point: f32) -> Self { + Self { + pixels_per_point, + aa_size: 1.0 / pixels_per_point, + ..Default::default() + } + } +} + impl TessellationOptions { #[inline(always)] pub fn round_to_pixel(&self, point: f32) -> f32 { @@ -420,7 +454,11 @@ fn stroke_path( out.reserve_triangles(2 * n as usize); out.reserve_vertices(2 * n as usize); - let last_index = if path_type == Closed { n } else { n - 1 }; + let last_index = if path_type == PathType::Closed { + n + } else { + n - 1 + }; for i in 0..last_index { out.add_triangle( idx + (2 * i + 0) % (2 * n), @@ -519,11 +557,10 @@ impl Tessellator { return; } - let path = &mut self.scratchpad_path; - path.clear(); - path.add_circle(center, radius); - fill_closed_path(&path.0, fill, options, out); - stroke_path(&path.0, Closed, stroke, options, out); + self.scratchpad_path.clear(); + self.scratchpad_path.add_circle(center, radius); + self.scratchpad_path.fill(fill, options, out); + self.scratchpad_path.stroke_closed(stroke, options, out); } Shape::Mesh(mesh) => { if mesh.is_valid() { @@ -533,10 +570,9 @@ impl Tessellator { } } Shape::LineSegment { points, stroke } => { - let path = &mut self.scratchpad_path; - path.clear(); - path.add_line_segment(points); - stroke_path(&path.0, Open, stroke, options, out); + self.scratchpad_path.clear(); + self.scratchpad_path.add_line_segment(points); + self.scratchpad_path.stroke_open(stroke, options, out); } Shape::Path { points, @@ -545,12 +581,11 @@ impl Tessellator { stroke, } => { if points.len() >= 2 { - let path = &mut self.scratchpad_path; - path.clear(); + self.scratchpad_path.clear(); if closed { - path.add_line_loop(&points); + self.scratchpad_path.add_line_loop(&points); } else { - path.add_open_points(&points); + self.scratchpad_path.add_open_points(&points); } if fill != Color32::TRANSPARENT { @@ -558,10 +593,14 @@ impl Tessellator { closed, "You asked to fill a path that is not closed. That makes no sense." ); - fill_closed_path(&path.0, fill, options, out); + self.scratchpad_path.fill(fill, options, out); } - let typ = if closed { Closed } else { Open }; - stroke_path(&path.0, typ, stroke, options, out); + let typ = if closed { + PathType::Closed + } else { + PathType::Open + }; + self.scratchpad_path.stroke(typ, stroke, options, out); } } Shape::Rect { @@ -581,8 +620,8 @@ impl Tessellator { Shape::Text { pos, galley, - color, - fake_italics, + underline, + override_text_color, } => { if options.debug_paint_text_rects { self.tessellate_rect( @@ -590,12 +629,12 @@ impl Tessellator { rect: Rect::from_min_size(pos, galley.size).expand(0.5), corner_radius: 2.0, fill: Default::default(), - stroke: (0.5, color).into(), + stroke: (0.5, Color32::GREEN).into(), }, out, ); } - self.tessellate_text(tex_size, pos, &galley, color, fake_italics, out); + self.tessellate_text(tex_size, pos, &galley, underline, override_text_color, out); } } } @@ -626,107 +665,87 @@ impl Tessellator { path.clear(); path::rounded_rectangle(&mut self.scratchpad_points, rect, corner_radius); path.add_line_loop(&self.scratchpad_points); - fill_closed_path(&path.0, fill, self.options, out); - stroke_path(&path.0, Closed, stroke, self.options, out); + path.fill(fill, self.options, out); + path.stroke_closed(stroke, self.options, out); } pub fn tessellate_text( &mut self, tex_size: [usize; 2], - pos: Pos2, + galley_pos: Pos2, galley: &super::Galley, - color: Color32, - fake_italics: bool, + underline: Stroke, + override_text_color: Option, out: &mut Mesh, ) { - if color == Color32::TRANSPARENT || galley.is_empty() { + if galley.is_empty() { return; } - if cfg!(any( - feature = "extra_asserts", - all(feature = "extra_debug_asserts", debug_assertions), - )) { - galley.sanity_check(); - } + + out.vertices.reserve(galley.num_vertices); + out.indices.reserve(galley.num_indices); // The contents of the galley is already snapped to pixel coordinates, // but we need to make sure the galley ends up on the start of a physical pixel: - let pos = pos2( - self.options.round_to_pixel(pos.x), - self.options.round_to_pixel(pos.y), + let galley_pos = pos2( + self.options.round_to_pixel(galley_pos.x), + self.options.round_to_pixel(galley_pos.y), ); - let num_chars = galley.char_count_excluding_newlines(); - out.reserve_triangles(num_chars * 2); - out.reserve_vertices(num_chars * 4); - - let inv_tex_w = 1.0 / tex_size[0] as f32; - let inv_tex_h = 1.0 / tex_size[1] as f32; - - let clip_slack = 2.0; // Some fudge to handle letters that are slightly larger than expected. - let clip_rect_min_y = self.clip_rect.min.y - clip_slack; - let clip_rect_max_y = self.clip_rect.max.y + clip_slack; + let uv_normalizer = vec2(1.0 / tex_size[0] as f32, 1.0 / tex_size[1] as f32); for row in &galley.rows { - let row_min_y = pos.y + row.y_min; - let row_max_y = pos.y + row.y_max; - let is_line_visible = clip_rect_min_y <= row_max_y && row_min_y <= clip_rect_max_y; + if row.visuals.mesh.is_empty() { + continue; + } - if self.options.coarse_tessellation_culling && !is_line_visible { + let row_rect = row.visuals.mesh_bounds.translate(galley_pos.to_vec2()); + + if self.options.coarse_tessellation_culling && !self.clip_rect.intersects(row_rect) { // culling individual lines of text is important, since a single `Shape::Text` // can span hundreds of lines. continue; } - for (x_offset, uv_rect) in row.x_offsets.iter().zip(&row.uv_rects) { - if let Some(glyph) = uv_rect { - let mut left_top = pos + glyph.offset + vec2(*x_offset, row.y_min); - left_top.x = self.options.round_to_pixel(left_top.x); // Pixel-perfection. - left_top.y = self.options.round_to_pixel(left_top.y); // Pixel-perfection. + let index_offset = out.vertices.len() as u32; - let rect = Rect::from_min_max(left_top, left_top + glyph.size); - let uv = Rect::from_min_max( - pos2( - glyph.min.0 as f32 * inv_tex_w, - glyph.min.1 as f32 * inv_tex_h, - ), - pos2( - glyph.max.0 as f32 * inv_tex_w, - glyph.max.1 as f32 * inv_tex_h, - ), - ); + out.indices.extend( + row.visuals + .mesh + .indices + .iter() + .map(|index| index + index_offset), + ); - if fake_italics { - let idx = out.vertices.len() as u32; - out.add_triangle(idx, idx + 1, idx + 2); - out.add_triangle(idx + 2, idx + 1, idx + 3); + out.vertices.extend( + row.visuals + .mesh + .vertices + .iter() + .enumerate() + .map(|(i, vertex)| { + let mut color = vertex.color; - let top_offset = rect.height() * 0.25 * Vec2::X; + if let Some(override_text_color) = override_text_color { + if row.visuals.glyph_vertex_range.contains(&i) { + color = override_text_color; + } + } - out.vertices.push(Vertex { - pos: rect.left_top() + top_offset, - uv: uv.left_top(), + Vertex { + pos: galley_pos + vertex.pos.to_vec2(), + uv: (vertex.uv.to_vec2() * uv_normalizer).to_pos2(), color, - }); - out.vertices.push(Vertex { - pos: rect.right_top() + top_offset, - uv: uv.right_top(), - color, - }); - out.vertices.push(Vertex { - pos: rect.left_bottom(), - uv: uv.left_bottom(), - color, - }); - out.vertices.push(Vertex { - pos: rect.right_bottom(), - uv: uv.right_bottom(), - color, - }); - } else { - out.add_rect_with_uv(rect, uv, color); - } - } + } + }), + ); + + if underline != Stroke::none() { + self.scratchpad_path.clear(); + self.scratchpad_path + .add_line_segment([row_rect.left_bottom(), row_rect.right_bottom()]); + self.scratchpad_path + .stroke_open(underline, self.options, out); } } } diff --git a/epaint/src/text/font.rs b/epaint/src/text/font.rs index aba978e6..bac9401f 100644 --- a/epaint/src/text/font.rs +++ b/epaint/src/text/font.rs @@ -1,9 +1,6 @@ use crate::{ mutex::{Mutex, RwLock}, - text::{ - galley::{Galley, Row}, - TextStyle, - }, + text::TextStyle, TextureAtlas, }; use ahash::AHashMap; @@ -13,28 +10,37 @@ use std::sync::Arc; // ---------------------------------------------------------------------------- -#[derive(Clone, Copy, Debug, PartialEq)] +#[derive(Clone, Copy, Debug, Default, PartialEq)] pub struct UvRect { /// X/Y offset for nice rendering (unit: points). pub offset: Vec2, + + /// Screen size (in points) of this glyph. + /// Note that the height is different from the font height. pub size: Vec2, /// Top left corner UV in texture. - pub min: (u16, u16), + pub min: [u16; 2], /// Bottom right corner (exclusive). - pub max: (u16, u16), + pub max: [u16; 2], +} + +impl UvRect { + pub fn is_nothing(&self) -> bool { + self.min == self.max + } } #[derive(Clone, Copy, Debug)] pub struct GlyphInfo { - id: ab_glyph::GlyphId, + pub(crate) id: ab_glyph::GlyphId, /// Unit: points. pub advance_width: f32, /// Texture coordinates. None for space. - pub uv_rect: Option, + pub uv_rect: UvRect, } impl Default for GlyphInfo { @@ -42,7 +48,7 @@ impl Default for GlyphInfo { Self { id: ab_glyph::GlyphId(0), advance_width: 0.0, - uv_rect: None, + uv_rect: Default::default(), } } } @@ -161,6 +167,7 @@ impl FontImpl { } } + #[inline] pub fn pair_kerning( &self, last_glyph_id: ab_glyph::GlyphId, @@ -281,11 +288,12 @@ impl Font { self.row_height } - pub fn uv_rect(&self, c: char) -> Option { + pub fn uv_rect(&self, c: char) -> UvRect { self.glyph_info_cache .read() .get(&c) - .and_then(|gi| gi.1.uv_rect) + .map(|gi| gi.1.uv_rect) + .unwrap_or_default() } /// Width of this character in points. @@ -309,6 +317,13 @@ impl Font { font_index_glyph_info } + #[inline] + pub(crate) fn glyph_info_and_font_impl(&self, c: char) -> (&FontImpl, GlyphInfo) { + let (font_index, glyph_info) = self.glyph_info(c); + let font_impl = &self.fonts[font_index]; + (font_impl, glyph_info) + } + fn glyph_info_no_cache_or_fallback(&self, c: char) -> Option<(FontIndex, GlyphInfo)> { for (font_index, font_impl) in self.fonts.iter().enumerate() { if let Some(glyph_info) = font_impl.glyph_info(c) { @@ -320,325 +335,6 @@ impl Font { } None } - - /// Typeset the given text onto one row. - /// Assumes there are no `\n` in the text. - /// Return `x_offsets`, one longer than the number of characters in the text. - fn layout_single_row_fragment(&self, text: &str) -> Vec { - let mut x_offsets = Vec::with_capacity(text.chars().count() + 1); - x_offsets.push(0.0); - - let mut cursor_x_in_points = 0.0f32; - let mut last_glyph_id = None; - - for c in text.chars() { - if !self.fonts.is_empty() { - let (font_index, glyph_info) = self.glyph_info(c); - - let font_impl = &self.fonts[font_index]; - - if let Some(last_glyph_id) = last_glyph_id { - cursor_x_in_points += font_impl.pair_kerning(last_glyph_id, glyph_info.id) - } - cursor_x_in_points += glyph_info.advance_width; - cursor_x_in_points = self.round_to_pixel(cursor_x_in_points); - last_glyph_id = Some(glyph_info.id); - } - - x_offsets.push(cursor_x_in_points); - } - - x_offsets - } - - /// Typeset the given text onto one row. - /// Any `\n` will show up as the replacement character. - /// Always returns exactly one `Row` in the `Galley`. - /// - /// Most often you probably want `\n` to produce a new row, - /// and so [`Self::layout_no_wrap`] may be a better choice. - pub fn layout_single_line(&self, text: String) -> Galley { - let x_offsets = self.layout_single_row_fragment(&text); - let row = Row { - x_offsets, - uv_rects: vec![], // will be filled in later - y_min: 0.0, - y_max: self.row_height(), - ends_with_newline: false, - }; - let width = row.max_x(); - let size = vec2(width, self.row_height()); - let galley = Galley { - text_style: self.text_style, - text, - rows: vec![row], - size, - }; - self.finalize_galley(galley) - } - - /// Will line break at `\n`. - /// - /// Always returns at least one row. - pub fn layout_no_wrap(&self, text: String) -> Galley { - self.layout_multiline(text, f32::INFINITY) - } - - /// Will wrap text at the given width and line break at `\n`. - /// - /// Always returns at least one row. - pub fn layout_multiline(&self, text: String, max_width_in_points: f32) -> Galley { - self.layout_multiline_with_indentation_and_max_width(text, 0.0, max_width_in_points) - } - - /// * `first_row_indentation`: extra space before the very first character (in points). - /// * `max_width_in_points`: wrapping width. - /// - /// Always returns at least one row. - pub fn layout_multiline_with_indentation_and_max_width( - &self, - text: String, - first_row_indentation: f32, - max_width_in_points: f32, - ) -> Galley { - let row_height = self.row_height(); - let mut cursor_y = 0.0; - let mut rows = Vec::new(); - - let mut paragraph_start = 0; - - while paragraph_start < text.len() { - let next_newline = text[paragraph_start..].find('\n'); - let paragraph_end = next_newline - .map(|newline| paragraph_start + newline) - .unwrap_or_else(|| text.len()); - - assert!(paragraph_start <= paragraph_end); - let paragraph_text = &text[paragraph_start..paragraph_end]; - let line_indentation = if rows.is_empty() { - first_row_indentation - } else { - 0.0 - }; - let mut paragraph_rows = self.layout_paragraph_max_width( - paragraph_text, - line_indentation, - max_width_in_points, - ); - assert!(!paragraph_rows.is_empty()); - paragraph_rows.last_mut().unwrap().ends_with_newline = next_newline.is_some(); - - for row in &mut paragraph_rows { - row.y_min += cursor_y; - row.y_max += cursor_y; - } - cursor_y = paragraph_rows.last().unwrap().y_max; - - // cursor_y += row_height * 0.2; // Extra spacing between paragraphs. - - rows.append(&mut paragraph_rows); - - paragraph_start = paragraph_end + 1; - } - - if text.is_empty() { - rows.push(Row { - x_offsets: vec![first_row_indentation], - uv_rects: vec![], - y_min: cursor_y, - y_max: cursor_y + row_height, - ends_with_newline: false, - }); - } else if text.ends_with('\n') { - rows.push(Row { - x_offsets: vec![0.0], - uv_rects: vec![], - y_min: cursor_y, - y_max: cursor_y + row_height, - ends_with_newline: false, - }); - } - - let mut widest_row = 0.0; - for row in &rows { - widest_row = row.max_x().max(widest_row); - } - let size = vec2(widest_row, rows.last().unwrap().y_max); - - let text_style = self.text_style; - let galley = Galley { - text_style, - text, - rows, - size, - }; - self.finalize_galley(galley) - } - - /// A paragraph is text with no line break character in it. - /// The text will be wrapped by the given `max_width_in_points`. - /// Always returns at least one row. - fn layout_paragraph_max_width( - &self, - text: &str, - mut first_row_indentation: f32, - max_width_in_points: f32, - ) -> Vec { - if text.is_empty() { - return vec![Row { - x_offsets: vec![first_row_indentation], - uv_rects: vec![], - y_min: 0.0, - y_max: self.row_height(), - ends_with_newline: false, - }]; - } - - let full_x_offsets = self.layout_single_row_fragment(text); - - let mut row_start_x = 0.0; // NOTE: BEFORE the `first_row_indentation`. - - let mut cursor_y = 0.0; - let mut row_start_idx = 0; - - // Keeps track of good places to insert row break if we exceed `max_width_in_points`. - let mut row_break_candidates = RowBreakCandidates::default(); - - let mut out_rows = vec![]; - - for (i, (x, chr)) in full_x_offsets.iter().skip(1).zip(text.chars()).enumerate() { - crate::epaint_assert!(chr != '\n'); - let potential_row_width = first_row_indentation + x - row_start_x; - - if potential_row_width > max_width_in_points { - let is_first_row = out_rows.is_empty(); - if is_first_row - && first_row_indentation > 0.0 - && !row_break_candidates.has_word_boundary() - { - // Allow the first row to be completely empty, because we know there will be more space on the next row: - assert_eq!(row_start_idx, 0); - out_rows.push(Row { - x_offsets: vec![first_row_indentation], - uv_rects: vec![], - y_min: cursor_y, - y_max: cursor_y + self.row_height(), - ends_with_newline: false, - }); - cursor_y = self.round_to_pixel(cursor_y + self.row_height()); - first_row_indentation = 0.0; // Continue all other rows as if there is no indentation - } else if let Some(last_kept_index) = row_break_candidates.get() { - out_rows.push(Row { - x_offsets: full_x_offsets[row_start_idx..=last_kept_index + 1] - .iter() - .map(|x| first_row_indentation + x - row_start_x) - .collect(), - uv_rects: vec![], // Will be filled in later! - y_min: cursor_y, - y_max: cursor_y + self.row_height(), - ends_with_newline: false, - }); - - row_start_idx = last_kept_index + 1; - row_start_x = first_row_indentation + full_x_offsets[row_start_idx]; - row_break_candidates = Default::default(); - cursor_y = self.round_to_pixel(cursor_y + self.row_height()); - } - } - - row_break_candidates.add(i, chr); - } - - if row_start_idx + 1 < full_x_offsets.len() { - out_rows.push(Row { - x_offsets: full_x_offsets[row_start_idx..] - .iter() - .map(|x| first_row_indentation + x - row_start_x) - .collect(), - uv_rects: vec![], // Will be filled in later! - y_min: cursor_y, - y_max: cursor_y + self.row_height(), - ends_with_newline: false, - }); - } - - out_rows - } - - fn finalize_galley(&self, mut galley: Galley) -> Galley { - let mut chars = galley.text.chars(); - for row in &mut galley.rows { - row.uv_rects.clear(); - row.uv_rects.reserve(row.char_count_excluding_newline()); - for _ in 0..row.char_count_excluding_newline() { - let c = chars.next().unwrap(); - row.uv_rects.push(self.uv_rect(c)); - } - if row.ends_with_newline { - let newline = chars.next().unwrap(); - assert_eq!(newline, '\n'); - } - } - assert_eq!(chars.next(), None); - galley.sanity_check(); - galley - } -} - -/// Keeps track of good places to break a long row of text. -/// Will focus primarily on spaces, secondarily on things like `-` -#[derive(Clone, Copy, Default)] -struct RowBreakCandidates { - /// Breaking at ` ` or other whitespace - /// is always the primary candidate. - space: Option, - /// Logogram (single character representing a whole word) are good candidates for line break. - logogram: Option, - /// Breaking at a dash is super- - /// good idea. - dash: Option, - /// This is nicer for things like URLs, e.g. www. - /// example.com. - punctuation: Option, - /// Breaking after just random character is some - /// times necessary. - any: Option, -} - -impl RowBreakCandidates { - fn add(&mut self, index: usize, chr: char) { - const NON_BREAKING_SPACE: char = '\u{A0}'; - if chr.is_whitespace() && chr != NON_BREAKING_SPACE { - self.space = Some(index); - } else if is_chinese(chr) { - self.logogram = Some(index); - } else if chr == '-' { - self.dash = Some(index); - } else if chr.is_ascii_punctuation() { - self.punctuation = Some(index); - } else { - 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) - } -} - -#[inline] -fn is_chinese(c: char) -> bool { - ('\u{4E00}' <= c && c <= '\u{9FFF}') - || ('\u{3400}' <= c && c <= '\u{4DBF}') - || ('\u{2B740}' <= c && c <= '\u{2B81F}') } #[inline] @@ -663,12 +359,12 @@ fn allocate_glyph( let glyph = glyph_id.with_scale_and_position(scale_in_pixels, ab_glyph::Point { x: 0.0, y: 0.0 }); - let uv_rect = font.outline_glyph(glyph).and_then(|glyph| { + let uv_rect = font.outline_glyph(glyph).map(|glyph| { let bb = glyph.px_bounds(); let glyph_width = bb.width() as usize; let glyph_height = bb.height() as usize; if glyph_width == 0 || glyph_height == 0 { - None + UvRect::default() } else { let glyph_pos = atlas.allocate((glyph_width, glyph_height)); @@ -683,17 +379,18 @@ fn allocate_glyph( let offset_in_pixels = vec2(bb.min.x as f32, scale_in_pixels as f32 + bb.min.y as f32); let offset = offset_in_pixels / pixels_per_point + y_offset * Vec2::Y; - Some(UvRect { + UvRect { offset, size: vec2(glyph_width as f32, glyph_height as f32) / pixels_per_point, - min: (glyph_pos.0 as u16, glyph_pos.1 as u16), - max: ( + min: [glyph_pos.0 as u16, glyph_pos.1 as u16], + max: [ (glyph_pos.0 + glyph_width) as u16, (glyph_pos.1 + glyph_height) as u16, - ), - }) + ], + } } }); + let uv_rect = uv_rect.unwrap_or_default(); let advance_width_in_points = font.as_scaled(scale_in_pixels).h_advance(glyph_id) / pixels_per_point; diff --git a/epaint/src/text/fonts.rs b/epaint/src/text/fonts.rs index 3afaae0b..ae737cc5 100644 --- a/epaint/src/text/fonts.rs +++ b/epaint/src/text/fonts.rs @@ -10,7 +10,7 @@ use crate::{ mutex::Mutex, text::{ font::{Font, FontImpl}, - Galley, + Galley, LayoutJob, }, Texture, TextureAtlas, }; @@ -315,69 +315,58 @@ impl Fonts { self.fonts[&text_style].row_height() } - /// Will line break at `\n`. + /// Layout some text. + /// This is the most advanced layout function. + /// See also [`Self::layout`], [`Self::layout_no_wrap`] and + /// [`Self::layout_delayed_color`]. /// - /// Always returns at least one row. - pub fn layout_no_wrap(&self, text_style: TextStyle, text: String) -> Arc { - self.layout_multiline(text_style, text, f32::INFINITY) - } - - /// Typeset the given text onto one row. - /// Any `\n` will show up as the replacement character. - /// Always returns exactly one `Row` in the `Galley`. - /// - /// Most often you probably want `\n` to produce a new row, - /// and so [`Self::layout_no_wrap`] may be a better choice. - pub fn layout_single_line(&self, text_style: TextStyle, text: String) -> Arc { - self.galley_cache.lock().layout( - &self.fonts, - LayoutJob { - text_style, - text, - layout_params: LayoutParams::SingleLine, - }, - ) + /// The implementation uses memoization so repeated calls are cheap. + pub fn layout_job(&self, job: impl Into>) -> Arc { + self.galley_cache.lock().layout(self, job.into()) } /// Will wrap text at the given width and line break at `\n`. /// - /// Always returns at least one row. - pub fn layout_multiline( + /// The implementation uses memoization so repeated calls are cheap. + pub fn layout( &self, - text_style: TextStyle, text: String, - max_width_in_points: f32, + text_style: TextStyle, + color: crate::Color32, + wrap_width: f32, ) -> Arc { - self.layout_multiline_with_indentation_and_max_width( - text_style, - text, - 0.0, - max_width_in_points, - ) + let job = LayoutJob::simple(text, text_style, color, wrap_width); + self.layout_job(job) } - /// * `first_row_indentation`: extra space before the very first character (in points). - /// * `max_width_in_points`: wrapping width. + /// Will line break at `\n`. /// - /// Always returns at least one row. - pub fn layout_multiline_with_indentation_and_max_width( + /// The implementation uses memoization so repeated calls are cheap. + pub fn layout_no_wrap( &self, - text_style: TextStyle, text: String, - first_row_indentation: f32, - max_width_in_points: f32, + text_style: TextStyle, + color: crate::Color32, ) -> Arc { - self.galley_cache.lock().layout( - &self.fonts, - LayoutJob { - text_style, - text, - layout_params: LayoutParams::Multiline { - first_row_indentation: first_row_indentation.into(), - max_width_in_points: max_width_in_points.into(), - }, - }, - ) + let job = LayoutJob::simple(text, text_style, color, f32::INFINITY); + self.layout_job(job) + } + + /// Like [`Self::layout`], made for when you want to pick a color for the text later. + /// + /// The implementation uses memoization so repeated calls are cheap. + pub fn layout_delayed_color( + &self, + text: String, + text_style: TextStyle, + wrap_width: f32, + ) -> Arc { + self.layout_job(LayoutJob::simple( + text, + text_style, + crate::Color32::TEMPORARY_COLOR, + wrap_width, + )) } pub fn num_galleys_in_cache(&self) -> usize { @@ -386,7 +375,7 @@ impl Fonts { /// Must be called once per frame to clear the [`Galley`] cache. pub fn end_frame(&self) { - self.galley_cache.lock().end_frame() + self.galley_cache.lock().end_frame(); } } @@ -401,22 +390,6 @@ impl std::ops::Index for Fonts { // ---------------------------------------------------------------------------- -#[derive(Clone, Copy, Eq, PartialEq, Hash)] -enum LayoutParams { - SingleLine, - Multiline { - first_row_indentation: ordered_float::OrderedFloat, - max_width_in_points: ordered_float::OrderedFloat, - }, -} - -#[derive(Clone, Eq, PartialEq, Hash)] -struct LayoutJob { - text_style: TextStyle, - layout_params: LayoutParams, - text: String, -} - struct CachedGalley { /// When it was last used last_used: u32, @@ -427,41 +400,26 @@ struct CachedGalley { struct GalleyCache { /// Frame counter used to do garbage collection on the cache generation: u32, - cache: AHashMap, + cache: AHashMap, CachedGalley>, } impl GalleyCache { - fn layout(&mut self, fonts: &BTreeMap, job: LayoutJob) -> Arc { - if let Some(cached) = self.cache.get_mut(&job) { - cached.last_used = self.generation; - cached.galley.clone() - } else { - let LayoutJob { - text_style, - layout_params, - text, - } = job.clone(); - let font = &fonts[&text_style]; - let galley = match layout_params { - LayoutParams::SingleLine => font.layout_single_line(text), - LayoutParams::Multiline { - first_row_indentation, - max_width_in_points, - } => font.layout_multiline_with_indentation_and_max_width( - text, - first_row_indentation.into_inner(), - max_width_in_points.into_inner(), - ), - }; - let galley = Arc::new(galley); - self.cache.insert( - job, - CachedGalley { + fn layout(&mut self, fonts: &Fonts, job: Arc) -> Arc { + match self.cache.entry(job.clone()) { + std::collections::hash_map::Entry::Occupied(entry) => { + let cached = entry.into_mut(); + cached.last_used = self.generation; + cached.galley.clone() + } + std::collections::hash_map::Entry::Vacant(entry) => { + let galley = super::layout(fonts, job); + let galley = Arc::new(galley); + entry.insert(CachedGalley { last_used: self.generation, galley: galley.clone(), - }, - ); - galley + }); + galley + } } } diff --git a/epaint/src/text/mod.rs b/epaint/src/text/mod.rs index 9aab626a..34897b72 100644 --- a/epaint/src/text/mod.rs +++ b/epaint/src/text/mod.rs @@ -3,14 +3,16 @@ pub mod cursor; mod font; mod fonts; -mod galley; +mod text_layout; +mod text_layout_types; /// One `\t` character is this many spaces wide. pub const TAB_SIZE: usize = 4; pub use { fonts::{FontDefinitions, FontFamily, Fonts, TextStyle}, - galley::{Galley, Row}, + text_layout::layout, + text_layout_types::*, }; /// Suggested character to use to replace those in password text fields. diff --git a/epaint/src/text/text_layout.rs b/epaint/src/text/text_layout.rs new file mode 100644 index 00000000..c9732506 --- /dev/null +++ b/epaint/src/text/text_layout.rs @@ -0,0 +1,538 @@ +use std::ops::RangeInclusive; +use std::sync::Arc; + +use super::{Fonts, Galley, Glyph, LayoutJob, LayoutSection, Row, RowVisuals}; +use crate::{Color32, Mesh, Stroke, Vertex}; +use emath::*; + +/// Temporary storage before line-wrapping. +#[derive(Default, Clone)] +struct Paragraph { + /// Start of the next glyph to be added. + pub cursor_x: f32, + pub glyphs: Vec, + /// In case of an empty paragraph ("\n"), use this as height. + pub empty_paragraph_height: f32, +} + +/// Layout text into a [`Galley`]. +/// +/// In most cases you should use [`Fonts::layout_job`] instead +/// since that memoizes the input, making subsequent layouting of the same text much faster. +pub fn layout(fonts: &Fonts, job: Arc) -> Galley { + 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); + } + + let rows = rows_from_paragraphs(paragraphs, job.wrap_width); + + galley_from_rows(fonts, job, rows) +} + +fn layout_section( + fonts: &Fonts, + job: &LayoutJob, + section_index: u32, + section: &LayoutSection, + out_paragraphs: &mut Vec, +) { + let LayoutSection { + leading_space, + byte_range, + format, + } = section; + let font = &fonts[format.style]; + let font_height = font.row_height(); + + let mut paragraph = out_paragraphs.last_mut().unwrap(); + if paragraph.glyphs.is_empty() { + paragraph.empty_paragraph_height = font_height; // TODO: replace this hack with actually including `\n` in the glyphs? + } + + paragraph.cursor_x += leading_space; + + let mut last_glyph_id = None; + + for chr in job.text[byte_range.clone()].chars() { + if job.break_on_newline && chr == '\n' { + out_paragraphs.push(Paragraph::default()); + paragraph = out_paragraphs.last_mut().unwrap(); + paragraph.empty_paragraph_height = font_height; // TODO: replace this hack with actually including `\n` in the glyphs? + } else { + let (font_impl, glyph_info) = font.glyph_info_and_font_impl(chr); + if let Some(last_glyph_id) = last_glyph_id { + paragraph.cursor_x += font_impl.pair_kerning(last_glyph_id, glyph_info.id) + } + + paragraph.glyphs.push(Glyph { + chr, + pos: pos2(paragraph.cursor_x, f32::NAN), + size: vec2(glyph_info.advance_width, font_height), + uv_rect: glyph_info.uv_rect, + section_index, + }); + + paragraph.cursor_x += glyph_info.advance_width; + paragraph.cursor_x = font.round_to_pixel(paragraph.cursor_x); + last_glyph_id = Some(glyph_info.id); + } + } +} + +/// We ignore y at this stage +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 { + let num_paragraphs = paragraphs.len(); + + let mut rows = vec![]; + + for (i, paragraph) in paragraphs.into_iter().enumerate() { + let is_last_paragraph = (i + 1) == num_paragraphs; + + if paragraph.glyphs.is_empty() { + rows.push(Row { + glyphs: vec![], + visuals: Default::default(), + rect: Rect::from_min_size( + pos2(paragraph.cursor_x, 0.0), + vec2(0.0, paragraph.empty_paragraph_height), + ), + ends_with_newline: !is_last_paragraph, + }); + } else { + let paragraph_max_x = paragraph.glyphs.last().unwrap().max_x(); + if paragraph_max_x <= wrap_width { + // early-out optimization + let paragraph_min_x = paragraph.glyphs[0].pos.x; + rows.push(Row { + glyphs: paragraph.glyphs, + visuals: Default::default(), + rect: rect_from_x_range(paragraph_min_x..=paragraph_max_x), + ends_with_newline: !is_last_paragraph, + }); + } else { + line_break(¶graph, wrap_width, &mut rows); + rows.last_mut().unwrap().ends_with_newline = !is_last_paragraph; + } + } + } + + rows +} + +fn line_break(paragraph: &Paragraph, wrap_width: f32, 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; + + for (i, glyph) in paragraph.glyphs.iter().enumerate() { + let potential_row_width = glyph.pos.x - row_start_x; + + if potential_row_width > wrap_width { + if first_row_indentation > 0.0 && !row_break_candidates.has_word_boundary() { + // 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 { + 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() { + 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(); + } else { + // Found no place to break, so we have to overrun wrap_width. + } + } + + row_break_candidates.add(i, glyph.chr); + } + + 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(); + + 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, + }); + } +} + +/// 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 max_x: f32 = 0.0; + for row in &mut rows { + let mut row_height = first_row_min_height.max(row.rect.height()); + first_row_min_height = 0.0; + for glyph in &row.glyphs { + row_height = row_height.max(glyph.size.y); + } + row_height = fonts.round_to_pixel(row_height); + + // Now positions each glyph: + for glyph in &mut row.glyphs { + let format = &job.sections[glyph.section_index as usize].format; + glyph.pos.y = cursor_y + format.valign.to_factor() * (row_height - glyph.size.y); + glyph.pos.y = fonts.round_to_pixel(glyph.pos.y); + } + + row.rect.min.y = cursor_y; + row.rect.max.y = cursor_y + row_height; + + max_x = max_x.max(row.rect.right()); + cursor_y += row_height; + cursor_y = fonts.round_to_pixel(cursor_y); + } + + let format_summary = format_summary(&job); + + let mut num_vertices = 0; + let mut num_indices = 0; + + for row in &mut rows { + row.visuals = tessellate_row(fonts, &job, &format_summary, row); + num_vertices += row.visuals.mesh.vertices.len(); + num_indices += row.visuals.mesh.indices.len(); + } + + let size = vec2(max_x, cursor_y); + + Galley { + job, + rows, + size, + num_vertices, + num_indices, + } +} + +#[derive(Default)] +struct FormatSummary { + any_background: bool, + any_underline: bool, + any_strikethrough: bool, +} + +fn format_summary(job: &LayoutJob) -> FormatSummary { + let mut format_summary = FormatSummary::default(); + for section in &job.sections { + format_summary.any_background |= section.format.background != Color32::TRANSPARENT; + format_summary.any_underline |= section.format.underline != Stroke::none(); + format_summary.any_strikethrough |= section.format.strikethrough != Stroke::none(); + } + format_summary +} + +fn tessellate_row( + fonts: &Fonts, + job: &LayoutJob, + format_summary: &FormatSummary, + row: &mut Row, +) -> RowVisuals { + if row.glyphs.is_empty() { + return Default::default(); + } + + let mut mesh = Mesh::default(); + + mesh.reserve_triangles(row.glyphs.len() * 2); + mesh.reserve_vertices(row.glyphs.len() * 4); + + if format_summary.any_background { + add_row_backgrounds(job, row, &mut mesh); + } + + let glyph_vertex_start = mesh.vertices.len(); + tessellate_glyphs(fonts, job, row, &mut mesh); + let glyph_vertex_end = mesh.vertices.len(); + + if format_summary.any_underline { + add_row_hline(fonts, row, &mut mesh, |glyph| { + let format = &job.sections[glyph.section_index as usize].format; + let stroke = format.underline; + let y = glyph.logical_rect().bottom(); + (stroke, y) + }); + } + + if format_summary.any_strikethrough { + add_row_hline(fonts, row, &mut mesh, |glyph| { + let format = &job.sections[glyph.section_index as usize].format; + let stroke = format.strikethrough; + let y = glyph.logical_rect().center().y; + (stroke, y) + }); + } + + let mesh_bounds = mesh.calc_bounds(); + + RowVisuals { + mesh, + mesh_bounds, + glyph_vertex_range: glyph_vertex_start..glyph_vertex_end, + } +} + +/// Create background for glyphs that have them. +/// Creates as few rectangular regions as possible. +fn add_row_backgrounds(job: &LayoutJob, row: &Row, mesh: &mut Mesh) { + if row.glyphs.is_empty() { + return; + } + + let mut end_run = |start: Option<(Color32, Rect)>, stop_x: f32| { + if let Some((color, start_rect)) = start { + let rect = Rect::from_min_max(start_rect.left_top(), pos2(stop_x, start_rect.bottom())); + let rect = rect.expand(1.0); // looks better + mesh.add_colored_rect(rect, color); + } + }; + + let mut run_start = None; + let mut last_rect = Rect::NAN; + + for glyph in &row.glyphs { + let format = &job.sections[glyph.section_index as usize].format; + let color = format.background; + let rect = glyph.logical_rect(); + + if color == Color32::TRANSPARENT { + end_run(run_start.take(), last_rect.right()); + } else if let Some((existing_color, start)) = run_start { + if existing_color == color + && start.top() == rect.top() + && start.bottom() == rect.bottom() + { + // continue the same background rectangle + } else { + end_run(run_start.take(), last_rect.right()); + run_start = Some((color, rect)); + } + } else { + run_start = Some((color, rect)); + } + + last_rect = rect; + } + + end_run(run_start.take(), last_rect.right()); +} + +fn tessellate_glyphs(fonts: &Fonts, job: &LayoutJob, row: &Row, mesh: &mut Mesh) { + for glyph in &row.glyphs { + let uv_rect = glyph.uv_rect; + if !uv_rect.is_nothing() { + let mut left_top = glyph.pos + uv_rect.offset; + left_top.x = fonts.round_to_pixel(left_top.x); + left_top.y = fonts.round_to_pixel(left_top.y); + + let rect = Rect::from_min_max(left_top, left_top + uv_rect.size); + let uv = Rect::from_min_max( + pos2(uv_rect.min[0] as f32, uv_rect.min[1] as f32), + pos2(uv_rect.max[0] as f32, uv_rect.max[1] as f32), + ); + + let format = &job.sections[glyph.section_index as usize].format; + + let color = format.color; + + if format.italics { + let idx = mesh.vertices.len() as u32; + mesh.add_triangle(idx, idx + 1, idx + 2); + mesh.add_triangle(idx + 2, idx + 1, idx + 3); + + let top_offset = rect.height() * 0.25 * Vec2::X; + + mesh.vertices.push(Vertex { + pos: rect.left_top() + top_offset, + uv: uv.left_top(), + color, + }); + mesh.vertices.push(Vertex { + pos: rect.right_top() + top_offset, + uv: uv.right_top(), + color, + }); + mesh.vertices.push(Vertex { + pos: rect.left_bottom(), + uv: uv.left_bottom(), + color, + }); + mesh.vertices.push(Vertex { + pos: rect.right_bottom(), + uv: uv.right_bottom(), + color, + }); + } else { + mesh.add_rect_with_uv(rect, uv, color); + } + } + } +} + +/// Add a horizontal line over a row of glyphs with a stroke and y decided by a callback. +fn add_row_hline( + fonts: &Fonts, + row: &Row, + mesh: &mut Mesh, + stroke_and_y: impl Fn(&Glyph) -> (Stroke, f32), +) { + let mut end_line = |start: Option<(Stroke, Pos2)>, stop_x: f32| { + if let Some((stroke, start)) = start { + add_hline(fonts, [start, pos2(stop_x, start.y)], stroke, mesh); + } + }; + + let mut line_start = None; + let mut last_right_x = f32::NAN; + + for glyph in &row.glyphs { + let (stroke, y) = stroke_and_y(glyph); + + if stroke == Stroke::none() { + end_line(line_start.take(), last_right_x); + } else if let Some((existing_stroke, start)) = line_start { + if existing_stroke == stroke && start.y == y { + // continue the same line + } else { + end_line(line_start.take(), last_right_x); + line_start = Some((stroke, pos2(glyph.pos.x, y))); + } + } else { + line_start = Some((stroke, pos2(glyph.pos.x, y))); + } + + last_right_x = glyph.max_x(); + } + + end_line(line_start.take(), last_right_x); +} + +fn add_hline(fonts: &Fonts, [start, stop]: [Pos2; 2], stroke: Stroke, mesh: &mut Mesh) { + let antialiased = true; + + if antialiased { + let mut path = crate::tessellator::Path::default(); // TODO: reuse this to avoid re-allocations. + path.add_line_segment([start, stop]); + let options = crate::tessellator::TessellationOptions::from_pixels_per_point( + fonts.pixels_per_point(), + ); + path.stroke_open(stroke, options, mesh); + } else { + // Thin lines often lost, so this is a bad idea + + assert_eq!(start.y, stop.y); + + let min_y = fonts.round_to_pixel(start.y - 0.5 * stroke.width); + let max_y = fonts.round_to_pixel(min_y + stroke.width); + + let rect = Rect::from_min_max( + pos2(fonts.round_to_pixel(start.x), min_y), + pos2(fonts.round_to_pixel(stop.x), max_y), + ); + + mesh.add_colored_rect(rect, stroke.color); + } +} + +// ---------------------------------------------------------------------------- + +/// Keeps track of good places to break a long row of text. +/// Will focus primarily on spaces, secondarily on things like `-` +#[derive(Clone, Copy, Default)] +struct RowBreakCandidates { + /// Breaking at ` ` or other whitespace + /// is always the primary candidate. + space: Option, + /// Logograms (single character representing a whole word) are good candidates for line break. + logogram: Option, + /// Breaking at a dash is a super- + /// good idea. + dash: Option, + /// This is nicer for things like URLs, e.g. www. + /// example.com. + punctuation: Option, + /// Breaking after just random character is some + /// times necessary. + any: Option, +} + +impl RowBreakCandidates { + fn add(&mut self, index: usize, chr: char) { + const NON_BREAKING_SPACE: char = '\u{A0}'; + if chr.is_whitespace() && chr != NON_BREAKING_SPACE { + self.space = Some(index); + } else if is_chinese(chr) { + self.logogram = Some(index); + } else if chr == '-' { + self.dash = Some(index); + } else if chr.is_ascii_punctuation() { + self.punctuation = Some(index); + } else { + 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) + } +} + +#[inline] +fn is_chinese(c: char) -> bool { + ('\u{4E00}' <= c && c <= '\u{9FFF}') + || ('\u{3400}' <= c && c <= '\u{4DBF}') + || ('\u{2B740}' <= c && c <= '\u{2B81F}') +} diff --git a/epaint/src/text/galley.rs b/epaint/src/text/text_layout_types.rs similarity index 54% rename from epaint/src/text/galley.rs rename to epaint/src/text/text_layout_types.rs index 29697814..b38352b3 100644 --- a/epaint/src/text/galley.rs +++ b/epaint/src/text/text_layout_types.rs @@ -1,35 +1,224 @@ -//! A [`Galley`] is a piece of text after layout, i.e. where each character has been assigned a position. -//! -//! ## How it works -//! This is going to get complicated. -//! -//! To avoid confusion, we never use the word "line". -//! The `\n` character demarcates the split of text into "paragraphs". -//! Each paragraph is wrapped at some width onto one or more "rows". -//! -//! If this cursors sits right at the border of a wrapped row break (NOT paragraph break) -//! do we prefer the next row? -//! For instance, consider this single paragraph, word wrapped: -//! ``` text -//! Hello_ -//! world! -//! ``` -//! -//! The offset `6` is both the end of the first row -//! and the start of the second row. -//! [`CCursor::prefer_next_row`] etc selects which. +use std::ops::Range; +use std::sync::Arc; use super::{cursor::*, font::UvRect}; -use emath::{pos2, NumExt, Rect, Vec2}; +use crate::{Color32, Mesh, Stroke, TextStyle}; +use emath::*; + +/// Describes the task of laying out text. +/// +/// This supports mixing different fonts, color and formats (underline etc). +/// +/// Pass this to [`Fonts::layout_job]` or [`crate::text::layout`]. +#[derive(Clone, Debug)] +pub struct LayoutJob { + /// The complete text of this job, referenced by `LayoutSection`. + pub text: String, // TODO: Cow<'static, str> + + /// 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, + + /// The first row must be at least this high. + /// This is in case we lay out text that is the continuation + /// of some earlier text (sharing the same row), + /// in which case this will be the height of the earlier text. + /// In other cases, set this to `0.0`. + pub first_row_min_height: f32, + + /// If `false`, all newlines characters will be ignored + /// and show up as the replacement character. + /// Default: `true`. + pub break_on_newline: bool, + // TODO: option to show whitespace characters +} + +impl Default for LayoutJob { + #[inline] + fn default() -> Self { + Self { + text: Default::default(), + sections: Default::default(), + wrap_width: f32::INFINITY, + first_row_min_height: 0.0, + break_on_newline: true, + } + } +} + +impl LayoutJob { + /// Break on `\n` and at the given wrap width. + #[inline] + pub fn simple(text: String, text_style: TextStyle, color: Color32, wrap_width: f32) -> Self { + Self { + sections: vec![LayoutSection { + leading_space: 0.0, + byte_range: 0..text.len(), + format: TextFormat::simple(text_style, color), + }], + text, + wrap_width, + break_on_newline: true, + ..Default::default() + } + } + + /// Does not break on `\n`, but shows the replacement character instead. + #[inline] + pub fn simple_singleline(text: String, text_style: TextStyle, color: Color32) -> Self { + Self { + sections: vec![LayoutSection { + leading_space: 0.0, + byte_range: 0..text.len(), + format: TextFormat::simple(text_style, color), + }], + text, + wrap_width: f32::INFINITY, + break_on_newline: false, + ..Default::default() + } + } + + #[inline(always)] + pub fn is_empty(&self) -> bool { + self.sections.is_empty() + } + + /// Helper for adding a new section when building a `LayoutJob`. + pub fn append(&mut self, text: &str, leading_space: f32, format: TextFormat) { + let start = self.text.len(); + self.text += text; + let byte_range = start..self.text.len(); + self.sections.push(LayoutSection { + leading_space, + byte_range, + format, + }); + } +} + +impl std::hash::Hash for LayoutJob { + #[inline] + fn hash(&self, state: &mut H) { + let Self { + text, + sections, + wrap_width, + first_row_min_height, + break_on_newline, + } = self; + + text.hash(state); + sections.hash(state); + crate::f32_hash(state, *wrap_width); + crate::f32_hash(state, *first_row_min_height); + break_on_newline.hash(state); + } +} + +impl PartialEq for LayoutJob { + #[inline(always)] + fn eq(&self, other: &Self) -> bool { + self.text == other.text + && self.sections == other.sections + && crate::f32_eq(self.wrap_width, other.wrap_width) + && crate::f32_eq(self.first_row_min_height, other.first_row_min_height) + && self.break_on_newline == other.break_on_newline + } +} + +impl std::cmp::Eq for LayoutJob {} + +// ---------------------------------------------------------------------------- + +#[derive(Clone, Debug)] +pub struct LayoutSection { + /// Can be used for first row indentation. + pub leading_space: f32, + /// Range into the galley text + pub byte_range: Range, + pub format: TextFormat, +} + +impl std::hash::Hash for LayoutSection { + #[inline] + fn hash(&self, state: &mut H) { + let Self { + leading_space, + byte_range, + format, + } = self; + crate::f32_hash(state, *leading_space); + byte_range.hash(state); + format.hash(state); + } +} + +impl PartialEq for LayoutSection { + #[inline(always)] + fn eq(&self, other: &Self) -> bool { + crate::f32_eq(self.leading_space, other.leading_space) + && self.byte_range == other.byte_range + && self.format == other.format + } +} + +impl std::cmp::Eq for LayoutSection {} + +// ---------------------------------------------------------------------------- + +#[derive(Copy, Clone, Debug, Hash, Eq, PartialEq)] +pub struct TextFormat { + pub style: TextStyle, + /// Text color + pub color: Color32, + pub background: Color32, + pub italics: bool, + pub underline: Stroke, + pub strikethrough: Stroke, + /// If you use a small font and [`Align::TOP`] you + /// can get the effect of raised text. + pub valign: Align, + // TODO: lowered +} + +impl Default for TextFormat { + #[inline] + fn default() -> Self { + Self { + style: TextStyle::Body, + color: Color32::GRAY, + background: Color32::TRANSPARENT, + italics: false, + underline: Stroke::none(), + strikethrough: Stroke::none(), + valign: Align::BOTTOM, + } + } +} + +impl TextFormat { + #[inline] + pub fn simple(style: TextStyle, color: Color32) -> Self { + Self { + style, + color, + ..Default::default() + } + } +} + +// ---------------------------------------------------------------------------- -/// A collection of text locked into place. #[derive(Clone, Debug, PartialEq)] pub struct Galley { - /// The [`crate::TextStyle`] (font) used. - pub text_style: crate::TextStyle, - - /// The full text, including any an all `\n`. - pub text: String, + /// The job that this galley is the result of. + /// Contains the original string and style sections. + pub job: Arc, /// Rows of text, from top to bottom. /// The number of chars in all rows sum up to text.chars().count(). @@ -37,88 +226,123 @@ pub struct Galley { /// can be split up into multiple rows. pub rows: Vec, - // Optimization: calculated once and reused. + /// Bounding size (min is always `[0,0]`) pub size: Vec2, + + /// 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, } -/// A typeset piece of text on a single row. #[derive(Clone, Debug, PartialEq)] pub struct Row { - /// The start of each character, probably starting at zero. - /// The last element is the end of the last character. - /// This is never empty. - /// Unit: points. - /// - /// `x_offsets.len() + (ends_with_newline as usize) == text.chars().count() + 1` - pub x_offsets: Vec, + /// One for each `char`. + pub glyphs: Vec, - /// Per-character. Used when rendering. - pub uv_rects: Vec>, + /// Logical bounding rectangle based on font heights etc. + /// Use this when drawing a selection or similar! + /// Includes leading and trailing whitespace. + pub rect: Rect, - /// Top of the row, offset within the Galley. - /// Unit: points. - pub y_min: f32, - - /// Bottom of the row, offset within the Galley. - /// Unit: points. - pub y_max: f32, + /// The mesh, ready to be rendered. + pub visuals: RowVisuals, /// If true, this `Row` came from a paragraph ending with a `\n`. - /// The `\n` itself is omitted from `x_offsets`. + /// The `\n` itself is omitted from [`Self::glyphs`]. /// A `\n` in the input text always creates a new `Row` below it, /// so that text that ends with `\n` has an empty `Row` last. /// This also implies that the last `Row` in a `Galley` always has `ends_with_newline == false`. pub ends_with_newline: bool, } -impl Row { - #[inline] - pub fn sanity_check(&self) { - assert!(!self.x_offsets.is_empty()); - assert!(self.x_offsets.len() == self.uv_rects.len() + 1); +/// The tessellated output of a row. +#[derive(Clone, Debug, PartialEq)] +pub struct RowVisuals { + /// The tessellated text, using non-normalized (texel) UV coordinates. + /// That is, you need to divide the uv coordinates by the texture size. + pub mesh: Mesh, + + /// Bounds of the mesh, and can be used for culling. + /// Does NOT include leading or trailing whitespace glyphs!! + pub mesh_bounds: Rect, + + /// The range of vertices in the mesh the contain glyphs. + /// Before comes backgrounds (if any), and after any underlines and strikethrough. + pub glyph_vertex_range: Range, +} + +impl Default for RowVisuals { + fn default() -> Self { + Self { + mesh: Default::default(), + mesh_bounds: Rect::NOTHING, + glyph_vertex_range: 0..0, + } + } +} + +#[derive(Copy, Clone, Debug, PartialEq)] +pub struct Glyph { + pub chr: char, + /// Relative to the galley position. + /// Logical position: pos.y is the same for all chars of the same [`TextFormat`]. + pub pos: Pos2, + /// Advance width and font row height. + pub size: Vec2, + /// Position of the glyph in the font texture. + pub uv_rect: UvRect, + /// Index into [`LayoutJob::sections`]. Decides color etc. + pub section_index: u32, +} + +impl Glyph { + pub fn max_x(&self) -> f32 { + self.pos.x + self.size.x } + /// Same y range for all characters with the same [`TextFormat`]. + #[inline] + pub fn logical_rect(&self) -> Rect { + Rect::from_min_size(self.pos, self.size) + } +} + +// ---------------------------------------------------------------------------- + +impl Row { /// Excludes the implicit `\n` after the `Row`, if any. #[inline] pub fn char_count_excluding_newline(&self) -> usize { - assert!(!self.x_offsets.is_empty()); - self.x_offsets.len() - 1 + self.glyphs.len() } /// Includes the implicit `\n` after the `Row`, if any. #[inline] pub fn char_count_including_newline(&self) -> usize { - self.char_count_excluding_newline() + (self.ends_with_newline as usize) + self.glyphs.len() + (self.ends_with_newline as usize) } #[inline] - pub fn min_x(&self) -> f32 { - *self.x_offsets.first().unwrap() + pub fn min_y(&self) -> f32 { + self.rect.top() } #[inline] - pub fn max_x(&self) -> f32 { - *self.x_offsets.last().unwrap() + pub fn max_y(&self) -> f32 { + self.rect.bottom() } #[inline] pub fn height(&self) -> f32 { - self.y_max - self.y_min - } - - pub fn rect(&self) -> Rect { - Rect::from_min_max( - pos2(self.min_x(), self.y_min), - pos2(self.max_x(), self.y_max), - ) + self.rect.height() } /// Closest char at the desired x coordinate. /// Returns something in the range `[0, char_count_excluding_newline()]`. pub fn char_at(&self, desired_x: f32) -> usize { - for (i, char_x_bounds) in self.x_offsets.windows(2).enumerate() { - let char_center_x = 0.5 * (char_x_bounds[0] + char_x_bounds[1]); - if desired_x < char_center_x { + for (i, glyph) in self.glyphs.iter().enumerate() { + if desired_x < glyph.logical_rect().center().x { return i; } } @@ -126,56 +350,35 @@ impl Row { } pub fn x_offset(&self, column: usize) -> f32 { - self.x_offsets[column.min(self.x_offsets.len() - 1)] - } - - // Move down this much - #[inline(always)] - pub fn translate_y(&mut self, dy: f32) { - self.y_min += dy; - self.y_max += dy; + if let Some(glyph) = self.glyphs.get(column) { + glyph.pos.x + } else { + self.rect.right() + } } } impl Galley { #[inline(always)] pub fn is_empty(&self) -> bool { - self.text.is_empty() + self.job.is_empty() } #[inline(always)] - pub(crate) fn char_count_excluding_newlines(&self) -> usize { - let mut char_count = 0; - for row in &self.rows { - char_count += row.char_count_excluding_newline(); - } - char_count - } - - pub fn sanity_check(&self) { - let mut char_count = 0; - for row in &self.rows { - row.sanity_check(); - char_count += row.char_count_including_newline(); - } - crate::epaint_assert!(char_count == self.text.chars().count()); - if let Some(last_row) = self.rows.last() { - crate::epaint_assert!( - !last_row.ends_with_newline, - "If the text ends with '\\n', there would be an empty row last.\n\ - Galley: {:#?}", - self - ); - } + pub fn text(&self) -> &str { + &self.job.text } } +// ---------------------------------------------------------------------------- + /// ## Physical positions impl Galley { + /// Zero-width rect past the last character. fn end_pos(&self) -> Rect { if let Some(row) = self.rows.last() { - let x = row.max_x(); - Rect::from_min_max(pos2(x, row.y_min), pos2(x, row.y_max)) + let x = row.rect.right(); + Rect::from_min_max(pos2(x, row.min_y()), pos2(x, row.max_y())) } else { // Empty galley Rect::from_min_max(pos2(0.0, 0.0), pos2(0.0, 0.0)) @@ -201,7 +404,7 @@ impl Galley { && column >= row.char_count_excluding_newline(); if !select_next_row_instead { let x = row.x_offset(column); - return Rect::from_min_max(pos2(x, row.y_min), pos2(x, row.y_max)); + return Rect::from_min_max(pos2(x, row.min_y()), pos2(x, row.max_y())); } } } @@ -219,7 +422,7 @@ impl Galley { /// Returns a 0-width Rect. pub fn pos_from_cursor(&self, cursor: &Cursor) -> Rect { - self.pos_from_pcursor(cursor.pcursor) // The one TextEdit stores + self.pos_from_pcursor(cursor.pcursor) // pcursor is what TextEdit stores } /// Cursor at the given position within the galley @@ -231,8 +434,8 @@ impl Galley { let mut pcursor_it = PCursor::default(); for (row_nr, row) in self.rows.iter().enumerate() { - let is_pos_within_row = pos.y >= row.y_min && pos.y <= row.y_max; - let y_dist = (row.y_min - pos.y).abs().min((row.y_max - pos.y).abs()); + let is_pos_within_row = pos.y >= row.min_y() && pos.y <= row.max_y(); + let y_dist = (row.min_y() - pos.y).abs().min((row.max_y() - pos.y).abs()); if is_pos_within_row || y_dist < best_y_dist { best_y_dist = y_dist; let column = row.char_at(pos.x); @@ -515,7 +718,7 @@ impl Galley { } else { // keep same X coord let x = self.pos_from_cursor(cursor).center().x; - let column = if x > self.rows[new_row].max_x() { + let column = if x > self.rows[new_row].rect.right() { // beyond the end of this row - keep same colum cursor.rcursor.column } else { @@ -546,7 +749,7 @@ impl Galley { } else { // keep same X coord let x = self.pos_from_cursor(cursor).center().x; - let column = if x > self.rows[new_row].max_x() { + let column = if x > self.rows[new_row].rect.right() { // beyond the end of the next row - keep same column cursor.rcursor.column } else { @@ -578,244 +781,3 @@ impl Galley { }) } } - -// ---------------------------------------------------------------------------- - -#[test] -fn test_text_layout() { - impl PartialEq for Cursor { - fn eq(&self, other: &Cursor) -> bool { - (self.ccursor, self.rcursor, self.pcursor) - == (other.ccursor, other.rcursor, other.pcursor) - } - } - - use crate::*; - - let pixels_per_point = 1.0; - let fonts = text::Fonts::from_definitions(pixels_per_point, text::FontDefinitions::default()); - let font = &fonts[TextStyle::Monospace]; - - let galley = font.layout_multiline("".to_owned(), 1024.0); - assert_eq!(galley.rows.len(), 1); - assert!(!galley.rows[0].ends_with_newline); - assert_eq!(galley.rows[0].x_offsets, vec![0.0]); - - let galley = font.layout_multiline("\n".to_owned(), 1024.0); - assert_eq!(galley.rows.len(), 2); - assert!(galley.rows[0].ends_with_newline); - assert!(!galley.rows[1].ends_with_newline); - assert_eq!(galley.rows[1].x_offsets, vec![0.0]); - - let galley = font.layout_multiline("\n\n".to_owned(), 1024.0); - assert_eq!(galley.rows.len(), 3); - assert!(galley.rows[0].ends_with_newline); - assert!(galley.rows[1].ends_with_newline); - assert!(!galley.rows[2].ends_with_newline); - assert_eq!(galley.rows[2].x_offsets, vec![0.0]); - - let galley = font.layout_multiline(" ".to_owned(), 1024.0); - assert_eq!(galley.rows.len(), 1); - assert!(!galley.rows[0].ends_with_newline); - - let galley = font.layout_multiline("One row!".to_owned(), 1024.0); - assert_eq!(galley.rows.len(), 1); - assert!(!galley.rows[0].ends_with_newline); - - let galley = font.layout_multiline("First row!\n".to_owned(), 1024.0); - assert_eq!(galley.rows.len(), 2); - assert!(galley.rows[0].ends_with_newline); - assert!(!galley.rows[1].ends_with_newline); - assert_eq!(galley.rows[1].x_offsets, vec![0.0]); - - let galley = font.layout_multiline("line\nbreak".to_owned(), 40.0); - assert_eq!(galley.rows.len(), 2); - assert!(galley.rows[0].ends_with_newline); - assert!(!galley.rows[1].ends_with_newline); - - // Test wrapping: - let galley = font.layout_multiline("word wrap".to_owned(), 40.0); - assert_eq!(galley.rows.len(), 2); - assert!(!galley.rows[0].ends_with_newline); - assert!(!galley.rows[1].ends_with_newline); - - { - // Test wrapping: - let galley = font.layout_multiline("word wrap.\nNew para.".to_owned(), 40.0); - assert_eq!(galley.rows.len(), 4); - assert!(!galley.rows[0].ends_with_newline); - assert_eq!(galley.rows[0].char_count_excluding_newline(), "word ".len()); - assert_eq!(galley.rows[0].char_count_including_newline(), "word ".len()); - assert!(galley.rows[1].ends_with_newline); - assert_eq!(galley.rows[1].char_count_excluding_newline(), "wrap.".len()); - assert_eq!( - galley.rows[1].char_count_including_newline(), - "wrap.\n".len() - ); - assert_eq!(galley.rows[2].char_count_excluding_newline(), "New ".len()); - assert_eq!(galley.rows[3].char_count_excluding_newline(), "para.".len()); - assert!(!galley.rows[2].ends_with_newline); - assert!(!galley.rows[3].ends_with_newline); - - let cursor = Cursor::default(); - assert_eq!(cursor, galley.from_ccursor(cursor.ccursor)); - assert_eq!(cursor, galley.from_rcursor(cursor.rcursor)); - assert_eq!(cursor, galley.from_pcursor(cursor.pcursor)); - - let cursor = galley.end(); - assert_eq!(cursor, galley.from_ccursor(cursor.ccursor)); - assert_eq!(cursor, galley.from_rcursor(cursor.rcursor)); - assert_eq!(cursor, galley.from_pcursor(cursor.pcursor)); - assert_eq!( - cursor, - Cursor { - ccursor: CCursor::new(20), - rcursor: RCursor { row: 3, column: 5 }, - pcursor: PCursor { - paragraph: 1, - offset: 9, - prefer_next_row: false, - } - } - ); - - let cursor = galley.from_ccursor(CCursor::new(1)); - assert_eq!(cursor.rcursor, RCursor { row: 0, column: 1 }); - assert_eq!( - cursor.pcursor, - PCursor { - paragraph: 0, - offset: 1, - prefer_next_row: false, - } - ); - assert_eq!(cursor, galley.from_ccursor(cursor.ccursor)); - assert_eq!(cursor, galley.from_rcursor(cursor.rcursor)); - assert_eq!(cursor, galley.from_pcursor(cursor.pcursor)); - - let cursor = galley.from_pcursor(PCursor { - paragraph: 1, - offset: 2, - prefer_next_row: false, - }); - assert_eq!(cursor.rcursor, RCursor { row: 2, column: 2 }); - assert_eq!(cursor, galley.from_ccursor(cursor.ccursor)); - assert_eq!(cursor, galley.from_rcursor(cursor.rcursor)); - assert_eq!(cursor, galley.from_pcursor(cursor.pcursor)); - - let cursor = galley.from_pcursor(PCursor { - paragraph: 1, - offset: 6, - prefer_next_row: false, - }); - assert_eq!(cursor.rcursor, RCursor { row: 3, column: 2 }); - assert_eq!(cursor, galley.from_ccursor(cursor.ccursor)); - assert_eq!(cursor, galley.from_rcursor(cursor.rcursor)); - assert_eq!(cursor, galley.from_pcursor(cursor.pcursor)); - - // On the border between two rows within the same paragraph: - let cursor = galley.from_rcursor(RCursor { row: 0, column: 5 }); - assert_eq!( - cursor, - Cursor { - ccursor: CCursor::new(5), - rcursor: RCursor { row: 0, column: 5 }, - pcursor: PCursor { - paragraph: 0, - offset: 5, - prefer_next_row: false, - } - } - ); - assert_eq!(cursor, galley.from_rcursor(cursor.rcursor)); - - let cursor = galley.from_rcursor(RCursor { row: 1, column: 0 }); - assert_eq!( - cursor, - Cursor { - ccursor: CCursor::new(5), - rcursor: RCursor { row: 1, column: 0 }, - pcursor: PCursor { - paragraph: 0, - offset: 5, - prefer_next_row: false, - } - } - ); - assert_eq!(cursor, galley.from_rcursor(cursor.rcursor)); - } - - { - // Test cursor movement: - let galley = font.layout_multiline("word wrap.\nNew para.".to_owned(), 40.0); - assert_eq!(galley.rows.len(), 4); - assert!(!galley.rows[0].ends_with_newline); - assert!(galley.rows[1].ends_with_newline); - assert!(!galley.rows[2].ends_with_newline); - assert!(!galley.rows[3].ends_with_newline); - - let cursor = Cursor::default(); - - assert_eq!(galley.cursor_up_one_row(&cursor), cursor); - assert_eq!(galley.cursor_begin_of_row(&cursor), cursor); - - assert_eq!( - galley.cursor_end_of_row(&cursor), - Cursor { - ccursor: CCursor::new(5), - rcursor: RCursor { row: 0, column: 5 }, - pcursor: PCursor { - paragraph: 0, - offset: 5, - prefer_next_row: false, - } - } - ); - - assert_eq!( - galley.cursor_down_one_row(&cursor), - Cursor { - ccursor: CCursor::new(5), - rcursor: RCursor { row: 1, column: 0 }, - pcursor: PCursor { - paragraph: 0, - offset: 5, - prefer_next_row: false, - } - } - ); - - let cursor = Cursor::default(); - assert_eq!( - galley.cursor_down_one_row(&galley.cursor_down_one_row(&cursor)), - Cursor { - ccursor: CCursor::new(11), - rcursor: RCursor { row: 2, column: 0 }, - pcursor: PCursor { - paragraph: 1, - offset: 0, - prefer_next_row: false, - } - } - ); - - let cursor = galley.end(); - assert_eq!(galley.cursor_down_one_row(&cursor), cursor); - - let cursor = galley.end(); - assert!(galley.cursor_up_one_row(&galley.end()) != cursor); - - assert_eq!( - galley.cursor_up_one_row(&galley.end()), - Cursor { - ccursor: CCursor::new(15), - rcursor: RCursor { row: 2, column: 5 }, - pcursor: PCursor { - paragraph: 1, - offset: 4, - prefer_next_row: false, - } - } - ); - } -}