| 1 | //! Colors and fonts: Nothing OS's shapes and type, in Material You colors. |
| 2 | //! |
| 3 | //! The colors are a built-in palette (near-black, white "on" states, one red for live things), or |
| 4 | //! one read from a matugen JSON file. The file uses the keys of the owner's Aurora palette |
| 5 | //! (`~/.cache/matugen/aurora.json`: Material 3 roles, `on_x` spelled `xFg`), so that file works as |
| 6 | //! it is, and the roles map as in Aurora's `Theme.qml`. A key the file lacks keeps its built-in |
| 7 | //! color. The fonts ship inside the binary (`fonts/`, OFL). |
| 8 | |
| 9 | use std::collections::HashMap; |
| 10 | use std::fs; |
| 11 | use std::path::{Path, PathBuf}; |
| 12 | use std::time::SystemTime; |
| 13 | |
| 14 | use iced::font::{Family, Weight}; |
| 15 | use iced::{Color, Font}; |
| 16 | |
| 17 | /// The colors the UI uses, by role: a ladder of surfaces, and the "on" states on top. |
| 18 | #[derive(Debug, Clone, Copy, PartialEq)] |
| 19 | pub struct Palette { |
| 20 | /// Window background (`surfaceLowest`). |
| 21 | pub bg: Color, |
| 22 | /// Cards and dialogs (`surfaceLow`). |
| 23 | pub s1: Color, |
| 24 | /// Icon circles, tracks, inputs, the switches' and pills' "off" fill (`surfaceHigh`). |
| 25 | pub s2: Color, |
| 26 | /// Unlit dots, hovered rows (`surfaceHighest`). |
| 27 | pub s3: Color, |
| 28 | /// Inactive things: the pages not open, an off switch's knob (`outlineVariant`). |
| 29 | pub mute: Color, |
| 30 | /// Caps labels (`outline`). |
| 31 | pub grey: Color, |
| 32 | /// Text (`surfaceFg`). |
| 33 | pub fg: Color, |
| 34 | /// Secondary text (`surfaceVariantFg`). |
| 35 | pub fg_dim: Color, |
| 36 | /// What is on or selected: switches, the chosen segment, lit dots, the cursor (`primary`). |
| 37 | pub fill: Color, |
| 38 | /// Text and icons on `fill` (`primaryFg`). |
| 39 | pub fill_fg: Color, |
| 40 | /// Tonal emphasis: text selection (`primaryContainer`). |
| 41 | pub container: Color, |
| 42 | pub container_fg: Color, |
| 43 | /// Live things, one at a time: the open page's dot, "Saving…", ringing, charging (`tertiary`). |
| 44 | pub live: Color, |
| 45 | pub live_fg: Color, |
| 46 | pub err: Color, |
| 47 | pub err_fg: Color, |
| 48 | } |
| 49 | |
| 50 | /// Near-black surfaces, white "on" states and a red for live things. |
| 51 | pub const BUILT_IN: Palette = Palette { |
| 52 | bg: Color::from_rgb8(0x00, 0x00, 0x00), |
| 53 | s1: Color::from_rgb8(0x11, 0x11, 0x12), |
| 54 | s2: Color::from_rgb8(0x1C, 0x1C, 0x1E), |
| 55 | s3: Color::from_rgb8(0x2A, 0x2A, 0x2D), |
| 56 | mute: Color::from_rgb8(0x45, 0x45, 0x49), |
| 57 | grey: Color::from_rgb8(0x8E, 0x8E, 0x93), |
| 58 | fg: Color::from_rgb8(0xF2, 0xF2, 0xF2), |
| 59 | fg_dim: Color::from_rgb8(0xB8, 0xB8, 0xBC), |
| 60 | fill: Color::from_rgb8(0xF2, 0xF2, 0xF2), |
| 61 | fill_fg: Color::from_rgb8(0x00, 0x00, 0x00), |
| 62 | container: Color::from_rgb8(0x3A, 0x3A, 0x3E), |
| 63 | container_fg: Color::from_rgb8(0xF2, 0xF2, 0xF2), |
| 64 | live: Color::from_rgb8(0xD7, 0x19, 0x21), |
| 65 | live_fg: Color::from_rgb8(0xFF, 0xFF, 0xFF), |
| 66 | err: Color::from_rgb8(0xFF, 0xB4, 0xAB), |
| 67 | err_fg: Color::from_rgb8(0x69, 0x00, 0x05), |
| 68 | }; |
| 69 | |
| 70 | /// Where in the palette a key's color goes. |
| 71 | type Role = fn(&mut Palette) -> &mut Color; |
| 72 | |
| 73 | /// The palette file's keys, with each role's key and where it goes. `bg` and `fg` are the older |
| 74 | /// names of `surfaceLowest` and `surfaceFg`, still read. Other keys (`image`, `secondary`…) are |
| 75 | /// ignored. |
| 76 | const KEYS: [(&str, Option<&str>, Role); 16] = [ |
| 77 | ("surfaceLowest", Some("bg"), |p| &mut p.bg), |
| 78 | ("surfaceLow", None, |p| &mut p.s1), |
| 79 | ("surfaceHigh", None, |p| &mut p.s2), |
| 80 | ("surfaceHighest", None, |p| &mut p.s3), |
| 81 | ("outlineVariant", None, |p| &mut p.mute), |
| 82 | ("outline", None, |p| &mut p.grey), |
| 83 | ("surfaceFg", Some("fg"), |p| &mut p.fg), |
| 84 | ("surfaceVariantFg", None, |p| &mut p.fg_dim), |
| 85 | ("primary", None, |p| &mut p.fill), |
| 86 | ("primaryFg", None, |p| &mut p.fill_fg), |
| 87 | ("primaryContainer", None, |p| &mut p.container), |
| 88 | ("primaryContainerFg", None, |p| &mut p.container_fg), |
| 89 | ("tertiary", None, |p| &mut p.live), |
| 90 | ("tertiaryFg", None, |p| &mut p.live_fg), |
| 91 | ("err", None, |p| &mut p.err), |
| 92 | ("errFg", None, |p| &mut p.err_fg), |
| 93 | ]; |
| 94 | |
| 95 | /// A palette from the text of a palette file. |
| 96 | pub fn parse(json: &str) -> Result<Palette, String> { |
| 97 | let file: HashMap<String, serde_json::Value> = serde_json::from_str(json).map_err(|e| e.to_string())?; |
| 98 | let mut palette = BUILT_IN; |
| 99 | for (key, old, role) in KEYS { |
| 100 | let found = [Some(key), old] |
| 101 | .into_iter() |
| 102 | .flatten() |
| 103 | .find_map(|name| file.get(name).map(|value| (name, value))); |
| 104 | let Some((name, value)) = found else { |
| 105 | continue; |
| 106 | }; |
| 107 | let color = value |
| 108 | .as_str() |
| 109 | .and_then(parse_hex) |
| 110 | .ok_or_else(|| format!("{name}: {value} is not a #rrggbb color"))?; |
| 111 | *role(&mut palette) = color; |
| 112 | } |
| 113 | Ok(palette) |
| 114 | } |
| 115 | |
| 116 | /// `#rrggbb` or `#rrggbbaa`. |
| 117 | fn parse_hex(hex: &str) -> Option<Color> { |
| 118 | let digits = hex.strip_prefix('#')?; |
| 119 | let byte = |at: usize| u8::from_str_radix(digits.get(at..at + 2)?, 16).ok(); |
| 120 | match digits.len() { |
| 121 | 6 => Some(Color::from_rgb8(byte(0)?, byte(2)?, byte(4)?)), |
| 122 | 8 => Some(Color::from_rgba8( |
| 123 | byte(0)?, |
| 124 | byte(2)?, |
| 125 | byte(4)?, |
| 126 | f32::from(byte(6)?) / 255.0, |
| 127 | )), |
| 128 | _ => None, |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | /// The palette file to follow: `--theme <file>`, else `~/.config/budctl/theme.json`. |
| 133 | pub fn default_file() -> Option<PathBuf> { |
| 134 | budctl_core::config::dir().map(|dir| dir.join("theme.json")) |
| 135 | } |
| 136 | |
| 137 | /// A palette as a palette file, with every key [`parse`] reads: the built-in one is written to |
| 138 | /// [`default_file`] when there is none, to start a matugen template from. |
| 139 | pub fn to_json(p: &Palette) -> String { |
| 140 | let mut p = *p; |
| 141 | let lines: Vec<String> = KEYS |
| 142 | .iter() |
| 143 | .map(|(key, _, role)| format!(" \"{key}\": \"{}\"", to_hex(*role(&mut p)))) |
| 144 | .collect(); |
| 145 | format!("{{\n{}\n}}\n", lines.join(",\n")) |
| 146 | } |
| 147 | |
| 148 | /// `#rrggbb`, or `#rrggbbaa` when not opaque. |
| 149 | fn to_hex(color: Color) -> String { |
| 150 | let [r, g, b, a] = color.into_rgba8(); |
| 151 | if a == u8::MAX { |
| 152 | format!("#{r:02x}{g:02x}{b:02x}") |
| 153 | } else { |
| 154 | format!("#{r:02x}{g:02x}{b:02x}{a:02x}") |
| 155 | } |
| 156 | } |
| 157 | |
| 158 | /// The palette file and the version of it last read, so it is only read again when it changes. |
| 159 | pub struct Source { |
| 160 | file: Option<PathBuf>, |
| 161 | stamp: Option<SystemTime>, |
| 162 | } |
| 163 | |
| 164 | impl Source { |
| 165 | pub fn new(file: Option<PathBuf>) -> Self { |
| 166 | Source { file, stamp: None } |
| 167 | } |
| 168 | |
| 169 | /// The palette file followed, if any. |
| 170 | pub fn file(&self) -> Option<&Path> { |
| 171 | self.file.as_deref() |
| 172 | } |
| 173 | |
| 174 | /// The palette, if the file changed since the last call: built-in when the file is gone, an |
| 175 | /// error when it cannot be used. `None` when nothing changed. |
| 176 | pub fn refresh(&mut self) -> Option<Result<Palette, String>> { |
| 177 | let file = self.file.as_ref()?; |
| 178 | let stamp = fs::metadata(file).and_then(|m| m.modified()).ok(); |
| 179 | if stamp == self.stamp { |
| 180 | return None; |
| 181 | } |
| 182 | self.stamp = stamp; |
| 183 | if stamp.is_none() { |
| 184 | return Some(Ok(BUILT_IN)); |
| 185 | } |
| 186 | Some( |
| 187 | fs::read_to_string(file) |
| 188 | .map_err(|e| e.to_string()) |
| 189 | .and_then(|json| parse(&json)) |
| 190 | .map_err(|e| format!("{}: {e}", file.display())), |
| 191 | ) |
| 192 | } |
| 193 | } |
| 194 | |
| 195 | /// The fonts the binary carries, to load at start. |
| 196 | pub const FONTS: [&[u8]; 5] = [ |
| 197 | include_bytes!("../fonts/SpaceGrotesk-Regular.otf"), |
| 198 | include_bytes!("../fonts/SpaceGrotesk-Medium.otf"), |
| 199 | include_bytes!("../fonts/SpaceGrotesk-SemiBold.otf"), |
| 200 | include_bytes!("../fonts/SpaceMono-Regular.ttf"), |
| 201 | include_bytes!("../fonts/DotoRounded-Black.ttf"), |
| 202 | ]; |
| 203 | |
| 204 | /// UI text: titles semibold, body regular. |
| 205 | pub const UI: Font = Font::with_name("Space Grotesk"); |
| 206 | /// Caps labels. |
| 207 | const MONO: Font = Font::with_name("Space Mono"); |
| 208 | /// Numbers and short words, as round LED dots. Never under 26 px. |
| 209 | const DOT: Font = Font { |
| 210 | family: Family::Name("Doto Rounded"), |
| 211 | weight: Weight::Black, |
| 212 | ..Font::DEFAULT |
| 213 | }; |
| 214 | |
| 215 | /// The palette and the fonts, handed to everything that draws. |
| 216 | #[derive(Debug, Clone, Copy)] |
| 217 | pub struct Look { |
| 218 | pub p: Palette, |
| 219 | /// Whether things move: off, every change shows at once (`gui.motion`, and the tests). |
| 220 | pub motion: bool, |
| 221 | } |
| 222 | |
| 223 | impl Look { |
| 224 | /// The look of something fading in or out, `t` of the way visible (0 to 1). |
| 225 | /// |
| 226 | /// Surfaces (the dimming behind a dialog, cards, circles) come in twice as fast as what is |
| 227 | /// on them, so a card is solid by halfway and its words fade in on it, never through to |
| 228 | /// what is under it. Going out, the words go first, then the card. |
| 229 | pub fn fade(self, t: f32) -> Look { |
| 230 | let t = t.clamp(0.0, 1.0); |
| 231 | if t >= 1.0 { |
| 232 | return self; |
| 233 | } |
| 234 | let surface = (t * 2.0).min(1.0); |
| 235 | let s = |c: Color| Color { a: c.a * surface, ..c }; |
| 236 | let f = |c: Color| Color { a: c.a * t, ..c }; |
| 237 | let p = self.p; |
| 238 | Look { |
| 239 | p: Palette { |
| 240 | bg: s(p.bg), |
| 241 | s1: s(p.s1), |
| 242 | s2: s(p.s2), |
| 243 | s3: s(p.s3), |
| 244 | mute: f(p.mute), |
| 245 | grey: f(p.grey), |
| 246 | fg: f(p.fg), |
| 247 | fg_dim: f(p.fg_dim), |
| 248 | fill: f(p.fill), |
| 249 | fill_fg: f(p.fill_fg), |
| 250 | container: s(p.container), |
| 251 | container_fg: f(p.container_fg), |
| 252 | live: f(p.live), |
| 253 | live_fg: f(p.live_fg), |
| 254 | err: f(p.err), |
| 255 | err_fg: f(p.err_fg), |
| 256 | }, |
| 257 | ..self |
| 258 | } |
| 259 | } |
| 260 | |
| 261 | pub fn medium(self) -> Font { |
| 262 | Font { |
| 263 | weight: Weight::Medium, |
| 264 | ..UI |
| 265 | } |
| 266 | } |
| 267 | |
| 268 | pub fn semibold(self) -> Font { |
| 269 | Font { |
| 270 | weight: Weight::Semibold, |
| 271 | ..UI |
| 272 | } |
| 273 | } |
| 274 | |
| 275 | pub fn mono(self) -> Font { |
| 276 | MONO |
| 277 | } |
| 278 | |
| 279 | pub fn dot(self) -> Font { |
| 280 | DOT |
| 281 | } |
| 282 | } |
| 283 | |
| 284 | /// The iced theme for the widgets drawn with their default style. |
| 285 | pub fn iced_theme(p: &Palette) -> iced::Theme { |
| 286 | iced::Theme::custom( |
| 287 | "budctl", |
| 288 | iced::theme::Palette { |
| 289 | background: p.bg, |
| 290 | text: p.fg, |
| 291 | primary: p.fill, |
| 292 | success: p.fill, |
| 293 | warning: p.live, |
| 294 | danger: p.err, |
| 295 | }, |
| 296 | ) |
| 297 | } |
| 298 | |
| 299 | #[cfg(test)] |
| 300 | mod tests { |
| 301 | use super::*; |
| 302 | |
| 303 | #[test] |
| 304 | fn fading_only_makes_colors_clearer() { |
| 305 | let look = Look { |
| 306 | p: BUILT_IN, |
| 307 | motion: true, |
| 308 | }; |
| 309 | assert_eq!(look.fade(1.0).p, BUILT_IN); |
| 310 | let half = look.fade(0.5).p; |
| 311 | assert_eq!(half.fg, Color { a: 0.5, ..BUILT_IN.fg }); |
| 312 | assert_eq!( |
| 313 | half.live, |
| 314 | Color { |
| 315 | a: 0.5, |
| 316 | ..BUILT_IN.live |
| 317 | } |
| 318 | ); |
| 319 | assert_eq!(half.s1, BUILT_IN.s1, "a card is solid by halfway"); |
| 320 | assert_eq!(look.fade(0.25).p.s1.a, 0.5); |
| 321 | assert_eq!(look.fade(-1.0).p.s1.a, 0.0); |
| 322 | } |
| 323 | |
| 324 | #[test] |
| 325 | fn reads_the_aurora_keys() { |
| 326 | let palette = parse( |
| 327 | r##"{ |
| 328 | "image": "/some/wallpaper.jpg", |
| 329 | "primary": "#d7c2be", "primaryFg": "#3b2d2b", "primaryContainer": "#52433f", |
| 330 | "primaryContainerFg": "#f4ddd9", "secondary": "#d0c4c2", |
| 331 | "tertiary": "#e0c38c", "tertiaryFg": "#3f2e04", |
| 332 | "surfaceLowest": "#0f0e0e", "surfaceLow": "#1d1b1b", "surfaceContainer": "#211f1f", |
| 333 | "surfaceHigh": "#2c2929", "surfaceHighest": "#373434", "surfaceFg": "#e7e1e0", |
| 334 | "surfaceVariantFg": "#d2c3c1", "outline": "#9b8e8c", "outlineVariant": "#4e4443", |
| 335 | "err": "#ffb4ab", "errFg": "#690005" |
| 336 | }"##, |
| 337 | ) |
| 338 | .unwrap(); |
| 339 | assert_eq!(palette.bg, Color::from_rgb8(0x0f, 0x0e, 0x0e)); |
| 340 | assert_eq!(palette.s1, Color::from_rgb8(0x1d, 0x1b, 0x1b)); |
| 341 | assert_eq!(palette.s3, Color::from_rgb8(0x37, 0x34, 0x34)); |
| 342 | assert_eq!(palette.fill, Color::from_rgb8(0xd7, 0xc2, 0xbe)); |
| 343 | assert_eq!(palette.live, Color::from_rgb8(0xe0, 0xc3, 0x8c)); |
| 344 | assert_eq!(palette.grey, Color::from_rgb8(0x9b, 0x8e, 0x8c)); |
| 345 | assert_eq!(palette.mute, Color::from_rgb8(0x4e, 0x44, 0x43)); |
| 346 | assert_eq!(palette.err_fg, Color::from_rgb8(0x69, 0x00, 0x05)); |
| 347 | } |
| 348 | |
| 349 | #[test] |
| 350 | fn reads_the_older_names() { |
| 351 | let palette = parse(r##"{"bg": "#0f0e0e", "fg": "#e7e1e0", "surface": "#211f1f"}"##).unwrap(); |
| 352 | assert_eq!(palette.bg, Color::from_rgb8(0x0f, 0x0e, 0x0e)); |
| 353 | assert_eq!(palette.fg, Color::from_rgb8(0xe7, 0xe1, 0xe0)); |
| 354 | // The newer name wins. |
| 355 | let both = parse(r##"{"bg": "#000001", "surfaceLowest": "#000002"}"##).unwrap(); |
| 356 | assert_eq!(both.bg, Color::from_rgb8(0, 0, 2)); |
| 357 | } |
| 358 | |
| 359 | #[test] |
| 360 | fn missing_keys_keep_the_built_in_colors() { |
| 361 | let palette = parse(r##"{"primary": "#112233cc"}"##).unwrap(); |
| 362 | assert_eq!(palette.fill, Color::from_rgba8(0x11, 0x22, 0x33, 0.8)); |
| 363 | assert_eq!(palette.bg, BUILT_IN.bg); |
| 364 | assert_eq!(parse("{}").unwrap(), BUILT_IN); |
| 365 | } |
| 366 | |
| 367 | #[test] |
| 368 | fn bad_files_are_errors() { |
| 369 | assert!(parse("not json").is_err()); |
| 370 | assert!(parse(r#"{"bg": "red"}"#).unwrap_err().contains("bg")); |
| 371 | assert!(parse(r#"{"primary": 3}"#).unwrap_err().contains("primary")); |
| 372 | assert!(parse(r##"{"bg": "#12345"}"##).is_err()); |
| 373 | assert!(parse(r##"{"bg": "#ggggggg"}"##).is_err()); |
| 374 | } |
| 375 | |
| 376 | #[test] |
| 377 | fn the_written_palette_reads_back_the_same() { |
| 378 | assert_eq!(parse(&to_json(&BUILT_IN)).unwrap(), BUILT_IN); |
| 379 | let see_through = Palette { |
| 380 | fill: Color::from_rgba8(0x11, 0x22, 0x33, 0.8), |
| 381 | ..BUILT_IN |
| 382 | }; |
| 383 | assert_eq!(parse(&to_json(&see_through)).unwrap(), see_through); |
| 384 | } |
| 385 | |
| 386 | #[test] |
| 387 | fn a_missing_file_changes_nothing() { |
| 388 | let mut source = Source::new(Some(PathBuf::from("/nonexistent/budctl/theme.json"))); |
| 389 | // No file now, and none read before: the built-in palette stays. |
| 390 | assert_eq!(source.refresh(), None); |
| 391 | assert_eq!(Source::new(None).refresh(), None); |
| 392 | } |
| 393 | } |