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 <noreply@anthropic.com>
This commit is contained in:
FredApps
2026-11-03 21:47:11 +01:00
co-authored by Claude Opus 5.5
parent 801fd3aa67
commit 7d3103cb12
2 changed files with 210 additions and 55 deletions
+1 -1
View File
@@ -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,
});
+209 -54
View File
@@ -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<Item = String>) -> 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<f32> {
let spare = room - natural.iter().sum::<f32>();
let weight = grow.iter().sum::<f32>();
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<R>(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<f32> = 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<f32> = 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::<f32>() - 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]);
}
}