diff --git a/crates/horizon-theme/src/night.rs b/crates/horizon-theme/src/night.rs index 3cffde013d..b2aec54d5c 100644 --- a/crates/horizon-theme/src/night.rs +++ b/crates/horizon-theme/src/night.rs @@ -28,13 +28,11 @@ pub const CAPTION: Step = step("night-caption", 12., Web::Fixed, 1.4, 0., 400, f /// A screen uses two or three of these, plus [`crate::MICRO`] for paths and counts. pub const TYPE: [Step; 4] = [DISPLAY, TITLE, UI, CAPTION]; -/// Corners. A plate cuts each corner in two [`PIXEL_CORNER`] steps, like the sky's art, and its -/// 1 px stroke follows the steps. The main action (an accent fill: Play, Create, Install) cuts one -/// step. Other controls (buttons, fields, focus rings) round at [`RADIUS_CONTROL`]: at 1x a step -/// vanishes on a pale fill and reads as notches on a thin ring. Pills are [`crate::RADIUS_PILL`], -/// for chips only. -pub const PIXEL_CORNER: f32 = 4.; -pub const RADIUS_CONTROL: f32 = 8.; +/// Corners. The night look is square: plates, buttons, fields and focus rings have no rounded +/// corners and no stepped cuts. [`PIXEL_CORNER`] stays for the cut helpers' tests and callers; +/// zero draws a plain rectangle. +pub const PIXEL_CORNER: f32 = 0.; +pub const RADIUS_CONTROL: f32 = 0.; /// The main action (Play) is taller than the other controls. pub const PLAY: f32 = 56.; @@ -42,6 +40,22 @@ pub const PLAY: f32 = 56.; /// lower stops (the sunset) are too bright for light text, so text there always has a plate. pub const UNCARDED_STOPS: usize = 3; +/// The sky's hand-drawn art (the clouds, the sun and the two skylines): the colours a launcher +/// theme paints them with, beside the sky's own bands. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct SkyArt { + /// The sun's three bands, from its rim to its middle. + pub sun: [u32; 3], + /// The far and the near skyline. + pub far: u32, + pub near: u32, + /// The clouds far to near, and each layer's lit band. + pub cloud: [u32; 3], + pub lit: [u32; 3], + pub shade: u32, + pub star: u32, +} + #[derive(Clone, Copy, Debug, PartialEq)] pub struct Night { /// The sky from the top of the window to the horizon, drawn in bands. @@ -71,6 +85,8 @@ pub struct Night { pub warn: u32, pub danger: u32, pub dark: bool, + /// The sky's clouds, sun and skylines. + pub art: SkyArt, } pub const NIGHT: Night = Night { @@ -94,6 +110,15 @@ pub const NIGHT: Night = Night { warn: 0xf5b45a, danger: 0xff8a78, dark: true, + art: SkyArt { + sun: [0xffe28c, 0xffbf66, 0xff944f], + far: 0x3a1f5e, + near: 0x150f2e, + cloud: [0x3b2869, 0x6e3a7e, 0xc96a86], + lit: [0x5a3f8a, 0x9c5a92, 0xf7a98a], + shade: 0x2a1a4c, + star: 0xf4f2ff, + }, }; /// The light appearance: the same sky at dawn, pale blue to peach, with the night's violet. @@ -118,6 +143,15 @@ pub const DAWN: Night = Night { warn: 0x7a4f00, danger: 0xa3301d, dark: false, + art: SkyArt { + sun: [0xfffbea, 0xffeac2, 0xffd3a1], + far: 0xdcd3ee, + near: 0xbaafdd, + cloud: [0xeef0fb, 0xfbeef3, 0xfff1e4], + lit: [0xffffff; 3], + shade: 0xd9cbe8, + star: 0xffffff, + }, }; /// `top` at `alpha` over `under`, as the GPU blends them. diff --git a/crates/horizon-theme/tokens.css b/crates/horizon-theme/tokens.css index 678c47409d..2636585043 100644 --- a/crates/horizon-theme/tokens.css +++ b/crates/horizon-theme/tokens.css @@ -143,8 +143,8 @@ --index: 40px; --plate-bleed: 16px; --rise: 8px; - --night-pixel-corner: 4px; - --night-radius-control: 8px; + --night-pixel-corner: 0px; + --night-radius-control: 0px; --night-play: 56px; --measure: 56ch; --quick: 150ms; diff --git a/launcher/src/internet.rs b/launcher/src/internet.rs index 9d7e0b0164..763011ea7f 100644 --- a/launcher/src/internet.rs +++ b/launcher/src/internet.rs @@ -772,7 +772,7 @@ impl Launcher { .flex() .items_center() .gap(px(12.)) - .child(div().flex_none().size(px(6.)).rounded_full().bg(t.success)) + .child(div().flex_none().size(px(6.)).bg(t.success)) .child( text(theme::UI) .flex_1() diff --git a/launcher/src/language_page.rs b/launcher/src/language_page.rs index c7647a1b1d..0fbeb6e7c6 100644 --- a/launcher/src/language_page.rs +++ b/launcher/src/language_page.rs @@ -146,9 +146,7 @@ impl Launcher { .text_color(t.ink) .child(choice_name(choice)), ) - .when(chosen, |d| { - d.child(div().size(px(6.)).rounded_full().bg(t.accent)) - }) + .when(chosen, |d| d.child(div().size(px(6.)).bg(t.accent))) .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { this.pick_language(choice, cx) })), diff --git a/launcher/src/line_edit.rs b/launcher/src/line_edit.rs index 0526f55011..b2e1fdbc01 100644 --- a/launcher/src/line_edit.rs +++ b/launcher/src/line_edit.rs @@ -558,7 +558,7 @@ pub(super) fn content_with( .when(!selected.is_empty(), |d| { d.child( text(selected) - .rounded(px(2.)) + .rounded(px(if crate::glass_ui::on() { 2. } else { 0. })) .bg(t.accent.opacity(theme::SELECTION)), ) }) diff --git a/launcher/src/main.rs b/launcher/src/main.rs index 31da43875c..48e9c36abe 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -579,8 +579,7 @@ impl Launcher { // `window.appearance()`, so both draw the same look. titlebar::apply_appearance(window, settings.appearance); let mut this = Self { - theme: Theme::for_appearance(settings.appearance, settings.accent, window.appearance()) - .with_glass(settings.glass_tint(), settings.glass_blur()), + theme: crate::theme::from_settings(&settings, window.appearance()), page: Page::from_env(), switches: 0, content_scroll: Scrollbar::default(), @@ -3305,9 +3304,7 @@ impl Launcher { .when(latest, |d| { d.child(caps(tr("auto-update")).flex_none().text_color(t.muted)) }) - .when(selected, |d| { - d.child(div().size(px(6.)).rounded_full().bg(t.accent)) - }), + .when(selected, |d| d.child(div().size(px(6.)).bg(t.accent))), ) .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { this.pin_version((!latest).then(|| tag.clone())); @@ -3790,14 +3787,7 @@ impl Launcher { .text_size(px(theme::UI.px)) .line_height(px(21.)) .text_color(t.ink) - .child( - div() - .flex_none() - .mt(px(11.)) - .size(px(6.)) - .rounded_full() - .bg(t.danger), - ) + .child(div().flex_none().mt(px(11.)).size(px(6.)).bg(t.danger)) .child(body) } @@ -4045,6 +4035,7 @@ impl Launcher { Some(appearance.into_any_element()), &t, )) + .child(self.launcher_theme_row(cx)) .child(self.accent_row(cx)) .child(self.home_background_row(cx)) .child(self.language_row(cx)) @@ -5287,10 +5278,11 @@ impl Render for Launcher { if self.opening.is_none() && window.focused(cx).is_none() { window.focus(&self.root_focus, cx); } - // Dark: the new design's scene; Light keeps the dawn sky until the design has a light - // look. + // The glass look draws the new design's scene in the dark appearance; the light look and + // the blocky looks keep the baked sky. The sky is tiles that don't depend on the + // window's size, so a resize costs no rebake. glass::begin_build(window); - let sky = if t.dark { + let sky = if t.glass && t.dark { self.scene.clone().into_any_element() } else { let (sky, next) = self.sky.render( diff --git a/launcher/src/mods_project.rs b/launcher/src/mods_project.rs index 47108885fb..87e04726c7 100644 --- a/launcher/src/mods_project.rs +++ b/launcher/src/mods_project.rs @@ -413,7 +413,7 @@ impl Launcher { || (current.is_some() && value.is_none() && !sorts.iter().any(|(v, _)| *v == current)), - |d| d.child(div().size(px(6.)).rounded_full().bg(t.accent)), + |d| d.child(div().size(px(6.)).bg(t.accent)), ) .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { this.mods.browse.sort_menu = false; diff --git a/launcher/src/partner.rs b/launcher/src/partner.rs index d6ac26123a..84631c263e 100644 --- a/launcher/src/partner.rs +++ b/launcher/src/partner.rs @@ -151,7 +151,7 @@ fn headline() -> StyledText { } /// "HORIZON15 · 15% off" in a dashed cyan frame, as on Sculk's banner. -fn code_chip(size: f32, height: f32) -> Div { +fn code_chip(size: f32, height: f32, glass: bool) -> Div { div() .flex() .items_center() @@ -162,7 +162,7 @@ fn code_chip(size: f32, height: f32) -> Div { .border_1() .border_dashed() .border_color(rgba(CYAN, 0.7)) - .rounded(px(6.)) + .rounded(px(if glass { 6. } else { 0. })) .font_family(crate::theme::MONO) .text_size(px(size)) .whitespace_nowrap() @@ -203,7 +203,7 @@ pub fn card(id: &'static str, width: f32, compact: bool, t: &Theme) -> Stateful< .flex_none() .w(px(width)) .bg(rgb(CARD)) - .rounded(px(10.)) + .rounded(px(if t.glass { 10. } else { 0. })) .group_hover(id, |s| s.top(px(-2.))); let logo = |height: f32| { img(LOGO.clone()) @@ -251,7 +251,7 @@ pub fn card(id: &'static str, width: f32, compact: bool, t: &Theme) -> Stateful< .px(px(12.)) .pt(px(6.)) .pb(px(12.)) - .child(code_chip(11., 26.).flex_1()), + .child(code_chip(11., 26., t.glass).flex_1()), ) } else { let art_h = width * ART_HEIGHT / ART_WIDTH; @@ -260,7 +260,7 @@ pub fn card(id: &'static str, width: f32, compact: bool, t: &Theme) -> Stateful< .items_center() .justify_center() .h(px(36.)) - .rounded(px(8.)) + .rounded(px(if t.glass { 8. } else { 0. })) .bg(rgb(CYAN)) .group_hover(id, |s| s.bg(rgb(CYAN_HOVER))) .text_color(rgb(ON_CYAN)) @@ -304,7 +304,12 @@ pub fn card(id: &'static str, width: f32, compact: bool, t: &Theme) -> Stateful< .child(horizon_launcher::i18n::tr("Ready in under a minute")), ) .child(div().mt(px(16.)).child(button)) - .child(div().mt(px(8.)).flex().child(code_chip(12., 30.).flex_1())), + .child( + div() + .mt(px(8.)) + .flex() + .child(code_chip(12., 30., t.glass).flex_1()), + ), ) }; let el = div() diff --git a/launcher/src/servers_page.rs b/launcher/src/servers_page.rs index 861c2c2911..2ae0743e91 100644 --- a/launcher/src/servers_page.rs +++ b/launcher/src/servers_page.rs @@ -1570,7 +1570,7 @@ impl Launcher { .flex() .items_center() .gap(px(10.)) - .child(div().size(px(8.)).rounded_full().bg(dot)) + .child(div().size(px(8.)).bg(dot)) .child(caps(phase_label(&status)).text_color(t.muted)) .when_some(status.uptime.filter(|_| running), |d, up| { d.child(caps(format!("· up {}", uptime(up))).text_color(t.muted)) @@ -1691,7 +1691,7 @@ impl Launcher { .items_center() .gap(px(12.)) .p(px(16.)) - .child(div().flex_none().size(px(6.)).rounded_full().bg(t.success)) + .child(div().flex_none().size(px(6.)).bg(t.success)) .child( div() .flex_1() @@ -1714,7 +1714,7 @@ impl Launcher { .items_center() .gap(px(12.)) .p(px(16.)) - .child(div().flex_none().size(px(6.)).rounded_full().bg(t.accent)) + .child(div().flex_none().size(px(6.)).bg(t.accent)) .child( div() .flex_1() @@ -3054,7 +3054,6 @@ impl Launcher { .child( div() .size(px(12.)) - .rounded_full() .border_1() .border_color(if chosen { t.accent } else { t.muted }) .when(chosen, |d| d.bg(t.accent)), @@ -3128,7 +3127,6 @@ impl Launcher { .id(id) .flex_none() .size(px(18.)) - .rounded(px(4.)) .border_1() .border_color(if on { t.accent } else { t.muted }) .bg(if on { t.accent } else { t.surface }) diff --git a/launcher/src/sky.rs b/launcher/src/sky.rs index f57ead328e..be4c8bcfc2 100644 --- a/launcher/src/sky.rs +++ b/launcher/src/sky.rs @@ -9,7 +9,7 @@ //! motion, and stops 10 s after the window loses focus ([`motion`]). use crate::art::bake; use gpui::{div, img, prelude::*, px, rgb, Div, RenderImage}; -use horizon_theme::night::Night; +use horizon_theme::night::{Night, SkyArt}; use std::sync::{Arc, LazyLock}; use std::time::{Duration, Instant}; @@ -152,38 +152,6 @@ const SUN_RADIUS: usize = 15; const SUN_RISE: usize = 8; const SUN_X: f32 = 0.72; -/// The art's colours in one appearance, beside the night tokens' sky. -struct Colors { - sun: [u32; 3], - far: u32, - near: u32, - /// Clouds far to near, and each layer's lit band. - cloud: [u32; 3], - lit: [u32; 3], - shade: u32, - star: u32, -} - -const NIGHT_ART: Colors = Colors { - sun: [0xffe28c, 0xffbf66, 0xff944f], - far: 0x3a1f5e, - near: 0x150f2e, - cloud: [0x3b2869, 0x6e3a7e, 0xc96a86], - lit: [0x5a3f8a, 0x9c5a92, 0xf7a98a], - shade: 0x2a1a4c, - star: 0xf4f2ff, -}; - -const DAWN_ART: Colors = Colors { - sun: [0xfffbea, 0xffeac2, 0xffd3a1], - far: 0xdcd3ee, - near: 0xbaafdd, - cloud: [0xeef0fb, 0xfbeef3, 0xfff1e4], - lit: [0xffffff; 3], - shade: 0xd9cbe8, - star: 0xffffff, -}; - /// How much white the light look lays over the dawn's colours. const MORNING: f32 = 0.55; @@ -255,7 +223,7 @@ struct Layers { #[derive(Default)] pub struct Sky { - baked: Option<((bool, u32), Layers)>, + baked: Option<((SkyArt, u32), Layers)>, /// Images replaced by a new appearance or scale, still in the window's sprite atlas. replaced: Vec>, clock: Clock, @@ -285,9 +253,9 @@ impl Sky { }; self.clock.set(run, now); let t = self.clock.at(now).as_secs_f32(); - let layers = self.layers(n.dark, scale); + let layers = self.layers(n.art, scale); let snap = |v: f32| (v * scale).round() / scale; - let art = if n.dark { &NIGHT_ART } else { &DAWN_ART }; + let art = &n.art; let mut sky = div() .absolute() @@ -398,10 +366,10 @@ impl Sky { (sky, next) } - fn layers(&mut self, dark: bool, scale: f32) -> &Layers { - let key = (dark, (scale * 100.).round() as u32); + fn layers(&mut self, art: SkyArt, scale: f32) -> &Layers { + let key = (art, (scale * 100.).round() as u32); if !matches!(&self.baked, Some((k, _)) if *k == key) { - let art = if dark { &NIGHT_ART } else { &DAWN_ART }; + let art = &art; let layers = Layers { clouds: std::array::from_fn(|i| { let l = &CLOUDS[i]; @@ -532,7 +500,7 @@ fn stamp( } } -fn clouds(layer: &CloudLayer, art: &Colors, depth: usize) -> Vec<[u8; 4]> { +fn clouds(layer: &CloudLayer, art: &SkyArt, depth: usize) -> Vec<[u8; 4]> { let mut out = vec![[0; 4]; layer.period * CLOUD_ROWS]; for &(x, y, sprite) in layer.clouds { stamp(&mut out, layer.period, (x, y), sprite, |m| match m { @@ -578,7 +546,7 @@ fn skyline(line: &Skyline, color: u32, scale: f32) -> Arc { /// The upper half of a sun cut by the horizon, in three bands of colour, its lower half sliced by /// rows of sky like Horizon's mark. -fn sun(art: &Colors) -> Vec<[u8; 4]> { +fn sun(art: &SkyArt) -> Vec<[u8; 4]> { let (r, w) = (SUN_RADIUS as f32, SUN_RADIUS * 2); let mut out = vec![[0; 4]; w * SUN_RADIUS]; for y in 0..SUN_RADIUS { diff --git a/launcher/src/switch_page.rs b/launcher/src/switch_page.rs index b968f9d1e0..153ffd2421 100644 --- a/launcher/src/switch_page.rs +++ b/launcher/src/switch_page.rs @@ -119,9 +119,7 @@ impl Launcher { .child(text(theme::LABEL).truncate().text_color(t.ink).child(name)) .child(text(theme::CAPTION).text_color(t.muted).child(how)), ) - .when(chosen, |d| { - d.child(div().size(px(6.)).rounded_full().bg(t.accent)) - }) + .when(chosen, |d| d.child(div().size(px(6.)).bg(t.accent))) .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { this.play_as(who.clone()); cx.notify(); diff --git a/launcher/src/theme.rs b/launcher/src/theme.rs index f974e97c0c..e798c29461 100644 --- a/launcher/src/theme.rs +++ b/launcher/src/theme.rs @@ -3,8 +3,8 @@ //! Horizon's violet) in Bricolage Grotesque, with Geist Mono for paths and codes (all bundled, SIL //! Open Font License). Screens not rebuilt yet draw the Blocks parts in the night's colours. use gpui::{rgb, Hsla, WindowAppearance}; -use horizon_launcher::backend::Appearance; -use horizon_launcher::themes::Accent; +use horizon_launcher::backend::{Appearance, Settings}; +use horizon_launcher::themes::{self, Accent}; use horizon_theme as tokens; use std::time::Duration; @@ -74,6 +74,8 @@ pub struct Theme { pub on_face: Hsla, /// The night look's own tokens (the sky, plates with their opacity), for rebuilt screens. pub night: Night, + /// The accent this look was built from, before the glass look's mixes. + pub accent_choice: Accent, pub dark: bool, /// Whether the pages draw the redesign's glass look: both appearances do (light with ink /// for white and light plates, `glass_ui::light`). @@ -106,13 +108,36 @@ pub fn pinned_dark(choice: Appearance) -> Option { } } +/// The look Settings ask for: the appearance, accent and launcher theme, at the player's Glass +/// and Blur. +pub fn from_settings(settings: &Settings, system: WindowAppearance) -> Theme { + let mut theme = Theme::for_appearance(settings.appearance, settings.accent, system); + if let Some(picked) = themes::theme(settings.theme.as_deref()) { + theme = theme.with_launcher_theme(picked); + } + theme.with_glass(settings.glass_tint(), settings.glass_blur()) +} + impl Theme { /// The pinned look (`pinned_dark`: dark unless the player picked another), the system's when /// they asked to follow it, in the player's accent. - /// This look under another sky (a launcher theme from Settings), top to bottom. - pub fn with_sky(mut self, sky: [u32; 6]) -> Self { - self.night.sky = sky; - self + /// A launcher theme from Settings: its sky, or its whole palette (dark and light) when it + /// brings one, keeping the player's Glass and Blur. + pub fn with_launcher_theme(mut self, picked: &themes::Theme) -> Self { + let Some((dark, light)) = picked.palette else { + self.night.sky = picked.sky; + return self; + }; + let night = if self.dark { *dark } else { *light }; + let accent = self.accent_choice; + // A theme with its own palette draws the square, solid Blocks look: no glass plates, no + // rounded corners. The glass look's own colours stay the mockup's violet ones. + let mut themed = Self::from(accent.apply(night.blocks()), night, accent); + themed.look = tokens::glass::Glass::new(accent.rgb(), self.look.tint, self.look.blur); + if !themed.dark { + themed.look = light_look(themed.look); + } + themed } /// The glass look with the player's Glass (how much white the plates take, 0 to 1) and Blur @@ -132,7 +157,7 @@ impl Theme { ); let dark = pinned_dark(choice).unwrap_or(system_dark); let night = if dark { night::NIGHT } else { night::DAWN }; - Self::from(accent.apply(night.blocks()), night).glass(accent) + Self::from(accent.apply(night.blocks()), night, accent).glass(accent) } /// The dark appearance draws the glass look ("Glass" in `docs/design-system.md`): what the @@ -178,12 +203,13 @@ impl Theme { face_lip: none, on_face: c(look.on_accent), look, + accent_choice: accent, glass: true, ..self } } - fn from(p: tokens::Blocks, night: Night) -> Self { + fn from(p: tokens::Blocks, night: Night, accent: Accent) -> Self { Self { look: tokens::glass::Glass::default_look(), page: c(p.page), @@ -213,6 +239,7 @@ impl Theme { face_lip: c(p.face_lip), on_face: c(p.on_face), night, + accent_choice: accent, dark: p.dark, glass: false, } @@ -224,7 +251,28 @@ pub const SELECTION: f32 = 0.22; #[cfg(test)] mod tests { - use super::{tokens, SELECTION}; + use super::{from_settings, tokens, SELECTION}; + use gpui::WindowAppearance; + use horizon_launcher::backend::{Appearance, Settings}; + use horizon_launcher::themes::Accent; + + /// Nord asks for the solid, square Blocks look: no glass, and its own polar-night palette + /// with the frost accent rather than Horizon's violet glass colours. + #[test] + fn a_palette_theme_is_solid_and_square() { + let settings = Settings { + appearance: Appearance::Dark, + accent: Accent::from_rgb(0x88c0d0), + theme: Some("nord".into()), + ..Settings::default() + }; + let theme = from_settings(&settings, WindowAppearance::Dark); + assert!(!theme.glass, "Nord draws the blocks look"); + assert!(theme.dark); + assert_eq!(theme.night.page, 0x2e3440); + assert_eq!(theme.night.accent, 0x88c0d0); + assert_eq!(theme.accent_choice.rgb(), 0x88c0d0); + } /// `top` at `alpha` over `under`, as the GPU blends them. fn blend(top: u32, under: u32, alpha: f32) -> u32 { diff --git a/launcher/src/themes.rs b/launcher/src/themes.rs index 6e14daedf5..7b28e899a3 100644 --- a/launcher/src/themes.rs +++ b/launcher/src/themes.rs @@ -6,6 +6,7 @@ //! accent and the main action's face, bevel and lip) to it, keeping their saturation and //! lightness, then darkens or lightens each just enough to read as well as the violet does: white //! on the face at 4.5:1, the accent at 3:1 on every background. +use horizon_theme::night::{Night, SkyArt}; use horizon_theme::{contrast, Blocks}; use std::path::{Path, PathBuf}; @@ -19,27 +20,123 @@ pub struct Accent { rgb: Option, } +/// The accent a launcher theme brings: a hue in degrees (First Light) or one exact colour (Nord's +/// frost). +#[derive(Clone, Copy, Debug)] +pub enum ThemeAccent { + Hue(f32), + Colour(u32), +} + +impl ThemeAccent { + pub fn accent(self) -> Accent { + match self { + Self::Hue(hue) => Accent::custom(hue), + Self::Colour(rgb) => Accent::from_rgb(rgb), + } + } +} + /// A launcher theme: free for everyone in Settings › Appearance, never sold or claimed. Each /// has a sky (six stops, top to bottom, behind the light appearance) and the accent it brings. +/// A theme that brings a whole palette carries its dark and light [`Night`] and paints every +/// surface with it; the other themes only change the sky. pub struct Theme { /// Its name in `Settings::theme`. pub id: &'static str, /// Its name in Settings, in English (through `tr`). pub name: &'static str, pub sky: [u32; 6], - pub accent_hue: f32, + pub accent: ThemeAccent, + /// The theme's own palette (dark, light), for a theme that is more than a sky. + pub palette: Option<(&'static Night, &'static Night)>, } +/// Nord's polar night and snow storm, with the frost accent. +pub const NORD_DARK: Night = Night { + sky: [0x2e3440, 0x2e3440, 0x313845, 0x353d4b, 0x3b4252, 0x434c5e], + page: 0x2e3440, + side: 0x292e39, + card: 0x3b4252, + card_alpha: 0.88, + card_hi: 0x434c5e, + card_hi_alpha: 0.92, + stroke: 0xd8dee9, + stroke_alpha: 0.10, + rule_alpha: 0.10, + ink: 0xeceff4, + ink_soft: 0xd8dee9, + muted: 0xb6bfca, + accent: 0x88c0d0, + accent_hi: 0x9fd3d3, + on_accent: 0x2e3440, + success: 0xafc99a, + warn: 0xebcb8b, + danger: 0xeeb3b8, + dark: true, + art: SkyArt { + sun: [0x8fbcbb, 0x88c0d0, 0x5e81ac], + far: 0x3b4252, + near: 0x272c36, + cloud: [0x3b4252, 0x434c5e, 0x4c566a], + lit: [0x4c566a, 0x5e6a7d, 0x81a1c1], + shade: 0x272c36, + star: 0xeceff4, + }, +}; + +pub const NORD_LIGHT: Night = Night { + sky: [0xeceff4, 0xeceff4, 0xe5e9f0, 0xe5e9f0, 0xd8dee9, 0xc8d2e0], + page: 0xeceff4, + side: 0xe5e9f0, + card: 0xf8f9fb, + card_alpha: 0.90, + card_hi: 0xffffff, + card_hi_alpha: 0.95, + stroke: 0x4c566a, + stroke_alpha: 0.14, + rule_alpha: 0.10, + ink: 0x2e3440, + ink_soft: 0x434c5e, + muted: 0x536070, + accent: 0x4c6a92, + accent_hi: 0x4c6a92, + on_accent: 0xffffff, + success: 0x3f6b2e, + warn: 0x7a5d00, + danger: 0xa8323f, + dark: false, + art: SkyArt { + sun: [0xffffff, 0xeceff4, 0xd8dee9], + far: 0xd8dee9, + near: 0xc8d2e0, + cloud: [0xf8f9fb, 0xeceff4, 0xe5e9f0], + lit: [0xffffff; 3], + shade: 0xd8dee9, + star: 0xffffff, + }, +}; + /// First Light: the sky just before sunrise, with a harbour skyline. pub const FIRST_LIGHT: Theme = Theme { id: "first_light", name: "First Light", sky: [0x0b1030, 0x1d2a63, 0x3d4c94, 0x7f78c0, 0xe2a0b0, 0xffd6a0], - accent_hue: 246., + accent: ThemeAccent::Hue(246.), + palette: None, +}; + +/// Nord: the Nord palette's polar night, snow storm and frost. +pub const NORD: Theme = Theme { + id: "nord", + name: "Nord", + sky: NORD_DARK.sky, + accent: ThemeAccent::Colour(0x88c0d0), + palette: Some((&NORD_DARK, &NORD_LIGHT)), }; /// The launcher themes past Horizon's own sky. -pub const THEMES: [Theme; 1] = [FIRST_LIGHT]; +pub const THEMES: [Theme; 2] = [FIRST_LIGHT, NORD]; /// The theme named `id` (`Settings::theme`); None for Horizon's own sky or a name this launcher /// doesn't know. @@ -189,8 +286,14 @@ impl Accent { let (_, s, l) = hsl(c); (hue, s, l) }; - // The face holds white text. - let face = fit(turn(p.face), |c| contrast(p.on_face, c) >= 4.5, -1.); + // The face holds its label: white on a dark face, Nord's ink on a light one. Move the + // face away from the label until the two read. + let step = if contrast(p.on_face, 0x000000) < contrast(p.on_face, 0xffffff) { + 1. + } else { + -1. + }; + let face = fit(turn(p.face), |c| contrast(p.on_face, c) >= 4.5, step); let (_, _, face_l) = hsl(face); let (h, s, l) = turn(p.face_bevel); p.face_bevel = from_hsl(h, s, l.max(face_l + 0.08).min(0.92)); diff --git a/launcher/src/themes/tests.rs b/launcher/src/themes/tests.rs index ab488091a5..f7b5f05656 100644 --- a/launcher/src/themes/tests.rs +++ b/launcher/src/themes/tests.rs @@ -11,7 +11,12 @@ fn blend(top: u32, under: u32, alpha: f32) -> u32 { #[test] fn every_accent_reads_as_well_as_the_violet() { - for base in [NIGHT.blocks(), DAWN.blocks()] { + for base in [ + NIGHT.blocks(), + DAWN.blocks(), + NORD_DARK.blocks(), + NORD_LIGHT.blocks(), + ] { for accent in Accent::ALL .into_iter() .chain((0..720).map(|i| Accent::custom(i as f32 * 0.5))) @@ -61,6 +66,53 @@ fn colours_go_to_hsl_and_back() { assert_eq!(serde_json::to_string(&Accent::AMBER).unwrap(), "\"amber\""); } +#[test] +fn nord_text_reads_over_its_sky() { + use horizon_theme::night::{over, UNCARDED_STOPS}; + + for p in [NORD_DARK, NORD_LIGHT] { + let look = if p.dark { "dark" } else { "light" }; + let texts = [ + ("ink", p.ink), + ("ink_soft", p.ink_soft), + ("muted", p.muted), + ("accent_hi", p.accent_hi), + ("success", p.success), + ("warn", p.warn), + ("danger", p.danger), + ]; + for (i, &sky) in p.sky.iter().enumerate() { + for (plate, top, alpha) in [ + ("card", p.card, p.card_alpha), + ("card_hi", p.card_hi, p.card_hi_alpha), + ] { + let bg = over(top, alpha, sky); + for (tn, fg) in texts { + let ratio = contrast(fg, bg); + assert!( + ratio >= 4.5, + "{look} {tn} on {plate} over sky {i}: {ratio:.2}" + ); + } + } + } + for (i, &sky) in p.sky[..UNCARDED_STOPS].iter().enumerate() { + for (tn, fg) in &texts[..4] { + let ratio = contrast(*fg, sky); + assert!(ratio >= 4.5, "{look} {tn} on sky {i}: {ratio:.2}"); + } + } + for (bn, bg) in [("page", p.page), ("side", p.side)] { + for (tn, fg) in &texts[..4] { + let ratio = contrast(*fg, bg); + assert!(ratio >= 4.5, "{look} {tn} on {bn}: {ratio:.2}"); + } + } + let label = contrast(p.on_accent, p.accent); + assert!(label >= 4.5, "{look} the main action's label: {label:.2}"); + } +} + #[test] fn a_background_is_a_picture_kept_once() { let root = std::env::temp_dir().join(format!("horizon-background-{}", std::process::id())); diff --git a/launcher/src/themes_page.rs b/launcher/src/themes_page.rs index 5ccd08abd0..77e40bf015 100644 --- a/launcher/src/themes_page.rs +++ b/launcher/src/themes_page.rs @@ -46,31 +46,22 @@ impl Launcher { /// The theme Settings ask for, under the window's own light or dark: the player's look, /// under the launcher theme's sky if they picked one. pub(super) fn settings_theme(&self, window: &Window) -> Theme { - let theme = Theme::for_appearance( - self.settings.appearance, - self.settings.accent, - window.appearance(), - ) - .with_glass(self.settings.glass_tint(), self.settings.glass_blur()); - match themes::theme(self.settings.theme.as_deref()) { - Some(picked) => theme.with_sky(picked.sky), - None => theme, - } + crate::theme::from_settings(&self.settings, window.appearance()) } - /// Settings' launcher theme: Horizon's own sky or First Light, free for everyone. Picking one - /// brings its accent too; the accent row can change it after. + /// Settings' launcher theme: Horizon's own sky, First Light or Nord, free for everyone. + /// Picking one brings its accent too; the accent row can change it after. pub(super) fn launcher_theme_row(&mut self, cx: &mut Context) -> Div { let t = self.theme; let current = themes::theme(self.settings.theme.as_deref()).map(|th| th.id); let mut options: Vec<(Option<&'static str>, &'static str)> = vec![(None, "Horizon")]; options.extend(themes::THEMES.iter().map(|th| (Some(th.id), th.name))); let this = cx.entity().downgrade(); - let picker = ui::segments("launcher-theme", &options, current, &t, move |id, _, cx| { + let picker = segments("launcher-theme", &options, current, &t, move |id, _, cx| { let _ = this.update(cx, |this, cx| { this.settings.theme = id.map(str::to_owned); this.settings.accent = match themes::theme(id) { - Some(picked) => Accent::custom(picked.accent_hue), + Some(picked) => picked.accent.accent(), None => Accent::VIOLET, }; this.save_settings(); @@ -78,7 +69,8 @@ impl Launcher { cx.notify(); }); }); - ui::setting_row( + index_row( + 0, tr("Launcher theme"), Some( div() @@ -88,6 +80,7 @@ impl Launcher { .into_any_element(), ), Some(picker.into_any_element()), + &t, ) }