Krolyxon
Commit 7b28e7d

Branches

Refs →

budctlcratesbudctl-guisrctheme.rs

320 lines · 10.8 KB · Rust

1//! Colors and fonts. The colors are a built-in dark palette, or one read from a matugen JSON file.
2//!
3//! The file uses the keys of the owner's Aurora palette (`bg`, `fg`, `primary`…), so
4//! `~/.cache/matugen/aurora.json` works as it is, and the keys map to the same roles as in Aurora's
5//! `Theme.qml`, plus `secondary` for icons. A key the file lacks keeps its built-in color.
6
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10use std::time::SystemTime;
11
12use iced::font::Weight;
13use iced::{Color, Font};
14use serde::Deserialize;
15
16/// The colors the UI uses, by role.
17#[derive(Debug, Clone, Copy, PartialEq)]
18pub struct Palette {
19 /// Window background.
20 pub bg: Color,
21 /// Text.
22 pub fg: Color,
23 /// Secondary text, and icons at rest.
24 pub dim: Color,
25 /// What is on or selected: switches, the noise-control pill, battery rings.
26 pub accent: Color,
27 /// Icons and small marks on cards and the sidebar. matugen's `primary` (Aurora's key) is a
28 /// container color, too dark to draw thin lines with on a dark card.
29 pub icon: Color,
30 /// Text and icons on `accent`.
31 pub accent_fg: Color,
32 /// A quiet accent: the open page in the sidebar.
33 pub accent_dim: Color,
34 /// The sidebar.
35 pub surface_low: Color,
36 /// Cards.
37 pub surface: Color,
38 /// Hovered rows, and switches when off.
39 pub surface_high: Color,
40 /// Battery ring tracks.
41 pub surface_highest: Color,
42 /// Card borders and separators.
43 pub line: Color,
44 pub err: Color,
45 /// "Connected". matugen has no such role, so it stays green.
46 pub ok: Color,
47}
48
49/// Dark, with a red accent.
50pub const BUILT_IN: Palette = Palette {
51 bg: Color::from_rgb8(0x12, 0x12, 0x14),
52 fg: Color::from_rgb8(0xEE, 0xEE, 0xF1),
53 dim: Color::from_rgb8(0x9C, 0x9C, 0xA6),
54 accent: Color::from_rgb8(0xE0, 0x30, 0x2E),
55 icon: Color::from_rgb8(0xE0, 0x30, 0x2E),
56 accent_fg: Color::from_rgb8(0xFF, 0xFF, 0xFF),
57 accent_dim: Color::from_rgb8(0x3A, 0x1A, 0x1C),
58 surface_low: Color::from_rgb8(0x17, 0x17, 0x1A),
59 surface: Color::from_rgb8(0x1E, 0x1E, 0x22),
60 surface_high: Color::from_rgb8(0x29, 0x29, 0x2E),
61 surface_highest: Color::from_rgb8(0x33, 0x33, 0x39),
62 line: Color::from_rgb8(0x2D, 0x2D, 0x33),
63 err: Color::from_rgb8(0xFF, 0xB4, 0xAB),
64 ok: Color::from_rgb8(0x4C, 0xC3, 0x6A),
65};
66
67/// The palette file's keys. Other keys (`image`, `secondary`…) are ignored.
68#[derive(Deserialize)]
69#[serde(rename_all = "camelCase")]
70struct PaletteFile {
71 bg: Option<String>,
72 fg: Option<String>,
73 primary: Option<String>,
74 primary_fg: Option<String>,
75 primary_container: Option<String>,
76 secondary: Option<String>,
77 surface_low: Option<String>,
78 surface: Option<String>,
79 surface_high: Option<String>,
80 surface_highest: Option<String>,
81 outline_variant: Option<String>,
82 surface_variant_fg: Option<String>,
83 err: Option<String>,
84}
85
86/// A palette from the text of a palette file.
87pub fn parse(json: &str) -> Result<Palette, String> {
88 let file: PaletteFile = serde_json::from_str(json).map_err(|e| e.to_string())?;
89 let pick = |key: &str, value: Option<String>, default: Color| match value {
90 None => Ok(default),
91 Some(hex) => parse_hex(&hex).ok_or_else(|| format!("{key}: \"{hex}\" is not a #rrggbb color")),
92 };
93 let b = BUILT_IN;
94 Ok(Palette {
95 bg: pick("bg", file.bg, b.bg)?,
96 fg: pick("fg", file.fg, b.fg)?,
97 dim: pick("surfaceVariantFg", file.surface_variant_fg, b.dim)?,
98 accent: pick("primary", file.primary, b.accent)?,
99 icon: pick("secondary", file.secondary, b.icon)?,
100 accent_fg: pick("primaryFg", file.primary_fg, b.accent_fg)?,
101 accent_dim: pick("primaryContainer", file.primary_container, b.accent_dim)?,
102 surface_low: pick("surfaceLow", file.surface_low, b.surface_low)?,
103 surface: pick("surface", file.surface, b.surface)?,
104 surface_high: pick("surfaceHigh", file.surface_high, b.surface_high)?,
105 surface_highest: pick("surfaceHighest", file.surface_highest, b.surface_highest)?,
106 line: pick("outlineVariant", file.outline_variant, b.line)?,
107 err: pick("err", file.err, b.err)?,
108 ok: b.ok,
109 })
110}
111
112/// `#rrggbb` or `#rrggbbaa`.
113fn parse_hex(hex: &str) -> Option<Color> {
114 let digits = hex.strip_prefix('#')?;
115 let byte = |at: usize| u8::from_str_radix(digits.get(at..at + 2)?, 16).ok();
116 match digits.len() {
117 6 => Some(Color::from_rgb8(byte(0)?, byte(2)?, byte(4)?)),
118 8 => Some(Color::from_rgba8(
119 byte(0)?,
120 byte(2)?,
121 byte(4)?,
122 f32::from(byte(6)?) / 255.0,
123 )),
124 _ => None,
125 }
126}
127
128/// The palette file to follow: `--theme <file>`, else `~/.config/budctl/theme.json`.
129pub fn default_file() -> Option<PathBuf> {
130 budctl_core::config::dir().map(|dir| dir.join("theme.json"))
131}
132
133/// A palette as a palette file, with every key [`parse`] reads: the built-in one is written to
134/// [`default_file`] when there is none, to start a matugen template from.
135pub fn to_json(p: &Palette) -> String {
136 let keys = [
137 ("bg", p.bg),
138 ("fg", p.fg),
139 ("primary", p.accent),
140 ("primaryFg", p.accent_fg),
141 ("primaryContainer", p.accent_dim),
142 ("secondary", p.icon),
143 ("surfaceLow", p.surface_low),
144 ("surface", p.surface),
145 ("surfaceHigh", p.surface_high),
146 ("surfaceHighest", p.surface_highest),
147 ("outlineVariant", p.line),
148 ("surfaceVariantFg", p.dim),
149 ("err", p.err),
150 ];
151 let lines: Vec<String> = keys
152 .iter()
153 .map(|(key, color)| format!(" \"{key}\": \"{}\"", to_hex(*color)))
154 .collect();
155 format!("{{\n{}\n}}\n", lines.join(",\n"))
156}
157
158/// `#rrggbb`, or `#rrggbbaa` when not opaque.
159fn to_hex(color: Color) -> String {
160 let [r, g, b, a] = color.into_rgba8();
161 if a == u8::MAX {
162 format!("#{r:02x}{g:02x}{b:02x}")
163 } else {
164 format!("#{r:02x}{g:02x}{b:02x}{a:02x}")
165 }
166}
167
168/// The palette file and the version of it last read, so it is only read again when it changes.
169pub struct Source {
170 file: Option<PathBuf>,
171 stamp: Option<SystemTime>,
172}
173
174impl Source {
175 pub fn new(file: Option<PathBuf>) -> Self {
176 Source { file, stamp: None }
177 }
178
179 /// The palette file followed, if any.
180 pub fn file(&self) -> Option<&Path> {
181 self.file.as_deref()
182 }
183
184 /// The palette, if the file changed since the last call: built-in when the file is gone, an
185 /// error when it cannot be used. `None` when nothing changed.
186 pub fn refresh(&mut self) -> Option<Result<Palette, String>> {
187 let file = self.file.as_ref()?;
188 let stamp = fs::metadata(file).and_then(|m| m.modified()).ok();
189 if stamp == self.stamp {
190 return None;
191 }
192 self.stamp = stamp;
193 if stamp.is_none() {
194 return Some(Ok(BUILT_IN));
195 }
196 Some(
197 fs::read_to_string(file)
198 .map_err(|e| e.to_string())
199 .and_then(|json| parse(&json))
200 .map_err(|e| format!("{}: {e}", file.display())),
201 )
202 }
203}
204
205/// The palette and the fonts, handed to everything that draws.
206#[derive(Debug, Clone, Copy)]
207pub struct Look {
208 pub p: Palette,
209 pub font: Font,
210}
211
212impl Look {
213 pub fn medium(self) -> Font {
214 Font {
215 weight: Weight::Medium,
216 ..self.font
217 }
218 }
219
220 pub fn bold(self) -> Font {
221 Font {
222 weight: Weight::Bold,
223 ..self.font
224 }
225 }
226}
227
228/// The desktop's sans-serif font, as fontconfig names it. iced's own default is a font many
229/// systems lack.
230pub fn system_font() -> Font {
231 let family = Command::new("fc-match")
232 .args(["--format", "%{family[0]}", "sans-serif"])
233 .output()
234 .ok()
235 .filter(|out| out.status.success())
236 .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_owned())
237 .filter(|name| !name.is_empty());
238 match family {
239 // Font names are &'static; this runs once.
240 Some(name) => Font::with_name(Box::leak(name.into_boxed_str())),
241 None => Font::DEFAULT,
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.accent,
253 success: p.ok,
254 warning: p.accent,
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 "bg": "#0f0c0a", "fg": "#ebe0dc",
270 "primary": "#7b4b2f", "primaryFg": "#fffaff", "primaryContainer": "#5c3823",
271 "secondary": "#e2bfae",
272 "surfaceLow": "#181311", "surface": "#1e1815", "surfaceHigh": "#2a211d",
273 "surfaceHighest": "#362a26", "outline": "#9f8d85", "outlineVariant": "#52443d",
274 "surfaceVariantFg": "#d6c3b9", "err": "#ffb4ab"
275 }"##,
276 )
277 .unwrap();
278 assert_eq!(palette.bg, Color::from_rgb8(0x0f, 0x0c, 0x0a));
279 assert_eq!(palette.accent, Color::from_rgb8(0x7b, 0x4b, 0x2f));
280 assert_eq!(palette.accent_dim, Color::from_rgb8(0x5c, 0x38, 0x23));
281 assert_eq!(palette.icon, Color::from_rgb8(0xe2, 0xbf, 0xae));
282 assert_eq!(palette.line, Color::from_rgb8(0x52, 0x44, 0x3d));
283 assert_eq!(palette.dim, Color::from_rgb8(0xd6, 0xc3, 0xb9));
284 assert_eq!(palette.ok, BUILT_IN.ok);
285 }
286
287 #[test]
288 fn missing_keys_keep_the_built_in_colors() {
289 let palette = parse(r##"{"primary": "#112233cc"}"##).unwrap();
290 assert_eq!(palette.accent, Color::from_rgba8(0x11, 0x22, 0x33, 0.8));
291 assert_eq!(palette.bg, BUILT_IN.bg);
292 assert_eq!(parse("{}").unwrap(), BUILT_IN);
293 }
294
295 #[test]
296 fn bad_files_are_errors() {
297 assert!(parse("not json").is_err());
298 assert!(parse(r#"{"bg": "red"}"#).unwrap_err().contains("bg"));
299 assert!(parse(r##"{"bg": "#12345"}"##).is_err());
300 assert!(parse(r##"{"bg": "#ggggggg"}"##).is_err());
301 }
302
303 #[test]
304 fn the_written_palette_reads_back_the_same() {
305 assert_eq!(parse(&to_json(&BUILT_IN)).unwrap(), BUILT_IN);
306 let see_through = Palette {
307 accent: Color::from_rgba8(0x11, 0x22, 0x33, 0.8),
308 ..BUILT_IN
309 };
310 assert_eq!(parse(&to_json(&see_through)).unwrap(), see_through);
311 }
312
313 #[test]
314 fn a_missing_file_changes_nothing() {
315 let mut source = Source::new(Some(PathBuf::from("/nonexistent/budctl/theme.json")));
316 // No file now, and none read before: the built-in palette stays.
317 assert_eq!(source.refresh(), None);
318 assert_eq!(Source::new(None).refresh(), None);
319 }
320}