| 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 | |
| 8 | use std::fs; |
| 9 | use std::path::PathBuf; |
| 10 | use std::process::Command; |
| 11 | use std::time::SystemTime; |
| 12 | |
| 13 | use iced::font::{Family, Weight}; |
| 14 | use iced::{Color, Font}; |
| 15 | use serde::Deserialize; |
| 16 | |
| 17 | /// The colors the UI uses, by role, named as in Aurora. |
| 18 | #[derive(Debug, Clone, Copy, PartialEq)] |
| 19 | pub 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. |
| 38 | pub 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")] |
| 52 | struct 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. |
| 64 | pub 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`. |
| 84 | fn 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. |
| 101 | pub 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. |
| 119 | pub struct Source { |
| 120 | file: Option<PathBuf>, |
| 121 | stamp: Option<SystemTime>, |
| 122 | } |
| 123 | |
| 124 | impl 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)] |
| 152 | pub 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 | |
| 159 | impl 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. |
| 178 | const FONT: &str = "JetBrainsMono Nerd Font"; |
| 179 | |
| 180 | /// [`FONT`] when it is installed, else the desktop's monospace font as fontconfig names it. |
| 181 | pub 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). |
| 204 | pub 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)] |
| 219 | mod 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 | } |