Krolyxon
Commit 93bfdf6

Branches

Refs →

budctlcratesbudctl-guisrctheme.rs

333 lines · 11.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}
220
221impl Look {
222 pub fn medium(self) -> Font {
223 Font {
224 weight: Weight::Medium,
225 ..UI
226 }
227 }
228
229 pub fn semibold(self) -> Font {
230 Font {
231 weight: Weight::Semibold,
232 ..UI
233 }
234 }
235
236 pub fn mono(self) -> Font {
237 MONO
238 }
239
240 pub fn dot(self) -> Font {
241 DOT
242 }
243}
244
245/// The iced theme for the widgets drawn with their default style.
246pub fn iced_theme(p: &Palette) -> iced::Theme {
247 iced::Theme::custom(
248 "budctl",
249 iced::theme::Palette {
250 background: p.bg,
251 text: p.fg,
252 primary: p.fill,
253 success: p.fill,
254 warning: p.live,
255 danger: p.err,
256 },
257 )
258}
259
260#[cfg(test)]
261mod tests {
262 use super::*;
263
264 #[test]
265 fn reads_the_aurora_keys() {
266 let palette = parse(
267 r##"{
268 "image": "/some/wallpaper.jpg",
269 "primary": "#d7c2be", "primaryFg": "#3b2d2b", "primaryContainer": "#52433f",
270 "primaryContainerFg": "#f4ddd9", "secondary": "#d0c4c2",
271 "tertiary": "#e0c38c", "tertiaryFg": "#3f2e04",
272 "surfaceLowest": "#0f0e0e", "surfaceLow": "#1d1b1b", "surfaceContainer": "#211f1f",
273 "surfaceHigh": "#2c2929", "surfaceHighest": "#373434", "surfaceFg": "#e7e1e0",
274 "surfaceVariantFg": "#d2c3c1", "outline": "#9b8e8c", "outlineVariant": "#4e4443",
275 "err": "#ffb4ab", "errFg": "#690005"
276 }"##,
277 )
278 .unwrap();
279 assert_eq!(palette.bg, Color::from_rgb8(0x0f, 0x0e, 0x0e));
280 assert_eq!(palette.s1, Color::from_rgb8(0x1d, 0x1b, 0x1b));
281 assert_eq!(palette.s3, Color::from_rgb8(0x37, 0x34, 0x34));
282 assert_eq!(palette.fill, Color::from_rgb8(0xd7, 0xc2, 0xbe));
283 assert_eq!(palette.live, Color::from_rgb8(0xe0, 0xc3, 0x8c));
284 assert_eq!(palette.grey, Color::from_rgb8(0x9b, 0x8e, 0x8c));
285 assert_eq!(palette.mute, Color::from_rgb8(0x4e, 0x44, 0x43));
286 assert_eq!(palette.err_fg, Color::from_rgb8(0x69, 0x00, 0x05));
287 }
288
289 #[test]
290 fn reads_the_older_names() {
291 let palette = parse(r##"{"bg": "#0f0e0e", "fg": "#e7e1e0", "surface": "#211f1f"}"##).unwrap();
292 assert_eq!(palette.bg, Color::from_rgb8(0x0f, 0x0e, 0x0e));
293 assert_eq!(palette.fg, Color::from_rgb8(0xe7, 0xe1, 0xe0));
294 // The newer name wins.
295 let both = parse(r##"{"bg": "#000001", "surfaceLowest": "#000002"}"##).unwrap();
296 assert_eq!(both.bg, Color::from_rgb8(0, 0, 2));
297 }
298
299 #[test]
300 fn missing_keys_keep_the_built_in_colors() {
301 let palette = parse(r##"{"primary": "#112233cc"}"##).unwrap();
302 assert_eq!(palette.fill, Color::from_rgba8(0x11, 0x22, 0x33, 0.8));
303 assert_eq!(palette.bg, BUILT_IN.bg);
304 assert_eq!(parse("{}").unwrap(), BUILT_IN);
305 }
306
307 #[test]
308 fn bad_files_are_errors() {
309 assert!(parse("not json").is_err());
310 assert!(parse(r#"{"bg": "red"}"#).unwrap_err().contains("bg"));
311 assert!(parse(r#"{"primary": 3}"#).unwrap_err().contains("primary"));
312 assert!(parse(r##"{"bg": "#12345"}"##).is_err());
313 assert!(parse(r##"{"bg": "#ggggggg"}"##).is_err());
314 }
315
316 #[test]
317 fn the_written_palette_reads_back_the_same() {
318 assert_eq!(parse(&to_json(&BUILT_IN)).unwrap(), BUILT_IN);
319 let see_through = Palette {
320 fill: Color::from_rgba8(0x11, 0x22, 0x33, 0.8),
321 ..BUILT_IN
322 };
323 assert_eq!(parse(&to_json(&see_through)).unwrap(), see_through);
324 }
325
326 #[test]
327 fn a_missing_file_changes_nothing() {
328 let mut source = Source::new(Some(PathBuf::from("/nonexistent/budctl/theme.json")));
329 // No file now, and none read before: the built-in palette stays.
330 assert_eq!(source.refresh(), None);
331 assert_eq!(Source::new(None).refresh(), None);
332 }
333}