diff --git a/Cargo.lock b/Cargo.lock index 20dcf28ab..785493348 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -220,7 +220,7 @@ dependencies = [ "objc2-foundation 0.3.2", "parking_lot", "percent-encoding", - "windows-sys 0.59.0", + "windows-sys 0.61.2", "wl-clipboard-rs", "x11rb", ] @@ -1346,6 +1346,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" @@ -1413,6 +1426,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" @@ -1478,7 +1511,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -2131,6 +2164,7 @@ dependencies = [ "craft-update", "craft-window", "egui", + "egui_extras", "egui_kittest", "epaint_default_fonts", "gridcraft-chart", @@ -3507,7 +3541,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3963,7 +3997,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4348,7 +4382,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -4407,7 +4441,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -5158,7 +5192,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix 1.1.5", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6151,7 +6185,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -6967,11 +7001,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 297f7bab9..fd18f7041 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -18,6 +18,7 @@ gridcraft-chart = { workspace = true } # this feature. The UI draws โœ•, โ—€, โ†˜ and ๐Ÿ“‹ as text; without these faces the strict kittest # harness (and a host with no symbol font) renders them as tofu. 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 95c39a1a3..0e4eb6295 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -244,12 +244,17 @@ pub fn contributors_ui(ui: &mut egui::Ui) { let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending); ui.label(RichText::new(format!("{} contributors ยท {} commits", list.len(), group(TOTAL_COMMITS))).small().weak()); ui.separator(); - egui::ScrollArea::both().auto_shrink([false, false]).max_height(340.0).show(ui, |ui| { - if list.is_empty() { - ui.label("No contributor data was built into this copy."); - } else if v.table { - table(ui, &list, &mut v); - } else { + if list.is_empty() { + ui.label("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(), 340.0), |ui| { + ui.set_max_height(340.0); + egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| table(ui, &list, &mut v)); + }); + } else { + egui::ScrollArea::vertical().auto_shrink([false, false]).max_height(340.0).show(ui, |ui| { ui.horizontal_wrapped(|ui| { for (i, c) in list.iter().enumerate() { if i > 0 { @@ -258,39 +263,153 @@ 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}", k.label().1)).strong()).on_hover_text(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, each in proportion to its width above [`MIN_COLUMN`], and none goes below that. +fn fit_widths(natural: &[f32], grow: &[f32], room: f32) -> Vec { + let spare = room - natural.iter().sum::(); + let weight = grow.iter().sum::(); + let slack = |w: f32| (w - MIN_COLUMN).max(0.0); + let flexible: f32 = natural.iter().zip(grow).filter(|(_, g)| **g > 0.0).map(|(w, _)| slack(*w)).sum(); + natural + .iter() + .zip(grow) + .map(|(w, g)| { + if *g > 0.0 && spare >= 0.0 { + w + spare * g / weight + } else if *g > 0.0 && flexible > 0.0 { + w - (-spare).min(flexible) * slack(*w) / flexible + } 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. Every column, the last included, can be dragged wider or narrower at its right edge: +/// only a table dragged wider than the window scrolls sideways. Text too long for its column is +/// clipped. When a column's natural width or the window changes, the columns go back to a new fit, +/// dropping any dragged widths. +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)); + // The table keeps its own column widths, so a change in what they were fitted to has to clear + // them. A longer name can leave the fit itself unchanged, and scrolling can nudge the available + // width, so this compares the natural widths and the clip rect, which scrolling never moves. + let fit = egui::Id::unique(("credits_fit", id)); + let measured = (natural.to_vec(), ui.clip_rect().width()); + let refit = ui.data_mut(|d| d.get_temp::<(Vec, f32)>(fit)).as_ref() != Some(&measured); + ui.data_mut(|d| d.insert_temp(fit, measured)); + let mut t = + egui_extras::TableBuilder::new(ui).id_salt(id).striped(true).resizable(true).auto_shrink([false, false]).max_scroll_height(f32::INFINITY); + if refit { + t.reset(); + } + for w in widths { + t = t.column(egui_extras::Column::initial(w).at_least(MIN_COLUMN).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(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(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. @@ -300,21 +419,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().auto_shrink([false, false]).max_height(340.0).show(ui, |ui| { - egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| { - for h in ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/โˆ’"] { - ui.label(RichText::new(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(*title).strong(), rows.iter().filter_map(|r| r.get(i).cloned()))) + .collect(); + ui.allocate_ui(egui::vec2(ui.available_width(), 340.0), |ui| { + ui.set_max_height(340.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(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)); + }); + } + }); + } + }); }); }); } @@ -395,4 +543,131 @@ 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]); + } + + #[test] + fn a_column_at_the_minimum_leaves_the_shortfall_to_the_others() { + let w = fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 140.0); + assert!((w.iter().sum::() - 140.0).abs() < 0.01, "the columns still fit the room: {w:?}"); + assert!(w.iter().all(|c| *c >= MIN_COLUMN), "no column goes below the minimum: {w:?}"); + assert_eq!(fit_widths(&[100.0, 300.0, 50.0], &[1.0, 1.0, 0.0], 0.0), [MIN_COLUMN, MIN_COLUMN, 50.0]); + } + + const WINDOW: f32 = 640.5; + const NATURAL: [f32; 3] = [120.0, 80.0, 80.0]; + const GROW: [f32; 3] = [1.0, 0.0, 0.0]; + + /// One frame of a table long enough to scroll; the rects of its header cells. + fn table_frame(ctx: &egui::Context, width: f32, natural: &[f32], events: Vec) -> Vec { + scrolled_frame(ctx, width, natural, 0.0, events) + } + + /// [`table_frame`], scrolled `offset` points sideways. + fn scrolled_frame(ctx: &egui::Context, width: f32, natural: &[f32], offset: f32, events: Vec) -> Vec { + let screen_rect = Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::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(); + egui::ScrollArea::horizontal().auto_shrink([false, false]).horizontal_scroll_offset(offset).show(ui, |ui| { + fitted_table(ui, "test", natural, &GROW) + .header(20.0, |mut header| { + for _ in natural { + header.col(|ui| cells.push(ui.max_rect())); + } + }) + .body(|mut body| { + for _ in 0..40 { + body.row(20.0, |mut row| { + for _ in natural { + row.col(|ui| { + ui.label("x"); + }); + } + }); + } + }); + }); + }); + out.drop_without_applying_deltas(); + cells + } + + /// 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(); + table_frame(&ctx, WINDOW, &NATURAL, Vec::new()); + let cells = table_frame(&ctx, WINDOW, &NATURAL, Vec::new()); + (ctx, cells) + } + + /// Drags a column's right edge, which runs midway through the gap after `cell`, then lets go. + fn drag_edge(ctx: &egui::Context, cell: egui::Rect, by: f32) { + use egui::{Event, Modifiers, PointerButton}; + let start = egui::pos2(cell.right() + 4.0, cell.center().y); + let halfway = start + egui::vec2(by / 2.0, 0.0); + let end = start + egui::vec2(by, 0.0); + let moved = Event::PointerMoved; + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + for event in [moved(start), button(start, true), moved(halfway), moved(end), button(end, false)] { + table_frame(ctx, WINDOW, &NATURAL, 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); + table_frame(&ctx, WINDOW, &NATURAL, Vec::new()); + let after = table_frame(&ctx, WINDOW, &NATURAL, Vec::new()); + 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, &[200.0, 80.0, 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, &[120.0, 200.0, 80.0], 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, &NATURAL, 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); + table_frame(&ctx, WINDOW, &NATURAL, Vec::new()); + let dragged = table_frame(&ctx, WINDOW, &NATURAL, Vec::new()); + assert!((dragged[0].width() - opened[0].width() - 100.0).abs() < 1.0, "the drag took: {dragged:?}"); + for offset in [13.3, 27.77, 41.1, 0.6] { + scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new()); + let scrolled = scrolled_frame(&ctx, WINDOW, &NATURAL, offset, Vec::new()); + assert!((scrolled[0].width() - dragged[0].width()).abs() < 1.0, "scrolled {offset}: {scrolled:?}"); + } + } } diff --git a/crates/ui-egui/src/dialogs/show.rs b/crates/ui-egui/src/dialogs/show.rs index bb1e9f5e7..e4cd5bb24 100644 --- a/crates/ui-egui/src/dialogs/show.rs +++ b/crates/ui-egui/src/dialogs/show.rs @@ -148,7 +148,7 @@ pub fn show(app: &mut SheetApp, ctx: &egui::Context) { let mut confirm = false; let width = match d.name.as_str() { "formatCells" => 560.0, - "about" => 660.0, + "about" => 820.0, "insertFunction" | "commandSearch" | "nameManager" | "manageRules" | "journal" | "agents" | "equation" => 520.0, "macros" => 480.0, "recommendedCharts" => 600.0,