mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-13 23:17:43 +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",
|
"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]]
|
[[package]]
|
||||||
name = "egui_glow"
|
name = "egui_glow"
|
||||||
version = "0.36.2"
|
version = "0.36.2"
|
||||||
@@ -1125,6 +1139,26 @@ version = "1.1.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
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]]
|
[[package]]
|
||||||
name = "enumflags2"
|
name = "enumflags2"
|
||||||
version = "0.7.12"
|
version = "0.7.12"
|
||||||
@@ -1280,7 +1314,7 @@ checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "figcraft"
|
name = "figcraft"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"arboard",
|
"arboard",
|
||||||
"eframe",
|
"eframe",
|
||||||
@@ -1299,7 +1333,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "figcraft-cli"
|
name = "figcraft-cli"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"figcraft-doc",
|
"figcraft-doc",
|
||||||
"figcraft-engine",
|
"figcraft-engine",
|
||||||
@@ -1313,7 +1347,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "figcraft-color"
|
name = "figcraft-color"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kurbo",
|
"kurbo",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1322,7 +1356,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "figcraft-doc"
|
name = "figcraft-doc"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"figcraft-color",
|
"figcraft-color",
|
||||||
"figcraft-geom",
|
"figcraft-geom",
|
||||||
@@ -1334,7 +1368,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "figcraft-engine"
|
name = "figcraft-engine"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"figcraft-color",
|
"figcraft-color",
|
||||||
@@ -1356,7 +1390,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "figcraft-format"
|
name = "figcraft-format"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"figcraft-doc",
|
"figcraft-doc",
|
||||||
"image",
|
"image",
|
||||||
@@ -1368,7 +1402,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "figcraft-geom"
|
name = "figcraft-geom"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"kurbo",
|
"kurbo",
|
||||||
"serde",
|
"serde",
|
||||||
@@ -1376,7 +1410,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "figcraft-layout"
|
name = "figcraft-layout"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"figcraft-doc",
|
"figcraft-doc",
|
||||||
"figcraft-geom",
|
"figcraft-geom",
|
||||||
@@ -1384,7 +1418,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "figcraft-mcp"
|
name = "figcraft-mcp"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"figcraft-doc",
|
"figcraft-doc",
|
||||||
@@ -1399,7 +1433,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "figcraft-pathops"
|
name = "figcraft-pathops"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"figcraft-doc",
|
"figcraft-doc",
|
||||||
"figcraft-geom",
|
"figcraft-geom",
|
||||||
@@ -1408,7 +1442,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "figcraft-pdf"
|
name = "figcraft-pdf"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"figcraft-color",
|
"figcraft-color",
|
||||||
"figcraft-doc",
|
"figcraft-doc",
|
||||||
@@ -1425,7 +1459,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "figcraft-render"
|
name = "figcraft-render"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"crc32fast",
|
"crc32fast",
|
||||||
"figcraft-color",
|
"figcraft-color",
|
||||||
@@ -1441,7 +1475,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "figcraft-svg"
|
name = "figcraft-svg"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"figcraft-color",
|
"figcraft-color",
|
||||||
@@ -1454,7 +1488,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "figcraft-text"
|
name = "figcraft-text"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"figcraft-doc",
|
"figcraft-doc",
|
||||||
"figcraft-geom",
|
"figcraft-geom",
|
||||||
@@ -1466,10 +1500,11 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "figcraft-ui-egui"
|
name = "figcraft-ui-egui"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"egui",
|
"egui",
|
||||||
|
"egui_extras",
|
||||||
"figcraft-color",
|
"figcraft-color",
|
||||||
"figcraft-doc",
|
"figcraft-doc",
|
||||||
"figcraft-engine",
|
"figcraft-engine",
|
||||||
@@ -1481,11 +1516,12 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"toml",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "figcraft-web"
|
name = "figcraft-web"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"eframe",
|
"eframe",
|
||||||
"egui",
|
"egui",
|
||||||
@@ -5252,7 +5288,7 @@ checksum = "9440ea3e5aeabb0ac63af70daf835274065238cdd0cec83418f417eae38bacee"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "xtask"
|
name = "xtask"
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"figcraft-color",
|
"figcraft-color",
|
||||||
"figcraft-doc",
|
"figcraft-doc",
|
||||||
|
|||||||
+2
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "apps/*", "xtask"]
|
|||||||
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
|
# default-members = ["crates/*", "apps/figcraft", "apps/figcraft-cli", "xtask"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.2.0"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
license = "MIT OR Apache-2.0"
|
license = "MIT OR Apache-2.0"
|
||||||
rust-version = "1.95"
|
rust-version = "1.95"
|
||||||
@@ -52,6 +52,7 @@ crc32fast = "1"
|
|||||||
|
|
||||||
# ui
|
# ui
|
||||||
egui = "0.36"
|
egui = "0.36"
|
||||||
|
egui_extras = { version = "0.36", default-features = false }
|
||||||
eframe = { version = "0.36", default-features = false }
|
eframe = { version = "0.36", default-features = false }
|
||||||
rfd = "0.17"
|
rfd = "0.17"
|
||||||
arboard = { version = "3.6", default-features = false }
|
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 }
|
log = { workspace = true }
|
||||||
base64 = { workspace = true }
|
base64 = { workspace = true }
|
||||||
image = { workspace = true }
|
image = { workspace = true }
|
||||||
|
egui_extras = { workspace = true }
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
serde_json = { workspace = true }
|
serde_json = { workspace = true }
|
||||||
|
toml = "1"
|
||||||
+100
-28
@@ -1,33 +1,105 @@
|
|||||||
//! Compiles contributor credits into the binary (craftrules standards/contributors.md):
|
//! Bake the contributor credits into the binary as static Rust tables (craftrules
|
||||||
//! usernames from `contributors/contributors.json` (generated by craftrules' contributors.py).
|
//! standards/contributors.md). Nothing is read at run time.
|
||||||
//! A missing or malformed file still builds, with no contributors listed.
|
//!
|
||||||
|
//! - 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::fmt::Write as _;
|
||||||
use std::path::PathBuf;
|
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
let root = PathBuf::from(std::env::var_os("CARGO_MANIFEST_DIR").unwrap_or_default()).join("../../contributors");
|
let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../contributors");
|
||||||
let json = root.join("contributors.json");
|
let json = read(&dir.join("contributors.json"), "the About window shows no contributors", |t| {
|
||||||
println!("cargo::rerun-if-changed={}", json.display());
|
serde_json::from_str::<Value>(t).map_err(|e| e.to_string())
|
||||||
println!("cargo::rerun-if-changed={}", root.join("people.toml").display());
|
})
|
||||||
let mut out = String::from("pub static CONTRIBUTORS: &[&str] = &[");
|
.unwrap_or(Value::Null);
|
||||||
match std::fs::read_to_string(&json) {
|
let people = read(&dir.join("people.toml"), "contributors are shown by username only", |t| t.parse::<toml::Table>().map_err(|e| e.to_string()))
|
||||||
Ok(text) => match serde_json::from_str::<serde_json::Value>(&text) {
|
.unwrap_or_default();
|
||||||
Ok(v) => {
|
let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("credits.rs");
|
||||||
if let Some(list) = v.get("contributors").and_then(|c| c.as_array()) {
|
if let Err(e) = std::fs::write(&out, generate(&json, &people)) {
|
||||||
for c in list {
|
println!("cargo::warning=writing {}: {e}", out.display());
|
||||||
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());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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"));
|
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)))
|
.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) {
|
pub fn show(app: &mut FigApp, ctx: &Context) {
|
||||||
let t = Tokens::get(ctx);
|
let t = Tokens::get(ctx);
|
||||||
if app.ui.about {
|
if app.ui.about {
|
||||||
let mut open = true;
|
let mut open = true;
|
||||||
window("About FigCraft", ctx).open(&mut open).show(ctx, |ui| {
|
window("About FigCraft", ctx).open(&mut open).show(ctx, |ui| {
|
||||||
ui.set_width(380.0);
|
ui.set_width(ABOUT_WIDTH);
|
||||||
ui.horizontal(|ui| {
|
about_tabs(ui, &t, &mut app.ui.about_tab);
|
||||||
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.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.");
|
match app.ui.about_tab {
|
||||||
ui.add_space(8.0);
|
AboutTab::About => about_body(ui, &t),
|
||||||
ui.hyperlink_to("getartcraft.com/apps/figcraft", "https://getartcraft.com/apps/figcraft");
|
AboutTab::Contributors => crate::credits::contributors_ui(ui),
|
||||||
ui.hyperlink_to("Join us on Discord", "https://discord.gg/artcraft");
|
AboutTab::Models => crate::credits::models_ui(ui),
|
||||||
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(" · "));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if !open {
|
if !open {
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ pub struct UiState {
|
|||||||
/// The Variables editor, when open.
|
/// The Variables editor, when open.
|
||||||
pub variables: Option<variables_panel::Editor>,
|
pub variables: Option<variables_panel::Editor>,
|
||||||
pub about: bool,
|
pub about: bool,
|
||||||
|
/// The tab the About window shows.
|
||||||
|
pub about_tab: dialogs::AboutTab,
|
||||||
pub preferences: bool,
|
pub preferences: bool,
|
||||||
pub shortcuts_panel: bool,
|
pub shortcuts_panel: bool,
|
||||||
pub home: bool,
|
pub home: bool,
|
||||||
@@ -186,6 +188,7 @@ impl Default for UiState {
|
|||||||
palette: None,
|
palette: None,
|
||||||
variables: None,
|
variables: None,
|
||||||
about: false,
|
about: false,
|
||||||
|
about_tab: dialogs::AboutTab::About,
|
||||||
preferences: false,
|
preferences: false,
|
||||||
shortcuts_panel: false,
|
shortcuts_panel: false,
|
||||||
home: false,
|
home: false,
|
||||||
@@ -277,7 +280,10 @@ impl FigApp {
|
|||||||
let reqs = std::mem::take(&mut self.session.ui_requests);
|
let reqs = std::mem::take(&mut self.session.ui_requests);
|
||||||
for r in reqs {
|
for r in reqs {
|
||||||
match r.kind.as_str() {
|
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,
|
"dialog.preferences" => self.ui.preferences = true,
|
||||||
"panel.shortcuts" => self.ui.shortcuts_panel = !self.ui.shortcuts_panel,
|
"panel.shortcuts" => self.ui.shortcuts_panel = !self.ui.shortcuts_panel,
|
||||||
"palette.actions" => self.ui.palette = Some(String::new()),
|
"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