From 969df57ea2e10342e4d17db87c83fff395482dac Mon Sep 17 00:00:00 2001 From: FredApps <153173854+FredApps@users.noreply.github.com> Date: Wed, 4 Nov 2026 00:12:07 +0100 Subject: [PATCH] About: credits tables fit without side-scrolling and their columns resize (#5506) 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. The About dialog opens 820 points wide, so every column fits at its natural width. Co-authored-by: Claude Opus 5.5 --- Cargo.lock | 39 +++- crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/credits.rs | 262 ++++++++++++++++++++----- crates/ui-egui/src/dialogs/dispatch.rs | 2 +- 4 files changed, 246 insertions(+), 58 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 210c7872f6..ff78a02fb9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1520,6 +1520,19 @@ dependencies = [ "winit", ] +[[package]] +name = "egui_extras" +version = "0.37.0" +source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" +dependencies = [ + "ahash", + "egui", + "enum-map", + "itertools", + "log", + "profiling", +] + [[package]] name = "egui_glow" version = "0.37.0" @@ -1587,6 +1600,26 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" +[[package]] +name = "enum-map" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4532960034c15ba2f55e9eee631040ecc473c32e6c4b200828c759840f13a06" +dependencies = [ + "enum-map-derive", +] + +[[package]] +name = "enum-map-derive" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7127272f64b3e098f7d81f350d672081b879bff896d219c42370dc858b1af9ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", +] + [[package]] name = "enumflags2" version = "0.7.12" @@ -7017,6 +7050,7 @@ dependencies = [ "craft-update", "craft-window", "egui", + "egui_extras", "egui_kittest", "image", "js-sys", @@ -7580,11 +7614,6 @@ name = "egui_demo_lib" version = "0.37.0" source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" -[[patch.unused]] -name = "egui_extras" -version = "0.37.0" -source = "git+https://github.com/storytold/egui.git?rev=6d518fc799681571963842f95b55a6b68431b15a#6d518fc799681571963842f95b55a6b68431b15a" - [[patch.unused]] name = "egui_inspection" version = "0.37.0" diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 76f5392587..9104787589 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -25,6 +25,7 @@ wordcraft-render = { workspace = true } wordcraft-engine = { workspace = true } wordcraft-zotero = { workspace = true } egui = { workspace = true, features = ["default_fonts", "monochrome_emoji_fonts"] } +egui_extras = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } log = { workspace = true } diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 4a5217addc..4fe8b262a5 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -250,12 +250,17 @@ pub fn contributors_ui(ui: &mut egui::Ui) { ui.label(RichText::new(summary).small().weak()); ui.separator(); crate::rtl::island(ui, |ui| { - egui::ScrollArea::both().auto_shrink([false, true]).max_height(360.0).show(ui, |ui| { - if list.is_empty() { - ui.label(tl!("No contributor data was built into this copy.")); - } else if v.table { - table(ui, &list, &mut v); - } else { + if list.is_empty() { + ui.label(tl!("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(), 360.0), |ui| { + ui.set_max_height(360.0); + egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| table(ui, &list, &mut v)); + }); + } else { + egui::ScrollArea::vertical().auto_shrink([false, true]).max_height(360.0).show(ui, |ui| { ui.horizontal_wrapped(|ui| { for (i, c) in list.iter().enumerate() { if i > 0 { @@ -264,40 +269,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) { - crate::rtl::grid(ui, egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()), |ui| { - for k in SortKey::ALL { - let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" }; - if ui.button(RichText::new(format!("{}{arrow}", tl!(k.label().1))).strong()).on_hover_text(tl!(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(tl!(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(tl!(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. @@ -307,24 +421,53 @@ pub fn models_ui(ui: &mut egui::Ui) { return; } let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1); - crate::rtl::island(ui, |ui| { - egui::ScrollArea::both().auto_shrink([false, true]).max_height(360.0).show(ui, |ui| { - crate::rtl::grid(ui, egui::Grid::new("credits_models").striped(true).num_columns(6), |ui| { - for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"] { - ui.label(RichText::new(tl!(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 = ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"]; + // 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(tl!(title)).strong(), rows.iter().filter_map(|r| r.get(i).cloned()))) + .collect(); + crate::rtl::island(ui, |ui| { + ui.allocate_ui(egui::vec2(ui.available_width(), 360.0), |ui| { + ui.set_max_height(360.0); + 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(tl!(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)); + }); + } + }); + } + }); + }); + }); }); } @@ -404,4 +547,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]); + } } diff --git a/crates/ui-egui/src/dialogs/dispatch.rs b/crates/ui-egui/src/dialogs/dispatch.rs index 921f8d2699..bbc3544ae4 100644 --- a/crates/ui-egui/src/dialogs/dispatch.rs +++ b/crates/ui-egui/src/dialogs/dispatch.rs @@ -1112,7 +1112,7 @@ fn about_dialog(ui: &mut Ui, d: &mut Dialog) -> bool { let Dialog::About { tab } = d else { return false; }; - ui.set_width(660.0); + ui.set_width(820.0); let names = ["About", "Contributors", "Models", "Language data"].map(|n| tl!(n).to_string()); if let Some(i) = crate::widgets::dialog_tabs(ui, &names, *tab as usize) { *tab = i as u8;