Krolyxon
Commit 99a16bb

Branches

Refs →

budctlcratesbudctl-guisrctheme.rs

266 lines · 8.9 KB · Rust

1//! Colors and fonts, matched to the owner's shell (Aurora): its matugen palette, JetBrains Mono, and
2//! its 12 px base size.
3//!
4//! The palette file uses Aurora's keys (`bg`, `fg`, `primary`…), so `~/.cache/matugen/aurora.json`
5//! works as it is, and the keys map to the same roles as in Aurora's `Theme.qml`. A key the file
6//! lacks keeps its built-in color.
7
8use std::fs;
9use std::path::PathBuf;
10use std::process::Command;
11use std::time::SystemTime;
12
13use iced::font::{Family, Weight};
14use iced::{Color, Font};
15use serde::Deserialize;
16
17/// The colors the UI uses, by role, named as in Aurora.
18#[derive(Debug, Clone, Copy, PartialEq)]
19pub struct Palette {
20 /// Window background, and text on `accent`.
21 pub bg: Color,
22 /// Text.
23 pub fg: Color,
24 /// Secondary text: hints, and the labels of bars and groups.
25 pub dim: Color,
26 /// What is on or chosen: filled buttons and switches, section labels, bars.
27 pub accent: Color,
28 /// The sidebar, and hovered setting rows.
29 pub surface: Color,
30 /// Hovered buttons and rows, switches when off, bar tracks.
31 pub surface_high: Color,
32 /// Borders and separators.
33 pub line: Color,
34 pub err: Color,
35}
36
37/// Near-black, with a red accent.
38pub const BUILT_IN: Palette = Palette {
39 bg: Color::from_rgb8(0x0D, 0x0D, 0x0F),
40 fg: Color::from_rgb8(0xE4, 0xE4, 0xE8),
41 dim: Color::from_rgb8(0xA5, 0xA5, 0xAE),
42 accent: Color::from_rgb8(0xD9, 0x3A, 0x3A),
43 surface: Color::from_rgb8(0x19, 0x19, 0x1C),
44 surface_high: Color::from_rgb8(0x24, 0x24, 0x28),
45 line: Color::from_rgb8(0x3B, 0x3B, 0x42),
46 err: Color::from_rgb8(0xFF, 0xB4, 0xAB),
47};
48
49/// The palette file's keys. Other keys (`image`, `secondary`…) are ignored.
50#[derive(Deserialize)]
51#[serde(rename_all = "camelCase")]
52struct PaletteFile {
53 bg: Option<String>,
54 fg: Option<String>,
55 primary: Option<String>,
56 surface: Option<String>,
57 surface_high: Option<String>,
58 outline_variant: Option<String>,
59 surface_variant_fg: Option<String>,
60 err: Option<String>,
61}
62
63/// A palette from the text of a palette file.
64pub fn parse(json: &str) -> Result<Palette, String> {
65 let file: PaletteFile = serde_json::from_str(json).map_err(|e| e.to_string())?;
66 let pick = |key: &str, value: Option<String>, default: Color| match value {
67 None => Ok(default),
68 Some(hex) => parse_hex(&hex).ok_or_else(|| format!("{key}: \"{hex}\" is not a #rrggbb color")),
69 };
70 let b = BUILT_IN;
71 Ok(Palette {
72 bg: pick("bg", file.bg, b.bg)?,
73 fg: pick("fg", file.fg, b.fg)?,
74 dim: pick("surfaceVariantFg", file.surface_variant_fg, b.dim)?,
75 accent: pick("primary", file.primary, b.accent)?,
76 surface: pick("surface", file.surface, b.surface)?,
77 surface_high: pick("surfaceHigh", file.surface_high, b.surface_high)?,
78 line: pick("outlineVariant", file.outline_variant, b.line)?,
79 err: pick("err", file.err, b.err)?,
80 })
81}
82
83/// `#rrggbb` or `#rrggbbaa`.
84fn parse_hex(hex: &str) -> Option<Color> {
85 let digits = hex.strip_prefix('#')?;
86 let byte = |at: usize| u8::from_str_radix(digits.get(at..at + 2)?, 16).ok();
87 match digits.len() {
88 6 => Some(Color::from_rgb8(byte(0)?, byte(2)?, byte(4)?)),
89 8 => Some(Color::from_rgba8(
90 byte(0)?,
91 byte(2)?,
92 byte(4)?,
93 f32::from(byte(6)?) / 255.0,
94 )),
95 _ => None,
96 }
97}
98
99/// The palette file to follow when `--theme` names none: `~/.config/budctl/theme.json`, else
100/// Aurora's own palette. When neither exists yet, the first, so creating it later takes effect.
101pub fn default_file() -> Option<PathBuf> {
102 let home = std::env::var_os("HOME").map(PathBuf::from)?;
103 let xdg = |var: &str, fallback: &str| {
104 std::env::var_os(var)
105 .map(PathBuf::from)
106 .filter(|p| p.is_absolute())
107 .unwrap_or_else(|| home.join(fallback))
108 };
109 let ours = xdg("XDG_CONFIG_HOME", ".config").join("budctl/theme.json");
110 let aurora = xdg("XDG_CACHE_HOME", ".cache").join("matugen/aurora.json");
111 Some(if !ours.exists() && aurora.exists() {
112 aurora
113 } else {
114 ours
115 })
116}
117
118/// The palette file and the version of it last read, so it is only read again when it changes.
119pub struct Source {
120 file: Option<PathBuf>,
121 stamp: Option<SystemTime>,
122}
123
124impl Source {
125 pub fn new(file: Option<PathBuf>) -> Self {
126 Source { file, stamp: None }
127 }
128
129 /// The palette, if the file changed since the last call: built-in when the file is gone, an
130 /// error when it cannot be used. `None` when nothing changed.
131 pub fn refresh(&mut self) -> Option<Result<Palette, String>> {
132 let file = self.file.as_ref()?;
133 let stamp = fs::metadata(file).and_then(|m| m.modified()).ok();
134 if stamp == self.stamp {
135 return None;
136 }
137 self.stamp = stamp;
138 if stamp.is_none() {
139 return Some(Ok(BUILT_IN));
140 }
141 Some(
142 fs::read_to_string(file)
143 .map_err(|e| e.to_string())
144 .and_then(|json| parse(&json))
145 .map_err(|e| format!("{}: {e}", file.display())),
146 )
147 }
148}
149
150/// The palette and the font, handed to everything that draws.
151#[derive(Debug, Clone, Copy)]
152pub struct Look {
153 pub p: Palette,
154 pub font: Font,
155 /// The base text size (Aurora's `fontSize`). Other sizes are relative to it.
156 pub size: f32,
157}
158
159impl Look {
160 pub fn new(p: Palette, font: Font) -> Self {
161 Look { p, font, size: 12.0 }
162 }
163
164 /// The base size plus `delta` pixels.
165 pub fn px(self, delta: f32) -> f32 {
166 self.size + delta
167 }
168
169 pub fn bold(self) -> Font {
170 Font {
171 weight: Weight::Bold,
172 ..self.font
173 }
174 }
175}
176
177/// Aurora's font. Its Nerd Font glyphs are the icons.
178const FONT: &str = "JetBrainsMono Nerd Font";
179
180/// [`FONT`] when it is installed, else the desktop's monospace font as fontconfig names it.
181pub fn font() -> Font {
182 let installed = Command::new("fc-list")
183 .args(["-q", FONT])
184 .status()
185 .is_ok_and(|status| status.success());
186 if installed {
187 return Font::with_name(FONT);
188 }
189 let family = Command::new("fc-match")
190 .args(["--format", "%{family[0]}", "monospace"])
191 .output()
192 .ok()
193 .filter(|out| out.status.success())
194 .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_owned())
195 .filter(|name| !name.is_empty());
196 Font {
197 // Font names are &'static; this runs once.
198 family: family.map_or(Family::Monospace, |name| Family::Name(Box::leak(name.into_boxed_str()))),
199 ..Font::DEFAULT
200 }
201}
202
203/// The iced theme, for what is drawn with iced's default styles (the scrollbar).
204pub fn iced_theme(p: &Palette) -> iced::Theme {
205 iced::Theme::custom(
206 "budctl",
207 iced::theme::Palette {
208 background: p.bg,
209 text: p.fg,
210 primary: p.accent,
211 success: p.accent,
212 warning: p.accent,
213 danger: p.err,
214 },
215 )
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221
222 #[test]
223 fn reads_the_aurora_keys() {
224 let palette = parse(
225 r##"{
226 "image": "/some/wallpaper.jpg",
227 "bg": "#0f0c0a", "fg": "#ebe0dc",
228 "primary": "#7b4b2f", "primaryFg": "#fffaff", "primaryContainer": "#5c3823",
229 "secondary": "#e2bfae",
230 "surfaceLow": "#181311", "surface": "#1e1815", "surfaceHigh": "#2a211d",
231 "surfaceHighest": "#362a26", "outline": "#9f8d85", "outlineVariant": "#52443d",
232 "surfaceVariantFg": "#d6c3b9", "err": "#ffb4ab"
233 }"##,
234 )
235 .unwrap();
236 assert_eq!(palette.bg, Color::from_rgb8(0x0f, 0x0c, 0x0a));
237 assert_eq!(palette.accent, Color::from_rgb8(0x7b, 0x4b, 0x2f));
238 assert_eq!(palette.surface_high, Color::from_rgb8(0x2a, 0x21, 0x1d));
239 assert_eq!(palette.line, Color::from_rgb8(0x52, 0x44, 0x3d));
240 assert_eq!(palette.dim, Color::from_rgb8(0xd6, 0xc3, 0xb9));
241 }
242
243 #[test]
244 fn missing_keys_keep_the_built_in_colors() {
245 let palette = parse(r##"{"primary": "#112233cc"}"##).unwrap();
246 assert_eq!(palette.accent, Color::from_rgba8(0x11, 0x22, 0x33, 0.8));
247 assert_eq!(palette.bg, BUILT_IN.bg);
248 assert_eq!(parse("{}").unwrap(), BUILT_IN);
249 }
250
251 #[test]
252 fn bad_files_are_errors() {
253 assert!(parse("not json").is_err());
254 assert!(parse(r#"{"bg": "red"}"#).unwrap_err().contains("bg"));
255 assert!(parse(r##"{"bg": "#12345"}"##).is_err());
256 assert!(parse(r##"{"bg": "#ggggggg"}"##).is_err());
257 }
258
259 #[test]
260 fn a_missing_file_changes_nothing() {
261 let mut source = Source::new(Some(PathBuf::from("/nonexistent/budctl/theme.json")));
262 // No file now, and none read before: the built-in palette stays.
263 assert_eq!(source.refresh(), None);
264 assert_eq!(Source::new(None).refresh(), None);
265 }
266}