Compare commits

..
Author SHA1 Message Date
FredAppsandClaude Opus 5.5 a0fcfafc06 Refit credits tables on what they measure, not the fitted widths
Comparing the fitted widths missed the case it was meant for. Name is the
only growing column, so a longer name takes its extra width from the
spare room and the fit comes out the same: a dragged Name width stayed,
clipping the longer name.

It could also drop dragged widths by accident. The fit comes from the
available width inside the scroll area, which scrolling can nudge, so
scrolling a widened table could reset it.

The table now compares the natural widths and the clip rect's width,
which scrolling does not move.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 18:44:06 +01:00
FredAppsandClaude Opus 5.5 9f632d52e0 Let every credits column be dragged, and refit changed tables
The last column was egui's remainder column, which has no drag handle, so
it could only be resized from the edge before it. It is now an ordinary
column with its own handle.

A table keeps its column widths, so switching to a longer name left the
Name column at its old width and clipped the text. The table now goes
back to the opening fit whenever that fit changes: a longer widest cell
or a resized window. Dragged widths are dropped at that point.

fit_widths could overflow the room when one column hit the minimum,
because the other columns did not take up its share. Columns now give
way in proportion to their width above the minimum.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 17:17:58 +01:00
FredAppsandClaude Opus 5.5 dc1ba7d406 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.

The About dialog opens 820 points wide, so every column fits at its natural width.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 21:55:58 +01:00
8 changed files with 366 additions and 147 deletions
Generated
+34 -5
View File
@@ -806,6 +806,7 @@ dependencies = [
"craft-window",
"egui",
"egui-wgpu",
"egui_extras",
"egui_kittest",
"image",
"log",
@@ -1510,6 +1511,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"
@@ -1578,6 +1592,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.9",
]
[[package]]
name = "enumflags2"
version = "0.7.12"
@@ -6250,11 +6284,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"
+2
View File
@@ -63,6 +63,8 @@ quick-xml = "0.42"
craft-ui = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["serde"] }
craft-window = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] }
egui = { version = "0.37", default-features = false, features = ["default_fonts", "monochrome_emoji_fonts"] }
# Resizable table columns (About ▸ Contributors / Models); no image loaders.
egui_extras = { version = "0.37", default-features = false }
egui_kittest = "0.37"
eframe = { version = "0.37", default-features = false }
egui-wgpu = "0.37"
+7 -44
View File
@@ -367,7 +367,6 @@ pub fn reads_solid(d: &Drawing, h: &Hatch, px: f64) -> bool {
/// `px`: a pixel in the hatch's units for the level of detail (a dash or dot repeat under two
/// pixels is one continuous run); 0 = full detail, as plots, exports and picks use. `view`: only
/// the part of the hatch inside it is generated; `None` = all of it.
#[cfg(test)]
pub fn pattern_lines(d: &Drawing, h: &Hatch, loops: &[Vec<Vec2>], px: f64, view: Option<&Bounds2>) -> Vec<Vec<Vec2>> {
pattern_lines_until(d, h, loops, px, view, None)
}
@@ -381,21 +380,6 @@ pub(crate) fn pattern_lines_until(
px: f64,
view: Option<&Bounds2>,
cancel: Option<&AtomicBool>,
) -> Vec<Vec<Vec2>> {
pattern_lines_within(d, h, loops, px, view, cancel, MAX_RUNS, MAX_HATCH_WORK)
}
/// [`pattern_lines_until`] with the most runs and the dash-work allowance given.
#[allow(clippy::too_many_arguments)]
fn pattern_lines_within(
d: &Drawing,
h: &Hatch,
loops: &[Vec<Vec2>],
px: f64,
view: Option<&Bounds2>,
cancel: Option<&AtomicBool>,
max_runs: usize,
work_limit: u64,
) -> Vec<Vec<Vec2>> {
let lines = lines_of(d, h);
let scale = scale_of(h);
@@ -404,7 +388,7 @@ fn pattern_lines_within(
// Shared work budget across all families and spans, counting dash-processing steps even when
// they emit no geometry: gap-only families produce no runs, so `out.len()` alone cannot bound
// their cost (CWE-400).
let mut work: u64 = work_limit;
let mut work: u64 = MAX_HATCH_WORK;
for fam in &lines {
// A family with dashes but no positive (ink) element can never draw: skip it so its gaps
// are not swept at all.
@@ -419,8 +403,8 @@ fn pattern_lines_within(
continue;
}
let dashes: Vec<f64> = fam.dashes.iter().map(|d| d * scale).collect();
Family { max_runs, origin, ang, delta, spacing, dashes: &dashes }.sweep(loops, px, view, cancel, &mut out, &mut work);
if out.len() >= max_runs || work == 0 || is_set(cancel) {
Family { origin, ang, delta, spacing, dashes: &dashes }.sweep(loops, px, view, cancel, &mut out, &mut work);
if out.len() >= MAX_RUNS || work == 0 || is_set(cancel) {
break;
}
}
@@ -431,8 +415,6 @@ fn pattern_lines_within(
const SWATCH_REPEATS: f64 = 6.0;
/// Most runs a swatch holds.
const MAX_SWATCH_RUNS: usize = 4000;
/// The dash-work allowance of a swatch (a hatch's is [`MAX_HATCH_WORK`]).
const MAX_SWATCH_WORK: u64 = 2_000_000;
/// A preview of a pattern for a pattern picker: the runs of its line families `lines` inside the
/// `side` × `side` square with a corner at the origin, at a scale that shows a few repeats of the
@@ -459,19 +441,14 @@ pub fn pattern_swatch(lines: &[PatternLine], side: f64) -> Vec<Vec<Vec2>> {
pattern_lines: lines.to_vec(),
};
let square = vec![Vec2::new(0.0, 0.0), Vec2::new(side, 0.0), Vec2::new(side, side), Vec2::new(0.0, side)];
// The generator stops at the swatch's own limits, so a compact pattern is never made in full
// (about a million runs) to keep a few thousand.
let mut runs = pattern_lines_within(&Drawing::default(), &h, &[square], 0.0, None, None, MAX_SWATCH_RUNS, MAX_SWATCH_WORK);
let mut runs = pattern_lines(&Drawing::default(), &h, &[square], 0.0, None);
runs.truncate(MAX_SWATCH_RUNS);
runs.shrink_to_fit();
runs
}
/// One pattern line family, scaled and placed: parallel lines `spacing` apart through `origin`
/// at angle `ang`, each shifted along itself by `delta`'s along-line part from the last.
struct Family<'a> {
/// Most runs the hatch may hold in all ([`MAX_RUNS`], less for a swatch).
max_runs: usize,
origin: Vec2,
ang: f64,
delta: Vec2,
@@ -510,13 +487,6 @@ impl Family<'_> {
/// Intersect a pattern family with boundary loops, respecting view, output, cancellation, and shared dash-work limits.
fn sweep(&self, loops: &[Vec<Vec2>], px: f64, view: Option<&Bounds2>, cancel: Option<&AtomicBool>, out: &mut Vec<Vec<Vec2>>, work: &mut u64) {
// Transforming the boundary is charged before it is done, at the scanline rate (one unit per 8 vertices).
let verts: u64 = loops.iter().map(|l| l.len() as u64).sum();
let transform_charge = verts / 8 + 1;
if transform_charge > *work {
return;
}
*work -= transform_charge;
let local: Vec<Vec<Vec2>> = loops.iter().map(|l| l.iter().map(|p| self.to_local(*p)).collect()).collect();
let bb = Bounds2::from_points(local.iter().flatten().copied());
if bb.is_empty() {
@@ -535,17 +505,10 @@ impl Family<'_> {
// family draws anything at all).
let draws = self.dashes.iter().any(|d| *d >= 0.0);
let solid_line = self.dashes.is_empty() || pat_len < 1e-12 || (px > 0.0 && pat_len < 2.0 * px && draws);
// Each scanline visits every boundary edge and sorts the intersections. Charge both the
// traversal and an n*ceil(log2(n)) upper bound for sorting, scaled to one unit per eight
// operations, whether or not any dash is made.
let edges: u64 = local.iter().map(|l| l.len() as u64).sum();
let sort_levels = if edges > 1 { (u64::BITS - (edges - 1).leading_zeros()) as u64 } else { 0 };
let crossings_charge = edges.saturating_add(edges.saturating_mul(sort_levels)) / 8 + 1;
for k in k0..=k1 {
if out.len() >= self.max_runs || crossings_charge > *work || is_set(cancel) {
if out.len() >= MAX_RUNS || *work == 0 || is_set(cancel) {
return;
}
*work -= crossings_charge;
let y = k as f64 * self.spacing;
let xs = crossings(&local, y);
let offset = shift_per_line * k as f64;
@@ -575,10 +538,10 @@ impl Family<'_> {
'outer: while x < x1 {
for d in self.dashes {
guard += 1;
if guard > 100_000 || out.len() >= self.max_runs || *work == 0 {
*work = work.saturating_sub(1);
if guard > 100_000 || out.len() >= MAX_RUNS || *work == 0 {
break 'outer;
}
*work -= 1;
let len = d.abs();
if *d > 0.0 {
let a = x.max(x0);
-31
View File
@@ -21,17 +21,11 @@ use cadcraft_geom::Vec2;
/// Upper bound on the texts drawn along one run (hostile scales; `apply` caps the dashes).
const MAX_TEXTS: usize = 10_000;
/// Upper bound on the points of all the texts of one run: each placement keeps its own copy of the
/// label's outline, so a long label times [`MAX_TEXTS`] would otherwise be too much to hold.
const MAX_TEXT_POINTS: usize = 4_000_000;
/// One text element of the pattern, shaped once at the origin.
struct Element {
/// Distance from the start of a pattern repeat to the anchor (where the element ends).
at: f64,
local: Shaped,
/// Points in `local`, each placement's cost against [`MAX_TEXT_POINTS`].
points: usize,
rotation: f64,
absolute: bool,
offset: Vec2,
@@ -56,7 +50,6 @@ pub fn texts(d: &Drawing, pts: &[Vec2], lt: &Linetype, scale: f64, min_pattern:
return out;
}
let mut walk = Walk { pts, i: 0, start: 0.0 };
let mut points_left = MAX_TEXT_POINTS;
let mut base = 0.0;
'repeats: while base <= len {
for el in &elements {
@@ -64,8 +57,6 @@ pub fn texts(d: &Drawing, pts: &[Vec2], lt: &Linetype, scale: f64, min_pattern:
if s > len + 1e-9 || out.len() >= MAX_TEXTS {
break 'repeats;
}
let Some(left) = points_left.checked_sub(el.points) else { break 'repeats };
points_left = left;
let Some((p, dir)) = walk.at(s) else { break 'repeats };
let origin = p + dir * el.offset.x + dir.perp() * el.offset.y;
let rot = if el.absolute { el.rotation } else { dir.angle() + el.rotation };
@@ -102,16 +93,8 @@ fn elements(d: &Drawing, lt: &Linetype, scale: f64) -> Vec<Element> {
};
let (local, _) = cadcraft_fonts::place(&font, text, &params);
let finite = |v: f64| if v.is_finite() { v } else { 0.0 };
// Points plus one per stroke and glyph slot, and one for the label itself, so a label of empty
// glyphs still has a cost proportional to its size.
let points = local.strokes.iter().map(Vec::len).sum::<usize>()
+ local.glyphs.iter().flatten().map(Vec::len).sum::<usize>()
+ local.strokes.len()
+ local.glyphs.len()
+ 1;
out.push(Element {
at,
points,
local,
rotation: finite(el.rotation),
absolute: el.absolute,
@@ -192,20 +175,6 @@ mod tests {
assert!(texts(&d, &[Vec2::ZERO, Vec2::new(2.0, 0.0)], &plain, 1.0, 0.0).is_empty());
}
/// A long label repeated along a long run stops at the point allowance, well before MAX_TEXTS.
#[test]
fn placed_labels_stop_at_the_point_allowance() {
let d = Drawing::new_imperial();
let mut lt = gas();
lt.pattern[1].text = Some("MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMMM".into());
// 0.95 per repeat over 9,000: about 9,500 repeats, under the 50,000 the run is drawn for.
let t = texts(&d, &[Vec2::ZERO, Vec2::new(9_000.0, 0.0)], &lt, 1.0, 0.0);
let points: usize =
t.iter().map(|s| s.strokes.iter().map(Vec::len).sum::<usize>() + s.glyphs.iter().flatten().map(Vec::len).sum::<usize>()).sum();
assert!(!t.is_empty() && t.len() < MAX_TEXTS, "{}", t.len());
assert!(points <= MAX_TEXT_POINTS, "{points}");
}
#[test]
fn hostile_values_never_panic() {
let d = Drawing::new_imperial();
-11
View File
@@ -211,14 +211,3 @@ fn brick_joints_are_drawn_once_half_a_brick_apart_course_to_course() {
assert!(next.iter().all(|nx| (nx - x[0]).rem_euclid(500) == 250), "courses {course}/{}: {x:?} {next:?}", course + 1);
}
}
/// A pattern with a huge number of tiny dashes makes a swatch of a few thousand runs, stopping
/// there; and many scanlines over a many-edged boundary are charged to the hatch's work allowance.
#[test]
fn compact_patterns_stop_at_the_swatch_limits() {
let lines = vec![PatternLine { angle: 0.0, origin: (0.0, 0.0), delta: (0.0, 1.0), dashes: vec![1e-6, -1e-6] }];
let started = std::time::Instant::now();
let runs = cadcraft_render::pattern_swatch(&lines, 1.0);
assert!(!runs.is_empty() && runs.len() <= 4000, "{}", runs.len());
assert!(started.elapsed() < std::time::Duration::from_secs(20));
}
+1
View File
@@ -19,6 +19,7 @@ cadcraft-engine = { workspace = true }
cadcraft-fonts = { workspace = true }
craft-window = { workspace = true }
egui = { workspace = true }
egui_extras = { workspace = true }
# GPU canvas paint callback (same egui-wgpu/wgpu eframe uses with its "wgpu" feature).
egui-wgpu = { workspace = true }
serde = { workspace = true }
+321 -55
View File
@@ -253,12 +253,14 @@ pub fn contributors_ui(ui: &mut egui::Ui) {
.weak(),
);
ui.separator();
egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| {
if list.is_empty() {
ui.label(crate::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(crate::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.
egui::ScrollArea::horizontal().auto_shrink([false, false]).show(ui, |ui| table(ui, &list, &mut v));
} else {
egui::ScrollArea::vertical().auto_shrink([false, false]).show(ui, |ui| {
ui.horizontal_wrapped(|ui| {
for (i, c) in list.iter().enumerate() {
if i > 0 {
@@ -267,43 +269,154 @@ 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, each in proportion to its width above [`MIN_COLUMN`], and none goes below that.
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 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>, 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<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.
@@ -313,22 +426,48 @@ 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]).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(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 = ["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<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();
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));
});
}
});
}
});
});
}
@@ -408,4 +547,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::<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]);
}
#[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::<f32>() - 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<egui::Event>) -> Vec<egui::Rect> {
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<egui::Event>) -> Vec<egui::Rect> {
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<egui::Rect>) {
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<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, &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:?}");
}
}
}
+1 -1
View File
@@ -325,7 +325,7 @@ fn about(ctx: &egui::Context, open: &mut bool) {
egui::Window::new(crate::tl!("About CADCraft"))
.id(egui::Id::unique("About CADCraft"))
.open(open)
.default_size(vec2(640.0, 420.0))
.default_size(vec2(820.0, 420.0))
.collapsible(false)
.show(ctx, |ui| {
let mut tab = ui.data_mut(|d| d.get_temp::<u8>(tab_id)).unwrap_or(0);