From 0c87e02f5568000c1a0c40f4f7c5e875989dcd5c Mon Sep 17 00:00:00 2001 From: wayne Date: Sat, 9 Apr 2022 11:18:33 +0000 Subject: [PATCH] egui_extras: enable virtual scroll for heterogenous rows (#1444) Introduce `TableBody::heterogenous_rows` for "virtual scrolling" over rows with differing heights. --- egui_demo_lib/src/apps/demo/table_demo.rs | 118 +++++++++++++++--- egui_extras/src/table.rs | 144 +++++++++++++++++++--- 2 files changed, 228 insertions(+), 34 deletions(-) diff --git a/egui_demo_lib/src/apps/demo/table_demo.rs b/egui_demo_lib/src/apps/demo/table_demo.rs index 641e003f..b2eb5083 100644 --- a/egui_demo_lib/src/apps/demo/table_demo.rs +++ b/egui_demo_lib/src/apps/demo/table_demo.rs @@ -1,12 +1,14 @@ use egui::TextStyle; -use egui_extras::{Size, StripBuilder, TableBuilder}; +use egui_extras::{Size, StripBuilder, TableBuilder, TableRow}; /// Shows off a table with dynamic layout #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[derive(Default)] pub struct TableDemo { + heterogeneous_rows: bool, virtual_scroll: bool, resizable: bool, + num_rows: usize, } impl super::Demo for TableDemo { @@ -28,14 +30,40 @@ impl super::Demo for TableDemo { impl super::View for TableDemo { fn ui(&mut self, ui: &mut egui::Ui) { - ui.checkbox(&mut self.virtual_scroll, "Virtual scroll"); - ui.checkbox(&mut self.resizable, "Resizable columns"); - + let mut settings_height = 44.0; + if self.virtual_scroll { + settings_height = 66.0; + } else { + self.heterogeneous_rows = false + } // Leave room for the source code link after the table demo: StripBuilder::new(ui) + .size(Size::exact(settings_height)) // for the settings .size(Size::remainder()) // for the table .size(Size::exact(10.0)) // for the source code link .vertical(|mut strip| { + strip.cell(|ui| { + StripBuilder::new(ui) + .size(Size::exact(150.0)) + .size(Size::remainder()) + .horizontal(|mut strip| { + strip.cell(|ui| { + ui.checkbox(&mut self.virtual_scroll, "Virtual Scroll"); + if self.virtual_scroll { + ui.checkbox(&mut self.heterogeneous_rows, "Heterogeneous rows"); + } + ui.checkbox(&mut self.resizable, "Resizable columns"); + }); + if self.virtual_scroll { + strip.cell(|ui| { + ui.add( + egui::Slider::new(&mut self.num_rows, 0..=300_000) + .text("Num rows"), + ); + }); + } + }); + }); strip.cell(|ui| { self.table_ui(ui); }); @@ -77,19 +105,25 @@ impl TableDemo { }) .body(|mut body| { if self.virtual_scroll { - body.rows(text_height, 100_000, |row_index, mut row| { - row.col(|ui| { - ui.label(row_index.to_string()); + if !self.heterogeneous_rows { + body.rows(text_height, self.num_rows, |row_index, mut row| { + row.col(|ui| { + ui.label(row_index.to_string()); + }); + row.col(|ui| { + ui.label(clock_emoji(row_index)); + }); + row.col(|ui| { + ui.add( + egui::Label::new("Thousands of rows of even height") + .wrap(false), + ); + }); }); - row.col(|ui| { - ui.label(clock_emoji(row_index)); - }); - row.col(|ui| { - ui.add( - egui::Label::new("Thousands of rows of even height").wrap(false), - ); - }); - }); + } else { + let rows = DemoRows::new(self.num_rows); + body.heterogeneous_rows(rows, DemoRows::populate_row); + } } else { for row_index in 0..20 { let thick = row_index % 6 == 0; @@ -122,6 +156,58 @@ impl TableDemo { } } +struct DemoRows { + row_count: usize, + current_row: usize, +} + +impl DemoRows { + fn new(row_count: usize) -> Self { + Self { + row_count, + current_row: 0, + } + } + + fn populate_row(index: usize, mut row: TableRow<'_, '_>) { + let thick = index % 6 == 0; + row.col(|ui| { + ui.centered_and_justified(|ui| { + ui.label(index.to_string()); + }); + }); + row.col(|ui| { + ui.centered_and_justified(|ui| { + ui.label(clock_emoji(index)); + }); + }); + row.col(|ui| { + ui.centered_and_justified(|ui| { + ui.style_mut().wrap = Some(false); + if thick { + ui.heading("Extra thick row"); + } else { + ui.label("Normal row"); + } + }); + }); + } +} + +impl Iterator for DemoRows { + type Item = f32; + + fn next(&mut self) -> Option { + if self.current_row < self.row_count { + let thick = self.current_row % 6 == 0; + self.current_row += 1; + Some(if thick { 30.0 } else { 18.0 }) + } else { + None + } + } +} + fn clock_emoji(row_index: usize) -> String { char::from_u32(0x1f550 + row_index as u32 % 24) .unwrap() diff --git a/egui_extras/src/table.rs b/egui_extras/src/table.rs index caa5e13d..577093a3 100644 --- a/egui_extras/src/table.rs +++ b/egui_extras/src/table.rs @@ -354,24 +354,126 @@ pub struct TableBody<'a> { } impl<'a> TableBody<'a> { + fn y_progress(&self) -> f32 { + self.start_y - self.layout.current_y() + } + + /// Return a vector containing all column widths for this table body. + /// + /// This is primarily meant for use with [`TableBody::heterogeneous_rows`] in cases where row + /// heights are expected to according to the width of one or more cells -- for example, if text + /// is wrapped rather than clippped within the cell. + pub fn widths(&self) -> &[f32] { + &self.widths + } + + /// Add rows with varying heights. + /// + /// This takes a very slight performance hit compared to [`TableBody::rows`] due to the need to + /// iterate over all row heights in to calculate the virtual table height above and below the + /// visible region, but it is many orders of magnitude more performant than adding individual + /// heterogenously-sized rows using [`TableBody::row`] at the cost of the additional complexity + /// that comes with pre-calculating row heights and representing them as an iterator. + /// + /// ### Example + /// ``` + /// # egui::__run_test_ui(|ui| { + /// use egui_extras::{TableBuilder, Size}; + /// TableBuilder::new(ui) + /// .column(Size::remainder().at_least(100.0)) + /// .body(|mut body| { + /// let row_heights: Vec = vec![60.0, 18.0, 31.0, 240.0]; + /// body.heterogeneous_rows(row_heights.into_iter(), |row_index, mut row| { + /// let thick = row_index % 6 == 0; + /// row.col(|ui| { + /// ui.centered_and_justified(|ui| { + /// ui.label(row_index.to_string()); + /// }); + /// }); + /// }); + /// }); + /// # }); + /// ``` + pub fn heterogeneous_rows( + &mut self, + heights: impl Iterator, + mut populate_row: impl FnMut(usize, TableRow<'_, '_>), + ) { + // in order for each row to retain its striped color as the table is scrolled, we need an + // iterator with the boolean built in based on the enumerated index of the iterator element + let mut striped_heights = heights + .enumerate() + .map(|(index, height)| (index, index % 2 == 0, height)); + + let max_height = self.end_y - self.start_y; + let y_progress = self.y_progress(); + + // cumulative height of all rows above those being displayed + let mut height_above_visible: f64 = 0.0; + // cumulative height of all rows below those being displayed + let mut height_below_visible: f64 = 0.0; + + // calculate height above visible table range and populate the first non-virtual row. + // because this row is meant to slide under the top bound of the visual table we calculate + // height_of_first_row + height_above_visible >= y_progress as our break condition rather + // than just height_above_visible >= y_progress + for (row_index, striped, height) in &mut striped_heights { + if height as f64 + height_above_visible >= y_progress as f64 { + self.add_buffer(height_above_visible as f32); + let tr = TableRow { + layout: &mut self.layout, + widths: &self.widths, + striped: self.striped && striped, + height, + }; + self.row_nr += 1; + populate_row(row_index, tr); + break; + } + height_above_visible += height as f64; + } + + // populate visible rows, including the final row that should slide under the bottom bound + // of the visible table. + let mut current_height: f64 = 0.0; + for (row_index, striped, height) in &mut striped_heights { + if height as f64 + current_height > max_height as f64 { + break; + } + let tr = TableRow { + layout: &mut self.layout, + widths: &self.widths, + striped: self.striped && striped, + height, + }; + self.row_nr += 1; + populate_row(row_index, tr); + current_height += height as f64; + } + + // calculate height below the visible table range + for (_, _, height) in striped_heights { + height_below_visible += height as f64 + } + + // if height below visible is > 0 here then we need to add a buffer to allow the table to + // accurately calculate the "virtual" scrollbar position + if height_below_visible > 0.0 { + self.add_buffer(height_below_visible as f32); + } + } + /// Add rows with same height. /// /// Is a lot more performant than adding each individual row as non visible rows must not be rendered pub fn rows(mut self, height: f32, rows: usize, mut row: impl FnMut(usize, TableRow<'_, '_>)) { - let delta = self.layout.current_y() - self.start_y; + let y_progress = self.y_progress(); let mut start = 0; - if delta < 0.0 { - start = (-delta / height).floor() as usize; + if y_progress > 0.0 { + start = (y_progress / height).floor() as usize; - let skip_height = start as f32 * height; - TableRow { - layout: &mut self.layout, - widths: &self.widths, - striped: false, - height: skip_height, - } - .col(|_| ()); // advances the cursor + self.add_buffer(y_progress); } let max_height = self.end_y - self.start_y; @@ -393,13 +495,7 @@ impl<'a> TableBody<'a> { if rows - end > 0 { let skip_height = (rows - end) as f32 * height; - TableRow { - layout: &mut self.layout, - widths: &self.widths, - striped: false, - height: skip_height, - } - .col(|_| ()); // advances the cursor + self.add_buffer(skip_height); } } @@ -414,6 +510,18 @@ impl<'a> TableBody<'a> { self.row_nr += 1; } + + // Create a table row buffer of the given height to represent the non-visible portion of the + // table. + fn add_buffer(&mut self, height: f32) { + TableRow { + layout: &mut self.layout, + widths: &self.widths, + striped: false, + height, + } + .col(|_| ()); // advances the cursor + } } impl<'a> Drop for TableBody<'a> {