Merge pull request #11218 from oxi-does-dev/feat/launcher-nord

Launcher: Nord theme and the theme picker in the Blocks look
This commit is contained in:
rdbt authored and GitHub committed 2026-11-07 12:36:10 +00:00
commit 4ebee0d01e
15 files changed
+305 -116

No files matched your search

+41 -7
View File
@@ -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.
+2 -2
View File
@@ -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;
+1 -1
View File
@@ -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()
+1 -3
View File
@@ -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)
})),
+1 -1
View File
@@ -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)),
)
})
+8 -16
View File
@@ -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(
+1 -1
View File
@@ -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;
+11 -6
View File
@@ -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()
+3 -5
View File
@@ -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 })
+9 -41
View File
@@ -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<Arc<RenderImage>>,
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<RenderImage> {
/// 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 {
+1 -3
View File
@@ -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();
+57 -9
View File
@@ -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<bool> {
}
}
/// 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 {
+108 -5
View File
@@ -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<u32>,
}
/// 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));
+53 -1
View File
@@ -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()));
+8 -15
View File
@@ -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<Self>) -> 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,
)
}