diff --git a/crates/ui-egui/src/dialogs/credits.rs b/crates/ui-egui/src/dialogs/credits.rs index ac8ae093f..2c64ccafb 100644 --- a/crates/ui-egui/src/dialogs/credits.rs +++ b/crates/ui-egui/src/dialogs/credits.rs @@ -343,15 +343,17 @@ fn column_width<'a>(ui: &egui::Ui, header: &str, cells: impl Iterator], - 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>, 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 = 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() { diff --git a/crates/ui-egui/src/dialogs/credits/ui_tests.rs b/crates/ui-egui/src/dialogs/credits/ui_tests.rs index fb3993ad9..0791a844f 100644 --- a/crates/ui-egui/src/dialogs/credits/ui_tests.rs +++ b/crates/ui-egui/src/dialogs/credits/ui_tests.rs @@ -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; 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], name: f32, events: Vec) -> Vec { + 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 { + 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) { + 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 = 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"); +}