diff --git a/CHANGELOG.md b/CHANGELOG.md index 4642da637f5..b6733405cfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1230,7 +1230,7 @@ egui_extras::install_image_loaders(egui_ctx); * [Tweaked the default visuals style](https://github.com/emilk/egui/pull/450). * Plot: Renamed `Curve` to `Line`. * `TopPanel::top` is now `TopBottomPanel::top`. -* `SidePanel::left` no longet takes the default width by argument, but by a builder call. +* `SidePanel::left` no longer takes the default width by argument, but by a builder call. * `SidePanel::left` is resizable by default. ### 🐛 Fixed diff --git a/Cargo.lock b/Cargo.lock index 7e11391f1c2..3fdd454dcdd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1345,6 +1345,7 @@ dependencies = [ "egui", "egui_extras", "egui_kittest", + "rand", "serde", "unicode_names2", "wgpu", diff --git a/crates/egui/src/text_selection/accesskit_text.rs b/crates/egui/src/text_selection/accesskit_text.rs index d0c3869038d..2197bb6b178 100644 --- a/crates/egui/src/text_selection/accesskit_text.rs +++ b/crates/egui/src/text_selection/accesskit_text.rs @@ -43,7 +43,7 @@ pub fn update_accesskit_for_text_widget( let row_id = parent_id.with(row_index); ctx.accesskit_node_builder(row_id, |builder| { builder.set_role(accesskit::Role::TextRun); - let rect = row.rect.translate(galley_pos.to_vec2()); + let rect = row.rect().translate(galley_pos.to_vec2()); builder.set_bounds(accesskit::Rect { x0: rect.min.x.into(), y0: rect.min.y.into(), @@ -74,14 +74,14 @@ pub fn update_accesskit_for_text_widget( let old_len = value.len(); value.push(glyph.chr); character_lengths.push((value.len() - old_len) as _); - character_positions.push(glyph.pos.x - row.rect.min.x); + character_positions.push(glyph.pos.x - row.pos.x); character_widths.push(glyph.advance_width); } if row.ends_with_newline { value.push('\n'); character_lengths.push(1); - character_positions.push(row.rect.max.x - row.rect.min.x); + character_positions.push(row.size.x); character_widths.push(0.0); } word_lengths.push((character_lengths.len() - last_word_start) as _); diff --git a/crates/egui/src/text_selection/label_text_selection.rs b/crates/egui/src/text_selection/label_text_selection.rs index fe5eac00e78..41210b3718c 100644 --- a/crates/egui/src/text_selection/label_text_selection.rs +++ b/crates/egui/src/text_selection/label_text_selection.rs @@ -179,7 +179,10 @@ impl LabelSelectionState { if let epaint::Shape::Text(text_shape) = &mut shape.shape { let galley = Arc::make_mut(&mut text_shape.galley); for row_selection in row_selections { - if let Some(row) = galley.rows.get_mut(row_selection.row) { + if let Some(placed_row) = + galley.rows.get_mut(row_selection.row) + { + let row = Arc::make_mut(&mut placed_row.row); for vertex_index in row_selection.vertex_indices { if let Some(vertex) = row .visuals @@ -659,8 +662,8 @@ fn selected_text(galley: &Galley, cursor_range: &CursorRange) -> String { } fn estimate_row_height(galley: &Galley) -> f32 { - if let Some(row) = galley.rows.first() { - row.rect.height() + if let Some(placed_row) = galley.rows.first() { + placed_row.height() } else { galley.size().y } diff --git a/crates/egui/src/text_selection/visuals.rs b/crates/egui/src/text_selection/visuals.rs index dd7c867a222..81865d9b38a 100644 --- a/crates/egui/src/text_selection/visuals.rs +++ b/crates/egui/src/text_selection/visuals.rs @@ -31,11 +31,12 @@ pub fn paint_text_selection( let max = max.rcursor; for ri in min.row..=max.row { - let row = &mut galley.rows[ri]; + let row = Arc::make_mut(&mut galley.rows[ri].row); + let left = if ri == min.row { row.x_offset(min.column) } else { - row.rect.left() + 0.0 }; let right = if ri == max.row { row.x_offset(max.column) @@ -45,10 +46,10 @@ pub fn paint_text_selection( } else { 0.0 }; - row.rect.right() + newline_size + row.size.x + newline_size }; - let rect = Rect::from_min_max(pos2(left, row.min_y()), pos2(right, row.max_y())); + let rect = Rect::from_min_max(pos2(left, 0.0), pos2(right, row.size.y)); let mesh = &mut row.visuals.mesh; // Time to insert the selection rectangle into the row mesh. diff --git a/crates/egui/src/widget_text.rs b/crates/egui/src/widget_text.rs index d5cd16f09fd..56ddc502eed 100644 --- a/crates/egui/src/widget_text.rs +++ b/crates/egui/src/widget_text.rs @@ -645,8 +645,8 @@ impl WidgetText { Self::RichText(text) => text.font_height(fonts, style), Self::LayoutJob(job) => job.font_height(fonts), Self::Galley(galley) => { - if let Some(row) = galley.rows.first() { - row.height().round_ui() + if let Some(placed_row) = galley.rows.first() { + placed_row.height().round_ui() } else { galley.size().y.round_ui() } diff --git a/crates/egui/src/widgets/label.rs b/crates/egui/src/widgets/label.rs index b6ade45ae30..d542940c631 100644 --- a/crates/egui/src/widgets/label.rs +++ b/crates/egui/src/widgets/label.rs @@ -1,8 +1,8 @@ use std::sync::Arc; use crate::{ - epaint, pos2, text_selection, vec2, Align, Direction, FontSelection, Galley, Pos2, Response, - Sense, Stroke, TextWrapMode, Ui, Widget, WidgetInfo, WidgetText, WidgetType, + epaint, pos2, text_selection, Align, Direction, FontSelection, Galley, Pos2, Response, Sense, + Stroke, TextWrapMode, Ui, Widget, WidgetInfo, WidgetText, WidgetType, }; use self::text_selection::LabelSelectionState; @@ -194,10 +194,10 @@ impl Label { let pos = pos2(ui.max_rect().left(), ui.cursor().top()); assert!(!galley.rows.is_empty(), "Galleys are never empty"); // collect a response from many rows: - let rect = galley.rows[0].rect.translate(vec2(pos.x, pos.y)); + let rect = galley.rows[0].rect().translate(pos.to_vec2()); 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)); + for placed_row in galley.rows.iter().skip(1) { + let rect = placed_row.rect().translate(pos.to_vec2()); response |= ui.allocate_rect(rect, sense); } (pos, galley, response) diff --git a/crates/egui_demo_lib/Cargo.toml b/crates/egui_demo_lib/Cargo.toml index b494e18a99b..38e3cfba182 100644 --- a/crates/egui_demo_lib/Cargo.toml +++ b/crates/egui_demo_lib/Cargo.toml @@ -59,6 +59,7 @@ criterion.workspace = true egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } wgpu = { workspace = true, features = ["metal"] } egui = { workspace = true, features = ["default_fonts"] } +rand = "0.8" [[bench]] name = "benchmark" diff --git a/crates/egui_demo_lib/benches/benchmark.rs b/crates/egui_demo_lib/benches/benchmark.rs index d3820603d5c..6c25646d257 100644 --- a/crates/egui_demo_lib/benches/benchmark.rs +++ b/crates/egui_demo_lib/benches/benchmark.rs @@ -1,7 +1,10 @@ +use std::fmt::Write as _; + use criterion::{criterion_group, criterion_main, Criterion}; use egui::epaint::TextShape; use egui_demo_lib::LOREM_IPSUM_LONG; +use rand::Rng as _; pub fn criterion_benchmark(c: &mut Criterion) { use egui::RawInput; @@ -122,6 +125,30 @@ pub fn criterion_benchmark(c: &mut Criterion) { }); }); + c.bench_function("text_layout_cached_many_lines_modified", |b| { + const NUM_LINES: usize = 2_000; + + let mut string = String::new(); + for _ in 0..NUM_LINES { + for i in 0..30_u8 { + write!(string, "{i:02X} ").unwrap(); + } + string.push('\n'); + } + + let mut rng = rand::thread_rng(); + b.iter(|| { + fonts.begin_pass(pixels_per_point, max_texture_side); + + // Delete a random character, simulating a user making an edit in a long file: + let mut new_string = string.clone(); + let idx = rng.gen_range(0..string.len()); + new_string.remove(idx); + + fonts.layout(new_string, font_id.clone(), text_color, wrap_width); + }); + }); + let galley = fonts.layout(LOREM_IPSUM_LONG.to_owned(), font_id, text_color, wrap_width); let font_image_size = fonts.font_image_size(); let prepared_discs = fonts.texture_atlas().lock().prepared_discs(); diff --git a/crates/epaint/src/shape.rs b/crates/epaint/src/shape.rs index 56af703b669..451f3ee9f77 100644 --- a/crates/epaint/src/shape.rs +++ b/crates/epaint/src/shape.rs @@ -431,7 +431,8 @@ impl Shape { // Scale text: let galley = Arc::make_mut(&mut text_shape.galley); - for row in &mut galley.rows { + for placed_row in &mut galley.rows { + let row = Arc::make_mut(&mut placed_row.row); row.visuals.mesh_bounds = transform.scaling * row.visuals.mesh_bounds; for v in &mut row.visuals.mesh.vertices { v.pos = Pos2::new(transform.scaling * v.pos.x, transform.scaling * v.pos.y); diff --git a/crates/epaint/src/shape_transform.rs b/crates/epaint/src/shape_transform.rs index 263f9cf07a6..5e757a0553b 100644 --- a/crates/epaint/src/shape_transform.rs +++ b/crates/epaint/src/shape_transform.rs @@ -88,7 +88,8 @@ pub fn adjust_colors( if !galley.is_empty() { let galley = std::sync::Arc::make_mut(galley); - for row in &mut galley.rows { + for placed_row in &mut galley.rows { + let row = Arc::make_mut(&mut placed_row.row); for vertex in &mut row.visuals.mesh.vertices { adjust_color(&mut vertex.color); } diff --git a/crates/epaint/src/stats.rs b/crates/epaint/src/stats.rs index 68bba622ed2..456dea85fcf 100644 --- a/crates/epaint/src/stats.rs +++ b/crates/epaint/src/stats.rs @@ -91,7 +91,7 @@ impl AllocInfo { + galley.rows.iter().map(Self::from_galley_row).sum() } - fn from_galley_row(row: &crate::text::Row) -> Self { + fn from_galley_row(row: &crate::text::PlacedRow) -> Self { Self::from_mesh(&row.visuals.mesh) + Self::from_slice(&row.glyphs) } diff --git a/crates/epaint/src/tessellator.rs b/crates/epaint/src/tessellator.rs index e04eb03bee8..eba73e6daa0 100644 --- a/crates/epaint/src/tessellator.rs +++ b/crates/epaint/src/tessellator.rs @@ -1850,11 +1850,13 @@ impl Tessellator { continue; } + let final_row_pos = galley_pos + row.pos.to_vec2(); + let mut row_rect = row.visuals.mesh_bounds; if *angle != 0.0 { row_rect = row_rect.rotate_bb(rotator); } - row_rect = row_rect.translate(galley_pos.to_vec2()); + row_rect = row_rect.translate(final_row_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` @@ -1903,7 +1905,7 @@ impl Tessellator { }; Vertex { - pos: galley_pos + offset, + pos: final_row_pos + offset, uv: (uv.to_vec2() * uv_normalizer).to_pos2(), color, } diff --git a/crates/epaint/src/text/fonts.rs b/crates/epaint/src/text/fonts.rs index b952b2817d1..2c9d840d328 100644 --- a/crates/epaint/src/text/fonts.rs +++ b/crates/epaint/src/text/fonts.rs @@ -8,7 +8,7 @@ use crate::{ }, TextureAtlas, }; -use emath::{NumExt as _, OrderedFloat}; +use emath::{GuiRounding, NumExt as _, OrderedFloat}; #[cfg(feature = "default_fonts")] use epaint_default_fonts::{EMOJI_ICON, HACK_REGULAR, NOTO_EMOJI_REGULAR, UBUNTU_LIGHT}; @@ -737,7 +737,7 @@ impl GalleyCache { // Say the user asks to wrap at width 200.0. // The text layout wraps, and reports that the final width was 196.0 points. - // This than trickles up the `Ui` chain and gets stored as the width for a tooltip (say). + // This then trickles up the `Ui` chain and gets stored as the width for a tooltip (say). // On the next frame, this is then set as the max width for the tooltip, // and we end up calling the text layout code again, this time with a wrap width of 196.0. // Except, somewhere in the `Ui` chain with added margins etc, a rounding error was introduced, @@ -759,6 +759,117 @@ impl GalleyCache { let hash = crate::util::hash(&job); // TODO(emilk): even faster hasher? + match self.cache.entry(hash) { + 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) => { + // If the text contains newlines that will always break into a new row then + // we can easily lay out all the lines individually and then merge the `Galley`s. + // This allows individual lines to be cached separately which means small + // modifications to the source text will only cause impacted lines to be laid out again. + if job.break_on_newline && job.text.contains('\n') { + let galley = self.layout_multiline(fonts, job); + let galley = Arc::new(galley); + self.cache.insert( + hash, + CachedGalley { + last_used: self.generation, + galley: galley.clone(), + }, + ); + galley + } else { + let galley = super::layout(fonts, job.into()); + let galley = Arc::new(galley); + entry.insert(CachedGalley { + last_used: self.generation, + galley: galley.clone(), + }); + galley + } + } + } + } + + fn layout_multiline(&mut self, fonts: &mut FontsImpl, job: LayoutJob) -> Galley { + let mut current_section = 0; + let mut current = 0; + let mut left_max_rows = job.wrap.max_rows; + let mut galleys = Vec::new(); + let mut first_row_min_height = job.first_row_min_height; + while current != job.text.len() { + let end = job.text[current..] + .find('\n') + .map_or(job.text.len(), |i| i + current + 1); + let start = current; + + let mut line_job = LayoutJob { + text: job.text[current..end].to_string(), + wrap: crate::text::TextWrapping { + max_rows: left_max_rows, + ..job.wrap + }, + sections: Vec::new(), + break_on_newline: true, + halign: job.halign, + justify: job.justify, + first_row_min_height, + round_output_to_gui: job.round_output_to_gui, + }; + first_row_min_height = 0.0; + + while current < end { + let mut s = &job.sections[current_section]; + while s.byte_range.end <= current { + current_section += 1; + s = &job.sections[current_section]; + } + + assert!(s.byte_range.contains(¤t)); + let section_end = s.byte_range.end.min(end); + line_job.sections.push(crate::text::LayoutSection { + // Leading space should only be added to the first section + // if the there are multiple sections that will be created + // from splitting the current section. + leading_space: if current == s.byte_range.start { + s.leading_space + } else { + 0.0 + }, + byte_range: current - start..section_end - start, + format: s.format.clone(), + }); + current = section_end; + } + + let galley = self.layout_component_line(fonts, line_job); + // This will prevent us from invalidating cache entries unnecessarily + if left_max_rows != usize::MAX { + left_max_rows -= galley.rows.len(); + // Ignore extra trailing row, see merging counterpart below for more details. + if end < job.text.len() && !galley.elided { + left_max_rows += 1; + } + } + + let elided = galley.elided; + galleys.push(galley); + if elided { + break; + } + + current = end; + } + + concat_galleys(job, &galleys, fonts.pixels_per_point) + } + + fn layout_component_line(&mut self, fonts: &mut FontsImpl, job: LayoutJob) -> Arc { + let hash = crate::util::hash(&job); + match self.cache.entry(hash) { std::collections::hash_map::Entry::Occupied(entry) => { let cached = entry.into_mut(); @@ -791,6 +902,60 @@ impl GalleyCache { } } +fn concat_galleys(job: LayoutJob, galleys: &[Arc], pixels_per_point: f32) -> Galley { + let mut merged_galley = Galley { + job: Arc::new(job), + rows: Vec::new(), + elided: false, + rect: emath::Rect::ZERO, + mesh_bounds: emath::Rect::ZERO, + num_vertices: 0, + num_indices: 0, + pixels_per_point, + }; + + for (i, galley) in galleys.iter().enumerate() { + let current_offset = emath::vec2(0.0, merged_galley.rect.height()); + + let mut rows = galley.rows.iter(); + // As documented in `Row::ends_with_newline`, a '\n' will always create a + // new `Row` immediately below the current one. Here it doesn't make sense + // for us to append this new row so we just ignore it. + let is_last_row = i + 1 == galleys.len(); + if !is_last_row && !galley.elided { + let popped = rows.next_back(); + debug_assert_eq!(popped.unwrap().row.glyphs.len(), 0); + } + + merged_galley.rows.extend(rows.map(|placed_row| { + let mut new_pos = placed_row.pos + current_offset; + new_pos.y = new_pos.y.round_to_pixels(pixels_per_point); + merged_galley.mesh_bounds = merged_galley + .mesh_bounds + .union(placed_row.visuals.mesh_bounds.translate(new_pos.to_vec2())); + merged_galley.rect = merged_galley + .rect + .union(emath::Rect::from_min_size(new_pos, placed_row.size)); + + super::PlacedRow { + row: placed_row.row.clone(), + pos: new_pos, + } + })); + + merged_galley.num_vertices += galley.num_vertices; + merged_galley.num_indices += galley.num_indices; + // Note that if `galley.elided` is true this will be the last `Galley` in + // the vector and the loop will end. + merged_galley.elided |= galley.elided; + } + + if merged_galley.job.round_output_to_gui { + super::round_output_to_gui(&mut merged_galley.rect, &merged_galley.job); + } + merged_galley +} + // ---------------------------------------------------------------------------- struct FontImplCache { diff --git a/crates/epaint/src/text/mod.rs b/crates/epaint/src/text/mod.rs index 3cb0e98cbc5..cf5c8ebfc99 100644 --- a/crates/epaint/src/text/mod.rs +++ b/crates/epaint/src/text/mod.rs @@ -14,7 +14,7 @@ pub use { FontData, FontDefinitions, FontFamily, FontId, FontInsert, FontPriority, FontTweak, Fonts, FontsImpl, InsertFontFamily, }, - text_layout::layout, + text_layout::*, text_layout_types::*, }; diff --git a/crates/epaint/src/text/text_layout.rs b/crates/epaint/src/text/text_layout.rs index 2f19e538f60..e4fce1b821b 100644 --- a/crates/epaint/src/text/text_layout.rs +++ b/crates/epaint/src/text/text_layout.rs @@ -1,11 +1,10 @@ -use std::ops::RangeInclusive; use std::sync::Arc; use emath::{pos2, vec2, Align, GuiRounding as _, NumExt, Pos2, Rect, Vec2}; use crate::{stroke::PathStroke, text::font::Font, Color32, Mesh, Stroke, Vertex}; -use super::{FontsImpl, Galley, Glyph, LayoutJob, LayoutSection, Row, RowVisuals}; +use super::{FontsImpl, Galley, Glyph, LayoutJob, LayoutSection, PlacedRow, Row, RowVisuals}; // ---------------------------------------------------------------------------- @@ -96,10 +95,11 @@ pub fn layout(fonts: &mut FontsImpl, job: Arc) -> Galley { let mut elided = false; let mut rows = rows_from_paragraphs(paragraphs, &job, &mut elided); if elided { - if let Some(last_row) = rows.last_mut() { + if let Some(last_placed) = rows.last_mut() { + let last_row = Arc::get_mut(&mut last_placed.row).unwrap(); replace_last_glyph_with_overflow_character(fonts, &job, last_row); if let Some(last) = last_row.glyphs.last() { - last_row.rect.max.x = last.max_x(); + last_row.size.x = last.max_x(); } } } @@ -108,12 +108,12 @@ pub fn layout(fonts: &mut FontsImpl, job: Arc) -> Galley { if justify || job.halign != Align::LEFT { let num_rows = rows.len(); - for (i, row) in rows.iter_mut().enumerate() { + for (i, placed_row) in rows.iter_mut().enumerate() { let is_last_row = i + 1 == num_rows; - let justify_row = justify && !row.ends_with_newline && !is_last_row; + let justify_row = justify && !placed_row.ends_with_newline && !is_last_row; halign_and_justify_row( point_scale, - row, + placed_row, job.halign, job.wrap.max_width, justify_row, @@ -188,17 +188,12 @@ fn layout_section( } } -/// 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) -} - // Ignores the Y coordinate. fn rows_from_paragraphs( paragraphs: Vec, job: &LayoutJob, elided: &mut bool, -) -> Vec { +) -> Vec { let num_paragraphs = paragraphs.len(); let mut rows = vec![]; @@ -212,31 +207,35 @@ fn rows_from_paragraphs( let is_last_paragraph = (i + 1) == num_paragraphs; if paragraph.glyphs.is_empty() { - rows.push(Row { - section_index_at_start: paragraph.section_index_at_start, - 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, + rows.push(PlacedRow { + row: Arc::new(Row { + section_index_at_start: paragraph.section_index_at_start, + glyphs: vec![], + visuals: Default::default(), + size: vec2(0.0, paragraph.empty_paragraph_height), + ends_with_newline: !is_last_paragraph, + }), + pos: pos2(0.0, 0.0), }); } else { let paragraph_max_x = paragraph.glyphs.last().unwrap().max_x(); if paragraph_max_x <= job.effective_wrap_width() { // Early-out optimization: the whole paragraph fits on one row. - let paragraph_min_x = paragraph.glyphs[0].pos.x; - rows.push(Row { - section_index_at_start: paragraph.section_index_at_start, - glyphs: paragraph.glyphs, - visuals: Default::default(), - rect: rect_from_x_range(paragraph_min_x..=paragraph_max_x), - ends_with_newline: !is_last_paragraph, + rows.push(PlacedRow { + row: Arc::new(Row { + section_index_at_start: paragraph.section_index_at_start, + glyphs: paragraph.glyphs, + visuals: Default::default(), + size: vec2(paragraph_max_x, 0.0), + ends_with_newline: !is_last_paragraph, + }), + pos: pos2(0.0, f32::NAN), }); } else { line_break(¶graph, job, &mut rows, elided); - rows.last_mut().unwrap().ends_with_newline = !is_last_paragraph; + let placed_row = rows.last_mut().unwrap(); + let row = Arc::get_mut(&mut placed_row.row).unwrap(); + row.ends_with_newline = !is_last_paragraph; } } } @@ -244,7 +243,12 @@ fn rows_from_paragraphs( rows } -fn line_break(paragraph: &Paragraph, job: &LayoutJob, out_rows: &mut Vec, elided: &mut bool) { +fn line_break( + paragraph: &Paragraph, + job: &LayoutJob, + out_rows: &mut Vec, + elided: &mut bool, +) { let wrap_width = job.effective_wrap_width(); // Keeps track of good places to insert row break if we exceed `wrap_width`. @@ -270,12 +274,15 @@ fn line_break(paragraph: &Paragraph, job: &LayoutJob, out_rows: &mut Vec, e { // Allow the first row to be completely empty, because we know there will be more space on the next row: // TODO(emilk): this records the height of this first row as zero, though that is probably fine since first_row_indentation usually comes with a first_row_min_height. - out_rows.push(Row { - section_index_at_start: paragraph.section_index_at_start, - glyphs: vec![], - visuals: Default::default(), - rect: rect_from_x_range(first_row_indentation..=first_row_indentation), - ends_with_newline: false, + out_rows.push(PlacedRow { + row: Arc::new(Row { + section_index_at_start: paragraph.section_index_at_start, + glyphs: vec![], + visuals: Default::default(), + size: vec2(0.0, 0.0), + ends_with_newline: false, + }), + pos: pos2(0.0, f32::NAN), }); row_start_x += first_row_indentation; first_row_indentation = 0.0; @@ -291,15 +298,17 @@ fn line_break(paragraph: &Paragraph, job: &LayoutJob, out_rows: &mut Vec, e .collect(); let section_index_at_start = glyphs[0].section_index; - let paragraph_min_x = glyphs[0].pos.x; let paragraph_max_x = glyphs.last().unwrap().max_x(); - out_rows.push(Row { - section_index_at_start, - glyphs, - visuals: Default::default(), - rect: rect_from_x_range(paragraph_min_x..=paragraph_max_x), - ends_with_newline: false, + out_rows.push(PlacedRow { + row: Arc::new(Row { + section_index_at_start, + glyphs, + visuals: Default::default(), + size: vec2(paragraph_max_x, 0.0), + ends_with_newline: false, + }), + pos: pos2(0.0, f32::NAN), }); // Start a new row: @@ -333,12 +342,15 @@ fn line_break(paragraph: &Paragraph, job: &LayoutJob, out_rows: &mut Vec, e let paragraph_min_x = glyphs[0].pos.x; let paragraph_max_x = glyphs.last().unwrap().max_x(); - out_rows.push(Row { - section_index_at_start, - glyphs, - visuals: Default::default(), - rect: rect_from_x_range(paragraph_min_x..=paragraph_max_x), - ends_with_newline: false, + out_rows.push(PlacedRow { + row: Arc::new(Row { + section_index_at_start, + glyphs, + visuals: Default::default(), + size: vec2(paragraph_max_x - paragraph_min_x, 0.0), + ends_with_newline: false, + }), + pos: pos2(paragraph_min_x, 0.0), }); } } @@ -500,11 +512,13 @@ fn replace_last_glyph_with_overflow_character( /// Ignores the Y coordinate. fn halign_and_justify_row( point_scale: PointScale, - row: &mut Row, + placed_row: &mut PlacedRow, halign: Align, wrap_width: f32, justify: bool, ) { + let row = Arc::get_mut(&mut placed_row.row).unwrap(); + if row.glyphs.is_empty() { return; } @@ -572,7 +586,8 @@ fn halign_and_justify_row( / (num_spaces_in_range as f32); } - let mut translate_x = target_min_x - original_min_x - extra_x_per_glyph * glyph_range.0 as f32; + placed_row.pos.x = point_scale.round_to_pixel(target_min_x); + let mut translate_x = -original_min_x - extra_x_per_glyph * glyph_range.0 as f32; for glyph in &mut row.glyphs { glyph.pos.x += translate_x; @@ -584,23 +599,24 @@ fn halign_and_justify_row( } // Note we ignore the leading/trailing whitespace here! - row.rect.min.x = target_min_x; - row.rect.max.x = target_max_x; + row.size.x = target_max_x - target_min_x; } /// Calculate the Y positions and tessellate the text. fn galley_from_rows( point_scale: PointScale, job: Arc, - mut rows: Vec, + mut rows: Vec, elided: bool, ) -> Galley { let mut first_row_min_height = job.first_row_min_height; let mut cursor_y = 0.0; let mut min_x: f32 = 0.0; let mut max_x: f32 = 0.0; - for row in &mut rows { - let mut max_row_height = first_row_min_height.max(row.rect.height()); + for placed_row in &mut rows { + let mut max_row_height = first_row_min_height.max(placed_row.rect().height()); + let row = Arc::get_mut(&mut placed_row.row).unwrap(); + first_row_min_height = 0.0; for glyph in &row.glyphs { max_row_height = max_row_height.max(glyph.line_height); @@ -611,8 +627,7 @@ fn galley_from_rows( for glyph in &mut row.glyphs { let format = &job.sections[glyph.section_index as usize].format; - glyph.pos.y = cursor_y - + glyph.font_impl_ascent + glyph.pos.y = glyph.font_impl_ascent // Apply valign to the different in height of the entire row, and the height of this `Font`: + format.valign.to_factor() * (max_row_height - glyph.line_height) @@ -620,15 +635,13 @@ fn galley_from_rows( // When mixing different `FontImpl` (e.g. latin and emojis), // we always center the difference: + 0.5 * (glyph.font_height - glyph.font_impl_height); - - glyph.pos.y = point_scale.round_to_pixel(glyph.pos.y); } - row.rect.min.y = cursor_y; - row.rect.max.y = cursor_y + max_row_height; + placed_row.pos.y = cursor_y; + row.size.y = max_row_height; - min_x = min_x.min(row.rect.min.x); - max_x = max_x.max(row.rect.max.x); + min_x = min_x.min(placed_row.rect().min.x); + max_x = max_x.max(placed_row.rect().max.x); cursor_y += max_row_height; cursor_y = point_scale.round_to_pixel(cursor_y); // TODO(emilk): it would be better to do the calculations in pixels instead. } @@ -639,7 +652,8 @@ fn galley_from_rows( let mut num_vertices = 0; let mut num_indices = 0; - for row in &mut rows { + for placed_row in &mut rows { + let row = Arc::get_mut(&mut placed_row.row).unwrap(); row.visuals = tessellate_row(point_scale, &job, &format_summary, row); mesh_bounds = mesh_bounds.union(row.visuals.mesh_bounds); num_vertices += row.visuals.mesh.vertices.len(); @@ -649,25 +663,12 @@ fn galley_from_rows( let mut rect = Rect::from_min_max(pos2(min_x, 0.0), pos2(max_x, cursor_y)); if job.round_output_to_gui { - for row in &mut rows { - row.rect = row.rect.round_ui(); - } - - let did_exceed_wrap_width_by_a_lot = rect.width() > job.wrap.max_width + 1.0; - - rect = rect.round_ui(); - - if did_exceed_wrap_width_by_a_lot { - // If the user picked a too aggressive wrap width (e.g. more narrow than any individual glyph), - // we should let the user know by reporting that our width is wider than the wrap width. - } else { - // Make sure we don't report being wider than the wrap width the user picked: - rect.max.x = rect - .max - .x - .at_most(rect.min.x + job.wrap.max_width) - .floor_ui(); + for placed_row in &mut rows { + placed_row.pos = placed_row.pos.round_ui(); + let row = Arc::get_mut(&mut placed_row.row).unwrap(); + row.size = row.size.round_ui(); } + round_output_to_gui(&mut rect, &job); } Galley { @@ -682,6 +683,24 @@ fn galley_from_rows( } } +pub(crate) fn round_output_to_gui(rect: &mut Rect, job: &LayoutJob) { + let did_exceed_wrap_width_by_a_lot = rect.width() > job.wrap.max_width + 1.0; + + *rect = rect.round_ui(); + + if did_exceed_wrap_width_by_a_lot { + // If the user picked a too aggressive wrap width (e.g. more narrow than any individual glyph), + // we should let the user know by reporting that our width is wider than the wrap width. + } else { + // Make sure we don't report being wider than the wrap width the user picked: + rect.max.x = rect + .max + .x + .at_most(rect.min.x + job.wrap.max_width) + .floor_ui(); + } +} + #[derive(Default)] struct FormatSummary { any_background: bool, @@ -1146,6 +1165,7 @@ mod tests { vec!["# DNA…"] ); let row = &galley.rows[0]; - assert_eq!(row.rect.max.x, row.glyphs.last().unwrap().max_x()); + assert_eq!(row.pos, Pos2::ZERO); + assert_eq!(row.rect().max.x, row.glyphs.last().unwrap().max_x()); } } diff --git a/crates/epaint/src/text/text_layout_types.rs b/crates/epaint/src/text/text_layout_types.rs index b228d023e66..528e8846071 100644 --- a/crates/epaint/src/text/text_layout_types.rs +++ b/crates/epaint/src/text/text_layout_types.rs @@ -500,14 +500,14 @@ pub struct Galley { /// Contains the original string and style sections. pub job: Arc, - /// Rows of text, from top to bottom. + /// Rows of text, from top to bottom, and their offsets. /// /// The number of characters in all rows sum up to `job.text.chars().count()` /// unless [`Self::elided`] is `true`. /// /// Note that a paragraph (a piece of text separated with `\n`) /// can be split up into multiple rows. - pub rows: Vec, + pub rows: Vec, /// Set to true the text was truncated due to [`TextWrapping::max_rows`]. pub elided: bool, @@ -539,6 +539,32 @@ pub struct Galley { pub pixels_per_point: f32, } +#[derive(Clone, Debug, PartialEq)] +#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +pub struct PlacedRow { + /// The underlying row unpositioned [`Row`]. + pub row: Arc, + + /// The position of this [`Row`] relative to the galley. + pub pos: Pos2, +} + +impl PlacedRow { + /// Logical bounding rectangle on font heights etc. + /// Use this when drawing a selection or similar! + pub fn rect(&self) -> Rect { + Rect::from_min_size(self.pos, self.row.size) + } +} + +impl std::ops::Deref for PlacedRow { + type Target = Row; + + fn deref(&self) -> &Self::Target { + &self.row + } +} + #[derive(Clone, Debug, PartialEq)] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct Row { @@ -548,10 +574,9 @@ pub struct Row { /// One for each `char`. pub glyphs: Vec, - /// Logical bounding rectangle based on font heights etc. - /// Use this when drawing a selection or similar! + /// Logical size based on font heights etc. /// Includes leading and trailing whitespace. - pub rect: Rect, + pub size: Vec2, /// The mesh, ready to be rendered. pub visuals: RowVisuals, @@ -675,22 +700,7 @@ impl Row { self.glyphs.len() + (self.ends_with_newline as usize) } - #[inline] - pub fn min_y(&self) -> f32 { - self.rect.top() - } - - #[inline] - pub fn max_y(&self) -> f32 { - self.rect.bottom() - } - - #[inline] - pub fn height(&self) -> f32 { - self.rect.height() - } - - /// Closest char at the desired x coordinate. + /// Closest char at the desired x coordinate in row-relative coordinates. /// Returns something in the range `[0, char_count_excluding_newline()]`. pub fn char_at(&self, desired_x: f32) -> usize { for (i, glyph) in self.glyphs.iter().enumerate() { @@ -705,9 +715,26 @@ impl Row { if let Some(glyph) = self.glyphs.get(column) { glyph.pos.x } else { - self.rect.right() + self.size.x } } + + #[inline] + pub fn height(&self) -> f32 { + self.size.y + } +} + +impl PlacedRow { + #[inline] + pub fn min_y(&self) -> f32 { + self.rect().top() + } + + #[inline] + pub fn max_y(&self) -> f32 { + self.rect().bottom() + } } impl Galley { @@ -757,7 +784,7 @@ impl Galley { /// Zero-width rect past the last character. fn end_pos(&self) -> Rect { if let Some(row) = self.rows.last() { - let x = row.rect.right(); + let x = row.rect().right(); Rect::from_min_max(pos2(x, row.min_y()), pos2(x, row.max_y())) } else { // Empty galley @@ -841,11 +868,15 @@ impl Galley { let mut pcursor_it = PCursor::default(); for (row_nr, row) in self.rows.iter().enumerate() { - let is_pos_within_row = row.min_y() <= pos.y && pos.y <= row.max_y(); - let y_dist = (row.min_y() - pos.y).abs().min((row.max_y() - pos.y).abs()); + let min_y = row.min_y(); + let max_y = row.max_y(); + + let is_pos_within_row = min_y <= pos.y && pos.y <= max_y; + let y_dist = (min_y - pos.y).abs().min((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); + // char_at is `Row` not `PlacedRow` relative which means we have to subtract the pos. + let column = row.char_at(pos.x - row.pos.x); let prefer_next_row = column < row.char_count_excluding_newline(); cursor = Cursor { ccursor: CCursor { @@ -1134,11 +1165,12 @@ impl Galley { } else { // keep same X coord let x = self.pos_from_cursor(cursor).center().x; - let column = if x > self.rows[new_row].rect.right() { + let row = &self.rows[new_row]; + let column = if x > row.rect().right() { // beyond the end of this row - keep same column cursor.rcursor.column } else { - self.rows[new_row].char_at(x) + row.char_at(x) }; RCursor { row: new_row, @@ -1165,11 +1197,12 @@ impl Galley { } else { // keep same X coord let x = self.pos_from_cursor(cursor).center().x; - let column = if x > self.rows[new_row].rect.right() { + let row = &self.rows[new_row]; + let column = if x > row.rect().right() { // beyond the end of the next row - keep same column cursor.rcursor.column } else { - self.rows[new_row].char_at(x) + row.char_at(x) }; RCursor { row: new_row,