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, + ) + } +}