mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-13 15:09:08 +00:00
About: Contributors and Models tabs with full credits; bump version to 0.2.0 (from 0.1.0) for release
Ports PhotoCraft's credits view (craftrules standards/contributors.md): contributor table with PR/commit/line stats and verified names, AI-model credits, generated contributors.json, and docs/contributors.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
0638c98564
commit
7acc224ca2
10 files changed
+1191
-69
No files matched your search
Generated
+53
-17
@@ -1090,6 +1090,20 @@ dependencies = [
|
||||
"winit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_extras"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"egui",
|
||||
"enum-map",
|
||||
"itertools",
|
||||
"log",
|
||||
"profiling",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "egui_glow"
|
||||
version = "0.36.2"
|
||||
@@ -1125,6 +1139,26 @@ version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enum-map"
|
||||
version = "2.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9"
|
||||
dependencies = [
|
||||
"enum-map-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enum-map-derive"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
@@ -1280,7 +1314,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
|
||||
|
||||
[[package]]
|
||||
name = "figcraft"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"eframe",
|
||||
@@ -1299,7 +1333,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-cli"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-engine",
|
||||
@@ -1313,7 +1347,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-color"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1322,7 +1356,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-doc"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-geom",
|
||||
@@ -1334,7 +1368,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-engine"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-color",
|
||||
@@ -1356,7 +1390,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-format"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"image",
|
||||
@@ -1368,7 +1402,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-geom"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"kurbo",
|
||||
"serde",
|
||||
@@ -1376,7 +1410,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-layout"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1384,7 +1418,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-mcp"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-doc",
|
||||
@@ -1399,7 +1433,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pathops"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1408,7 +1442,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-pdf"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
@@ -1425,7 +1459,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-render"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"figcraft-color",
|
||||
@@ -1441,7 +1475,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-svg"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"figcraft-color",
|
||||
@@ -1454,7 +1488,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-text"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"figcraft-doc",
|
||||
"figcraft-geom",
|
||||
@@ -1466,10 +1500,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-ui-egui"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"egui",
|
||||
"egui_extras",
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
"figcraft-engine",
|
||||
@@ -1481,11 +1516,12 @@ dependencies = [
|
||||
"log",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "figcraft-web"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"eframe",
|
||||
"egui",
|
||||
@@ -5252,7 +5288,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
||||
|
||||
[[package]]
|
||||
name = "xtask"
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"figcraft-color",
|
||||
"figcraft-doc",
|
||||
|
||||
+2
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
||||
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "0.2.0"
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.95"
|
||||
@@ -52,6 +52,7 @@ crc32fast = "1"
|
||||
|
||||
# ui
|
||||
egui = "0.36"
|
||||
egui_extras = { version = "0.36", default-features = false }
|
||||
eframe = { version = "0.36", default-features = false }
|
||||
rfd = "0.17"
|
||||
arboard = { version = "3.6", default-features = false }
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"schema": 1,
|
||||
"repo": "storytold/figcraft",
|
||||
"branch": "main",
|
||||
"head": "0638c98564052c1be729405862b5b0b6d946d902",
|
||||
"head_date": "2026-10-10T01:04:13Z",
|
||||
"commits": 82,
|
||||
"unattributed_commits": 0,
|
||||
"contributors": [
|
||||
{
|
||||
"login": "echelon",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 0,
|
||||
"commits": 25,
|
||||
"lines_added": 52057,
|
||||
"lines_deleted": 2673,
|
||||
"binary_added": 20,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T15:38:50Z",
|
||||
"last_commit": "2026-10-09T18:17:22Z"
|
||||
},
|
||||
{
|
||||
"login": "bflatastic",
|
||||
"people_entry": false,
|
||||
"github_name": null,
|
||||
"prs": 57,
|
||||
"commits": 57,
|
||||
"lines_added": 31774,
|
||||
"lines_deleted": 3537,
|
||||
"binary_added": 0,
|
||||
"binary_deleted": 0,
|
||||
"first_commit": "2026-10-09T19:05:58Z",
|
||||
"last_commit": "2026-10-10T01:04:13Z"
|
||||
}
|
||||
],
|
||||
"models": [
|
||||
{
|
||||
"id": "anthropic/claude-opus/5.5",
|
||||
"company": "Anthropic",
|
||||
"model": "Claude Opus",
|
||||
"version": "5.5",
|
||||
"commits": 81,
|
||||
"lines_added": 83824,
|
||||
"lines_deleted": 6210
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -22,6 +22,8 @@ serde_json = { workspace = true }
|
||||
log = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
image = { workspace = true }
|
||||
egui_extras = { workspace = true }
|
||||
|
||||
[build-dependencies]
|
||||
serde_json = { workspace = true }
|
||||
toml = "1"
|
||||
+100
-28
@@ -1,33 +1,105 @@
|
||||
//! Compiles contributor credits into the binary (craftrules standards/contributors.md):
|
||||
//! usernames from `contributors/contributors.json` (generated by craftrules' contributors.py).
|
||||
//! A missing or malformed file still builds, with no contributors listed.
|
||||
//! 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 std::path::PathBuf;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap_or_default()).join("../../contributors");
|
||||
let json = root.join("contributors.json");
|
||||
println!("cargo::rerun-if-changed={}", json.display());
|
||||
println!("cargo::rerun-if-changed={}", root.join("people.toml").display());
|
||||
let mut out = String::from("pub static CONTRIBUTORS: &[&str] = &[");
|
||||
match std::fs::read_to_string(&json) {
|
||||
Ok(text) => match serde_json::from_str::<serde_json::Value>(&text) {
|
||||
Ok(v) => {
|
||||
if let Some(list) = v.get("contributors").and_then(|c| c.as_array()) {
|
||||
for c in list {
|
||||
if let Some(login) = c.get("login").and_then(|l| l.as_str()) {
|
||||
let _ = write!(out, "{login:?}, ");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => println!("cargo::warning=contributors.json: {e}"),
|
||||
},
|
||||
Err(_) => println!("cargo::warning=no contributors/contributors.json (run craftrules scripts/contributors.py)"),
|
||||
}
|
||||
out.push_str("];\n");
|
||||
let dest = PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("credits.rs");
|
||||
if let Err(e) = std::fs::write(&dest, out) {
|
||||
println!("cargo::error=writing {}: {e}", dest.display());
|
||||
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();
|
||||
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<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()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,3 +1,648 @@
|
||||
//! Contributor credits compiled in by `build.rs`.
|
||||
//! 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::theme::{Tokens, semibold};
|
||||
|
||||
#[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)
|
||||
}
|
||||
|
||||
/// Counts line up on the right of their column; names and dates read from the left.
|
||||
fn numeric(self) -> bool {
|
||||
!self.default_ascending()
|
||||
}
|
||||
|
||||
/// The table cell for `c` (the name column shows `c.name(..)` as a link instead).
|
||||
fn cell(self, c: &Contributor) -> String {
|
||||
match self {
|
||||
SortKey::Name => c.login.to_string(),
|
||||
SortKey::Prs => group(c.prs),
|
||||
SortKey::Commits => group(c.commits),
|
||||
SortKey::LinesAdded => group(c.lines_added),
|
||||
SortKey::LinesDeleted => group(c.lines_deleted),
|
||||
SortKey::LinesDelta => signed(c.lines_delta()),
|
||||
SortKey::BinaryAdded => group(c.binary_added),
|
||||
SortKey::BinaryDeleted => group(c.binary_deleted),
|
||||
SortKey::FirstCommit => day(c.first_commit).to_string(),
|
||||
SortKey::LastCommit => day(c.last_commit).to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
group(self.prs),
|
||||
group(self.commits),
|
||||
group(self.lines_added),
|
||||
group(self.lines_deleted),
|
||||
signed(self.lines_delta()),
|
||||
group(self.binary_added),
|
||||
group(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 }
|
||||
}
|
||||
}
|
||||
|
||||
/// Height of the Contributors list (grab bag or table, header included), the same on both views so
|
||||
/// switching between them doesn't resize the window. The Models table is as tall as its rows, up
|
||||
/// to this.
|
||||
const LIST_HEIGHT: f32 = 320.0;
|
||||
const TABLE_HEADER_H: f32 = 24.0;
|
||||
const TABLE_ROW_H: f32 = 22.0;
|
||||
/// Room for the sort arrow in every header, so the columns don't move when the sort does.
|
||||
const SORT_ARROW_W: f32 = 12.0;
|
||||
/// Padding inside each cell, on each side.
|
||||
const CELL_PAD: f32 = 6.0;
|
||||
const NAME_MIN_W: f32 = 110.0;
|
||||
|
||||
/// 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) {
|
||||
contributors_in(ui, CONTRIBUTORS, TOTAL_COMMITS);
|
||||
}
|
||||
|
||||
/// [`contributors_ui`] over a given list (tests pass their own).
|
||||
fn contributors_in(ui: &mut egui::Ui, contributors: &[Contributor], total_commits: u64) {
|
||||
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(RichText::new("Show").color(t.text_secondary));
|
||||
for m in NameMode::ALL {
|
||||
if ui.selectable_label(v.names == m, m.label()).clicked() {
|
||||
v.names = m;
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
ui.label(RichText::new("Sort").color(t.text_secondary));
|
||||
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();
|
||||
}
|
||||
}
|
||||
});
|
||||
// egui's icon font has these arrows; the UI font (Inter) may not have ▲/▼.
|
||||
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);
|
||||
let counts = format!("Contributors: {} · Commits: {}", group(list.len() as u64), group(total_commits));
|
||||
ui.label(RichText::new(counts).small().color(t.text_secondary));
|
||||
ui.separator();
|
||||
if list.is_empty() {
|
||||
ui.label(RichText::new("No contributor data was built into this copy.").color(t.text_secondary));
|
||||
} else if v.table {
|
||||
table(ui, &list, &mut v, LIST_HEIGHT);
|
||||
} else {
|
||||
egui::ScrollArea::vertical().id_salt("credits_bag").auto_shrink([false, false]).max_height(LIST_HEIGHT).show(ui, |ui| {
|
||||
// Spacing alone separates the names: a "·" between them started some lines.
|
||||
ui.spacing_mut().item_spacing = egui::vec2(14.0, 8.0);
|
||||
// Each name is one unit: otherwise egui flows a link that doesn't fit onto the next
|
||||
// line as wrapped text, at the font's line height instead of the row spacing.
|
||||
ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend);
|
||||
// horizontal_wrapped gives its first row the interact height; text rows are shorter,
|
||||
// so the first gap came out wider than the rest.
|
||||
ui.spacing_mut().interact_size.y = 0.0;
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for c in &list {
|
||||
link(ui, c, v.names);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
ui.data_mut(|d| d.insert_temp(id, v));
|
||||
}
|
||||
|
||||
/// Height of a table showing all `rows` (header included), up to [`LIST_HEIGHT`].
|
||||
fn table_height(ui: &egui::Ui, rows: usize) -> f32 {
|
||||
// Each row, and the header, is followed by the row gap.
|
||||
let gap = ui.spacing().item_spacing.y;
|
||||
let body = rows as f32 * (TABLE_ROW_H + gap) + gap;
|
||||
(TABLE_HEADER_H + body).min(LIST_HEIGHT)
|
||||
}
|
||||
|
||||
fn link(ui: &mut egui::Ui, c: &Contributor, names: NameMode) -> egui::Response {
|
||||
ui.hyperlink_to(c.name(names), format!("https://github.com/{}", c.login)).on_hover_text(c.summary())
|
||||
}
|
||||
|
||||
fn text_width(ui: &egui::Ui, text: &str, font: &egui::FontId) -> f32 {
|
||||
ui.fonts_mut(|f| f.layout_no_wrap(text.to_string(), font.clone(), egui::Color32::WHITE).size().x)
|
||||
}
|
||||
|
||||
/// A column's width: its widest cell, or its header plus the sort arrow's room, so no column
|
||||
/// changes width when the sort or the rows change.
|
||||
fn column_width<'a>(ui: &egui::Ui, header: &str, cells: impl Iterator<Item = &'a str>) -> f32 {
|
||||
let body = egui::TextStyle::Body.resolve(ui.style());
|
||||
let widest = cells.map(|c| text_width(ui, c, &body)).fold(0.0, f32::max);
|
||||
let head = text_width(ui, header, &semibold(body.size)) + SORT_ARROW_W;
|
||||
(widest.max(head) + 2.0 * CELL_PAD).ceil()
|
||||
}
|
||||
|
||||
/// A credits table: columns of `widths`, the `None` one taking what the others leave (at least
|
||||
/// `flex_min`). The header stays put while the rows scroll, within `height` (header included). Too
|
||||
/// narrow for every column, it scrolls sideways rather than clip one.
|
||||
fn credits_table(
|
||||
ui: &mut egui::Ui,
|
||||
id: &str,
|
||||
widths: &[Option<f32>],
|
||||
flex_min: f32,
|
||||
height: f32,
|
||||
header: impl FnOnce(egui_extras::TableRow<'_, '_>),
|
||||
body: impl FnOnce(egui_extras::TableBody<'_>),
|
||||
) {
|
||||
// The rows' scroll bar sits beside the columns: leave it room, or it pushes the table past the
|
||||
// window and a sideways scroll bar appears under it.
|
||||
let fixed: f32 = widths.iter().flatten().sum::<f32>() + ui.spacing().scroll.allocated_width();
|
||||
// The table puts a row gap under its header.
|
||||
let body_h = (height - TABLE_HEADER_H - ui.spacing().item_spacing.y).max(TABLE_ROW_H);
|
||||
egui::ScrollArea::horizontal().id_salt((id, "h")).auto_shrink([false, true]).show(ui, |ui| {
|
||||
let flex = (ui.available_width() - fixed).max(flex_min);
|
||||
// No gaps between cells: their padding spaces them, and the header rule runs unbroken.
|
||||
ui.spacing_mut().item_spacing.x = 0.0;
|
||||
let mut b = egui_extras::TableBuilder::new(ui)
|
||||
.id_salt(id)
|
||||
.striped(true)
|
||||
.resizable(false)
|
||||
.auto_shrink([false, false])
|
||||
.min_scrolled_height(body_h)
|
||||
.max_scroll_height(body_h)
|
||||
.cell_layout(egui::Layout::left_to_right(egui::Align::Center));
|
||||
for w in widths {
|
||||
b = b.column(match w {
|
||||
Some(w) => egui_extras::Column::exact(*w),
|
||||
None => egui_extras::Column::exact(flex).clip(true),
|
||||
});
|
||||
}
|
||||
b.header(TABLE_HEADER_H, header).body(body);
|
||||
});
|
||||
}
|
||||
|
||||
/// The width of each Contributors column for `list`; the name column (`None`) takes the rest.
|
||||
fn contributor_widths(ui: &egui::Ui, list: &[&Contributor]) -> Vec<Option<f32>> {
|
||||
SortKey::ALL
|
||||
.iter()
|
||||
.map(|&k| {
|
||||
let cells: Vec<String> = list.iter().map(|c| k.cell(c)).collect();
|
||||
(k != SortKey::Name).then(|| column_width(ui, k.label().1, cells.iter().map(String::as_str)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The Contributors table; clicking a header sorts by it (again reverses it).
|
||||
fn table(ui: &mut egui::Ui, list: &[&Contributor], v: &mut View, height: f32) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
let names = v.names;
|
||||
let widths = contributor_widths(ui, list);
|
||||
credits_table(
|
||||
ui,
|
||||
"credits_table",
|
||||
&widths,
|
||||
NAME_MIN_W,
|
||||
height,
|
||||
|mut header| {
|
||||
for k in SortKey::ALL {
|
||||
header.col(|ui| {
|
||||
let sort = (v.key == k).then_some(v.ascending);
|
||||
let (long, short) = k.label();
|
||||
if header_cell(ui, &t, short, k.numeric(), Some(sort)).on_hover_text(long).clicked() {
|
||||
if v.key == k {
|
||||
v.ascending = !v.ascending;
|
||||
} else {
|
||||
v.key = k;
|
||||
v.ascending = k.default_ascending();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
|body| {
|
||||
body.rows(TABLE_ROW_H, list.len(), |mut row| {
|
||||
let Some(c) = list.get(row.index()) else { return };
|
||||
row.col(|ui| {
|
||||
ui.add_space(CELL_PAD);
|
||||
link(ui, c, names);
|
||||
});
|
||||
for k in SortKey::ALL.into_iter().skip(1) {
|
||||
row.col(|ui| cell(ui, k.numeric(), &k.cell(c)));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
fn cell(ui: &mut egui::Ui, numeric: bool, text: &str) {
|
||||
let layout = if numeric { egui::Layout::right_to_left(egui::Align::Center) } else { egui::Layout::left_to_right(egui::Align::Center) };
|
||||
ui.with_layout(layout, |ui| {
|
||||
ui.add_space(CELL_PAD);
|
||||
ui.add(egui::Label::new(text).selectable(false));
|
||||
});
|
||||
}
|
||||
|
||||
/// One header: its label (on the right over counts, like their numbers) under a rule. A sortable
|
||||
/// one (`sort` is `Some`) is a button, with the arrow on the label's inner side when it's the sort
|
||||
/// (`Some(Some(ascending))`), in room every header keeps.
|
||||
fn header_cell(ui: &mut egui::Ui, t: &Tokens, label: &str, numeric: bool, sort: Option<Option<bool>>) -> egui::Response {
|
||||
let rect = ui.max_rect();
|
||||
let resp = ui.allocate_rect(rect, if sort.is_some() { egui::Sense::click() } else { egui::Sense::hover() });
|
||||
let kind = if sort.is_some() { egui::WidgetType::Button } else { egui::WidgetType::Label };
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(kind, true, label));
|
||||
let resp = if sort.is_some() { resp.on_hover_cursor(egui::CursorIcon::PointingHand) } else { resp };
|
||||
let current = sort.flatten();
|
||||
let color = if sort.is_none() || current.is_some() || resp.hovered() { t.text } else { t.text_secondary };
|
||||
let size = egui::TextStyle::Body.resolve(ui.style()).size;
|
||||
let galley = ui.painter().layout_no_wrap(label.to_string(), semibold(size), color);
|
||||
let inner = rect.shrink2(egui::vec2(CELL_PAD, 0.0));
|
||||
let y = rect.center().y - galley.size().y / 2.0;
|
||||
let (text_x, arrow_x) = if numeric {
|
||||
let x = inner.right() - galley.size().x;
|
||||
(x, x - SORT_ARROW_W / 2.0)
|
||||
} else {
|
||||
(inner.left(), inner.left() + galley.size().x + SORT_ARROW_W / 2.0)
|
||||
};
|
||||
ui.painter().galley(egui::pos2(text_x, y), galley, color);
|
||||
if let Some(ascending) = current {
|
||||
let (c, h) = (egui::pos2(arrow_x, rect.center().y), 3.0);
|
||||
let pts = if ascending {
|
||||
vec![c + egui::vec2(-h, h * 0.6), c + egui::vec2(h, h * 0.6), c + egui::vec2(0.0, -h * 0.9)]
|
||||
} else {
|
||||
vec![c + egui::vec2(-h, -h * 0.6), c + egui::vec2(h, -h * 0.6), c + egui::vec2(0.0, h * 0.9)]
|
||||
};
|
||||
ui.painter().add(egui::Shape::convex_polygon(pts, t.accent, egui::Stroke::NONE));
|
||||
}
|
||||
let line = egui::Rect::from_min_max(egui::pos2(rect.left(), rect.bottom() - 1.0), rect.max);
|
||||
ui.painter().rect_filled(line, 0.0, t.border);
|
||||
resp
|
||||
}
|
||||
|
||||
/// About ▸ Models: AI models credited in Co-Authored-By trailers.
|
||||
pub fn models_ui(ui: &mut egui::Ui) {
|
||||
models_in(ui, MODELS, TOTAL_COMMITS);
|
||||
}
|
||||
|
||||
/// Share of all commits, whole percent; a model with any commits never reads "0%".
|
||||
fn percent(commits: u64, total: u64) -> String {
|
||||
let p = 100.0 * commits as f64 / total.max(1) as f64;
|
||||
if commits > 0 && p < 0.5 { "<1%".into() } else { format!("{:.0}%", p.min(100.0)) }
|
||||
}
|
||||
|
||||
const MODEL_HEADS: [&str; 6] = ["Company", "Model", "Version", "Commits", "% of all commits", "Lines +/−"];
|
||||
|
||||
/// The Models table's cells for `m`, column by column; counts are the last three.
|
||||
fn model_cells(m: &Model, total: u64) -> [String; 6] {
|
||||
[
|
||||
m.company.to_string(),
|
||||
m.model.to_string(),
|
||||
m.version.to_string(),
|
||||
group(m.commits),
|
||||
percent(m.commits, total),
|
||||
format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)),
|
||||
]
|
||||
}
|
||||
|
||||
/// The width of each Models column; the model name (`None`) takes the spare width.
|
||||
fn model_widths(ui: &egui::Ui, rows: &[[String; 6]]) -> Vec<Option<f32>> {
|
||||
MODEL_HEADS
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, h)| (i != 1).then(|| column_width(ui, h, rows.iter().filter_map(|r| r.get(i)).map(String::as_str))))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// [`models_ui`] over a given list (tests pass their own).
|
||||
fn models_in(ui: &mut egui::Ui, models: &[Model], total_commits: u64) {
|
||||
let t = Tokens::get(ui.ctx());
|
||||
if models.is_empty() {
|
||||
ui.label(RichText::new("No model credits were built into this copy.").color(t.text_secondary));
|
||||
return;
|
||||
}
|
||||
// Counted against every commit, or the busiest model's if the total somehow trails it.
|
||||
let total = total_commits.max(models.iter().map(|m| m.commits).max().unwrap_or(0));
|
||||
let rows: Vec<[String; 6]> = models.iter().map(|m| model_cells(m, total)).collect();
|
||||
let widths = model_widths(ui, &rows);
|
||||
let numeric = |i: usize| i >= 3;
|
||||
credits_table(
|
||||
ui,
|
||||
"credits_models",
|
||||
&widths,
|
||||
NAME_MIN_W,
|
||||
table_height(ui, rows.len()),
|
||||
|mut header| {
|
||||
for (i, h) in MODEL_HEADS.iter().enumerate() {
|
||||
header.col(|ui| {
|
||||
header_cell(ui, &t, h, numeric(i), None);
|
||||
});
|
||||
}
|
||||
},
|
||||
|body| {
|
||||
body.rows(TABLE_ROW_H, rows.len(), |mut row| {
|
||||
let Some(cells) = rows.get(row.index()) else { return };
|
||||
for (i, text) in cells.iter().enumerate() {
|
||||
row.col(|ui| cell(ui, numeric(i), text));
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[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(""), "—");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_model_with_commits_never_reads_zero_percent() {
|
||||
assert_eq!(percent(228, 482), "47%");
|
||||
assert_eq!(percent(2, 482), "<1%");
|
||||
assert_eq!(percent(0, 482), "0%");
|
||||
assert_eq!(percent(5, 0), "100%"); // a total that trails the model's own count
|
||||
assert_eq!(percent(3, 1), "100%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_built_in_tables_are_consistent() {
|
||||
// The compiled-in tables (from contributors.json, or empty without it) hang together.
|
||||
for c in CONTRIBUTORS {
|
||||
assert!(!c.login.is_empty());
|
||||
assert!(c.first_commit <= c.last_commit || c.last_commit.is_empty());
|
||||
}
|
||||
// Model counts never exceed the commits they're a share of (when the total is known).
|
||||
for m in MODELS {
|
||||
assert!(TOTAL_COMMITS == 0 || m.commits <= TOTAL_COMMITS, "{m:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "credits_tests.rs"]
|
||||
mod ui_tests;
|
||||
@@ -0,0 +1,211 @@
|
||||
//! The credits tabs as drawn, through egui headless with FigCraft's fonts and theme: every column
|
||||
//! fits the About window, switching the Contributors view keeps its size, clicking a header sorts
|
||||
//! by it, and empty tables (no contributors.json) draw a note instead.
|
||||
|
||||
use egui::{Event, Pos2, RawInput, Rect, vec2};
|
||||
|
||||
use super::*;
|
||||
use crate::dialogs::ABOUT_WIDTH;
|
||||
|
||||
fn leak(s: String) -> &'static str {
|
||||
Box::leak(s.into_boxed_str())
|
||||
}
|
||||
|
||||
/// `n` contributors with distinct, realistic-sized counts (six-digit line counts, a long login).
|
||||
fn people(n: u64) -> Vec<Contributor> {
|
||||
(0..n)
|
||||
.map(|i| Contributor {
|
||||
login: if i == 0 { "nullpointerexception" } else { leak(format!("user{i:02}")) },
|
||||
display_name: None,
|
||||
real_name: None,
|
||||
prs: 100 + i,
|
||||
commits: 1000 + i,
|
||||
lines_added: 277_000 + i,
|
||||
lines_deleted: 22_000 + i,
|
||||
binary_added: 30 + i,
|
||||
binary_deleted: i,
|
||||
first_commit: leak(format!("2026-09-{:02}T00:00:00Z", 1 + i % 28)),
|
||||
last_commit: "2026-10-07T00:00:00Z",
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn model(name: &'static str, commits: u64) -> Model {
|
||||
Model { company: "Anthropic", model: name, version: "5.5", commits, lines_added: 150_000 + commits, lines_deleted: 20_000 }
|
||||
}
|
||||
|
||||
/// A headless egui with FigCraft's fonts and light theme; `draw` runs inside a fixed-width column.
|
||||
struct Ui {
|
||||
ctx: egui::Context,
|
||||
time: f64,
|
||||
}
|
||||
|
||||
impl Ui {
|
||||
fn new() -> Self {
|
||||
let ctx = egui::Context::default();
|
||||
crate::theme::install_fonts(&ctx);
|
||||
crate::theme::apply(&ctx, Tokens::light());
|
||||
let mut u = Self { ctx, time: 0.0 };
|
||||
// Fonts set now take effect on the next frame.
|
||||
u.frame(vec![], |_| {});
|
||||
u
|
||||
}
|
||||
|
||||
fn set_view(&self, v: View) {
|
||||
self.ctx.data_mut(|d| d.insert_temp(egui::Id::new("credits_view"), v));
|
||||
}
|
||||
|
||||
fn view(&self) -> View {
|
||||
self.ctx.data(|d| d.get_temp::<View>(egui::Id::new("credits_view"))).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// One frame; returns the rect `draw` filled.
|
||||
fn frame(&mut self, events: Vec<Event>, mut draw: impl FnMut(&mut egui::Ui)) -> Rect {
|
||||
self.time += 1.0 / 60.0;
|
||||
let input = RawInput {
|
||||
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(ABOUT_WIDTH + 40.0, 600.0))),
|
||||
time: Some(self.time),
|
||||
events,
|
||||
..Default::default()
|
||||
};
|
||||
let mut drawn = Rect::NOTHING;
|
||||
self.ctx
|
||||
.run_ui(input, |ui| {
|
||||
ui.set_width(ABOUT_WIDTH);
|
||||
drawn = ui.scope(|ui| draw(ui)).response.rect;
|
||||
})
|
||||
.textures_delta
|
||||
.clear();
|
||||
drawn
|
||||
}
|
||||
|
||||
fn click(&mut self, at: Pos2, draw: impl Fn(&mut egui::Ui)) {
|
||||
let press = |pressed| Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE };
|
||||
self.frame(vec![Event::PointerMoved(at)], &draw);
|
||||
self.frame(vec![press(true)], &draw);
|
||||
self.frame(vec![press(false)], &draw);
|
||||
self.frame(vec![], &draw);
|
||||
}
|
||||
|
||||
/// Run `f` with a `Ui` from a frame (for measuring text).
|
||||
fn measure<R>(&mut self, f: impl FnOnce(&egui::Ui) -> R) -> R {
|
||||
let mut f = Some(f);
|
||||
let mut out = None;
|
||||
self.frame(vec![], |ui| {
|
||||
if let Some(f) = f.take() {
|
||||
out = Some(f(ui));
|
||||
}
|
||||
});
|
||||
out.unwrap_or_else(|| panic!("no frame"))
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_column_fits_the_about_window() {
|
||||
let mut u = Ui::new();
|
||||
let list = people(8);
|
||||
let refs: Vec<&Contributor> = list.iter().collect();
|
||||
let (fixed, scroll) = u.measure(|ui| (contributor_widths(ui, &refs).iter().flatten().sum::<f32>(), ui.spacing().scroll.allocated_width()));
|
||||
assert!(fixed + scroll + NAME_MIN_W <= ABOUT_WIDTH, "contributor columns need {} pt of {ABOUT_WIDTH}", fixed + scroll + NAME_MIN_W);
|
||||
let models: Vec<Model> = (0..8).map(|i| model(leak(format!("Claude Model {i}")), [228, 45, 6, 2, 2, 1, 1, 1][i])).collect();
|
||||
let rows: Vec<[String; 6]> = models.iter().map(|m| model_cells(m, 482)).collect();
|
||||
let fixed = u.measure(|ui| model_widths(ui, &rows).iter().flatten().sum::<f32>());
|
||||
assert!(fixed + scroll + NAME_MIN_W <= ABOUT_WIDTH, "model columns need {} pt of {ABOUT_WIDTH}", fixed + scroll + NAME_MIN_W);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switching_the_contributors_view_keeps_its_size() {
|
||||
let list = people(60);
|
||||
let mut u = Ui::new();
|
||||
let mut height = |table| {
|
||||
u.set_view(View { table, ..View::default() });
|
||||
let mut r = Rect::NOTHING;
|
||||
for _ in 0..3 {
|
||||
r = u.frame(vec![], |ui| contributors_in(ui, &list, 1));
|
||||
}
|
||||
r.height()
|
||||
};
|
||||
let (bag, table) = (height(false), height(true));
|
||||
assert!((bag - table).abs() < 0.5, "{bag} pt tall as a grab bag, {table} pt as a table");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clicking_a_header_sorts_by_it() {
|
||||
let list = people(12);
|
||||
let draw = |ui: &mut egui::Ui| contributors_in(ui, &list, 1);
|
||||
let mut u = Ui::new();
|
||||
u.set_view(View { table: true, ..View::default() });
|
||||
let mut r = Rect::NOTHING;
|
||||
for _ in 0..3 {
|
||||
r = u.frame(vec![], draw);
|
||||
}
|
||||
// The table fills the bottom LIST_HEIGHT of the tab; its header row is on top.
|
||||
let header_y = r.bottom() - LIST_HEIGHT + TABLE_HEADER_H / 2.0;
|
||||
let refs: Vec<&Contributor> = list.iter().collect();
|
||||
let widths = u.measure(|ui| contributor_widths(ui, &refs));
|
||||
let fixed: f32 = widths.iter().flatten().sum();
|
||||
let scroll = u.measure(|ui| ui.spacing().scroll.allocated_width());
|
||||
let name_w = (ABOUT_WIDTH - fixed - scroll).max(NAME_MIN_W);
|
||||
// The middle of column `i` (0 is the name).
|
||||
let x = |i: usize| r.left() + name_w + widths.iter().skip(1).take(i - 1).flatten().sum::<f32>() + widths[i].unwrap_or(0.0) / 2.0;
|
||||
assert_eq!((u.view().key, u.view().ascending), (SortKey::FirstCommit, true));
|
||||
let commits = SortKey::ALL.iter().position(|k| *k == SortKey::Commits).unwrap_or(0);
|
||||
u.click(Pos2::new(x(commits), header_y), draw);
|
||||
assert_eq!((u.view().key, u.view().ascending), (SortKey::Commits, false), "a count sorts biggest first");
|
||||
u.click(Pos2::new(x(commits), header_y), draw);
|
||||
assert_eq!((u.view().key, u.view().ascending), (SortKey::Commits, true), "clicking again reverses");
|
||||
let last = SortKey::ALL.len() - 1;
|
||||
u.click(Pos2::new(x(last), header_y), draw);
|
||||
assert_eq!((u.view().key, u.view().ascending), (SortKey::LastCommit, true), "a date sorts oldest first");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_tables_draw_a_note() {
|
||||
// What a build without contributors.json shows: no table, no panic, on every view.
|
||||
let mut u = Ui::new();
|
||||
for table in [false, true] {
|
||||
u.set_view(View { table, ..View::default() });
|
||||
let r = u.frame(vec![], |ui| contributors_in(ui, &[], 0));
|
||||
assert!(r.height() > 0.0 && r.height() < LIST_HEIGHT, "{r:?}");
|
||||
}
|
||||
let r = u.frame(vec![], |ui| models_in(ui, &[], 0));
|
||||
assert!(r.height() > 0.0 && r.height() < 40.0, "{r:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_models_table_is_as_tall_as_its_rows() {
|
||||
let mut u = Ui::new();
|
||||
let few = [model("Claude Opus", 80)];
|
||||
let many: Vec<Model> = (0..40).map(|i| model(leak(format!("Model {i}")), i)).collect();
|
||||
let mut h = |models: &[Model]| {
|
||||
let mut r = Rect::NOTHING;
|
||||
for _ in 0..2 {
|
||||
r = u.frame(vec![], |ui| models_in(ui, models, 81));
|
||||
}
|
||||
r.height()
|
||||
};
|
||||
let (one, lots) = (h(&few), h(&many));
|
||||
assert!(one < 3.0 * TABLE_ROW_H + TABLE_HEADER_H, "one model takes {one} pt");
|
||||
assert!(lots <= LIST_HEIGHT + 1.0, "40 models take {lots} pt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_about_window_draws_every_tab() {
|
||||
// The whole app, with the compiled-in credits (whatever contributors.json held, or none).
|
||||
let ctx = egui::Context::default();
|
||||
let mut app = crate::FigApp::new(figcraft_engine::Session::new(), Box::new(crate::NoServices));
|
||||
app.ui.about = true;
|
||||
for (i, tab) in crate::dialogs::AboutTab::ALL.into_iter().enumerate() {
|
||||
app.ui.about_tab = tab;
|
||||
for f in 0..3 {
|
||||
let input = RawInput {
|
||||
screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))),
|
||||
time: Some((i * 3 + f) as f64 / 60.0),
|
||||
..Default::default()
|
||||
};
|
||||
ctx.run_ui(input, |ui| app.ui(ui)).textures_delta.clear();
|
||||
}
|
||||
assert!(app.ui.about, "{tab:?}: the About window closed");
|
||||
assert_eq!(app.ui.about_tab, tab);
|
||||
}
|
||||
}
|
||||
@@ -24,33 +24,83 @@ fn window<'a>(title: &'a str, ctx: &Context) -> egui::Window<'a> {
|
||||
.frame(egui::Frame::window(&ctx.global_style()).fill(t.panel).inner_margin(egui::Margin::same(20)))
|
||||
}
|
||||
|
||||
/// The About window's tabs.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum AboutTab {
|
||||
#[default]
|
||||
About,
|
||||
Contributors,
|
||||
Models,
|
||||
}
|
||||
|
||||
impl AboutTab {
|
||||
pub const ALL: [AboutTab; 3] = [AboutTab::About, AboutTab::Contributors, AboutTab::Models];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
AboutTab::About => "About",
|
||||
AboutTab::Contributors => "Contributors",
|
||||
AboutTab::Models => "Models",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The About window's width, the same on every tab (the Contributors table needs the room).
|
||||
pub const ABOUT_WIDTH: f32 = 640.0;
|
||||
|
||||
/// The About window's tab row, drawn like the Design / Prototype tabs of the right panel.
|
||||
fn about_tabs(ui: &mut egui::Ui, t: &Tokens, current: &mut AboutTab) {
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 2.0;
|
||||
for tab in AboutTab::ALL {
|
||||
let label = tab.label();
|
||||
let active = *current == tab;
|
||||
let galley = ui.painter().layout_no_wrap(label.into(), semibold(11.0), if active { t.text } else { t.text_secondary });
|
||||
let (r, resp) = ui.allocate_exact_size(vec2(galley.size().x + 16.0, 24.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, active, label));
|
||||
if active || resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(5), t.hover);
|
||||
}
|
||||
ui.painter().galley(egui::pos2(r.min.x + 8.0, r.center().y - galley.size().y / 2.0), galley, t.text);
|
||||
if resp.clicked() {
|
||||
*current = tab;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// About ▸ About: the logo, version, blurb and links.
|
||||
fn about_body(ui: &mut egui::Ui, t: &Tokens) {
|
||||
ui.horizontal(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(48.0, 48.0), Sense::hover());
|
||||
ui.painter().rect_filled(r, CornerRadius::same(10), ACCENT);
|
||||
icons::paint(ui.painter(), r.shrink(10.0), Icon::Logo, Color32::WHITE);
|
||||
ui.vertical(|ui| {
|
||||
ui.label(RichText::new("FigCraft").font(semibold(18.0)));
|
||||
ui.label(RichText::new(format!("Version {VERSION}")).color(t.text_secondary));
|
||||
});
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
ui.label("Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps.");
|
||||
ui.add_space(8.0);
|
||||
ui.hyperlink_to("getartcraft.com/apps/figcraft", "https://getartcraft.com/apps/figcraft");
|
||||
ui.hyperlink_to("Join us on Discord", "https://discord.gg/artcraft");
|
||||
ui.add_space(8.0);
|
||||
ui.label(RichText::new("Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community.").color(t.text_secondary).font(regular(11.0)));
|
||||
}
|
||||
|
||||
pub fn show(app: &mut FigApp, ctx: &Context) {
|
||||
let t = Tokens::get(ctx);
|
||||
if app.ui.about {
|
||||
let mut open = true;
|
||||
window("About FigCraft", ctx).open(&mut open).show(ctx, |ui| {
|
||||
ui.set_width(380.0);
|
||||
ui.horizontal(|ui| {
|
||||
let (r, _) = ui.allocate_exact_size(vec2(48.0, 48.0), Sense::hover());
|
||||
ui.painter().rect_filled(r, CornerRadius::same(10), ACCENT);
|
||||
icons::paint(ui.painter(), r.shrink(10.0), Icon::Logo, Color32::WHITE);
|
||||
ui.vertical(|ui| {
|
||||
ui.label(RichText::new("FigCraft").font(semibold(18.0)));
|
||||
ui.label(RichText::new(format!("Version {VERSION}")).color(t.text_secondary));
|
||||
});
|
||||
});
|
||||
ui.set_width(ABOUT_WIDTH);
|
||||
about_tabs(ui, &t, &mut app.ui.about_tab);
|
||||
ui.add_space(12.0);
|
||||
ui.label("Interface design and prototyping. A free, open-source, clean-room design tool written from scratch in Rust, part of the ArtCraft Crafting Apps.");
|
||||
ui.add_space(8.0);
|
||||
ui.hyperlink_to("getartcraft.com/apps/figcraft", "https://getartcraft.com/apps/figcraft");
|
||||
ui.hyperlink_to("Join us on Discord", "https://discord.gg/artcraft");
|
||||
ui.add_space(8.0);
|
||||
ui.label(RichText::new("Dual-licensed MIT OR Apache-2.0. Made by the ArtCraft team and community.").color(t.text_secondary).font(regular(11.0)));
|
||||
let credits = crate::credits::CONTRIBUTORS;
|
||||
if !credits.is_empty() {
|
||||
ui.add_space(8.0);
|
||||
ui.label(RichText::new("Contributors").font(semibold(12.0)));
|
||||
ui.label(credits.iter().map(|c| format!("@{c}")).collect::<Vec<_>>().join(" · "));
|
||||
match app.ui.about_tab {
|
||||
AboutTab::About => about_body(ui, &t),
|
||||
AboutTab::Contributors => crate::credits::contributors_ui(ui),
|
||||
AboutTab::Models => crate::credits::models_ui(ui),
|
||||
}
|
||||
});
|
||||
if !open {
|
||||
|
||||
@@ -121,6 +121,8 @@ pub struct UiState {
|
||||
/// The Variables editor, when open.
|
||||
pub variables: Option<variables_panel::Editor>,
|
||||
pub about: bool,
|
||||
/// The tab the About window shows.
|
||||
pub about_tab: dialogs::AboutTab,
|
||||
pub preferences: bool,
|
||||
pub shortcuts_panel: bool,
|
||||
pub home: bool,
|
||||
@@ -186,6 +188,7 @@ impl Default for UiState {
|
||||
palette: None,
|
||||
variables: None,
|
||||
about: false,
|
||||
about_tab: dialogs::AboutTab::About,
|
||||
preferences: false,
|
||||
shortcuts_panel: false,
|
||||
home: false,
|
||||
@@ -277,7 +280,10 @@ impl FigApp {
|
||||
let reqs = std::mem::take(&mut self.session.ui_requests);
|
||||
for r in reqs {
|
||||
match r.kind.as_str() {
|
||||
"dialog.about" => self.ui.about = true,
|
||||
"dialog.about" => {
|
||||
self.ui.about = true;
|
||||
self.ui.about_tab = dialogs::AboutTab::About;
|
||||
}
|
||||
"dialog.preferences" => self.ui.preferences = true,
|
||||
"panel.shortcuts" => self.ui.shortcuts_panel = !self.ui.shortcuts_panel,
|
||||
"palette.actions" => self.ui.palette = Some(String::new()),
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
# Contributors in the About window
|
||||
|
||||
**About ▸ Contributors** credits everyone who contributed to FigCraft, 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.
|
||||
Reference in new issue
Block a user