About: refit credits tables when what they measure changes

The columns became resizable, so the table keeps its own widths. A longer
name, a changed cell or a resized window left the old widths in place and
clipped text. The table now goes back to its fit when a column measure or
the clip rect width changes, which scrolling never moves.

Adds tests that drag every column edge, change the fit and scroll a
widened table, as CodeRabbit asked for the drag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
FredApps
2026-11-05 15:12:20 +01:00
co-authored by Claude Opus 5.5
parent fe0fbf852b
commit 9748583f84
2 changed files with 141 additions and 8 deletions
+23 -8
View File
@@ -343,15 +343,17 @@ fn column_width<'a>(ui: &egui::Ui, header: &str, cells: impl Iterator<Item = &'a
}
/// A credits table: columns of `widths`, the `None` one taking what the others leave (at least
/// `flex_min`). The header stays put while the rows scroll, within `height` (header included). Too
/// [`NAME_MIN_W`]). The header stays put while the rows scroll, within `height` (header included). Too
/// narrow for every column, it scrolls sideways rather than clip one. Every
/// column edge can be dragged, down to [`MIN_COLUMN`] (the `None` column to `flex_min`): widening
/// a column grows the table, as in a spreadsheet, and only then does it scroll sideways.
/// column edge can be dragged, down to [`MIN_COLUMN`] (the `None` column to [`NAME_MIN_W`]): widening
/// a column grows the table, as in a spreadsheet, and only then does it scroll sideways. When the
/// window, a column's width or `flex_text` (the width the `None` column's text needs) changes, the
/// columns go back to this fit, dropping any dragged widths.
fn credits_table(
ui: &mut egui::Ui,
id: &str,
widths: &[Option<f32>],
flex_min: f32,
flex_text: f32,
height: f32,
header: impl FnOnce(egui_extras::TableRow<'_, '_>),
body: impl FnOnce(egui_extras::TableBody<'_>),
@@ -362,9 +364,16 @@ fn credits_table(
// The table puts a row gap under its header.
let body_h = (height - TABLE_HEADER_H - ui.spacing().item_spacing.y).max(TABLE_ROW_H);
egui::ScrollArea::horizontal().id_salt((id, "h")).auto_shrink([false, true]).show(ui, |ui| {
let flex = (ui.available_width() - fixed).max(flex_min);
let flex = (ui.available_width() - fixed).max(NAME_MIN_W);
// No gaps between cells: their padding spaces them, and the header rule runs unbroken.
ui.spacing_mut().item_spacing.x = 0.0;
// The table keeps its own column widths, so a change in what they were fitted to has to clear
// them. Scrolling can nudge the available width, so this compares the clip rect, which scrolling
// never moves.
let fit = egui::Id::unique(("credits_fit", id));
let measured = (widths.to_vec(), flex_text, ui.clip_rect().width());
let refit = ui.data_mut(|d| d.get_temp::<(Vec<Option<f32>>, f32, f32)>(fit)).as_ref() != Some(&measured);
ui.data_mut(|d| d.insert_temp(fit, measured));
let mut b = egui_extras::TableBuilder::new(ui)
.id_salt(id)
.striped(true)
@@ -373,10 +382,13 @@ fn credits_table(
.min_scrolled_height(body_h)
.max_scroll_height(body_h)
.cell_layout(egui::Layout::left_to_right(egui::Align::Center));
if refit {
b.reset();
}
for w in widths {
b = b.column(match w {
Some(w) => egui_extras::Column::initial(*w).at_least(MIN_COLUMN).clip(true),
None => egui_extras::Column::initial(flex).at_least(flex_min).clip(true),
None => egui_extras::Column::initial(flex).at_least(NAME_MIN_W).clip(true),
});
}
b.header(TABLE_HEADER_H, header).body(body);
@@ -399,11 +411,13 @@ fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View, height: f32) {
let t = Tokens::get(ui.ctx());
let names = v.names;
let widths = contributor_widths(ui, list);
let shown: Vec<String> = list.iter().map(|c| c.name(names)).collect();
let name_text = column_width(ui, SortKey::Name.label().1, shown.iter().map(String::as_str));
credits_table(
ui,
"credits_table",
&widths,
NAME_MIN_W,
name_text,
height,
|mut header| {
for k in SortKey::ALL {
@@ -526,12 +540,13 @@ fn models_in(ui: &mut egui::Ui, models: &[Model], total_commits: u64) {
let total = total_commits.max(models.iter().map(|m| m.commits).max().unwrap_or(0));
let rows: Vec<[String; 6]> = models.iter().map(|m| model_cells(m, total)).collect();
let widths = model_widths(ui, &rows);
let model_text = column_width(ui, tr(MODEL_HEADS[1]), rows.iter().filter_map(|r| r.get(1)).map(String::as_str));
let numeric = |i: usize| i >= 3;
credits_table(
ui,
"credits_models",
&widths,
NAME_MIN_W,
model_text,
table_height(ui, rows.len()),
|mut header| {
for (i, h) in MODEL_HEADS.iter().enumerate() {
@@ -267,3 +267,121 @@ fn the_about_window_draws_every_tab() {
assert_eq!(app.ui.about_tab, tab);
}
}
const WINDOW: f32 = 640.5;
const WIDTHS: [Option<f32>; 3] = [None, Some(80.0), Some(80.0)];
const NAME: f32 = 120.0;
/// One frame of a table long enough to scroll, in a window `width` wide; the rects of its header cells.
fn table_frame(ctx: &egui::Context, width: f32, widths: &[Option<f32>], name: f32, events: Vec<egui::Event>) -> Vec<Rect> {
let screen_rect = Some(Rect::from_min_size(egui::Pos2::ZERO, vec2(width, 300.0)));
let raw = egui::RawInput { screen_rect, events, ..Default::default() };
let mut cells = Vec::new();
let out = ctx.run_ui(raw, |ui| {
cells.clear();
credits_table(
ui,
"test",
widths,
name,
240.0,
|mut header| {
for _ in widths {
header.col(|ui| cells.push(ui.max_rect()));
}
},
|body| {
body.rows(TABLE_ROW_H, 40, |mut row| {
for _ in widths {
row.col(|ui| {
ui.label("x");
});
}
});
},
);
});
out.drop_without_applying_deltas();
cells
}
/// [`table_frame`] at the usual size.
fn settled_frame(ctx: &egui::Context) -> Vec<Rect> {
table_frame(ctx, WINDOW, &WIDTHS, NAME, Vec::new())
}
/// A new context with the table open and settled, and the rects of its header cells.
fn opened_table() -> (egui::Context, Vec<Rect>) {
let ctx = egui::Context::default();
settled_frame(&ctx);
let cells = settled_frame(&ctx);
(ctx, cells)
}
/// Drags a column's right edge (cells have no gap between them), then lets go.
fn drag_edge(ctx: &egui::Context, cell: Rect, by: f32) {
let start = egui::pos2(cell.right(), cell.center().y);
let halfway = start + vec2(by / 2.0, 0.0);
let end = start + vec2(by, 0.0);
let moved = egui::Event::PointerMoved;
let button = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
for event in [moved(start), button(start, true), moved(halfway), moved(end), button(end, false)] {
table_frame(ctx, WINDOW, &WIDTHS, NAME, vec![event]);
}
}
#[test]
fn every_column_can_be_dragged_at_its_right_edge_and_stays_there() {
let (_, opened) = opened_table();
for (dragged, cell) in opened.iter().enumerate() {
let (ctx, _) = opened_table();
drag_edge(&ctx, *cell, -30.0);
settled_frame(&ctx);
let after = settled_frame(&ctx);
for (column, (was, now)) in opened.iter().zip(&after).enumerate() {
let want = if column == dragged { was.width() - 30.0 } else { was.width() };
assert!((now.width() - want).abs() < 1.0, "dragging column {dragged}: column {column} is {now:?}, was {was:?}");
}
}
}
#[test]
fn a_changed_fit_replaces_the_column_widths() {
let (ctx, opened) = opened_table();
drag_edge(&ctx, opened[0], -30.0);
let longer_name = table_frame(&ctx, WINDOW, &WIDTHS, NAME + 80.0, Vec::new());
assert!((longer_name[0].width() - opened[0].width()).abs() < 1.0, "a longer name drops a dragged width: {longer_name:?}");
let wider = table_frame(&ctx, WINDOW, &[None, Some(200.0), Some(80.0)], NAME, Vec::new());
let widths: Vec<f32> = wider.iter().map(|c| c.width().round()).collect();
assert!(widths.ends_with(&[200.0, 80.0]), "a longer cell widens its column: {widths:?}");
assert!(wider[2].right() < WINDOW, "the table still fits the window: {wider:?}");
let narrower = table_frame(&ctx, 400.0, &WIDTHS, NAME, Vec::new());
assert!(narrower[2].right() < 400.0, "a narrower window refits the table: {narrower:?}");
}
#[test]
fn scrolling_sideways_keeps_dragged_widths() {
let (ctx, opened) = opened_table();
drag_edge(&ctx, opened[0], 100.0);
settled_frame(&ctx);
let dragged = settled_frame(&ctx);
assert!((dragged[0].width() - opened[0].width() - 100.0).abs() < 1.0, "the drag took: {dragged:?}");
table_frame(&ctx, WINDOW, &WIDTHS, NAME, vec![egui::Event::PointerMoved(dragged[0].center())]);
let mut scrolled = false;
for delta in [-13.3, -27.77, -41.1, 30.6] {
let wheel = egui::Event::MouseWheel {
source: egui::MouseWheelSource::Unknown,
unit: egui::MouseWheelUnit::Point,
delta: vec2(delta, 0.0),
modifiers: egui::Modifiers::NONE,
phase: egui::TouchPhase::Move,
};
table_frame(&ctx, WINDOW, &WIDTHS, NAME, vec![wheel]);
for _ in 0..8 {
let cells = settled_frame(&ctx);
assert!((cells[0].width() - dragged[0].width()).abs() < 1.0, "scrolled by {delta}: {cells:?}");
scrolled |= cells[0].left() < dragged[0].left() - 1.0;
}
}
assert!(scrolled, "the wheel never moved the table sideways");
}