Krolyxon
Commit 3d0b2eb

Branches

Refs →

budctlcratesbudctl-guisrctheme.rs

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