Skip to main content

ui/
theme_app.rs

1// theme_app -- app themes: the active style's tokens overridden from a small text file
2//
3//   Theme::from_ini(text) -> Result<Theme, ThemeError>   parse a theme (the format below)
4//   Theme::load(path) -> Result<Theme, ThemeError>       ... from a file (app://, config://, a
5//                              path); relative font files are taken from the theme's folder
6//   theme.to_ini() -> String / theme.save(path) -> bool  the text form of what the theme sets
7//   token_index(name) -> Option<usize>   the T_* index (token / 4) of a token name ("bg")
8//   TOKEN_NAMES, TS_NAMES      the names of the tokens and of the text styles
9//
10// The format is INI (base::ini):
11//   [theme]     name = the theme's name
12//   [colors]    TOKEN = #RRGGBB | #AARRGGBB for light and dark; [light], [dark] for one of
13//               them, [contrast] for high contrast (on top of the scheme's colours).  TOKEN is
14//               a T_* name in lower case (bg, card, ctrl_stroke, text2, accent_text, ...);
15//               accent is the colour the style derives its accent ramp from (as accent_set;
16//               high contrast: the accent fills)
17//   [metrics]   radius = DIP | PERCENT%   rounded corners that are not pills (0: square)
18//               spacing = PERCENT%        padding and gaps of boxes and grid boxes
19//               control_height = DIP      buttons, text boxes, combo boxes, drop-down and split
20//                                         buttons (not compact or large ones)
21//   [text]      family = FILE | installed family   (family.semibold, family.bold: their faces;
22//               else from the family: a file gets a synthetic bold, a variable one its wght);
23//               family.axes = TAG=VALUE, ...   axis values of a variable family (wdth=87.5,
24//               opsz=12; weights still come from the faces' 400 / 600 / 700, opsz else 11 pt);
25//               STYLE.size = DIP (24.8), STYLE.line = DIP, STYLE.weight = regular | semibold |
26//               bold | 100..900 for STYLE in caption, body, body_strong, subtitle, title,
27//               title_large, display
28// Errors name the line (in the UI language): an unknown section or key, a line without '=',
29// a key before the first section or twice in one section, a value out of range.  A theme is
30// applied whole or not at all (Ui::set_theme).
31
32use alloc::format;
33use alloc::string::String;
34use alloc::vec::Vec;
35
36use base::ini::{Ini, Item};
37use base::num::f64_parse;
38
39use crate::consts::*;
40use crate::i18n::{fill, tr};
41
42/// Theme::colors / accent index: the light theme
43pub const THEME_LIGHT: usize = 0;
44/// ... the dark theme
45pub const THEME_DARK: usize = 1;
46/// ... high contrast
47pub const THEME_CONTRAST: usize = 2;
48
49/// the T_* tokens by name, in token order (index = token / 4); theme files write them in
50/// lower case
51pub const TOKEN_NAMES: [&str; T_COUNT] = [
52    "BG", "BG_ALT", "LAYER", "LAYER_STROKE", "CARD", "CARD_STROKE", "CTRL", "CTRL_HOVER", "CTRL_PRESSED", "CTRL_DISABLED",
53    "CTRL_STROKE", "CTRL_STROKE2", "STRONG_STROKE", "SUBTLE", "SUBTLE_HOVER", "SUBTLE_PRESSED", "SUBTLE_DISABLED", "TEXT",
54    "TEXT2", "TEXT3", "TEXT_DISABLED", "ACCENT", "ACCENT_HOVER", "ACCENT_PRESSED", "ACCENT_DISABLED", "ON_ACCENT",
55    "ON_ACCENT_DIS", "ACCENT_TEXT", "FOCUS_OUT", "FOCUS_IN", "FLYOUT", "FLYOUT_STROKE", "SMOKE", "DIVIDER", "INPUT_ACTIVE",
56    "CRIT", "OK", "WARN", "INFO", "CRIT_BG", "OK_BG", "WARN_BG", "INFO_BG", "CLOSE_HOVER", "SHADOW", "SCROLL", "TRACK",
57    "KNOB", "ALT_FILL", "STRONG_FILL",
58];
59
60/// the TS_* text styles by name (theme files: body.size, title.weight)
61pub const TS_NAMES: [&str; TS_COUNT as usize] = ["caption", "body", "body_strong", "subtitle", "title", "title_large", "display"];
62
63const SECTIONS: [&str; 7] = ["theme", "colors", "light", "dark", "contrast", "metrics", "text"];
64const MODES: [&str; 3] = ["light", "dark", "contrast"];
65
66/// the T_* index of a token name (any case, "T_" in front allowed)
67pub fn token_index(name: &str) -> Option<usize> {
68    let n = name.strip_prefix("T_").or_else(|| name.strip_prefix("t_")).unwrap_or(name);
69    TOKEN_NAMES.iter().position(|t| t.eq_ignore_ascii_case(n))
70}
71
72/// rounded corners of a theme: every corner radius that is not a pill becomes this many DIP,
73/// or the style's radius scaled by this percentage
74#[derive(Clone, Copy, Debug, PartialEq, Eq)]
75#[non_exhaustive]
76pub enum Radius {
77    Dip(u16),
78    Percent(u16),
79}
80
81/// a text style of a theme: size in DIP (24.8), line height in DIP, weight 100..900; None =
82/// the style's (a size alone scales the style's line height with it)
83#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
84pub struct TextStyle {
85    pub size_fx: Option<u16>,
86    pub line: Option<u16>,
87    pub weight: Option<u16>,
88}
89
90/// an app theme (see the module header); Default = nothing overridden
91#[derive(Clone, Debug, PartialEq)]
92pub struct Theme {
93    pub name: String,
94    /// colour tokens per THEME_LIGHT / _DARK / _CONTRAST (index token / 4), 0xAARRGGBB
95    pub colors: [[Option<u32>; T_COUNT]; 3],
96    /// the accent per mode, 0xRRGGBB: the style derives its accent ramp from it
97    pub accent: [Option<u32>; 3],
98    pub radius: Option<Radius>,
99    /// box padding and gaps in percent (100: the app's values)
100    pub spacing: u16,
101    /// DIP; 0 = the style's
102    pub control_height: u16,
103    pub text: [TextStyle; TS_COUNT as usize],
104    /// "" = the style's faces; a font file (VFS path) or an installed family
105    pub family: String,
106    pub family_semibold: String,
107    pub family_bold: String,
108    /// axis values of a variable family (tag, user units); empty: its defaults
109    pub family_axes: Vec<([u8; 4], f32)>,
110}
111
112impl Default for Theme {
113    fn default() -> Theme {
114        Theme {
115            name: String::new(),
116            colors: [[None; T_COUNT]; 3],
117            accent: [None; 3],
118            radius: None,
119            spacing: 100,
120            control_height: 0,
121            text: [TextStyle::default(); TS_COUNT as usize],
122            family: String::new(),
123            family_semibold: String::new(),
124            family_bold: String::new(),
125            family_axes: Vec::new(),
126        }
127    }
128}
129
130/// why a theme could not be read or applied: the line of the file (0: not a line) and the
131/// message in the UI language ("Zeile 4: ...")
132#[derive(Clone, Debug, PartialEq, Eq)]
133pub struct ThemeError {
134    pub line: u32,
135    pub msg: String,
136}
137
138impl core::fmt::Display for ThemeError {
139    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
140        f.write_str(&self.msg)
141    }
142}
143
144impl ThemeError {
145    // a message of the library (tr) with its arguments; the line number comes first
146    fn at(line: usize, text: &'static str, args: &[&str]) -> ThemeError {
147        let n = format!("{}", line);
148        let mut all: Vec<&str> = Vec::with_capacity(args.len() + 1);
149        all.push(&n);
150        all.extend_from_slice(args);
151        ThemeError { line: line as u32, msg: fill(tr(text), &all) }
152    }
153
154    /// an error that belongs to no line (a file that cannot be read, a font)
155    pub(crate) fn general(text: &'static str, arg: &str) -> ThemeError {
156        ThemeError { line: 0, msg: fill(tr(text), &[arg]) }
157    }
158}
159
160const E_SECTION: &str = "Zeile {}: unbekannter Abschnitt [{}]";
161const E_KEY: &str = "Zeile {}: unbekannter Schlüssel „{}“ in [{}]";
162const E_TWICE: &str = "Zeile {}: „{}“ steht zweimal in [{}]";
163const E_LINE: &str = "Zeile {}: „Schlüssel = Wert“ erwartet";
164const E_OUTSIDE: &str = "Zeile {}: „{}“ steht vor dem ersten Abschnitt";
165const E_COLOR: &str = "Zeile {}: „{}“ ist keine Farbe (#RRGGBB oder #AARRGGBB)";
166const E_NUMBER: &str = "Zeile {}: „{}“ ist keine Zahl von {} bis {}";
167const E_WEIGHT: &str = "Zeile {}: „{}“ ist keine Schriftstärke (regular, semibold, bold, 100 bis 900)";
168const E_AXES: &str = "Zeile {}: „{}“ sind keine Achsenwerte (wght=650, wdth=87.5 …)";
169pub(crate) const E_READ: &str = "Theme-Datei nicht lesbar: {}";
170pub(crate) const E_FONT: &str = "Schrift nicht ladbar: {}";
171
172/// #RGB, #RRGGBB (opaque) or #AARRGGBB -> 0xAARRGGBB
173pub(crate) fn parse_color(v: &str) -> Option<u32> {
174    let h = v.strip_prefix('#')?;
175    if !h.bytes().all(|c| c.is_ascii_hexdigit()) {
176        return None;
177    }
178    let x = u32::from_str_radix(h, 16).ok()?;
179    match h.len() {
180        3 => {
181            let (r, g, b) = ((x >> 8) & 15, (x >> 4) & 15, x & 15);
182            Some(0xFF00_0000 | (r * 17) << 16 | (g * 17) << 8 | b * 17)
183        }
184        6 => Some(0xFF00_0000 | x),
185        8 => Some(x),
186        _ => None,
187    }
188}
189
190fn color_text(c: u32) -> String {
191    if c >> 24 == 0xFF { format!("#{:06X}", c & 0xFF_FFFF) } else { format!("#{:08X}", c) }
192}
193
194// a whole number lo..=hi (optionally with a trailing '%' when pct)
195fn parse_num(v: &str, lo: u32, hi: u32, pct: bool) -> Option<(u32, bool)> {
196    let (t, is_pct) = match v.strip_suffix('%') {
197        Some(t) if pct => (t.trim_end(), true),
198        _ => (v, false),
199    };
200    let n: u32 = t.parse().ok()?;
201    (lo..=hi).contains(&n).then_some((n, is_pct))
202}
203
204// a size in DIP with decimals -> 24.8
205fn parse_size(v: &str) -> Option<u16> {
206    let (x, used) = f64_parse(v.as_bytes());
207    if used == 0 || used != v.len() || !(4.0..=200.0).contains(&x) {
208        return None;
209    }
210    Some((x * 256.0 + 0.5) as u16)
211}
212
213fn parse_weight(v: &str) -> Option<u16> {
214    match v.to_ascii_lowercase().as_str() {
215        "regular" | "normal" => Some(400),
216        "medium" => Some(500),
217        "semibold" => Some(600),
218        "bold" => Some(700),
219        _ => v.parse::<u16>().ok().filter(|w| (100..=900).contains(w)),
220    }
221}
222
223// "wdth=87.5, opsz=12" (also "wdth 87.5", tags of 1..4 characters, quoted or not) ->
224// [(tag, value)]; None: not that form
225pub(crate) fn parse_axes(v: &str) -> Option<Vec<([u8; 4], f32)>> {
226    let mut out = Vec::new();
227    for item in v.split(',') {
228        let item = item.trim();
229        if item.is_empty() {
230            continue;
231        }
232        let (t, x) = item.split_once(['=', ' '])?;
233        let t = t.trim().trim_matches(['"', '\'']);
234        let x = x.trim();
235        let (val, used) = f64_parse(x.as_bytes());
236        if t.is_empty() || t.len() > 4 || !t.bytes().all(|c| (0x21..0x7F).contains(&c)) || used == 0 || used != x.len() {
237            return None;
238        }
239        let mut tag = [b' '; 4];
240        tag[..t.len()].copy_from_slice(t.as_bytes());
241        out.push((tag, val as f32));
242    }
243    (!out.is_empty()).then_some(out)
244}
245
246fn axes_text(axes: &[([u8; 4], f32)]) -> String {
247    let items: Vec<String> = axes
248        .iter()
249        .map(|(t, v)| format!("{}={}", String::from_utf8_lossy(t).trim_end(), base::num::f64_format(*v as f64)))
250        .collect();
251    items.join(", ")
252}
253
254fn weight_text(w: u16) -> String {
255    match w {
256        400 => String::from("regular"),
257        600 => String::from("semibold"),
258        700 => String::from("bold"),
259        _ => format!("{}", w),
260    }
261}
262
263fn size_text(fx: u16) -> String {
264    if fx & 255 == 0 { format!("{}", fx >> 8) } else { base::num::f64_format(fx as f64 / 256.0) }
265}
266
267impl Theme {
268    /// parse a theme file's text (module header); every error names its line
269    pub fn from_ini(text: &str) -> Result<Theme, ThemeError> {
270        let ini = Ini::parse(text.as_bytes());
271        let mut th = Theme::default();
272        let mut sec: Option<usize> = None; // index into SECTIONS
273        let mut seen: Vec<String> = Vec::new(); // the keys of the current section
274        for item in ini.items() {
275            match item {
276                Item::Section { line, name } => {
277                    let Some(i) = SECTIONS.iter().position(|s| s.eq_ignore_ascii_case(name)) else {
278                        return Err(ThemeError::at(line, E_SECTION, &[name]));
279                    };
280                    sec = Some(i);
281                    seen.clear();
282                }
283                Item::Bad { line, .. } => return Err(ThemeError::at(line, E_LINE, &[])),
284                Item::Key { line, key, value, .. } => {
285                    let Some(s) = sec else { return Err(ThemeError::at(line, E_OUTSIDE, &[key])) };
286                    let low = key.to_ascii_lowercase();
287                    if seen.contains(&low) {
288                        return Err(ThemeError::at(line, E_TWICE, &[key, SECTIONS[s]]));
289                    }
290                    seen.push(low);
291                    th.set_key(SECTIONS[s], key, value, line)?;
292                }
293            }
294        }
295        Ok(th)
296    }
297
298    // one key of a section
299    fn set_key(&mut self, sec: &str, key: &str, v: &str, line: usize) -> Result<(), ThemeError> {
300        let unknown = || ThemeError::at(line, E_KEY, &[key, sec]);
301        let num = |lo: u32, hi: u32| ThemeError::at(line, E_NUMBER, &[v, &format!("{}", lo), &format!("{}", hi)]);
302        match sec {
303            "theme" => {
304                if !key.eq_ignore_ascii_case("name") {
305                    return Err(unknown());
306                }
307                self.name = String::from(v);
308            }
309            "colors" | "light" | "dark" | "contrast" => {
310                let modes: &[usize] = match sec {
311                    "colors" => &[THEME_LIGHT, THEME_DARK],
312                    "light" => &[THEME_LIGHT],
313                    "dark" => &[THEME_DARK],
314                    _ => &[THEME_CONTRAST],
315                };
316                let accent = key.eq_ignore_ascii_case("accent");
317                let tok = if accent { None } else { Some(token_index(key).ok_or_else(unknown)?) };
318                let c = parse_color(v).ok_or_else(|| ThemeError::at(line, E_COLOR, &[v]))?;
319                for &m in modes {
320                    match tok {
321                        Some(t) => self.colors[m][t] = Some(c),
322                        None => self.accent[m] = Some(c & 0xFF_FFFF),
323                    }
324                }
325            }
326            "metrics" => match key.to_ascii_lowercase().as_str() {
327                "radius" => {
328                    self.radius = Some(match v.strip_suffix('%') {
329                        Some(_) => Radius::Percent(parse_num(v, 0, 400, true).ok_or_else(|| num(0, 400))?.0 as u16),
330                        None => Radius::Dip(parse_num(v, 0, 64, false).ok_or_else(|| num(0, 64))?.0 as u16),
331                    });
332                }
333                "spacing" => self.spacing = parse_num(v, 25, 400, true).ok_or_else(|| num(25, 400))?.0 as u16,
334                "control_height" => self.control_height = parse_num(v, 12, 96, false).ok_or_else(|| num(12, 96))?.0 as u16,
335                _ => return Err(unknown()),
336            },
337            _ => {
338                // [text]
339                let low = key.to_ascii_lowercase();
340                match low.as_str() {
341                    "family" => self.family = String::from(v),
342                    "family.semibold" => self.family_semibold = String::from(v),
343                    "family.bold" => self.family_bold = String::from(v),
344                    "family.axes" => self.family_axes = parse_axes(v).ok_or_else(|| ThemeError::at(line, E_AXES, &[v]))?,
345                    _ => {
346                        let (ts, what) = low.split_once('.').ok_or_else(unknown)?;
347                        let i = TS_NAMES.iter().position(|n| *n == ts).ok_or_else(unknown)?;
348                        let t = &mut self.text[i];
349                        match what {
350                            "size" => t.size_fx = Some(parse_size(v).ok_or_else(|| num(4, 200))?),
351                            "line" => t.line = Some(parse_num(v, 4, 300, false).ok_or_else(|| num(4, 300))?.0 as u16),
352                            "weight" => t.weight = Some(parse_weight(v).ok_or_else(|| ThemeError::at(line, E_WEIGHT, &[v]))?),
353                            _ => return Err(unknown()),
354                        }
355                    }
356                }
357            }
358        }
359        Ok(())
360    }
361
362    /// read and parse a theme file; relative font files then name files in its folder
363    pub fn load(path: &str) -> Result<Theme, ThemeError> {
364        let data = plat::read_file(path).ok_or_else(|| ThemeError::general(E_READ, path))?;
365        let text = core::str::from_utf8(&data).map_err(|_| ThemeError::general(E_READ, path))?;
366        let mut th = Theme::from_ini(text)?;
367        let dir = match path.rfind(['/', '\\']) {
368            Some(i) => &path[..i + 1],
369            None => "",
370        };
371        for f in [&mut th.family, &mut th.family_semibold, &mut th.family_bold] {
372            if !dir.is_empty() && is_file(f) && !is_absolute(f) {
373                *f = format!("{}{}", dir, f);
374            }
375        }
376        Ok(th)
377    }
378
379    /// the theme as the text of a theme file (only what it sets)
380    pub fn to_ini(&self) -> String {
381        String::from_utf8(self.ini().to_bytes()).unwrap_or_default()
382    }
383
384    /// write the theme file (missing folders are made) -> written
385    pub fn save(&self, path: &str) -> bool {
386        self.ini().save(path)
387    }
388
389    fn ini(&self) -> Ini {
390        let mut ini = Ini::parse(b"# JFLib app theme (docs/STYLES.md, \"App themes\")\n");
391        if !self.name.is_empty() {
392            ini.set("theme", "name", &self.name);
393        }
394        for (m, sec) in MODES.iter().enumerate() {
395            if let Some(a) = self.accent[m] {
396                ini.set(sec, "accent", &color_text(a | 0xFF00_0000));
397            }
398            for (i, c) in self.colors[m].iter().enumerate() {
399                if let Some(c) = c {
400                    ini.set(sec, &TOKEN_NAMES[i].to_ascii_lowercase(), &color_text(*c));
401                }
402            }
403        }
404        match self.radius {
405            Some(Radius::Dip(d)) => {
406                ini.set("metrics", "radius", &format!("{}", d));
407            }
408            Some(Radius::Percent(p)) => {
409                ini.set("metrics", "radius", &format!("{}%", p));
410            }
411            None => {}
412        }
413        if self.spacing != 100 {
414            ini.set("metrics", "spacing", &format!("{}%", self.spacing));
415        }
416        if self.control_height != 0 {
417            ini.set("metrics", "control_height", &format!("{}", self.control_height));
418        }
419        for (k, v) in [("family", &self.family), ("family.semibold", &self.family_semibold), ("family.bold", &self.family_bold)] {
420            if !v.is_empty() {
421                ini.set("text", k, v);
422            }
423        }
424        if !self.family_axes.is_empty() {
425            ini.set("text", "family.axes", &axes_text(&self.family_axes));
426        }
427        for (i, t) in self.text.iter().enumerate() {
428            if let Some(s) = t.size_fx {
429                ini.set("text", &format!("{}.size", TS_NAMES[i]), &size_text(s));
430            }
431            if let Some(l) = t.line {
432                ini.set("text", &format!("{}.line", TS_NAMES[i]), &format!("{}", l));
433            }
434            if let Some(w) = t.weight {
435                ini.set("text", &format!("{}.weight", TS_NAMES[i]), &weight_text(w));
436            }
437        }
438        ini
439    }
440
441    /// whether the theme changes text (a family or a text style)
442    pub(crate) fn has_text(&self) -> bool {
443        !self.family.is_empty() || self.text.iter().any(|t| *t != TextStyle::default())
444    }
445}
446
447/// a font file rather than an installed family: a scheme, a folder or a font extension
448pub(crate) fn is_file(spec: &str) -> bool {
449    let l = spec.to_ascii_lowercase();
450    l.contains("://") || l.contains('/') || l.contains('\\') || [".ttf", ".otf", ".ttc", ".otc"].iter().any(|e| l.ends_with(e))
451}
452
453fn is_absolute(f: &str) -> bool {
454    let b = f.as_bytes();
455    f.contains("://") || f.starts_with('/') || f.starts_with('\\') || (b.len() > 2 && b[1] == b':')
456}