diff --git a/Cargo.lock b/Cargo.lock index 7e97e6f..e3f79dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index d0a803f..0b767ff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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 } diff --git a/contributors/contributors.json b/contributors/contributors.json new file mode 100644 index 0000000..3b6aaa3 --- /dev/null +++ b/contributors/contributors.json @@ -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 + } + ] +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 4348383..f38707d 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -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" diff --git a/crates/ui-egui/build.rs b/crates/ui-egui/build.rs index 6f3fe9e..f40007f 100644 --- a/crates/ui-egui/build.rs +++ b/crates/ui-egui/build.rs @@ -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::(&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::(t).map_err(|e| e.to_string()) + }) + .unwrap_or(Value::Null); + let people = read(&dir.join("people.toml"), "contributors are shown by username only", |t| t.parse::().map_err(|e| e.to_string())) + .unwrap_or_default(); + let out = std::path::PathBuf::from(std::env::var_os("OUT_DIR").unwrap_or_default()).join("credits.rs"); + if let Err(e) = std::fs::write(&out, generate(&json, &people)) { + println!("cargo::warning=writing {}: {e}", out.display()); } } + +fn read(path: &std::path::Path, fallback: &str, parse: impl Fn(&str) -> Result) -> Option { + println!("cargo::rerun-if-changed={}", path.display()); + let r = std::fs::read_to_string(path).map_err(|e| e.to_string()).and_then(|t| parse(&t)); + if let Err(e) = &r { + println!("cargo::warning={}: {e}; {fallback}", path.display()); + } + r.ok() +} + +/// A non-empty string field of someone's people.toml entry, as a Rust `Option<&str>` literal. +fn name(v: Option<&str>) -> String { + match v.map(str::trim).filter(|s| !s.is_empty()) { + Some(x) => format!("Some({x:?})"), + None => "None".into(), + } +} + +fn s(v: &Value, k: &str) -> String { + format!("{:?}", v.get(k).and_then(Value::as_str).unwrap_or("")) +} + +fn n(v: &Value, k: &str) -> u64 { + v.get(k).and_then(Value::as_u64).unwrap_or(0) +} + +fn rows(json: &Value, k: &str) -> Vec { + json.get(k).and_then(Value::as_array).cloned().unwrap_or_default() +} + +fn generate(json: &Value, people: &toml::Table) -> String { + let mut o = String::new(); + let _ = writeln!(o, "pub static TOTAL_COMMITS: u64 = {};", n(json, "commits")); + let _ = writeln!(o, "pub static CONTRIBUTORS: &[Contributor] = &["); + for c in rows(json, "contributors") { + let login = c.get("login").and_then(Value::as_str).unwrap_or(""); + if login.is_empty() { + continue; + } + // Names only from a verified, self-chosen people.toml entry. + let entry = people.get("people").and_then(|p| p.get(login)).filter(|_| c.get("people_entry").and_then(Value::as_bool) == Some(true)); + let field = |k: &str| entry.and_then(|e| e.get(k)).and_then(toml::Value::as_str); + let synced = entry.and_then(|e| e.get("sync_github_name")).and_then(toml::Value::as_bool) == Some(true); + let display = field("display_name") + .filter(|s| !s.trim().is_empty()) + .or_else(|| if synced { c.get("github_name").and_then(Value::as_str) } else { None }); + let _ = writeln!( + o, + " Contributor {{ login: {login:?}, display_name: {}, real_name: {}, prs: {}, commits: {}, lines_added: {}, lines_deleted: {}, binary_added: {}, binary_deleted: {}, first_commit: {}, last_commit: {} }},", + name(display), + name(field("real_name")), + n(&c, "prs"), + n(&c, "commits"), + n(&c, "lines_added"), + n(&c, "lines_deleted"), + n(&c, "binary_added"), + n(&c, "binary_deleted"), + s(&c, "first_commit"), + s(&c, "last_commit"), + ); + } + let _ = writeln!(o, "];"); + let _ = writeln!(o, "pub static MODELS: &[Model] = &["); + for m in rows(json, "models") { + let _ = writeln!( + o, + " Model {{ company: {}, model: {}, version: {}, commits: {}, lines_added: {}, lines_deleted: {} }},", + s(&m, "company"), + s(&m, "model"), + s(&m, "version"), + n(&m, "commits"), + n(&m, "lines_added"), + n(&m, "lines_deleted"), + ); + } + let _ = writeln!(o, "];"); + o +} diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 288d5aa..7696981 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -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::(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) -> 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], + 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::() + 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> { + SortKey::ALL + .iter() + .map(|&k| { + let cells: Vec = 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>) -> 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> { + 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; diff --git a/crates/ui-egui/src/credits_tests.rs b/crates/ui-egui/src/credits_tests.rs new file mode 100644 index 0000000..a36e53d --- /dev/null +++ b/crates/ui-egui/src/credits_tests.rs @@ -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 { + (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::(egui::Id::new("credits_view"))).unwrap_or_default() + } + + /// One frame; returns the rect `draw` filled. + fn frame(&mut self, events: Vec, 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(&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::(), 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 = (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::()); + 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::() + 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 = (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); + } +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index cd39071..b579249 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -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::>().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 { diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index f60d131..a6de13e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -121,6 +121,8 @@ pub struct UiState { /// The Variables editor, when open. pub variables: Option, 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()), diff --git a/docs/contributors.md b/docs/contributors.md new file mode 100644 index 0000000..24a2505 --- /dev/null +++ b/docs/contributors.md @@ -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.