From 557bd56e1962266e765cc7b7958c1fd3f14fa3e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hubert=20G=C5=82uchowski?= Date: Tue, 1 Apr 2025 18:55:39 +0200 Subject: [PATCH] Optimize editing long text by caching each paragraph (#5411) ## What (written by @emilk) When editing long text (thousands of line), egui would previously re-layout the entire text on each edit. This could be slow. With this PR, we instead split the text into paragraphs (split on `\n`) and then cache each such paragraph. When editing text then, only the changed paragraph needs to be laid out again. Still, there is overhead from splitting the text, hashing each paragraph, and then joining the results, so the runtime complexity is still O(N). In our benchmark, editing a 2000 line string goes from ~8ms to ~300 ms, a speedup of ~25x. In the future, we could also consider laying out each paragraph in parallel, to speed up the initial layout of the text. ## Details This is an ~~almost complete~~ implementation of the approach described by emilk [in this comment](), excluding CoW semantics for `LayoutJob` (but including them for `Row`). It supersedes the previous unsuccessful attempt here: https://github.com/emilk/egui/pull/4000. Draft because: - [X] ~~Currently individual rows will have `ends_with_newline` always set to false. This breaks selection with Ctrl+A (and probably many other things)~~ - [X] ~~The whole block for doing the splitting and merging should probably become a function (I'll do that later).~~ - [X] ~~I haven't run the check script, the tests, and haven't made sure all of the examples build (although I assume they probably don't rely on Galley internals).~~ - [x] ~~Layout is sometimes incorrect (missing empty lines, wrapping sometimes makes text overlap).~~ - A lot of text-related code had to be changed so this needs to be properly tested to ensure no layout issues were introduced, especially relating to the now row-relative coordinate system of `Row`s. Also this requires that we're fine making these very breaking changes. It does significantly improve the performance of rendering large blocks of text (if they have many newlines), this is the test program I used to test it (adapted from ):
code ```rust use eframe::egui::{self, CentralPanel, TextEdit}; use std::fmt::Write; fn main() -> Result<(), eframe::Error> { let options = eframe::NativeOptions { ..Default::default() }; eframe::run_native( "editor big file test", options, Box::new(|_cc| Ok(Box::::new(MyApp::new()))), ) } struct MyApp { text: String, } impl MyApp { fn new() -> Self { let mut string = String::new(); for line_bytes in (0..50000).map(|_| (0u8..50)) { for byte in line_bytes { write!(string, " {byte:02x}").unwrap(); } write!(string, "\n").unwrap(); } println!("total bytes: {}", string.len()); MyApp { text: string } } } impl eframe::App for MyApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { CentralPanel::default().show(ctx, |ui| { let start = std::time::Instant::now(); egui::ScrollArea::vertical().show(ui, |ui| { let code_editor = TextEdit::multiline(&mut self.text) .code_editor() .desired_width(f32::INFINITY) .desired_rows(40); let response = code_editor.show(ui).response; if response.changed() { println!("total bytes now: {}", self.text.len()); } }); let end = std::time::Instant::now(); let time_to_update = end - start; if time_to_update.as_secs_f32() > 0.5 { println!("Long update took {:.3}s", time_to_update.as_secs_f32()) } }); } } ```
I think the way to proceed would be to make a new type, something like `PositionedRow`, that would wrap an `Arc` but have a separate `pos` ~~and `ends_with_newline`~~ (that would mean `Row` only holds a `size` instead of a `rect`). This type would of course have getters that would allow you to easily get a `Rect` from it and probably a `Deref` to the underlying `Row`. ~~I haven't done this yet because I wanted to get some opinions whether this would be an acceptable API first.~~ This is now implemented, but of course I'm still open to discussion about this approach and whether it's what we want to do. Breaking changes (currently): - The `Galley::rows` field has a different type. - There is now a `PlacedRow` wrapper for `Row`. - `Row` now uses a coordinate system relative to itself instead of the `Galley`. * Closes * [X] I have followed the instructions in the PR template --------- Co-authored-by: Emil Ernerfeldt --- Cargo.lock | 53 ++- Cargo.toml | 1 + .../egui/src/text_selection/accesskit_text.rs | 6 +- .../text_selection/label_text_selection.rs | 9 +- crates/egui/src/text_selection/visuals.rs | 9 +- crates/egui/src/widget_text.rs | 4 +- crates/egui/src/widgets/label.rs | 10 +- crates/egui_demo_lib/Cargo.toml | 1 + crates/egui_demo_lib/benches/benchmark.rs | 27 ++ crates/emath/src/pos2.rs | 14 +- crates/emath/src/rect.rs | 6 +- crates/epaint/Cargo.toml | 1 + crates/epaint/src/shape_transform.rs | 3 +- crates/epaint/src/shapes/shape.rs | 34 +- crates/epaint/src/shapes/text_shape.rs | 57 ++++ crates/epaint/src/stats.rs | 2 +- crates/epaint/src/tessellator.rs | 6 +- crates/epaint/src/text/fonts.rs | 323 +++++++++++++++++- crates/epaint/src/text/mod.rs | 2 +- crates/epaint/src/text/text_layout.rs | 194 ++++++----- crates/epaint/src/text/text_layout_types.rs | 189 ++++++++-- 21 files changed, 754 insertions(+), 197 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3a04d32f..4276896b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -642,6 +642,17 @@ dependencies = [ "piper", ] +[[package]] +name = "bstr" +version = "1.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "531a9155a481e2ee699d4f98f43c0ca4ff8ee1bfd55c31e9e98fb29d2b176fe0" +dependencies = [ + "memchr", + "regex-automata", + "serde", +] + [[package]] name = "bumpalo" version = "3.16.0" @@ -896,6 +907,18 @@ dependencies = [ "env_logger", ] +[[package]] +name = "console" +version = "0.15.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +dependencies = [ + "encode_unicode", + "libc", + "once_cell", + "windows-sys 0.59.0", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -1331,6 +1354,7 @@ dependencies = [ "egui", "egui_extras", "egui_kittest", + "rand", "serde", "unicode_names2", ] @@ -1420,6 +1444,12 @@ dependencies = [ "serde", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "endi" version = "1.1.0" @@ -1520,6 +1550,7 @@ dependencies = [ "profiling", "rayon", "serde", + "similar-asserts", ] [[package]] @@ -2389,7 +2420,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4" dependencies = [ "cfg-if", - "windows-targets 0.48.5", + "windows-targets 0.52.6", ] [[package]] @@ -3669,6 +3700,26 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +dependencies = [ + "bstr", + "unicode-segmentation", +] + +[[package]] +name = "similar-asserts" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b441962c817e33508847a22bd82f03a30cff43642dc2fae8b050566121eb9a" +dependencies = [ + "console", + "similar", +] + [[package]] name = "simplecss" version = "0.2.1" diff --git a/Cargo.toml b/Cargo.toml index cb750bfd..a0051513 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -96,6 +96,7 @@ puffin_http = "0.16" raw-window-handle = "0.6.0" ron = "0.8" serde = { version = "1", features = ["derive"] } +similar-asserts = "1.4.2" thiserror = "1.0.37" type-map = "0.5.0" wasm-bindgen = "0.2" diff --git a/crates/egui/src/text_selection/accesskit_text.rs b/crates/egui/src/text_selection/accesskit_text.rs index d189498f..de193e3b 100644 --- a/crates/egui/src/text_selection/accesskit_text.rs +++ b/crates/egui/src/text_selection/accesskit_text.rs @@ -45,7 +45,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 = global_from_galley * row.rect; + let rect = global_from_galley * row.rect(); builder.set_bounds(accesskit::Rect { x0: rect.min.x.into(), y0: rect.min.y.into(), @@ -76,14 +76,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 acd3db7d..e24992a0 100644 --- a/crates/egui/src/text_selection/label_text_selection.rs +++ b/crates/egui/src/text_selection/label_text_selection.rs @@ -186,7 +186,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 @@ -701,8 +704,8 @@ fn selected_text(galley: &Galley, cursor_range: &CCursorRange) -> 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 32a040a8..deee5690 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 = galley.layout_from_cursor(max); 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 5ddafc4b..e66cb1bc 100644 --- a/crates/egui/src/widget_text.rs +++ b/crates/egui/src/widget_text.rs @@ -671,8 +671,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 c36b9fc6..3656af92 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; @@ -216,10 +216,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 0e0299f1..77b8fdcb 100644 --- a/crates/egui_demo_lib/Cargo.toml +++ b/crates/egui_demo_lib/Cargo.toml @@ -58,6 +58,7 @@ serde = { workspace = true, optional = true } criterion.workspace = true egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } egui = { workspace = true, features = ["default_fonts"] } +rand = "0.9" [[bench]] name = "benchmark" diff --git a/crates/egui_demo_lib/benches/benchmark.rs b/crates/egui_demo_lib/benches/benchmark.rs index cbcc4d88..dab6bdd7 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; @@ -128,6 +131,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::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.random_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/emath/src/pos2.rs b/crates/emath/src/pos2.rs index 1f4bd864..62590b10 100644 --- a/crates/emath/src/pos2.rs +++ b/crates/emath/src/pos2.rs @@ -1,5 +1,7 @@ -use std::fmt; -use std::ops::{Add, AddAssign, Sub, SubAssign}; +use std::{ + fmt, + ops::{Add, AddAssign, MulAssign, Sub, SubAssign}, +}; use crate::{lerp, Div, Mul, Vec2}; @@ -305,6 +307,14 @@ impl Mul for f32 { } } +impl MulAssign for Pos2 { + #[inline(always)] + fn mul_assign(&mut self, rhs: f32) { + self.x *= rhs; + self.y *= rhs; + } +} + impl Div for Pos2 { type Output = Self; diff --git a/crates/emath/src/rect.rs b/crates/emath/src/rect.rs index 521b6f33..00bed04f 100644 --- a/crates/emath/src/rect.rs +++ b/crates/emath/src/rect.rs @@ -710,7 +710,11 @@ impl Rect { impl fmt::Debug for Rect { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "[{:?} - {:?}]", self.min, self.max) + if let Some(precision) = f.precision() { + write!(f, "[{1:.0$?} - {2:.0$?}]", precision, self.min, self.max) + } else { + write!(f, "[{:?} - {:?}]", self.min, self.max) + } } } diff --git a/crates/epaint/Cargo.toml b/crates/epaint/Cargo.toml index 72018887..b8b006d4 100644 --- a/crates/epaint/Cargo.toml +++ b/crates/epaint/Cargo.toml @@ -101,6 +101,7 @@ backtrace = { workspace = true, optional = true } [dev-dependencies] criterion.workspace = true +similar-asserts.workspace = true [[bench]] diff --git a/crates/epaint/src/shape_transform.rs b/crates/epaint/src/shape_transform.rs index 469f2e52..57de1496 100644 --- a/crates/epaint/src/shape_transform.rs +++ b/crates/epaint/src/shape_transform.rs @@ -89,7 +89,8 @@ pub fn adjust_colors( if !galley.is_empty() { let galley = 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/shapes/shape.rs b/crates/epaint/src/shapes/shape.rs index 7b38bda6..a855d653 100644 --- a/crates/epaint/src/shapes/shape.rs +++ b/crates/epaint/src/shapes/shape.rs @@ -456,39 +456,7 @@ impl Shape { rect_shape.blur_width *= transform.scaling; } Self::Text(text_shape) => { - let TextShape { - pos, - galley, - underline, - fallback_color: _, - override_text_color: _, - opacity_factor: _, - angle: _, - } = text_shape; - - *pos = transform * *pos; - underline.width *= transform.scaling; - - let Galley { - job: _, - rows, - elided: _, - rect, - mesh_bounds, - num_vertices: _, - num_indices: _, - pixels_per_point: _, - } = Arc::make_mut(galley); - - for row in rows { - 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); - } - } - - *mesh_bounds = transform.scaling * *mesh_bounds; - *rect = transform.scaling * *rect; + text_shape.transform(transform); } Self::Mesh(mesh) => { Arc::make_mut(mesh).transform(transform); diff --git a/crates/epaint/src/shapes/text_shape.rs b/crates/epaint/src/shapes/text_shape.rs index ef549bd9..e88213b9 100644 --- a/crates/epaint/src/shapes/text_shape.rs +++ b/crates/epaint/src/shapes/text_shape.rs @@ -89,6 +89,63 @@ impl TextShape { self.opacity_factor = opacity_factor; self } + + /// Move the shape by this many points, in-place. + pub fn transform(&mut self, transform: emath::TSTransform) { + let Self { + pos, + galley, + underline, + fallback_color: _, + override_text_color: _, + opacity_factor: _, + angle: _, + } = self; + + *pos = transform * *pos; + underline.width *= transform.scaling; + + let Galley { + job: _, + rows, + elided: _, + rect, + mesh_bounds, + num_vertices: _, + num_indices: _, + pixels_per_point: _, + } = Arc::make_mut(galley); + + *rect = transform.scaling * *rect; + *mesh_bounds = transform.scaling * *mesh_bounds; + + for text::PlacedRow { pos, row } in rows { + *pos *= transform.scaling; + + let text::Row { + section_index_at_start: _, + glyphs: _, // TODO(emilk): would it make sense to transform these? + size, + visuals, + ends_with_newline: _, + } = Arc::make_mut(row); + + *size *= transform.scaling; + + let text::RowVisuals { + mesh, + mesh_bounds, + glyph_index_start: _, + glyph_vertex_range: _, + } = visuals; + + *mesh_bounds = transform.scaling * *mesh_bounds; + + for v in &mut mesh.vertices { + v.pos *= transform.scaling; + } + } + } } impl From for Shape { diff --git a/crates/epaint/src/stats.rs b/crates/epaint/src/stats.rs index cb72d90e..2acf1e93 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 914b1cc3..2b24869a 100644 --- a/crates/epaint/src/tessellator.rs +++ b/crates/epaint/src/tessellator.rs @@ -2033,11 +2033,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` @@ -2086,7 +2088,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 ccbf66f9..bfa85468 100644 --- a/crates/epaint/src/text/fonts.rs +++ b/crates/epaint/src/text/fonts.rs @@ -4,7 +4,7 @@ use crate::{ mutex::{Mutex, MutexGuard}, text::{ font::{Font, FontImpl}, - Galley, LayoutJob, + Galley, LayoutJob, LayoutSection, }, TextureAtlas, }; @@ -617,7 +617,9 @@ pub struct FontsAndCache { impl FontsAndCache { fn layout_job(&mut self, job: LayoutJob) -> Arc { - self.galley_cache.layout(&mut self.fonts, job) + let allow_split_paragraphs = true; // Optimization for editing text with many paragraphs. + self.galley_cache + .layout(&mut self.fonts, job, allow_split_paragraphs) } } @@ -726,6 +728,12 @@ impl FontsImpl { struct CachedGalley { /// When it was last used last_used: u32, + + /// Hashes of all other entries this one depends on for quick re-layout. + /// Their `last_used`s should be updated alongside this one to make sure they're + /// not evicted. + children: Option>, + galley: Arc, } @@ -737,13 +745,18 @@ struct GalleyCache { } impl GalleyCache { - fn layout(&mut self, fonts: &mut FontsImpl, mut job: LayoutJob) -> Arc { + fn layout_internal( + &mut self, + fonts: &mut FontsImpl, + mut job: LayoutJob, + allow_split_paragraphs: bool, + ) -> (u64, Arc) { if job.wrap.max_width.is_finite() { // Protect against rounding errors in egui layout code. // 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, @@ -765,22 +778,176 @@ impl GalleyCache { let hash = crate::util::hash(&job); // TODO(emilk): even faster hasher? - match self.cache.entry(hash) { + let galley = match self.cache.entry(hash) { std::collections::hash_map::Entry::Occupied(entry) => { + // The job was found in cache - no need to re-layout. 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.into()); - let galley = Arc::new(galley); - entry.insert(CachedGalley { - last_used: self.generation, - galley: galley.clone(), - }); + + let galley = cached.galley.clone(); + if let Some(children) = &cached.children { + // The point of `allow_split_paragraphs` is to split large jobs into paragraph, + // and then cache each paragraph individually. + // That way, if we edit a single paragraph, only that paragraph will be re-layouted. + // For that to work we need to keep all the child/paragraph + // galleys alive while the parent galley is alive: + for child_hash in children.clone().iter() { + if let Some(cached_child) = self.cache.get_mut(child_hash) { + cached_child.last_used = self.generation; + } + } + } + galley } + std::collections::hash_map::Entry::Vacant(entry) => { + let job = Arc::new(job); + if allow_split_paragraphs && should_cache_each_paragraph_individually(&job) { + let (child_galleys, child_hashes) = + self.layout_each_paragraph_individuallly(fonts, &job); + debug_assert_eq!( + child_hashes.len(), + child_galleys.len(), + "Bug in `layout_each_paragraph_individuallly`" + ); + let galley = + Arc::new(Galley::concat(job, &child_galleys, fonts.pixels_per_point)); + + self.cache.insert( + hash, + CachedGalley { + last_used: self.generation, + children: Some(child_hashes.into()), + galley: galley.clone(), + }, + ); + galley + } else { + let galley = super::layout(fonts, job); + let galley = Arc::new(galley); + entry.insert(CachedGalley { + last_used: self.generation, + children: None, + galley: galley.clone(), + }); + galley + } + } + }; + + (hash, galley) + } + + fn layout( + &mut self, + fonts: &mut FontsImpl, + job: LayoutJob, + allow_split_paragraphs: bool, + ) -> Arc { + self.layout_internal(fonts, job, allow_split_paragraphs).1 + } + + /// Split on `\n` and lay out (and cache) each paragraph individually. + fn layout_each_paragraph_individuallly( + &mut self, + fonts: &mut FontsImpl, + job: &LayoutJob, + ) -> (Vec>, Vec) { + profiling::function_scope!(); + + let mut current_section = 0; + let mut start = 0; + let mut max_rows_remaining = job.wrap.max_rows; + let mut child_galleys = Vec::new(); + let mut child_hashes = Vec::new(); + + while start < job.text.len() { + let is_first_paragraph = start == 0; + let end = job.text[start..] + .find('\n') + .map_or(job.text.len(), |i| start + i + 1); + + let mut paragraph_job = LayoutJob { + text: job.text[start..end].to_owned(), + wrap: crate::text::TextWrapping { + max_rows: max_rows_remaining, + ..job.wrap + }, + sections: Vec::new(), + break_on_newline: job.break_on_newline, + halign: job.halign, + justify: job.justify, + first_row_min_height: if is_first_paragraph { + job.first_row_min_height + } else { + 0.0 + }, + round_output_to_gui: job.round_output_to_gui, + }; + + // Add overlapping sections: + for section in &job.sections[current_section..job.sections.len()] { + let LayoutSection { + leading_space, + byte_range: section_range, + format, + } = section; + + // `start` and `end` are the byte range of the current paragraph. + // How does the current section overlap with the paragraph range? + + if section_range.end <= start { + // The section is behind us + current_section += 1; + } else if end <= section_range.start { + break; // Haven't reached this one yet. + } else { + // Section range overlaps with paragraph range + debug_assert!( + section_range.start < section_range.end, + "Bad byte_range: {section_range:?}" + ); + let new_range = section_range.start.saturating_sub(start) + ..(section_range.end.at_most(end)).saturating_sub(start); + debug_assert!( + new_range.start <= new_range.end, + "Bad new section range: {new_range:?}" + ); + paragraph_job.sections.push(LayoutSection { + leading_space: if start <= new_range.start { + *leading_space + } else { + 0.0 + }, + byte_range: new_range, + format: format.clone(), + }); + } + } + + // TODO(emilk): we could lay out each paragraph in parallel to get a nice speedup on multicore machines. + let (hash, galley) = self.layout_internal(fonts, paragraph_job, false); + child_hashes.push(hash); + + // This will prevent us from invalidating cache entries unnecessarily: + if max_rows_remaining != usize::MAX { + max_rows_remaining -= galley.rows.len(); + // Ignore extra trailing row, see merging `Galley::concat` for more details. + if end < job.text.len() && !galley.elided { + max_rows_remaining += 1; + } + } + + let elided = galley.elided; + child_galleys.push(galley); + if elided { + break; + } + + start = end; } + + (child_galleys, child_hashes) } pub fn num_galleys_in_cache(&self) -> usize { @@ -797,6 +964,16 @@ impl GalleyCache { } } +/// If true, lay out and cache each paragraph (sections separated by newlines) individually. +/// +/// This makes it much faster to re-layout the full text when only a portion of it has changed since last frame, i.e. when editing somewhere in a file with thousands of lines/paragraphs. +fn should_cache_each_paragraph_individually(job: &LayoutJob) -> bool { + // We currently don't support this elided text, i.e. when `max_rows` is set. + // Most often, elided text is elided to one row, + // and so will always be fast to lay out. + job.break_on_newline && job.wrap.max_rows == usize::MAX && job.text.contains('\n') +} + // ---------------------------------------------------------------------------- struct FontImplCache { @@ -867,3 +1044,121 @@ impl FontImplCache { .clone() } } + +#[cfg(feature = "default_fonts")] +#[cfg(test)] +mod tests { + use core::f32; + + use super::*; + use crate::{text::TextFormat, Stroke}; + use ecolor::Color32; + use emath::Align; + + fn jobs() -> Vec { + vec![ + LayoutJob::simple( + String::default(), + FontId::new(14.0, FontFamily::Monospace), + Color32::WHITE, + f32::INFINITY, + ), + LayoutJob::simple( + "Simple test.".to_owned(), + FontId::new(14.0, FontFamily::Monospace), + Color32::WHITE, + f32::INFINITY, + ), + LayoutJob::simple( + "This some text that may be long.\nDet kanske också finns lite ÅÄÖ här.".to_owned(), + FontId::new(14.0, FontFamily::Proportional), + Color32::WHITE, + 50.0, + ), + { + let mut job = LayoutJob { + first_row_min_height: 20.0, + ..Default::default() + }; + job.append( + "1st paragraph has some leading space.\n", + 16.0, + TextFormat { + font_id: FontId::new(14.0, FontFamily::Proportional), + ..Default::default() + }, + ); + job.append( + "2nd paragraph has underline and strikthrough, and has some non-ASCII characters:\n ÅÄÖ.", + 0.0, + TextFormat { + font_id: FontId::new(15.0, FontFamily::Monospace), + underline: Stroke::new(1.0, Color32::RED), + strikethrough: Stroke::new(1.0, Color32::GREEN), + ..Default::default() + }, + ); + job.append( + "3rd paragraph is kind of boring, but has italics.\nAnd a newline", + 0.0, + TextFormat { + font_id: FontId::new(10.0, FontFamily::Proportional), + italics: true, + ..Default::default() + }, + ); + + job + }, + ] + } + + #[test] + fn test_split_paragraphs() { + for pixels_per_point in [1.0, 2.0_f32.sqrt(), 2.0] { + let max_texture_side = 4096; + let mut fonts = FontsImpl::new( + pixels_per_point, + max_texture_side, + FontDefinitions::default(), + ); + + for halign in [Align::Min, Align::Center, Align::Max] { + for justify in [false, true] { + for mut job in jobs() { + job.halign = halign; + job.justify = justify; + + let whole = GalleyCache::default().layout(&mut fonts, job.clone(), false); + + let split = GalleyCache::default().layout(&mut fonts, job.clone(), true); + + for (i, row) in whole.rows.iter().enumerate() { + println!( + "Whole row {i}: section_index_at_start={}, first glyph section_index: {:?}", + row.row.section_index_at_start, + row.row.glyphs.first().map(|g| g.section_index) + ); + } + for (i, row) in split.rows.iter().enumerate() { + println!( + "Split row {i}: section_index_at_start={}, first glyph section_index: {:?}", + row.row.section_index_at_start, + row.row.glyphs.first().map(|g| g.section_index) + ); + } + + // Don't compare for equaliity; but format with a specific precision and make sure we hit that. + // NOTE: we use a rather low precision, because as long as we're within a pixel I think it's good enough. + similar_asserts::assert_eq!( + format!("{:#.1?}", split), + format!("{:#.1?}", whole), + "pixels_per_point: {pixels_per_point:.2}, input text: '{}'", + job.text + ); + } + } + } + } + } +} diff --git a/crates/epaint/src/text/mod.rs b/crates/epaint/src/text/mod.rs index 3cb0e98c..cf5c8ebf 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 d1b2d503..b2dba96f 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}; // ---------------------------------------------------------------------------- @@ -70,12 +69,14 @@ impl Paragraph { /// In most cases you should use [`crate::Fonts::layout_job`] instead /// since that memoizes the input, making subsequent layouting of the same text much faster. pub fn layout(fonts: &mut FontsImpl, job: Arc) -> Galley { + profiling::function_scope!(); + if job.wrap.max_rows == 0 { // Early-out: no text return Galley { job, rows: Default::default(), - rect: Rect::from_min_max(Pos2::ZERO, Pos2::ZERO), + rect: Rect::ZERO, mesh_bounds: Rect::NOTHING, num_vertices: 0, num_indices: 0, @@ -96,10 +97,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::make_mut(&mut last_placed.row); 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 +110,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 +190,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 +209,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 { + pos: Pos2::ZERO, + 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, + }), }); } 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 { + pos: pos2(0.0, f32::NAN), + 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, + }), }); } 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::make_mut(&mut placed_row.row); + row.ends_with_newline = !is_last_paragraph; } } } @@ -244,7 +245,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 +276,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 { + pos: pos2(0.0, f32::NAN), + row: Arc::new(Row { + section_index_at_start: paragraph.section_index_at_start, + glyphs: vec![], + visuals: Default::default(), + size: Vec2::ZERO, + ends_with_newline: false, + }), }); row_start_x += first_row_indentation; first_row_indentation = 0.0; @@ -291,15 +300,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 { + pos: pos2(0.0, f32::NAN), + row: Arc::new(Row { + section_index_at_start, + glyphs, + visuals: Default::default(), + size: vec2(paragraph_max_x, 0.0), + ends_with_newline: false, + }), }); // Start a new row: @@ -333,12 +344,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 { + pos: pos2(paragraph_min_x, 0.0), + 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, + }), }); } } @@ -500,11 +514,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::make_mut(&mut placed_row.row); + if row.glyphs.is_empty() { return; } @@ -572,7 +588,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 +601,23 @@ 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::make_mut(&mut placed_row.row); + first_row_min_height = 0.0; for glyph in &row.glyphs { max_row_height = max_row_height.max(glyph.line_height); @@ -611,8 +628,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) @@ -624,53 +640,38 @@ fn galley_from_rows( 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); 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. } let format_summary = format_summary(&job); + let mut rect = Rect::ZERO; let mut mesh_bounds = Rect::NOTHING; let mut num_vertices = 0; let mut num_indices = 0; - for row in &mut rows { + for placed_row in &mut rows { + rect = rect.union(placed_row.rect()); + + let row = Arc::make_mut(&mut placed_row.row); row.visuals = tessellate_row(point_scale, &job, &format_summary, row); - mesh_bounds = mesh_bounds.union(row.visuals.mesh_bounds); + + mesh_bounds = + mesh_bounds.union(row.visuals.mesh_bounds.translate(placed_row.pos.to_vec2())); num_vertices += row.visuals.mesh.vertices.len(); num_indices += row.visuals.mesh.indices.len(); - } - 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(); + row.section_index_at_start = u32::MAX; // No longer in use. + for glyph in &mut row.glyphs { + glyph.section_index = u32::MAX; // No longer in use. } } - Galley { + let mut galley = Galley { job, rows, elided, @@ -679,7 +680,13 @@ fn galley_from_rows( num_vertices, num_indices, pixels_per_point: point_scale.pixels_per_point, + }; + + if galley.job.round_output_to_gui { + galley.round_output_to_gui(); } + + galley } #[derive(Default)] @@ -876,7 +883,7 @@ fn add_row_hline( let (stroke, mut y) = stroke_and_y(glyph); stroke.round_center_to_pixel(point_scale.pixels_per_point, &mut y); - if stroke == Stroke::NONE { + if stroke.is_empty() { end_line(line_start.take(), last_right_x); } else if let Some((existing_stroke, start)) = line_start { if existing_stroke == stroke && start.y == y { @@ -1130,6 +1137,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 b6d7ccf4..4bd15d3e 100644 --- a/crates/epaint/src/text/text_layout_types.rs +++ b/crates/epaint/src/text/text_layout_types.rs @@ -9,7 +9,7 @@ use super::{ font::UvRect, }; use crate::{Color32, FontId, Mesh, Stroke}; -use emath::{pos2, vec2, Align, NumExt, OrderedFloat, Pos2, Rect, Vec2}; +use emath::{pos2, vec2, Align, GuiRounding as _, NumExt, OrderedFloat, Pos2, Rect, Vec2}; /// Describes the task of laying out text. /// @@ -508,14 +508,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, @@ -547,19 +547,50 @@ 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 position of this [`Row`] relative to the galley. + /// + /// This is rounded to the closest _pixel_ in order to produce crisp, pixel-perfect text. + pub pos: Pos2, + + /// The underlying unpositioned [`Row`]. + pub row: Arc, +} + +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 { - /// This is included in case there are no glyphs - pub section_index_at_start: u32, + /// This is included in case there are no glyphs. + /// + /// Only used during layout, then set to an invalid value in order to + /// enable the paragraph-concat optimization path without having to + /// adjust `section_index` when concatting. + pub(crate) section_index_at_start: u32, /// 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, @@ -613,7 +644,7 @@ pub struct Glyph { /// The character this glyph represents. pub chr: char, - /// Baseline position, relative to the galley. + /// Baseline position, relative to the row. /// Logical position: pos.y is the same for all chars of the same [`TextFormat`]. pub pos: Pos2, @@ -642,7 +673,11 @@ pub struct Glyph { pub uv_rect: UvRect, /// Index into [`LayoutJob::sections`]. Decides color etc. - pub section_index: u32, + /// + /// Only used during layout, then set to an invalid value in order to + /// enable the paragraph-concat optimization path without having to + /// adjust `section_index` when concatting. + pub(crate) section_index: u32, } impl Glyph { @@ -683,22 +718,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() { @@ -713,9 +733,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 { @@ -734,6 +771,92 @@ impl Galley { pub fn size(&self) -> Vec2 { self.rect.size() } + + pub(crate) fn round_output_to_gui(&mut self) { + for placed_row in &mut self.rows { + // Optimization: only call `make_mut` if necessary (can cause a deep clone) + let rounded_size = placed_row.row.size.round_ui(); + if placed_row.row.size != rounded_size { + Arc::make_mut(&mut placed_row.row).size = rounded_size; + } + } + + let rect = &mut self.rect; + + let did_exceed_wrap_width_by_a_lot = rect.width() > self.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 + self.job.wrap.max_width) + .floor_ui(); + } + } + + /// Append each galley under the previous one. + pub fn concat(job: Arc, galleys: &[Arc], pixels_per_point: f32) -> Self { + profiling::function_scope!(); + + let mut merged_galley = Self { + job, + rows: Vec::new(), + elided: false, + rect: Rect::ZERO, + mesh_bounds: Rect::NOTHING, + num_vertices: 0, + num_indices: 0, + pixels_per_point, + }; + + for (i, galley) in galleys.iter().enumerate() { + let current_y_offset = 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, "Bug in Galley::concat"); + } + + merged_galley.rows.extend(rows.map(|placed_row| { + let new_pos = placed_row.pos + current_y_offset * Vec2::Y; + let new_pos = new_pos.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(Rect::from_min_size(new_pos, placed_row.size)); + + super::PlacedRow { + pos: new_pos, + row: placed_row.row.clone(), + } + })); + + 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 { + merged_galley.round_output_to_gui(); + } + + merged_galley + } } impl AsRef for Galley { @@ -765,7 +888,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 @@ -816,11 +939,15 @@ impl Galley { let mut ccursor_index = 0; for row in &self.rows { - 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 = CCursor { index: ccursor_index + column,