From d3a3e4fa736235e9dfc19586a235bc6e4974f3a1 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Sat, 16 May 2020 17:28:15 +0200 Subject: [PATCH] Refactor text layout: fewer allocations --- emigui/README.md | 2 +- emigui/src/containers/resize.rs | 5 + emigui/src/examples/app.rs | 6 +- emigui/src/font.rs | 175 +++++++++++++++++++------------- emigui/src/widgets.rs | 2 - 5 files changed, 115 insertions(+), 75 deletions(-) diff --git a/emigui/README.md b/emigui/README.md index 57646d7f..7508fdbb 100644 --- a/emigui/README.md +++ b/emigui/README.md @@ -22,7 +22,7 @@ This is the core library crate Emigui. It is fully platform independent without * [x] Scroll-wheel input * [x] Drag background to scroll * [ ] Kinetic scrolling -* [x] Add support for clicking links +* [x] Add support for clicking hyperlinks * [x] Menu bar (File, Edit, etc) * [ ] Sub-menus * [ ] Keyboard shortcuts diff --git a/emigui/src/containers/resize.rs b/emigui/src/containers/resize.rs index 76f597c1..354569f4 100644 --- a/emigui/src/containers/resize.rs +++ b/emigui/src/containers/resize.rs @@ -47,6 +47,11 @@ impl Default for Resize { } impl Resize { + pub fn default_width(mut self, width: f32) -> Self { + self.default_size.x = width; + self + } + pub fn default_height(mut self, height: f32) -> Self { self.default_size.y = height; self diff --git a/emigui/src/examples/app.rs b/emigui/src/examples/app.rs index 6193d9d2..7f69f42d 100644 --- a/emigui/src/examples/app.rs +++ b/emigui/src/examples/app.rs @@ -120,9 +120,9 @@ impl OpenWindows { fn show_menu_bar(ui: &mut Ui, windows: &mut OpenWindows) { menu::bar(ui, |ui| { menu::menu(ui, "File", |ui| { - ui.add(Button::new("Do nothing")); - ui.add(Button::new("Carry on")); - ui.add(Button::new("Don't Quit")); + if ui.add(Button::new("Clear memory")).clicked { + *ui.ctx().memory() = Default::default(); + } }); menu::menu(ui, "Windows", |ui| { ui.add(Checkbox::new(&mut windows.examples, "Examples")); diff --git a/emigui/src/font.rs b/emigui/src/font.rs index 7fca9b3d..4700d71e 100644 --- a/emigui/src/font.rs +++ b/emigui/src/font.rs @@ -8,9 +8,12 @@ use crate::{ texture_atlas::TextureAtlas, }; -/// A typeset piece of text on a single line. Could be a whole line, or just a word. +/// A typeset piece of text on a single line. pub struct Fragment { - /// The start of each character, starting at zero. + /// The start of each character, probably starting at zero. + /// The last element is the end of the last character. + /// x_offsets.len() == text.chars().count() + 1 + /// This is never empty. /// Unit: points. pub x_offsets: Vec, @@ -18,7 +21,8 @@ pub struct Fragment { /// Unit: points. pub y_offset: f32, - /// The actual characters + // TODO: make this a str reference into a String owned by Galley + /// The actual characters. pub text: String, } @@ -41,7 +45,9 @@ impl Fragment { // } /// A collection of text locked into place. +#[derive(Default)] pub struct Galley { + // TODO: maybe rename/refactor this as `lines`? pub fragments: Vec, pub size: Vec2, } @@ -192,17 +198,13 @@ impl Font { ); } - /// Returns the a single line of characters separated into words - /// Always returns at least one frament. - fn layout_words(&self, text: &str) -> Galley { + /// Typeset the given text onto one line. + /// Assumes there are no \n in the text. + /// Return x_offsets, one longer than the number of characters in the text. + fn layout_single_line_fragment(&self, text: &str) -> Vec { let scale_in_pixels = Scale::uniform(self.scale_in_pixels); - let mut current_fragment = Fragment { - x_offsets: vec![0.0], - y_offset: 0.0, - text: String::new(), - }; - let mut fragments = vec![]; + let mut x_offsets = vec![0.0]; let mut cursor_x_in_points = 0.0f32; let mut last_glyph_id = None; @@ -217,84 +219,118 @@ impl Font { cursor_x_in_points += glyph.advance_width; cursor_x_in_points = self.round_to_pixel(cursor_x_in_points); last_glyph_id = Some(glyph.id); - - let is_space = glyph.uv_rect.is_none(); - if is_space { - // TODO: also break after hyphens etc - if !current_fragment.text.is_empty() { - fragments.push(current_fragment); - current_fragment = Fragment { - x_offsets: vec![cursor_x_in_points], - y_offset: 0.0, - text: String::new(), - } - } - // TODO: add a fragment for the space aswell - } else { - current_fragment.text.push(c); - current_fragment.x_offsets.push(cursor_x_in_points); - } } else { // Ignore unknown glyph } + x_offsets.push(cursor_x_in_points); } - if !current_fragment.text.is_empty() { - fragments.push(current_fragment) - } - - let width = if fragments.is_empty() { - 0.0 - } else { - fragments.last().unwrap().max_x() - }; - - let size = vec2(width, self.height()); - - Galley { fragments, size } + x_offsets } /// Typeset the given text onto one line. - /// Always returns at least one frament. + /// Assumes there are no \n in the text. + /// Always returns exactly one frament. pub fn layout_single_line(&self, text: &str) -> Galley { - // TODO: return a single Fragment instead of calling layout_words - // saves a lot of allocations - self.layout_words(text) + let x_offsets = self.layout_single_line_fragment(text); + let fragment = Fragment { + x_offsets, + y_offset: 0.0, + text: text.to_owned(), + }; + assert_eq!(fragment.x_offsets.len(), fragment.text.chars().count() + 1); + let width = fragment.max_x(); + let size = vec2(width, self.height()); + Galley { + fragments: vec![fragment], + size, + } } /// A paragraph is text with no line break character in it. - /// The text will be linebreaked by the given max_width_in_points. + /// The text will be linebreaked by the given `max_width_in_points`. /// TODO: return Galley ? pub fn layout_paragraph_max_width( &self, text: &str, max_width_in_points: f32, ) -> Vec { - let mut galley = self.layout_words(text); - if galley.fragments.is_empty() || galley.size.x <= max_width_in_points { - return galley.fragments; // Early-out - } + let full_x_offsets = self.layout_single_line_fragment(text); - let line_spacing = self.line_spacing(); - - // Break up lines: - let mut line_start_x = 0.0; + let mut line_start_x = full_x_offsets[0]; + assert_eq!(line_start_x, 0.0); let mut cursor_y = 0.0; + let mut line_start_idx = 0; - for word in galley.fragments.iter_mut().skip(1) { - if word.max_x() - line_start_x >= max_width_in_points { - // Time for a new line: - cursor_y += line_spacing; - line_start_x = word.min_x(); + // start index of the last space. A candidate for a new line. + let mut last_space = None; + + let mut out_fragments = vec![]; + + for (i, (x, chr)) in full_x_offsets.iter().skip(1).zip(text.chars()).enumerate() { + let line_width = x - line_start_x; + + if line_width > max_width_in_points { + if let Some(last_space_idx) = last_space { + let include_trailing_space = true; + let fragment = if include_trailing_space { + Fragment { + x_offsets: full_x_offsets[line_start_idx..=last_space_idx + 1] + .iter() + .map(|x| x - line_start_x) + .collect(), + y_offset: cursor_y, + text: text + .chars() + .skip(line_start_idx) + .take(last_space_idx + 1 - line_start_idx) + .collect(), + } + } else { + Fragment { + x_offsets: full_x_offsets[line_start_idx..=last_space_idx] + .iter() + .map(|x| x - line_start_x) + .collect(), + y_offset: cursor_y, + text: text + .chars() + .skip(line_start_idx) + .take(last_space_idx - line_start_idx) + .collect(), + } + }; + assert_eq!(fragment.x_offsets.len(), fragment.text.chars().count() + 1); + out_fragments.push(fragment); + + line_start_idx = last_space_idx + 1; + line_start_x = full_x_offsets[line_start_idx]; + last_space = None; + cursor_y += self.line_spacing(); + cursor_y = self.round_to_pixel(cursor_y); + } } - word.y_offset += cursor_y; - for x in &mut word.x_offsets { - *x -= line_start_x; + const NON_BREAKING_SPACE: char = '\u{A0}'; + if chr.is_whitespace() && chr != NON_BREAKING_SPACE { + last_space = Some(i); } } - galley.fragments + if line_start_idx + 1 < full_x_offsets.len() { + let fragment = Fragment { + x_offsets: full_x_offsets[line_start_idx..] + .iter() + .map(|x| x - line_start_x) + .collect(), + y_offset: cursor_y, + text: text.chars().skip(line_start_idx).collect(), + }; + assert_eq!(fragment.x_offsets.len(), fragment.text.chars().count() + 1); + out_fragments.push(fragment); + } + + out_fragments } pub fn layout_multiline(&self, text: &str, max_width_in_points: f32) -> Galley { @@ -302,13 +338,14 @@ impl Font { let mut cursor_y = 0.0; let mut fragments = Vec::new(); for line in text.split('\n') { - let mut line_fragments = self.layout_paragraph_max_width(line, max_width_in_points); - if let Some(last_word) = line_fragments.last() { - let line_height = last_word.y_offset + line_spacing; - for fragment in &mut line_fragments { + let mut paragraph_fragments = + self.layout_paragraph_max_width(line, max_width_in_points); + if let Some(last_fragment) = paragraph_fragments.last() { + let line_height = last_fragment.y_offset + line_spacing; + for fragment in &mut paragraph_fragments { fragment.y_offset += cursor_y; } - fragments.append(&mut line_fragments); + fragments.append(&mut paragraph_fragments); cursor_y += line_height; // TODO: add extra spacing between paragraphs } else { cursor_y += line_spacing; diff --git a/emigui/src/widgets.rs b/emigui/src/widgets.rs index d84e54e4..e0d357fa 100644 --- a/emigui/src/widgets.rs +++ b/emigui/src/widgets.rs @@ -149,7 +149,6 @@ impl Widget for Hyperlink { let id = ui.make_child_id(&self.url); let font = &ui.fonts()[text_style]; let line_spacing = font.line_spacing(); - // TODO: underline let galley = font.layout_multiline(&self.text, ui.available().width()); let interact = ui.reserve_space(galley.size, Some(id)); if interact.hovered { @@ -161,7 +160,6 @@ impl Widget for Hyperlink { if interact.hovered { // Underline: - // TODO: underline spaces between words too. for fragment in &galley.fragments { let pos = interact.rect.min; let y = pos.y + fragment.y_offset + line_spacing;