| 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 | |
| 7 | use std::fs; |
| 8 | use std::path::{Path, PathBuf}; |
| 9 | use std::process::Command; |
| 10 | use std::time::SystemTime; |
| 11 | |
| 12 | use iced::font::Weight; |
| 13 | use iced::{Color, Font}; |
| 14 | use serde::Deserialize; |
| 15 | |
| 16 | /// The colors the UI uses, by role. |
| 17 | #[derive(Debug, Clone, Copy, PartialEq)] |
| 18 | pub 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. |
| 50 | pub 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")] |
| 70 | struct 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. |
| 87 | pub 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`. |
| 113 | fn 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`. |
| 129 | pub 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. |
| 135 | pub 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. |
| 159 | fn 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. |
| 169 | pub struct Source { |
| 170 | file: Option<PathBuf>, |
| 171 | stamp: Option<SystemTime>, |
| 172 | } |
| 173 | |
| 174 | impl 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)] |
| 207 | pub struct Look { |
| 208 | pub p: Palette, |
| 209 | pub font: Font, |
| 210 | } |
| 211 | |
| 212 | impl 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. |
| 230 | pub 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. |
| 246 | pub 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)] |
| 261 | mod 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 | } |