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.
This commit is contained in:
Emil Ernerfeldt 2024-07-02 21:13:55 +02:00 committed by GitHub
parent 753412193c
commit 8ef0e85b85
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 119 additions and 41 deletions

View File

@ -68,6 +68,14 @@ impl Label {
self 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? /// Can the user select the text with the mouse?
/// ///
/// Overrides [`crate::style::Interaction::selectable_labels`]. /// Overrides [`crate::style::Interaction::selectable_labels`].

View File

@ -58,6 +58,8 @@ const NUM_MANUAL_ROWS: usize = 20;
impl crate::View for TableDemo { impl crate::View for TableDemo {
fn ui(&mut self, ui: &mut egui::Ui) { fn ui(&mut self, ui: &mut egui::Ui) {
let mut reset = false;
ui.vertical(|ui| { ui.vertical(|ui| {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.checkbox(&mut self.striped, "Striped"); 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); self.scroll_to_row = Some(self.scroll_to_row_slider);
} }
} }
reset = ui.button("Reset").clicked();
}); });
ui.separator(); ui.separator();
@ -115,7 +119,7 @@ impl crate::View for TableDemo {
.vertical(|mut strip| { .vertical(|mut strip| {
strip.cell(|ui| { strip.cell(|ui| {
egui::ScrollArea::horizontal().show(ui, |ui| { egui::ScrollArea::horizontal().show(ui, |ui| {
self.table_ui(ui); self.table_ui(ui, reset);
}); });
}); });
strip.cell(|ui| { strip.cell(|ui| {
@ -128,7 +132,7 @@ impl crate::View for TableDemo {
} }
impl 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}; use egui_extras::{Column, TableBuilder};
let text_height = egui::TextStyle::Body let text_height = egui::TextStyle::Body
@ -142,9 +146,14 @@ impl TableDemo {
.resizable(self.resizable) .resizable(self.resizable)
.cell_layout(egui::Layout::left_to_right(egui::Align::Center)) .cell_layout(egui::Layout::left_to_right(egui::Align::Center))
.column(Column::auto()) .column(Column::auto())
.column(
Column::remainder()
.at_least(40.0)
.clip(true)
.resizable(true),
)
.column(Column::auto()) .column(Column::auto())
.column(Column::initial(100.0).range(40.0..=300.0)) .column(Column::remainder())
.column(Column::initial(100.0).at_least(40.0).clip(true))
.column(Column::remainder()) .column(Column::remainder())
.min_scrolled_height(0.0) .min_scrolled_height(0.0)
.max_scroll_height(available_height); .max_scroll_height(available_height);
@ -157,19 +166,23 @@ impl TableDemo {
table = table.scroll_to_row(row_index, None); table = table.scroll_to_row(row_index, None);
} }
if reset {
table.reset();
}
table table
.header(20.0, |mut header| { .header(20.0, |mut header| {
header.col(|ui| { header.col(|ui| {
ui.strong("Row"); ui.strong("Row");
}); });
header.col(|ui| { header.col(|ui| {
ui.strong("Interaction"); ui.strong("Clipped text");
}); });
header.col(|ui| { header.col(|ui| {
ui.strong("Expanding content"); ui.strong("Expanding content");
}); });
header.col(|ui| { header.col(|ui| {
ui.strong("Clipped text"); ui.strong("Interaction");
}); });
header.col(|ui| { header.col(|ui| {
ui.strong("Content"); ui.strong("Content");
@ -187,13 +200,13 @@ impl TableDemo {
ui.label(row_index.to_string()); ui.label(row_index.to_string());
}); });
row.col(|ui| { row.col(|ui| {
ui.checkbox(&mut self.checked, "Click me"); ui.label(long_text(row_index));
}); });
row.col(|ui| { row.col(|ui| {
expanding_content(ui); expanding_content(ui);
}); });
row.col(|ui| { row.col(|ui| {
ui.label(long_text(row_index)); ui.checkbox(&mut self.checked, "Click me");
}); });
row.col(|ui| { row.col(|ui| {
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
@ -217,13 +230,13 @@ impl TableDemo {
ui.label(row_index.to_string()); ui.label(row_index.to_string());
}); });
row.col(|ui| { row.col(|ui| {
ui.checkbox(&mut self.checked, "Click me"); ui.label(long_text(row_index));
}); });
row.col(|ui| { row.col(|ui| {
expanding_content(ui); expanding_content(ui);
}); });
row.col(|ui| { row.col(|ui| {
ui.label(long_text(row_index)); ui.checkbox(&mut self.checked, "Click me");
}); });
row.col(|ui| { row.col(|ui| {
ui.add( ui.add(
@ -245,13 +258,13 @@ impl TableDemo {
ui.label(row_index.to_string()); ui.label(row_index.to_string());
}); });
row.col(|ui| { row.col(|ui| {
ui.checkbox(&mut self.checked, "Click me"); ui.label(long_text(row_index));
}); });
row.col(|ui| { row.col(|ui| {
expanding_content(ui); expanding_content(ui);
}); });
row.col(|ui| { row.col(|ui| {
ui.label(long_text(row_index)); ui.checkbox(&mut self.checked, "Click me");
}); });
row.col(|ui| { row.col(|ui| {
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
@ -280,14 +293,7 @@ impl TableDemo {
} }
fn expanding_content(ui: &mut egui::Ui) { fn expanding_content(ui: &mut egui::Ui) {
let width = ui.available_width().clamp(20.0, 200.0); ui.add(egui::Separator::default().horizontal());
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 { fn long_text(row_index: usize) -> String {

View File

@ -150,14 +150,16 @@ impl<'l> StripLayout<'l> {
let used_rect = child_ui.min_rect(); let used_rect = child_ui.min_rect();
self.set_pos(max_rect); let allocation_rect = if self.ui.is_sizing_pass() {
used_rect
let allocation_rect = if flags.clip { } else if flags.clip {
max_rect max_rect
} else { } else {
max_rect.union(used_rect) max_rect.union(used_rect)
}; };
self.set_pos(allocation_rect);
self.ui.advance_cursor_after_rect(allocation_rect); self.ui.advance_cursor_after_rect(allocation_rect);
let response = child_ui.interact(max_rect, child_ui.id(), self.sense); let response = child_ui.interact(max_rect, child_ui.id(), self.sense);

View File

@ -156,8 +156,7 @@ fn to_sizing(columns: &[Column]) -> crate::sizing::Sizing {
InitialColumnSize::Automatic(suggested_width) => Size::initial(suggested_width), InitialColumnSize::Automatic(suggested_width) => Size::initial(suggested_width),
InitialColumnSize::Remainder => Size::remainder(), InitialColumnSize::Remainder => Size::remainder(),
} }
.at_least(column.width_range.min) .with_range(column.width_range);
.at_most(column.width_range.max);
sizing.add(size); sizing.add(size);
} }
sizing sizing
@ -405,6 +404,12 @@ impl<'a> TableBuilder<'a> {
* self.ui.spacing().scroll.allocated_width() * 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 /// 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> { pub fn header(self, height: f32, add_header_row: impl FnOnce(TableRow<'_, '_>)) -> Table<'a> {
let available_width = self.available_width(); let available_width = self.available_width();
@ -423,14 +428,14 @@ impl<'a> TableBuilder<'a> {
let state_id = ui.id().with("__table_state"); 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 mut max_used_widths = vec![0.0; columns.len()];
let table_top = ui.cursor().top(); let table_top = ui.cursor().top();
ui.scope(|ui| { ui.scope(|ui| {
if is_sizing_pass { if is_sizing_pass {
// Hide first-frame-jitters when auto-sizing.
ui.set_sizing_pass(); ui.set_sizing_pass();
} }
let mut layout = StripLayout::new(ui, CellDirection::Horizontal, cell_layout, sense); 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 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 max_used_widths = vec![0.0; columns.len()];
let table_top = ui.cursor().top(); let table_top = ui.cursor().top();
@ -519,11 +525,21 @@ impl<'a> TableBuilder<'a> {
#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))] #[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
struct TableState { struct TableState {
column_widths: Vec<f32>, column_widths: Vec<f32>,
/// If known from previous frame
#[cfg_attr(feature = "serde", serde(skip))]
max_used_widths: Vec<f32>,
} }
impl TableState { impl TableState {
/// Return true if we should do a sizing pass. /// 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); let rect = Rect::from_min_size(ui.available_rect_before_wrap().min, Vec2::ZERO);
ui.ctx().check_for_id_clash(state_id, rect, "Table"); ui.ctx().check_for_id_clash(state_id, rect, "Table");
@ -537,20 +553,56 @@ impl TableState {
let is_sizing_pass = let is_sizing_pass =
ui.is_sizing_pass() || state.is_none() && columns.iter().any(|c| c.is_auto()); 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 = let initial_widths =
to_sizing(columns).to_lengths(available_width, ui.spacing().item_spacing.x); to_sizing(columns).to_lengths(available_width, ui.spacing().item_spacing.x);
Self { Self {
column_widths: initial_widths, 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) (is_sizing_pass, state)
} }
fn store(self, ui: &egui::Ui, state_id: egui::Id) { fn store(self, ui: &egui::Ui, state_id: egui::Id) {
ui.data_mut(|d| d.insert_persisted(state_id, self)); 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::<Self>(state_id));
}
} }
// ---------------------------------------------------------------------------- // ----------------------------------------------------------------------------
@ -645,7 +697,6 @@ impl<'a> Table<'a> {
let clip_rect = ui.clip_rect(); let clip_rect = ui.clip_rect();
// Hide first-frame-jitters when auto-sizing.
ui.scope(|ui| { ui.scope(|ui| {
if is_sizing_pass { if is_sizing_pass {
ui.set_sizing_pass(); ui.set_sizing_pass();
@ -695,16 +746,11 @@ impl<'a> Table<'a> {
let column_is_resizable = column.resizable.unwrap_or(resizable); let column_is_resizable = column.resizable.unwrap_or(resizable);
let width_range = column.width_range; 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(); let is_last_column = i + 1 == columns.len();
if is_last_column
if is_last_column && column.initial_width == InitialColumnSize::Remainder { && column.initial_width == InitialColumnSize::Remainder
&& !ui.is_sizing_pass()
{
// If the last column is 'remainder', then let it fill the remainder! // If the last column is 'remainder', then let it fill the remainder!
let eps = 0.1; // just to avoid some rounding errors. let eps = 0.1; // just to avoid some rounding errors.
*column_width = available_width - eps; *column_width = available_width - eps;
@ -715,6 +761,20 @@ impl<'a> Table<'a> {
break; 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; x += *column_width + spacing_x;
if column.is_auto() && (is_sizing_pass || !column_is_resizable) { 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); ui.painter().line_segment([p0, p1], stroke);
}; }
available_width -= *column_width + spacing_x; available_width -= *column_width + spacing_x;
} }
state.max_used_widths = max_used_widths;
state.store(ui, state_id); state.store(ui, state_id);
} }
} }