diff --git a/crates/egui/src/containers/scroll_area.rs b/crates/egui/src/containers/scroll_area.rs index 64345cbe4..98bb959f9 100644 --- a/crates/egui/src/containers/scroll_area.rs +++ b/crates/egui/src/containers/scroll_area.rs @@ -66,6 +66,10 @@ pub struct ScrollAreaOutput { /// The current state of the scroll area. pub state: State, + /// The size of the content. If this is larger than [`Self::inner_rect`], + /// then there was need for scrolling. + pub content_size: Vec2, + /// Where on the screen the content is (excludes scroll bars). pub inner_rect: Rect, } @@ -198,6 +202,8 @@ impl ScrollArea { /// Set the horizontal and vertical scroll offset position. /// + /// Positive offset means scrolling down/right. + /// /// See also: [`Self::vertical_scroll_offset`], [`Self::horizontal_scroll_offset`], /// [`Ui::scroll_to_cursor`](crate::ui::Ui::scroll_to_cursor) and /// [`Response::scroll_to_me`](crate::Response::scroll_to_me) @@ -209,6 +215,8 @@ impl ScrollArea { /// Set the vertical scroll offset position. /// + /// Positive offset means scrolling down. + /// /// See also: [`Self::scroll_offset`], [`Ui::scroll_to_cursor`](crate::ui::Ui::scroll_to_cursor) and /// [`Response::scroll_to_me`](crate::Response::scroll_to_me) pub fn vertical_scroll_offset(mut self, offset: f32) -> Self { @@ -218,6 +226,8 @@ impl ScrollArea { /// Set the horizontal scroll offset position. /// + /// Positive offset means scrolling right. + /// /// See also: [`Self::scroll_offset`], [`Ui::scroll_to_cursor`](crate::ui::Ui::scroll_to_cursor) and /// [`Response::scroll_to_me`](crate::Response::scroll_to_me) pub fn horizontal_scroll_offset(mut self, offset: f32) -> Self { @@ -541,18 +551,20 @@ impl ScrollArea { let id = prepared.id; let inner_rect = prepared.inner_rect; let inner = add_contents(&mut prepared.content_ui, prepared.viewport); - let state = prepared.end(ui); + let (content_size, state) = prepared.end(ui); ScrollAreaOutput { inner, id, state, + content_size, inner_rect, } } } impl Prepared { - fn end(self, ui: &mut Ui) -> State { + /// Returns content size and state + fn end(self, ui: &mut Ui) -> (Vec2, State) { let Prepared { id, mut state, @@ -847,7 +859,7 @@ impl Prepared { state.store(ui.ctx(), id); - state + (content_size, state) } } diff --git a/crates/egui_demo_app/Cargo.toml b/crates/egui_demo_app/Cargo.toml index e8bb5617d..21b9bab7e 100644 --- a/crates/egui_demo_app/Cargo.toml +++ b/crates/egui_demo_app/Cargo.toml @@ -20,8 +20,8 @@ default = ["glow", "persistence"] http = ["ehttp", "image", "poll-promise", "egui_extras/image"] persistence = ["eframe/persistence", "egui/persistence", "serde"] -screen_reader = ["eframe/screen_reader"] # experimental -serde = ["dep:serde", "egui_demo_lib/serde", "egui_extras/serde", "egui/serde"] +screen_reader = ["eframe/screen_reader"] # experimental +serde = ["dep:serde", "egui_demo_lib/serde", "egui/serde"] syntax_highlighting = ["egui_demo_lib/syntax_highlighting"] glow = ["eframe/glow"] diff --git a/crates/egui_demo_lib/src/demo/table_demo.rs b/crates/egui_demo_lib/src/demo/table_demo.rs index 09b4cef4d..01e0bdd98 100644 --- a/crates/egui_demo_lib/src/demo/table_demo.rs +++ b/crates/egui_demo_lib/src/demo/table_demo.rs @@ -10,20 +10,22 @@ enum DemoType { #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] pub struct TableDemo { demo: DemoType, + striped: bool, resizable: bool, num_rows: usize, - row_to_scroll_to: i32, - vertical_scroll_offset: Option, + scroll_to_row_slider: usize, + scroll_to_row: Option, } impl Default for TableDemo { fn default() -> Self { Self { demo: DemoType::Manual, + striped: true, resizable: true, num_rows: 10_000, - row_to_scroll_to: 0, - vertical_scroll_offset: None, + scroll_to_row_slider: 0, + scroll_to_row: None, } } } @@ -45,16 +47,15 @@ impl super::Demo for TableDemo { } } -fn scroll_offset_for_row(ui: &egui::Ui, row: i32) -> f32 { - let text_height = egui::TextStyle::Body.resolve(ui.style()).size; - let row_item_spacing = ui.spacing().item_spacing.y; - row as f32 * (text_height + row_item_spacing) -} +const NUM_MANUAL_ROWS: usize = 32; impl super::View for TableDemo { fn ui(&mut self, ui: &mut egui::Ui) { ui.vertical(|ui| { - ui.checkbox(&mut self.resizable, "Resizable columns"); + ui.horizontal(|ui| { + ui.checkbox(&mut self.striped, "Striped"); + ui.checkbox(&mut self.resizable, "Resizable columns"); + }); ui.label("Table type:"); ui.radio_value(&mut self.demo, DemoType::Manual, "Few, manual rows"); @@ -77,16 +78,20 @@ impl super::View for TableDemo { ); } - if self.demo == DemoType::ManyHomogenous { - ui.add( - egui::Slider::new(&mut self.row_to_scroll_to, 0..=self.num_rows as i32) + { + let max_rows = if self.demo == DemoType::Manual { + NUM_MANUAL_ROWS + } else { + self.num_rows + }; + + let slider_response = ui.add( + egui::Slider::new(&mut self.scroll_to_row_slider, 0..=max_rows) .logarithmic(true) .text("Row to scroll to"), ); - - if ui.button("Scroll to row").clicked() { - self.vertical_scroll_offset - .replace(scroll_offset_for_row(ui, self.row_to_scroll_to)); + if slider_response.changed() { + self.scroll_to_row = Some(self.scroll_to_row_slider); } } }); @@ -113,37 +118,45 @@ impl super::View for TableDemo { impl TableDemo { fn table_ui(&mut self, ui: &mut egui::Ui) { - use egui_extras::{Size, TableBuilder}; + use egui_extras::{Column, TableBuilder}; let text_height = egui::TextStyle::Body.resolve(ui.style()).size; let mut table = TableBuilder::new(ui) - .striped(true) + .striped(self.striped) .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) - .column(Size::initial(60.0).at_least(40.0)) - .column(Size::initial(60.0).at_least(40.0)) - .column(Size::remainder().at_least(60.0)) - .resizable(self.resizable); - - if let Some(y_scroll) = self.vertical_scroll_offset.take() { - table = table.vertical_scroll_offset(y_scroll); + .column(Column::auto()) + .column(Column::initial(100.0).range(40.0..=300.0).resizable(true)) + .column( + Column::initial(100.0) + .at_least(40.0) + .resizable(true) + .clip(true), + ) + .column(Column::remainder()); + + if let Some(row_nr) = self.scroll_to_row.take() { + table = table.scroll_to_row(row_nr, None); } table .header(20.0, |mut header| { header.col(|ui| { - ui.heading("Row"); + ui.strong("Row"); }); header.col(|ui| { - ui.heading("Clock"); + ui.strong("Expanding content"); }); header.col(|ui| { - ui.heading("Content"); + ui.strong("Clipped text"); + }); + header.col(|ui| { + ui.strong("Content"); }); }) .body(|mut body| match self.demo { DemoType::Manual => { - for row_index in 0..20 { + for row_index in 0..NUM_MANUAL_ROWS { let is_thick = thick_row(row_index); let row_height = if is_thick { 30.0 } else { 18.0 }; body.row(row_height, |mut row| { @@ -151,7 +164,10 @@ impl TableDemo { ui.label(row_index.to_string()); }); row.col(|ui| { - ui.label(clock_emoji(row_index)); + expanding_content(ui); + }); + row.col(|ui| { + ui.label(long_text(row_index)); }); row.col(|ui| { ui.style_mut().wrap = Some(false); @@ -170,7 +186,10 @@ impl TableDemo { ui.label(row_index.to_string()); }); row.col(|ui| { - ui.label(clock_emoji(row_index)); + expanding_content(ui); + }); + row.col(|ui| { + ui.label(long_text(row_index)); }); row.col(|ui| { ui.add( @@ -191,24 +210,21 @@ impl TableDemo { (0..self.num_rows).into_iter().map(row_thickness), |row_index, mut row| { row.col(|ui| { - ui.centered_and_justified(|ui| { - ui.label(row_index.to_string()); - }); + ui.label(row_index.to_string()); }); row.col(|ui| { - ui.centered_and_justified(|ui| { - ui.label(clock_emoji(row_index)); - }); + expanding_content(ui); }); row.col(|ui| { - ui.centered_and_justified(|ui| { - ui.style_mut().wrap = Some(false); - if thick_row(row_index) { - ui.heading("Extra thick row"); - } else { - ui.label("Normal row"); - } - }); + ui.label(long_text(row_index)); + }); + row.col(|ui| { + ui.style_mut().wrap = Some(false); + if thick_row(row_index) { + ui.heading("Extra thick row"); + } else { + ui.label("Normal row"); + } }); }, ); @@ -217,10 +233,19 @@ impl TableDemo { } } -fn clock_emoji(row_index: usize) -> String { - char::from_u32(0x1f550 + row_index as u32 % 24) - .unwrap() - .to_string() +fn expanding_content(ui: &mut egui::Ui) { + let width = ui.available_width().clamp(20.0, 200.0); + let height = ui.available_height(); + let (rect, _response) = ui.allocate_exact_size(egui::vec2(width, height), egui::Sense::hover()); + ui.painter().hline( + rect.x_range(), + rect.center().y, + (1.0, ui.visuals().text_color()), + ); +} + +fn long_text(row_index: usize) -> String { + format!("Row {row_index} has some long text that you may want to clip, or it will take up too much horizontal space!") } fn thick_row(row_index: usize) -> bool { diff --git a/crates/egui_extras/Cargo.toml b/crates/egui_extras/Cargo.toml index 4926febcf..f3a1b8e14 100644 --- a/crates/egui_extras/Cargo.toml +++ b/crates/egui_extras/Cargo.toml @@ -29,9 +29,6 @@ default = [] ## Enable [`DatePickerButton`] widget. datepicker = ["chrono"] -## Allow serialization using [`serde`](https://docs.rs/serde). -serde = ["dep:serde"] - ## Support loading svg images. svg = ["resvg", "tiny-skia", "usvg"] @@ -42,6 +39,8 @@ tracing = ["dep:tracing", "egui/tracing"] [dependencies] egui = { version = "0.19.0", path = "../egui", default-features = false } +serde = { version = "1", features = ["derive"] } + #! ### Optional dependencies # Date operations needed for datepicker widget @@ -63,9 +62,6 @@ resvg = { version = "0.23", optional = true } tiny-skia = { version = "0.6", optional = true } # must be updated in lock-step with resvg usvg = { version = "0.23", optional = true } -# feature "serde": -serde = { version = "1", features = ["derive"], optional = true } - # feature "tracing" tracing = { version = "0.1", optional = true, default-features = false, features = [ "std", diff --git a/crates/egui_extras/src/datepicker/button.rs b/crates/egui_extras/src/datepicker/button.rs index 23ccbd5bf..d6dcbfe25 100644 --- a/crates/egui_extras/src/datepicker/button.rs +++ b/crates/egui_extras/src/datepicker/button.rs @@ -2,8 +2,7 @@ use super::popup::DatePickerPopup; use chrono::{Date, Utc}; use egui::{Area, Button, Frame, InnerResponse, Key, Order, RichText, Ui, Widget}; -#[derive(Default, Clone)] -#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +#[derive(Default, Clone, serde::Deserialize, serde::Serialize)] pub(crate) struct DatePickerButtonState { pub picker_visible: bool, } diff --git a/crates/egui_extras/src/datepicker/popup.rs b/crates/egui_extras/src/datepicker/popup.rs index 445e2d6f0..637dfccfc 100644 --- a/crates/egui_extras/src/datepicker/popup.rs +++ b/crates/egui_extras/src/datepicker/popup.rs @@ -1,10 +1,12 @@ -use super::{button::DatePickerButtonState, month_data}; -use crate::{Size, StripBuilder, TableBuilder}; use chrono::{Date, Datelike, NaiveDate, Utc, Weekday}; + use egui::{Align, Button, Color32, ComboBox, Direction, Id, Layout, RichText, Ui, Vec2}; -#[derive(Default, Clone)] -#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] +use super::{button::DatePickerButtonState, month_data}; + +use crate::{Column, Size, StripBuilder, TableBuilder}; + +#[derive(Default, Clone, serde::Deserialize, serde::Serialize)] struct DatePickerPopupState { year: i32, month: u32, @@ -243,9 +245,8 @@ impl<'a> DatePickerPopup<'a> { strip.cell(|ui| { ui.spacing_mut().item_spacing = Vec2::new(1.0, 2.0); TableBuilder::new(ui) - .scroll(false) - .clip(false) - .columns(Size::remainder(), if self.calendar_week { 8 } else { 7 }) + .vscroll(false) + .columns(Column::remainder(), if self.calendar_week { 8 } else { 7 }) .header(height, |mut header| { if self.calendar_week { header.col(|ui| { diff --git a/crates/egui_extras/src/layout.rs b/crates/egui_extras/src/layout.rs index 56ae547d4..932594bf5 100644 --- a/crates/egui_extras/src/layout.rs +++ b/crates/egui_extras/src/layout.rs @@ -31,19 +31,15 @@ pub struct StripLayout<'l> { pub(crate) ui: &'l mut Ui, direction: CellDirection, pub(crate) rect: Rect, - cursor: Pos2, + pub(crate) cursor: Pos2, + /// Keeps track of the max used position, + /// so we know how much space we used. max: Pos2, - pub(crate) clip: bool, cell_layout: egui::Layout, } impl<'l> StripLayout<'l> { - pub(crate) fn new( - ui: &'l mut Ui, - direction: CellDirection, - clip: bool, - cell_layout: egui::Layout, - ) -> Self { + pub(crate) fn new(ui: &'l mut Ui, direction: CellDirection, cell_layout: egui::Layout) -> Self { let rect = ui.available_rect_before_wrap(); let pos = rect.left_top(); @@ -53,7 +49,6 @@ impl<'l> StripLayout<'l> { rect, cursor: pos, max: pos, - clip, cell_layout, } } @@ -92,34 +87,41 @@ impl<'l> StripLayout<'l> { self.set_pos(self.cell_rect(&width, &height)); } + /// This is the innermost part of [`crate::Table`] and [`crate::Strip`]. + /// + /// Return the used space (`min_rect`) plus the [`Response`] of the whole cell. pub(crate) fn add( &mut self, + clip: bool, + striped: bool, width: CellSize, height: CellSize, - add_contents: impl FnOnce(&mut Ui), - ) -> Response { - let rect = self.cell_rect(&width, &height); - let used_rect = self.cell(rect, add_contents); - self.set_pos(rect); - self.ui.allocate_rect(rect.union(used_rect), Sense::hover()) - } + add_cell_contents: impl FnOnce(&mut Ui), + ) -> (Rect, Response) { + let max_rect = self.cell_rect(&width, &height); - pub(crate) fn add_striped( - &mut self, - width: CellSize, - height: CellSize, - add_contents: impl FnOnce(&mut Ui), - ) -> Response { - let rect = self.cell_rect(&width, &height); + if striped { + // Make sure we don't have a gap in the stripe background: + let stripe_rect = max_rect.expand2(0.5 * self.ui.spacing().item_spacing); + + self.ui + .painter() + .rect_filled(stripe_rect, 0.0, self.ui.visuals().faint_bg_color); + } + + let used_rect = self.cell(clip, max_rect, add_cell_contents); + + self.set_pos(max_rect); - // Make sure we don't have a gap in the stripe background: - let rect = rect.expand2(0.5 * self.ui.spacing().item_spacing); + let allocation_rect = if clip { + max_rect + } else { + max_rect.union(used_rect) + }; - self.ui - .painter() - .rect_filled(rect, 0.0, self.ui.visuals().faint_bg_color); + let response = self.ui.allocate_rect(allocation_rect, Sense::hover()); - self.add(width, height, add_contents) + (used_rect, response) } /// only needed for layouts with multiple lines, like [`Table`](crate::Table). @@ -144,17 +146,17 @@ impl<'l> StripLayout<'l> { self.ui.allocate_rect(rect, Sense::hover()); } - fn cell(&mut self, rect: Rect, add_contents: impl FnOnce(&mut Ui)) -> Rect { + fn cell(&mut self, clip: bool, rect: Rect, add_cell_contents: impl FnOnce(&mut Ui)) -> Rect { let mut child_ui = self.ui.child_ui(rect, self.cell_layout); - if self.clip { + if clip { let margin = egui::Vec2::splat(self.ui.visuals().clip_rect_margin); let margin = margin.min(0.5 * self.ui.spacing().item_spacing); let clip_rect = rect.expand2(margin); child_ui.set_clip_rect(clip_rect.intersect(child_ui.clip_rect())); } - add_contents(&mut child_ui); + add_cell_contents(&mut child_ui); child_ui.min_rect() } diff --git a/crates/egui_extras/src/strip.rs b/crates/egui_extras/src/strip.rs index ac33ed186..9c0918590 100644 --- a/crates/egui_extras/src/strip.rs +++ b/crates/egui_extras/src/strip.rs @@ -56,11 +56,11 @@ impl<'a> StripBuilder<'a> { ui, sizing: Default::default(), cell_layout, - clip: true, + clip: false, } } - /// Should we clip the contents of each cell? Default: `true`. + /// Should we clip the contents of each cell? Default: `false`. pub fn clip(mut self, clip: bool) -> Self { self.clip = clip; self @@ -98,15 +98,11 @@ impl<'a> StripBuilder<'a> { self.ui.available_rect_before_wrap().width(), self.ui.spacing().item_spacing.x, ); - let mut layout = StripLayout::new( - self.ui, - CellDirection::Horizontal, - self.clip, - self.cell_layout, - ); + let mut layout = StripLayout::new(self.ui, CellDirection::Horizontal, self.cell_layout); strip(Strip { layout: &mut layout, direction: CellDirection::Horizontal, + clip: self.clip, sizes: widths, size_index: 0, }); @@ -125,15 +121,11 @@ impl<'a> StripBuilder<'a> { self.ui.available_rect_before_wrap().height(), self.ui.spacing().item_spacing.y, ); - let mut layout = StripLayout::new( - self.ui, - CellDirection::Vertical, - self.clip, - self.cell_layout, - ); + let mut layout = StripLayout::new(self.ui, CellDirection::Vertical, self.cell_layout); strip(Strip { layout: &mut layout, direction: CellDirection::Vertical, + clip: self.clip, sizes: heights, size_index: 0, }); @@ -146,6 +138,7 @@ impl<'a> StripBuilder<'a> { pub struct Strip<'a, 'b> { layout: &'b mut StripLayout<'a>, direction: CellDirection, + clip: bool, sizes: Vec, size_index: usize, } @@ -172,7 +165,9 @@ impl<'a, 'b> Strip<'a, 'b> { /// Add cell contents. pub fn cell(&mut self, add_contents: impl FnOnce(&mut Ui)) { let (width, height) = self.next_cell_size(); - self.layout.add(width, height, add_contents); + let striped = false; + self.layout + .add(self.clip, striped, width, height, add_contents); } /// Add an empty cell. @@ -183,7 +178,7 @@ impl<'a, 'b> Strip<'a, 'b> { /// Add a strip as cell. pub fn strip(&mut self, strip_builder: impl FnOnce(StripBuilder<'_>)) { - let clip = self.layout.clip; + let clip = self.clip; self.cell(|ui| { strip_builder(StripBuilder::new(ui).clip(clip)); }); diff --git a/crates/egui_extras/src/table.rs b/crates/egui_extras/src/table.rs index 3fc302e44..91aeb219a 100644 --- a/crates/egui_extras/src/table.rs +++ b/crates/egui_extras/src/table.rs @@ -3,47 +3,187 @@ //! | fixed size | all available space/minimum | 30% of available width | fixed size | //! Takes all available height, so if you want something below the table, put it in a strip. +use egui::{Align, NumExt as _, Rect, Response, Ui, Vec2}; + use crate::{ layout::{CellDirection, CellSize}, - sizing::Sizing, - Size, StripLayout, + StripLayout, }; -use egui::{Rect, Response, Ui, Vec2}; +// -----------------------------------------------------------------=---------- + +#[derive(Clone, Copy, Debug, PartialEq)] +enum InitialColumnSize { + /// Absolute size in points + Absolute(f32), + + /// Base on content + Automatic(f32), + + /// Take all available space + Remainder, +} + +/// Specifies the properties of a column, like its width range. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Column { + initial_width: InitialColumnSize, + width_range: (f32, f32), + /// Clip contents if too narrow? + clip: bool, + + resizable: Option, +} + +impl Column { + /// Automatically sized based on content. + /// + /// If you have many thousands of rows and are therefore using [`TableBody::rows`] + /// or [`TableBody::heterogeneous_rows`], then the automatic size will only be based + /// on the currently visible rows. + pub fn auto() -> Self { + Self::auto_with_initial_suggestion(100.0) + } + + /// Automatically sized. + /// + /// The given fallback is a loose suggestion, that may be used to wrap + /// cell contents, if they contain a wrapping layout. + /// In most cases though, the given value is ignored. + pub fn auto_with_initial_suggestion(suggested_width: f32) -> Self { + Self::new(InitialColumnSize::Automatic(suggested_width)) + } + + /// With this initial width. + pub fn initial(width: f32) -> Self { + Self::new(InitialColumnSize::Absolute(width)) + } + + /// Always this exact width, never shrink or grow. + pub fn exact(width: f32) -> Self { + Self::new(InitialColumnSize::Absolute(width)) + .range(width..=width) + .clip(true) + } + + /// Take all the space remaining after the other columns have + /// been sized. + /// + /// If you have multiple [`Column::remainder`] they all + /// share the remaining space equally. + pub fn remainder() -> Self { + Self::new(InitialColumnSize::Remainder) + } + + fn new(initial_width: InitialColumnSize) -> Self { + Self { + initial_width, + width_range: (0.0, f32::INFINITY), + resizable: None, + clip: false, + } + } + + /// Can this column be resized by dragging the column separator? + /// + /// If you don't call this, the fallback value of + /// [`TableBuilder::resizable`] is used (which by default is `false`). + pub fn resizable(mut self, resizable: bool) -> Self { + self.resizable = Some(resizable); + self + } + + /// If `true`: Allow the column to shrink enough to clip the contents. + /// If `false`: The column will always be wide enough to contain all its content. + /// + /// Clipping can make sense if you expect a column to contain a lot of things, + /// and you don't want it too take up too much space. + /// If you turn on clipping you should also consider calling [`Self::at_least`]. + /// + /// Default: `false`. + pub fn clip(mut self, clip: bool) -> Self { + self.clip = clip; + self + } + + /// Won't shrink below this width (in points). + /// + /// Default: 0.0 + pub fn at_least(mut self, minimum: f32) -> Self { + self.width_range.0 = minimum; + self + } + + /// Won't grow above this width (in points). + /// + /// Default: [`f32::INFINITY`] + pub fn at_most(mut self, maximum: f32) -> Self { + self.width_range.1 = maximum; + self + } + + /// Allowed range of movement (in points), if in a resizable [`Table`](crate::table::Table). + pub fn range(mut self, range: std::ops::RangeInclusive) -> Self { + self.width_range = (*range.start(), *range.end()); + self + } + + fn is_auto(&self) -> bool { + match self.initial_width { + InitialColumnSize::Automatic(_) => true, + InitialColumnSize::Absolute(_) | InitialColumnSize::Remainder => false, + } + } +} + +fn to_sizing(columns: &[Column]) -> crate::sizing::Sizing { + use crate::Size; + + let mut sizing = crate::sizing::Sizing::default(); + for column in columns { + let size = match column.initial_width { + InitialColumnSize::Absolute(width) => Size::exact(width), + InitialColumnSize::Automatic(suggested_width) => Size::initial(suggested_width), + InitialColumnSize::Remainder => Size::remainder(), + } + .at_least(column.width_range.0) + .at_most(column.width_range.1); + sizing.add(size); + } + sizing +} + +// -----------------------------------------------------------------=---------- /// Builder for a [`Table`] with (optional) fixed header and scrolling body. /// -/// Cell widths are precalculated so we can have tables like this: -/// -/// | fixed size | all available space/minimum | 30% of available width | fixed size | -/// -/// In contrast to normal egui behavior, columns/rows do *not* grow with its children! -/// Takes all available height, so if you want something below the table, put it in a strip. -/// /// You must pre-allocate all columns with [`Self::column`]/[`Self::columns`]. /// +/// If you have multiple [`Table`]:s in the same [`Ui`] +/// you will need to give them unique id:s by surrounding them with [`Ui::push_id`]. +/// /// ### Example /// ``` /// # egui::__run_test_ui(|ui| { -/// use egui_extras::{TableBuilder, Size}; +/// use egui_extras::{TableBuilder, Column}; /// TableBuilder::new(ui) -/// .column(Size::remainder().at_least(100.0)) -/// .column(Size::exact(40.0)) +/// .column(Column::auto().resizable(true)) +/// .column(Column::remainder()) /// .header(20.0, |mut header| { /// header.col(|ui| { -/// ui.heading("Growing"); +/// ui.heading("First column"); /// }); /// header.col(|ui| { -/// ui.heading("Fixed"); +/// ui.heading("Second column"); /// }); /// }) /// .body(|mut body| { /// body.row(30.0, |mut row| { /// row.col(|ui| { -/// ui.label("first row growing cell"); +/// ui.label("Hello"); /// }); /// row.col(|ui| { -/// ui.button("action"); +/// ui.button("world!"); /// }); /// }); /// }); @@ -51,14 +191,18 @@ use egui::{Rect, Response, Ui, Vec2}; /// ``` pub struct TableBuilder<'a> { ui: &'a mut Ui, - sizing: Sizing, - scroll: bool, + columns: Vec, striped: bool, resizable: bool, - clip: bool, + cell_layout: egui::Layout, + + // Scroll stuff: + vscroll: bool, stick_to_bottom: bool, + scroll_to_row: Option<(usize, Option)>, scroll_offset_y: Option, - cell_layout: egui::Layout, + min_scrolled_height: f32, + max_scroll_height: f32, } impl<'a> TableBuilder<'a> { @@ -66,24 +210,21 @@ impl<'a> TableBuilder<'a> { let cell_layout = *ui.layout(); Self { ui, - sizing: Default::default(), - scroll: true, + columns: Default::default(), striped: false, resizable: false, - clip: true, + cell_layout, + + vscroll: true, stick_to_bottom: false, + scroll_to_row: None, scroll_offset_y: None, - cell_layout, + min_scrolled_height: 200.0, + max_scroll_height: 800.0, } } - /// Enable scrollview in body (default: true) - pub fn scroll(mut self, scroll: bool) -> Self { - self.scroll = scroll; - self - } - - /// Enable striped row background (default: false) + /// Enable striped row background for improved readability (default: `false`) pub fn striped(mut self, striped: bool) -> Self { self.striped = striped; self @@ -91,24 +232,30 @@ impl<'a> TableBuilder<'a> { /// Make the columns resizable by dragging. /// - /// If the _last_ column is [`Size::Remainder`], then it won't be resizable + /// You can set this for individual columns with [`Column::resizable`]. + /// [`Self::resizable`] is used as a fallback for any column for which you don't call + /// [`Column::resizable`]. + /// + /// If the _last_ column is [`Column::remainder`], then it won't be resizable /// (and instead use up the remainder). /// /// Default is `false`. - /// - /// If you have multiple [`Table`]:s in the same [`Ui`] - /// you will need to give them unique id:s with [`Ui::push_id`]. pub fn resizable(mut self, resizable: bool) -> Self { self.resizable = resizable; self } - /// Should we clip the contents of each cell? Default: `true`. - pub fn clip(mut self, clip: bool) -> Self { - self.clip = clip; + /// Enable vertical scrolling in body (default: `true`) + pub fn vscroll(mut self, vscroll: bool) -> Self { + self.vscroll = vscroll; self } + #[deprecated = "Renamed to vscroll"] + pub fn scroll(self, vscroll: bool) -> Self { + self.vscroll(vscroll) + } + /// Should the scroll handle stick to the bottom position even as the content size changes /// dynamically? The scroll handle remains stuck until manually changed, and will become stuck /// once again when repositioned to the bottom. Default: `false`. @@ -117,12 +264,46 @@ impl<'a> TableBuilder<'a> { self } - /// Set the vertical scroll offset position. + /// Set a row to scroll to. + /// + /// `align` specifies if the row should be positioned in the top, center, or bottom of the view + /// (using [`Align::TOP`], [`Align::Center`] or [`Align::BOTTOM`]). + /// If `align` is `None`, the table will scroll just enough to bring the cursor into view. + /// + /// See also: [`Self::vertical_scroll_offset`]. + pub fn scroll_to_row(mut self, row: usize, align: Option) -> Self { + self.scroll_to_row = Some((row, align)); + self + } + + /// Set the vertical scroll offset position, in points. + /// + /// See also: [`Self::scroll_to_row`]. pub fn vertical_scroll_offset(mut self, offset: f32) -> Self { self.scroll_offset_y = Some(offset); self } + /// The minimum height of a vertical scroll area which requires scroll bars. + /// + /// The scroll area will only become smaller than this if the content is smaller than this + /// (and so we don't require scroll bars). + /// + /// Default: `200.0`. + pub fn min_scrolled_height(mut self, min_scrolled_height: f32) -> Self { + self.min_scrolled_height = min_scrolled_height; + self + } + + /// Don't make the scroll area higher than this (add scroll-bars instead!). + /// + /// In other words: add scroll-bars when this height is reached. + /// Default: `800.0`. + pub fn max_scroll_height(mut self, max_scroll_height: f32) -> Self { + self.max_scroll_height = max_scroll_height; + self + } + /// What layout should we use for the individual cells? pub fn cell_layout(mut self, cell_layout: egui::Layout) -> Self { self.cell_layout = cell_layout; @@ -130,22 +311,22 @@ impl<'a> TableBuilder<'a> { } /// Allocate space for one column. - pub fn column(mut self, width: Size) -> Self { - self.sizing.add(width); + pub fn column(mut self, column: Column) -> Self { + self.columns.push(column); self } /// Allocate space for several columns at once. - pub fn columns(mut self, size: Size, count: usize) -> Self { + pub fn columns(mut self, column: Column, count: usize) -> Self { for _ in 0..count { - self.sizing.add(size); + self.columns.push(column); } self } fn available_width(&self) -> f32 { self.ui.available_rect_before_wrap().width() - - if self.scroll { + - if self.vscroll { self.ui.spacing().item_spacing.x + self.ui.spacing().scroll_bar_width } else { 0.0 @@ -153,58 +334,74 @@ impl<'a> TableBuilder<'a> { } /// Create a header row which always stays visible and at the top - pub fn header(self, height: f32, header: impl FnOnce(TableRow<'_, '_>)) -> Table<'a> { + pub fn header(self, height: f32, add_header_row: impl FnOnce(TableRow<'_, '_>)) -> Table<'a> { let available_width = self.available_width(); let Self { ui, - sizing, - scroll, + columns, striped, resizable, - clip, + cell_layout, + + vscroll, stick_to_bottom, + scroll_to_row, scroll_offset_y, - cell_layout, + min_scrolled_height, + max_scroll_height, } = self; - let resize_id = resizable.then(|| ui.id().with("__table_resize")); + let state_id = ui.id().with("__table_state"); - let default_widths = sizing.to_lengths(available_width, ui.spacing().item_spacing.x); - let widths = read_persisted_widths(ui, default_widths, resize_id); + let initial_widths = + to_sizing(&columns).to_lengths(available_width, ui.spacing().item_spacing.x); + let mut max_used_widths = vec![0.0; initial_widths.len()]; + let (had_state, state) = TableState::load(ui, initial_widths, state_id); + let is_first_frame = !had_state; + let first_frame_auto_size_columns = is_first_frame && columns.iter().any(|c| c.is_auto()); let table_top = ui.cursor().top(); - { - let mut layout = StripLayout::new(ui, CellDirection::Horizontal, clip, cell_layout); - header(TableRow { + // Hide first-frame-jitters when auto-sizing. + ui.add_visible_ui(!first_frame_auto_size_columns, |ui| { + let mut layout = StripLayout::new(ui, CellDirection::Horizontal, cell_layout); + add_header_row(TableRow { layout: &mut layout, - widths: &widths, - width_index: 0, + columns: &columns, + widths: &state.column_widths, + max_used_widths: &mut max_used_widths, + col_index: 0, striped: false, height, }); layout.allocate_rect(); - } + }); Table { ui, table_top, - resize_id, - sizing, + state_id, + columns, available_width, - widths, - scroll, + state, + max_used_widths, + first_frame_auto_size_columns, + resizable, striped, - clip, + cell_layout, + + vscroll, stick_to_bottom, + scroll_to_row, scroll_offset_y, - cell_layout, + min_scrolled_height, + max_scroll_height, } } /// Create table body without a header row - pub fn body(self, body: F) + pub fn body(self, add_body_contents: F) where F: for<'b> FnOnce(TableBody<'b>), { @@ -212,169 +409,259 @@ impl<'a> TableBuilder<'a> { let Self { ui, - sizing, - scroll, + columns, striped, resizable, - clip, + cell_layout, + + vscroll, stick_to_bottom, + scroll_to_row, scroll_offset_y, - cell_layout, + min_scrolled_height, + max_scroll_height, } = self; - let resize_id = resizable.then(|| ui.id().with("__table_resize")); + let state_id = ui.id().with("__table_state"); - let default_widths = sizing.to_lengths(available_width, ui.spacing().item_spacing.x); - let widths = read_persisted_widths(ui, default_widths, resize_id); + let initial_widths = + to_sizing(&columns).to_lengths(available_width, ui.spacing().item_spacing.x); + let max_used_widths = vec![0.0; initial_widths.len()]; + let (had_state, state) = TableState::load(ui, initial_widths, state_id); + let is_first_frame = !had_state; + let first_frame_auto_size_columns = is_first_frame && columns.iter().any(|c| c.is_auto()); let table_top = ui.cursor().top(); Table { ui, table_top, - resize_id, - sizing, + state_id, + columns, available_width, - widths, - scroll, + state, + max_used_widths, + first_frame_auto_size_columns, + resizable, striped, - clip, + cell_layout, + + vscroll, stick_to_bottom, + scroll_to_row, scroll_offset_y, - cell_layout, + min_scrolled_height, + max_scroll_height, } - .body(body); + .body(add_body_contents); } } -fn read_persisted_widths( - ui: &egui::Ui, - default_widths: Vec, - resize_id: Option, -) -> Vec { - if let Some(resize_id) = resize_id { +// ---------------------------------------------------------------------------- + +#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] +struct TableState { + column_widths: Vec, +} + +impl TableState { + /// Returns `true` if it did load. + fn load(ui: &egui::Ui, default_widths: Vec, state_id: egui::Id) -> (bool, Self) { let rect = Rect::from_min_size(ui.available_rect_before_wrap().min, Vec2::ZERO); - ui.ctx().check_for_id_clash(resize_id, rect, "Table"); - if let Some(persisted) = ui.data().get_persisted::>(resize_id) { + ui.ctx().check_for_id_clash(state_id, rect, "Table"); + + if let Some(state) = ui.data().get_persisted::(state_id) { // make sure that the stored widths aren't out-dated - if persisted.len() == default_widths.len() { - return persisted; + if state.column_widths.len() == default_widths.len() { + return (true, state); } } + + ( + false, + Self { + column_widths: default_widths, + }, + ) } - default_widths + fn store(self, ui: &egui::Ui, state_id: egui::Id) { + ui.data().insert_persisted(state_id, self); + } } +// ---------------------------------------------------------------------------- + /// Table struct which can construct a [`TableBody`]. /// /// Is created by [`TableBuilder`] by either calling [`TableBuilder::body`] or after creating a header row with [`TableBuilder::header`]. pub struct Table<'a> { ui: &'a mut Ui, table_top: f32, - resize_id: Option, - sizing: Sizing, + state_id: egui::Id, + columns: Vec, available_width: f32, - widths: Vec, - scroll: bool, + state: TableState, + /// Accumulated maximum used widths for each column. + max_used_widths: Vec, + first_frame_auto_size_columns: bool, + resizable: bool, striped: bool, - clip: bool, + cell_layout: egui::Layout, + + // Scroll stuff: + vscroll: bool, stick_to_bottom: bool, + scroll_to_row: Option<(usize, Option)>, scroll_offset_y: Option, - cell_layout: egui::Layout, + min_scrolled_height: f32, + max_scroll_height: f32, } impl<'a> Table<'a> { /// Create table body after adding a header row - pub fn body(self, body: F) + pub fn body(self, add_body_contents: F) where F: for<'b> FnOnce(TableBody<'b>), { let Table { ui, table_top, - resize_id, - sizing, + state_id, + columns, + resizable, mut available_width, - widths, - scroll, + mut state, + mut max_used_widths, + first_frame_auto_size_columns, + vscroll, striped, - clip, + cell_layout, + stick_to_bottom, + scroll_to_row, scroll_offset_y, - cell_layout, + min_scrolled_height, + max_scroll_height, } = self; let avail_rect = ui.available_rect_before_wrap(); - let mut new_widths = widths.clone(); - - let mut scroll_area = egui::ScrollArea::new([false, scroll]) + let mut scroll_area = egui::ScrollArea::new([false, vscroll]) .auto_shrink([true; 2]) - .stick_to_bottom(stick_to_bottom); + .stick_to_bottom(stick_to_bottom) + .min_scrolled_height(min_scrolled_height) + .max_height(max_scroll_height); if let Some(scroll_offset_y) = scroll_offset_y { scroll_area = scroll_area.vertical_scroll_offset(scroll_offset_y); } + let columns_ref = &columns; + let widths_ref = &state.column_widths; + let max_used_widths_ref = &mut max_used_widths; + scroll_area.show(ui, move |ui| { - let layout = StripLayout::new(ui, CellDirection::Horizontal, clip, cell_layout); - - body(TableBody { - layout, - widths, - striped, - row_nr: 0, - start_y: avail_rect.top(), - end_y: avail_rect.bottom(), + let mut scroll_to_y_range = None; + + // Hide first-frame-jitters when auto-sizing. + ui.add_visible_ui(!first_frame_auto_size_columns, |ui| { + let layout = StripLayout::new(ui, CellDirection::Horizontal, cell_layout); + + add_body_contents(TableBody { + layout, + columns: columns_ref, + widths: widths_ref, + max_used_widths: max_used_widths_ref, + striped, + row_nr: 0, + start_y: avail_rect.top(), + end_y: avail_rect.bottom(), + scroll_to_row: scroll_to_row.map(|(r, _)| r), + scroll_to_y_range: &mut scroll_to_y_range, + }); + + if scroll_to_row.is_some() && scroll_to_y_range.is_none() { + // TableBody::row didn't find the right row, so scroll to the bottom: + scroll_to_y_range = Some((f32::INFINITY, f32::INFINITY)); + } }); + + if let Some((min_y, max_y)) = scroll_to_y_range { + let x = 0.0; // ignored, we only have vertical scrolling + let rect = egui::Rect::from_min_max(egui::pos2(x, min_y), egui::pos2(x, max_y)); + let align = scroll_to_row.and_then(|(_, a)| a); + ui.scroll_to_rect(rect, align); + } }); let bottom = ui.min_rect().bottom(); - // TODO(emilk): fix frame-delay by interacting before laying out (but painting later). - if let Some(resize_id) = resize_id { - let spacing_x = ui.spacing().item_spacing.x; - let mut x = avail_rect.left() - spacing_x * 0.5; - for (i, width) in new_widths.iter_mut().enumerate() { - x += *width + spacing_x; - - // If the last column is Size::Remainder, then let it fill the remainder! - let last_column = i + 1 == sizing.sizes.len(); - if last_column { - if let Size::Remainder { range: (min, max) } = sizing.sizes[i] { - let eps = 0.1; // just to avoid some rounding errors. - *width = (available_width - eps).clamp(min, max); - break; - } - } + let spacing_x = ui.spacing().item_spacing.x; + let mut x = avail_rect.left() - spacing_x * 0.5; + for (i, column_width) in state.column_widths.iter_mut().enumerate() { + let column = &columns[i]; + let column_is_resizable = column.resizable.unwrap_or(resizable); + let (min_width, max_width) = column.width_range; + + if !column.clip { + // Unless we clip we don't want to shrink below the + // size that was actually used: + *column_width = column_width.at_least(max_used_widths[i]); + } + *column_width = column_width.clamp(min_width, max_width); + + let is_last_column = i + 1 == columns.len(); + + if is_last_column && column.initial_width == InitialColumnSize::Remainder { + // If the last column is 'remainder', then let it fill the remainder! + let eps = 0.1; // just to avoid some rounding errors. + *column_width = available_width - eps; + *column_width = column_width.at_least(max_used_widths[i]); + *column_width = column_width.clamp(min_width, max_width); + break; + } - let resize_id = ui.id().with("__panel_resize").with(i); + x += *column_width + spacing_x; + + if column.is_auto() && (first_frame_auto_size_columns || !column_is_resizable) { + *column_width = max_used_widths[i]; + *column_width = column_width.clamp(min_width, max_width); + } else if column_is_resizable { + let column_resize_id = ui.id().with("resize_column").with(i); let mut p0 = egui::pos2(x, table_top); let mut p1 = egui::pos2(x, bottom); let line_rect = egui::Rect::from_min_max(p0, p1) .expand(ui.style().interaction.resize_grab_radius_side); - let mouse_over_resize_line = ui.rect_contains_pointer(line_rect); - let any_pressed_and_down = { - let pointer = &ui.input().pointer; - pointer.any_pressed() && pointer.any_down() - }; - if any_pressed_and_down && mouse_over_resize_line { - ui.memory().set_dragged_id(resize_id); - } - let is_resizing = ui.memory().is_being_dragged(resize_id); - if is_resizing { + let resize_response = + ui.interact(line_rect, column_resize_id, egui::Sense::click_and_drag()); + + if resize_response.double_clicked() { + // Resize to the minimum of what is needed. + + *column_width = max_used_widths[i].clamp(min_width, max_width); + } else if resize_response.dragged() { if let Some(pointer) = ui.ctx().pointer_latest_pos() { - let new_width = *width + pointer.x - x; - let (min, max) = sizing.sizes[i].range(); - let new_width = new_width.clamp(min, max); - let x = x - *width + new_width; - p0.x = x; - p1.x = x; - - *width = new_width; + let mut new_width = *column_width + pointer.x - x; + if !column.clip { + // Unless we clip we don't want to shrink below the + // size that was actually used. + // However, we still want to allow content that shrinks when you try + // to make the column less wide, so we allow some small shrinkage each frame: + // big enough to allow shrinking over time, small enough not to look ugly when + // shrinking fails. This is a bit of a HACK around immediate mode. + let max_shrinkage_per_frame = 8.0; + new_width = + new_width.at_least(max_used_widths[i] - max_shrinkage_per_frame); + } + new_width = new_width.clamp(min_width, max_width); + + let x = x - *column_width + new_width; + (p0.x, p1.x) = (x, x); + + *column_width = new_width; } } @@ -382,13 +669,13 @@ impl<'a> Table<'a> { let pointer = &ui.input().pointer; pointer.any_down() || pointer.any_pressed() }; - let resize_hover = mouse_over_resize_line && !dragging_something_else; + let resize_hover = resize_response.hovered() && !dragging_something_else; - if resize_hover || is_resizing { + if resize_hover || resize_response.dragged() { ui.output().cursor_icon = egui::CursorIcon::ResizeColumn; } - let stroke = if is_resizing { + let stroke = if resize_response.dragged() { ui.style().visuals.widgets.active.bg_stroke } else if resize_hover { ui.style().visuals.widgets.hovered.bg_stroke @@ -396,28 +683,52 @@ impl<'a> Table<'a> { // ui.visuals().widgets.inactive.bg_stroke ui.visuals().widgets.noninteractive.bg_stroke }; - ui.painter().line_segment([p0, p1], stroke); - available_width -= *width + spacing_x; - } + ui.painter().line_segment([p0, p1], stroke); + }; - ui.data().insert_persisted(resize_id, new_widths); + available_width -= *column_width + spacing_x; } + + state.store(ui, state_id); } } /// The body of a table. +/// /// Is created by calling `body` on a [`Table`] (after adding a header row) or [`TableBuilder`] (without a header row). pub struct TableBody<'a> { layout: StripLayout<'a>, - widths: Vec, + + columns: &'a [Column], + + /// Current column widths. + widths: &'a [f32], + + /// Accumulated maximum used widths for each column. + max_used_widths: &'a mut [f32], + striped: bool, row_nr: usize, start_y: f32, end_y: f32, + + /// Look for this row to scroll to. + scroll_to_row: Option, + + /// If we find the correct row to scroll to, + /// this is set to the y-range of the row. + scroll_to_y_range: &'a mut Option<(f32, f32)>, } impl<'a> TableBody<'a> { + /// Where in screen-space is the table body? + pub fn max_rect(&self) -> Rect { + self.layout + .rect + .translate(egui::vec2(0.0, self.scroll_offset_y())) + } + fn scroll_offset_y(&self) -> f32 { self.start_y - self.layout.rect.top() } @@ -428,20 +739,28 @@ impl<'a> TableBody<'a> { /// heights are expected to according to the width of one or more cells -- for example, if text /// is wrapped rather than clipped within the cell. pub fn widths(&self) -> &[f32] { - &self.widths + self.widths } /// Add a single row with the given height. /// /// If you have many thousands of row it can be more performant to instead use [`Self::rows`] or [`Self::heterogeneous_rows`]. - pub fn row(&mut self, height: f32, row: impl FnOnce(TableRow<'a, '_>)) { - row(TableRow { + pub fn row(&mut self, height: f32, add_row_content: impl FnOnce(TableRow<'a, '_>)) { + let top_y = self.layout.cursor.y; + add_row_content(TableRow { layout: &mut self.layout, - widths: &self.widths, - width_index: 0, + columns: self.columns, + widths: self.widths, + max_used_widths: self.max_used_widths, + col_index: 0, striped: self.striped && self.row_nr % 2 == 0, height, }); + let bottom_y = self.layout.cursor.y; + + if Some(self.row_nr) == self.scroll_to_row { + *self.scroll_to_y_range = Some((top_y, bottom_y)); + } self.row_nr += 1; } @@ -455,9 +774,9 @@ impl<'a> TableBody<'a> { /// ### Example /// ``` /// # egui::__run_test_ui(|ui| { - /// use egui_extras::{TableBuilder, Size}; + /// use egui_extras::{TableBuilder, Column}; /// TableBuilder::new(ui) - /// .column(Size::remainder().at_least(100.0)) + /// .column(Column::remainder().at_least(100.0)) /// .body(|mut body| { /// let row_height = 18.0; /// let num_rows = 10_000; @@ -473,11 +792,19 @@ impl<'a> TableBody<'a> { mut self, row_height_sans_spacing: f32, total_rows: usize, - mut row: impl FnMut(usize, TableRow<'_, '_>), + mut add_row_content: impl FnMut(usize, TableRow<'_, '_>), ) { let spacing = self.layout.ui.spacing().item_spacing; let row_height_with_spacing = row_height_sans_spacing + spacing.y; + if let Some(scroll_to_row) = self.scroll_to_row { + let scroll_to_row = scroll_to_row.at_most(total_rows.saturating_sub(1)) as f32; + *self.scroll_to_y_range = Some(( + self.layout.cursor.y + scroll_to_row * row_height_with_spacing, + self.layout.cursor.y + (scroll_to_row + 1.0) * row_height_with_spacing, + )); + } + let scroll_offset_y = self .scroll_offset_y() .min(total_rows as f32 * row_height_with_spacing); @@ -494,12 +821,14 @@ impl<'a> TableBody<'a> { let max_row = max_row.min(total_rows); for idx in min_row..max_row { - row( + add_row_content( idx, TableRow { layout: &mut self.layout, - widths: &self.widths, - width_index: 0, + columns: self.columns, + widths: self.widths, + max_used_widths: self.max_used_widths, + col_index: 0, striped: self.striped && idx % 2 == 0, height: row_height_sans_spacing, }, @@ -517,15 +846,15 @@ impl<'a> TableBody<'a> { /// 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 + /// heterogeneously-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}; + /// use egui_extras::{TableBuilder, Column}; /// TableBuilder::new(ui) - /// .column(Size::remainder().at_least(100.0)) + /// .column(Column::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| { @@ -542,7 +871,7 @@ impl<'a> TableBody<'a> { pub fn heterogeneous_rows( mut self, heights: impl Iterator, - mut populate_row: impl FnMut(usize, TableRow<'_, '_>), + mut add_row_content: impl FnMut(usize, TableRow<'_, '_>), ) { let spacing = self.layout.ui.spacing().item_spacing; let mut enumerated_heights = heights.enumerate(); @@ -550,39 +879,66 @@ impl<'a> TableBody<'a> { let max_height = self.end_y - self.start_y; let scroll_offset_y = self.scroll_offset_y() as f64; + let scroll_to_y_range_offset = self.layout.cursor.y as f64; + let mut cursor_y: f64 = 0.0; // Skip the invisible rows, and populate the first non-virtual row. for (row_index, row_height) in &mut enumerated_heights { let old_cursor_y = cursor_y; cursor_y += (row_height + spacing.y) as f64; + + if Some(row_index) == self.scroll_to_row { + *self.scroll_to_y_range = Some(( + (scroll_to_y_range_offset + old_cursor_y) as f32, + (scroll_to_y_range_offset + cursor_y) as f32, + )); + } + if cursor_y >= scroll_offset_y { // This row is visible: - self.add_buffer(old_cursor_y as f32); - let tr = TableRow { - layout: &mut self.layout, - widths: &self.widths, - width_index: 0, - striped: self.striped && row_index % 2 == 0, - height: row_height, - }; - populate_row(row_index, tr); + self.add_buffer(old_cursor_y as f32); // skip all the invisible rows + + add_row_content( + row_index, + TableRow { + layout: &mut self.layout, + columns: self.columns, + widths: self.widths, + max_used_widths: self.max_used_widths, + col_index: 0, + striped: self.striped && row_index % 2 == 0, + height: row_height, + }, + ); break; } } // populate visible rows: for (row_index, row_height) in &mut enumerated_heights { - let tr = TableRow { - layout: &mut self.layout, - widths: &self.widths, - width_index: 0, - striped: self.striped && row_index % 2 == 0, - height: row_height, - }; - populate_row(row_index, tr); + let top_y = cursor_y; + add_row_content( + row_index, + TableRow { + layout: &mut self.layout, + columns: self.columns, + widths: self.widths, + max_used_widths: self.max_used_widths, + col_index: 0, + striped: self.striped && row_index % 2 == 0, + height: row_height, + }, + ); cursor_y += (row_height + spacing.y) as f64; + if Some(row_index) == self.scroll_to_row { + *self.scroll_to_y_range = Some(( + (scroll_to_y_range_offset + top_y) as f32, + (scroll_to_y_range_offset + cursor_y) as f32, + )); + } + if cursor_y > scroll_offset_y + max_height as f64 { break; } @@ -590,9 +946,27 @@ impl<'a> TableBody<'a> { // calculate height below the visible table range: let mut height_below_visible: f64 = 0.0; - for (_, height) in enumerated_heights { - height_below_visible += height as f64; + for (row_index, row_height) in enumerated_heights { + height_below_visible += (row_height + spacing.y) as f64; + + let top_y = cursor_y; + cursor_y += (row_height + spacing.y) as f64; + if Some(row_index) == self.scroll_to_row { + *self.scroll_to_y_range = Some(( + (scroll_to_y_range_offset + top_y) as f32, + (scroll_to_y_range_offset + cursor_y) as f32, + )); + } + } + + if self.scroll_to_row.is_some() && self.scroll_to_y_range.is_none() { + // Catch desire to scroll past the end: + *self.scroll_to_y_range = Some(( + (scroll_to_y_range_offset + cursor_y) as f32, + (scroll_to_y_range_offset + cursor_y) as f32, + )); } + if height_below_visible > 0.0 { // we need to add a buffer to allow the table to // accurately calculate the scrollbar position @@ -617,17 +991,26 @@ impl<'a> Drop for TableBody<'a> { /// Is created by [`TableRow`] for each created [`TableBody::row`] or each visible row in rows created by calling [`TableBody::rows`]. pub struct TableRow<'a, 'b> { layout: &'b mut StripLayout<'a>, + columns: &'b [Column], widths: &'b [f32], - width_index: usize, + /// grows during building with the maximum widths + max_used_widths: &'b mut [f32], + col_index: usize, striped: bool, height: f32, } impl<'a, 'b> TableRow<'a, 'b> { /// Add the contents of a column. - pub fn col(&mut self, add_contents: impl FnOnce(&mut Ui)) -> Response { - let width = if let Some(width) = self.widths.get(self.width_index) { - self.width_index += 1; + /// + /// Return the used space (`min_rect`) plus the [`Response`] of the whole cell. + pub fn col(&mut self, add_cell_contents: impl FnOnce(&mut Ui)) -> (Rect, Response) { + let col_index = self.col_index; + + let clip = self.columns.get(col_index).map_or(false, |c| c.clip); + + let width = if let Some(width) = self.widths.get(col_index) { + self.col_index += 1; *width } else { crate::log_or_panic!( @@ -640,11 +1023,15 @@ impl<'a, 'b> TableRow<'a, 'b> { let width = CellSize::Absolute(width); let height = CellSize::Absolute(self.height); - if self.striped { - self.layout.add_striped(width, height, add_contents) - } else { - self.layout.add(width, height, add_contents) + let (used_rect, response) = + self.layout + .add(clip, self.striped, width, height, add_cell_contents); + + if let Some(max_w) = self.max_used_widths.get_mut(col_index) { + *max_w = max_w.max(used_rect.width()); } + + (used_rect, response) } }