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:
Brandon ThomasandClaude Opus 5.5 committed 2026-10-09 21:10:33 -04:00
1 parent 0638c98564
commit 7acc224ca2
10 files changed
+1191 -69

No files matched your search

Generated
+53 -17
View File
@@ -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
View File
@@ -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 }
+48
View File
@@ -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
}
]
}
+2
View File
@@ -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
View File
@@ -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
}
+646 -1
View File
@@ -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;
+211
View File
@@ -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);
}
}
+71 -21
View File
@@ -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 {
+7 -1
View File
@@ -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()),
+51
View File
@@ -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.