From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id 043741FF0AA for ; Tue, 06 Oct 2026 11:07:46 +0200 (CEST) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id BCE902138C; Tue, 06 Oct 2026 11:07:45 +0200 (CEST) From: Dominik Csapak To: yew-devel@lists.proxmox.com Subject: [RFC PATCH yew-widget-toolkit] widget: data table: implement auto column sizing based on sample records Date: Tue, 6 Oct 2026 11:06:18 +0200 Message-ID: <20261006090734.1376686-1-d.csapak@proxmox.com> X-Mailer: git-send-email 2.47.3 MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL 0.391 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: EXZ5BGMOIYMFDGSHVA4SHULB2XAXRS2X X-Message-ID-Hash: EXZ5BGMOIYMFDGSHVA4SHULB2XAXRS2X X-MailFrom: d.csapak@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Yew framework devel list at Proxmox List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: When a column size is not explicitly given (like "100px" or "15em"), the default size is implicitly determined by the header text. In cases where the content should always be shown (like in an action column) and depends on the theme/density (for padding/width/etc.) this lead to an bad tradeoff: Either a fixed size is used and it's sometimes too wide or too narrow, or some css expression is used that dynamically determines the size. Both are not ideal, so introduce 'sample records' that the data table can use to (invisibly) render a set of dummy rows which can dynamically determine the correct size. This can be used for e.g. setting an example that should always fit, or having a fixed set of ActionIcons, etc. When multiple records are given, the largest one wins. Signed-off-by: Dominik Csapak --- IMHO there is no better solution when having buffered tables and having the headers + body split like we have. The overhead should be small enough, e.g. a single invisible row shouldn't hurt (even though it might look weird in the markup) src/widget/data_table/data_table.rs | 90 ++++++++++++++++++++++++-- src/widget/data_table/header_widget.rs | 45 +++++++++++++ 2 files changed, 131 insertions(+), 4 deletions(-) diff --git a/src/widget/data_table/data_table.rs b/src/widget/data_table/data_table.rs index f00d9ab..338115f 100644 --- a/src/widget/data_table/data_table.rs +++ b/src/widget/data_table/data_table.rs @@ -22,9 +22,10 @@ use crate::state::{DataStore, Selection, SelectionObserver}; use crate::widget::{Column, Container, get_unique_element_id}; use super::{ - CellConfiguration, DataTableColumn, DataTableHeader, DataTableKeyboardEvent, - DataTableMouseEvent, DataTableRow, DataTableRowRenderCallback, HeaderWidget, IndexedHeader, - IntoOptionalDataTableRowRenderCallback, create_indexed_header_list, + CellConfiguration, DataTableCellRenderArgs, DataTableColumn, DataTableHeader, + DataTableKeyboardEvent, DataTableMouseEvent, DataTableRow, DataTableRowRenderCallback, + HeaderWidget, IndexedHeader, IntoOptionalDataTableRowRenderCallback, + create_indexed_header_list, }; pub enum HeaderMsg { @@ -173,6 +174,15 @@ pub struct DataTable { #[prop_or(22)] pub min_row_height: usize, + /// Sample records used for sizing the columns. + /// + /// Default column widths are computed from the header, because the table rows are rendered + /// separately (and only partially with virtual scrolling). These records are rendered + /// invisibly inside the header, using the column render functions, so content based widths + /// like "auto" or "max-content" account for them. For flex widths, they act as minimum width. + #[prop_or_default] + pub col_sizing_records: Vec, + /// Selection object. #[prop_or_default] pub selection: Option, @@ -352,6 +362,17 @@ impl DataTable { self.min_row_height = min_row_height; } + /// Builder style method to set the sample records used for sizing the columns. + pub fn col_sizing_records(mut self, records: impl IntoIterator) -> Self { + self.set_col_sizing_records(records); + self + } + + /// Method to set the sample records used for sizing the columns. + pub fn set_col_sizing_records(&mut self, records: impl IntoIterator) { + self.col_sizing_records = records.into_iter().collect(); + } + /// Builder style method to set the autoselect flag. pub fn autoselect(mut self, autoselect: impl IntoPropValue) -> Self { self.set_autoselect(autoselect); @@ -1049,6 +1070,66 @@ impl PwtDataTable { }; } + // Render the cells of the sizing records with the column renderers, wrapped like table cells, + // so that they get the same font and padding when the header measures them. The cells of + // one column are stacked, so the widest one determines the column width. + fn render_column_sizers(&self, props: &DataTable) -> Vec> { + if props.col_sizing_records.is_empty() { + return Vec::new(); + } + + let records: Vec<(Key, &S::Record)> = props + .col_sizing_records + .iter() + .map(|record| (props.store.extract_key(record), record)) + .collect(); + + let mut column_index = 0; + self.columns + .iter() + .enumerate() + .map(|(column_num, column)| { + if let Some(true) = self.column_hidden.get(column_num) { + return None; + } + + let mut sizer = Container::new().class("pwt-datatable-content"); + for (row_index, (record_key, record)) in records.iter().enumerate() { + let mut args = DataTableCellRenderArgs { + selection: props.selection.clone(), + record: *record, + record_key, + row_index, + column_index, + selected: false, + config: (*self.cell_config).clone(), + is_expanded: false, + is_leaf: true, + level: 0, + }; + + let cell = column.apply_render(&mut args); + + sizer.add_child( + Container::new() + .class(args.config.class) + .styles(args.config.style) + .style("white-space", "nowrap") + // cell widths get reduced by the border width, see + // render_empty_row_with_sizes + .style( + "border-inline-end", + props.bordered.then_some("1px solid transparent"), + ) + .with_child(html! {
{cell}
}), + ); + } + column_index += 1; + Some(sizer.into()) + }) + .collect() + } + fn init_headers(&mut self, props: &DataTable) { let headers = create_indexed_header_list(&props.headers); @@ -1764,7 +1845,8 @@ impl Component for PwtDataTable { .focusable(props.header_focusable && props.show_header) .selection_status(self.selection_status) .header_class(header_class) - .reserve_scroll_space(self.scrollbar_size.unwrap_or_default()), + .reserve_scroll_space(self.scrollbar_size.unwrap_or_default()) + .column_sizers(self.render_column_sizers(props)), ) .into_html_with_ref(self.header_scroll_ref.clone()), ) diff --git a/src/widget/data_table/header_widget.rs b/src/widget/data_table/header_widget.rs index 64faf33..dfe2f76 100644 --- a/src/widget/data_table/header_widget.rs +++ b/src/widget/data_table/header_widget.rs @@ -43,6 +43,11 @@ pub struct HeaderWidget { pub focusable: bool, reserve_scroll_space: f64, + + /// Invisible content per column (indexed like the column list), used to size the column + /// tracks. + #[prop_or_default] + pub column_sizers: Vec>, } impl HeaderWidget { @@ -95,6 +100,17 @@ impl HeaderWidget { pub fn set_reserve_scroll_space(&mut self, reserve_scroll_space: f64) { self.reserve_scroll_space = reserve_scroll_space; } + + /// Builder style method to set the column sizers. + pub fn column_sizers(mut self, column_sizers: Vec>) -> Self { + self.set_column_sizers(column_sizers); + self + } + + /// Method to set the column sizers. + pub fn set_column_sizers(&mut self, column_sizers: Vec>) { + self.column_sizers = column_sizers; + } } pub enum Msg { @@ -626,6 +642,35 @@ impl Component for PwtHeaderWidget { &mut header_row, ); + // Put the sizers into their column tracks, so that content based track sizes account for + // them. They have no height and are invisible, so they do not change the header layout. + // Header cells end at row line 10, so use the row after that to avoid interfering with + // the auto placed group headers. + let mut grid_column = 0; + for col_idx in 0..self.state.columns().len() { + if self.state.get_column_hidden(col_idx) { + continue; + } + grid_column += 1; + if let Some(Some(sizer)) = props.column_sizers.get(col_idx) { + header_row.push( + Container::new() + .key(Key::from(format!("sizer-{col_idx}"))) + .attribute("role", "none") + .attribute("aria-hidden", "true") + .attribute( + "style", + format!( + "grid-row: 10;grid-column-start: {grid_column};\ + height: 0;overflow: clip;visibility: hidden;" + ), + ) + .with_child(sizer.clone()) + .into(), + ); + } + } + let column_count = self.state.visible_column_count(); // add some space at the end to make room for the tables vertical scrollbar -- 2.47.3