From 7d3103cb125432134e0067ccaa71bf2bc40f99bf Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Tue, 3 Nov 2026 21:38:52 +0100 Subject: [PATCH] fix(ui): About credits tables fit without side-scrolling and their columns resize The Contributors table and the Models table were egui Grids inside a two-way scroll area: they opened wider than the dialog, so they scrolled sideways, and Company and Model sat almost touching. Both are now egui_extras tables. Each column opens at the width of its header and widest cell, the spare width goes to the text columns (Name; Company and Model), and every column edge can be dragged. Widening a column grows the table like a spreadsheet, and only then does it scroll sideways; the header stays put while the rows scroll. Counts and dates are right-aligned and every cell is vertically centred. The sorted column stays highlighted and its direction shows on the toolbar's sort-order button, so headers no longer reserve room for an arrow. The opening widths come from fit_widths, a pure function with unit tests that a fitted table fills its room exactly and that only the text columns give way when room is short. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 2 +- crates/ui-egui/src/dialogs/credits.rs | 263 ++++++++++++++++++++------ 2 files changed, 210 insertions(+), 55 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 84c36f41aa..a0cec96f53 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -132,7 +132,7 @@ fn dialogs(app: &mut PdfCraftApp, ctx: &egui::Context) { Dialog::Print => 820.0, Dialog::FieldProps | Dialog::BulkFieldProps => 600.0, Dialog::Preferences => 610.0, - Dialog::About => 780.0, + Dialog::About => 820.0, Dialog::PinTool => 540.0, _ => 520.0, }); diff --git a/crates/ui-egui/src/dialogs/credits.rs b/crates/ui-egui/src/dialogs/credits.rs index ad45ec77b6..1c909072e0 100644 --- a/crates/ui-egui/src/dialogs/credits.rs +++ b/crates/ui-egui/src/dialogs/credits.rs @@ -266,12 +266,17 @@ pub fn contributors_ui(ui: &mut egui::Ui) { let counts = crate::i18n::fmt(crate::i18n::t("{n} contributors · {c} commits"), &[("n", &list.len().to_string()), ("c", &group(TOTAL_COMMITS))]); ui.label(RichText::new(counts).small().color(t.text_muted)); ui.separator(); - egui::ScrollArea::both().id_salt("credits_list").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| { - if list.is_empty() { - ui.label(crate::i18n::t("No contributor data was built into this copy.")); - } else if v.table { - table(ui, &list, &mut v); - } else { + if list.is_empty() { + ui.label(crate::i18n::t("No contributor data was built into this copy.")); + } else if v.table { + // The table scrolls vertically itself, under a fixed header; it only scrolls sideways + // once its columns are dragged wider than the dialog. + ui.allocate_ui(egui::vec2(ui.available_width(), LIST_HEIGHT), |ui| { + ui.set_max_height(LIST_HEIGHT); + egui::ScrollArea::horizontal().id_salt("credits_list").auto_shrink([false, false]).show(ui, |ui| table(ui, &list, &mut v)); + }); + } else { + egui::ScrollArea::vertical().id_salt("credits_list").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| { ui.horizontal_wrapped(|ui| { for (i, c) in list.iter().enumerate() { if i > 0 { @@ -280,43 +285,149 @@ pub fn contributors_ui(ui: &mut egui::Ui) { ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary()); } }); - } - }); + }); + } ui.data_mut(|d| d.insert_temp(id, v)); } -fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) { - egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| { - for k in SortKey::ALL { - let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" }; - if ui - .button(RichText::new(format!("{}{arrow}", crate::i18n::t(k.label().1))).strong()) - .on_hover_text(crate::i18n::t(k.label().0)) - .clicked() - { - if v.key == k { - v.ascending = !v.ascending; - } else { - v.key = k; - v.ascending = k.default_ascending(); - } +/// How the contributor table's spare width is shared out: all of it to Name. +const CONTRIBUTOR_GROW: [f32; 10] = [1.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]; + +/// How the model table's spare width is shared out: to Company and Model, the text columns. +const MODEL_GROW: [f32; 6] = [1.0, 2.0, 0.0, 0.0, 0.0, 0.0]; + +/// The narrowest a column can be dragged. +const MIN_COLUMN: f32 = 28.0; + +/// The width a column needs for its header and its widest cell. +fn natural(ui: &egui::Ui, header: RichText, cells: impl Iterator) -> f32 { + let measure = |text: egui::WidgetText| text.into_galley(ui, Some(egui::TextWrapMode::Extend), f32::INFINITY, egui::TextStyle::Body).size().x; + let widest = cells.map(|c| measure(c.into())).fold(measure(header.into()), f32::max); + widest + 2.0 * ui.spacing().button_padding.x +} + +/// Opening column widths: each column's natural width, with the spare room shared out by `grow`, +/// so the columns fill `room` exactly. When there is too little room, only the growing columns +/// give way, down to [`MIN_COLUMN`]. +fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec { + let spare = room - natural.iter().sum::(); + let weight = grow.iter().sum::(); + let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| *w).sum(); + natural + .iter() + .zip(grow) + .map(|(w, g)| { + if spare >= 0.0 && weight > 0.0 { + w + spare * g / weight + } else if *g > 0.0 && flexible > 0.0 { + (w + spare * w / flexible).max(MIN_COLUMN) + } else { + *w } - } - ui.end_row(); - for c in list { - ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary()); - ui.label(group(c.prs)); - ui.label(group(c.commits)); - ui.label(group(c.lines_added)); - ui.label(group(c.lines_deleted)); - ui.label(signed(c.lines_delta())); - ui.label(group(c.binary_added)); - ui.label(group(c.binary_deleted)); - ui.label(day(c.first_commit)); - ui.label(day(c.last_commit)); - ui.end_row(); - } - }); + }) + .collect() +} + +/// A striped table whose columns open at [`fit_widths`], so it fills the width and nothing scrolls +/// sideways. Each column can be dragged wider or narrower at its edge. The last column fills +/// whatever width is left but never shrinks below its opening width, so widening a column grows +/// the table, as in a spreadsheet, instead of crushing the last one: only a table dragged wider +/// than the window scrolls sideways. Text too long for its column is clipped. +fn fitted_table<'a>(ui: &'a mut egui::Ui, id: &str, natural: &[f32], grow: &[f32]) -> egui_extras::TableBuilder<'a> { + // Every cell is followed by the item gap, and the vertical scrollbar keeps its margins. + let s = ui.spacing(); + let gaps = s.item_spacing.x * natural.len() as f32; + let bar = s.scroll.bar_width + s.scroll.bar_inner_margin + s.scroll.bar_outer_margin; + let widths = fit_widths(natural, grow, (ui.available_width() - gaps - bar).max(0.0)); + let mut t = + egui_extras::TableBuilder::new(ui).id_salt(id).striped(true).resizable(true).auto_shrink([false, false]).max_scroll_height(f32::INFINITY); + let last = widths.len().saturating_sub(1); + for (i, w) in widths.into_iter().enumerate() { + let c = if i == last { + egui_extras::Column::remainder().at_least(w.max(MIN_COLUMN)) + } else { + egui_extras::Column::initial(w).at_least(MIN_COLUMN) + }; + t = t.column(c.clip(true)); + } + t +} + +/// Add a cell's widget right-aligned (counts, dates) or left-aligned (names), vertically centred. +fn aligned(ui: &mut egui::Ui, right: bool, add: impl FnOnce(&mut egui::Ui) -> R) -> R { + let layout = if right { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) }; + ui.with_layout(layout, add).inner +} + +fn row_height(ui: &egui::Ui) -> f32 { + ui.text_style_height(&egui::TextStyle::Body) + 6.0 +} + +/// One contributor's table cells after the name, in `SortKey::ALL` order. +fn cells(c: &Contributor) -> [String; 9] { + [ + group(c.prs), + group(c.commits), + group(c.lines_added), + group(c.lines_deleted), + signed(c.lines_delta()), + group(c.binary_added), + group(c.binary_deleted), + day(c.first_commit).to_string(), + day(c.last_commit).to_string(), + ] +} + +/// A contributor-table header. The sorted column is highlighted; its direction shows on the +/// toolbar's sort-order button, so no arrow widens the column. +fn header_text(k: SortKey) -> RichText { + RichText::new(crate::i18n::t(k.label().1)).strong() +} + +fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) { + let h = row_height(ui); + let rows: Vec<[String; 9]> = list.iter().map(|c| cells(c)).collect(); + let widths: Vec = SortKey::ALL + .iter() + .enumerate() + .map(|(i, &k)| match i.checked_sub(1) { + None => natural(ui, header_text(k), list.iter().map(|c| c.name(v.names))), + Some(col) => natural(ui, header_text(k), rows.iter().filter_map(|r| r.get(col).cloned())), + }) + .collect(); + fitted_table(ui, "credits_table", &widths, &CONTRIBUTOR_GROW) + .header(h + 4.0, |mut header| { + for k in SortKey::ALL { + header.col(|ui| { + let r = aligned(ui, k != SortKey::Name, |ui| ui.selectable_label(v.key == k, header_text(k))) + .on_hover_text(crate::i18n::t(k.label().0)); + + if r.clicked() { + if v.key == k { + v.ascending = !v.ascending; + } else { + v.key = k; + v.ascending = k.default_ascending(); + } + } + }); + } + }) + .body(|mut body| { + for (c, cells) in list.iter().zip(rows) { + body.row(h, |mut row| { + row.col(|ui| { + aligned(ui, false, |ui| ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login))) + .on_hover_text(c.summary()); + }); + for text in cells { + row.col(|ui| { + aligned(ui, true, |ui| ui.label(text)); + }); + } + }); + } + }); } /// About ▸ Models: AI models credited in Co-Authored-By trailers. @@ -326,21 +437,50 @@ pub fn models_ui(ui: &mut egui::Ui) { return; } let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1); - egui::ScrollArea::both().id_salt("credits_models").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| { - egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| { - for h in MODEL_COLUMNS { - ui.label(RichText::new(crate::i18n::t(h)).strong()); - } - ui.end_row(); - for m in MODELS { - ui.label(m.company); - ui.label(m.model); - ui.label(m.version); - ui.label(group(m.commits)); - ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64)); - ui.label(format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted))); - ui.end_row(); - } + let h = row_height(ui); + let titles = MODEL_COLUMNS; + // Company, Model and Version read as text; the counts line up on the right. + let right = [false, false, false, true, true, true]; + let rows: Vec<[String; 6]> = MODELS + .iter() + .map(|m| { + [ + m.company.to_string(), + m.model.to_string(), + m.version.to_string(), + group(m.commits), + format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64), + format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)), + ] + }) + .collect(); + let widths: Vec = titles + .iter() + .enumerate() + .map(|(i, title)| natural(ui, RichText::new(crate::i18n::t(title)).strong(), rows.iter().filter_map(|r| r.get(i).cloned()))) + .collect(); + ui.allocate_ui(egui::vec2(ui.available_width(), LIST_HEIGHT), |ui| { + ui.set_max_height(LIST_HEIGHT); + egui::ScrollArea::horizontal().id_salt("credits_models").auto_shrink([false, false]).show(ui, |ui| { + fitted_table(ui, "credits_models", &widths, &MODEL_GROW) + .header(h + 4.0, |mut header| { + for (title, r) in titles.into_iter().zip(right) { + header.col(|ui| { + aligned(ui, r, |ui| ui.label(RichText::new(crate::i18n::t(title)).strong())); + }); + } + }) + .body(|mut body| { + for cells in rows { + body.row(h, |mut row| { + for (text, r) in cells.into_iter().zip(right) { + row.col(|ui| { + aligned(ui, r, |ui| ui.label(text)); + }); + } + }); + } + }); }); }); } @@ -421,4 +561,19 @@ mod tests { assert_eq!(day("2026-10-07T18:45:23Z"), "2026-10-07"); assert_eq!(day(""), "—"); } + + #[test] + fn columns_fill_the_room_and_spare_width_goes_to_growing_columns() { + assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 300.0), [200.0, 40.0, 60.0]); + assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 2.0, 0.0], 230.0), [110.0, 60.0, 60.0]); + let w = fit_widths(&[123.0, 45.5, 67.25], &[1.0, 0.0, 2.0], 512.0); + assert!((w.iter().sum::() - 512.0).abs() < 0.01, "a fitted table neither scrolls nor leaves a gap: {w:?}"); + } + + #[test] + fn too_little_room_shrinks_only_growing_columns() { + assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 150.0), [50.0, 40.0, 60.0]); + assert_eq!(fit_widths(&[100.0, 40.0, 60.0], &[1.0, 0.0, 0.0], 0.0), [MIN_COLUMN, 40.0, 60.0]); + assert_eq!(fit_widths(&[100.0, 40.0], &[0.0, 0.0], 50.0), [100.0, 40.0]); + } }