mirror of
https://github.com/emilk/egui.git
synced 2026-06-27 15:13:12 -04:00
## 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](<https://github.com/emilk/egui/issues/3086#issuecomment-1724205777>), 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 <https://github.com/emilk/egui/issues/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 <https://github.com/emilk/egui/issues/3086> * [X] I have followed the instructions in the PR template --------- Co-authored-by: Emil Ernerfeldt <emil.ernerfeldt@gmail.com>
250 lines
7.3 KiB
Rust
250 lines
7.3 KiB
Rust
//! Collect statistics about what is being painted.
|
|
|
|
use crate::{ClippedShape, Galley, Mesh, Primitive, Shape};
|
|
|
|
/// Size of the elements in a vector/array.
|
|
#[derive(Clone, Copy, PartialEq)]
|
|
enum ElementSize {
|
|
Unknown,
|
|
Homogeneous(usize),
|
|
Heterogenous,
|
|
}
|
|
|
|
impl Default for ElementSize {
|
|
fn default() -> Self {
|
|
Self::Unknown
|
|
}
|
|
}
|
|
|
|
/// Aggregate information about a bunch of allocations.
|
|
#[derive(Clone, Copy, Default, PartialEq)]
|
|
pub struct AllocInfo {
|
|
element_size: ElementSize,
|
|
num_allocs: usize,
|
|
num_elements: usize,
|
|
num_bytes: usize,
|
|
}
|
|
|
|
impl<T> From<&[T]> for AllocInfo {
|
|
fn from(slice: &[T]) -> Self {
|
|
Self::from_slice(slice)
|
|
}
|
|
}
|
|
|
|
impl std::ops::Add for AllocInfo {
|
|
type Output = Self;
|
|
|
|
fn add(self, rhs: Self) -> Self {
|
|
use ElementSize::{Heterogenous, Homogeneous, Unknown};
|
|
let element_size = match (self.element_size, rhs.element_size) {
|
|
(Heterogenous, _) | (_, Heterogenous) => Heterogenous,
|
|
(Unknown, other) | (other, Unknown) => other,
|
|
(Homogeneous(lhs), Homogeneous(rhs)) if lhs == rhs => Homogeneous(lhs),
|
|
_ => Heterogenous,
|
|
};
|
|
|
|
Self {
|
|
element_size,
|
|
num_allocs: self.num_allocs + rhs.num_allocs,
|
|
num_elements: self.num_elements + rhs.num_elements,
|
|
num_bytes: self.num_bytes + rhs.num_bytes,
|
|
}
|
|
}
|
|
}
|
|
|
|
impl std::ops::AddAssign for AllocInfo {
|
|
fn add_assign(&mut self, rhs: Self) {
|
|
*self = *self + rhs;
|
|
}
|
|
}
|
|
|
|
impl std::iter::Sum for AllocInfo {
|
|
fn sum<I>(iter: I) -> Self
|
|
where
|
|
I: Iterator<Item = Self>,
|
|
{
|
|
let mut sum = Self::default();
|
|
for value in iter {
|
|
sum += value;
|
|
}
|
|
sum
|
|
}
|
|
}
|
|
|
|
impl AllocInfo {
|
|
// pub fn from_shape(shape: &Shape) -> Self {
|
|
// match shape {
|
|
// Shape::Noop
|
|
// Shape::Vec(shapes) => Self::from_shapes(shapes)
|
|
// | Shape::Circle { .. }
|
|
// | Shape::LineSegment { .. }
|
|
// | Shape::Rect { .. } => Self::default(),
|
|
// Shape::Path { points, .. } => Self::from_slice(points),
|
|
// Shape::Text { galley, .. } => Self::from_galley(galley),
|
|
// Shape::Mesh(mesh) => Self::from_mesh(mesh),
|
|
// }
|
|
// }
|
|
|
|
pub fn from_galley(galley: &Galley) -> Self {
|
|
Self::from_slice(galley.text().as_bytes())
|
|
+ Self::from_slice(&galley.rows)
|
|
+ galley.rows.iter().map(Self::from_galley_row).sum()
|
|
}
|
|
|
|
fn from_galley_row(row: &crate::text::PlacedRow) -> Self {
|
|
Self::from_mesh(&row.visuals.mesh) + Self::from_slice(&row.glyphs)
|
|
}
|
|
|
|
pub fn from_mesh(mesh: &Mesh) -> Self {
|
|
Self::from_slice(&mesh.indices) + Self::from_slice(&mesh.vertices)
|
|
}
|
|
|
|
pub fn from_slice<T>(slice: &[T]) -> Self {
|
|
use std::mem::size_of;
|
|
let element_size = size_of::<T>();
|
|
Self {
|
|
element_size: ElementSize::Homogeneous(element_size),
|
|
num_allocs: 1,
|
|
num_elements: slice.len(),
|
|
num_bytes: std::mem::size_of_val(slice),
|
|
}
|
|
}
|
|
|
|
pub fn num_elements(&self) -> usize {
|
|
assert!(
|
|
self.element_size != ElementSize::Heterogenous,
|
|
"Heterogenous element size"
|
|
);
|
|
self.num_elements
|
|
}
|
|
|
|
pub fn num_allocs(&self) -> usize {
|
|
self.num_allocs
|
|
}
|
|
|
|
pub fn num_bytes(&self) -> usize {
|
|
self.num_bytes
|
|
}
|
|
|
|
pub fn megabytes(&self) -> String {
|
|
megabytes(self.num_bytes())
|
|
}
|
|
|
|
pub fn format(&self, what: &str) -> String {
|
|
if self.num_allocs() == 0 {
|
|
format!("{:6} {:16}", 0, what)
|
|
} else if self.num_allocs() == 1 {
|
|
format!(
|
|
"{:6} {:16} {} 1 allocation",
|
|
self.num_elements,
|
|
what,
|
|
self.megabytes()
|
|
)
|
|
} else if self.element_size != ElementSize::Heterogenous {
|
|
format!(
|
|
"{:6} {:16} {} {:3} allocations",
|
|
self.num_elements(),
|
|
what,
|
|
self.megabytes(),
|
|
self.num_allocs()
|
|
)
|
|
} else {
|
|
format!(
|
|
"{:6} {:16} {} {:3} allocations",
|
|
"",
|
|
what,
|
|
self.megabytes(),
|
|
self.num_allocs()
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Collected allocation statistics for shapes and meshes.
|
|
#[derive(Clone, Copy, Default)]
|
|
pub struct PaintStats {
|
|
pub shapes: AllocInfo,
|
|
pub shape_text: AllocInfo,
|
|
pub shape_path: AllocInfo,
|
|
pub shape_mesh: AllocInfo,
|
|
pub shape_vec: AllocInfo,
|
|
pub num_callbacks: usize,
|
|
|
|
pub text_shape_vertices: AllocInfo,
|
|
pub text_shape_indices: AllocInfo,
|
|
|
|
/// Number of separate clip rectangles
|
|
pub clipped_primitives: AllocInfo,
|
|
pub vertices: AllocInfo,
|
|
pub indices: AllocInfo,
|
|
}
|
|
|
|
impl PaintStats {
|
|
pub fn from_shapes(shapes: &[ClippedShape]) -> Self {
|
|
let mut stats = Self::default();
|
|
stats.shape_path.element_size = ElementSize::Heterogenous; // nicer display later
|
|
stats.shape_vec.element_size = ElementSize::Heterogenous; // nicer display later
|
|
|
|
stats.shapes = AllocInfo::from_slice(shapes);
|
|
for ClippedShape { shape, .. } in shapes {
|
|
stats.add(shape);
|
|
}
|
|
stats
|
|
}
|
|
|
|
fn add(&mut self, shape: &Shape) {
|
|
match shape {
|
|
Shape::Vec(shapes) => {
|
|
// self += PaintStats::from_shapes(&shapes); // TODO(emilk)
|
|
self.shapes += AllocInfo::from_slice(shapes);
|
|
self.shape_vec += AllocInfo::from_slice(shapes);
|
|
for shape in shapes {
|
|
self.add(shape);
|
|
}
|
|
}
|
|
Shape::Noop
|
|
| Shape::Circle { .. }
|
|
| Shape::Ellipse { .. }
|
|
| Shape::LineSegment { .. }
|
|
| Shape::Rect { .. }
|
|
| Shape::CubicBezier(_)
|
|
| Shape::QuadraticBezier(_) => {}
|
|
Shape::Path(path_shape) => {
|
|
self.shape_path += AllocInfo::from_slice(&path_shape.points);
|
|
}
|
|
Shape::Text(text_shape) => {
|
|
self.shape_text += AllocInfo::from_galley(&text_shape.galley);
|
|
|
|
for row in &text_shape.galley.rows {
|
|
self.text_shape_indices += AllocInfo::from_slice(&row.visuals.mesh.indices);
|
|
self.text_shape_vertices += AllocInfo::from_slice(&row.visuals.mesh.vertices);
|
|
}
|
|
}
|
|
Shape::Mesh(mesh) => {
|
|
self.shape_mesh += AllocInfo::from_mesh(mesh);
|
|
}
|
|
Shape::Callback(_) => {
|
|
self.num_callbacks += 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn with_clipped_primitives(
|
|
mut self,
|
|
clipped_primitives: &[crate::ClippedPrimitive],
|
|
) -> Self {
|
|
self.clipped_primitives += AllocInfo::from_slice(clipped_primitives);
|
|
for clipped_primitive in clipped_primitives {
|
|
if let Primitive::Mesh(mesh) = &clipped_primitive.primitive {
|
|
self.vertices += AllocInfo::from_slice(&mesh.vertices);
|
|
self.indices += AllocInfo::from_slice(&mesh.indices);
|
|
}
|
|
}
|
|
self
|
|
}
|
|
}
|
|
|
|
fn megabytes(size: usize) -> String {
|
|
format!("{:.2} MB", size as f64 / 1e6)
|
|
}
|