Files
vectorcraft/crates/ui-egui/src/credits.rs
T
LosminekandClaude Opus 5.5 e3dec2e87b M14.i18n: translate Czech labels at UI render sites
Localize native/web file filters, diagnostic labels, numbered presets
and contributor tooltips with English fallback and Czech plural forms.
Fix gradient adjective agreement and disambiguate Japanese printer marks
in PDF/Print choices and summaries. Keep file extensions and names intact.

Add Czech catalog rows and regression coverage; update localization evidence.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 08:18:13 +07:00

425 lines
15 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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::VectorcraftApp;
#[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 => tl!("Username"),
NameMode::DisplayName => tl!("Display name"),
NameMode::RealName => tl!("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, in the UI language (the symbolic count headers stay as they are).
pub fn label(self) -> (&'static str, &'static str) {
match self {
SortKey::Name => (tl!("Name (A–Z)"), tl!("Name")),
SortKey::Prs => {
let header = "PRs";
(tl!("Merged PRs"), tl!(header))
}
SortKey::Commits => (tl!("Commits"), tl!("Commits")),
SortKey::LinesAdded => (tl!("Lines added"), "+LOC"),
SortKey::LinesDeleted => (tl!("Lines deleted"), "−LOC"),
SortKey::LinesDelta => (tl!("Line delta"), "ΔLOC"),
SortKey::BinaryAdded => (tl!("Binary assets added"), "+Bin"),
SortKey::BinaryDeleted => (tl!("Binary assets removed"), "−Bin"),
SortKey::FirstCommit => (tl!("First commit"), tl!("First")),
SortKey::LastCommit => (tl!("Last commit"), tl!("Last")),
}
}
/// Names and dates read naturally oldest/A first; counts read biggest first.
pub fn default_ascending(self) -> bool {
matches!(self, SortKey::Name | SortKey::FirstCommit | SortKey::LastCommit)
}
}
impl Contributor {
pub fn name(&self, mode: NameMode) -> String {
let chosen = match mode {
NameMode::Username => None,
NameMode::DisplayName => self.display_name,
NameMode::RealName => self.real_name,
};
chosen.map_or_else(|| format!("@{}", self.login), str::to_string)
}
pub fn lines_delta(&self) -> i128 {
i128::from(self.lines_added) - i128::from(self.lines_deleted)
}
/// One line with everything we know, for tooltips.
pub fn summary(&self) -> String {
let lang = crate::i18n::current();
let template = "@{login}: {prs}, {commits}, +{added} / −{deleted} lines (Δ {delta}), +{binary_added} / −{binary_deleted} binary assets, {first} – {last}";
let prs = crate::i18n::trn(lang, self.prs, "{n} PR", "{n} PRs");
let commits = crate::i18n::trn(lang, self.commits, "{n} commit", "{n} commits");
crate::i18n::fmt(
tl!(template),
&[
("login", self.login),
("prs", &prs),
("commits", &commits),
("added", &group(self.lines_added)),
("deleted", &group(self.lines_deleted)),
("delta", &signed(self.lines_delta())),
("binary_added", &self.binary_added.to_string()),
("binary_deleted", &self.binary_deleted.to_string()),
("first", day(self.first_commit)),
("last", 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) }
}
/// The About window doesn't resize: long lists scroll inside it.
const SCROLL_HEIGHT: f32 = 340.0;
#[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 }
}
}
/// About ▸ Contributors: a name toggle, a sort, and the list as a grab bag or a table.
pub fn contributors_ui(app: &mut VectorcraftApp, ui: &mut egui::Ui) {
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(tl!("Show"));
for m in NameMode::ALL {
if ui.selectable_label(v.names == m, m.label()).clicked() {
v.names = m;
}
}
ui.separator();
ui.label(tl!("Sort"));
egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| {
for k in SortKey::ALL {
if ui.selectable_label(v.key == k, k.label().0).clicked() {
v.key = k;
v.ascending = k.default_ascending();
}
}
});
if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text(tl!("Reverse the order")).clicked() {
v.ascending = !v.ascending;
}
ui.separator();
if ui.selectable_label(!v.table, tl!("Grab bag")).clicked() {
v.table = false;
}
if ui.selectable_label(v.table, tl!("Table")).clicked() {
v.table = true;
}
});
let list = sorted(CONTRIBUTORS, v.names, v.key, v.ascending);
let (people, commits) = (group(list.len() as u64), group(TOTAL_COMMITS));
let counts = crate::i18n::fmt(tl!("{people} contributors · {commits} commits"), &[("people", &people), ("commits", &commits)]);
ui.label(RichText::new(counts).small().weak());
ui.separator();
egui::ScrollArea::both().max_height(SCROLL_HEIGHT).auto_shrink([false, true]).show(ui, |ui| {
if list.is_empty() {
ui.label(tl!("No contributor data was built into this copy."));
} else if v.table {
table(app, ui, &list, &mut v);
} else {
ui.horizontal_wrapped(|ui| {
for (i, c) in list.iter().enumerate() {
if i > 0 {
ui.label(RichText::new("·").weak());
}
link(app, ui, c, v.names);
}
});
}
});
ui.data_mut(|d| d.insert_temp(id, v));
}
fn link(app: &mut VectorcraftApp, ui: &mut egui::Ui, c: &Contributor, names: NameMode) -> egui::Response {
let url = format!("https://github.com/{}", c.login);
let r = ui.add(egui::Link::new(c.name(names))).on_hover_text(c.summary());
if r.clicked() {
app.open_url(&url);
}
r
}
fn table(app: &mut VectorcraftApp, ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) {
egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| {
for k in SortKey::ALL {
let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" };
if ui.button(RichText::new(format!("{}{arrow}", k.label().1)).strong()).on_hover_text(k.label().0).clicked() {
if v.key == k {
v.ascending = !v.ascending;
} else {
v.key = k;
v.ascending = k.default_ascending();
}
}
}
ui.end_row();
for c in list {
link(app, ui, c, v.names);
ui.label(group(c.prs));
ui.label(group(c.commits));
ui.label(group(c.lines_added));
ui.label(group(c.lines_deleted));
ui.label(signed(c.lines_delta()));
ui.label(group(c.binary_added));
ui.label(group(c.binary_deleted));
ui.label(day(c.first_commit));
ui.label(day(c.last_commit));
ui.end_row();
}
});
}
/// About ▸ Models: AI models credited in Co-Authored-By trailers.
pub fn models_ui(ui: &mut egui::Ui) {
if MODELS.is_empty() {
ui.label(tl!("No model credits were built into this copy."));
return;
}
let assisted: u64 = MODELS.iter().map(|m| m.commits).max().unwrap_or(0).max(1);
egui::ScrollArea::both().max_height(SCROLL_HEIGHT).auto_shrink([false, true]).show(ui, |ui| {
egui::Grid::new("credits_models").striped(true).num_columns(6).show(ui, |ui| {
for h in [tl!("Company"), tl!("Model"), tl!("Version"), tl!("Commits"), tl!("% of all commits"), tl!("Lines +/−")] {
ui.label(RichText::new(h).strong());
}
ui.end_row();
for m in MODELS {
ui.label(m.company);
ui.label(m.model);
ui.label(m.version);
ui.label(group(m.commits));
ui.label(format!("{:.0}%", 100.0 * m.commits as f64 / TOTAL_COMMITS.max(assisted) as f64));
ui.label(format!("+{} / −{}", group(m.lines_added), group(m.lines_deleted)));
ui.end_row();
}
});
});
}
#[cfg(test)]
mod tests {
use super::*;
fn c(
login: &'static str,
display: Option<&'static str>,
real: Option<&'static str>,
added: u64,
deleted: u64,
first: &'static str,
) -> Contributor {
Contributor {
login,
display_name: display,
real_name: real,
prs: 0,
commits: 1,
lines_added: added,
lines_deleted: deleted,
binary_added: 0,
binary_deleted: 0,
first_commit: first,
last_commit: first,
}
}
fn people() -> [Contributor; 4] {
[
c("zed", None, None, 5, 50, "2026-01-02T00:00:00Z"),
c("Bob", Some("Ann"), None, 10, 0, "2026-01-01T00:00:00Z"),
c("alice", None, None, 7, 1, ""),
c("carol", None, Some("Dana"), 1, 0, "2026-01-03T00:00:00Z"),
]
}
fn logins(v: &[&Contributor]) -> Vec<&'static str> {
v.iter().map(|c| c.login).collect()
}
#[test]
fn names_fall_back_to_the_username() {
let p = c("bob", Some("Bobby"), None, 0, 0, "");
assert_eq!(p.name(NameMode::Username), "@bob");
assert_eq!(p.name(NameMode::DisplayName), "Bobby");
assert_eq!(p.name(NameMode::RealName), "@bob");
}
#[test]
fn alphabetical_ignores_at_and_case() {
let all = people();
let v = sorted(&all, NameMode::Username, SortKey::Name, true);
assert_eq!(logins(&v), ["alice", "Bob", "carol", "zed"]);
let v = sorted(&all, NameMode::DisplayName, SortKey::Name, true);
assert_eq!(logins(&v), ["alice", "Bob", "carol", "zed"]); // "Ann" sorts with the @usernames
let v = sorted(&all, NameMode::RealName, SortKey::Name, false);
assert_eq!(logins(&v), ["zed", "carol", "Bob", "alice"]); // "Dana" between bob and zed
}
#[test]
fn delta_and_dates() {
let all = people();
let v = sorted(&all, NameMode::Username, SortKey::LinesDelta, false);
assert_eq!(logins(&v), ["Bob", "alice", "carol", "zed"]);
let v = sorted(&all, NameMode::Username, SortKey::FirstCommit, true);
assert_eq!(logins(&v), ["Bob", "zed", "carol", "alice"]); // undated last
}
#[test]
fn 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(""), "—");
}
}