Skip to content

Commit 557bd56

Browse files
afishhhemilk
andauthored
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](<#3086 (comment)>), excluding CoW semantics for `LayoutJob` (but including them for `Row`). It supersedes the previous unsuccessful attempt here: #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 <#3086>): <details> <summary>code</summary> ```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::<MyApp>::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()) } }); } } ``` </details> I think the way to proceed would be to make a new type, something like `PositionedRow`, that would wrap an `Arc<Row>` 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 <#3086> * [X] I have followed the instructions in the PR template --------- Co-authored-by: Emil Ernerfeldt <[email protected]>
1 parent e275409 commit 557bd56

File tree

21 files changed

+753
-196
lines changed

21 files changed

+753
-196
lines changed

Cargo.lock

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,17 @@ dependencies = [
642642
"piper",
643643
]
644644

645+
[[package]]
646+
name = "bstr"
647+
version = "1.11.3"
648+
source = "registry+https://github.com/rust-lang/crates.io-index"
649+
checksum = "531a9155a481e2ee699d4f98f43c0ca4ff8ee1bfd55c31e9e98fb29d2b176fe0"
650+
dependencies = [
651+
"memchr",
652+
"regex-automata",
653+
"serde",
654+
]
655+
645656
[[package]]
646657
name = "bumpalo"
647658
version = "3.16.0"
@@ -896,6 +907,18 @@ dependencies = [
896907
"env_logger",
897908
]
898909

910+
[[package]]
911+
name = "console"
912+
version = "0.15.11"
913+
source = "registry+https://github.com/rust-lang/crates.io-index"
914+
checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8"
915+
dependencies = [
916+
"encode_unicode",
917+
"libc",
918+
"once_cell",
919+
"windows-sys 0.59.0",
920+
]
921+
899922
[[package]]
900923
name = "core-foundation"
901924
version = "0.9.4"
@@ -1331,6 +1354,7 @@ dependencies = [
13311354
"egui",
13321355
"egui_extras",
13331356
"egui_kittest",
1357+
"rand",
13341358
"serde",
13351359
"unicode_names2",
13361360
]
@@ -1420,6 +1444,12 @@ dependencies = [
14201444
"serde",
14211445
]
14221446

1447+
[[package]]
1448+
name = "encode_unicode"
1449+
version = "1.0.0"
1450+
source = "registry+https://github.com/rust-lang/crates.io-index"
1451+
checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0"
1452+
14231453
[[package]]
14241454
name = "endi"
14251455
version = "1.1.0"
@@ -1520,6 +1550,7 @@ dependencies = [
15201550
"profiling",
15211551
"rayon",
15221552
"serde",
1553+
"similar-asserts",
15231554
]
15241555

15251556
[[package]]
@@ -2389,7 +2420,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
23892420
checksum = "4979f22fdb869068da03c9f7528f8297c6fd2606bc3a4affe42e6a823fdb8da4"
23902421
dependencies = [
23912422
"cfg-if",
2392-
"windows-targets 0.48.5",
2423+
"windows-targets 0.52.6",
23932424
]
23942425

23952426
[[package]]
@@ -3669,6 +3700,26 @@ version = "0.3.7"
36693700
source = "registry+https://github.com/rust-lang/crates.io-index"
36703701
checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe"
36713702

3703+
[[package]]
3704+
name = "similar"
3705+
version = "2.7.0"
3706+
source = "registry+https://github.com/rust-lang/crates.io-index"
3707+
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
3708+
dependencies = [
3709+
"bstr",
3710+
"unicode-segmentation",
3711+
]
3712+
3713+
[[package]]
3714+
name = "similar-asserts"
3715+
version = "1.7.0"
3716+
source = "registry+https://github.com/rust-lang/crates.io-index"
3717+
checksum = "b5b441962c817e33508847a22bd82f03a30cff43642dc2fae8b050566121eb9a"
3718+
dependencies = [
3719+
"console",
3720+
"similar",
3721+
]
3722+
36723723
[[package]]
36733724
name = "simplecss"
36743725
version = "0.2.1"

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ puffin_http = "0.16"
9696
raw-window-handle = "0.6.0"
9797
ron = "0.8"
9898
serde = { version = "1", features = ["derive"] }
99+
similar-asserts = "1.4.2"
99100
thiserror = "1.0.37"
100101
type-map = "0.5.0"
101102
wasm-bindgen = "0.2"

crates/egui/src/text_selection/accesskit_text.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ pub fn update_accesskit_for_text_widget(
4545
let row_id = parent_id.with(row_index);
4646
ctx.accesskit_node_builder(row_id, |builder| {
4747
builder.set_role(accesskit::Role::TextRun);
48-
let rect = global_from_galley * row.rect;
48+
let rect = global_from_galley * row.rect();
4949
builder.set_bounds(accesskit::Rect {
5050
x0: rect.min.x.into(),
5151
y0: rect.min.y.into(),
@@ -76,14 +76,14 @@ pub fn update_accesskit_for_text_widget(
7676
let old_len = value.len();
7777
value.push(glyph.chr);
7878
character_lengths.push((value.len() - old_len) as _);
79-
character_positions.push(glyph.pos.x - row.rect.min.x);
79+
character_positions.push(glyph.pos.x - row.pos.x);
8080
character_widths.push(glyph.advance_width);
8181
}
8282

8383
if row.ends_with_newline {
8484
value.push('\n');
8585
character_lengths.push(1);
86-
character_positions.push(row.rect.max.x - row.rect.min.x);
86+
character_positions.push(row.size.x);
8787
character_widths.push(0.0);
8888
}
8989
word_lengths.push((character_lengths.len() - last_word_start) as _);

crates/egui/src/text_selection/label_text_selection.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,10 @@ impl LabelSelectionState {
186186
if let epaint::Shape::Text(text_shape) = &mut shape.shape {
187187
let galley = Arc::make_mut(&mut text_shape.galley);
188188
for row_selection in row_selections {
189-
if let Some(row) = galley.rows.get_mut(row_selection.row) {
189+
if let Some(placed_row) =
190+
galley.rows.get_mut(row_selection.row)
191+
{
192+
let row = Arc::make_mut(&mut placed_row.row);
190193
for vertex_index in row_selection.vertex_indices {
191194
if let Some(vertex) = row
192195
.visuals
@@ -701,8 +704,8 @@ fn selected_text(galley: &Galley, cursor_range: &CCursorRange) -> String {
701704
}
702705

703706
fn estimate_row_height(galley: &Galley) -> f32 {
704-
if let Some(row) = galley.rows.first() {
705-
row.rect.height()
707+
if let Some(placed_row) = galley.rows.first() {
708+
placed_row.height()
706709
} else {
707710
galley.size().y
708711
}

crates/egui/src/text_selection/visuals.rs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,12 @@ pub fn paint_text_selection(
3131
let max = galley.layout_from_cursor(max);
3232

3333
for ri in min.row..=max.row {
34-
let row = &mut galley.rows[ri];
34+
let row = Arc::make_mut(&mut galley.rows[ri].row);
35+
3536
let left = if ri == min.row {
3637
row.x_offset(min.column)
3738
} else {
38-
row.rect.left()
39+
0.0
3940
};
4041
let right = if ri == max.row {
4142
row.x_offset(max.column)
@@ -45,10 +46,10 @@ pub fn paint_text_selection(
4546
} else {
4647
0.0
4748
};
48-
row.rect.right() + newline_size
49+
row.size.x + newline_size
4950
};
5051

51-
let rect = Rect::from_min_max(pos2(left, row.min_y()), pos2(right, row.max_y()));
52+
let rect = Rect::from_min_max(pos2(left, 0.0), pos2(right, row.size.y));
5253
let mesh = &mut row.visuals.mesh;
5354

5455
// Time to insert the selection rectangle into the row mesh.

crates/egui/src/widget_text.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -671,8 +671,8 @@ impl WidgetText {
671671
Self::RichText(text) => text.font_height(fonts, style),
672672
Self::LayoutJob(job) => job.font_height(fonts),
673673
Self::Galley(galley) => {
674-
if let Some(row) = galley.rows.first() {
675-
row.height().round_ui()
674+
if let Some(placed_row) = galley.rows.first() {
675+
placed_row.height().round_ui()
676676
} else {
677677
galley.size().y.round_ui()
678678
}

crates/egui/src/widgets/label.rs

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
use std::sync::Arc;
22

33
use crate::{
4-
epaint, pos2, text_selection, vec2, Align, Direction, FontSelection, Galley, Pos2, Response,
5-
Sense, Stroke, TextWrapMode, Ui, Widget, WidgetInfo, WidgetText, WidgetType,
4+
epaint, pos2, text_selection, Align, Direction, FontSelection, Galley, Pos2, Response, Sense,
5+
Stroke, TextWrapMode, Ui, Widget, WidgetInfo, WidgetText, WidgetType,
66
};
77

88
use self::text_selection::LabelSelectionState;
@@ -216,10 +216,10 @@ impl Label {
216216
let pos = pos2(ui.max_rect().left(), ui.cursor().top());
217217
assert!(!galley.rows.is_empty(), "Galleys are never empty");
218218
// collect a response from many rows:
219-
let rect = galley.rows[0].rect.translate(vec2(pos.x, pos.y));
219+
let rect = galley.rows[0].rect().translate(pos.to_vec2());
220220
let mut response = ui.allocate_rect(rect, sense);
221-
for row in galley.rows.iter().skip(1) {
222-
let rect = row.rect.translate(vec2(pos.x, pos.y));
221+
for placed_row in galley.rows.iter().skip(1) {
222+
let rect = placed_row.rect().translate(pos.to_vec2());
223223
response |= ui.allocate_rect(rect, sense);
224224
}
225225
(pos, galley, response)

crates/egui_demo_lib/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ serde = { workspace = true, optional = true }
5858
criterion.workspace = true
5959
egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] }
6060
egui = { workspace = true, features = ["default_fonts"] }
61+
rand = "0.9"
6162

6263
[[bench]]
6364
name = "benchmark"

crates/egui_demo_lib/benches/benchmark.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
use std::fmt::Write as _;
2+
13
use criterion::{criterion_group, criterion_main, Criterion};
24

35
use egui::epaint::TextShape;
46
use egui_demo_lib::LOREM_IPSUM_LONG;
7+
use rand::Rng as _;
58

69
pub fn criterion_benchmark(c: &mut Criterion) {
710
use egui::RawInput;
@@ -128,6 +131,30 @@ pub fn criterion_benchmark(c: &mut Criterion) {
128131
});
129132
});
130133

134+
c.bench_function("text_layout_cached_many_lines_modified", |b| {
135+
const NUM_LINES: usize = 2_000;
136+
137+
let mut string = String::new();
138+
for _ in 0..NUM_LINES {
139+
for i in 0..30_u8 {
140+
write!(string, "{i:02X} ").unwrap();
141+
}
142+
string.push('\n');
143+
}
144+
145+
let mut rng = rand::rng();
146+
b.iter(|| {
147+
fonts.begin_pass(pixels_per_point, max_texture_side);
148+
149+
// Delete a random character, simulating a user making an edit in a long file:
150+
let mut new_string = string.clone();
151+
let idx = rng.random_range(0..string.len());
152+
new_string.remove(idx);
153+
154+
fonts.layout(new_string, font_id.clone(), text_color, wrap_width);
155+
});
156+
});
157+
131158
let galley = fonts.layout(LOREM_IPSUM_LONG.to_owned(), font_id, text_color, wrap_width);
132159
let font_image_size = fonts.font_image_size();
133160
let prepared_discs = fonts.texture_atlas().lock().prepared_discs();

crates/emath/src/pos2.rs

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
use std::fmt;
2-
use std::ops::{Add, AddAssign, Sub, SubAssign};
1+
use std::{
2+
fmt,
3+
ops::{Add, AddAssign, MulAssign, Sub, SubAssign},
4+
};
35

46
use crate::{lerp, Div, Mul, Vec2};
57

@@ -305,6 +307,14 @@ impl Mul<Pos2> for f32 {
305307
}
306308
}
307309

310+
impl MulAssign<f32> for Pos2 {
311+
#[inline(always)]
312+
fn mul_assign(&mut self, rhs: f32) {
313+
self.x *= rhs;
314+
self.y *= rhs;
315+
}
316+
}
317+
308318
impl Div<f32> for Pos2 {
309319
type Output = Self;
310320

0 commit comments

Comments
 (0)