Krolyxon
Commit 7cea7cb

Branches

Refs →

budctlcratesbudctl-guisrctheme.rs

393 lines · 13.6 KB · Rust

1//! Colors and fonts: Nothing OS's shapes and type, in Material You colors.
2//!
3//! The colors are a built-in palette (near-black, white "on" states, one red for live things), or
4//! one read from a matugen JSON file. The file uses the keys of the owner's Aurora palette
5//! (`~/.cache/matugen/aurora.json`: Material 3 roles, `on_x` spelled `xFg`), so that file works as
6//! it is, and the roles map as in Aurora's `Theme.qml`. A key the file lacks keeps its built-in
7//! color. The fonts ship inside the binary (`fonts/`, OFL).
8
9use std::collections::HashMap;
10use std::fs;
11use std::path::{Path, PathBuf};
12use std::time::SystemTime;
13
14use iced::font::{Family, Weight};
15use iced::{Color, Font};
16
17/// The colors the UI uses, by role: a ladder of surfaces, and the "on" states on top.
18#[derive(Debug, Clone, Copy, PartialEq)]
19pub struct Palette {
20 /// Window background (`surfaceLowest`).
21 pub bg: Color,
22 /// Cards and dialogs (`surfaceLow`).
23 pub s1: Color,
24 /// Icon circles, tracks, inputs, the switches' and pills' "off" fill (`surfaceHigh`).
25 pub s2: Color,
26 /// Unlit dots, hovered rows (`surfaceHighest`).
27 pub s3: Color,
28 /// Inactive things: the pages not open, an off switch's knob (`outlineVariant`).
29 pub mute: Color,
30 /// Caps labels (`outline`).
31 pub grey: Color,
32 /// Text (`surfaceFg`).
33 pub fg: Color,
34 /// Secondary text (`surfaceVariantFg`).
35 pub fg_dim: Color,
36 /// What is on or selected: switches, the chosen segment, lit dots, the cursor (`primary`).
37 pub fill: Color,
38 /// Text and icons on `fill` (`primaryFg`).
39 pub fill_fg: Color,
40 /// Tonal emphasis: text selection (`primaryContainer`).
41 pub container: Color,
42 pub container_fg: Color,
43 /// Live things, one at a time: the open page's dot, "Saving…", ringing, charging (`tertiary`).
44 pub live: Color,
45 pub live_fg: Color,
46 pub err: Color,
47 pub err_fg: Color,
48}
49
50/// Near-black surfaces, white "on" states and a red for live things.
51pub const BUILT_IN: Palette = Palette {
52 bg: Color::from_rgb8(0x00, 0x00, 0x00),
53 s1: Color::from_rgb8(0x11, 0x11, 0x12),
54 s2: Color::from_rgb8(0x1C, 0x1C, 0x1E),
55 s3: Color::from_rgb8(0x2A, 0x2A, 0x2D),
56 mute: Color::from_rgb8(0x45, 0x45, 0x49),
57 grey: Color::from_rgb8(0x8E, 0x8E, 0x93),
58 fg: Color::from_rgb8(0xF2, 0xF2, 0xF2),
59 fg_dim: Color::from_rgb8(0xB8, 0xB8, 0xBC),
60 fill: Color::from_rgb8(0xF2, 0xF2, 0xF2),
61 fill_fg: Color::from_rgb8(0x00, 0x00, 0x00),
62 container: Color::from_rgb8(0x3A, 0x3A, 0x3E),
63 container_fg: Color::from_rgb8(0xF2, 0xF2, 0xF2),
64 live: Color::from_rgb8(0xD7, 0x19, 0x21),
65 live_fg: Color::from_rgb8(0xFF, 0xFF, 0xFF),
66 err: Color::from_rgb8(0xFF, 0xB4, 0xAB),
67 err_fg: Color::from_rgb8(0x69, 0x00, 0x05),
68};
69
70/// Where in the palette a key's color goes.
71type Role = fn(&mut Palette) -> &mut Color;
72
73/// The palette file's keys, with each role's key and where it goes. `bg` and `fg` are the older
74/// names of `surfaceLowest` and `surfaceFg`, still read. Other keys (`image`, `secondary`…) are
75/// ignored.
76const KEYS: [(&str, Option<&str>, Role); 16] = [
77 ("surfaceLowest", Some("bg"), |p| &mut p.bg),
78 ("surfaceLow", None, |p| &mut p.s1),
79 ("surfaceHigh", None, |p| &mut p.s2),
80 ("surfaceHighest", None, |p| &mut p.s3),
81 ("outlineVariant", None, |p| &mut p.mute),
82 ("outline", None, |p| &mut p.grey),
83 ("surfaceFg", Some("fg"), |p| &mut p.fg),
84 ("surfaceVariantFg", None, |p| &mut p.fg_dim),
85 ("primary", None, |p| &mut p.fill),
86 ("primaryFg", None, |p| &mut p.fill_fg),
87 ("primaryContainer", None, |p| &mut p.container),
88 ("primaryContainerFg", None, |p| &mut p.container_fg),
89 ("tertiary", None, |p| &mut p.live),
90 ("tertiaryFg", None, |p| &mut p.live_fg),
91 ("err", None, |p| &mut p.err),
92 ("errFg", None, |p| &mut p.err_fg),
93];
94
95/// A palette from the text of a palette file.
96pub fn parse(json: &str) -> Result<Palette, String> {
97 let file: HashMap<String, serde_json::Value> = serde_json::from_str(json).map_err(|e| e.to_string())?;
98 let mut palette = BUILT_IN;
99 for (key, old, role) in KEYS {
100 let found = [Some(key), old]
101 .into_iter()
102 .flatten()
103 .find_map(|name| file.get(name).map(|value| (name, value)));
104 let Some((name, value)) = found else {
105 continue;
106 };
107 let color = value
108 .as_str()
109 .and_then(parse_hex)
110 .ok_or_else(|| format!("{name}: {value} is not a #rrggbb color"))?;
111 *role(&mut palette) = color;
112 }
113 Ok(palette)
114}
115
116/// `#rrggbb` or `#rrggbbaa`.
117fn parse_hex(hex: &str) -> Option<Color> {
118 let digits = hex.strip_prefix('#')?;
119 let byte = |at: usize| u8::from_str_radix(digits.get(at..at + 2)?, 16).ok();
120 match digits.len() {
121 6 => Some(Color::from_rgb8(byte(0)?, byte(2)?, byte(4)?)),
122 8 => Some(Color::from_rgba8(
123 byte(0)?,
124 byte(2)?,
125 byte(4)?,
126 f32::from(byte(6)?) / 255.0,
127 )),
128 _ => None,
129 }
130}
131
132/// The palette file to follow: `--theme <file>`, else `~/.config/budctl/theme.json`.
133pub fn default_file() -> Option<PathBuf> {
134 budctl_core::config::dir().map(|dir| dir.join("theme.json"))
135}
136
137/// A palette as a palette file, with every key [`parse`] reads: the built-in one is written to
138/// [`default_file`] when there is none, to start a matugen template from.
139pub fn to_json(p: &Palette) -> String {
140 let mut p = *p;
141 let lines: Vec<String> = KEYS
142 .iter()
143 .map(|(key, _, role)| format!(" \"{key}\": \"{}\"", to_hex(*role(&mut p))))
144 .collect();
145 format!("{{\n{}\n}}\n", lines.join(",\n"))
146}
147
148/// `#rrggbb`, or `#rrggbbaa` when not opaque.
149fn to_hex(color: Color) -> String {
150 let [r, g, b, a] = color.into_rgba8();
151 if a == u8::MAX {
152 format!("#{r:02x}{g:02x}{b:02x}")
153 } else {
154 format!("#{r:02x}{g:02x}{b:02x}{a:02x}")
155 }
156}
157
158/// The palette file and the version of it last read, so it is only read again when it changes.
159pub struct Source {
160 file: Option<PathBuf>,
161 stamp: Option<SystemTime>,
162}
163
164impl Source {
165 pub fn new(file: Option<PathBuf>) -> Self {
166 Source { file, stamp: None }
167 }
168
169 /// The palette file followed, if any.
170 pub fn file(&self) -> Option<&Path> {
171 self.file.as_deref()
172 }
173
174 /// The palette, if the file changed since the last call: built-in when the file is gone, an
175 /// error when it cannot be used. `None` when nothing changed.
176 pub fn refresh(&mut self) -> Option<Result<Palette, String>> {
177 let file = self.file.as_ref()?;
178 let stamp = fs::metadata(file).and_then(|m| m.modified()).ok();
179 if stamp == self.stamp {
180 return None;
181 }
182 self.stamp = stamp;
183 if stamp.is_none() {
184 return Some(Ok(BUILT_IN));
185 }
186 Some(
187 fs::read_to_string(file)
188 .map_err(|e| e.to_string())
189 .and_then(|json| parse(&json))
190 .map_err(|e| format!("{}: {e}", file.display())),
191 )
192 }
193}
194
195/// The fonts the binary carries, to load at start.
196pub const FONTS: [&[u8]; 5] = [
197 include_bytes!("../fonts/SpaceGrotesk-Regular.otf"),
198 include_bytes!("../fonts/SpaceGrotesk-Medium.otf"),
199 include_bytes!("../fonts/SpaceGrotesk-SemiBold.otf"),
200 include_bytes!("../fonts/SpaceMono-Regular.ttf"),
201 include_bytes!("../fonts/DotoRounded-Black.ttf"),
202];
203
204/// UI text: titles semibold, body regular.
205pub const UI: Font = Font::with_name("Space Grotesk");
206/// Caps labels.
207const MONO: Font = Font::with_name("Space Mono");
208/// Numbers and short words, as round LED dots. Never under 26 px.
209const DOT: Font = Font {
210 family: Family::Name("Doto Rounded"),
211 weight: Weight::Black,
212 ..Font::DEFAULT
213};
214
215/// The palette and the fonts, handed to everything that draws.
216#[derive(Debug, Clone, Copy)]
217pub struct Look {
218 pub p: Palette,
219 /// Whether things move: off, every change shows at once (`gui.motion`, and the tests).
220 pub motion: bool,
221}
222
223impl Look {
224 /// The look of something fading in or out, `t` of the way visible (0 to 1).
225 ///
226 /// Surfaces (the dimming behind a dialog, cards, circles) come in twice as fast as what is
227 /// on them, so a card is solid by halfway and its words fade in on it, never through to
228 /// what is under it. Going out, the words go first, then the card.
229 pub fn fade(self, t: f32) -> Look {
230 let t = t.clamp(0.0, 1.0);
231 if t >= 1.0 {
232 return self;
233 }
234 let surface = (t * 2.0).min(1.0);
235 let s = |c: Color| Color { a: c.a * surface, ..c };
236 let f = |c: Color| Color { a: c.a * t, ..c };
237 let p = self.p;
238 Look {
239 p: Palette {
240 bg: s(p.bg),
241 s1: s(p.s1),
242 s2: s(p.s2),
243 s3: s(p.s3),
244 mute: f(p.mute),
245 grey: f(p.grey),
246 fg: f(p.fg),
247 fg_dim: f(p.fg_dim),
248 fill: f(p.fill),
249 fill_fg: f(p.fill_fg),
250 container: s(p.container),
251 container_fg: f(p.container_fg),
252 live: f(p.live),
253 live_fg: f(p.live_fg),
254 err: f(p.err),
255 err_fg: f(p.err_fg),
256 },
257 ..self
258 }
259 }
260
261 pub fn medium(self) -> Font {
262 Font {
263 weight: Weight::Medium,
264 ..UI
265 }
266 }
267
268 pub fn semibold(self) -> Font {
269 Font {
270 weight: Weight::Semibold,
271 ..UI
272 }
273 }
274
275 pub fn mono(self) -> Font {
276 MONO
277 }
278
279 pub fn dot(self) -> Font {
280 DOT
281 }
282}
283
284/// The iced theme for the widgets drawn with their default style.
285pub fn iced_theme(p: &Palette) -> iced::Theme {
286 iced::Theme::custom(
287 "budctl",
288 iced::theme::Palette {
289 background: p.bg,
290 text: p.fg,
291 primary: p.fill,
292 success: p.fill,
293 warning: p.live,
294 danger: p.err,
295 },
296 )
297}
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 #[test]
304 fn fading_only_makes_colors_clearer() {
305 let look = Look {
306 p: BUILT_IN,
307 motion: true,
308 };
309 assert_eq!(look.fade(1.0).p, BUILT_IN);
310 let half = look.fade(0.5).p;
311 assert_eq!(half.fg, Color { a: 0.5, ..BUILT_IN.fg });
312 assert_eq!(
313 half.live,
314 Color {
315 a: 0.5,
316 ..BUILT_IN.live
317 }
318 );
319 assert_eq!(half.s1, BUILT_IN.s1, "a card is solid by halfway");
320 assert_eq!(look.fade(0.25).p.s1.a, 0.5);
321 assert_eq!(look.fade(-1.0).p.s1.a, 0.0);
322 }
323
324 #[test]
325 fn reads_the_aurora_keys() {
326 let palette = parse(
327 r##"{
328 "image": "/some/wallpaper.jpg",
329 "primary": "#d7c2be", "primaryFg": "#3b2d2b", "primaryContainer": "#52433f",
330 "primaryContainerFg": "#f4ddd9", "secondary": "#d0c4c2",
331 "tertiary": "#e0c38c", "tertiaryFg": "#3f2e04",
332 "surfaceLowest": "#0f0e0e", "surfaceLow": "#1d1b1b", "surfaceContainer": "#211f1f",
333 "surfaceHigh": "#2c2929", "surfaceHighest": "#373434", "surfaceFg": "#e7e1e0",
334 "surfaceVariantFg": "#d2c3c1", "outline": "#9b8e8c", "outlineVariant": "#4e4443",
335 "err": "#ffb4ab", "errFg": "#690005"
336 }"##,
337 )
338 .unwrap();
339 assert_eq!(palette.bg, Color::from_rgb8(0x0f, 0x0e, 0x0e));
340 assert_eq!(palette.s1, Color::from_rgb8(0x1d, 0x1b, 0x1b));
341 assert_eq!(palette.s3, Color::from_rgb8(0x37, 0x34, 0x34));
342 assert_eq!(palette.fill, Color::from_rgb8(0xd7, 0xc2, 0xbe));
343 assert_eq!(palette.live, Color::from_rgb8(0xe0, 0xc3, 0x8c));
344 assert_eq!(palette.grey, Color::from_rgb8(0x9b, 0x8e, 0x8c));
345 assert_eq!(palette.mute, Color::from_rgb8(0x4e, 0x44, 0x43));
346 assert_eq!(palette.err_fg, Color::from_rgb8(0x69, 0x00, 0x05));
347 }
348
349 #[test]
350 fn reads_the_older_names() {
351 let palette = parse(r##"{"bg": "#0f0e0e", "fg": "#e7e1e0", "surface": "#211f1f"}"##).unwrap();
352 assert_eq!(palette.bg, Color::from_rgb8(0x0f, 0x0e, 0x0e));
353 assert_eq!(palette.fg, Color::from_rgb8(0xe7, 0xe1, 0xe0));
354 // The newer name wins.
355 let both = parse(r##"{"bg": "#000001", "surfaceLowest": "#000002"}"##).unwrap();
356 assert_eq!(both.bg, Color::from_rgb8(0, 0, 2));
357 }
358
359 #[test]
360 fn missing_keys_keep_the_built_in_colors() {
361 let palette = parse(r##"{"primary": "#112233cc"}"##).unwrap();
362 assert_eq!(palette.fill, Color::from_rgba8(0x11, 0x22, 0x33, 0.8));
363 assert_eq!(palette.bg, BUILT_IN.bg);
364 assert_eq!(parse("{}").unwrap(), BUILT_IN);
365 }
366
367 #[test]
368 fn bad_files_are_errors() {
369 assert!(parse("not json").is_err());
370 assert!(parse(r#"{"bg": "red"}"#).unwrap_err().contains("bg"));
371 assert!(parse(r#"{"primary": 3}"#).unwrap_err().contains("primary"));
372 assert!(parse(r##"{"bg": "#12345"}"##).is_err());
373 assert!(parse(r##"{"bg": "#ggggggg"}"##).is_err());
374 }
375
376 #[test]
377 fn the_written_palette_reads_back_the_same() {
378 assert_eq!(parse(&to_json(&BUILT_IN)).unwrap(), BUILT_IN);
379 let see_through = Palette {
380 fill: Color::from_rgba8(0x11, 0x22, 0x33, 0.8),
381 ..BUILT_IN
382 };
383 assert_eq!(parse(&to_json(&see_through)).unwrap(), see_through);
384 }
385
386 #[test]
387 fn a_missing_file_changes_nothing() {
388 let mut source = Source::new(Some(PathBuf::from("/nonexistent/budctl/theme.json")));
389 // No file now, and none read before: the built-in palette stays.
390 assert_eq!(source.refresh(), None);
391 assert_eq!(Source::new(None).refresh(), None);
392 }
393}