1use 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
42pub const THEME_LIGHT: usize = 0;
44pub const THEME_DARK: usize = 1;
46pub const THEME_CONTRAST: usize = 2;
48
49pub 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
60pub 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
66pub 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
75#[non_exhaustive]
76pub enum Radius {
77 Dip(u16),
78 Percent(u16),
79}
80
81#[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#[derive(Clone, Debug, PartialEq)]
92pub struct Theme {
93 pub name: String,
94 pub colors: [[Option<u32>; T_COUNT]; 3],
96 pub accent: [Option<u32>; 3],
98 pub radius: Option<Radius>,
99 pub spacing: u16,
101 pub control_height: u16,
103 pub text: [TextStyle; TS_COUNT as usize],
104 pub family: String,
106 pub family_semibold: String,
107 pub family_bold: String,
108 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#[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 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 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
172pub(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
194fn 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
204fn 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
223pub(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 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; let mut seen: Vec<String> = Vec::new(); 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 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 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 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 pub fn to_ini(&self) -> String {
381 String::from_utf8(self.ini().to_bytes()).unwrap_or_default()
382 }
383
384 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 pub(crate) fn has_text(&self) -> bool {
443 !self.family.is_empty() || self.text.iter().any(|t| *t != TextStyle::default())
444 }
445}
446
447pub(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}