//! The About window's credits: every contributor (GitHub login always; display and real names only //! with recorded consent) and the AI models that helped. The tables are baked into the binary by //! build.rs from contributors/contributors.json (craftrules standards/contributors.md); nothing is //! read at run time. use crate::i18n::{tr, tr_args}; use std::cmp::Ordering; use egui::RichText; use crate::automation::Registry; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Contributor { pub login: &'static str, pub display_name: Option<&'static str>, pub real_name: Option<&'static str>, pub prs: u64, pub commits: u64, pub lines_added: u64, pub lines_deleted: u64, pub binary_added: u64, pub binary_deleted: u64, /// ISO 8601 UTC; empty for someone credited only through a merged PR. pub first_commit: &'static str, pub last_commit: &'static str, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct Model { pub company: &'static str, pub model: &'static str, pub version: &'static str, pub commits: u64, pub lines_added: u64, pub lines_deleted: u64, } include!(concat!(env!("OUT_DIR"), "/credits.rs")); /// Which name to show. Display and real names fall back to the `@username` when not given. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum NameMode { #[default] Username, DisplayName, RealName, } impl NameMode { pub const ALL: [NameMode; 3] = [NameMode::Username, NameMode::DisplayName, NameMode::RealName]; pub fn label(self) -> &'static str { match self { NameMode::Username => "Username", NameMode::DisplayName => "Display name", NameMode::RealName => "Real name", } } /// Automation id suffix. pub fn id(self) -> &'static str { match self { NameMode::Username => "username", NameMode::DisplayName => "displayName", NameMode::RealName => "realName", } } } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub enum SortKey { Name, Prs, Commits, LinesAdded, LinesDeleted, LinesDelta, BinaryAdded, BinaryDeleted, #[default] FirstCommit, LastCommit, } impl SortKey { pub const ALL: [SortKey; 10] = [ SortKey::Name, SortKey::Prs, SortKey::Commits, SortKey::LinesAdded, SortKey::LinesDeleted, SortKey::LinesDelta, SortKey::BinaryAdded, SortKey::BinaryDeleted, SortKey::FirstCommit, SortKey::LastCommit, ]; /// Menu label and table header. pub fn label(self) -> (&'static str, &'static str) { match self { SortKey::Name => ("Name (A–Z)", "Name"), SortKey::Prs => ("Merged PRs", "PRs"), SortKey::Commits => ("Commits", "Commits"), SortKey::LinesAdded => ("Lines added", "+LOC"), SortKey::LinesDeleted => ("Lines deleted", "−LOC"), SortKey::LinesDelta => ("Line delta", "ΔLOC"), SortKey::BinaryAdded => ("Binary assets added", "+Bin"), SortKey::BinaryDeleted => ("Binary assets removed", "−Bin"), SortKey::FirstCommit => ("First commit", "First"), SortKey::LastCommit => ("Last commit", "Last"), } } /// Automation id suffix. pub fn id(self) -> &'static str { match self { SortKey::Name => "name", SortKey::Prs => "prs", SortKey::Commits => "commits", SortKey::LinesAdded => "linesAdded", SortKey::LinesDeleted => "linesDeleted", SortKey::LinesDelta => "linesDelta", SortKey::BinaryAdded => "binaryAdded", SortKey::BinaryDeleted => "binaryDeleted", SortKey::FirstCommit => "firstCommit", SortKey::LastCommit => "lastCommit", } } /// Names and dates read naturally oldest/A first; counts read biggest first. pub fn default_ascending(self) -> bool { matches!(self, SortKey::Name | SortKey::FirstCommit | SortKey::LastCommit) } } impl Contributor { pub fn name(&self, mode: NameMode) -> String { let chosen = match mode { NameMode::Username => None, NameMode::DisplayName => self.display_name, NameMode::RealName => self.real_name, }; chosen.map_or_else(|| format!("@{}", self.login), str::to_string) } pub fn lines_delta(&self) -> i128 { i128::from(self.lines_added) - i128::from(self.lines_deleted) } /// One line with everything we know, for tooltips. pub fn summary(&self) -> String { format!( "@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}", self.login, self.prs, self.commits, group(self.lines_added), group(self.lines_deleted), signed(self.lines_delta()), self.binary_added, self.binary_deleted, day(self.first_commit), day(self.last_commit), ) } } /// Alphabetical key: case-insensitive, ignoring the `@` of usernames. fn name_key(name: &str) -> String { name.trim_start_matches('@').to_lowercase() } fn compare(a: &Contributor, b: &Contributor, mode: NameMode, key: SortKey) -> Ordering { match key { SortKey::Name => name_key(&a.name(mode)).cmp(&name_key(&b.name(mode))), SortKey::Prs => a.prs.cmp(&b.prs), SortKey::Commits => a.commits.cmp(&b.commits), SortKey::LinesAdded => a.lines_added.cmp(&b.lines_added), SortKey::LinesDeleted => a.lines_deleted.cmp(&b.lines_deleted), SortKey::LinesDelta => a.lines_delta().cmp(&b.lines_delta()), SortKey::BinaryAdded => a.binary_added.cmp(&b.binary_added), SortKey::BinaryDeleted => a.binary_deleted.cmp(&b.binary_deleted), // A PR-only contributor (no commit date) sorts after everyone with a date. SortKey::FirstCommit => dated(a.first_commit).cmp(&dated(b.first_commit)), SortKey::LastCommit => dated(a.last_commit).cmp(&dated(b.last_commit)), } } fn dated(d: &str) -> (bool, &str) { (d.is_empty(), d) } /// Contributors in display order. Ties fall back to the name (A–Z) so the order is stable. pub fn sorted(list: &[Contributor], mode: NameMode, key: SortKey, ascending: bool) -> Vec<&Contributor> { let mut v: Vec<&Contributor> = list.iter().collect(); v.sort_by(|a, b| { let o = compare(a, b, mode, key); let o = if ascending { o } else { o.reverse() }; o.then_with(|| compare(a, b, mode, SortKey::Name)).then_with(|| a.login.cmp(b.login)) }); v } fn group(n: u64) -> String { let s = n.to_string(); let mut out = String::with_capacity(s.len() + s.len() / 3); for (i, ch) in s.chars().enumerate() { if i > 0 && (s.len() - i).is_multiple_of(3) { out.push(','); } out.push(ch); } out } fn signed(n: i128) -> String { let g = group(u64::try_from(n.unsigned_abs()).unwrap_or(u64::MAX)); if n < 0 { format!("−{g}") } else { format!("+{g}") } } fn day(iso: &str) -> &str { if iso.is_empty() { "—" } else { iso.get(..10).unwrap_or(iso) } } #[derive(Clone, Copy, Debug, PartialEq, Eq)] struct View { names: NameMode, key: SortKey, ascending: bool, table: bool, } impl Default for View { fn default() -> Self { View { names: NameMode::Username, key: SortKey::FirstCommit, ascending: true, table: false } } } /// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table. Every /// control registers an automation id under `about.credits.`. pub fn contributors_ui(ui: &mut egui::Ui, auto: &mut Registry) { let id = egui::Id::new("credits_view"); let mut v = ui.data_mut(|d| d.get_temp::(id)).unwrap_or_default(); ui.horizontal_wrapped(|ui| { ui.label(tr("Show")); for m in NameMode::ALL { let r = ui.selectable_label(v.names == m, m.label()); auto.add(&format!("about.credits.show.{}", m.id()), r.rect, m.label()); if r.clicked() { v.names = m; } } ui.separator(); ui.label(tr("Sort")); let combo = egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| { for k in SortKey::ALL { let r = ui.selectable_label(v.key == k, k.label().0); auto.add(&format!("about.credits.sort.{}", k.id()), r.rect, k.label().0); if r.clicked() { v.key = k; v.ascending = k.default_ascending(); } } }); auto.add("about.credits.sort", combo.response.rect, "Sort"); let r = ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text(tr("Reverse the order")); auto.add("about.credits.reverse", r.rect, "Reverse the order"); if r.clicked() { v.ascending = !v.ascending; } ui.separator(); let r = ui.selectable_label(!v.table, tr("Grab bag")); auto.add("about.credits.view.grabBag", r.rect, "Grab bag"); if r.clicked() { v.table = false; } let r = ui.selectable_label(v.table, tr("Table")); auto.add("about.credits.view.table", r.rect, "Table"); if r.clicked() { v.table = true; } }); let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending); ui.label(RichText::new(tr_args("{} contributors · {} commits", &[&(list.len()), &(group(TOTAL_COMMITS))])).small().weak()); ui.separator(); egui::ScrollArea::both().auto_shrink([false, false]).show(ui, |ui| { if list.is_empty() { ui.label(tr("No contributor data was built into this copy.")); } else if v.table { table(ui, &list, &mut v, auto); } else { ui.horizontal_wrapped(|ui| { for (i, c) in list.iter().enumerate() { if i > 0 { ui.label(RichText::new("·").weak()); } let r = ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary()); auto.add(&format!("about.credits.contributor.{}", c.login), r.rect, &c.name(v.names)); } }); } }); ui.data_mut(|d| d.insert_temp(id, v)); } fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View, auto: &mut Registry) { 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 { "" }; let r = ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0); auto.add(&format!("about.credits.header.{}", k.id()), r.rect, k.label().0); if r.clicked() { if v.key == k { v.ascending = !v.ascending; } else { v.key = k; v.ascending = k.default_ascending(); } } } ui.end_row(); for c in list { let r = ui.hyperlink_to(c.name(v.names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary()); auto.add(&format!("about.credits.contributor.{}", c.login), r.rect, &c.name(v.names)); 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(); } }); } /// About ▸ Models: AI models credited in Co-Authored-By trailers. pub fn models_ui(ui: &mut egui::Ui) { if MODELS.is_empty() { ui.label(tr("No model credits were built into this copy.")); 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(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(); } }); }); } #[cfg(test)] mod tests { use super::*; fn c(login: &'static str, display: Option<&'static str>, real: Option<&'static str>, added: u64, deleted: u64, first: &'static str) -> Contributor { Contributor { login, display_name: display, real_name: real, prs: 0, commits: 1, lines_added: added, lines_deleted: deleted, binary_added: 0, binary_deleted: 0, first_commit: first, last_commit: first, } } fn people() -> [Contributor; 4] { [ c("zed", None, None, 5, 50, "2026-01-02T00:00:00Z"), c("Bob", Some("Ann"), None, 10, 0, "2026-01-01T00:00:00Z"), c("alice", None, None, 7, 1, ""), c("carol", None, Some("Dana"), 1, 0, "2026-01-03T00:00:00Z"), ] } fn logins(v: &[&Contributor]) -> Vec<&'static str> { v.iter().map(|c| c.login).collect() } #[test] fn names_fall_back_to_the_username() { let p = c("bob", Some("Bobby"), None, 0, 0, ""); assert_eq!(p.name(NameMode::Username), "@bob"); assert_eq!(p.name(NameMode::DisplayName), "Bobby"); assert_eq!(p.name(NameMode::RealName), "@bob"); } #[test] fn alphabetical_ignores_at_and_case() { let all = people(); let v = sorted(&all, NameMode::Username, SortKey::Name, true); assert_eq!(logins(&v), ["alice", "Bob", "carol", "zed"]); let v = sorted(&all, NameMode::DisplayName, SortKey::Name, true); assert_eq!(logins(&v), ["alice", "Bob", "carol", "zed"]); // "Ann" sorts with the @usernames let v = sorted(&all, NameMode::RealName, SortKey::Name, false); assert_eq!(logins(&v), ["zed", "carol", "Bob", "alice"]); // "Dana" between bob and zed } #[test] fn delta_and_dates() { let all = people(); let v = sorted(&all, NameMode::Username, SortKey::LinesDelta, false); assert_eq!(logins(&v), ["Bob", "alice", "carol", "zed"]); let v = sorted(&all, NameMode::Username, SortKey::FirstCommit, true); assert_eq!(logins(&v), ["Bob", "zed", "carol", "alice"]); // undated last } #[test] fn formatting() { assert_eq!(group(1234567), "1,234,567"); assert_eq!(group(12), "12"); assert_eq!(signed(-1000), "−1,000"); assert_eq!(day("2026-10-07T18:45:23Z"), "2026-10-07"); assert_eq!(day(""), "—"); } }