From 8ef0e85b85c210e0eb89a15f5a4dd2dfcc842f58 Mon Sep 17 00:00:00 2001 From: Emil Ernerfeldt Date: Tue, 2 Jul 2024 21:13:55 +0200 Subject: [PATCH] egui_extras: Improve the auto-sizing of `Table` (#4756) This makes the sizing pass of an `egui_table` ensure the table uses as little width as possible. Subsequently, it will redistribute all non-resizable columns on the available space, so that a table better follow the parent container as it is resized. I also added `table.reset()` for forgetting the current column widths. --- crates/egui/src/widgets/label.rs | 8 ++ crates/egui_demo_lib/src/demo/table_demo.rs | 46 +++++----- crates/egui_extras/src/layout.rs | 8 +- crates/egui_extras/src/table.rs | 98 +++++++++++++++++---- 4 files changed, 119 insertions(+), 41 deletions(-) diff --git a/crates/egui/src/widgets/label.rs b/crates/egui/src/widgets/label.rs index 0772e7634..d2085d4fb 100644 --- a/crates/egui/src/widgets/label.rs +++ b/crates/egui/src/widgets/label.rs @@ -68,6 +68,14 @@ impl Label { self } + /// Set [`Self::wrap_mode`] to [`TextWrapMode::Extend`], + /// disabling wrapping and truncating, and instead expanding the parent [`Ui`]. + #[inline] + pub fn extend(mut self) -> Self { + self.wrap_mode = Some(TextWrapMode::Extend); + self + } + /// Can the user select the text with the mouse? /// /// Overrides [`crate::style::Interaction::selectable_labels`]. diff --git a/crates/egui_demo_lib/src/demo/table_demo.rs b/crates/egui_demo_lib/src/demo/table_demo.rs index f059803ed..a52d07e64 100644 --- a/crates/egui_demo_lib/src/demo/table_demo.rs +++ b/crates/egui_demo_lib/src/demo/table_demo.rs @@ -58,6 +58,8 @@ const NUM_MANUAL_ROWS: usize = 20; impl crate::View for TableDemo { fn ui(&mut self, ui: &mut egui::Ui) { + let mut reset = false; + ui.vertical(|ui| { ui.horizontal(|ui| { ui.checkbox(&mut self.striped, "Striped"); @@ -102,6 +104,8 @@ impl crate::View for TableDemo { self.scroll_to_row = Some(self.scroll_to_row_slider); } } + + reset = ui.button("Reset").clicked(); }); ui.separator(); @@ -115,7 +119,7 @@ impl crate::View for TableDemo { .vertical(|mut strip| { strip.cell(|ui| { egui::ScrollArea::horizontal().show(ui, |ui| { - self.table_ui(ui); + self.table_ui(ui, reset); }); }); strip.cell(|ui| { @@ -128,7 +132,7 @@ impl crate::View for TableDemo { } impl TableDemo { - fn table_ui(&mut self, ui: &mut egui::Ui) { + fn table_ui(&mut self, ui: &mut egui::Ui, reset: bool) { use egui_extras::{Column, TableBuilder}; let text_height = egui::TextStyle::Body @@ -142,9 +146,14 @@ impl TableDemo { .resizable(self.resizable) .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) .column(Column::auto()) + .column( + Column::remainder() + .at_least(40.0) + .clip(true) + .resizable(true), + ) .column(Column::auto()) - .column(Column::initial(100.0).range(40.0..=300.0)) - .column(Column::initial(100.0).at_least(40.0).clip(true)) + .column(Column::remainder()) .column(Column::remainder()) .min_scrolled_height(0.0) .max_scroll_height(available_height); @@ -157,19 +166,23 @@ impl TableDemo { table = table.scroll_to_row(row_index, None); } + if reset { + table.reset(); + } + table .header(20.0, |mut header| { header.col(|ui| { ui.strong("Row"); }); header.col(|ui| { - ui.strong("Interaction"); + ui.strong("Clipped text"); }); header.col(|ui| { ui.strong("Expanding content"); }); header.col(|ui| { - ui.strong("Clipped text"); + ui.strong("Interaction"); }); header.col(|ui| { ui.strong("Content"); @@ -187,13 +200,13 @@ impl TableDemo { ui.label(row_index.to_string()); }); row.col(|ui| { - ui.checkbox(&mut self.checked, "Click me"); + ui.label(long_text(row_index)); }); row.col(|ui| { expanding_content(ui); }); row.col(|ui| { - ui.label(long_text(row_index)); + ui.checkbox(&mut self.checked, "Click me"); }); row.col(|ui| { ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); @@ -217,13 +230,13 @@ impl TableDemo { ui.label(row_index.to_string()); }); row.col(|ui| { - ui.checkbox(&mut self.checked, "Click me"); + ui.label(long_text(row_index)); }); row.col(|ui| { expanding_content(ui); }); row.col(|ui| { - ui.label(long_text(row_index)); + ui.checkbox(&mut self.checked, "Click me"); }); row.col(|ui| { ui.add( @@ -245,13 +258,13 @@ impl TableDemo { ui.label(row_index.to_string()); }); row.col(|ui| { - ui.checkbox(&mut self.checked, "Click me"); + ui.label(long_text(row_index)); }); row.col(|ui| { expanding_content(ui); }); row.col(|ui| { - ui.label(long_text(row_index)); + ui.checkbox(&mut self.checked, "Click me"); }); row.col(|ui| { ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); @@ -280,14 +293,7 @@ impl TableDemo { } 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()), - ); + ui.add(egui::Separator::default().horizontal()); } fn long_text(row_index: usize) -> String { diff --git a/crates/egui_extras/src/layout.rs b/crates/egui_extras/src/layout.rs index db3b5f203..1c8f6b0c9 100644 --- a/crates/egui_extras/src/layout.rs +++ b/crates/egui_extras/src/layout.rs @@ -150,14 +150,16 @@ impl<'l> StripLayout<'l> { let used_rect = child_ui.min_rect(); - self.set_pos(max_rect); - - let allocation_rect = if flags.clip { + let allocation_rect = if self.ui.is_sizing_pass() { + used_rect + } else if flags.clip { max_rect } else { max_rect.union(used_rect) }; + self.set_pos(allocation_rect); + self.ui.advance_cursor_after_rect(allocation_rect); let response = child_ui.interact(max_rect, child_ui.id(), self.sense); diff --git a/crates/egui_extras/src/table.rs b/crates/egui_extras/src/table.rs index 92ac9256b..fd6abfee2 100644 --- a/crates/egui_extras/src/table.rs +++ b/crates/egui_extras/src/table.rs @@ -156,8 +156,7 @@ fn to_sizing(columns: &[Column]) -> crate::sizing::Sizing { InitialColumnSize::Automatic(suggested_width) => Size::initial(suggested_width), InitialColumnSize::Remainder => Size::remainder(), } - .at_least(column.width_range.min) - .at_most(column.width_range.max); + .with_range(column.width_range); sizing.add(size); } sizing @@ -405,6 +404,12 @@ impl<'a> TableBuilder<'a> { * self.ui.spacing().scroll.allocated_width() } + /// Reset all column widths. + pub fn reset(&mut self) { + let state_id = self.ui.id().with("__table_state"); + TableState::reset(self.ui, state_id); + } + /// Create a header row which always stays visible and at the top pub fn header(self, height: f32, add_header_row: impl FnOnce(TableRow<'_, '_>)) -> Table<'a> { let available_width = self.available_width(); @@ -423,14 +428,14 @@ impl<'a> TableBuilder<'a> { let state_id = ui.id().with("__table_state"); - let (is_sizing_pass, state) = TableState::load(ui, state_id, &columns, available_width); + let (is_sizing_pass, state) = + TableState::load(ui, state_id, resizable, &columns, available_width); let mut max_used_widths = vec![0.0; columns.len()]; let table_top = ui.cursor().top(); ui.scope(|ui| { if is_sizing_pass { - // Hide first-frame-jitters when auto-sizing. ui.set_sizing_pass(); } let mut layout = StripLayout::new(ui, CellDirection::Horizontal, cell_layout, sense); @@ -489,7 +494,8 @@ impl<'a> TableBuilder<'a> { let state_id = ui.id().with("__table_state"); - let (is_sizing_pass, state) = TableState::load(ui, state_id, &columns, available_width); + let (is_sizing_pass, state) = + TableState::load(ui, state_id, resizable, &columns, available_width); let max_used_widths = vec![0.0; columns.len()]; let table_top = ui.cursor().top(); @@ -519,11 +525,21 @@ impl<'a> TableBuilder<'a> { #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] struct TableState { column_widths: Vec, + + /// If known from previous frame + #[cfg_attr(feature = "serde", serde(skip))] + max_used_widths: Vec, } impl TableState { /// Return true if we should do a sizing pass. - fn load(ui: &Ui, state_id: egui::Id, columns: &[Column], available_width: f32) -> (bool, Self) { + fn load( + ui: &Ui, + state_id: egui::Id, + resizable: bool, + columns: &[Column], + available_width: f32, + ) -> (bool, Self) { let rect = Rect::from_min_size(ui.available_rect_before_wrap().min, Vec2::ZERO); ui.ctx().check_for_id_clash(state_id, rect, "Table"); @@ -537,20 +553,56 @@ impl TableState { let is_sizing_pass = ui.is_sizing_pass() || state.is_none() && columns.iter().any(|c| c.is_auto()); - let state = state.unwrap_or_else(|| { + let mut state = state.unwrap_or_else(|| { let initial_widths = to_sizing(columns).to_lengths(available_width, ui.spacing().item_spacing.x); Self { column_widths: initial_widths, + max_used_widths: Default::default(), } }); + if !is_sizing_pass && state.max_used_widths.len() == columns.len() { + // Make sure any non-resizable `remainder` columns are updated + // to take up the remainder of the current available width. + // Also handles changing item spacing. + let mut sizing = crate::sizing::Sizing::default(); + for ((prev_width, max_used), column) in state + .column_widths + .iter() + .zip(&state.max_used_widths) + .zip(columns) + { + use crate::Size; + + let column_resizable = column.resizable.unwrap_or(resizable); + let size = if column_resizable { + // Resiable columns keep their width: + Size::exact(*prev_width) + } else { + match column.initial_width { + InitialColumnSize::Absolute(width) => Size::exact(width), + InitialColumnSize::Automatic(_) => Size::exact(*prev_width), + InitialColumnSize::Remainder => Size::remainder(), + } + .at_least(column.width_range.min.max(*max_used)) + .at_most(column.width_range.max) + }; + sizing.add(size); + } + state.column_widths = sizing.to_lengths(available_width, ui.spacing().item_spacing.x); + } + (is_sizing_pass, state) } fn store(self, ui: &egui::Ui, state_id: egui::Id) { ui.data_mut(|d| d.insert_persisted(state_id, self)); } + + fn reset(ui: &egui::Ui, state_id: egui::Id) { + ui.data_mut(|d| d.remove::(state_id)); + } } // ---------------------------------------------------------------------------- @@ -645,7 +697,6 @@ impl<'a> Table<'a> { let clip_rect = ui.clip_rect(); - // Hide first-frame-jitters when auto-sizing. ui.scope(|ui| { if is_sizing_pass { ui.set_sizing_pass(); @@ -695,16 +746,11 @@ impl<'a> Table<'a> { let column_is_resizable = column.resizable.unwrap_or(resizable); let width_range = 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 = width_range.clamp(*column_width); - let is_last_column = i + 1 == columns.len(); - - if is_last_column && column.initial_width == InitialColumnSize::Remainder { + if is_last_column + && column.initial_width == InitialColumnSize::Remainder + && !ui.is_sizing_pass() + { // 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; @@ -715,6 +761,20 @@ impl<'a> Table<'a> { break; } + if ui.is_sizing_pass() { + if column.clip { + // If we clip, we don't need to be as wide as the max used width + *column_width = column_width.min(max_used_widths[i]); + } else { + *column_width = max_used_widths[i]; + } + } else 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 = width_range.clamp(*column_width); + x += *column_width + spacing_x; if column.is_auto() && (is_sizing_pass || !column_is_resizable) { @@ -775,11 +835,13 @@ impl<'a> Table<'a> { }; ui.painter().line_segment([p0, p1], stroke); - }; + } available_width -= *column_width + spacing_x; } + state.max_used_widths = max_used_widths; + state.store(ui, state_id); } }