From 8beaa57331d3bf1d89d93eda0c09feeb4b49dca7 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:07:38 +0100 Subject: [PATCH] launcher: an accent colour and a picture behind Home Settings > Launcher picks an accent (violet by default, blue, teal, green, amber, red, pink) and the player's own picture behind Home. An accent turns the violet tokens to its hue and adjusts their lightness until text on the Play button and the accent on every background read as well as the violet's. The picture is checked, copied under a name of its hash and shown cropped under the banner's veils. CLI: settings accent NAME, settings background PATH|default. Co-Authored-By: Claude Opus 5.5 --- docs/launcher-commands.md | 4 +- launcher/README.md | 7 ++ launcher/lang/fr.json | 10 +- launcher/lang/ru.json | 10 +- launcher/src/backend.rs | 29 +++++ launcher/src/cli.rs | 26 ++++- launcher/src/lib.rs | 1 + launcher/src/main.rs | 36 +++++-- launcher/src/theme.rs | 22 ++-- launcher/src/themes.rs | 201 +++++++++++++++++++++++++++++++++++ launcher/src/themes/tests.rs | 107 +++++++++++++++++++ launcher/src/themes_page.rs | 155 +++++++++++++++++++++++++++ 12 files changed, 577 insertions(+), 31 deletions(-) create mode 100644 launcher/src/themes.rs create mode 100644 launcher/src/themes/tests.rs create mode 100644 launcher/src/themes_page.rs diff --git a/docs/launcher-commands.md b/docs/launcher-commands.md index f5fea0192..900a10283 100644 --- a/docs/launcher-commands.md +++ b/docs/launcher-commands.md @@ -161,11 +161,11 @@ MCP: `account_skin`. ## `horizon-launcher settings` -Show the launcher's settings, or set one (the Settings page): selected-version TAG, minecraft-dir PATH, server-dir PATH ("default" resets a folder), install-new-versions on|off, opening-animation on|off, proxy http://host:port ("default" removes it), while-playing stay|minimize|hide (what the window does while the game runs), language system|en|fr|ru (the window's language; system follows the computer's). +Show the launcher's settings, or set one (the Settings page): selected-version TAG, minecraft-dir PATH, server-dir PATH ("default" resets a folder), install-new-versions on|off, opening-animation on|off, proxy http://host:port ("default" removes it), while-playing stay|minimize|hide (what the window does while the game runs), language system|en|fr|ru (the window's language; system follows the computer's), accent violet|blue|teal|green|amber|red|pink, background PATH (a picture behind Home; "default" shows Horizon's landscape). horizon-launcher settings [KEY] [VALUE] -- `KEY` (selected-version, minecraft-dir, server-dir, install-new-versions, opening-animation, proxy, while-playing, language) +- `KEY` (selected-version, minecraft-dir, server-dir, install-new-versions, opening-animation, proxy, while-playing, language, accent, background) - `VALUE`: The new value (with KEY) MCP: `settings`. diff --git a/launcher/README.md b/launcher/README.md index 9b617064b..6aec8b633 100644 --- a/launcher/README.md +++ b/launcher/README.md @@ -129,6 +129,13 @@ The opening, then Play ([clip](docs/opening.webm)): the Console's Share log takes them out of the log too. `horizon-launcher account offline [NAME]`, `account select ` (an account or an offline name), `account forget-offline `. +- **Accent and Home background**: on top of Dark, Light or System, Settings > Launcher picks an + accent (Horizon's violet, blue, teal, green, amber, red or pink) and a picture of the player's + own behind Home (PNG, JPEG or WebP, cropped to fill; **Landscape** brings Horizon's back). An + accent turns the violet tokens to its hue, then darkens or lightens them just enough to keep + white on the Play button at 4.5:1 and the accent at 3:1 on every background (a test holds every + accent in both looks to it). The picture is copied into the data folder. + `horizon-launcher settings accent NAME`, `settings background PATH|default`. - **Language**: English, French and Russian (the languages Horizon's own game menus have). The window follows the system's language unless the player picks one in Settings > Launcher (each named in itself); a string a table lacks shows in English. The tables are diff --git a/launcher/lang/fr.json b/launcher/lang/fr.json index 719f6fd52..d2181dfcc 100644 --- a/launcher/lang/fr.json +++ b/launcher/lang/fr.json @@ -83,5 +83,13 @@ "Choose a cape…": "Choisir une cape…", "No cape": "Pas de cape", "Default": "Par défaut", - "A 64×64 (or 64×32) skin PNG and a 64×32 cape PNG, kept by the launcher and given to the game when this name plays. The game draws the default skins for now; it uses these once it can. A Microsoft account's skin and cape: coming soon.": "Un PNG de skin 64×64 (ou 64×32) et un PNG de cape 64×32, gardés par le launcher et donnés au jeu quand ce nom joue. Le jeu dessine encore les skins par défaut ; il utilisera ceux-ci dès qu'il le pourra. Skin et cape d'un compte Microsoft : bientôt." + "A 64×64 (or 64×32) skin PNG and a 64×32 cape PNG, kept by the launcher and given to the game when this name plays. The game draws the default skins for now; it uses these once it can. A Microsoft account's skin and cape: coming soon.": "Un PNG de skin 64×64 (ou 64×32) et un PNG de cape 64×32, gardés par le launcher et donnés au jeu quand ce nom joue. Le jeu dessine encore les skins par défaut ; il utilisera ceux-ci dès qu'il le pourra. Skin et cape d'un compte Microsoft : bientôt.", + "Accent colour": "Couleur d'accent", + "Horizon's violet by default. Every colour keeps text as easy to read.": "Le violet d'Horizon par défaut. Chaque couleur garde le texte aussi lisible.", + "Choose": "Choisir", + "Choose a picture…": "Choisir une image…", + "Landscape": "Paysage", + "Your picture, behind Home, cropped to fill it.": "Votre image, derrière l'accueil, recadrée pour le remplir.", + "Horizon's landscape. Pick a PNG, JPEG or WebP picture of your own.": "Le paysage d'Horizon. Choisissez votre propre image PNG, JPEG ou WebP.", + "Home background": "Fond de l'accueil" } diff --git a/launcher/lang/ru.json b/launcher/lang/ru.json index 127cda928..d47b03cf0 100644 --- a/launcher/lang/ru.json +++ b/launcher/lang/ru.json @@ -83,5 +83,13 @@ "Choose a cape…": "Выбрать плащ…", "No cape": "Без плаща", "Default": "По умолчанию", - "A 64×64 (or 64×32) skin PNG and a 64×32 cape PNG, kept by the launcher and given to the game when this name plays. The game draws the default skins for now; it uses these once it can. A Microsoft account's skin and cape: coming soon.": "PNG скина 64×64 (или 64×32) и PNG плаща 64×32 хранятся в лаунчере и передаются игре, когда играет это имя. Пока игра рисует стандартные скины; она начнёт использовать эти, как только сможет. Скин и плащ аккаунта Microsoft: скоро." + "A 64×64 (or 64×32) skin PNG and a 64×32 cape PNG, kept by the launcher and given to the game when this name plays. The game draws the default skins for now; it uses these once it can. A Microsoft account's skin and cape: coming soon.": "PNG скина 64×64 (или 64×32) и PNG плаща 64×32 хранятся в лаунчере и передаются игре, когда играет это имя. Пока игра рисует стандартные скины; она начнёт использовать эти, как только сможет. Скин и плащ аккаунта Microsoft: скоро.", + "Accent colour": "Цвет акцента", + "Horizon's violet by default. Every colour keeps text as easy to read.": "По умолчанию фиолетовый Horizon. С любым цветом текст читается так же легко.", + "Choose": "Выбрать", + "Choose a picture…": "Выбрать картинку…", + "Landscape": "Пейзаж", + "Your picture, behind Home, cropped to fill it.": "Ваша картинка за главной страницей, обрезанная по размеру.", + "Horizon's landscape. Pick a PNG, JPEG or WebP picture of your own.": "Пейзаж Horizon. Выберите свою картинку PNG, JPEG или WebP.", + "Home background": "Фон главной" } diff --git a/launcher/src/backend.rs b/launcher/src/backend.rs index 462b2cdb3..3c2c3d3e4 100644 --- a/launcher/src/backend.rs +++ b/launcher/src/backend.rs @@ -2604,6 +2604,13 @@ pub struct Settings { /// What the window does while the game runs. Stays by default. #[serde(default)] pub while_playing: WhilePlaying, + /// The accent colour on top of the appearance (`themes::Accent`). Horizon's violet by default. + #[serde(default)] + pub accent: crate::themes::Accent, + /// Home's background: a picture the player picked, kept in the data folder under this name + /// (`themes::keep_background`). Unset shows Horizon's landscape. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub background: Option, /// The window's language. Unset follows the system's (`i18n::Language::system`). #[serde(default, skip_serializing_if = "Option::is_none")] pub language: Option, @@ -2631,6 +2638,11 @@ impl Settings { { fields.remove("while_playing"); } + if fields.get("accent").is_some_and(|a| { + serde_json::from_value::(a.clone()).is_err() + }) { + fields.remove("accent"); + } // A language this launcher doesn't have yet follows the system's. if fields.get("language").is_some_and(|l| { serde_json::from_value::>(l.clone()).is_err() @@ -2990,6 +3002,23 @@ mod tests { assert!(saved.get("language").is_none(), "unset is left out"); } + #[test] + fn the_accent_is_violet_unless_picked_and_an_unknown_one_keeps_the_rest() { + use crate::themes::Accent; + assert_eq!(Settings::parse("{}").accent, Accent::Violet); + let teal = + Settings::parse(r#"{"accent": "teal", "background": "background-0123456789ab.png"}"#); + assert_eq!( + (teal.accent, teal.background.as_deref()), + (Accent::Teal, Some("background-0123456789ab.png")) + ); + let future = Settings::parse(r#"{"accent": "plaid", "game_args": "--x"}"#); + assert_eq!( + (future.accent, future.game_args.as_str()), + (Accent::Violet, "--x") + ); + } + #[test] fn the_appearance_is_dark_unless_picked_and_an_unknown_one_keeps_the_rest() { assert_eq!(Settings::parse("{}").appearance, Appearance::Dark); diff --git a/launcher/src/cli.rs b/launcher/src/cli.rs index ff7efcbc7..cf7770c65 100644 --- a/launcher/src/cli.rs +++ b/launcher/src/cli.rs @@ -604,7 +604,9 @@ enum Cmd { /// minecraft-dir PATH, server-dir PATH ("default" resets a folder), install-new-versions /// on|off, opening-animation on|off, proxy http://host:port ("default" removes it), /// while-playing stay|minimize|hide (what the window does while the game runs), language - /// system|en|fr|ru (the window's language; system follows the computer's) + /// system|en|fr|ru (the window's language; system follows the computer's), accent + /// violet|blue|teal|green|amber|red|pink, background PATH (a picture behind Home; "default" + /// shows Horizon's landscape) Settings { #[arg(value_parser = SETTING_KEYS)] key: Option, @@ -741,7 +743,7 @@ pub fn command() -> clap::Command { pub const AI_COMMANDS: [&str; 2] = ["mcp", "agent-config"]; /// The keys `settings` sets. -const SETTING_KEYS: [&str; 8] = [ +const SETTING_KEYS: [&str; 10] = [ "selected-version", "minecraft-dir", "server-dir", @@ -750,6 +752,8 @@ const SETTING_KEYS: [&str; 8] = [ "proxy", "while-playing", "language", + "accent", + "background", ]; fn on_off(value: &str) -> Result { @@ -772,6 +776,22 @@ fn set_setting(s: &mut backend::Settings, key: &str, value: &str) -> Result<(), s.while_playing = backend::WhilePlaying::parse(value) .ok_or_else(|| format!("{value}: say stay, minimize or hide"))? } + "accent" => { + use horizon_launcher::themes::Accent; + s.accent = Accent::parse(value).ok_or_else(|| { + let names: Vec<_> = Accent::ALL.iter().map(|a| a.name()).collect(); + format!("{value}: say {}", names.join(", ")) + })? + } + "background" => { + s.background = match value.trim() { + "default" | "" => None, + file => Some(horizon_launcher::themes::keep_background( + &backend::data_dir(), + std::path::Path::new(file), + )?), + } + } "language" => { use horizon_launcher::i18n::Language; s.language = match value.trim() { @@ -809,6 +829,8 @@ fn settings_json(s: &backend::Settings) -> serde_json::Value { "desktop_entry": s.desktop_entry.unwrap_or(true), "while_playing": s.while_playing.name(), "language": s.language.map_or("system", |l| l.code()), + "accent": s.accent.name(), + "background": horizon_launcher::themes::background(&backend::data_dir(), s.background.as_deref()), "no_ai": backend::no_ai(), }) } diff --git a/launcher/src/lib.rs b/launcher/src/lib.rs index d971c9667..43b28f42e 100644 --- a/launcher/src/lib.rs +++ b/launcher/src/lib.rs @@ -29,4 +29,5 @@ pub mod skins; pub mod sources; pub mod tailscale; pub mod telemetry; +pub mod themes; pub mod worlds; diff --git a/launcher/src/main.rs b/launcher/src/main.rs index 67b6d0f84..e1c0e8ca9 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -36,6 +36,7 @@ mod shortcut_page; mod skin_page; mod switch_page; mod theme; +mod themes_page; mod titlebar; mod while_playing; mod worlds_page; @@ -172,6 +173,8 @@ struct Launcher { account_menu: bool, /// Settings > Account's skin panel for an offline name (`skin_page.rs`). skin: Option, + /// Why the last picture for Home's background was refused. + background_error: Option, settings: Settings, /// The Discord, sponsor and website links: built in until links.json on main has been read. links: backend::Links, @@ -277,7 +280,7 @@ impl Launcher { fn new(window: &mut Window, cx: &mut Context) -> Self { let (tx, rx) = channel(); let appearance = cx.observe_window_appearance(window, |this, window, cx| { - this.theme = Theme::for_appearance(this.settings.appearance, window.appearance()); + this.theme = this.settings_theme(window); cx.notify(); }); cx.spawn(async move |this, cx| loop { @@ -344,7 +347,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, window.appearance()), + theme: Theme::for_appearance(settings.appearance, settings.accent, window.appearance()), page: Page::from_env(), switches: 0, content_scroll: Scrollbar::default(), @@ -373,6 +376,7 @@ impl Launcher { picker_open: false, account_menu: false, skin: None, + background_error: None, args_input: settings.game_args.clone(), args_focused: false, args_focus: cx.focus_handle(), @@ -2218,6 +2222,21 @@ impl Launcher { gpui::linear_color_stop(t.page.opacity(to), at), )) }; + let picture = self.home_background(); + let scene = match picture { + Some(path) => gpui::img(path) + .absolute() + .top_0() + .left_0() + .size_full() + .object_fit(gpui::ObjectFit::Cover), + None => gpui::img(scene) + .absolute() + .bottom_0() + .left_0() + .w(px(scene_width)) + .h(px(art::SCENE_HEIGHT as f32 * art::SCENE_TEXEL)), + }; let banner = div() .relative() .flex() @@ -2232,14 +2251,7 @@ impl Launcher { .bg(gpui::rgb(art::sky_top(t.dark))) .border_b_2() .border_color(t.outline) - .child( - gpui::img(scene) - .absolute() - .bottom_0() - .left_0() - .w(px(scene_width)) - .h(px(art::SCENE_HEIGHT as f32 * art::SCENE_TEXEL)), - ) + .child(scene) .child(veil(90., 0.82, 0., 0.62)) .child(veil(180., 0., 0.75, 1.)) .child(div().relative().child(hero)) @@ -2679,7 +2691,7 @@ impl Launcher { this.settings.appearance = choice; this.settings.save(); titlebar::apply_appearance(window, choice); - this.theme = Theme::for_appearance(choice, window.appearance()); + this.theme = this.settings_theme(window); cx.notify(); }); } @@ -2722,6 +2734,8 @@ impl Launcher { Some(appearance.into_any_element()), &t, )) + .child(self.accent_row(cx)) + .child(self.home_background_row(cx)) .child(self.language_row(cx)) .child(self.while_playing_row(n + 3, cx)) .child(index_row( diff --git a/launcher/src/theme.rs b/launcher/src/theme.rs index 9d495a598..8ef6c43dc 100644 --- a/launcher/src/theme.rs +++ b/launcher/src/theme.rs @@ -4,6 +4,7 @@ //! Mono for text (all bundled, SIL Open Font License). use gpui::{rgb, Hsla, WindowAppearance}; use horizon_launcher::backend::Appearance; +use horizon_launcher::themes::Accent; use horizon_theme as tokens; use std::time::Duration; @@ -91,25 +92,18 @@ pub fn pinned_dark(choice: Appearance) -> Option { impl Theme { /// The pinned look (`pinned_dark`: dark unless the player picked another), the system's when - /// they asked to follow it. - pub fn for_appearance(choice: Appearance, system: WindowAppearance) -> Self { + /// they asked to follow it, in the player's accent. + pub fn for_appearance(choice: Appearance, accent: Accent, system: WindowAppearance) -> Self { let system_dark = !matches!( system, WindowAppearance::Light | WindowAppearance::VibrantLight ); - if pinned_dark(choice).unwrap_or(system_dark) { - Self::dark() + let dark = pinned_dark(choice).unwrap_or(system_dark); + Self::from(accent.apply(if dark { + tokens::BLOCKS_DARK } else { - Self::light() - } - } - - pub fn dark() -> Self { - Self::from(tokens::BLOCKS_DARK) - } - - pub fn light() -> Self { - Self::from(tokens::BLOCKS_LIGHT) + tokens::BLOCKS_LIGHT + })) } fn from(p: tokens::Blocks) -> Self { diff --git a/launcher/src/themes.rs b/launcher/src/themes.rs new file mode 100644 index 000000000..7d12441bd --- /dev/null +++ b/launcher/src/themes.rs @@ -0,0 +1,201 @@ +//! The player's own look, on top of Dark, Light or System: an accent colour and a picture behind +//! Home. +//! +//! An accent is a hue. Horizon's violet is the default; another hue turns the violet tokens +//! (`horizon_theme::Blocks`' 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::{contrast, Blocks}; +use std::path::{Path, PathBuf}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Accent { + #[default] + Violet, + Blue, + Teal, + Green, + Amber, + Red, + Pink, +} + +impl Accent { + pub const ALL: [Accent; 7] = [ + Self::Violet, + Self::Blue, + Self::Teal, + Self::Green, + Self::Amber, + Self::Red, + Self::Pink, + ]; + + /// Its name in `settings.json` and on the command line. + pub fn name(self) -> &'static str { + match self { + Self::Violet => "violet", + Self::Blue => "blue", + Self::Teal => "teal", + Self::Green => "green", + Self::Amber => "amber", + Self::Red => "red", + Self::Pink => "pink", + } + } + + pub fn parse(name: &str) -> Option { + let name = name.trim().to_ascii_lowercase(); + let name = if name == "purple" || name == "default" { + "violet" + } else { + &name + }; + Self::ALL.into_iter().find(|a| a.name() == name) + } + + /// Its hue in degrees; None for the violet, whose tokens stay as they are. + fn hue(self) -> Option { + match self { + Self::Violet => None, + Self::Blue => Some(212.), + Self::Teal => Some(176.), + Self::Green => Some(140.), + Self::Amber => Some(36.), + Self::Red => Some(356.), + Self::Pink => Some(322.), + } + } + + /// `palette` with this accent. + pub fn apply(self, mut p: Blocks) -> Blocks { + let Some(hue) = self.hue() else { + return p; + }; + let turn = |c: u32| { + 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.); + 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)); + let (h, s, l) = turn(p.face_lip); + p.face_lip = from_hsl(h, s, l.min(face_l - 0.12).max(0.05)); + p.face = face; + // The accent marks things on every background: lighter on dark, darker on light. + let grounds = [p.page, p.raised, p.button, p.panel, p.well]; + let reads = |c: u32| grounds.iter().all(|&g| contrast(c, g) >= 3.2); + p.accent = fit(turn(p.accent), reads, if p.dark { 1. } else { -1. }); + p + } +} + +/// The colour's hue (degrees), saturation and lightness (0 to 1). +fn hsl(c: u32) -> (f32, f32, f32) { + let [r, g, b] = [16, 8, 0].map(|s| ((c >> s) & 255) as f32 / 255.); + let (max, min) = (r.max(g).max(b), r.min(g).min(b)); + let l = (max + min) / 2.; + if max == min { + return (0., 0., l); + } + let d = max - min; + let s = if l > 0.5 { + d / (2. - max - min) + } else { + d / (max + min) + }; + let h = if max == r { + (g - b) / d + if g < b { 6. } else { 0. } + } else if max == g { + (b - r) / d + 2. + } else { + (r - g) / d + 4. + }; + (h * 60., s, l) +} + +fn from_hsl(h: f32, s: f32, l: f32) -> u32 { + let c = (1. - (2. * l - 1.).abs()) * s; + let x = c * (1. - ((h / 60.) % 2. - 1.).abs()); + let m = l - c / 2.; + let (r, g, b) = match (h.rem_euclid(360.) / 60.) as u32 { + 0 => (c, x, 0.), + 1 => (x, c, 0.), + 2 => (0., c, x), + 3 => (0., x, c), + 4 => (x, 0., c), + _ => (c, 0., x), + }; + let byte = |v: f32| ((v + m).clamp(0., 1.) * 255.).round() as u32; + byte(r) << 16 | byte(g) << 8 | byte(b) +} + +/// The colour, made lighter (`step` 1) or darker (-1) a little at a time until `good` holds. +fn fit((h, s, l): (f32, f32, f32), good: impl Fn(u32) -> bool, step: f32) -> u32 { + let mut l = l; + for _ in 0..100 { + let c = from_hsl(h, s, l); + if good(c) || !(0.0..=1.0).contains(&l) { + return c; + } + l += step * 0.01; + } + from_hsl(h, s, l.clamp(0., 1.)) +} + +/// The pictures Home can show: what the decoders read. +const PICTURES: [&str; 4] = ["png", "jpg", "jpeg", "webp"]; +/// A picture above this is refused: a screen's worth is far less. +const MAX_BYTES: u64 = 40 << 20; + +/// Keeps `file` as Home's background in `dir` (`background-.`: a new picture gets a +/// new name, so the window doesn't show the one it cached), after checking it is a picture, and +/// returns the kept file's name. An older background goes. +pub fn keep_background(dir: &Path, file: &Path) -> Result { + let shown = file.display(); + let ext = file + .extension() + .and_then(|e| e.to_str()) + .map(str::to_ascii_lowercase) + .filter(|e| PICTURES.contains(&e.as_str())) + .ok_or_else(|| format!("{shown}: a PNG, JPEG or WebP picture, please."))?; + let size = std::fs::metadata(file) + .map_err(|e| format!("{shown}: {e}"))? + .len(); + if size > MAX_BYTES { + return Err(format!("{shown} is over 40 MB.")); + } + let bytes = std::fs::read(file).map_err(|e| format!("{shown}: {e}"))?; + let (w, h) = image::ImageReader::new(std::io::Cursor::new(&bytes)) + .with_guessed_format() + .ok() + .and_then(|r| r.into_dimensions().ok()) + .ok_or_else(|| format!("{shown} isn't a picture the launcher can read."))?; + if w < 64 || h < 64 { + return Err(format!("{shown} is {w}×{h}: too small to fill Home.")); + } + std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + let hash = crate::sources::sha1_hex(&bytes); + let name = format!("background-{}.{ext}", &hash[..12]); + std::fs::write(dir.join(&name), &bytes).map_err(|e| format!("{name}: {e}"))?; + for old in std::fs::read_dir(dir).into_iter().flatten().flatten() { + let other = old.file_name().to_string_lossy().into_owned(); + if other.starts_with("background-") && other != name { + let _ = std::fs::remove_file(old.path()); + } + } + Ok(name) +} + +/// Home's background, if the player keeps one and it is still there. +pub fn background(dir: &Path, name: Option<&str>) -> Option { + let name = name.filter(|n| n.starts_with("background-") && !n.contains(['/', '\\']))?; + Some(dir.join(name)).filter(|p| p.is_file()) +} + +#[cfg(test)] +#[path = "themes/tests.rs"] +mod tests; diff --git a/launcher/src/themes/tests.rs b/launcher/src/themes/tests.rs new file mode 100644 index 000000000..e66a5e083 --- /dev/null +++ b/launcher/src/themes/tests.rs @@ -0,0 +1,107 @@ +use super::*; +use horizon_theme::{BLOCKS_DARK, BLOCKS_LIGHT}; + +fn blend(top: u32, under: u32, alpha: f32) -> u32 { + let channel = |shift: u32| { + let (a, b) = ((top >> shift & 0xff) as f32, (under >> shift & 0xff) as f32); + ((a * alpha + b * (1. - alpha)).round() as u32) << shift + }; + channel(16) | channel(8) | channel(0) +} + +#[test] +fn every_accent_reads_as_well_as_the_violet() { + for base in [BLOCKS_DARK, BLOCKS_LIGHT] { + for accent in Accent::ALL { + let p = accent.apply(base); + let what = format!( + "{} {}", + accent.name(), + if p.dark { "dark" } else { "light" } + ); + let on_face = contrast(p.on_face, p.face); + assert!(on_face >= 4.5, "{what}: text on the face {on_face:.2}"); + for g in [p.page, p.raised, p.button, p.panel, p.well] { + let ring = contrast(p.accent, g); + assert!(ring >= 3.0, "{what}: accent on {g:06x} {ring:.2}"); + } + // Text over a selected suggestion (the launcher's theme::SELECTION). + let selected = contrast(p.ink, blend(p.accent, p.panel, 0.22)); + assert!(selected >= 4.5, "{what}: text on a selection {selected:.2}"); + let (_, _, face) = hsl(p.face); + assert!( + hsl(p.face_bevel).2 > face && hsl(p.face_lip).2 < face, + "{what}" + ); + if accent != Accent::Violet { + let (h, _, _) = hsl(p.face); + let want = accent.hue().unwrap(); + let off = ((h - want).rem_euclid(360.)).min((want - h).rem_euclid(360.)); + assert!(off < 3., "{what}: face hue {h:.0}, not {want}"); + } + } + let violet = Accent::Violet.apply(base); + assert_eq!((violet.accent, violet.face), (base.accent, base.face)); + } +} + +#[test] +fn colours_go_to_hsl_and_back() { + for c in [ + 0x5b3fd6, 0x9a86ff, 0x000000, 0xffffff, 0x45c27a, 0xf0b84f, 0x123456, + ] { + let (h, s, l) = hsl(c); + assert_eq!(from_hsl(h, s, l), c, "{c:06x}"); + } + assert_eq!(Accent::parse("Teal"), Some(Accent::Teal)); + assert_eq!(Accent::parse("purple"), Some(Accent::Violet)); + assert_eq!(Accent::parse("chartreuse"), None); + assert_eq!(serde_json::to_string(&Accent::Amber).unwrap(), "\"amber\""); +} + +#[test] +fn a_background_is_a_picture_kept_once() { + let root = std::env::temp_dir().join(format!("horizon-background-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + let picture = |name: &str, w: u32, h: u32| { + let path = root.join(name); + image::RgbImage::new(w, h).save(&path).unwrap(); + path + }; + let dir = root.join("data"); + let png = picture("dusk.png", 160, 90); + let first = keep_background(&dir, &png).unwrap(); + assert!( + first.starts_with("background-") && first.ends_with(".png"), + "{first}" + ); + let jpg = picture("noon.jpg", 160, 90); + let second = keep_background(&dir, &jpg).unwrap(); + assert!(second.ends_with(".jpg") && second != first, "{second}"); + assert!(!dir.join(&first).exists(), "the older one goes"); + assert_eq!(background(&dir, Some(&second)), Some(dir.join(&second))); + assert_eq!(background(&dir, Some(&first)), None, "gone"); + assert_eq!(background(&dir, Some("../noon.jpg")), None); + assert_eq!(background(&dir, None), None); + + let tiny = picture("tiny.png", 16, 16); + assert!(keep_background(&dir, &tiny) + .unwrap_err() + .contains("too small")); + let text = root.join("notes.txt"); + std::fs::write(&text, "hi").unwrap(); + assert!(keep_background(&dir, &text) + .unwrap_err() + .contains("PNG, JPEG or WebP")); + let fake = root.join("fake.png"); + std::fs::write(&fake, "not a picture").unwrap(); + assert!(keep_background(&dir, &fake) + .unwrap_err() + .contains("can read")); + assert!( + dir.join(&second).exists(), + "a refused file keeps the old one" + ); + std::fs::remove_dir_all(&root).unwrap(); +} diff --git a/launcher/src/themes_page.rs b/launcher/src/themes_page.rs new file mode 100644 index 000000000..0f3ab890f --- /dev/null +++ b/launcher/src/themes_page.rs @@ -0,0 +1,155 @@ +//! Settings' accent colour and Home background rows (`horizon_launcher::themes`). +use super::*; +use horizon_launcher::themes::{self, Accent}; +use std::path::PathBuf; + +/// Each accent's swatch, in points. +const SWATCH: f32 = 26.; + +impl Launcher { + /// The theme Settings ask for, under the window's own light or dark. + pub(super) fn settings_theme(&self, window: &Window) -> Theme { + Theme::for_appearance( + self.settings.appearance, + self.settings.accent, + window.appearance(), + ) + } + + /// The picture behind Home, if the player keeps one. + pub(super) fn home_background(&self) -> Option { + themes::background(&backend::data_dir(), self.settings.background.as_deref()) + } + + pub(super) fn accent_row(&self, cx: &mut Context) -> Div { + let t = self.theme; + let mut swatches = div().flex().gap(px(6.)); + for accent in Accent::ALL { + let face = Theme::for_appearance( + if t.dark { + backend::Appearance::Dark + } else { + backend::Appearance::Light + }, + accent, + gpui::WindowAppearance::Dark, + ) + .face; + let chosen = self.settings.accent == accent; + swatches = swatches.child( + keyboard( + div().id(SharedString::from(format!("accent-{}", accent.name()))), + &t, + ) + .size(px(SWATCH)) + .bg(face) + .border_2() + .border_color(if chosen { t.ink } else { t.outline }) + .cursor_pointer() + .when(chosen, |d| { + d.flex() + .items_center() + .justify_center() + .child(div().size(px(8.)).bg(t.on_face)) + }) + .on_click(cx.listener(move |this, _: &ClickEvent, window, cx| { + this.settings.accent = accent; + this.settings.save(); + this.theme = this.settings_theme(window); + cx.notify(); + })), + ); + } + index_row( + 0, + tr("Accent colour"), + Some( + div() + .child(tr( + "Horizon's violet by default. Every colour keeps text as easy to read.", + )) + .into_any_element(), + ), + Some(swatches.into_any_element()), + &t, + ) + } + + fn choose_background(&mut self, cx: &mut Context) { + let paths = cx.prompt_for_paths(PathPromptOptions { + files: true, + directories: false, + multiple: false, + prompt: Some(tr("Choose").to_string().into()), + }); + cx.spawn(async move |this, cx| { + let Ok(Ok(Some(mut paths))) = paths.await else { + return; + }; + let Some(path) = paths.pop() else { + return; + }; + let _ = this.update(cx, |this, cx| { + this.take_background(&path); + cx.notify(); + }); + }) + .detach(); + } + + fn take_background(&mut self, path: &std::path::Path) { + match themes::keep_background(&backend::data_dir(), path) { + Ok(name) => { + self.settings.background = Some(name); + self.settings.save(); + self.background_error = None; + } + Err(e) => self.background_error = Some(e), + } + } + + pub(super) fn home_background_row(&self, cx: &mut Context) -> Div { + let t = self.theme; + let custom = self.home_background().is_some(); + let mut actions = div().flex().gap(px(4.)).child( + button( + "background-choose", + "Choose a picture…", + Kind::Secondary, + &t, + ) + .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + this.choose_background(cx); + cx.notify(); + })), + ); + if custom { + actions = actions.child( + button("background-reset", "Landscape", Kind::Ghost, &t).on_click(cx.listener( + |this, _: &ClickEvent, _, cx| { + this.settings.background = None; + this.settings.save(); + cx.notify(); + }, + )), + ); + } + let said = if custom { + tr("Your picture, behind Home, cropped to fill it.") + } else { + tr("Horizon's landscape. Pick a PNG, JPEG or WebP picture of your own.") + }; + let detail = div().flex().flex_col().child(said.to_string()).children( + self.background_error + .clone() + .map(|e| div().text_color(t.danger).child(e)), + ); + index_row( + 0, + tr("Home background"), + Some(detail.into_any_element()), + Some(actions.into_any_element()), + &t, + ) + } +}