Krolyxon
Commit 2476af5

Branches

Refs →

budctlcratesbudctl-guisrctheme.rs

279 lines · 9.5 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 let config = std::env::var_os("XDG_CONFIG_HOME")
131 .map(PathBuf::from)
132 .filter(|p| p.is_absolute())
133 .or_else(|| std::env::var_os("HOME").map(|home| Path::new(&home).join(".config")))?;
134 Some(config.join("budctl/theme.json"))
135}
136
137/// The palette file and the version of it last read, so it is only read again when it changes.
138pub struct Source {
139 file: Option<PathBuf>,
140 stamp: Option<SystemTime>,
141}
142
143impl Source {
144 pub fn new(file: Option<PathBuf>) -> Self {
145 Source { file, stamp: None }
146 }
147
148 /// The palette file followed, if any.
149 pub fn file(&self) -> Option<&Path> {
150 self.file.as_deref()
151 }
152
153 /// The palette, if the file changed since the last call: built-in when the file is gone, an
154 /// error when it cannot be used. `None` when nothing changed.
155 pub fn refresh(&mut self) -> Option<Result<Palette, String>> {
156 let file = self.file.as_ref()?;
157 let stamp = fs::metadata(file).and_then(|m| m.modified()).ok();
158 if stamp == self.stamp {
159 return None;
160 }
161 self.stamp = stamp;
162 if stamp.is_none() {
163 return Some(Ok(BUILT_IN));
164 }
165 Some(
166 fs::read_to_string(file)
167 .map_err(|e| e.to_string())
168 .and_then(|json| parse(&json))
169 .map_err(|e| format!("{}: {e}", file.display())),
170 )
171 }
172}
173
174/// The palette and the fonts, handed to everything that draws.
175#[derive(Debug, Clone, Copy)]
176pub struct Look {
177 pub p: Palette,
178 pub font: Font,
179}
180
181impl Look {
182 pub fn medium(self) -> Font {
183 Font {
184 weight: Weight::Medium,
185 ..self.font
186 }
187 }
188
189 pub fn bold(self) -> Font {
190 Font {
191 weight: Weight::Bold,
192 ..self.font
193 }
194 }
195}
196
197/// The desktop's sans-serif font, as fontconfig names it. iced's own default is a font many
198/// systems lack.
199pub fn system_font() -> Font {
200 let family = Command::new("fc-match")
201 .args(["--format", "%{family[0]}", "sans-serif"])
202 .output()
203 .ok()
204 .filter(|out| out.status.success())
205 .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_owned())
206 .filter(|name| !name.is_empty());
207 match family {
208 // Font names are &'static; this runs once.
209 Some(name) => Font::with_name(Box::leak(name.into_boxed_str())),
210 None => Font::DEFAULT,
211 }
212}
213
214/// The iced theme for the widgets drawn with their default style.
215pub fn iced_theme(p: &Palette) -> iced::Theme {
216 iced::Theme::custom(
217 "budctl",
218 iced::theme::Palette {
219 background: p.bg,
220 text: p.fg,
221 primary: p.accent,
222 success: p.ok,
223 warning: p.accent,
224 danger: p.err,
225 },
226 )
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[test]
234 fn reads_the_aurora_keys() {
235 let palette = parse(
236 r##"{
237 "image": "/some/wallpaper.jpg",
238 "bg": "#0f0c0a", "fg": "#ebe0dc",
239 "primary": "#7b4b2f", "primaryFg": "#fffaff", "primaryContainer": "#5c3823",
240 "secondary": "#e2bfae",
241 "surfaceLow": "#181311", "surface": "#1e1815", "surfaceHigh": "#2a211d",
242 "surfaceHighest": "#362a26", "outline": "#9f8d85", "outlineVariant": "#52443d",
243 "surfaceVariantFg": "#d6c3b9", "err": "#ffb4ab"
244 }"##,
245 )
246 .unwrap();
247 assert_eq!(palette.bg, Color::from_rgb8(0x0f, 0x0c, 0x0a));
248 assert_eq!(palette.accent, Color::from_rgb8(0x7b, 0x4b, 0x2f));
249 assert_eq!(palette.accent_dim, Color::from_rgb8(0x5c, 0x38, 0x23));
250 assert_eq!(palette.icon, Color::from_rgb8(0xe2, 0xbf, 0xae));
251 assert_eq!(palette.line, Color::from_rgb8(0x52, 0x44, 0x3d));
252 assert_eq!(palette.dim, Color::from_rgb8(0xd6, 0xc3, 0xb9));
253 assert_eq!(palette.ok, BUILT_IN.ok);
254 }
255
256 #[test]
257 fn missing_keys_keep_the_built_in_colors() {
258 let palette = parse(r##"{"primary": "#112233cc"}"##).unwrap();
259 assert_eq!(palette.accent, Color::from_rgba8(0x11, 0x22, 0x33, 0.8));
260 assert_eq!(palette.bg, BUILT_IN.bg);
261 assert_eq!(parse("{}").unwrap(), BUILT_IN);
262 }
263
264 #[test]
265 fn bad_files_are_errors() {
266 assert!(parse("not json").is_err());
267 assert!(parse(r#"{"bg": "red"}"#).unwrap_err().contains("bg"));
268 assert!(parse(r##"{"bg": "#12345"}"##).is_err());
269 assert!(parse(r##"{"bg": "#ggggggg"}"##).is_err());
270 }
271
272 #[test]
273 fn a_missing_file_changes_nothing() {
274 let mut source = Source::new(Some(PathBuf::from("/nonexistent/budctl/theme.json")));
275 // No file now, and none read before: the built-in palette stays.
276 assert_eq!(source.refresh(), None);
277 assert_eq!(Source::new(None).refresh(), None);
278 }
279}