8000 epaint: Break up memozation of text layouts into paragraphs by dacid44 · Pull Request #4000 · emilk/egui · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

epaint: Break up memozation of text layouts into paragraphs #4000

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from

Conversation

dacid44
Copy link
@dacid44 dacid44 commented Feb 7, 2024

Draft for now, I just want to confirm that I'm on the right track for the general logic, and make sure I know about the edge cases when splitting LayoutJobs and merging Galleys. It looks like I will need to:

  • Filter which LayoutSections go into each LayoutJob, and change their offsets
  • Possibly make sure each LayoutJob ends with a newline
  • When merging Galleys, merge the rects and mesh_bounds of each Galley together, and possibly offset them?
  • Make sure the elided field makes it onto the full Galley if set on any paragraph, and stop adding the following Galleys if that field is set
  • Sum num_vertices and num_indices
  • Anything else?

The code compiles right now, but does not pass cargo cranky and panics immediately when that codepath is run (byte index out of bounds, probably due to not filtering and offsetting LayoutSections. Also the todo!().)

@dacid44
Copy link
Author
dacid44 commented Feb 8, 2024

This is currently broken. No matter how many things I try to add the y-offset of the previous galleys to, all of the paragraphs render in the top left corner of the text box, on top of each other. I'd appreciate any insights as to why this might be.
image

Also, splitting up the LayoutJob (or at least, my current implementation of it) doesn't preserve the indices of the sections. Will I have to offset Glyph.section_index to correct this as well?

@haricot
Copy link
Contributor
haricot commented Feb 9, 2024

My two cents:

The current Paragraph struct is misnamed, as it refers to a block of text that will be split into lines, not a typographic paragraph "/n/n". TextBlock would be a more appropriate name.

When generating the TextBlocks, only the X positioning of glyphs is calculated. The Y positions are ignored at this stage.

Line breaks happen later when converting TextBlocks to Rows, based on the wrap width. A TextBlock can span multiple Rows if it exceeds the wrap width.

To properly position the wrapped Rows vertically, each TextBlock needs a paragraph index that is incremented for each one.

When generating the Rows in line_break(), the paragraph (TextBlock ) index can be used to increment the Y position, so Rows from the same TextBlock are offset vertically.

This prevents the issue of Rows overlapping each other at the same Y position when breaking a TextBlock into multiple Rows.

So in summary, the TextBlocks are intermediate storage of positioned glyphs before line wrapping. The line b 8000 reaks and Y positioning happen in a later stage when converting TextBlocks to Rows.

@dacid44
Copy link
Author
dacid44 commented Feb 15, 2024

Huh, it says your comment was posted on the 10th, but I've been regularly checking this PR and it didn't appear for me until today. Weird.

Anyway, it looks like all use of the Paragraph struct seems to happen with the layout() function in text_layout.rs. I hadn't actually even looked deep enough to see that the Paragraph struct existed, as I was mostly treating that function as a black box, and trying to merge Galleys together after they are returned from that function. Are you recommending modifying it? The original proposal was to still cache rendering at the Galley level, and just to cache smaller Galleys, but it could actually be better to memoize further down.

My main question is: what is the frame of reference for the rendering positions inside a Galley? If I want to position a Galley directly below another one and merge their rows (and thus glyphs) together, what do I need to change to position them properly?

@haricot
Copy link
Contributor
haricot commented Feb 16, 2024

Maybe instead of using galley_from_rows function here in layout function we could use another function galley_from_rows_with_previous_offset_y or galley_from_rows_with_full_previous_len_rows ,
so that the paragraphs composed of rows do not overlap (I allow myself to temporarily call them "textBlock" or "textChunk" to avoid any ambiguity).
It may also be necessary to customize layout functions to receive the previous information.

/// Layout text into a [`Galley`].
///
/// 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<LayoutJob>) -> Galley {
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),
mesh_bounds: Rect::NOTHING,
num_vertices: 0,
num_indices: 0,
pixels_per_point: fonts.pixels_per_point(),
elided: true,
};
}
// For most of this we ignore the y coordinate:
let mut paragraphs = vec![Paragraph::from_section_index(0)];
for (section_index, section) in job.sections.iter().enumerate() {
layout_section(fonts, &job, section_index as u32, section, &mut paragraphs);
}
let point_scale 8000 = PointScale::new(fonts.pixels_per_point());
let mut elided = false;
let mut rows = rows_from_paragraphs(paragraphs, &job, &mut elided);
if elided {
if let Some(last_row) = rows.last_mut() {
replace_last_glyph_with_overflow_character(fonts, &job, last_row);
}
}
let justify = job.justify && job.wrap.max_width.is_finite();
if justify || job.halign != Align::LEFT {
let num_rows = rows.len();
for (i, 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;
halign_and_justify_row(
point_scale,
row,
job.halign,
job.wrap.max_width,
justify_row,
);
}
}
// Calculate the Y positions and tessellate the text:
galley_from_rows(point_scale, job, rows, elided)

@enomado
Copy link
Contributor
enomado commented Mar 17, 2024

I dont get the solution, may be my problem is different than solving here. I want to show BIG 2 megabytes text, so why just dont virtualize lines, and make user pass big text as Vec<String>, and virtualize it.

And make selection api as Range<(row, col)>

emilk added a commit that referenced this pull request Apr 1, 2025
## 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 <emil.ernerfeldt@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Optimizing re-layout of 1MB+ pieces of text in a TextEdit
3 participants
0