From ef1b55dca6a26815e8828e566e07bdd1a1140c81 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Wed, 7 Oct 2026 21:16:30 -0400 Subject: [PATCH] About: contributor and model credits compiled in (grab bag + table, self-submitted names) (#3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * About: contributors compiled in from contributors.json (grab bag + table, sortable, name toggle) Replaces the TSV files and `cargo xtask contributors` with the craftrules standard (standards/contributors.md): contributors/contributors.json is generated by craftrules/scripts/contributors.py and baked into the binary by build.rs as static tables. Each contributor line: @username, opt-in display/real name, PRs, commits, lines +/−/Δ, binary assets +/−, first/last commit. Sortable by every column; alphabetical ignores case and the leading @. Co-Authored-By: Claude Opus 5.5 * Contributors docs: craftrules is two levels up (../../craftrules) Co-Authored-By: Claude Opus 5.5 * Contributors: self-submitted names in contributors/people.toml, read by build.rs Co-Authored-By: Claude Opus 5.5 * Contributors: regenerate (people_entry verified) Co-Authored-By: Claude Opus 5.5 * AGENTS.md: contributor credits section (agents offer to credit their own human only); docs Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- AGENTS.md | 18 ++ Cargo.lock | 1 + contributors/commits.tsv | 6 - contributors/contributors.json | 35 +++ contributors/models.tsv | 6 - contributors/people.toml | 23 ++ contributors/people.tsv | 3 - contributors/prs.tsv | 1 - crates/ui-egui/Cargo.toml | 4 + crates/ui-egui/build.rs | 115 ++++++++-- crates/ui-egui/src/about.rs | 94 -------- crates/ui-egui/src/credits.rs | 399 +++++++++++++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 75 +------ crates/ui-egui/src/lib.rs | 2 +- docs/contributors.md | 51 +++++ xtask/src/contributors.rs | 144 ------------ xtask/src/main.rs | 3 - 17 files changed, 635 insertions(+), 345 deletions(-) delete mode 100644 contributors/commits.tsv create mode 100644 contributors/contributors.json delete mode 100644 contributors/models.tsv create mode 100644 contributors/people.toml delete mode 100644 contributors/people.tsv delete mode 100644 contributors/prs.tsv delete mode 100644 crates/ui-egui/src/about.rs create mode 100644 crates/ui-egui/src/credits.rs create mode 100644 docs/contributors.md delete mode 100644 xtask/src/contributors.rs diff --git a/AGENTS.md b/AGENTS.md index a65a20be..a9893471 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -47,3 +47,21 @@ People trust CADCraft with their drawings; a crash loses their work. **This outr ## Roadmap `ROADMAP.md` (committed) tracks status, milestones, parity and estimates. Update it whenever a milestone task lands. `cargo xtask parity` recomputes the command-catalog parity in `docs/parity.md`. + +## Contributor credits (About window) + +- About ▸ Contributors/Models are compiled into the binary from `contributors/contributors.json` + (commit stats; generated, never hand-edit) and `contributors/people.toml` (names people chose for + themselves). See `docs/contributors.md`. +- **Agents working for a contributor:** when you prepare a PR, check whether your human's GitHub + username has a `[people.]` entry in `contributors/people.toml`. If not, ask them once + whether they want to be credited by more than their username: a real name, a display name, and/or + their public GitHub profile name (`sync_github_name = true`). If yes, add **only their own** entry + (copy the template at the top of the file, or run + `python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "…" --sync-github-name`) + and include it in their PR, committed as them. If no, change nothing: they are credited as + `@username` anyway. +- Never add, edit, guess or copy anyone else's entry or name (not from git config, commit authors or + GitHub profiles). Never hand-edit `contributors.json`. +- Maintainers refresh the stats with `python3 ../../craftrules/scripts/contributors.py .` (it also + re-verifies who wrote each `people.toml` entry; `--check` only verifies). diff --git a/Cargo.lock b/Cargo.lock index 0ac14ae5..63e4a4f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -688,6 +688,7 @@ dependencies = [ "naga", "serde", "serde_json", + "toml", ] [[package]] diff --git a/contributors/commits.tsv b/contributors/commits.tsv deleted file mode 100644 index 79e23acf..00000000 --- a/contributors/commits.tsv +++ /dev/null @@ -1,6 +0,0 @@ -sha url login date additions deletions pr models -7e4583b3406de501e48dce5e9accaade8b608e85 https://github.com/storytold/cadcraft/commit/7e4583b3406de501e48dce5e9accaade8b608e85 echelon 2026-10-07T02:15:47Z 7 0 -70f0489e0ce753fdf46d03d59ea47f974bb0b26c https://github.com/storytold/cadcraft/commit/70f0489e0ce753fdf46d03d59ea47f974bb0b26c echelon 2026-10-07T05:22:25Z 30261 5 anthropic/claude-opus/5.5 -1565bfd5c70760fcdd007874f9fa4ba5e6658549 https://github.com/storytold/cadcraft/commit/1565bfd5c70760fcdd007874f9fa4ba5e6658549 echelon 2026-10-07T05:31:44Z 2191 44 anthropic/claude-opus/5.5 -125803d25823a1eb1f14ea46dfdb617d08bc27b8 https://github.com/storytold/cadcraft/commit/125803d25823a1eb1f14ea46dfdb617d08bc27b8 echelon 2026-10-07T05:43:22Z 236 6 anthropic/claude-opus/5.5 -e68e81a89ab7752cacd32acb0c93020b739b3624 https://github.com/storytold/cadcraft/commit/e68e81a89ab7752cacd32acb0c93020b739b3624 echelon 2026-10-07T05:50:51Z 1714 5 anthropic/claude-opus/5.5 diff --git a/contributors/contributors.json b/contributors/contributors.json new file mode 100644 index 00000000..0cdea1f3 --- /dev/null +++ b/contributors/contributors.json @@ -0,0 +1,35 @@ +{ + "schema": 1, + "repo": "storytold/cadcraft", + "branch": "main", + "head": "14e143bfdd55fe9063ba7d87dbbc692e5c901063", + "head_date": "2026-10-07T18:45:23Z", + "commits": 18, + "unattributed_commits": 0, + "contributors": [ + { + "login": "echelon", + "people_entry": true, + "github_name": null, + "prs": 1, + "commits": 18, + "lines_added": 69334, + "lines_deleted": 2968, + "binary_added": 26, + "binary_deleted": 5, + "first_commit": "2026-10-06T00:15:47Z", + "last_commit": "2026-10-07T18:45:23Z" + } + ], + "models": [ + { + "id": "anthropic/claude-opus/5.5", + "company": "Anthropic", + "model": "Claude Opus", + "version": "5.5", + "commits": 17, + "lines_added": 69327, + "lines_deleted": 2968 + } + ] +} diff --git a/contributors/models.tsv b/contributors/models.tsv deleted file mode 100644 index 6813a3e3..00000000 --- a/contributors/models.tsv +++ /dev/null @@ -1,6 +0,0 @@ -# Maps Co-Authored-By trailer names to AI models. -id company model version trailer -anthropic/claude-opus/5.5 Anthropic Claude Opus 5.5 Claude Opus 5.5 -anthropic/claude-opus/5.5 Anthropic Claude Opus 5.5 Claude Opus 5.5 (1M context) -anthropic/claude-sonnet/5.5 Anthropic Claude Sonnet 5.5 Claude Sonnet 5.5 -anthropic/claude-fable/5.1 Anthropic Claude Fable 5.1 Claude Fable 5.1 diff --git a/contributors/people.toml b/contributors/people.toml new file mode 100644 index 00000000..89c6db20 --- /dev/null +++ b/contributors/people.toml @@ -0,0 +1,23 @@ +# Contributor names for the About window (craftrules standards/contributors.md). +# +# Everyone who contributes is credited by GitHub username automatically. Anything more is up to +# you: add YOUR OWN entry below, in your own PR (commit it yourself). Never add someone else; a +# maintainer may add a person only when that person asked, recording where in consent_source. +# +# Easiest: python3 ../../craftrules/scripts/contributors.py --add-me . \ +# [--real-name "Ada Lovelace"] [--display-name "Ada"] [--sync-github-name] +# +# Or copy this block, replacing your-github-username (every field is optional): +# +# [people.your-github-username] +# real_name = "Your Name" # shown when the viewer picks "Real name" +# display_name = "Nickname" # shown when the viewer picks "Display name" +# sync_github_name = true # or: use the name on your public GitHub profile as display name +# +# To remove your names, delete your entry (or set fields empty) in a PR. The next +# contributors.py run, and the next build, drop them. + +[people.echelon] +real_name = "Brandon Thomas" +display_name = "Brandon Thomas" +sync_github_name = false diff --git a/contributors/people.tsv b/contributors/people.tsv deleted file mode 100644 index 511e2711..00000000 --- a/contributors/people.tsv +++ /dev/null @@ -1,3 +0,0 @@ -# Consent and opt-in fields (craftrules standards/contributors.md). Never written by xtask. -login consent consent_date consent_source display_name real_name email -echelon yes 2026-10-06 owner Brandon Thomas Brandon Thomas diff --git a/contributors/prs.tsv b/contributors/prs.tsv deleted file mode 100644 index 08fd89b2..00000000 --- a/contributors/prs.tsv +++ /dev/null @@ -1 +0,0 @@ -number url login merged_at additions deletions commits models diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index ca4524ed..ddf9e334 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -25,3 +25,7 @@ image = { workspace = true } [dev-dependencies] # Validates the canvas WGSL and its GLSL ES 3.0 (WebGL2) translation in tests (same naga wgpu uses). naga = { version = "30", features = ["wgsl-in", "glsl-out"] } + +[build-dependencies] +serde_json = { workspace = true } +toml = "1" diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index e475b2e1..f40007fb 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -1,18 +1,105 @@ -//! Compile the contributor credits (contributors/*.tsv, craftrules standards/contributors.md) -//! into the About window. Missing or unreadable files give empty tables and a warning. +//! Bake the contributor credits into the binary as static Rust tables (craftrules +//! standards/contributors.md). Nothing is read at run time. +//! +//! - contributors/contributors.json: commit stats per GitHub username, generated by craftrules +//! scripts/contributors.py, which also marks whose people.toml entry it verified. +//! - contributors/people.toml: the names people chose for themselves. A name is used only when the +//! JSON says the entry was verified (`people_entry`). +//! +//! A missing or malformed file gives empty tables (or no names) and a warning. + +use std::fmt::Write as _; + +use serde_json::Value; fn main() { - let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contributors"); - let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()); - for f in ["commits.tsv", "prs.tsv", "models.tsv", "people.tsv"] { - let p = root.join(f); - println!("cargo::rerun-if-changed={}", p.display()); - let text = std::fs::read_to_string(&p).unwrap_or_else(|e| { - println!("cargo::warning=contributors/{f}: {e}; the About window shows an empty table"); - String::new() - }); - if let Err(e) = std::fs::write(out.join(f), text) { - println!("cargo::warning=writing {f}: {e}"); - } + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contributors"); + let json = read(&dir.join("contributors.json"), "the About window shows no contributors", |t| { + serde_json::from_str::(t).map_err(|e| e.to_string()) + }) + .unwrap_or(Value::Null); + let people = read(&dir.join("people.toml"), "contributors are shown by username only", |t| t.parse::().map_err(|e| e.to_string())) + .unwrap_or_default(); + let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("credits.rs"); + if let Err(e) = std::fs::write(&out, generate(&json, &people)) { + println!("cargo::warning=writing {}: {e}", out.display()); } } + +fn read(path: &std::path::Path, fallback: &str, parse: impl Fn(&str) -> Result) -> Option { + println!("cargo::rerun-if-changed={}", path.display()); + let r = std::fs::read_to_string(path).map_err(|e| e.to_string()).and_then(|t| parse(&t)); + if let Err(e) = &r { + println!("cargo::warning={}: {e}; {fallback}", path.display()); + } + r.ok() +} + +/// A non-empty string field of someone's people.toml entry, as a Rust `Option<&str>` literal. +fn name(v: Option<&str>) -> String { + match v.map(str::trim).filter(|s| !s.is_empty()) { + Some(x) => format!("Some({x:?})"), + None => "None".into(), + } +} + +fn s(v: &Value, k: &str) -> String { + format!("{:?}", v.get(k).and_then(Value::as_str).unwrap_or("")) +} + +fn n(v: &Value, k: &str) -> u64 { + v.get(k).and_then(Value::as_u64).unwrap_or(0) +} + +fn rows(json: &Value, k: &str) -> Vec { + json.get(k).and_then(Value::as_array).cloned().unwrap_or_default() +} + +fn generate(json: &Value, people: &toml::Table) -> String { + let mut o = String::new(); + let _ = writeln!(o, "pub static TOTAL_COMMITS: u64 = {};", n(json, "commits")); + let _ = writeln!(o, "pub static CONTRIBUTORS: &[Contributor] = &["); + for c in rows(json, "contributors") { + let login = c.get("login").and_then(Value::as_str).unwrap_or(""); + if login.is_empty() { + continue; + } + // Names only from a verified, self-chosen people.toml entry. + let entry = people.get("people").and_then(|p| p.get(login)).filter(|_| c.get("people_entry").and_then(Value::as_bool) == Some(true)); + let field = |k: &str| entry.and_then(|e| e.get(k)).and_then(toml::Value::as_str); + let synced = entry.and_then(|e| e.get("sync_github_name")).and_then(toml::Value::as_bool) == Some(true); + let display = field("display_name") + .filter(|s| !s.trim().is_empty()) + .or_else(|| if synced { c.get("github_name").and_then(Value::as_str) } else { None }); + let _ = writeln!( + o, + " Contributor {{ login: {login:?}, display_name: {}, real_name: {}, prs: {}, commits: {}, lines_added: {}, lines_deleted: {}, binary_added: {}, binary_deleted: {}, first_commit: {}, last_commit: {} }},", + name(display), + name(field("real_name")), + n(&c, "prs"), + n(&c, "commits"), + n(&c, "lines_added"), + n(&c, "lines_deleted"), + n(&c, "binary_added"), + n(&c, "binary_deleted"), + s(&c, "first_commit"), + s(&c, "last_commit"), + ); + } + let _ = writeln!(o, "];"); + let _ = writeln!(o, "pub static MODELS: &[Model] = &["); + for m in rows(json, "models") { + let _ = writeln!( + o, + " Model {{ company: {}, model: {}, version: {}, commits: {}, lines_added: {}, lines_deleted: {} }},", + s(&m, "company"), + s(&m, "model"), + s(&m, "version"), + n(&m, "commits"), + n(&m, "lines_added"), + n(&m, "lines_deleted"), + ); + } + let _ = writeln!(o, "];"); + o +} diff --git a/crates/ui-egui/src/about.rs b/crates/ui-egui/src/about.rs deleted file mode 100644 index befd7015..00000000 --- a/crates/ui-egui/src/about.rs +++ /dev/null @@ -1,94 +0,0 @@ -//! The About window's credits: contributors (GitHub logins; names only with recorded consent) -//! and AI models, from the TSVs compiled in by build.rs. - -use std::collections::BTreeMap; - -const COMMITS: &str = include_str!(concat!(env!("OUT_DIR"), "/commits.tsv")); -const PRS: &str = include_str!(concat!(env!("OUT_DIR"), "/prs.tsv")); -const MODELS: &str = include_str!(concat!(env!("OUT_DIR"), "/models.tsv")); -const PEOPLE: &str = include_str!(concat!(env!("OUT_DIR"), "/people.tsv")); - -fn rows(t: &str) -> Vec> { - t.lines().filter(|l| !l.trim().is_empty() && !l.starts_with('#')).skip(1).map(|l| l.split('\t').collect()).collect() -} - -#[derive(Clone, Debug, Default)] -pub struct Person { - pub login: String, - pub display: Option, - pub real: Option, - pub first: String, - pub last: String, - pub commits: usize, - pub prs: usize, - pub added: u64, - pub removed: u64, -} - -#[derive(Clone, Debug, Default)] -pub struct Model { - pub id: String, - pub company: String, - pub model: String, - pub version: String, - pub commits: usize, - pub added: u64, - pub removed: u64, -} - -pub fn people() -> Vec { - let mut by: BTreeMap = BTreeMap::new(); - for r in rows(COMMITS) { - let (Some(login), Some(date)) = (r.get(2), r.get(3)) else { continue }; - let p = by.entry(login.to_string()).or_insert_with(|| Person { - login: login.to_string(), - first: date.to_string(), - last: date.to_string(), - ..Default::default() - }); - p.commits += 1; - p.added += r.get(4).and_then(|v| v.parse::().ok()).unwrap_or(0); - p.removed += r.get(5).and_then(|v| v.parse::().ok()).unwrap_or(0); - if *date < p.first.as_str() { - p.first = date.to_string(); - } - if *date > p.last.as_str() { - p.last = date.to_string(); - } - } - for r in rows(PRS) { - if let Some(p) = r.get(2).and_then(|l| by.get_mut(*l)) { - p.prs += 1; - } - } - for r in rows(PEOPLE) { - if r.get(1) == Some(&"yes") - && let Some(p) = r.first().and_then(|l| by.get_mut(*l)) - { - p.display = r.get(4).filter(|s| !s.is_empty()).map(|s| s.to_string()); - p.real = r.get(5).filter(|s| !s.is_empty()).map(|s| s.to_string()); - } - } - let mut v: Vec = by.into_values().filter(|p| !p.login.ends_with("[bot]")).collect(); - v.sort_by(|a, b| a.first.cmp(&b.first)); - v -} - -pub fn models() -> (Vec, usize) { - let meta: BTreeMap = rows(MODELS) - .iter() - .filter_map(|r| Some((r.first()?.to_string(), (r.get(1)?.to_string(), r.get(2)?.to_string(), r.get(3).unwrap_or(&"").to_string())))) - .collect(); - let mut by: BTreeMap = BTreeMap::new(); - let all = rows(COMMITS); - for r in &all { - for id in r.get(7).unwrap_or(&"").split(';').filter(|s| !s.is_empty()) { - let (company, model, version) = meta.get(id).cloned().unwrap_or_default(); - let m = by.entry(id.to_string()).or_insert_with(|| Model { id: id.to_string(), company, model, version, ..Default::default() }); - m.commits += 1; - m.added += r.get(4).and_then(|v| v.parse::().ok()).unwrap_or(0); - m.removed += r.get(5).and_then(|v| v.parse::().ok()).unwrap_or(0); - } - } - (by.into_values().collect(), all.len()) -} diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs new file mode 100644 index 00000000..765dec19 --- /dev/null +++ b/crates/ui-egui/src/credits.rs @@ -0,0 +1,399 @@ +//! 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 std::cmp::Ordering; + +use egui::RichText; + +#[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", + } + } +} + +#[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"), + } + } + + /// 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. +pub fn contributors_ui(ui: &mut egui::Ui) { + 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("Show"); + for m in NameMode::ALL { + if ui.selectable_label(v.names == m, m.label()).clicked() { + v.names = m; + } + } + ui.separator(); + ui.label("Sort"); + egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| { + for k in SortKey::ALL { + if ui.selectable_label(v.key == k, k.label().0).clicked() { + v.key = k; + v.ascending = k.default_ascending(); + } + } + }); + if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text("Reverse the order").clicked() { + v.ascending = !v.ascending; + } + ui.separator(); + if ui.selectable_label(!v.table, "Grab bag").clicked() { + v.table = false; + } + if ui.selectable_label(v.table, "Table").clicked() { + v.table = true; + } + }); + 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]).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 { + ui.horizontal_wrapped(|ui| { + for (i, c) in list.iter().enumerate() { + if i > 0 { + ui.label(RichText::new("·").weak()); + } + 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(); + } + } + } + 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(); + } + }); +} + +/// About ▸ Models: AI models credited in Co-Authored-By trailers. +pub fn models_ui(ui: &mut egui::Ui) { + if MODELS.is_empty() { + ui.label("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(""), "—"); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 77522d6b..e44c3cf7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -92,8 +92,6 @@ fn dsettings(app: &mut CadApp, ctx: &egui::Context, open: &mut bool) { fn about(ctx: &egui::Context, open: &mut bool) { let t = Tokens::get(); let tab_id = egui::Id::new("about_tab"); - let sort_id = egui::Id::new("about_sort"); - let name_id = egui::Id::new("about_names"); egui::Window::new("About CADCraft").open(open).default_size(vec2(640.0, 420.0)).collapsible(false).show(ctx, |ui| { let mut tab = ui.data_mut(|d| d.get_temp::(tab_id)).unwrap_or(0); ui.horizontal(|ui| { @@ -106,77 +104,8 @@ fn about(ctx: &egui::Context, open: &mut bool) { ui.data_mut(|d| d.insert_temp(tab_id, tab)); ui.separator(); match tab { - 1 => { - let mut names = ui.data_mut(|d| d.get_temp::(name_id)).unwrap_or(0); - ui.horizontal(|ui| { - ui.label("Show:"); - for (i, l) in ["GitHub username", "Display name", "Real name"].iter().enumerate() { - if ui.selectable_label(names == i as u8, *l).clicked() { - names = i as u8; - } - } - }); - ui.data_mut(|d| d.insert_temp(name_id, names)); - let mut sort: (u8, bool) = ui.data_mut(|d| d.get_temp(sort_id)).unwrap_or((1, true)); - let mut people = crate::about::people(); - let name_of = |p: &crate::about::Person| match names { - 1 => p.display.clone().unwrap_or_else(|| format!("@{}", p.login)), - 2 => p.real.clone().unwrap_or_else(|| format!("@{}", p.login)), - _ => format!("@{}", p.login), - }; - people.sort_by(|a, b| { - let o = match sort.0 { - 0 => name_of(a).trim_start_matches('@').to_lowercase().cmp(&name_of(b).trim_start_matches('@').to_lowercase()), - 1 => a.first.cmp(&b.first), - 2 => a.last.cmp(&b.last), - 3 => a.prs.cmp(&b.prs), - 4 => a.commits.cmp(&b.commits), - 5 => a.added.cmp(&b.added), - _ => a.removed.cmp(&b.removed), - }; - if sort.1 { o } else { o.reverse() } - }); - egui::Grid::new("contributors").striped(true).num_columns(7).show(ui, |ui| { - for (i, h) in ["Name", "First", "Last", "PRs", "Commits", "Added", "Removed"].iter().enumerate() { - let arrow = if sort.0 == i as u8 { if sort.1 { " ▲" } else { " ▼" } } else { "" }; - if ui.button(RichText::new(format!("{h}{arrow}")).strong()).clicked() { - sort = if sort.0 == i as u8 { (sort.0, !sort.1) } else { (i as u8, true) }; - } - } - ui.end_row(); - for p in &people { - ui.hyperlink_to(name_of(p), format!("https://github.com/{}", p.login)); - ui.label(p.first.get(..10).unwrap_or(&p.first)); - ui.label(p.last.get(..10).unwrap_or(&p.last)); - ui.label(p.prs.to_string()); - ui.label(p.commits.to_string()); - ui.label(p.added.to_string()); - ui.label(p.removed.to_string()); - ui.end_row(); - } - }); - ui.data_mut(|d| d.insert_temp(sort_id, sort)); - } - 2 => { - let (models, total) = crate::about::models(); - let assisted: usize = models.iter().map(|m| m.commits).max().unwrap_or(0).max(1); - egui::Grid::new("models").striped(true).num_columns(7).show(ui, |ui| { - for h in ["Company", "Model", "Version", "Commits", "% of assisted", "% of all", "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(m.commits.to_string()); - ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / assisted as f64)); - ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / total.max(1) as f64)); - ui.label(format!("+{} / −{}", m.added, m.removed)); - ui.end_row(); - } - }); - } + 1 => crate::credits::contributors_ui(ui), + 2 => crate::credits::models_ui(ui), _ => { ui.heading("CADCraft"); ui.label(format!("Version {}", env!("CARGO_PKG_VERSION"))); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 5a353a7f..8efab78c 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -8,11 +8,11 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] #![forbid(unsafe_code)] -pub mod about; pub mod canvas; pub mod chrome; pub mod cmdline; pub mod control; +pub mod credits; pub mod dialogs; pub mod gpu; pub mod icons; diff --git a/docs/contributors.md b/docs/contributors.md new file mode 100644 index 00000000..acda57b2 --- /dev/null +++ b/docs/contributors.md @@ -0,0 +1,51 @@ +# Contributors in the About window + +**About ▸ Contributors** credits everyone who contributed to CADCraft, and **About ▸ Models** credits +the AI models named in `Co-Authored-By` trailers. This follows the shared craftrules standard +[`standards/contributors.md`](https://github.com/storytold/craftrules/blob/main/standards/contributors.md); +this page is the local copy of the decision. + +## Decision + +- Two files, both **compiled into the binary** by the UI crate's `build.rs` (nothing is read from + disk or the network at run time; the web build has them too): + - [`contributors/contributors.json`](../contributors/contributors.json): commit stats per GitHub + username. **Generated** by `craftrules/scripts/contributors.py`; never hand-edit. + - [`contributors/people.toml`](../contributors/people.toml): the names people chose for + themselves. **Each contributor adds only their own entry**, in their own PR. +- Each contributor gets one line: GitHub username (always), display name (opt-in, or their synced + GitHub profile name), real name (only if they told us), merged PRs, commits, lines added, lines + deleted, binary assets added, binary assets removed, first and last commit date. +- The list is shown as a **grab bag** (names flowing as text) or a **table**, in the same order. It + sorts by name, PRs, commits, lines added, lines deleted, line delta, binary assets added/removed, + first or last commit date. The name toggle cycles **Username → Display name → Real name**; a + missing name falls back to `@username`. Alphabetical sorting is case-insensitive and ignores the `@`. + +## Credit yourself + +Add your entry to `contributors/people.toml` in a PR you commit yourself (every field optional): + +```toml +[people.your-github-username] +real_name = "Your Name" +display_name = "Nickname" +sync_github_name = true # use the name on your public GitHub profile as display name +``` + +or, with craftrules checked out next to `craft-apps/`: + +```sh +python3 ../../craftrules/scripts/contributors.py --add-me . --real-name "Your Name" --sync-github-name +``` + +Names show after the next refresh: the script checks that each entry was committed by its own +user (or by a maintainer with a `consent_source`). Delete your entry to remove your names. + +## Refreshing (maintainers) + +```sh +python3 ../../craftrules/scripts/contributors.py . # regenerate + verify; needs git, Python 3.11+, gh +python3 ../../craftrules/scripts/contributors.py --check . # verify people.toml only +``` + +Commit the updated `contributors/contributors.json`. Git author names and emails are never recorded. diff --git a/xtask/src/contributors.rs b/xtask/src/contributors.rs deleted file mode 100644 index adf9ddc7..00000000 --- a/xtask/src/contributors.rs +++ /dev/null @@ -1,144 +0,0 @@ -//! `cargo xtask contributors`: refresh contributors/commits.tsv and prs.tsv from the local git -//! history and GitHub (via `gh`), per craftrules standards/contributors.md. Never writes -//! people.tsv or models.tsv. Git author names and emails are never recorded. - -use std::collections::HashMap; -use std::path::Path; -use std::process::Command; - -const REPO: &str = "storytold/cadcraft"; - -fn run(root: &Path, cmd: &str, args: &[&str]) -> Result { - let out = Command::new(cmd).current_dir(root).args(args).output().map_err(|e| format!("{cmd}: {e}"))?; - if !out.status.success() { - return Err(format!("{cmd} {}: {}", args.join(" "), String::from_utf8_lossy(&out.stderr))); - } - Ok(String::from_utf8_lossy(&out.stdout).to_string()) -} - -fn trailer_names(models: &str) -> HashMap { - models - .lines() - .filter(|l| !l.starts_with('#') && !l.starts_with("id\t")) - .filter_map(|l| { - let c: Vec<&str> = l.split('\t').collect(); - Some((c.get(4)?.to_string(), c.first()?.to_string())) - }) - .collect() -} - -pub fn run_cmd(root: &Path) -> Result<(), String> { - let models = std::fs::read_to_string(root.join("contributors/models.tsv")).unwrap_or_default(); - let map = trailer_names(&models); - // Commit → GitHub login (paginated; the only network step). - let mut logins: HashMap = HashMap::new(); - for page in 1..=200 { - let path = format!("repos/{REPO}/commits?per_page=100&page={page}"); - let Ok(out) = run(root, "gh", &["api", &path, "--jq", ".[] | [.sha, (.author.login // \"unknown\")] | @tsv"]) else { break }; - if out.trim().is_empty() { - break; - } - for l in out.lines() { - if let Some((sha, login)) = l.split_once('\t') { - logins.insert(sha.to_string(), login.to_string()); - } - } - } - // PR numbers by merge commit. - let mut prs_rows = Vec::new(); - let mut pr_of: HashMap = HashMap::new(); - if let Ok(out) = run( - root, - "gh", - &[ - "pr", - "list", - "--repo", - REPO, - "--state", - "merged", - "--limit", - "1000", - "--json", - "number,url,author,mergedAt,additions,deletions,commits,mergeCommit", - "--jq", - ".[] | [.number, .url, .author.login, .mergedAt, .additions, .deletions, (.commits|length), .mergeCommit.oid, ([.commits[].oid]|join(\",\"))] | @tsv", - ], - ) { - for l in out.lines() { - let c: Vec<&str> = l.split('\t').collect(); - if c.len() < 9 { - continue; - } - for sha in c[8].split(',') { - pr_of.insert(sha.to_string(), c[0].to_string()); - } - prs_rows.push(c[..7].join("\t")); - } - } - // Local history with numstat and trailers. - let out = Command::new("git") - .current_dir(root) - .env("TZ", "UTC") - .args([ - "log", - "--no-merges", - "--date=format-local:%Y-%m-%dT%H:%M:%SZ", - "--format=@@%H\t%cd\t%(trailers:key=Co-Authored-By,valueonly,separator=;)", - "--numstat", - ]) - .output() - .map_err(|e| format!("git: {e}"))?; - let log = String::from_utf8_lossy(&out.stdout).to_string(); - let mut rows: Vec<(String, String)> = Vec::new(); - let mut cur: Option<(String, String, String, u64, u64)> = None; - let flush = |cur: &mut Option<(String, String, String, u64, u64)>, rows: &mut Vec<(String, String)>| { - if let Some((sha, date, trailers, add, del)) = cur.take() { - let login = logins.get(&sha).cloned().unwrap_or_else(|| "unknown".into()); - let mut ms: Vec = trailers - .split(';') - .filter_map(|t| { - let name = t.trim().split('<').next()?.trim().to_string(); - map.get(&name).cloned() - }) - .collect(); - ms.sort(); - ms.dedup(); - let pr = pr_of.get(&sha).cloned().unwrap_or_default(); - rows.push(( - date.clone(), - format!("{sha}\thttps://github.com/{REPO}/commit/{sha}\t{login}\t{date}\t{add}\t{del}\t{pr}\t{}", ms.join(";")), - )); - } - }; - for l in log.lines() { - if let Some(rest) = l.strip_prefix("@@") { - flush(&mut cur, &mut rows); - let c: Vec<&str> = rest.splitn(3, '\t').collect(); - cur = Some((c.first().unwrap_or(&"").to_string(), c.get(1).unwrap_or(&"").to_string(), c.get(2).unwrap_or(&"").to_string(), 0, 0)); - } else if let Some(c) = cur.as_mut() { - let p: Vec<&str> = l.split('\t').collect(); - if p.len() == 3 { - c.3 += p[0].parse::().unwrap_or(0); - c.4 += p[1].parse::().unwrap_or(0); - } - } - } - flush(&mut cur, &mut rows); - rows.sort(); - let mut commits = String::from("sha\turl\tlogin\tdate\tadditions\tdeletions\tpr\tmodels\n"); - for (_, r) in &rows { - commits.push_str(r); - commits.push('\n'); - } - std::fs::write(root.join("contributors/commits.tsv"), commits).map_err(|e| e.to_string())?; - let mut prs = String::from("number\turl\tlogin\tmerged_at\tadditions\tdeletions\tcommits\tmodels\n"); - prs_rows.sort(); - for r in prs_rows { - prs.push_str(&r); - prs.push_str("\t\n"); - } - std::fs::write(root.join("contributors/prs.tsv"), prs).map_err(|e| e.to_string())?; - println!("contributors: {} commits", rows.len()); - Ok(()) -} diff --git a/xtask/src/main.rs b/xtask/src/main.rs index dd9713df..92a3108e 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -4,7 +4,6 @@ //! invoked through `std::process::Command`. mod assets; -mod contributors; mod ico; mod layers; mod parity; @@ -26,7 +25,6 @@ commands: show where test corpora live; --download fetches PngSuite into corpus/pngsuite stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) parity recompute the AutoCAD feature-parity summary in docs/parity.md - contributors refresh contributors/commits.tsv and prs.tsv (git + gh) ico ... pack square PNGs (<= 256 px) into a Windows .ico (see packaging/icons.sh) "; @@ -44,7 +42,6 @@ fn main() -> ExitCode { Some("parity") => parity::run(&root()), Some("ico") => ico::run(&rest), Some("version") => version::run(&root(), &rest), - Some("contributors") => contributors::run_cmd(&root()), Some("-h" | "--help" | "help") | None => { print!("{USAGE}"); Ok(())