About: contributor and model credits compiled in (grab bag + table, self-submitted names) (#252)

* About: contributors compiled in from contributors.json (Contributors + Models tabs, grab bag + table, sortable, name toggle)

Adds the craftrules standard (standards/contributors.md) to LightCraft: contributors/contributors.json
is generated by craftrules/scripts/contributors.py and baked into the binary by
crates/ui-egui/build.rs (merged next to the tr_format! generation) as static tables. The About
dialog gets tabs About · Contributors · Models. 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 @. Tabs and the list view are
control-channel widgets (button:aboutTab-*, button:creditsTable / button:creditsGrabBag).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Contributors docs: craftrules is two levels up (../../craftrules)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Contributors: self-submitted names in contributors/people.toml; AGENTS.md credit-yourself section

build.rs reads contributors/people.toml next to the generated contributors.json and shows a name only
for entries the generator verified as written by that person (craftrules standards/contributors.md).

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Contributors: regenerate (verified people.toml entries)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Contributors: regenerate after rebase onto main

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Contributors: regenerate (verified people.toml entries)

Co-Authored-By: Claude Opus 5.5 <[email protected]>

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Brandon ThomasandClaude Opus 5.5 authored and GitHub committed 2026-10-07 21:22:08 -04:00
1 parent 2694d7089f
commit 4159ceb1b0
11 files changed
+1175 -21

No files matched your search

+18
View File
@@ -123,3 +123,21 @@ photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`,
`nix/package.nix` = the Nix package (`nix build` builds both binaries with the craft-fonts input, installs the
desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained and not
in CI: it may lag behind the workspace; see README → Quick start.
## 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.<username>]` 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).
Generated
+1
View File
@@ -3088,6 +3088,7 @@ dependencies = [
"log",
"serde",
"serde_json",
"toml",
"web-time",
]
+466
View File
@@ -0,0 +1,466 @@
{
"schema": 1,
"repo": "storytold/lightcraft",
"branch": "main",
"head": "2694d7089fbf4ada62c6738a7c62fecdfd5711fb",
"head_date": "2026-10-08T00:53:19Z",
"commits": 424,
"unattributed_commits": 0,
"contributors": [
{
"login": "echelon",
"people_entry": true,
"github_name": null,
"prs": 71,
"commits": 330,
"lines_added": 125623,
"lines_deleted": 8643,
"binary_added": 38,
"binary_deleted": 8,
"first_commit": "2026-09-30T14:04:12Z",
"last_commit": "2026-10-08T00:32:34Z"
},
{
"login": "leipsfur",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 131,
"lines_deleted": 5,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-02T15:51:58Z",
"last_commit": "2026-10-02T15:51:58Z"
},
{
"login": "GlemNL",
"people_entry": false,
"github_name": null,
"prs": 9,
"commits": 13,
"lines_added": 1233,
"lines_deleted": 96,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-02T21:16:29Z",
"last_commit": "2026-10-04T09:42:39Z"
},
{
"login": "storybored379",
"people_entry": false,
"github_name": null,
"prs": 7,
"commits": 7,
"lines_added": 1194,
"lines_deleted": 82,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-04T00:28:49Z",
"last_commit": "2026-10-06T06:51:43Z"
},
{
"login": "midasdf",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 424,
"lines_deleted": 25,
"binary_added": 1,
"binary_deleted": 0,
"first_commit": "2026-10-05T16:07:26Z",
"last_commit": "2026-10-05T16:07:26Z"
},
{
"login": "voshart",
"people_entry": false,
"github_name": null,
"prs": 3,
"commits": 12,
"lines_added": 2481,
"lines_deleted": 1389,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-05T16:23:27Z",
"last_commit": "2026-10-07T16:07:24Z"
},
{
"login": "arata-1972",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 4,
"lines_added": 2892,
"lines_deleted": 726,
"binary_added": 2,
"binary_deleted": 0,
"first_commit": "2026-10-05T20:14:35Z",
"last_commit": "2026-10-05T22:16:15Z"
},
{
"login": "benlukka",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 49,
"lines_deleted": 4,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-06T11:18:03Z",
"last_commit": "2026-10-06T11:18:03Z"
},
{
"login": "kywk",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 72,
"lines_deleted": 8,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-06T20:21:14Z",
"last_commit": "2026-10-06T20:21:14Z"
},
{
"login": "treyreynolds",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 159,
"lines_deleted": 27,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-06T22:21:46Z",
"last_commit": "2026-10-06T22:21:46Z"
},
{
"login": "kovalskiguinther",
"people_entry": false,
"github_name": null,
"prs": 0,
"commits": 1,
"lines_added": 4328,
"lines_deleted": 17,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-06T23:15:04Z",
"last_commit": "2026-10-06T23:15:04Z"
},
{
"login": "cruzer45",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 3,
"lines_added": 658,
"lines_deleted": 26,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T01:30:23Z",
"last_commit": "2026-10-07T03:53:09Z"
},
{
"login": "coygeek",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 4,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T02:27:15Z",
"last_commit": "2026-10-07T02:27:15Z"
},
{
"login": "lzjtugjc",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 1895,
"lines_deleted": 294,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T03:34:33Z",
"last_commit": "2026-10-07T03:34:33Z"
},
{
"login": "itsmunzir",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 46,
"lines_deleted": 30,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T07:40:55Z",
"last_commit": "2026-10-07T07:40:55Z"
},
{
"login": "haraldh",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 5,
"lines_added": 1163,
"lines_deleted": 141,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T07:54:27Z",
"last_commit": "2026-10-07T13:16:08Z"
},
{
"login": "sakamoto824",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 2,
"lines_added": 3649,
"lines_deleted": 167,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T09:32:12Z",
"last_commit": "2026-10-07T10:54:19Z"
},
{
"login": "anarion80",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 402,
"lines_deleted": 2,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T11:54:51Z",
"last_commit": "2026-10-07T11:54:51Z"
},
{
"login": "radialmonster",
"people_entry": false,
"github_name": null,
"prs": 3,
"commits": 19,
"lines_added": 2630,
"lines_deleted": 259,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T12:33:51Z",
"last_commit": "2026-10-08T00:46:10Z"
},
{
"login": "bogusyogi",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 434,
"lines_deleted": 29,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T17:06:00Z",
"last_commit": "2026-10-07T17:06:00Z"
},
{
"login": "nledford",
"people_entry": false,
"github_name": null,
"prs": 4,
"commits": 8,
"lines_added": 525,
"lines_deleted": 60,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T17:33:38Z",
"last_commit": "2026-10-07T22:49:00Z"
},
{
"login": "MusseJusse",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 34,
"lines_deleted": 3,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T18:38:23Z",
"last_commit": "2026-10-07T18:38:23Z"
},
{
"login": "Ndsanes",
"people_entry": false,
"github_name": null,
"prs": 2,
"commits": 2,
"lines_added": 332,
"lines_deleted": 86,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T19:08:28Z",
"last_commit": "2026-10-07T19:08:33Z"
},
{
"login": "RicSchonfelder",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 1439,
"lines_deleted": 6,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T20:03:20Z",
"last_commit": "2026-10-07T20:03:20Z"
},
{
"login": "Gremiger",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 19,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T21:43:23Z",
"last_commit": "2026-10-07T21:43:23Z"
},
{
"login": "DWAA1660",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 54,
"lines_deleted": 12,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T22:08:01Z",
"last_commit": "2026-10-07T22:08:01Z"
},
{
"login": "agorevski",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 68,
"lines_deleted": 11,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-07T22:18:44Z",
"last_commit": "2026-10-07T22:18:44Z"
},
{
"login": "andrewyao",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 1,
"lines_added": 1,
"lines_deleted": 1,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": "2026-10-08T00:53:19Z",
"last_commit": "2026-10-08T00:53:19Z"
},
{
"login": "GuintherKovalski",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 0,
"lines_added": 0,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": null,
"last_commit": null
},
{
"login": "XRTce",
"people_entry": false,
"github_name": null,
"prs": 1,
"commits": 0,
"lines_added": 0,
"lines_deleted": 0,
"binary_added": 0,
"binary_deleted": 0,
"first_commit": null,
"last_commit": null
}
],
"models": [
{
"id": "anthropic/claude-opus/5.5",
"company": "Anthropic",
"model": "Claude Opus",
"version": "5.5",
"commits": 364,
"lines_added": 131810,
"lines_deleted": 9367
},
{
"id": "anthropic/claude-sonnet/5.5",
"company": "Anthropic",
"model": "Claude Sonnet",
"version": "5.5",
"commits": 31,
"lines_added": 3941,
"lines_deleted": 1543
},
{
"id": "openai/codex",
"company": "OpenAI",
"model": "Codex",
"version": "",
"commits": 14,
"lines_added": 8341,
"lines_deleted": 995
},
{
"id": "anthropic/claude",
"company": "Anthropic",
"model": "Claude",
"version": "",
"commits": 2,
"lines_added": 1304,
"lines_deleted": 192
},
{
"id": "anthropic/claude-sonnet/5",
"company": "Anthropic",
"model": "Claude Sonnet",
"version": "5",
"commits": 2,
"lines_added": 298,
"lines_deleted": 2
},
{
"id": "anthropic/claude-opus/4.8",
"company": "Anthropic",
"model": "Claude Opus",
"version": "4.8",
"commits": 1,
"lines_added": 1633,
"lines_deleted": 3
},
{
"id": "deepseek/deepseek/v3.2",
"company": "DeepSeek",
"model": "DeepSeek",
"version": "V3.2",
"commits": 1,
"lines_added": 1895,
"lines_deleted": 294
}
]
}
+23
View File
@@ -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
+1
View File
@@ -29,3 +29,4 @@ lightcraft-codecs = { workspace = true }
[build-dependencies]
serde_json = { workspace = true }
toml = "1"
+108
View File
@@ -10,6 +10,12 @@
//! Every catalog must carry the same messages: a `tr_format!` call whose message is in no catalog
//! does not compile, so a message missing from one language would otherwise go unnoticed until it
//! shows English.
//!
//! It also bakes the contributor credits into the binary as static Rust tables (`$OUT_DIR/credits.rs`,
//! included by `src/credits.rs`; craftrules `standards/contributors.md`): commit stats from
//! `contributors/contributors.json` (generated by craftrules `scripts/contributors.py`) and the names
//! people chose for themselves from `contributors/people.toml`. Nothing is read at run time. A
//! missing or malformed file gives empty tables (or usernames only) and a warning, never a failed build.
use std::{
collections::{BTreeMap, BTreeSet},
env, fs,
@@ -22,6 +28,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
println!("cargo:rerun-if-changed=locales");
let out = PathBuf::from(env::var("OUT_DIR")?);
fs::write(out.join("tr-formats.rs"), generate()?)?;
let (json, people) = credits::load();
fs::write(out.join("credits.rs"), credits::generate(&json, &people))?;
Ok(())
}
@@ -98,3 +106,103 @@ fn generate() -> Result<String, Box<dyn std::error::Error>> {
));
Ok(source)
}
/// The About window's credits tables.
mod credits {
use std::fmt::Write as _;
use serde_json::Value;
/// `contributors/contributors.json` and `contributors/people.toml`; `Null`/empty (with a build
/// warning) when missing or malformed. Names come only from people.toml entries the JSON marks
/// verified (`people_entry`).
pub fn load() -> (Value, toml::Table) {
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::<Value>(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::<toml::Table>().map_err(|e| e.to_string()))
.unwrap_or_default();
(json, people)
}
fn read<T>(path: &std::path::Path, fallback: &str, parse: impl Fn(&str) -> Result<T, String>) -> Option<T> {
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<Value> {
json.get(k).and_then(Value::as_array).cloned().unwrap_or_default()
}
pub 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
}
}
+429
View File
@@ -0,0 +1,429 @@
//! 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;
use crate::{LightcraftApp, theme::Tokens};
#[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 }
}
}
/// The scrolling list's height: the About dialog is a fixed-size, centred window.
const LIST_HEIGHT: f32 = 360.0;
fn profile_url(c: &Contributor) -> String {
format!("https://github.com/{}", c.login)
}
/// A contributor's name as a link to their GitHub profile, with the whole line as its tooltip.
fn name_link(app: &mut LightcraftApp, ui: &mut egui::Ui, c: &Contributor, names: NameMode) {
if ui.link(c.name(names)).on_hover_text(c.summary()).clicked() {
let _ = crate::links::open(app, &profile_url(c));
}
}
/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table.
pub fn contributors_ui(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let t = Tokens::get(ui.ctx());
let id = egui::Id::new("credits_view");
let mut v = ui.data_mut(|d| d.get_temp::<View>(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();
let r = ui.selectable_label(!v.table, "Grab bag");
crate::widgets::register(ui.ctx(), "button:creditsGrabBag", r.rect);
if r.clicked() {
v.table = false;
}
let r = ui.selectable_label(v.table, "Table");
crate::widgets::register(ui.ctx(), "button:creditsTable", r.rect);
if r.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().color(t.text_dim));
ui.separator();
egui::ScrollArea::both().id_salt("credits_scroll").max_height(LIST_HEIGHT).auto_shrink([false, true]).show(ui, |ui| {
if list.is_empty() {
ui.label("No contributor data was built into this copy.");
} else if v.table {
table(app, ui, &list, &mut v);
} else {
ui.horizontal_wrapped(|ui| {
for (i, c) in list.iter().enumerate() {
if i > 0 {
ui.label(RichText::new("·").color(t.text_dim));
}
name_link(app, ui, c, v.names);
}
});
}
});
ui.data_mut(|d| d.insert_temp(id, v));
}
fn table(app: &mut LightcraftApp, 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 {
name_link(app, ui, c, 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("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().id_salt("credits_models_scroll").max_height(LIST_HEIGHT).auto_shrink([false, true]).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 the_committed_json_is_baked_in() {
// contributors/contributors.json is committed, so a build always has someone to credit
assert!(!CONTRIBUTORS.is_empty(), "build.rs produced no contributors");
assert!(TOTAL_COMMITS > 0);
assert!(CONTRIBUTORS.iter().all(|c| !c.login.is_empty() && !c.login.starts_with('@')));
assert!(MODELS.iter().all(|m| !m.model.is_empty()));
}
#[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(""), "—");
}
}
+27
View File
@@ -1637,6 +1637,33 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
/// Help ▸ About: the About, Contributors and Models tabs switch and paint (credits are
/// compiled in).
#[test]
fn about_dialog_tabs_show_the_credits() {
// an empty library: the dialog needs no photos, and no decodes compete with other tests
let services = crate::Services { png: None, ..Default::default() };
let mut h = Headless::new(LightcraftApp::new(lightcraft_engine::Session::new(), services), [1300.0, 820.0], 1.0);
let t = Duration::from_secs(10);
let r = h.request("ui.menu.invoke", json!({"id": "app.about"}), t);
assert_eq!(r["ok"], true, "{r}");
assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::About));
// a new window sizes itself on its first frame: let it settle before clicking its tabs
h.step();
h.step();
for (i, (tab, _)) in crate::panels::dialogs::ABOUT_TABS.iter().enumerate().rev() {
let r = h.request("ui.clickWidget", json!({"id": format!("button:aboutTab-{tab}")}), t);
assert_eq!(r["ok"], true, "{tab}: {r}");
h.step();
let shown = h.view.ctx.data_mut(|d| d.get_temp::<u8>(egui::Id::new("about_tab")));
assert_eq!(shown.map(usize::from), Some(i), "{tab}");
}
h.request("ui.clickWidget", json!({"id": "button:aboutTab-contributors"}), t);
let img = h.snapshot(SETTLE);
assert_eq!(img.size, [1300, 820]);
assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::About), "switching tabs keeps the dialog open");
}
/// Settings (⌘,): tabs switch, app settings change the UI state, library settings go through
/// the engine; the delete confirmation guards ⌫.
#[test]
+1
View File
@@ -7,6 +7,7 @@
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
pub mod control;
pub mod credits;
pub mod export_task;
pub mod headless;
pub mod i18n;
+43 -21
View File
@@ -54,6 +54,9 @@ pub(crate) fn rename_preview(
(read(&rows), total)
}
/// About dialog tabs: (widget id suffix, label). The credits come from `crate::credits`.
pub const ABOUT_TABS: &[(&str, &str)] = &[("about", "About"), ("contributors", "Contributors"), ("models", "Models")];
/// Help ▸ What's New (docs/whats-new.md).
pub const WHATS_NEW: &str = include_str!("../../../../docs/whats-new.md");
@@ -708,27 +711,46 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) {
ui.label(egui::RichText::new(crate::i18n::tr("They can be restored from Recently Deleted until it is emptied.")).color(t.text_dim));
}
Dialog::About => {
ui.label(egui::RichText::new("LightCraft").font(t.semibold(20.0)).color(t.text));
ui.label(crate::i18n::tr_format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION")));
ui.label(format!("MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.", crate::theme::font_credits()));
ui.add_space(10.0);
let discord = egui::Button::new(egui::RichText::new(crate::i18n::tr("Join the ArtCraft Discord")).font(t.semibold(15.0)).color(egui::Color32::WHITE))
.fill(t.accent)
.min_size(egui::vec2(260.0, 34.0));
let r = ui.add(discord).on_hover_text(crate::links::DISCORD);
crate::widgets::register(ui.ctx(), "button:aboutDiscord", r.rect);
if r.clicked() {
let _ = crate::links::open(app, crate::links::DISCORD);
}
ui.add_space(6.0);
for (label, url) in [
("LightCraft website", crate::links::APP_PAGE),
("Source code on GitHub", crate::links::GITHUB),
("ArtCraft — more creative apps", crate::links::WEBSITE),
] {
let r = ui.link(crate::i18n::tr(label)).on_hover_text(url);
if r.clicked() {
let _ = crate::links::open(app, url);
ui.set_min_width(680.0);
let tab_id = egui::Id::new("about_tab");
let mut tab = ui.data_mut(|d| d.get_temp::<u8>(tab_id)).unwrap_or(0);
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 4.0;
for (i, (id, label)) in ABOUT_TABS.iter().enumerate() {
if crate::widgets::text_button(ui, &format!("aboutTab-{id}"), label, usize::from(tab) == i).clicked() {
tab = u8::try_from(i).unwrap_or(0);
}
}
});
ui.data_mut(|d| d.insert_temp(tab_id, tab));
ui.separator();
match tab {
1 => crate::credits::contributors_ui(app, ui),
2 => crate::credits::models_ui(ui),
_ => {
ui.label(egui::RichText::new("LightCraft").font(t.semibold(20.0)).color(t.text));
ui.label(crate::i18n::tr_format!("Version {} — a clean-room, pure-Rust photo library and raw developer.", env!("CARGO_PKG_VERSION")));
ui.label(format!("MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.", crate::theme::font_credits()));
ui.add_space(10.0);
let discord = egui::Button::new(egui::RichText::new(crate::i18n::tr("Join the ArtCraft Discord")).font(t.semibold(15.0)).color(egui::Color32::WHITE))
.fill(t.accent)
.min_size(egui::vec2(260.0, 34.0));
let r = ui.add(discord).on_hover_text(crate::links::DISCORD);
crate::widgets::register(ui.ctx(), "button:aboutDiscord", r.rect);
if r.clicked() {
let _ = crate::links::open(app, crate::links::DISCORD);
}
ui.add_space(6.0);
for (label, url) in [
("LightCraft website", crate::links::APP_PAGE),
("Source code on GitHub", crate::links::GITHUB),
("ArtCraft — more creative apps", crate::links::WEBSITE),
] {
let r = ui.link(crate::i18n::tr(label)).on_hover_text(url);
if r.clicked() {
let _ = crate::links::open(app, url);
}
}
}
}
}
+58
View File
@@ -0,0 +1,58 @@
# Contributors in the About window
**About ▸ Contributors** credits everyone who contributed to LightCraft, 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 `@`.
## In LightCraft
- The About dialog (`crates/ui-egui/src/panels/dialogs.rs`, Help ▸ About LightCraft) has the tabs
**About · Contributors · Models**; the control channel can switch them with
`ui.clickWidget {"id": "button:aboutTab-contributors"}` (`-about`, `-models`), and the list view
with `button:creditsGrabBag` / `button:creditsTable`.
## 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.