| 1 | use draw::{hsl, hue, srgb}; |
| 2 | |
| 3 | /// Colours are linear RGBA; sizes are logical pixels. |
| 4 | #[derive(Clone, Debug)] |
| 5 | pub struct Theme { |
| 6 | pub font_size: f32, |
| 7 | /// Content surfaces, such as fields and lists. |
| 8 | pub base: [f32; 4], |
| 9 | /// Side panels. |
| 10 | pub panel: [f32; 4], |
| 11 | /// The window's title bar, toolbar and tab row; transparent where the system's backdrop |
| 12 | /// shows through them. |
| 13 | pub strip: [f32; 4], |
| 14 | /// The notebook sidebar: the strip's colour, unless kept opaque over a backdrop. |
| 15 | pub sidebar: [f32; 4], |
| 16 | /// Toolbar buttons and their groups at rest: clear, or a face where the backdrop would |
| 17 | /// hide their icons. |
| 18 | pub tool: [f32; 4], |
| 19 | /// Gathers the toolbar's groups on one `tool` face the length of the row, ruled between |
| 20 | /// groups, rather than a face per group. |
| 21 | pub tool_panel: bool, |
| 22 | pub accent: [f32; 4], |
| 23 | /// The text caret, and selected text's fill with and without keyboard focus; the |
| 24 | /// platform's own where it has them. |
| 25 | pub caret: [f32; 4], |
| 26 | pub selection: [f32; 4], |
| 27 | pub inactive_selection: [f32; 4], |
| 28 | pub text: [f32; 4], |
| 29 | pub text_dim: [f32; 4], |
| 30 | /// Text on section colours. |
| 31 | pub ink: [f32; 4], |
| 32 | /// The page's colour, which the open page's tab shares. |
| 33 | pub paper: [f32; 4], |
| 34 | /// Text on the paper, and on the page wherever its colour is left automatic. |
| 35 | pub paper_ink: [f32; 4], |
| 36 | /// Key caps, small controls and field borders. |
| 37 | pub chip: [f32; 4], |
| 38 | /// Menus and other popups, and the shadow they cast. |
| 39 | pub popup: [f32; 4], |
| 40 | pub shadow: [f32; 4], |
| 41 | /// Saturation and lightness each of a section's colours takes from its hue. |
| 42 | pub shades: Shades, |
| 43 | /// Menus as the desktop draws its own, where Snowbound follows it; see `Theme::menu`. |
| 44 | pub desktop_menu: Option<Menu>, |
| 45 | } |
| 46 | |
| 47 | /// How menus and the other popups look and move. Colours are linear RGBA; sizes are |
| 48 | /// logical pixels. |
| 49 | #[derive(Clone, Debug, PartialEq)] |
| 50 | pub struct Menu { |
| 51 | pub fill: [f32; 4], |
| 52 | pub border: [f32; 4], |
| 53 | /// The layers of the shadow a popup casts, painted in order. |
| 54 | pub shadows: Vec<Shadow>, |
| 55 | pub text: [f32; 4], |
| 56 | /// Shortcuts and headings. |
| 57 | pub dim: [f32; 4], |
| 58 | pub disabled: [f32; 4], |
| 59 | /// The row the pointer or keyboard is on, and its outline. |
| 60 | pub highlight: [f32; 4], |
| 61 | pub highlight_border: Option<[f32; 4]>, |
| 62 | /// The line between groups of rows. |
| 63 | pub rule: [f32; 4], |
| 64 | pub font_size: f32, |
| 65 | pub radius: f32, |
| 66 | /// Inset of the popup's contents. |
| 67 | pub pad: f32, |
| 68 | pub row: f32, |
| 69 | pub row_radius: f32, |
| 70 | /// Inset of a row's icon and text inside its highlight. |
| 71 | pub row_pad: f32, |
| 72 | /// Height of the band between groups, and how far the rule across it stays from the |
| 73 | /// rows' ends. |
| 74 | pub rule_band: f32, |
| 75 | pub rule_inset: f32, |
| 76 | pub motion: PopupMotion, |
| 77 | } |
| 78 | |
| 79 | /// A soft shadow blurred `blur` wide, `drop` below its box and grown `spread` past it, as |
| 80 | /// CSS's `box-shadow` takes them. |
| 81 | #[derive(Clone, Copy, Debug, PartialEq)] |
| 82 | pub struct Shadow { |
| 83 | pub color: [f32; 4], |
| 84 | pub blur: f32, |
| 85 | pub drop: f32, |
| 86 | pub spread: f32, |
| 87 | } |
| 88 | |
| 89 | /// How popups other than dialogs open and close. |
| 90 | #[derive(Clone, Copy, Debug, PartialEq)] |
| 91 | pub enum PopupMotion { |
| 92 | /// Growing out of what they open from as they fade, Snowbound's own. |
| 93 | Grow, |
| 94 | /// At once, as GTK 4 shows its popovers. |
| 95 | Cut, |
| 96 | /// Opacity alone: in linearly over the first seconds, out over the second easing as |
| 97 | /// OutQuart, as KWin's Fading Popups. |
| 98 | Fade([f32; 2]), |
| 99 | } |
| 100 | |
| 101 | /// The platform's caret and selection colours in a light or `dark` appearance. |
| 102 | fn text_colors(dark: bool) -> [[f32; 4]; 3] { |
| 103 | draw::edit::Platform::CURRENT |
| 104 | .text_colors(dark) |
| 105 | .map(|([red, green, blue], alpha)| { |
| 106 | let [red, green, blue, _] = srgb(red, green, blue); |
| 107 | [red, green, blue, alpha] |
| 108 | }) |
| 109 | } |
| 110 | |
| 111 | /// Saturation and lightness pairs, each from 0 to 1. |
| 112 | #[derive(Clone, Copy, Debug)] |
| 113 | pub struct Shades { |
| 114 | /// The frame's top and bottom; the bottom turns 10° further round the hue. |
| 115 | pub frame: [[f32; 2]; 2], |
| 116 | pub tab: [f32; 2], |
| 117 | pub edge: [f32; 2], |
| 118 | pub accent: [f32; 2], |
| 119 | } |
| 120 | |
| 121 | /// A section's colours in a theme. |
| 122 | #[derive(Clone, Copy, Debug)] |
| 123 | pub struct Section { |
| 124 | /// Top and bottom of the frame around the page, which the open tab shares. |
| 125 | pub frame: [[f32; 4]; 2], |
| 126 | /// Tabs not open. |
| 127 | pub tab: [f32; 4], |
| 128 | /// Borders around the open tab and the page. |
| 129 | pub edge: [f32; 4], |
| 130 | pub accent: [f32; 4], |
| 131 | } |
| 132 | |
| 133 | impl Section { |
| 134 | /// A tab under the pointer: most of the way to the accent, which lies past the frame's |
| 135 | /// lightness from the tabs in both themes, so it stands out from both. |
| 136 | pub fn hover(&self) -> [f32; 4] { |
| 137 | crate::mix(self.tab, self.accent, 0.7) |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | impl Theme { |
| 142 | /// After File Pilot's default dark scheme. |
| 143 | pub fn dark() -> Self { |
| 144 | let [caret, selection, inactive_selection] = text_colors(true); |
| 145 | Self { |
| 146 | font_size: 13.0, |
| 147 | base: srgb(0x19, 0x1b, 0x1c), |
| 148 | panel: srgb(0x1f, 0x22, 0x23), |
| 149 | strip: srgb(0x27, 0x2a, 0x2b), |
| 150 | sidebar: srgb(0x27, 0x2a, 0x2b), |
| 151 | tool: [0.0; 4], |
| 152 | tool_panel: false, |
| 153 | accent: srgb(0x00, 0x79, 0xa6), |
| 154 | caret, |
| 155 | selection, |
| 156 | inactive_selection, |
| 157 | text: srgb(0xdd, 0xde, 0xe0), |
| 158 | text_dim: srgb(0x6b, 0x70, 0x78), |
| 159 | ink: srgb(0xe8, 0xe9, 0xeb), |
| 160 | paper: srgb(0x1f, 0x20, 0x22), |
| 161 | paper_ink: srgb(0xe6, 0xe6, 0xe6), |
| 162 | chip: srgb(0x38, 0x3c, 0x3d), |
| 163 | popup: srgb(0x2b, 0x2e, 0x30), |
| 164 | shadow: [0.0, 0.0, 0.0, 0.6], |
| 165 | shades: Shades { |
| 166 | frame: [[0.30, 0.36], [0.30, 0.30]], |
| 167 | tab: [0.22, 0.25], |
| 168 | edge: [0.30, 0.16], |
| 169 | accent: [0.55, 0.55], |
| 170 | }, |
| 171 | desktop_menu: None, |
| 172 | } |
| 173 | } |
| 174 | |
| 175 | pub fn light() -> Self { |
| 176 | let [caret, selection, inactive_selection] = text_colors(false); |
| 177 | Self { |
| 178 | font_size: 13.0, |
| 179 | base: srgb(0xfc, 0xfc, 0xfd), |
| 180 | panel: srgb(0xf4, 0xf5, 0xf7), |
| 181 | strip: srgb(0xeb, 0xed, 0xf0), |
| 182 | sidebar: srgb(0xeb, 0xed, 0xf0), |
| 183 | tool: [0.0; 4], |
| 184 | tool_panel: false, |
| 185 | accent: srgb(0x00, 0x79, 0xa6), |
| 186 | caret, |
| 187 | selection, |
| 188 | inactive_selection, |
| 189 | text: srgb(0x1f, 0x23, 0x28), |
| 190 | text_dim: srgb(0x7b, 0x82, 0x8c), |
| 191 | ink: srgb(0x1d, 0x1e, 0x20), |
| 192 | paper: [1.0; 4], |
| 193 | paper_ink: [0.0, 0.0, 0.0, 1.0], |
| 194 | chip: srgb(0xcf, 0xd3, 0xd9), |
| 195 | popup: [1.0; 4], |
| 196 | shadow: [0.0, 0.0, 0.0, 0.3], |
| 197 | shades: Shades { |
| 198 | frame: [[0.55, 0.82], [0.55, 0.76]], |
| 199 | tab: [0.45, 0.70], |
| 200 | edge: [0.30, 0.52], |
| 201 | accent: draw::LIGHT_ACCENT, |
| 202 | }, |
| 203 | desktop_menu: None, |
| 204 | } |
| 205 | } |
| 206 | |
| 207 | /// The theme over a system material that shows through the strip: dim text, controls |
| 208 | /// and fields take the text colour at part opacity, as vibrancy draws them. |
| 209 | pub fn over_backdrop(self) -> Self { |
| 210 | let text = |alpha| [self.text[0], self.text[1], self.text[2], alpha]; |
| 211 | Self { |
| 212 | strip: [0.0; 4], |
| 213 | sidebar: [0.0; 4], |
| 214 | text_dim: text(0.5), |
| 215 | chip: text(0.1), |
| 216 | base: text(0.05), |
| 217 | ..self |
| 218 | } |
| 219 | } |
| 220 | |
| 221 | /// Menus as the desktop draws them, or Snowbound's own. |
| 222 | pub fn menu(&self) -> Menu { |
| 223 | use crate::popup::{MENU_ROW, PAD}; |
| 224 | self.desktop_menu.clone().unwrap_or_else(|| Menu { |
| 225 | fill: self.popup, |
| 226 | border: self.chip, |
| 227 | shadows: vec![Shadow { |
| 228 | color: self.shadow, |
| 229 | blur: crate::POPUP_SHADOW[0], |
| 230 | drop: crate::POPUP_SHADOW[1], |
| 231 | spread: 0.0, |
| 232 | }], |
| 233 | text: self.text, |
| 234 | dim: self.text_dim, |
| 235 | disabled: self.text_dim, |
| 236 | highlight: self.hover(), |
| 237 | highlight_border: None, |
| 238 | rule: self.chip, |
| 239 | font_size: self.font_size, |
| 240 | radius: 6.0, |
| 241 | pad: PAD, |
| 242 | row: MENU_ROW, |
| 243 | row_radius: 4.0, |
| 244 | row_pad: 8.0, |
| 245 | rule_band: 9.0, |
| 246 | rule_inset: 0.0, |
| 247 | motion: PopupMotion::Grow, |
| 248 | }) |
| 249 | } |
| 250 | |
| 251 | /// Hovered controls: the accent over the base. |
| 252 | pub fn hover(&self) -> [f32; 4] { |
| 253 | crate::mix(self.base, self.accent, 0.35) |
| 254 | } |
| 255 | |
| 256 | /// Colours for icons' slots under a section of `accent`, with `highlight` the colour |
| 257 | /// the highlighter applies. The accent keeps its hue but stands as clear of the toolbar's |
| 258 | /// lightness as text must; the badge takes the first of green, blue and orange whose hue |
| 259 | /// stands clear of the accent's. |
| 260 | pub fn icon_palette(&self, accent: [f32; 4], highlight: [f32; 4]) -> draw::Palette { |
| 261 | // Over a system material the strip is clear, and the panel has its lightness. |
| 262 | let behind = if self.strip[3] == 1.0 { |
| 263 | self.strip |
| 264 | } else { |
| 265 | self.panel |
| 266 | }; |
| 267 | let [under, ..] = draw::oklab(behind); |
| 268 | let [lightness, a, b] = draw::oklab(accent); |
| 269 | let lightness = if under < 0.5 { |
| 270 | lightness.max(under + ICON_CONTRAST) |
| 271 | } else { |
| 272 | lightness.min(under - ICON_CONTRAST) |
| 273 | }; |
| 274 | let [r, g, b] = draw::from_oklab([lightness, a, b]); |
| 275 | let accent = [r, g, b, 1.0]; |
| 276 | let apart = |a: f32, b: f32| { |
| 277 | let turn = (a - b).rem_euclid(360.0); |
| 278 | turn.min(360.0 - turn) |
| 279 | }; |
| 280 | let accent_hue = hue(accent); |
| 281 | let badge = [135.0, 215.0, 30.0] |
| 282 | .into_iter() |
| 283 | .find(|badge| apart(*badge, accent_hue) >= 60.0) |
| 284 | .expect("Three hues this far apart leave one clear of any other"); |
| 285 | let rgb = |[r, g, b, _]: [f32; 4]| [r, g, b]; |
| 286 | draw::Palette { |
| 287 | accent: Some(rgb(accent)), |
| 288 | highlight: Some(rgb(highlight)), |
| 289 | badge: Some(rgb(hsl(badge, 0.65, 0.45))), |
| 290 | } |
| 291 | } |
| 292 | |
| 293 | /// The colours a section takes from the hue of `color`, linear RGBA, as grey as it is. |
| 294 | pub fn section(&self, color: [f32; 4]) -> Section { |
| 295 | let (hue, vividness) = (hue(color), draw::vividness(color)); |
| 296 | let shade = |hue: f32, [saturation, lightness]: [f32; 2]| { |
| 297 | hsl(hue, saturation * vividness, lightness) |
| 298 | }; |
| 299 | let Shades { |
| 300 | frame, |
| 301 | tab, |
| 302 | edge, |
| 303 | accent, |
| 304 | } = self.shades; |
| 305 | Section { |
| 306 | frame: [shade(hue, frame[0]), shade(hue + 10.0, frame[1])], |
| 307 | tab: shade(hue, tab), |
| 308 | edge: shade(hue, edge), |
| 309 | accent: shade(hue, accent), |
| 310 | } |
| 311 | } |
| 312 | } |
| 313 | |
| 314 | /// Least OKLab lightness difference between icons' accent marks and the toolbar. |
| 315 | const ICON_CONTRAST: f32 = 0.4; |
| 316 | |
| 317 | #[cfg(test)] |
| 318 | mod tests { |
| 319 | use super::*; |
| 320 | |
| 321 | #[test] |
| 322 | fn sections_keep_their_hue_at_the_theme_s_shades() { |
| 323 | let orange = srgb(0xf6, 0xb0, 0x78); |
| 324 | let section = Theme::dark().section(orange); |
| 325 | assert!((hue(section.frame[0]) - hue(orange)).abs() < 3.0); |
| 326 | assert!((hue(section.frame[1]) - hue(orange) - 10.0).abs() < 3.0); |
| 327 | assert_eq!(hsl(0.0, 1.0, 0.5), srgb(0xff, 0, 0)); |
| 328 | assert_eq!(hsl(240.0, 0.0, 1.0), srgb(0xff, 0xff, 0xff)); |
| 329 | } |
| 330 | |
| 331 | /// OneNote 2010's Silver (`corpus/section-color/native/colors.txt`) shades grey, as |
| 332 | /// faint in hue as it is, while its Blue takes the theme's full saturation. |
| 333 | #[test] |
| 334 | fn grey_sections_stay_grey() { |
| 335 | // HSL saturation, as sRGB shows it. |
| 336 | let saturation = |color: [f32; 4]| { |
| 337 | let [r, g, b] = draw::srgb_bytes(color).map(|byte| f32::from(byte) / 255.0); |
| 338 | let [max, min] = [r.max(g).max(b), r.min(g).min(b)]; |
| 339 | (max - min) / (1.0 - (max + min - 1.0).abs()) |
| 340 | }; |
| 341 | for theme in [Theme::light(), Theme::dark()] { |
| 342 | let silver = theme.section(srgb(0x95, 0x95, 0xaa)); |
| 343 | let blue = theme.section(srgb(0x8a, 0xa8, 0xe4)); |
| 344 | for (silver, blue) in [ |
| 345 | (silver.tab, blue.tab), |
| 346 | (silver.accent, blue.accent), |
| 347 | (silver.frame[0], blue.frame[0]), |
| 348 | ] { |
| 349 | assert!( |
| 350 | saturation(silver) < saturation(blue) * 0.4, |
| 351 | "{silver:?} {blue:?}" |
| 352 | ); |
| 353 | } |
| 354 | } |
| 355 | } |
| 356 | |
| 357 | #[test] |
| 358 | fn icon_badges_stand_clear_of_the_accent() { |
| 359 | let badge = |accent_hue: f32| { |
| 360 | let palette = Theme::light().icon_palette(hsl(accent_hue, 0.6, 0.45), [1.0; 4]); |
| 361 | let [r, g, b] = palette.badge.unwrap(); |
| 362 | hue([r, g, b, 1.0]).round() |
| 363 | }; |
| 364 | assert_eq!(badge(210.0), 135.0); |
| 365 | assert_eq!(badge(120.0), 215.0); |
| 366 | assert_eq!(badge(170.0), 30.0); |
| 367 | } |
| 368 | |
| 369 | #[test] |
| 370 | fn icon_accents_stand_clear_of_the_toolbar() { |
| 371 | for theme in [Theme::light(), Theme::dark()] { |
| 372 | let [under, ..] = draw::oklab(theme.strip); |
| 373 | for hue in (0..360).step_by(15) { |
| 374 | let accent = theme.section(hsl(hue as f32, 0.6, 0.6)).accent; |
| 375 | let [r, g, b] = theme.icon_palette(accent, [1.0; 4]).accent.unwrap(); |
| 376 | let [lightness, ..] = draw::oklab([r, g, b, 1.0]); |
| 377 | assert!( |
| 378 | (lightness - under).abs() >= ICON_CONTRAST - 0.02, |
| 379 | "{hue}: {lightness} on {under}" |
| 380 | ); |
| 381 | } |
| 382 | } |
| 383 | } |
| 384 | |
| 385 | #[test] |
| 386 | fn a_hovered_tab_stands_out_from_the_frame_and_its_neighbours() { |
| 387 | let lightness = |[r, g, b, _]: [f32; 4]| { |
| 388 | let y = 0.2126 * r + 0.7152 * g + 0.0722 * b; |
| 389 | 116.0 * y.cbrt() - 16.0 |
| 390 | }; |
| 391 | for theme in [Theme::light(), Theme::dark()] { |
| 392 | for hue in (0..360).step_by(5) { |
| 393 | let section = theme.section(hsl(hue as f32, 0.6, 0.6)); |
| 394 | let hover = lightness(section.hover()); |
| 395 | for other in [section.frame[0], section.frame[1], section.tab] { |
| 396 | assert!((hover - lightness(other)).abs() > 5.0, "{hue}"); |
| 397 | } |
| 398 | } |
| 399 | } |
| 400 | } |
| 401 | |
| 402 | #[test] |
| 403 | fn colours_mix_as_premultiplied_so_transparency_fades_a_colour_in() { |
| 404 | let [white, black] = [[1.0; 4], [0.0, 0.0, 0.0, 1.0]]; |
| 405 | assert_eq!(crate::mix(white, black, 0.25), [0.75, 0.75, 0.75, 1.0]); |
| 406 | let accent = Theme::light().accent; |
| 407 | let faded = crate::mix([0.0; 4], accent, 0.6); |
| 408 | assert!((0..3).all(|channel| (faded[channel] - accent[channel]).abs() < 1e-6)); |
| 409 | assert!((faded[3] - 0.6).abs() < 1e-6); |
| 410 | assert_eq!(crate::mix([0.0; 4], [0.0; 4], 0.5), [0.0; 4]); |
| 411 | } |
| 412 | } |