Skip to main content

teamcraft/domain/
skins.rs

1//! UI skins and avatar helpers.
2
3use serde::Serialize;
4
5/// A selectable look of the UI. The colours live in `static/style.css`
6/// (`[data-skin="<id>"]`), the logos in `media/`.
7#[derive(Clone, Copy, Debug, Serialize)]
8pub struct Skin {
9    pub id: &'static str,
10    pub name: &'static str,
11    /// Path below `/static/media/`.
12    pub logo: &'static str,
13    pub description: &'static str,
14    /// Light, dark, or following the operating system (`prefers-color-scheme`).
15    pub scheme: Scheme,
16}
17
18/// The colour scheme of a skin.
19#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
20#[serde(rename_all = "snake_case")]
21pub enum Scheme {
22    Light,
23    Dark,
24    /// Light or dark, as the operating system (browser) prefers.
25    System,
26}
27
28impl Scheme {
29    pub fn as_str(self) -> &'static str {
30        match self {
31            Scheme::Light => "light",
32            Scheme::Dark => "dark",
33            Scheme::System => "system",
34        }
35    }
36}
37
38pub const DEFAULT_SKIN: &str = "teamcraft";
39
40/// The TeamCraft icon (shown in the header, the footer and as favicon).
41pub const TEAMCRAFT_ICON: &str = "logos/TeamCraft_Icon.svg";
42
43pub const SKINS: [Skin; 10] = [
44    Skin {
45        id: "teamcraft",
46        name: "TeamCraft",
47        logo: TEAMCRAFT_ICON,
48        description: "Follows your system: light or dark",
49        scheme: Scheme::System,
50    },
51    Skin {
52        id: "teamcraft-day",
53        name: "TeamCraft Light",
54        logo: TEAMCRAFT_ICON,
55        description: "House style, light",
56        scheme: Scheme::Light,
57    },
58    Skin {
59        id: "teamcraft-night",
60        name: "TeamCraft Dark",
61        logo: TEAMCRAFT_ICON,
62        description: "House style, dark",
63        scheme: Scheme::Dark,
64    },
65    Skin {
66        id: "arch",
67        name: "Arch",
68        logo: TEAMCRAFT_ICON,
69        description: "Blueprint grid",
70        scheme: Scheme::Dark,
71    },
72    Skin {
73        id: "cachyos",
74        name: "CachyOS",
75        logo: TEAMCRAFT_ICON,
76        description: "Teal dot matrix",
77        scheme: Scheme::Dark,
78    },
79    Skin {
80        id: "endeavouros",
81        name: "EndeavourOS",
82        logo: TEAMCRAFT_ICON,
83        description: "Purple sunset",
84        scheme: Scheme::Dark,
85    },
86    Skin {
87        id: "kde",
88        name: "KDE",
89        logo: TEAMCRAFT_ICON,
90        description: "Breeze dark",
91        scheme: Scheme::Dark,
92    },
93    Skin {
94        id: "kubuntu",
95        name: "Kubuntu",
96        logo: TEAMCRAFT_ICON,
97        description: "Blue rings",
98        scheme: Scheme::Dark,
99    },
100    Skin {
101        id: "opensuse",
102        name: "openSUSE",
103        logo: TEAMCRAFT_ICON,
104        description: "Chameleon scales",
105        scheme: Scheme::Dark,
106    },
107    Skin {
108        id: "ubuntu",
109        name: "Ubuntu",
110        logo: TEAMCRAFT_ICON,
111        description: "Circle of friends",
112        scheme: Scheme::Dark,
113    },
114];
115
116/// Returns the skin id if it is known, otherwise the default skin.
117pub fn skin_or_default(id: &str) -> &'static str {
118    let id = id.trim();
119    SKINS
120        .iter()
121        .find(|s| s.id == id)
122        .map(|s| s.id)
123        .unwrap_or(DEFAULT_SKIN)
124}
125
126pub fn is_skin(id: &str) -> bool {
127    SKINS.iter().any(|s| s.id == id)
128}
129
130/// The colour scheme of a skin (unknown ids: the default skin).
131pub fn skin_scheme(id: &str) -> Scheme {
132    let id = skin_or_default(id);
133    SKINS
134        .iter()
135        .find(|s| s.id == id)
136        .map(|s| s.scheme)
137        .unwrap_or(Scheme::System)
138}
139
140/// Up to two initials for the avatar: first letters of the first and last
141/// word of the display name (the username when the name is empty).
142pub fn initials(display_name: &str, username: &str) -> String {
143    let source = if display_name.trim().is_empty() {
144        username
145    } else {
146        display_name
147    };
148    let words: Vec<&str> = source.split_whitespace().collect();
149    let first = |w: &str| {
150        w.chars()
151            .next()
152            .map(|c| c.to_uppercase().collect::<String>())
153    };
154    match words.as_slice() {
155        [] => "?".to_string(),
156        [one] => first(one).unwrap_or_default(),
157        [a, .., b] => format!(
158            "{}{}",
159            first(a).unwrap_or_default(),
160            first(b).unwrap_or_default()
161        ),
162    }
163}
164
165/// A stable avatar tint 1..=6 (`--avatar-N` in skins.css) from the username.
166pub fn avatar_tint(username: &str) -> u32 {
167    // FNV-1a: stable across builds and platforms
168    let hash = username.bytes().fold(0x811c_9dc5_u32, |h, b| {
169        (h ^ u32::from(b)).wrapping_mul(0x0100_0193)
170    });
171    hash % 6 + 1
172}
173
174/// Whether a skin is always light (the System skin is not: it may be dark).
175pub fn skin_is_light(id: &str) -> bool {
176    skin_scheme(id) == Scheme::Light
177}