| @@ -0,0 +1,821 @@ |
| 1 | + //! Themes: MetalUI's Bone and Graphite, plus popular color schemes (Tokyo |
| 2 | + //! Night, Catppuccin, GitHub and more) rebuilt as MetalUI palettes, so caps |
| 3 | + //! still rise over sunk wells in every one of them. Also the Themes page. |
| 4 | + |
| 5 | + use std::{rc::Rc, sync::LazyLock}; |
| 6 | + |
| 7 | + use gpui_kit::component::{h_flex, v_flex}; |
| 8 | + use gpui_kit::prelude::FluentBuilder as _; |
| 9 | + use gpui_kit::*; |
| 10 | + |
| 11 | + use crate::settings::{self, ThemeMode}; |
| 12 | + use crate::ui::metal::{self, BONE, GRAPHITE, Palette, hex, shadows, vertical}; |
| 13 | + use crate::ui::settings::{radio, row, section}; |
| 14 | + |
| 15 | + /// A published color scheme, by the few colors a palette is built from. |
| 16 | + pub struct Scheme { |
| 17 | + pub id: &'static str, |
| 18 | + pub name: &'static str, |
| 19 | + pub dark: bool, |
| 20 | + /// The editor background, which the sheet takes. |
| 21 | + pub bg: &'static str, |
| 22 | + /// The panels around the editor, which the page takes. |
| 23 | + pub panel: &'static str, |
| 24 | + pub fg: &'static str, |
| 25 | + /// Secondary text: comments, descriptions. |
| 26 | + pub muted: &'static str, |
| 27 | + /// Primary buttons. |
| 28 | + pub accent: &'static str, |
| 29 | + pub green: &'static str, |
| 30 | + pub red: &'static str, |
| 31 | + pub yellow: &'static str, |
| 32 | + pub blue: &'static str, |
| 33 | + /// Orange, pink, teal, magenta, blue and violet, standing in for MetalUI's |
| 34 | + /// ember, blush, tide, spark, dusk and iris. |
| 35 | + pub tints: [&'static str; 6], |
| 36 | + } |
| 37 | + |
| 38 | + /// The schemes, from their published palettes. Dark ones first, then light, |
| 39 | + /// in the order the Themes page shows them. |
| 40 | + pub const SCHEMES: &[Scheme] = &[ |
| 41 | + Scheme { |
| 42 | + id: "tokyo-night", |
| 43 | + name: "Tokyo Night", |
| 44 | + dark: true, |
| 45 | + bg: "#1a1b26", |
| 46 | + panel: "#16161e", |
| 47 | + fg: "#c0caf5", |
| 48 | + muted: "#737aa2", |
| 49 | + accent: "#7aa2f7", |
| 50 | + green: "#9ece6a", |
| 51 | + red: "#f7768e", |
| 52 | + yellow: "#e0af68", |
| 53 | + blue: "#7aa2f7", |
| 54 | + tints: ["#ff9e64", "#f7768e", "#73daca", "#bb9af7", "#7aa2f7", "#9d7cd8"], |
| 55 | + }, |
| 56 | + Scheme { |
| 57 | + id: "tokyo-night-storm", |
| 58 | + name: "Tokyo Night Storm", |
| 59 | + dark: true, |
| 60 | + bg: "#24283b", |
| 61 | + panel: "#1f2335", |
| 62 | + fg: "#c0caf5", |
| 63 | + muted: "#737aa2", |
| 64 | + accent: "#7aa2f7", |
| 65 | + green: "#9ece6a", |
| 66 | + red: "#f7768e", |
| 67 | + yellow: "#e0af68", |
| 68 | + blue: "#7aa2f7", |
| 69 | + tints: ["#ff9e64", "#f7768e", "#73daca", "#bb9af7", "#7aa2f7", "#9d7cd8"], |
| 70 | + }, |
| 71 | + Scheme { |
| 72 | + id: "catppuccin-mocha", |
| 73 | + name: "Catppuccin Mocha", |
| 74 | + dark: true, |
| 75 | + bg: "#1e1e2e", |
| 76 | + panel: "#181825", |
| 77 | + fg: "#cdd6f4", |
| 78 | + muted: "#7f849c", |
| 79 | + accent: "#cba6f7", |
| 80 | + green: "#a6e3a1", |
| 81 | + red: "#f38ba8", |
| 82 | + yellow: "#f9e2af", |
| 83 | + blue: "#89b4fa", |
| 84 | + tints: ["#fab387", "#f5c2e7", "#94e2d5", "#cba6f7", "#89b4fa", "#b4befe"], |
| 85 | + }, |
| 86 | + Scheme { |
| 87 | + id: "catppuccin-macchiato", |
| 88 | + name: "Catppuccin Macchiato", |
| 89 | + dark: true, |
| 90 | + bg: "#24273a", |
| 91 | + panel: "#1e2030", |
| 92 | + fg: "#cad3f5", |
| 93 | + muted: "#8087a2", |
| 94 | + accent: "#c6a0f6", |
| 95 | + green: "#a6da95", |
| 96 | + red: "#ed8796", |
| 97 | + yellow: "#eed49f", |
| 98 | + blue: "#8aadf4", |
| 99 | + tints: ["#f5a97f", "#f5bde6", "#8bd5ca", "#c6a0f6", "#8aadf4", "#b7bdf8"], |
| 100 | + }, |
| 101 | + Scheme { |
| 102 | + id: "catppuccin-frappe", |
| 103 | + name: "Catppuccin Frappé", |
| 104 | + dark: true, |
| 105 | + bg: "#303446", |
| 106 | + panel: "#292c3c", |
| 107 | + fg: "#c6d0f5", |
| 108 | + muted: "#838ba7", |
| 109 | + accent: "#ca9ee6", |
| 110 | + green: "#a6d189", |
| 111 | + red: "#e78284", |
| 112 | + yellow: "#e5c890", |
| 113 | + blue: "#8caaee", |
| 114 | + tints: ["#ef9f76", "#f4b8e4", "#81c8be", "#ca9ee6", "#8caaee", "#babbf1"], |
| 115 | + }, |
| 116 | + Scheme { |
| 117 | + id: "github-dark", |
| 118 | + name: "GitHub Dark", |
| 119 | + dark: true, |
| 120 | + bg: "#0d1117", |
| 121 | + panel: "#010409", |
| 122 | + fg: "#e6edf3", |
| 123 | + muted: "#7d8590", |
| 124 | + accent: "#238636", |
| 125 | + green: "#3fb950", |
| 126 | + red: "#f85149", |
| 127 | + yellow: "#d29922", |
| 128 | + blue: "#4493f8", |
| 129 | + tints: ["#ffa657", "#db61a2", "#a5d6ff", "#d2a8ff", "#79c0ff", "#a371f7"], |
| 130 | + }, |
| 131 | + Scheme { |
| 132 | + id: "github-dark-dimmed", |
| 133 | + name: "GitHub Dark Dimmed", |
| 134 | + dark: true, |
| 135 | + bg: "#22272e", |
| 136 | + panel: "#1c2128", |
| 137 | + fg: "#adbac7", |
| 138 | + muted: "#768390", |
| 139 | + accent: "#347d39", |
| 140 | + green: "#57ab5a", |
| 141 | + red: "#e5534b", |
| 142 | + yellow: "#c69026", |
| 143 | + blue: "#539bf5", |
| 144 | + tints: ["#f69d50", "#c96198", "#96d0ff", "#dcbdfb", "#6cb6ff", "#986ee2"], |
| 145 | + }, |
| 146 | + Scheme { |
| 147 | + id: "one-dark", |
| 148 | + name: "One Dark", |
| 149 | + dark: true, |
| 150 | + bg: "#282c34", |
| 151 | + panel: "#21252b", |
| 152 | + fg: "#abb2bf", |
| 153 | + muted: "#7f848e", |
| 154 | + accent: "#61afef", |
| 155 | + green: "#98c379", |
| 156 | + red: "#e06c75", |
| 157 | + yellow: "#e5c07b", |
| 158 | + blue: "#61afef", |
| 159 | + tints: ["#d19a66", "#e06c75", "#56b6c2", "#c678dd", "#61afef", "#e5c07b"], |
| 160 | + }, |
| 161 | + Scheme { |
| 162 | + id: "nord", |
| 163 | + name: "Nord", |
| 164 | + dark: true, |
| 165 | + bg: "#3b4252", |
| 166 | + panel: "#2e3440", |
| 167 | + fg: "#eceff4", |
| 168 | + muted: "#616e88", |
| 169 | + accent: "#88c0d0", |
| 170 | + green: "#a3be8c", |
| 171 | + red: "#bf616a", |
| 172 | + yellow: "#ebcb8b", |
| 173 | + blue: "#81a1c1", |
| 174 | + tints: ["#d08770", "#bf616a", "#8fbcbb", "#b48ead", "#81a1c1", "#5e81ac"], |
| 175 | + }, |
| 176 | + Scheme { |
| 177 | + id: "gruvbox-dark", |
| 178 | + name: "Gruvbox Dark", |
| 179 | + dark: true, |
| 180 | + bg: "#282828", |
| 181 | + panel: "#1d2021", |
| 182 | + fg: "#ebdbb2", |
| 183 | + muted: "#a89984", |
| 184 | + accent: "#fe8019", |
| 185 | + green: "#b8bb26", |
| 186 | + red: "#fb4934", |
| 187 | + yellow: "#fabd2f", |
| 188 | + blue: "#83a598", |
| 189 | + tints: ["#fe8019", "#fb4934", "#8ec07c", "#d3869b", "#83a598", "#fabd2f"], |
| 190 | + }, |
| 191 | + Scheme { |
| 192 | + id: "rose-pine", |
| 193 | + name: "Rosé Pine", |
| 194 | + dark: true, |
| 195 | + bg: "#1f1d2e", |
| 196 | + panel: "#191724", |
| 197 | + fg: "#e0def4", |
| 198 | + muted: "#908caa", |
| 199 | + accent: "#ebbcba", |
| 200 | + green: "#9ccfd8", |
| 201 | + red: "#eb6f92", |
| 202 | + yellow: "#f6c177", |
| 203 | + blue: "#c4a7e7", |
| 204 | + tints: ["#ebbcba", "#eb6f92", "#9ccfd8", "#c4a7e7", "#31748f", "#f6c177"], |
| 205 | + }, |
| 206 | + Scheme { |
| 207 | + id: "rose-pine-moon", |
| 208 | + name: "Rosé Pine Moon", |
| 209 | + dark: true, |
| 210 | + bg: "#2a273f", |
| 211 | + panel: "#232136", |
| 212 | + fg: "#e0def4", |
| 213 | + muted: "#908caa", |
| 214 | + accent: "#ea9a97", |
| 215 | + green: "#9ccfd8", |
| 216 | + red: "#eb6f92", |
| 217 | + yellow: "#f6c177", |
| 218 | + blue: "#c4a7e7", |
| 219 | + tints: ["#ea9a97", "#eb6f92", "#9ccfd8", "#c4a7e7", "#3e8fb0", "#f6c177"], |
| 220 | + }, |
| 221 | + Scheme { |
| 222 | + id: "solarized-dark", |
| 223 | + name: "Solarized Dark", |
| 224 | + dark: true, |
| 225 | + bg: "#002b36", |
| 226 | + panel: "#00212b", |
| 227 | + fg: "#93a1a1", |
| 228 | + muted: "#586e75", |
| 229 | + accent: "#268bd2", |
| 230 | + green: "#859900", |
| 231 | + red: "#dc322f", |
| 232 | + yellow: "#b58900", |
| 233 | + blue: "#268bd2", |
| 234 | + tints: ["#cb4b16", "#d33682", "#2aa198", "#6c71c4", "#268bd2", "#b58900"], |
| 235 | + }, |
| 236 | + Scheme { |
| 237 | + id: "dracula", |
| 238 | + name: "Dracula", |
| 239 | + dark: true, |
| 240 | + bg: "#282a36", |
| 241 | + panel: "#21222c", |
| 242 | + fg: "#f8f8f2", |
| 243 | + muted: "#6272a4", |
| 244 | + accent: "#bd93f9", |
| 245 | + green: "#50fa7b", |
| 246 | + red: "#ff5555", |
| 247 | + yellow: "#f1fa8c", |
| 248 | + blue: "#8be9fd", |
| 249 | + tints: ["#ffb86c", "#ff79c6", "#8be9fd", "#bd93f9", "#50fa7b", "#f1fa8c"], |
| 250 | + }, |
| 251 | + Scheme { |
| 252 | + id: "tokyo-night-day", |
| 253 | + name: "Tokyo Night Day", |
| 254 | + dark: false, |
| 255 | + bg: "#e1e2e7", |
| 256 | + panel: "#d0d5e3", |
| 257 | + fg: "#3760bf", |
| 258 | + muted: "#6172b0", |
| 259 | + accent: "#2e7de9", |
| 260 | + green: "#587539", |
| 261 | + red: "#f52a65", |
| 262 | + yellow: "#8c6c3e", |
| 263 | + blue: "#2e7de9", |
| 264 | + tints: ["#b15c00", "#f52a65", "#118c74", "#9854f1", "#2e7de9", "#7847bd"], |
| 265 | + }, |
| 266 | + Scheme { |
| 267 | + id: "catppuccin-latte", |
| 268 | + name: "Catppuccin Latte", |
| 269 | + dark: false, |
| 270 | + bg: "#eff1f5", |
| 271 | + panel: "#e6e9ef", |
| 272 | + fg: "#4c4f69", |
| 273 | + muted: "#8c8fa1", |
| 274 | + accent: "#8839ef", |
| 275 | + green: "#40a02b", |
| 276 | + red: "#d20f39", |
| 277 | + yellow: "#df8e1d", |
| 278 | + blue: "#1e66f5", |
| 279 | + tints: ["#fe640b", "#ea76cb", "#179299", "#8839ef", "#1e66f5", "#7287fd"], |
| 280 | + }, |
| 281 | + Scheme { |
| 282 | + id: "github-light", |
| 283 | + name: "GitHub Light", |
| 284 | + dark: false, |
| 285 | + bg: "#ffffff", |
| 286 | + panel: "#f6f8fa", |
| 287 | + fg: "#1f2328", |
| 288 | + muted: "#656d76", |
| 289 | + accent: "#1f883d", |
| 290 | + green: "#1a7f37", |
| 291 | + red: "#d1242f", |
| 292 | + yellow: "#9a6700", |
| 293 | + blue: "#0969da", |
| 294 | + tints: ["#bc4c00", "#bf3989", "#1b7c83", "#8250df", "#0550ae", "#6639ba"], |
| 295 | + }, |
| 296 | + Scheme { |
| 297 | + id: "one-light", |
| 298 | + name: "One Light", |
| 299 | + dark: false, |
| 300 | + bg: "#fafafa", |
| 301 | + panel: "#eaeaeb", |
| 302 | + fg: "#383a42", |
| 303 | + muted: "#a0a1a7", |
| 304 | + accent: "#4078f2", |
| 305 | + green: "#50a14f", |
| 306 | + red: "#e45649", |
| 307 | + yellow: "#c18401", |
| 308 | + blue: "#4078f2", |
| 309 | + tints: ["#986801", "#e45649", "#0184bc", "#a626a4", "#4078f2", "#c18401"], |
| 310 | + }, |
| 311 | + Scheme { |
| 312 | + id: "gruvbox-light", |
| 313 | + name: "Gruvbox Light", |
| 314 | + dark: false, |
| 315 | + bg: "#fbf1c7", |
| 316 | + panel: "#f2e5bc", |
| 317 | + fg: "#3c3836", |
| 318 | + muted: "#7c6f64", |
| 319 | + accent: "#af3a03", |
| 320 | + green: "#79740e", |
| 321 | + red: "#9d0006", |
| 322 | + yellow: "#b57614", |
| 323 | + blue: "#076678", |
| 324 | + tints: ["#af3a03", "#9d0006", "#427b58", "#8f3f71", "#076678", "#b57614"], |
| 325 | + }, |
| 326 | + Scheme { |
| 327 | + id: "rose-pine-dawn", |
| 328 | + name: "Rosé Pine Dawn", |
| 329 | + dark: false, |
| 330 | + bg: "#fffaf3", |
| 331 | + panel: "#faf4ed", |
| 332 | + fg: "#575279", |
| 333 | + muted: "#797593", |
| 334 | + accent: "#d7827e", |
| 335 | + green: "#56949f", |
| 336 | + red: "#b4637a", |
| 337 | + yellow: "#ea9d34", |
| 338 | + blue: "#907aa9", |
| 339 | + tints: ["#d7827e", "#b4637a", "#56949f", "#907aa9", "#286983", "#ea9d34"], |
| 340 | + }, |
| 341 | + Scheme { |
| 342 | + id: "solarized-light", |
| 343 | + name: "Solarized Light", |
| 344 | + dark: false, |
| 345 | + bg: "#fdf6e3", |
| 346 | + panel: "#eee8d5", |
| 347 | + fg: "#586e75", |
| 348 | + muted: "#93a1a1", |
| 349 | + accent: "#268bd2", |
| 350 | + green: "#859900", |
| 351 | + red: "#dc322f", |
| 352 | + yellow: "#b58900", |
| 353 | + blue: "#268bd2", |
| 354 | + tints: ["#cb4b16", "#d33682", "#2aa198", "#6c71c4", "#268bd2", "#b58900"], |
| 355 | + }, |
| 356 | + ]; |
| 357 | + |
| 358 | + impl Palette { |
| 359 | + /// Builds a full MetalUI palette from a scheme. Every surface steps off |
| 360 | + /// the scheme's background the way Graphite's (dark) or Bone's (light) |
| 361 | + /// step off theirs, inks are nudged until they read on those surfaces, and |
| 362 | + /// the shadow recipes are Graphite's, or Bone's tinted to the scheme's hue. |
| 363 | + pub fn from_scheme(scheme: &Scheme) -> Palette { |
| 364 | + let dark = scheme.dark; |
| 365 | + let bg = hex(scheme.bg); |
| 366 | + let page = hex(scheme.panel); |
| 367 | + let (s_hi, s_lo, well, cap, pressed, row_on, kbd) = if dark { |
| 368 | + ( |
| 369 | + lighten(bg, 0.029), |
| 370 | + dim(bg, 0.87), |
| 371 | + (dim(bg, 0.54), dim(bg, 0.66)), |
| 372 | + (lighten(bg, 0.049), lighten(bg, 0.014)), |
| 373 | + (dim(bg, 0.66), dim(bg, 0.77)), |
| 374 | + (lighten(bg, 0.059), lighten(bg, 0.021)), |
| 375 | + (lighten(bg, 0.07), lighten(bg, 0.021)), |
| 376 | + ) |
| 377 | + } else { |
| 378 | + ( |
| 379 | + lighten(bg, 0.023), |
| 380 | + lighten(bg, -0.026), |
| 381 | + (lighten(bg, -0.067), lighten(bg, -0.039)), |
| 382 | + (lighten(bg, 0.06), lighten(bg, -0.008)), |
| 383 | + (lighten(bg, -0.057), lighten(bg, -0.029)), |
| 384 | + (lighten(bg, 0.06), bg), |
| 385 | + (lighten(bg, 0.06), lighten(bg, -0.015)), |
| 386 | + ) |
| 387 | + }; |
| 388 | + |
| 389 | + // Inks get lighter on dark themes and darker on light ones until they |
| 390 | + // read on everything text sits on. |
| 391 | + let behind = [page, s_hi, bg, row_on.0, well.0]; |
| 392 | + let ink = legible(hex(scheme.fg), &behind, 4.5, dark); |
| 393 | + let ink3 = legible(hex(scheme.muted), &behind, 3.5, dark); |
| 394 | + let ink2 = legible(mix(ink, ink3, 0.5), &behind, 4.5, dark); |
| 395 | + let text = |color: &str, ratio: f32| legible(hex(color), &[s_hi, bg], ratio, dark); |
| 396 | + |
| 397 | + let accent = hex(scheme.accent); |
| 398 | + let primary_ink = [white(), if dark { page } else { ink }] |
| 399 | + .into_iter() |
| 400 | + .max_by(|a, b| contrast(*a, accent).total_cmp(&contrast(*b, accent))) |
| 401 | + .unwrap_or(white()); |
| 402 | + // Danger caps carry white text. |
| 403 | + let danger = legible(hex(scheme.red), &[white()], 3.5, false); |
| 404 | + let (primary_sh, primary_pressed_sh) = colored_cap(accent, dark); |
| 405 | + let (danger_sh, danger_pressed_sh) = colored_cap(danger, dark); |
| 406 | + |
| 407 | + // Bone's shadows are warm browns; these take the scheme's hue instead. |
| 408 | + // Graphite's are black and white, so tinting leaves them be. |
| 409 | + let tone = hsla(bg.h, bg.s.min(0.25), 0.14, 1.); |
| 410 | + let metal: &Palette = if dark { &GRAPHITE } else { &BONE }; |
| 411 | + |
| 412 | + Palette { |
| 413 | + dark, |
| 414 | + page, |
| 415 | + s_hi, |
| 416 | + s: bg, |
| 417 | + s_lo, |
| 418 | + well_top: well.0, |
| 419 | + well_bot: well.1, |
| 420 | + ink, |
| 421 | + ink2, |
| 422 | + ink3, |
| 423 | + icon: mix(ink, ink2, 0.4), |
| 424 | + engrave: ink.alpha(if dark { 0.5 } else { 0.65 }), |
| 425 | + tag_ring: ink.alpha(if dark { 0.16 } else { 0.2 }), |
| 426 | + rule: black().alpha(if dark { 0.45 } else { 0.075 }), |
| 427 | + // A near-black sheet swallows a dark rule, so its lip carries it. |
| 428 | + rule_lip: white().alpha(if dark { 0.035 + (0.14 - bg.l).max(0.) * 0.5 } else { 0.8 }), |
| 429 | + row_hover: white().alpha(if dark { 0.06 } else { 0.7 }), |
| 430 | + menu_bg: if dark { mix(bg, s_lo, 0.6) } else { lighten(bg, 0.015) }.alpha(0.98), |
| 431 | + menu_row_hover: if dark { white().alpha(0.06) } else { black().alpha(0.05) }, |
| 432 | + field_ink: mix(ink3, bg, 0.2), |
| 433 | + cap, |
| 434 | + pressed, |
| 435 | + row_on, |
| 436 | + kbd, |
| 437 | + primary: (lighten(accent, 0.04), lighten(accent, -0.04)), |
| 438 | + primary_pressed: (lighten(accent, -0.1), lighten(accent, -0.06)), |
| 439 | + primary_ink, |
| 440 | + danger: (lighten(danger, 0.04), lighten(danger, -0.04)), |
| 441 | + danger_pressed: (lighten(danger, -0.1), lighten(danger, -0.045)), |
| 442 | + region: if dark { |
| 443 | + (white().alpha(0.02), white().alpha(0.012)) |
| 444 | + } else { |
| 445 | + (tone.alpha(0.028), tone.alpha(0.012)) |
| 446 | + }, |
| 447 | + green_deep: hex(scheme.green), |
| 448 | + green_ink: text(scheme.green, 4.5), |
| 449 | + red: text(scheme.red, 4.), |
| 450 | + amber: hex(scheme.yellow), |
| 451 | + amber_ink: text(scheme.yellow, 4.5), |
| 452 | + blue_ink: text(scheme.blue, 4.5), |
| 453 | + led_off: if dark { |
| 454 | + (mix(ink, bg, 0.45), mix(ink, bg, 0.8)) |
| 455 | + } else { |
| 456 | + (lighten(bg, -0.13), lighten(bg, -0.18)) |
| 457 | + }, |
| 458 | + tints: scheme.tints.map(|tint| text(tint, 3.)), |
| 459 | + raise: retint(&metal.raise, tone), |
| 460 | + raise_lite: retint(&metal.raise_lite, tone), |
| 461 | + raise_sm: retint(&metal.raise_sm, tone), |
| 462 | + btn: retint(&metal.btn, tone), |
| 463 | + pressed_sh: retint(&metal.pressed_sh, tone), |
| 464 | + well: retint(&metal.well, tone), |
| 465 | + region_sh: retint(&metal.region_sh, tone), |
| 466 | + primary_sh, |
| 467 | + primary_pressed_sh, |
| 468 | + danger_sh, |
| 469 | + danger_pressed_sh, |
| 470 | + led_off_sh: metal.led_off_sh.clone(), |
| 471 | + } |
| 472 | + } |
| 473 | + } |
| 474 | + |
| 475 | + /// The raised and pressed shadows of a cap in a color of its own. On light |
| 476 | + /// themes they're MetalUI's danger recipe, shaded in the cap's hue; on dark |
| 477 | + /// ones the page is darker than any shade of it, so they fall black. |
| 478 | + fn colored_cap(color: Hsla, dark: bool) -> (Vec<BoxShadow>, Vec<BoxShadow>) { |
| 479 | + let shade = |l: f32, a: f32| css(hsla(color.h, color.s, l, a)); |
| 480 | + let (edge, near, far, deep, ring) = if dark { |
| 481 | + let black = |a: f32| css(black().alpha(a)); |
| 482 | + (black(0.6), black(0.35), black(0.45), black(0.35), black(0.6)) |
| 483 | + } else { |
| 484 | + (shade(0.25, 0.3), shade(0.22, 0.14), shade(0.37, 0.24), shade(0.16, 0.45), shade(0.22, 0.55)) |
| 485 | + }; |
| 486 | + ( |
| 487 | + shadows(&format!( |
| 488 | + "inset 0 0 6px 1px #ffffff24, inset 1px 2px 2px -1px #ffffff52, 0 0 0 .5px {edge}, 0 1px 2px {near}, \ |
| 489 | + 0 5px 12px -4px {far}" |
| 490 | + )), |
| 491 | + shadows(&format!("inset 0 1px 3px {deep}, 0 0 0 .5px {ring}")), |
| 492 | + ) |
| 493 | + } |
| 494 | + |
| 495 | + /// Turns a recipe's tinted shadows to `tone`'s hue and saturation, keeping |
| 496 | + /// their lightness and alpha. Black and white layers stay as they are. |
| 497 | + fn retint(layers: &[BoxShadow], tone: Hsla) -> Vec<BoxShadow> { |
| 498 | + layers |
| 499 | + .iter() |
| 500 | + .map(|layer| { |
| 501 | + let mut layer = layer.clone(); |
| 502 | + if layer.color.s > 0. { |
| 503 | + layer.color = hsla(tone.h, tone.s, layer.color.l, layer.color.a); |
| 504 | + } |
| 505 | + layer |
| 506 | + }) |
| 507 | + .collect() |
| 508 | + } |
| 509 | + |
| 510 | + // ---- color math ---------------------------------------------------------------- |
| 511 | + |
| 512 | + /// Shifts lightness by `by`, darker when negative. |
| 513 | + fn lighten(color: Hsla, by: f32) -> Hsla { |
| 514 | + Hsla { l: (color.l + by).clamp(0., 1.), ..color } |
| 515 | + } |
| 516 | + |
| 517 | + /// Scales lightness, for darkening an already dark color in proportion. |
| 518 | + fn dim(color: Hsla, factor: f32) -> Hsla { |
| 519 | + Hsla { l: color.l * factor, ..color } |
| 520 | + } |
| 521 | + |
| 522 | + /// Blends `a` toward `b` in RGB. |
| 523 | + fn mix(a: Hsla, b: Hsla, t: f32) -> Hsla { |
| 524 | + let (a, b) = (a.to_rgb(), b.to_rgb()); |
| 525 | + let lerp = |x: f32, y: f32| x + (y - x) * t; |
| 526 | + Rgba { r: lerp(a.r, b.r), g: lerp(a.g, b.g), b: lerp(a.b, b.b), a: lerp(a.a, b.a) }.into() |
| 527 | + } |
| 528 | + |
| 529 | + /// `#rrggbbaa`, as `metal::shadows` reads it. |
| 530 | + fn css(color: Hsla) -> String { |
| 531 | + let c = color.to_rgb(); |
| 532 | + let byte = |v: f32| (v.clamp(0., 1.) * 255.).round() as u8; |
| 533 | + format!("#{:02x}{:02x}{:02x}{:02x}", byte(c.r), byte(c.g), byte(c.b), byte(c.a)) |
| 534 | + } |
| 535 | + |
| 536 | + /// WCAG relative luminance. |
| 537 | + fn luminance(color: Hsla) -> f32 { |
| 538 | + let c = color.to_rgb(); |
| 539 | + let linear = |v: f32| if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }; |
| 540 | + 0.2126 * linear(c.r) + 0.7152 * linear(c.g) + 0.0722 * linear(c.b) |
| 541 | + } |
| 542 | + |
| 543 | + /// WCAG contrast ratio, 1 to 21. |
| 544 | + pub fn contrast(a: Hsla, b: Hsla) -> f32 { |
| 545 | + let (a, b) = (luminance(a), luminance(b)); |
| 546 | + (a.max(b) + 0.05) / (a.min(b) + 0.05) |
| 547 | + } |
| 548 | + |
| 549 | + /// Moves `color` lighter (or darker) until it reads at `ratio` on every one |
| 550 | + /// of `surfaces`. |
| 551 | + fn legible(mut color: Hsla, surfaces: &[Hsla], ratio: f32, lighter: bool) -> Hsla { |
| 552 | + for _ in 0..100 { |
| 553 | + if surfaces.iter().all(|surface| contrast(color, *surface) >= ratio) { |
| 554 | + break; |
| 555 | + } |
| 556 | + color = lighten(color, if lighter { 0.01 } else { -0.01 }); |
| 557 | + } |
| 558 | + color |
| 559 | + } |
| 560 | + |
| 561 | + // ---- the catalog ----------------------------------------------------------------- |
| 562 | + |
| 563 | + /// A theme on the Themes page. |
| 564 | + pub struct Theme { |
| 565 | + pub id: &'static str, |
| 566 | + pub name: &'static str, |
| 567 | + pub palette: &'static Palette, |
| 568 | + } |
| 569 | + |
| 570 | + static THEMES: LazyLock<Vec<Theme>> = LazyLock::new(|| { |
| 571 | + let metal = [("graphite", "Graphite", &*GRAPHITE), ("bone", "Bone", &*BONE)]; |
| 572 | + let schemes = SCHEMES.iter().map(|scheme| { |
| 573 | + let palette: &'static Palette = Box::leak(Box::new(Palette::from_scheme(scheme))); |
| 574 | + (scheme.id, scheme.name, palette) |
| 575 | + }); |
| 576 | + metal.into_iter().chain(schemes).map(|(id, name, palette)| Theme { id, name, palette }).collect() |
| 577 | + }); |
| 578 | + |
| 579 | + /// Every theme, Bone and Graphite first. |
| 580 | + pub fn all() -> &'static [Theme] { |
| 581 | + &THEMES |
| 582 | + } |
| 583 | + |
| 584 | + /// The palette of the theme with this id, if it's of the right kind, or |
| 585 | + /// Graphite or Bone. |
| 586 | + pub fn find(id: &str, dark: bool) -> &'static Palette { |
| 587 | + all() |
| 588 | + .iter() |
| 589 | + .find(|theme| theme.id == id && theme.palette.dark == dark) |
| 590 | + .map_or(if dark { &*GRAPHITE } else { &*BONE }, |theme| theme.palette) |
| 591 | + } |
| 592 | + |
| 593 | + /// The palette the settings pick: the dark or light theme, by mode. |
| 594 | + pub fn chosen(system_dark: bool) -> &'static Palette { |
| 595 | + let (mode, light, dark) = |
| 596 | + settings::get(|settings| (settings.theme_mode, settings.light_theme.clone(), settings.dark_theme.clone())); |
| 597 | + match mode { |
| 598 | + ThemeMode::Light => find(&light, false), |
| 599 | + ThemeMode::Dark => find(&dark, true), |
| 600 | + ThemeMode::System if system_dark => find(&dark, true), |
| 601 | + ThemeMode::System => find(&light, false), |
| 602 | + } |
| 603 | + } |
| 604 | + |
| 605 | + /// Switches between light, dark and the desktop's choice, and applies it. |
| 606 | + pub fn set_mode(mode: ThemeMode, window: &Window, cx: &mut App) { |
| 607 | + settings::update(|settings| settings.theme_mode = mode); |
| 608 | + metal::sync_appearance(window, cx); |
| 609 | + } |
| 610 | + |
| 611 | + /// Makes `theme` the light or dark theme, switching to its mode unless the |
| 612 | + /// mode follows the desktop, and applies it. |
| 613 | + pub fn pick(theme: &Theme, window: &Window, cx: &mut App) { |
| 614 | + let (id, dark) = (theme.id.to_string(), theme.palette.dark); |
| 615 | + settings::update(|settings| { |
| 616 | + if dark { |
| 617 | + settings.dark_theme = id; |
| 618 | + } else { |
| 619 | + settings.light_theme = id; |
| 620 | + } |
| 621 | + if settings.theme_mode != ThemeMode::System { |
| 622 | + settings.theme_mode = if dark { ThemeMode::Dark } else { ThemeMode::Light }; |
| 623 | + } |
| 624 | + }); |
| 625 | + metal::sync_appearance(window, cx); |
| 626 | + } |
| 627 | + |
| 628 | + // ---- the Themes page ------------------------------------------------------------- |
| 629 | + |
| 630 | + const MODES: &[(ThemeMode, &str)] = |
| 631 | + &[(ThemeMode::Light, "Light"), (ThemeMode::Dark, "Dark"), (ThemeMode::System, "System")]; |
| 632 | + |
| 633 | + /// Repaints the settings view after a change. |
| 634 | + pub type Changed = Rc<dyn Fn(&mut Window, &mut App)>; |
| 635 | + |
| 636 | + /// The Themes settings page: the mode, then a card per theme, dark and light. |
| 637 | + pub fn page(cx: &App, changed: Changed) -> Vec<AnyElement> { |
| 638 | + let p = metal::palette(cx); |
| 639 | + let (mode, light, dark) = |
| 640 | + settings::get(|settings| (settings.theme_mode, settings.light_theme.clone(), settings.dark_theme.clone())); |
| 641 | + |
| 642 | + let on_mode = changed.clone(); |
| 643 | + let mode = section( |
| 644 | + "Theme", |
| 645 | + None, |
| 646 | + vec![row( |
| 647 | + "Mode", |
| 648 | + "Light and Dark keep to the themes picked below. System switches between them with the desktop.", |
| 649 | + metal::segmented("theme-mode", MODES, mode, p, move |mode, window, cx| { |
| 650 | + set_mode(mode, window, cx); |
| 651 | + on_mode(window, cx); |
| 652 | + }), |
| 653 | + p, |
| 654 | + )], |
| 655 | + p, |
| 656 | + ); |
| 657 | + let dark_themes = section( |
| 658 | + "Dark themes", |
| 659 | + Some("Shown in dark mode, and in System mode while the desktop is dark."), |
| 660 | + vec![cards(find(&dark, true), p, &changed)], |
| 661 | + p, |
| 662 | + ); |
| 663 | + let light_themes = section( |
| 664 | + "Light themes", |
| 665 | + Some("Shown in light mode, and in System mode while the desktop is light."), |
| 666 | + vec![cards(find(&light, false), p, &changed)], |
| 667 | + p, |
| 668 | + ); |
| 669 | + vec![mode, dark_themes, light_themes] |
| 670 | + } |
| 671 | + |
| 672 | + /// A grid of the themes of `chosen`'s kind, `chosen` ringed. |
| 673 | + fn cards(chosen: &'static Palette, p: &'static Palette, changed: &Changed) -> AnyElement { |
| 674 | + let themes = all().iter().filter(|theme| theme.palette.dark == chosen.dark); |
| 675 | + div() |
| 676 | + .grid() |
| 677 | + .grid_cols(3) |
| 678 | + .gap(px(16.)) |
| 679 | + .p(px(18.)) |
| 680 | + .children(themes.map(|theme| { |
| 681 | + let selected = std::ptr::eq(theme.palette, chosen); |
| 682 | + let shown = std::ptr::eq(theme.palette, p); |
| 683 | + let ring = if selected { |
| 684 | + BoxShadow::new(px(0.), px(0.), p.green_deep).spread_radius(px(2.)) |
| 685 | + } else { |
| 686 | + BoxShadow::new(px(0.), px(0.), p.rule).spread_radius(px(1.)) |
| 687 | + }; |
| 688 | + let changed = changed.clone(); |
| 689 | + v_flex() |
| 690 | + .id(SharedString::from(format!("theme-{}", theme.id))) |
| 691 | + .min_w_0() |
| 692 | + .gap(px(10.)) |
| 693 | + .cursor_pointer() |
| 694 | + .on_click(move |_, window, cx| { |
| 695 | + pick(theme, window, cx); |
| 696 | + changed(window, cx); |
| 697 | + }) |
| 698 | + .child(div().h(px(96.)).rounded(px(14.)).shadow(vec![ring]).child(preview(theme.palette))) |
| 699 | + .child( |
| 700 | + h_flex() |
| 701 | + .gap(px(8.)) |
| 702 | + .child(radio(selected, p)) |
| 703 | + .child( |
| 704 | + div() |
| 705 | + .flex_1() |
| 706 | + .min_w_0() |
| 707 | + .truncate() |
| 708 | + .text_size(px(13.5)) |
| 709 | + .font_weight(FontWeight::MEDIUM) |
| 710 | + .text_color(p.ink) |
| 711 | + .child(theme.name), |
| 712 | + ) |
| 713 | + .when(shown, |this| this.child(metal::tag("On", p))), |
| 714 | + ) |
| 715 | + })) |
| 716 | + .into_any_element() |
| 717 | + } |
| 718 | + |
| 719 | + /// A miniature of the app in a theme: the sheet on the table, a thread list |
| 720 | + /// with one thread picked, a code well and a composer. |
| 721 | + pub fn preview(c: &Palette) -> Div { |
| 722 | + let line = |width: f32, color: Hsla| div().flex_none().h(px(4.)).w(px(width)).rounded_full().bg(color); |
| 723 | + h_flex() |
| 724 | + .size_full() |
| 725 | + .overflow_hidden() |
| 726 | + .rounded(px(14.)) |
| 727 | + .bg(c.page) |
| 728 | + .p(px(8.)) |
| 729 | + .gap(px(8.)) |
| 730 | + .child( |
| 731 | + v_flex() |
| 732 | + .w(px(30.)) |
| 733 | + .gap(px(6.)) |
| 734 | + .child( |
| 735 | + div() |
| 736 | + .p(px(3.)) |
| 737 | + .rounded(px(4.)) |
| 738 | + .bg(vertical(c.row_on)) |
| 739 | + .shadow(c.raise_sm.clone()) |
| 740 | + .child(line(20., c.ink2)), |
| 741 | + ) |
| 742 | + .child(div().px(px(3.)).child(line(16., c.ink3.opacity(0.7)))) |
| 743 | + .child(div().px(px(3.)).child(line(20., c.ink3.opacity(0.7)))), |
| 744 | + ) |
| 745 | + .child( |
| 746 | + v_flex() |
| 747 | + .flex_1() |
| 748 | + .h_full() |
| 749 | + .p(px(8.)) |
| 750 | + .gap(px(5.)) |
| 751 | + .rounded(px(10.)) |
| 752 | + .bg(vertical((c.s_hi, c.s))) |
| 753 | + .shadow(c.raise_sm.clone()) |
| 754 | + .child(line(40., c.ink.opacity(0.8))) |
| 755 | + .child(line(56., c.ink3.opacity(0.7))) |
| 756 | + .child( |
| 757 | + v_flex() |
| 758 | + .gap(px(3.)) |
| 759 | + .p(px(4.)) |
| 760 | + .rounded(px(5.)) |
| 761 | + .bg(vertical((c.well_top, c.well_bot))) |
| 762 | + .shadow(c.well.clone()) |
| 763 | + .child(h_flex().gap(px(3.)).child(line(10., c.blue_ink)).child(line(18., c.green_ink))) |
| 764 | + .child(h_flex().gap(px(3.)).child(line(14., c.tints[3])).child(line(10., c.amber_ink))), |
| 765 | + ) |
| 766 | + .child(div().flex_1()) |
| 767 | + .child( |
| 768 | + h_flex() |
| 769 | + .h(px(16.)) |
| 770 | + .px(px(4.)) |
| 771 | + .rounded(px(6.)) |
| 772 | + .bg(vertical(c.row_on)) |
| 773 | + .shadow(c.raise_sm.clone()) |
| 774 | + .child(div().flex_1()) |
| 775 | + .child(div().size(px(8.)).rounded_full().bg(vertical(c.primary))), |
| 776 | + ), |
| 777 | + ) |
| 778 | + } |
| 779 | + |
| 780 | + #[cfg(test)] |
| 781 | + mod tests { |
| 782 | + use super::{SCHEMES, all, contrast, find, luminance}; |
| 783 | + use crate::ui::metal::{BONE, GRAPHITE}; |
| 784 | + use gpui_kit::white; |
| 785 | + |
| 786 | + #[test] |
| 787 | + fn every_theme_reads() { |
| 788 | + for theme in all() { |
| 789 | + let (id, p) = (theme.id, theme.palette); |
| 790 | + for surface in [p.page, p.s_hi, p.s] { |
| 791 | + assert!(contrast(p.ink, surface) >= 4.5, "{id}: ink on {surface:?}"); |
| 792 | + assert!(contrast(p.ink2, surface) >= 3., "{id}: ink2 on {surface:?}"); |
| 793 | + assert!(contrast(p.ink3, surface) >= 3., "{id}: ink3 on {surface:?}"); |
| 794 | + assert!(contrast(p.blue_ink, surface) >= 3., "{id}: blue ink on {surface:?}"); |
| 795 | + assert!(contrast(p.green_ink, surface) >= 3., "{id}: green ink on {surface:?}"); |
| 796 | + } |
| 797 | + assert!(contrast(p.primary_ink, p.primary.1) >= 3., "{id}: primary cap"); |
| 798 | + assert!(contrast(white(), p.danger.1) >= 3., "{id}: danger cap"); |
| 799 | + assert!(luminance(p.page) < luminance(p.s_hi), "{id}: the sheet should stand off the page"); |
| 800 | + assert_eq!(p.dark, luminance(p.s) < 0.18, "{id}: dark or light"); |
| 801 | + } |
| 802 | + } |
| 803 | + |
| 804 | + #[test] |
| 805 | + fn theme_ids_are_unique() { |
| 806 | + let themes = all(); |
| 807 | + assert_eq!(themes.len(), SCHEMES.len() + 2); |
| 808 | + for (ix, theme) in themes.iter().enumerate() { |
| 809 | + assert!(themes[ix + 1..].iter().all(|other| other.id != theme.id), "{} twice", theme.id); |
| 810 | + } |
| 811 | + } |
| 812 | + |
| 813 | + #[test] |
| 814 | + fn unknown_themes_fall_back() { |
| 815 | + assert!(std::ptr::eq(find("graphite", true), &*GRAPHITE)); |
| 816 | + assert!(std::ptr::eq(find("no-such-theme", true), &*GRAPHITE)); |
| 817 | + assert!(std::ptr::eq(find("tokyo-night", false), &*BONE)); |
| 818 | + let latte = find("catppuccin-latte", false); |
| 819 | + assert!(!latte.dark && !std::ptr::eq(latte, &*BONE)); |
| 820 | + } |
| 821 | + } |