From f53998674de95895558b99e00f572175293db3e0 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:37:12 +0200 Subject: [PATCH 1/8] Launcher mod browser: project pages, search filters, project icons and gallery A result's title opens its page in the launcher: icon, authors, downloads, followers, licence, verdict (for a Java mod, what ports and what doesn't) and Install, links, then Description (Markdown), Gallery and Versions tabs (browser::details, Source::details on Modrinth's project and members). Filters: sort (relevance, downloads, follows, newest, updated), loader, game version, environment and categories, from the source's tag lists. Icons and gallery images are fetched at run time over HTTPS from the sources' image CDNs only, size-capped, decoded and scaled to PNGs in a 128 MB disk cache (browser::images); a placeholder shows until they arrive. Vanilla's mark is a grass block drawn from the player's prepared game files. Modrinth's logo isn't shown: its branding is all rights reserved without written permission. Co-Authored-By: Claude Opus 5.5 --- docs/mod-browser.md | 35 ++ launcher/Cargo.toml | 2 +- launcher/src/browser.rs | 15 + launcher/src/browser/curseforge.rs | 7 +- launcher/src/browser/details.rs | 368 +++++++++++++++++ launcher/src/browser/images.rs | 265 +++++++++++++ launcher/src/browser/modrinth.rs | 69 +++- launcher/src/main.rs | 1 + launcher/src/mods_page.rs | 113 ++++-- launcher/src/mods_project.rs | 617 +++++++++++++++++++++++++++++ 10 files changed, 1435 insertions(+), 57 deletions(-) create mode 100644 launcher/src/browser/details.rs create mode 100644 launcher/src/browser/images.rs create mode 100644 launcher/src/mods_project.rs diff --git a/docs/mod-browser.md b/docs/mod-browser.md index 6601fea70..74959ab4c 100644 --- a/docs/mod-browser.md +++ b/docs/mod-browser.md @@ -71,6 +71,41 @@ report, **Port** and **Play** ([modpacks.md](modpacks.md)). Installed packs are file and the checksum they were checked against, with **Update to …** when a newer version runs and **Remove** (asked twice). +### Project pages, filters and icons + +A result's title opens its page inside the launcher, not in a web browser: its icon, authors, downloads, followers, +last update, licence and source, how well it runs (for a Java mod, what ports and what doesn't, from its check) with +**Install** or **Import**, the project's links (its page on the source, source code, issues, wiki, Discord, +donations; `https://` only, opened in the system browser), then three tabs: **Description** (the project's Markdown, +drawn by the launcher's renderer), **Gallery** and **Versions** (each version's date, loaders, game versions, file +and size, and the verdict of any version already checked). The data is `browser::details` (`Source::details`; on +Modrinth `GET /project/{id}` and `/project/{id}/members`, then `/project/{id}/version`). + +**Filters** sit above the results: the order (best match, relevance, downloads, follows, newest, updated) and, under +**Filters**, the loader, game version, environment (client, server) and categories, with the values the source lists +for the kind (Modrinth's `/tag/category`, `/tag/loader`, `/tag/game_version`, asked once per run). A loader or version +picked replaces the default (Java mod loaders and 26.3 for mods); a version other than 26.3 checks and installs the +newest file for 1.21 or later, as **Show everything** does. Changing tabs clears the filters. CurseForge support +is coming soon; its source already maps the order and the game version. + +**Icons and gallery images** are the projects' own, fetched at run time from the source's image server +(`browser::images`): HTTPS from `cdn.modrinth.com` (or CurseForge's `media.forgecdn.net` / `edge.forgecdn.net`) only, +at most 8 MB each, decoded (PNG, JPEG, WebP, GIF, at most 8192 pixels a side), scaled (icons 128 px, gallery 960 px) +and kept as PNGs in `mod-browser/images/` under the game folder, which is kept under 128 MB by dropping the oldest +files. A plate with the project's initial stands in until the icon arrives. Nothing of a project's art is in the +launcher or the repository. + +**Vanilla Minecraft** is marked with a grass block drawn at run time from the player's own prepared game files +(`assets/minecraft/textures/block/grass_block_*.png`, tinted with the plains grass colour): the game-version filter +shows it. No Mojang file or logo is in the launcher or the repository. + +**Source logos.** Modrinth's logo is not shown: Modrinth's own repository says its branding is all rights reserved and +"the use of Modrinth branding elements, including but not limited to the wrench-in-labyrinth logo, ... is strictly +prohibited without explicit written permission from Rinth, Inc." (`packages/assets/COPYING.md` in +[modrinth/code](https://github.com/modrinth/code/blob/main/packages/assets/COPYING.md)), and its +[terms](https://modrinth.com/legal/terms) ask for prior written permission to use its marks. Results name their source +in text ("from Modrinth") until Rinth, Inc. agrees. CurseForge support, and so its logo, is coming soon. + ## Installing, updating, removing `mod-browser.json` beside `options.txt` lists every file the browser installed: its source, project, version, file diff --git a/launcher/Cargo.toml b/launcher/Cargo.toml index 1097d5675..006e35c5e 100644 --- a/launcher/Cargo.toml +++ b/launcher/Cargo.toml @@ -39,7 +39,7 @@ horizon-telemetry = { path = "../crates/horizon-telemetry" } getrandom = "0.3" # Players' heads on the Servers page, from the default skins in the player's own game files (already # in the lockfile through gpui). -image = { version = "0.25", default-features = false, features = ["png"] } +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] } # Microsoft account secrets in the OS keychain: the Secret Service on Linux through oo7 (the same # build gpui uses), Keychain and Credential Manager through keyring. diff --git a/launcher/src/browser.rs b/launcher/src/browser.rs index c4a6ff726..091560638 100644 --- a/launcher/src/browser.rs +++ b/launcher/src/browser.rs @@ -7,7 +7,9 @@ //! (`resourcepacks/`) and Java shader packs (`shaderpacks/`, Iris and OptiFine). Java code itself //! doesn't run in Horizon (docs/mods.md). pub mod curseforge; +pub mod details; pub mod download; +pub mod images; pub mod installed; pub mod java; pub mod modrinth; @@ -189,6 +191,7 @@ pub struct Query { pub everything: bool, pub offset: usize, pub limit: usize, + pub filters: details::Filters, } impl Query { @@ -199,6 +202,7 @@ impl Query { everything: false, offset: 0, limit: 20, + filters: details::Filters::default(), } } } @@ -210,6 +214,17 @@ pub trait Source: Send + Sync { fn project(&self, id: &str) -> Result; /// Versions, newest first. fn versions(&self, project: &Project) -> Result, String>; + /// The project's page: description, gallery, links, authors. + fn details(&self, project: &Project) -> Result { + Ok(details::Details { + body: project.summary.clone(), + ..details::Details::default() + }) + } + /// The values the search's filters can take for `kind`. + fn tags(&self, _kind: Kind) -> Result { + Ok(details::Tags::default()) + } /// Downloads one of its files into `folder`, checked ([`download::fetch`]). fn fetch( &self, diff --git a/launcher/src/browser/curseforge.rs b/launcher/src/browser/curseforge.rs index 04e722bba..0a7a04a5e 100644 --- a/launcher/src/browser/curseforge.rs +++ b/launcher/src/browser/curseforge.rs @@ -69,12 +69,15 @@ impl CurseForge { pub(super) fn search_url(query: &Query) -> String { let mut url = format!( - "{API}/mods/search?gameId={MINECRAFT}&classId={}&sortField=2&sortOrder=desc&index={}&pageSize={}", + "{API}/mods/search?gameId={MINECRAFT}&classId={}&sortField={}&sortOrder=desc&index={}&pageSize={}", class(query.kind), + query.filters.sort(&query.text).curseforge(), query.offset, query.limit.clamp(1, 50), ); - if !query.everything && matches!(query.kind, Kind::ResourcePack | Kind::Mod) { + if let Some(v) = &query.filters.game_version { + url.push_str(&format!("&gameVersion={}", encode(v))); + } else if !query.everything && matches!(query.kind, Kind::ResourcePack | Kind::Mod) { url.push_str(&format!("&gameVersion={GAME}")); } if !query.text.is_empty() { diff --git a/launcher/src/browser/details.rs b/launcher/src/browser/details.rs new file mode 100644 index 000000000..004db12b4 --- /dev/null +++ b/launcher/src/browser/details.rs @@ -0,0 +1,368 @@ +//! What the browser shows beyond a search hit: a project's page (description, gallery, links, +//! authors) and the search's filters (categories, loader, game version, environment, sort), with +//! the lists of values each source offers for them. Data only: `mods_page` draws it. +use super::{Kind, GAME}; +use serde_json::Value; + +/// How results are ordered. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)] +pub enum Sort { + #[default] + Relevance, + Downloads, + Follows, + Newest, + Updated, +} + +impl Sort { + pub const ALL: [Sort; 5] = [ + Sort::Relevance, + Sort::Downloads, + Sort::Follows, + Sort::Newest, + Sort::Updated, + ]; + pub fn label(self) -> &'static str { + match self { + Sort::Relevance => "Relevance", + Sort::Downloads => "Downloads", + Sort::Follows => "Follows", + Sort::Newest => "Newest", + Sort::Updated => "Updated", + } + } + /// Modrinth's `index`. + pub fn modrinth(self) -> &'static str { + match self { + Sort::Relevance => "relevance", + Sort::Downloads => "downloads", + Sort::Follows => "follows", + Sort::Newest => "newest", + Sort::Updated => "updated", + } + } + /// CurseForge's `sortField` (`ModsSearchSortField`). + pub fn curseforge(self) -> u8 { + match self { + Sort::Relevance | Sort::Follows => 2, + Sort::Downloads => 6, + Sort::Newest => 11, + Sort::Updated => 3, + } + } +} + +/// Where a project runs. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Environment { + Client, + Server, +} + +impl Environment { + pub const ALL: [Environment; 2] = [Environment::Client, Environment::Server]; + pub fn label(self) -> &'static str { + match self { + Environment::Client => "Client", + Environment::Server => "Server", + } + } +} + +/// A search's filters. Empty: the browser's defaults (what runs in Horizon). +#[derive(Clone, Debug, Default, PartialEq, Eq, Hash)] +pub struct Filters { + /// Every one of these categories (`decoration`, `worldgen`...). + pub categories: Vec, + /// One loader (`neoforge`, `fabric`, `iris`...), in place of the kind's defaults. + pub loader: Option, + /// One Minecraft version, in place of the one Horizon plays. + pub game_version: Option, + pub environment: Option, + /// None: by relevance with search text, else by downloads. + pub sort: Option, +} + +impl Filters { + /// The sort a search uses. + pub fn sort(&self, text: &str) -> Sort { + self.sort.unwrap_or(if text.is_empty() { + Sort::Downloads + } else { + Sort::Relevance + }) + } + /// A game version other than the one Horizon plays was asked for. + pub fn other_version(&self) -> bool { + self.game_version.as_deref().is_some_and(|v| v != GAME) + } +} + +/// One value a filter can take. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Tag { + pub name: String, + /// The group a category is in on its source (`categories`, `features`, `resolutions`). + pub header: String, +} + +/// The values each filter can take for a kind. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Tags { + pub categories: Vec, + pub loaders: Vec, + /// Release versions, newest first. + pub game_versions: Vec, +} + +/// A gallery image. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Image { + pub url: String, + pub title: String, + pub description: String, + pub featured: bool, +} + +/// A link a project gives: its source, issues, wiki, Discord, donations. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Link { + pub label: String, + pub url: String, +} + +/// A project's page. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Details { + /// Markdown (Modrinth's body; HTML blocks in it are left as text the renderer skips). + pub body: String, + pub gallery: Vec, + pub links: Vec, + pub authors: Vec, + pub followers: u64, + pub published: String, + pub updated: String, + pub categories: Vec, + /// `required`, `optional`, `unsupported` or `unknown`. + pub client_side: String, + pub server_side: String, +} + +fn strings(v: &Value) -> Vec { + v.as_array() + .into_iter() + .flatten() + .filter_map(|s| s.as_str().map(str::to_owned)) + .collect() +} + +fn text(v: &Value) -> String { + v.as_str().unwrap_or_default().to_owned() +} + +/// Only `https://` links are kept: a page never opens another scheme. +fn https(url: &str) -> Option { + url.starts_with("https://").then(|| url.to_owned()) +} + +/// Modrinth's `GET /project/{id}` and `GET /project/{id}/members`. +pub fn modrinth_details(project: &Value, members: &Value) -> Details { + let mut gallery: Vec<(i64, Image)> = project["gallery"] + .as_array() + .into_iter() + .flatten() + .filter_map(|g| { + Some(( + g["ordering"].as_i64().unwrap_or(0), + Image { + url: https(g["url"].as_str()?)?, + title: text(&g["title"]), + description: text(&g["description"]), + featured: g["featured"].as_bool().unwrap_or(false), + }, + )) + }) + .collect(); + gallery.sort_by_key(|(order, i)| (!i.featured, *order)); + let mut links = Vec::new(); + for (key, label) in [ + ("source_url", "Source"), + ("issues_url", "Issues"), + ("wiki_url", "Wiki"), + ("discord_url", "Discord"), + ] { + if let Some(url) = project[key].as_str().and_then(https) { + links.push(Link { + label: label.into(), + url, + }); + } + } + for d in project["donation_urls"].as_array().into_iter().flatten() { + if let Some(url) = d["url"].as_str().and_then(https) { + links.push(Link { + label: format!("Donate ({})", text(&d["platform"])), + url, + }); + } + } + let mut owners: Vec<(bool, String)> = members + .as_array() + .into_iter() + .flatten() + .filter_map(|m| { + let name = m["user"]["username"].as_str()?.to_owned(); + Some((m["role"].as_str() != Some("Owner"), name)) + }) + .collect(); + owners.sort(); + let mut categories = strings(&project["categories"]); + categories.extend(strings(&project["additional_categories"])); + Details { + body: text(&project["body"]), + gallery: gallery.into_iter().map(|(_, i)| i).collect(), + links, + authors: owners.into_iter().map(|(_, n)| n).collect(), + followers: project["followers"].as_u64().unwrap_or(0), + published: text(&project["published"]), + updated: text(&project["updated"]), + categories, + client_side: text(&project["client_side"]), + server_side: text(&project["server_side"]), + } +} + +/// Modrinth's `/tag/category`, `/tag/loader` and `/tag/game_version` for one project type. +pub fn modrinth_tags( + project_type: &str, + categories: &Value, + loaders: &Value, + versions: &Value, +) -> Tags { + let loader_names: Vec = loaders + .as_array() + .into_iter() + .flatten() + .filter(|l| { + l["supported_project_types"] + .as_array() + .is_some_and(|t| t.iter().any(|x| x == project_type)) + }) + .filter_map(|l| l["name"].as_str().map(str::to_owned)) + .collect(); + let mut categories: Vec = categories + .as_array() + .into_iter() + .flatten() + .filter(|c| c["project_type"] == project_type) + .filter_map(|c| { + let name = c["name"].as_str()?.to_owned(); + // Loaders are listed as categories too; the loader filter has them. + (!loader_names.contains(&name)).then(|| Tag { + name, + header: text(&c["header"]), + }) + }) + .collect(); + categories.sort_by(|a, b| (&a.header, &a.name).cmp(&(&b.header, &b.name))); + Tags { + categories, + loaders: loader_names, + game_versions: versions + .as_array() + .into_iter() + .flatten() + .filter(|v| v["version_type"] == "release") + .filter_map(|v| v["version"].as_str().map(str::to_owned)) + .collect(), + } +} + +/// The loaders a kind lists by default, when no loader is picked: Java mod loaders for mods, +/// Iris and OptiFine for shader packs (unless `everything`). +pub fn default_loaders(kind: Kind, everything: bool) -> Vec<&'static str> { + match kind { + Kind::Mod => super::java::LOADERS.to_vec(), + Kind::ShaderPack if !everything => vec!["iris", "optifine"], + _ => Vec::new(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn a_modrinth_page() { + let project = json!({ + "body": "# Fences\nMore fences.", + "gallery": [ + {"url": "https://cdn.modrinth.com/data/x/images/b.png", "featured": false, "ordering": 0, "title": "B"}, + {"url": "https://cdn.modrinth.com/data/x/images/a.png", "featured": true, "ordering": 5, "title": "A"}, + {"url": "http://example.com/c.png", "featured": false, "ordering": 1} + ], + "source_url": "https://github.com/x/y", "issues_url": "javascript:alert(1)", + "wiki_url": null, "discord_url": "https://discord.gg/x", + "donation_urls": [{"platform": "Ko-fi", "url": "https://ko-fi.com/x"}], + "followers": 12, "categories": ["decoration"], "additional_categories": ["utility"], + "client_side": "required", "server_side": "required" + }); + let members = json!([ + {"user": {"username": "helper"}, "role": "Member"}, + {"user": {"username": "Macaw"}, "role": "Owner"} + ]); + let d = modrinth_details(&project, &members); + assert_eq!( + d.gallery + .iter() + .map(|i| i.title.as_str()) + .collect::>(), + ["A", "B"] + ); + assert_eq!( + d.links.iter().map(|l| l.label.as_str()).collect::>(), + ["Source", "Discord", "Donate (Ko-fi)"] + ); + assert_eq!(d.authors, ["Macaw", "helper"]); + assert_eq!(d.categories, ["decoration", "utility"]); + assert_eq!(d.followers, 12); + } + + #[test] + fn modrinth_tags_by_project_type() { + let categories = json!([ + {"name": "decoration", "project_type": "mod", "header": "categories"}, + {"name": "fabric", "project_type": "mod", "header": "loaders"}, + {"name": "16x", "project_type": "resourcepack", "header": "resolutions"} + ]); + let loaders = json!([ + {"name": "fabric", "supported_project_types": ["mod", "modpack"]}, + {"name": "iris", "supported_project_types": ["shader"]} + ]); + let versions = json!([ + {"version": "26.3", "version_type": "release"}, + {"version": "26.4-snapshot-1", "version_type": "snapshot"} + ]); + let t = modrinth_tags("mod", &categories, &loaders, &versions); + assert_eq!(t.categories.len(), 1); + assert_eq!(t.categories[0].name, "decoration"); + assert_eq!(t.loaders, ["fabric"]); + assert_eq!(t.game_versions, ["26.3"]); + } + + #[test] + fn sorts_default_to_relevance_with_text() { + let f = Filters::default(); + assert_eq!(f.sort(""), Sort::Downloads); + assert_eq!(f.sort("fences"), Sort::Relevance); + let f = Filters { + sort: Some(Sort::Updated), + game_version: Some("1.21.1".into()), + ..Filters::default() + }; + assert_eq!(f.sort("x"), Sort::Updated); + assert!(f.other_version()); + } +} diff --git a/launcher/src/browser/images.rs b/launcher/src/browser/images.rs new file mode 100644 index 000000000..fee2f219e --- /dev/null +++ b/launcher/src/browser/images.rs @@ -0,0 +1,265 @@ +//! Project icons and gallery images, fetched at run time from the sources' image servers and +//! kept on disk: nothing of a project's art is in the launcher. A URL is fetched only over HTTPS +//! from Modrinth's or CurseForge's CDN, at most [`MAX_DOWNLOAD`] bytes, decoded (PNG, JPEG, WebP, +//! GIF), scaled down and stored as a PNG the UI draws; the cache is kept under [`MAX_CACHE`] by +//! dropping the oldest files. +use sha1::{Digest, Sha1}; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; + +/// Hosts images may come from. +const HOSTS: [&str; 3] = [ + "https://cdn.modrinth.com/", + "https://media.forgecdn.net/", + "https://edge.forgecdn.net/", +]; +/// The most an image may weigh before decoding. +pub const MAX_DOWNLOAD: u64 = 8 << 20; +/// The most the cache keeps on disk. +pub const MAX_CACHE: u64 = 128 << 20; +/// Pixels: an icon's side, a gallery image's longest side. +pub const ICON: u32 = 128; +pub const GALLERY: u32 = 960; + +fn dir(home: &Path) -> PathBuf { + home.join("mod-browser/images") +} + +/// Why `url` is never fetched. +pub fn refuse(url: &str) -> Option<&'static str> { + if !HOSTS.iter().any(|h| url.starts_with(h)) { + return Some("not on Modrinth's or CurseForge's image servers over HTTPS"); + } + if url.len() > 2048 || url.contains(char::is_whitespace) { + return Some("not a plain URL"); + } + None +} + +/// Where the image of `url` scaled to `size` is kept. +pub fn path(home: &Path, url: &str, size: u32) -> PathBuf { + let hash: String = Sha1::digest(url.as_bytes()) + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + dir(home).join(format!("{hash}-{size}.png")) +} + +/// The image if it is cached already. +pub fn cached(home: &Path, url: &str, size: u32) -> Option { + let p = path(home, url, size); + p.is_file().then_some(p) +} + +/// The image of `url`, no larger than `size` pixels a side: the cached one, else fetched, +/// decoded, scaled and stored. +pub fn fetch(home: &Path, url: &str, size: u32) -> Result { + if let Some(p) = cached(home, url, size) { + return Ok(p); + } + if let Some(why) = refuse(url) { + return Err(format!("{url}: {why}")); + } + let response = super::download::agent() + .get(url) + .call() + .map_err(|e| format!("{url}: {e}"))?; + let mut bytes = Vec::new(); + response + .into_reader() + .take(MAX_DOWNLOAD + 1) + .read_to_end(&mut bytes) + .map_err(|e| format!("{url}: {e}"))?; + if bytes.len() as u64 > MAX_DOWNLOAD { + return Err(format!("{url}: larger than {} MB", MAX_DOWNLOAD >> 20)); + } + store(home, url, size, &bytes) +} + +/// Decodes `bytes`, scales them to fit `size` and writes the PNG. +pub fn store(home: &Path, url: &str, size: u32, bytes: &[u8]) -> Result { + let mut reader = image::ImageReader::new(std::io::Cursor::new(bytes)) + .with_guessed_format() + .map_err(|e| e.to_string())?; + let mut limits = image::Limits::default(); + limits.max_image_width = Some(8192); + limits.max_image_height = Some(8192); + limits.max_alloc = Some(256 << 20); + reader.limits(limits); + let decoded = reader.decode().map_err(|e| format!("{url}: {e}"))?; + let scaled = if decoded.width() > size || decoded.height() > size { + decoded.resize(size, size, image::imageops::FilterType::Triangle) + } else { + decoded + }; + let target = path(home, url, size); + let folder = dir(home); + fs::create_dir_all(&folder).map_err(|e| format!("{}: {e}", folder.display()))?; + let part = target.with_extension("part"); + scaled + .save_with_format(&part, image::ImageFormat::Png) + .map_err(|e| format!("{}: {e}", part.display()))?; + fs::rename(&part, &target).map_err(|e| format!("{}: {e}", target.display()))?; + prune(&folder, MAX_CACHE); + Ok(target) +} + +/// Drops the oldest files until the folder holds at most `max` bytes. +fn prune(folder: &Path, max: u64) { + let mut files: Vec<(std::time::SystemTime, u64, PathBuf)> = fs::read_dir(folder) + .into_iter() + .flatten() + .flatten() + .filter_map(|e| { + let m = e.metadata().ok()?; + Some((m.modified().ok()?, m.len(), e.path())) + }) + .collect(); + let mut total: u64 = files.iter().map(|f| f.1).sum(); + files.sort(); + for (_, len, path) in files { + if total <= max { + break; + } + if fs::remove_file(&path).is_ok() { + total -= len; + } + } +} + +/// Vanilla Minecraft's mark: a grass block drawn from the player's own prepared game files +/// (`/assets/minecraft/textures/block/`), never shipped. Kept beside the cache. +pub fn grass_block(home: &Path, resources: &Path) -> Option { + let target = dir(home).join("grass-block.png"); + if target.is_file() { + return Some(target); + } + let textures = resources.join("assets/minecraft/textures/block"); + let load = |name: &str| image::open(textures.join(name)).ok().map(|i| i.to_rgba8()); + let (top, side, overlay) = ( + load("grass_block_top.png")?, + load("grass_block_side.png")?, + load("grass_block_side_overlay.png")?, + ); + let icon = draw_block(&top, &side, &overlay); + fs::create_dir_all(dir(home)).ok()?; + icon.save(&target).ok()?; + Some(target) +} + +/// The plains grass colour vanilla's grass block item shows (`GrassColor.getDefaultColor` +/// is the colormap at 0.5, 1.0: `#91BD59`). +const GRASS: [u8; 3] = [0x91, 0xBD, 0x59]; + +/// An isometric block, 64 pixels a side, from 16x16 faces: the top tinted, the sides with their +/// tinted overlay, the left side shaded as the game shades a side facing away from the light. +fn draw_block( + top: &image::RgbaImage, + side: &image::RgbaImage, + overlay: &image::RgbaImage, +) -> image::RgbaImage { + let size = 64u32; + let mut out = image::RgbaImage::new(size, size); + let at = |img: &image::RgbaImage, u: f32, v: f32| { + let (w, h) = (img.width().min(16), img.height().min(16)); + let x = ((u * w as f32) as u32).min(w - 1); + let y = ((v * h as f32) as u32).min(h - 1); + *img.get_pixel(x, y) + }; + let tint = |p: image::Rgba, shade: f32| { + let mut q = p; + for c in 0..3 { + q[c] = (p[c] as f32 * GRASS[c] as f32 / 255. * shade) as u8; + } + q + }; + let shade = |p: image::Rgba, s: f32| { + let mut q = p; + for c in 0..3 { + q[c] = (p[c] as f32 * s) as u8; + } + q + }; + let side_px = |u: f32, v: f32, s: f32| { + let o = at(overlay, u, v); + if o[3] > 0 { + tint(o, s) + } else { + shade(at(side, u, v), s) + } + }; + // The cube: the top face a diamond, two sides below it. + let (cx, h) = (size as f32 / 2., size as f32 / 4.); + for y in 0..size { + for x in 0..size { + let (px, py) = (x as f32 + 0.5, y as f32 + 0.5); + let dx = (px - cx) / cx; + let dy = (py - h) / h; + let u = (dy + dx) / 2. + 0.5; + let v = (dy - dx) / 2. + 0.5; + let pixel = if (0. ..1.).contains(&u) && (0. ..1.).contains(&v) { + Some(tint(at(top, u, v), 1.0)) + } else if px < cx { + let u = px / cx; + let v = (py - h - u * h) / (2. * h); + (0. ..1.).contains(&v).then(|| side_px(u, v, 0.8)) + } else { + let u = (px - cx) / cx; + let v = (py - 2. * h + u * h) / (2. * h); + (0. ..1.).contains(&v).then(|| side_px(u, v, 0.6)) + }; + if let Some(p) = pixel { + out.put_pixel(x, y, p); + } + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_the_sources_image_servers() { + assert!(refuse("https://cdn.modrinth.com/data/AANobbMI/icon.png").is_none()); + assert!(refuse("https://media.forgecdn.net/avatars/1/2.png").is_none()); + assert!(refuse("http://cdn.modrinth.com/data/x/icon.png").is_some()); + assert!(refuse("https://cdn.modrinth.com.evil.example/x.png").is_some()); + assert!(refuse("https://example.com/x.png").is_some()); + } + + #[test] + fn images_are_scaled_stored_and_pruned() { + let home = std::env::temp_dir().join(format!("horizon-images-{}", std::process::id())); + let _ = fs::remove_dir_all(&home); + let big = image::RgbaImage::from_pixel(300, 150, image::Rgba([1, 2, 3, 255])); + let mut png = Vec::new(); + big.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .unwrap(); + let url = "https://cdn.modrinth.com/data/x/icon.png"; + let p = store(&home, url, ICON, &png).unwrap(); + assert_eq!(cached(&home, url, ICON), Some(p.clone())); + assert_eq!(image::open(&p).unwrap().width(), ICON); + assert!(store(&home, url, ICON, b"not an image").is_err()); + prune(&dir(&home), 0); + assert!(cached(&home, url, ICON).is_none()); + fs::remove_dir_all(&home).unwrap(); + } + + #[test] + fn a_grass_block_from_faces() { + let face = |c: [u8; 4]| image::RgbaImage::from_pixel(16, 16, image::Rgba(c)); + let icon = draw_block( + &face([200, 200, 200, 255]), + &face([120, 80, 50, 255]), + &face([0, 0, 0, 0]), + ); + // The top is tinted grass, the sides dirt shaded, the corners empty. + assert_eq!(icon.get_pixel(32, 16)[1], (200. * 0xBD as f32 / 255.) as u8); + assert_eq!(icon.get_pixel(4, 40).0, [96, 64, 40, 255]); + assert_eq!(icon.get_pixel(60, 40).0, [72, 48, 30, 255]); + assert_eq!(icon.get_pixel(1, 1)[3], 0); + } +} diff --git a/launcher/src/browser/modrinth.rs b/launcher/src/browser/modrinth.rs index 77b5475bb..3fde04493 100644 --- a/launcher/src/browser/modrinth.rs +++ b/launcher/src/browser/modrinth.rs @@ -1,5 +1,5 @@ //! Modrinth's public API (api.modrinth.com/v2): no key, files with SHA-512 and SHA-1. -use super::{FileRef, Kind, Project, Query, Source, SourceId, Version, GAME}; +use super::{details, FileRef, Kind, Project, Query, Source, SourceId, Version, GAME}; use serde_json::Value; const API: &str = "https://api.modrinth.com/v2"; @@ -23,26 +23,34 @@ fn kind_of(project_type: &str) -> Option { .find(|k| self::project_type(*k) == project_type) } -/// The search's facets (AND of ORs): the kind, and unless `everything` what runs. +/// The search's facets (AND of ORs): the kind, the filters, and unless `everything` what runs. pub(super) fn facets(query: &Query) -> String { + let f = &query.filters; let mut all = vec![vec![format!("project_type:{}", project_type(query.kind))]]; - if !query.everything { - match query.kind { - Kind::ResourcePack => all.push(vec![format!("versions:{GAME}")]), - Kind::ShaderPack => { - all.push(vec!["categories:iris".into(), "categories:optifine".into()]) - } - Kind::Modpack => {} - Kind::Mod => all.push(vec![format!("versions:{GAME}")]), + match &f.game_version { + Some(v) => all.push(vec![format!("versions:{v}")]), + None if !query.everything && matches!(query.kind, Kind::ResourcePack | Kind::Mod) => { + all.push(vec![format!("versions:{GAME}")]) } + None => {} } - if query.kind == Kind::Mod { - all.push( - super::java::LOADERS - .iter() - .map(|l| format!("categories:{l}")) - .collect(), - ); + let loaders: Vec = match &f.loader { + Some(l) => vec![format!("categories:{l}")], + None => details::default_loaders(query.kind, query.everything) + .iter() + .map(|l| format!("categories:{l}")) + .collect(), + }; + if !loaders.is_empty() { + all.push(loaders); + } + for c in &f.categories { + all.push(vec![format!("categories:{c}")]); + } + match f.environment { + Some(details::Environment::Client) => all.push(vec!["client_side!=unsupported".into()]), + Some(details::Environment::Server) => all.push(vec!["server_side!=unsupported".into()]), + None => {} } serde_json::to_string(&all).unwrap_or_default() } @@ -51,11 +59,7 @@ pub(super) fn search_url(query: &Query) -> String { let mut url = format!( "{API}/search?facets={}&index={}&offset={}&limit={}", encode(&facets(query)), - if query.text.is_empty() { - "downloads" - } else { - "relevance" - }, + query.filters.sort(&query.text).modrinth(), query.offset, query.limit.clamp(1, 100), ); @@ -193,6 +197,27 @@ impl Source for Modrinth { all.sort_by(|a, b| b.published.cmp(&a.published)); Ok(all) } + + fn details(&self, project: &Project) -> Result { + let id = encode(&project.id); + let p = get(&format!("{API}/project/{id}"))?; + let members = get(&format!("{API}/project/{id}/members")).unwrap_or_default(); + Ok(details::modrinth_details(&p, &members)) + } + + fn tags(&self, kind: Kind) -> Result { + static TAGS: std::sync::Mutex> = std::sync::Mutex::new(None); + let mut cached = TAGS.lock().map_err(|_| "tags".to_string())?; + if cached.is_none() { + *cached = Some(( + get(&format!("{API}/tag/category"))?, + get(&format!("{API}/tag/loader"))?, + get(&format!("{API}/tag/game_version"))?, + )); + } + let (c, l, v) = cached.as_ref().expect("filled above"); + Ok(details::modrinth_tags(project_type(kind), c, l, v)) + } } fn get(url: &str) -> Result { diff --git a/launcher/src/main.rs b/launcher/src/main.rs index bb1e8dceb..5456c235a 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -9,6 +9,7 @@ mod markdown; mod mcp; mod modpacks_page; mod mods_page; +mod mods_project; mod perf; mod scrollbar; mod servers_page; diff --git a/launcher/src/mods_page.rs b/launcher/src/mods_page.rs index fd6a24b76..e66c02490 100644 --- a/launcher/src/mods_page.rs +++ b/launcher/src/mods_page.rs @@ -31,6 +31,8 @@ pub(super) enum Event { Updates(HashMap<(SourceId, String), String>), /// A Java mod's check: its jar read, or why it couldn't be. Checked((SourceId, String), Result, String>), + /// A project page's part, an image or a filter's values (`mods_project`). + Loaded(super::mods_project::Loaded), } /// Where a Java mod's check is. @@ -41,8 +43,8 @@ pub(super) enum Checking { } pub(super) struct Mods { - kind: PackKind, - source: SourceId, + pub(super) kind: PackKind, + pub(super) source: SourceId, text: String, focus: FocusHandle, pub(super) focused: bool, @@ -57,13 +59,15 @@ pub(super) struct Mods { installed: Vec, loaded: bool, /// The project being installed, and the line and fraction of its download. - busy: Option<(String, String, f32)>, + pub(super) busy: Option<(String, String, f32)>, message: Option>, updates: HashMap<(SourceId, String), String>, confirm_remove: Option, /// Java mods' checks, by source and project. - checks: HashMap<(SourceId, String), Checking>, - tx: Sender, + pub(super) checks: HashMap<(SourceId, String), Checking>, + /// The project page, filters and images. + pub(super) browse: super::mods_project::Browse, + pub(super) tx: Sender, rx: Receiver, } @@ -90,6 +94,7 @@ impl Mods { updates: HashMap::new(), confirm_remove: None, checks: HashMap::new(), + browse: Default::default(), tx, rx, } @@ -99,14 +104,14 @@ impl Mods { self.focused = self.focus.is_focused(window); } - fn entry(&self, p: &Project) -> Option<&installed::Entry> { + pub(super) fn entry(&self, p: &Project) -> Option<&installed::Entry> { self.installed .iter() .find(|e| e.source == p.source && e.project == p.id) } /// How well a result runs: a Java mod's from its check once it has one. - fn support(&self, p: &Project) -> Support { + pub(super) fn support(&self, p: &Project) -> Support { let metadata = p.support(); if p.kind != PackKind::Mod || !metadata.installable() { return metadata; @@ -142,6 +147,7 @@ impl Launcher { pub(super) fn mods_tick(&mut self) -> bool { let mut changed = false; let mut to_check = Vec::new(); + let mut loaded = Vec::new(); while let Ok(event) = self.mods.rx.try_recv() { changed = true; let m = &mut self.mods; @@ -184,13 +190,17 @@ impl Launcher { }; m.checks.insert(key, state); } + Event::Loaded(l) => loaded.push(l), } } self.mods_check(to_check); + for l in loaded { + self.mods_loaded(l); + } changed } - fn mods_search(&mut self, offset: usize) { + pub(super) fn mods_search(&mut self, offset: usize) { let m = &mut self.mods; m.search += 1; m.searching = true; @@ -202,6 +212,7 @@ impl Launcher { everything: m.everything, offset, limit: PAGE, + filters: m.browse.filters.clone(), ..Query::new(&m.text, m.kind) }; let (tx, search, source) = (m.tx.clone(), m.search, m.source); @@ -228,7 +239,9 @@ impl Launcher { for p in &todo { m.checks.insert((p.source, p.id.clone()), Checking::Running); } - let (tx, everything) = (m.tx.clone(), m.everything); + // A version other than 26.3 asked for: check the newest file for 1.21 and later. + let everything = m.everything || m.browse.filters.other_version(); + let tx = m.tx.clone(); let queue = std::sync::Mutex::new(todo.into_iter()); std::thread::spawn(move || { let home = backend::game_home(); @@ -273,14 +286,15 @@ impl Launcher { /// Installs (or updates) a project on a thread: a pack into its folder, a modpack into /// Modpacks. `known` is the project when the page has it; else it is asked for first. - fn mods_install(&mut self, source: SourceId, id: String, known: Option) { + pub(super) fn mods_install(&mut self, source: SourceId, id: String, known: Option) { if self.mods.busy.is_some() { return; } self.mods.message = None; self.mods.busy = Some((id.clone(), format!("Asking {}…", source.label()), 0.)); self.mods.updates.remove(&(source, id.clone())); - let (tx, everything) = (self.mods.tx.clone(), self.mods.everything); + let everything = self.mods.everything || self.mods.browse.filters.other_version(); + let tx = self.mods.tx.clone(); std::thread::spawn(move || { let last = std::cell::Cell::new(-1); let progress = |line: &str, f: f32| { @@ -429,6 +443,9 @@ impl Launcher { let _ = this.update(cx, |this, cx| { if this.mods.kind != kind { this.mods.kind = kind; + // Categories and loaders differ by kind. + this.mods.browse.filters = Default::default(); + this.mods.browse.open = None; this.mods_search(0); } cx.notify(); @@ -492,15 +509,39 @@ impl Launcher { .child(self.mods_field(cx)) .child(toggle), ) - .child(source_note); + .child(source_note) + .child(self.mods_filters(cx)); + if let Some(project) = self.mods_project_page(cx) { + return div() + .flex() + .flex_col() + .gap(px(32.)) + .child(header) + .children(self.mods_busy_plate()) + .child(project); + } let mut page = div() .flex() .flex_col() .gap(px(32.)) .child(header) .child(controls); + page = page.children(self.mods_busy_plate()); + if self.mods.kind == PackKind::Modpack { + page = page.child(self.imported_packs(cx)); + } + if let Some(list) = self.mods_installed_list(cx) { + page = page.child(list); + } + page.child(self.mods_results(cx)) + } + + /// The install under way and the last install's answer. + pub(super) fn mods_busy_plate(&self) -> Vec
{ + let t = self.theme; + let mut out = Vec::new(); if let Some((_, line, f)) = &self.mods.busy { - page = page.child( + out.push( plate(&t) .flex() .flex_col() @@ -517,25 +558,17 @@ impl Launcher { ); } match &self.mods.message { - Some(Ok(done)) => { - page = page.child( - plate(&t) - .p(px(16.)) - .text_size(px(theme::UI.px)) - .text_color(t.ink) - .child(done.clone()), - ) - } - Some(Err(e)) => page = page.child(notice(e.clone(), &t)), + Some(Ok(done)) => out.push( + plate(&t) + .p(px(16.)) + .text_size(px(theme::UI.px)) + .text_color(t.ink) + .child(done.clone()), + ), + Some(Err(e)) => out.push(notice(e.clone(), &t)), None => {} } - if self.mods.kind == PackKind::Modpack { - page = page.child(self.imported_packs(cx)); - } - if let Some(list) = self.mods_installed_list(cx) { - page = page.child(list); - } - page.child(self.mods_results(cx)) + out } fn mods_installed_list(&self, cx: &mut Context) -> Option
{ @@ -650,8 +683,10 @@ impl Launcher { ) } - fn mods_results(&self, cx: &mut Context) -> Div { + fn mods_results(&mut self, cx: &mut Context) -> Div { let t = self.theme; + let found = self.mods.results.clone(); + let icons: Vec = found.iter().map(|p| self.mods_icon(p, 40.)).collect(); let m = &self.mods; if let Some(e) = &m.error { return div().child(notice(e.clone(), &t)); @@ -667,6 +702,7 @@ impl Launcher { return centered(line, &t); } let busy = m.busy.as_ref().map(|b| b.0.as_str()); + let mut icons = icons.into_iter(); let mut list = div().flex().flex_col().border_t_1().border_color(t.rule); for (i, p) in m.results.iter().enumerate() { let support = m.support(p); @@ -746,9 +782,22 @@ impl Launcher { .reason() .map(|r| div().text_color(t.ink_soft).child(r.to_string())), ); + let open = p.clone(); + let name = div() + .id(SharedString::from(format!("mods-open-{}", p.id))) + .flex() + .items_center() + .gap(px(12.)) + .cursor_pointer() + .child(icons.next().expect("one icon per result")) + .child(div().hover(|s| s.underline()).child(p.title.clone())) + .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { + this.mods_open(open.clone()); + cx.notify(); + })); list = list.child(index_row( i + 1, - p.title.clone(), + name, Some(detail.into_any_element()), Some(right.into_any_element()), &t, diff --git a/launcher/src/mods_project.rs b/launcher/src/mods_project.rs new file mode 100644 index 000000000..ef1d97975 --- /dev/null +++ b/launcher/src/mods_project.rs @@ -0,0 +1,617 @@ +//! The Mods page's project page, search filters and project icons (`horizon_launcher::browser`: +//! `details` for the data, `images` for the icons and gallery). Drawn only with the launcher's +//! shared components, so a restyle of those restyles this. +//! +//! - A result's title opens its page inside the launcher: icon, author, downloads, followers, +//! licence, how well it runs and Install, then Description (Markdown), Gallery, Versions and +//! the project's links. +//! - Filters: sort, loader, game version, environment and categories, with the values the +//! source lists for the kind. +//! - Icons and gallery images come from the source's image servers at run time, cached on disk; +//! a placeholder stands in until one arrives. +use super::mods_page::{Checking, Event}; +use super::*; +use gpui::img; +use horizon_launcher::browser::{ + self, + details::{Details, Environment, Filters, Sort, Tags}, + images, java, Kind as PackKind, Project, SourceId, Support, Version, +}; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; + +/// A project page's tabs. +#[derive(Clone, Copy, PartialEq, Eq)] +pub(super) enum Tab { + Description, + Gallery, + Versions, +} + +const TABS: [(Tab, &str); 3] = [ + (Tab::Description, "Description"), + (Tab::Gallery, "Gallery"), + (Tab::Versions, "Versions"), +]; + +/// The open project page. +pub(super) struct Opened { + pub(super) project: Project, + tab: Tab, + details: Option>, + versions: Option, String>>, +} + +/// The project page, filters and images' share of the Mods page's state. +#[derive(Default)] +pub(super) struct Browse { + pub(super) open: Option, + pub(super) filters: Filters, + pub(super) show_filters: bool, + tags: HashMap<(SourceId, PackKind), Tags>, + tags_asked: HashSet<(SourceId, PackKind)>, + /// Images on disk, by URL and size; `None` while they load or when they failed. + images: HashMap<(String, u32), Option>, + image_queue: Option>, +} + +/// What the background sends this module. +pub(super) enum Loaded { + Details((SourceId, String), Result), + Versions((SourceId, String), Result, String>), + Image((String, u32), Option), + Tags((SourceId, PackKind), Result), +} + +/// Images fetched at once. +const IMAGE_WORKERS: usize = 4; + +impl Launcher { + pub(super) fn mods_loaded(&mut self, loaded: Loaded) { + let b = &mut self.mods.browse; + fn open<'a>(b: &'a mut Browse, key: &(SourceId, String)) -> Option<&'a mut Opened> { + b.open + .as_mut() + .filter(|o| (o.project.source, &o.project.id) == (key.0, &key.1)) + } + match loaded { + Loaded::Details(key, d) => { + if let Some(o) = open(b, &key) { + o.details = Some(d); + } + } + Loaded::Versions(key, v) => { + if let Some(o) = open(b, &key) { + o.versions = Some(v); + } + } + Loaded::Image(key, path) => { + b.images.insert(key, path); + } + Loaded::Tags(key, Ok(tags)) => { + b.tags.insert(key, tags); + } + Loaded::Tags(_, Err(_)) => {} + } + } + + /// Opens a project's page and asks its source for the rest of it. + pub(super) fn mods_open(&mut self, project: Project) { + let key = (project.source, project.id.clone()); + let tx = self.mods.tx.clone(); + let p = project.clone(); + std::thread::spawn(move || { + let Some(source) = browser::source(p.source) else { + return; + }; + let d = source.details(&p); + let _ = tx.send(Event::Loaded(Loaded::Details(key.clone(), d))); + let v = source.versions(&p); + let _ = tx.send(Event::Loaded(Loaded::Versions(key, v))); + }); + self.mods.browse.open = Some(Opened { + project, + tab: Tab::Description, + details: None, + versions: None, + }); + } + + /// The image of `url` at `size` when it is on disk; else asks for it once. + fn mods_image(&mut self, url: &str, size: u32) -> Option { + let key = (url.to_owned(), size); + if let Some(state) = self.mods.browse.images.get(&key) { + return state.clone(); + } + let home = backend::game_home(); + if let Some(p) = images::cached(&home, url, size) { + self.mods.browse.images.insert(key, Some(p.clone())); + return Some(p); + } + self.mods.browse.images.insert(key.clone(), None); + if images::refuse(url).is_some() { + return None; + } + let b = &mut self.mods.browse; + let queue = b.image_queue.get_or_insert_with(|| { + let (send, receive) = std::sync::mpsc::channel::<(String, u32)>(); + let receive = std::sync::Arc::new(std::sync::Mutex::new(receive)); + for _ in 0..IMAGE_WORKERS { + let (receive, tx, home) = (receive.clone(), self.mods.tx.clone(), home.clone()); + std::thread::spawn(move || loop { + let Ok(next) = receive.lock().map(|r| r.recv()) else { + break; + }; + let Ok((url, size)) = next else { + break; + }; + let path = images::fetch(&home, &url, size).ok(); + if tx + .send(Event::Loaded(Loaded::Image((url, size), path))) + .is_err() + { + break; + } + }); + } + send + }); + let _ = queue.send(key); + None + } + + /// A project's icon, `side` pixels: its image, or its first letter on a plate until then. + pub(super) fn mods_icon(&mut self, p: &Project, side: f32) -> AnyElement { + let t = self.theme; + let path = p + .icon + .clone() + .and_then(|url| self.mods_image(&url, images::ICON)); + let frame = plate(&t) + .flex() + .flex_none() + .items_center() + .justify_center() + .size(px(side)) + .overflow_hidden(); + match path { + Some(path) => frame.child(img(path).size(px(side))).into_any_element(), + None => frame + .text_color(t.muted) + .child( + text(theme::UI).child( + p.title + .chars() + .next() + .map(|c| c.to_uppercase().to_string()) + .unwrap_or_default(), + ), + ) + .into_any_element(), + } + } + + /// The values the filters can take for the current kind and source, asked for once. + fn mods_tags(&mut self) -> Option<&Tags> { + let key = (self.mods.source, self.mods.kind); + if self.mods.browse.tags_asked.insert(key) { + let tx = self.mods.tx.clone(); + std::thread::spawn(move || { + let tags = browser::source(key.0) + .ok_or_else(|| "not set up".to_string()) + .and_then(|s| s.tags(key.1)); + let _ = tx.send(Event::Loaded(Loaded::Tags(key, tags))); + }); + } + self.mods.browse.tags.get(&key) + } + + /// Sets the filters and searches again. + fn mods_filter(&mut self, change: impl FnOnce(&mut Filters)) { + change(&mut self.mods.browse.filters); + self.mods_search(0); + } + + /// The filter bar: sort always; loader, version, environment and categories when shown. + pub(super) fn mods_filters(&mut self, cx: &mut Context) -> Div { + let t = self.theme; + let this = cx.entity().downgrade(); + let f = self.mods.browse.filters.clone(); + let sorts: Vec<(Option, &'static str)> = std::iter::once((None, "Best match")) + .chain(Sort::ALL.iter().map(|s| (Some(*s), s.label()))) + .collect(); + let sort = segments("mods-sort", &sorts, f.sort, &t, { + let this = this.clone(); + move |value, _, cx| { + let _ = this.update(cx, |this, cx| { + this.mods_filter(|f| f.sort = value); + cx.notify(); + }); + } + }); + let shown = self.mods.browse.show_filters; + let active = f.categories.len() + + f.loader.is_some() as usize + + f.game_version.is_some() as usize + + f.environment.is_some() as usize; + let toggle = button( + "mods-filters", + if active > 0 { + format!("Filters ({active})") + } else { + "Filters".to_string() + }, + if shown { Kind::Secondary } else { Kind::Ghost }, + &t, + ) + .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + this.mods.browse.show_filters = !this.mods.browse.show_filters; + cx.notify(); + })); + let mut bar = div().flex().flex_col().gap(px(12.)).child( + div() + .flex() + .flex_wrap() + .items_center() + .gap(px(12.)) + .child(sort) + .child(toggle), + ); + if !shown { + return bar; + } + let Some(tags) = self.mods_tags().cloned() else { + return bar.child(caps("Loading filters").text_color(t.muted)); + }; + // A chip: on or off, one value of a filter. + let chip = |id: String, label: String, on: bool| { + button( + id, + label, + if on { Kind::Secondary } else { Kind::Ghost }, + &t, + ) + }; + let row = |title: &'static str| { + div() + .flex() + .flex_wrap() + .items_center() + .gap(px(4.)) + .child(caps(title).text_color(t.muted).w(px(120.))) + }; + let mut loaders = row("Loader"); + for l in &tags.loaders { + let on = f.loader.as_deref() == Some(l.as_str()); + let value = l.clone(); + loaders = loaders.child(chip(format!("mods-loader-{l}"), l.clone(), on).on_click( + cx.listener(move |this, _: &ClickEvent, _, cx| { + let v = value.clone(); + this.mods_filter(|f| f.loader = if on { None } else { Some(v) }); + cx.notify(); + }), + )); + } + let mut versions = row("Game version"); + if let Some(path) = self.vanilla_icon() { + versions = versions.child(img(path).size(px(20.)).flex_none()); + } + for v in tags.game_versions.iter().take(12) { + let on = f.game_version.as_deref() == Some(v.as_str()); + let value = v.clone(); + versions = versions.child(chip(format!("mods-version-{v}"), v.clone(), on).on_click( + cx.listener(move |this, _: &ClickEvent, _, cx| { + let v = value.clone(); + this.mods_filter(|f| f.game_version = if on { None } else { Some(v) }); + cx.notify(); + }), + )); + } + let mut environment = row("Environment"); + for e in Environment::ALL { + let on = f.environment == Some(e); + environment = environment.child( + chip(format!("mods-env-{}", e.label()), e.label().into(), on).on_click( + cx.listener(move |this, _: &ClickEvent, _, cx| { + this.mods_filter(|f| f.environment = if on { None } else { Some(e) }); + cx.notify(); + }), + ), + ); + } + let mut categories = row("Categories"); + for c in &tags.categories { + let on = f.categories.contains(&c.name); + let value = c.name.clone(); + categories = categories.child( + chip(format!("mods-cat-{}", c.name), c.name.replace('-', " "), on).on_click( + cx.listener(move |this, _: &ClickEvent, _, cx| { + let v = value.clone(); + this.mods_filter(|f| { + if !f.categories.contains(&v) { + f.categories.push(v); + } else { + f.categories.retain(|x| *x != v); + } + }); + cx.notify(); + }), + ), + ); + } + let clear = button("mods-clear", "Clear filters", Kind::Ghost, &t).on_click(cx.listener( + |this, _: &ClickEvent, _, cx| { + this.mods_filter(|f| *f = Filters::default()); + cx.notify(); + }, + )); + bar = bar + .child(loaders) + .child(versions) + .child(environment) + .child(categories); + if active > 0 { + bar = bar.child(div().flex().child(clear)); + } + bar + } + + /// Vanilla's mark, drawn from the player's prepared game files when there are some. + fn vanilla_icon(&self) -> Option { + let tag = self.selected().filter(|tag| backend::is_prepared(tag))?; + images::grass_block(&backend::game_home(), &backend::resources(tag)) + } + + /// The open project's page. + pub(super) fn mods_project_page(&mut self, cx: &mut Context) -> Option
{ + let t = self.theme; + let o = self.mods.browse.open.as_ref()?; + let p = o.project.clone(); + let tab = o.tab; + let support = self.mods.support(&p); + let color = match support { + Support::Works => t.success, + Support::Partial(_) => t.accent, + Support::No(_) => t.muted, + }; + let back = button("mods-back", "Back to results", Kind::Ghost, &t).on_click(cx.listener( + |this, _: &ClickEvent, _, cx| { + this.mods.browse.open = None; + cx.notify(); + }, + )); + let details = o.details.clone(); + let versions = o.versions.clone(); + let mut meta = vec![format!("{} downloads", thousands(p.downloads))]; + if let Some(Ok(d)) = &details { + meta.push(format!("{} followers", thousands(d.followers))); + if let Some(day) = d.updated.get(..10) { + meta.push(format!("updated {day}")); + } + } + if !p.license.is_empty() { + meta.push(p.license.clone()); + } + meta.push(format!("from {}", p.source.label())); + let authors = match &details { + Some(Ok(d)) if !d.authors.is_empty() => d.authors.join(", "), + _ => p.author.clone(), + }; + let icon = self.mods_icon(&p, 72.); + let busy = self.mods.busy.as_ref().map(|b| b.0.clone()); + let installed = self.mods.entry(&p).is_some(); + let checking = matches!( + self.mods.checks.get(&(p.source, p.id.clone())), + None | Some(Checking::Running) + ) && p.kind == PackKind::Mod + && p.support().installable(); + let mut action = div().flex().items_center().gap(px(8.)).child( + caps(if checking { + "Checking" + } else { + support.label() + }) + .flex_none() + .text_color(color), + ); + if busy.as_deref() == Some(p.id.as_str()) { + action = action.child(caps("Installing").text_color(t.accent)); + } else if installed { + action = action.child(caps("Installed").text_color(t.muted)); + } else if support.installable() && !checking { + let label = if p.kind == PackKind::Modpack { + "Import" + } else { + "Install" + }; + let mut install = button("mods-page-install", label, Kind::Primary, &t); + if busy.is_some() { + install = disabled(install); + } else { + let p = p.clone(); + install = install.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { + this.mods_install(p.source, p.id.clone(), Some(p.clone())); + cx.notify(); + })); + } + action = action.child(install); + } + let head = div() + .flex() + .items_center() + .gap(px(20.)) + .child(icon) + .child( + div() + .flex() + .flex_col() + .flex_1() + .min_w_0() + .gap(px(4.)) + .child(text(theme::TITLE).text_color(t.ink).child(p.title.clone())) + .child( + text(theme::UI) + .text_color(t.muted) + .child(if authors.is_empty() { + p.summary.clone() + } else { + format!("by {authors} · {}", p.summary) + }), + ) + .child( + text(theme::CAPTION) + .text_color(t.muted) + .child(meta.join(" · ")), + ), + ) + .child(action); + let mut page = div() + .flex() + .flex_col() + .gap(px(24.)) + .child(div().flex().child(back)) + .child(head); + if let Some(why) = support.reason() { + page = page.child( + text(theme::UI) + .text_color(t.ink_soft) + .child(why.to_string()), + ); + } + if let Some(Checking::Done(c)) = self.mods.checks.get(&(p.source, p.id.clone())) { + let lines = |title: &str, list: &[String]| { + div() + .flex() + .flex_col() + .gap(px(2.)) + .child(caps(title).text_color(t.muted)) + .children( + list.iter() + .map(|l| text(theme::CAPTION).text_color(t.ink_soft).child(l.clone())), + ) + }; + let mut what = div().flex().gap(px(32.)); + if !c.ports.is_empty() { + what = what.child(lines("Ports", &c.ports)); + } + if !c.skipped.is_empty() { + what = what.child(lines("Doesn't port", &c.skipped)); + } + page = page.child(what); + } + if let Some(Ok(d)) = &details { + let mut links = div().flex().flex_wrap().gap(px(8.)); + let page_url = p.page.clone(); + if !page_url.is_empty() { + links = links.child( + button( + "mods-link-page", + format!("{} page", p.source.label()), + Kind::Ghost, + &t, + ) + .on_click(move |_, _, cx| cx.open_url(&page_url)), + ); + } + for (i, l) in d.links.iter().enumerate() { + let url = l.url.clone(); + links = links.child( + button(format!("mods-link-{i}"), l.label.clone(), Kind::Ghost, &t) + .on_click(move |_, _, cx| cx.open_url(&url)), + ); + } + page = page.child(links); + } + let this = cx.entity().downgrade(); + page = page.child(div().flex().child(segments("mods-tab", &TABS, tab, &t, { + move |value, _, cx| { + let _ = this.update(cx, |this, cx| { + if let Some(o) = &mut this.mods.browse.open { + o.tab = value; + } + cx.notify(); + }); + } + }))); + let body = match tab { + Tab::Description => match &details { + None => centered("Loading…", &t), + Some(Err(e)) => notice(e.clone(), &t), + Some(Ok(d)) => div().max_w(px(760.)).child(markdown::render(&d.body, &t)), + }, + Tab::Gallery => match &details { + None => centered("Loading…", &t), + Some(Err(e)) => notice(e.clone(), &t), + Some(Ok(d)) if d.gallery.is_empty() => centered("No gallery images.", &t), + Some(Ok(d)) => { + let mut grid = div().flex().flex_wrap().gap(px(16.)); + for g in &d.gallery { + let shot = match self.mods_image(&g.url, images::GALLERY) { + Some(path) => img(path).w(px(360.)).h(px(203.)).into_any_element(), + None => plate(&t).w(px(360.)).h(px(203.)).into_any_element(), + }; + grid = grid.child( + div() + .flex() + .flex_col() + .gap(px(6.)) + .w(px(360.)) + .child(plate(&t).overflow_hidden().child(shot)) + .when(!g.title.is_empty(), |d| { + d.child( + text(theme::CAPTION) + .text_color(t.ink_soft) + .child(g.title.clone()), + ) + }), + ); + } + grid + } + }, + Tab::Versions => match &versions { + None => centered("Loading…", &t), + Some(Err(e)) => notice(e.clone(), &t), + Some(Ok(list)) => { + let mut rows = div().flex().flex_col().border_t_1().border_color(t.rule); + for (i, v) in list.iter().take(50).enumerate() { + let file = v.file(); + let mut detail = vec![ + v.published.get(..10).unwrap_or_default().to_string(), + v.loaders.join(", "), + ]; + let games: Vec<&str> = v + .game_versions + .iter() + .rev() + .take(4) + .map(String::as_str) + .collect(); + detail.push(games.join(", ")); + if let Some(f) = file { + detail.push(format!("{} · {:.1} MB", f.name, f.size as f64 / 1e6)); + } + let checked = file.and_then(|f| { + (p.kind == PackKind::Mod) + .then(|| java::cached(&backend::game_home(), f)) + .flatten() + }); + rows = rows.child(index_row( + i + 1, + v.number.clone(), + Some(detail.join(" · ").into_any_element()), + checked.map(|c| { + caps(c.verdict.label()) + .text_color(t.muted) + .into_any_element() + }), + &t, + )); + } + rows + } + }, + }; + Some(page.child(body)) + } +} From b1dc23551b93ed2e307c42073b560c0cb7ae6734 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:02:25 +0200 Subject: [PATCH 2/8] tools: end-to-end check of a Java mod from the mod browser mod_browser_harness.py searches, checks and installs a mod through the launcher's mods CLI in a scratch game folder, opens a world with it offscreen, places one of its blocks and crafts one of its recipes on a crafting table, and times the world load with and without it. Macaw's Fences: check 4.0 s, install 3.1 s, world load 5.4 s -> 7.3 s, hedge placed, 4 crafted. Co-Authored-By: Claude Opus 5.5 --- docs/mod-browser.md | 13 ++ tools/mod_browser_harness.py | 270 +++++++++++++++++++++++++++++++++++ 2 files changed, 283 insertions(+) create mode 100755 tools/mod_browser_harness.py diff --git a/docs/mod-browser.md b/docs/mod-browser.md index 6601fea70..fe86328db 100644 --- a/docs/mod-browser.md +++ b/docs/mod-browser.md @@ -51,6 +51,19 @@ or GeckoLib) is listed as not ported. A mod that shares its id or a namespace wi game reads mod content when it starts: start it again after installing. **Remove** deletes the folder and turns it off. +### End-to-end check + +`tools/mod_browser_harness.py [--mod SLUG] [--query TEXT] [--keep]` runs the whole path a player takes, in a scratch +game folder: the launcher's `mods search --kind mod`, `mods check` and `mods install` (Modrinth), then the game +opens a survival world with the mod offscreen (horizon-ctl), places one of its blocks, and crafts one of its recipes +from vanilla ingredients on a crafting table and takes the result. It times the world's load with and without the +mod and prints a JSON summary; it exits 1 when a step fails. It needs the launcher, the game and horizon-ctl built +and the prepared 26.3 resources in `.local`. + +On the Mac mini (M4 Pro, offscreen) with Macaw's Fences and Walls (the default): check 4.0 s, install 3.1 s, world +load 5.4 s without mods and 7.3 s with it (resources 1.1 s and 2.6 s: the mod content folder is built on the first +start); the hedge places and its recipe crafts 4. + ## Checks on every download - The file must come from Modrinth's or CurseForge's file servers (`cdn.modrinth.com`, `edge.forgecdn.net`, diff --git a/tools/mod_browser_harness.py b/tools/mod_browser_harness.py new file mode 100755 index 000000000..4531570bc --- /dev/null +++ b/tools/mod_browser_harness.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +"""End-to-end harness for Java mods from the launcher's mod browser (docs/mod-browser.md). + + mod_browser_harness.py [--mod SLUG] [--query TEXT] [--keep] + +In a scratch game folder, as a player would through the Mods page (its CLI): + +1. `horizon-launcher mods search --kind mod TEXT` finds the mod on Modrinth; +2. `horizon-launcher mods check SLUG` downloads its jar (checksum checked) and says Works or Partly; +3. `horizon-launcher mods install SLUG` ports it into horizon-mods/ and turns it on; +4. the game opens a survival world with it (horizon-ctl, offscreen), and +5. a ported block item is given, held, and placed: the world then holds the mod's block; +6. a ported recipe with vanilla ingredients is crafted on a crafting table: the result slot shows + the mod's item and taking it puts it in the inventory. + +It also times the install and the world's load with the mod against the same world with none +(a ported mod must not slow a game that doesn't use it; this measures what one that does costs). +Prints one JSON line per step and a summary; exits 1 when a step fails. + +Needs the launcher (launcher/target/debug/horizon-launcher, built with +`--features gpui/runtime_shaders` on macOS), the game and horizon-ctl +(target/fast/horizon-client, target/fast/horizon-ctl, or --bin DIR) and the prepared resources in +.local/horizon-client/26.3. Downloads from Modrinth. +""" +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] + + +class Failed(Exception): + pass + + +def run(cmd, env, cwd=None, timeout=600): + p = subprocess.run(cmd, env=env, cwd=cwd, capture_output=True, text=True, timeout=timeout) + if p.returncode != 0: + raise Failed(f"{' '.join(map(str, cmd))}: exit {p.returncode}: {p.stderr.strip()[-800:] or p.stdout.strip()[-800:]}") + return p.stdout + + +def report(step, **fields): + print(json.dumps({"step": step, **fields}), flush=True) + + +class Game: + def __init__(self, ctl, client, resources, home, env): + self.ctl, self.client, self.resources, self.home, self.env = ctl, client, resources, home, env + self.socket = str(home / "ctl.sock") + self.pid = None + + def __call__(self, *args, timeout=120): + out = run([self.ctl, "--socket", self.socket, "--timeout", str(timeout), *map(str, args)], self.env, + timeout=timeout + 30) + return json.loads(out.strip().splitlines()[-1]) + + def launch(self, world): + start = time.monotonic() + state = self("launch", "--game", self.client, "--world", world, "--seed", "1234", "--wait", "--offscreen", + "--resources", self.resources, timeout=300) + self.pid = state.get("launch", {}).get("pid") + return time.monotonic() - start, state + + def quit(self): + try: + self("quit", timeout=30) + except Exception: + pass + if self.pid: + for _ in range(50): + try: + os.kill(self.pid, 0) + except OSError: + break + time.sleep(0.2) + else: + os.kill(self.pid, 9) + self.pid = None + + +def resources_loaded(log): + """Seconds the game's log says its resources took.""" + m = re.findall(r"resources loaded in ([0-9.]+)s", Path(log).read_text(errors="replace")) + return float(m[-1]) if m else None + + +def pick_block(mod): + """A block the carry declares with its item.""" + for f in sorted((mod / "data").glob("*/horizon/block/*.json")): + decl = json.loads(f.read_text()) + if decl.get("item", True): + return f"{f.parts[-4]}:{f.stem}" + raise Failed("the carry declares no block with an item") + + +def pick_recipe(mod, ns): + """A crafting recipe of the mod's whose result is its own and whose ingredients are plain vanilla + item ids, shaped first.""" + found = [] + for f in sorted((mod / "data").glob("*/recipe/**/*.json")): + try: + r = json.loads(f.read_text()) + except ValueError: + continue + result = (r.get("result") or {}).get("id", "") + if not result.startswith(ns + ":"): + continue + if r.get("type") == "minecraft:crafting_shaped": + keys = r.get("key", {}) + if all(isinstance(v, str) and v.startswith("minecraft:") for v in keys.values()) and \ + len(r["pattern"]) <= 3 and all(len(row) <= 3 for row in r["pattern"]): + found.append((0, f.name, r)) + elif r.get("type") == "minecraft:crafting_shapeless": + ing = r.get("ingredients", []) + if ing and len(ing) <= 9 and all(isinstance(v, str) and v.startswith("minecraft:") for v in ing): + found.append((1, f.name, r)) + if not found: + raise Failed("no crafting recipe of the mod's with vanilla ingredients") + return sorted(found, key=lambda x: (x[0], x[1]))[0][2] + + +def grid_cells(recipe): + """(slot, item) for each grid cell of a 3x3 crafting table (slots 100-108).""" + if recipe["type"] == "minecraft:crafting_shaped": + return [(100 + 3 * y + x, recipe["key"][c]) for y, row in enumerate(recipe["pattern"]) + for x, c in enumerate(row) if c != " "] + return [(100 + i, item) for i, item in enumerate(recipe["ingredients"])] + + +def inventory_slot(game, item): + inv = game("inventory") + for s in inv["hotbar"] + inv["main"]: + if s.get("item") == item: + return s["slot"] + raise Failed(f"{item} is not in the inventory: {inv.get('counts')}") + + +def main(): + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--mod", default="macaws-fences-and-walls", help="Modrinth slug of a Works or Partly mod") + ap.add_argument("--query", default="macaw", help="the search text that finds it") + ap.add_argument("--launcher", default=str(ROOT / "launcher/target/debug/horizon-launcher")) + ap.add_argument("--bin", default=str(ROOT / "target/fast"), help="folder with horizon-client and horizon-ctl") + ap.add_argument("--resources", default=str(ROOT / ".local/horizon-client/26.3")) + ap.add_argument("--keep", action="store_true", help="keep the scratch game folder") + args = ap.parse_args() + + home = Path(tempfile.mkdtemp(prefix="horizon-mod-browser-")) + env = dict(os.environ, HORIZON_HOME=str(home), HORIZON_NO_AUDIO="1", HORIZON_NO_HEARTBEAT="1", + HORIZON_CONTROL=str(home / "ctl.sock")) + launcher = [args.launcher] + game = Game(str(Path(args.bin) / "horizon-ctl"), str(Path(args.bin) / "horizon-client"), + args.resources, home, env) + summary = {"mod": args.mod} + ok = False + try: + # A baseline world first, with no mod: the same world's load time with the mod is compared. + base_home = home / "vanilla" + base_home.mkdir() + base = Game(game.ctl, game.client, args.resources, base_home, + dict(env, HORIZON_HOME=str(base_home), HORIZON_CONTROL=str(base_home / "ctl.sock"))) + base.socket = str(base_home / "ctl.sock") + os.chdir(base_home) + took, _ = base.launch("browser") + summary["world_load_without_mods_s"] = round(took, 2) + summary["resources_without_mods_s"] = resources_loaded(base_home / "ctl.log") + base.quit() + + found = json.loads(run(launcher + ["--json", "mods", "search", "--kind", "mod", *args.query.split()], env)) + slugs = [p["slug"] for p in found] + report("search", query=args.query, results=len(found), found=args.mod in slugs) + if args.mod not in slugs: + raise Failed(f"{args.mod} not in the results: {slugs[:10]}") + + start = time.monotonic() + check = json.loads(run(launcher + ["--json", "mods", "check", args.mod], env).strip().splitlines()[-1]) + summary["check_s"] = round(time.monotonic() - start, 2) + report("check", verdict=check["label"], summary=check["summary"], seconds=summary["check_s"]) + if check["verdict"] not in ("works", "partly"): + raise Failed(f"{args.mod} doesn't run: {check['summary']}") + + start = time.monotonic() + run(launcher + ["mods", "install", args.mod], env) + summary["install_s"] = round(time.monotonic() - start, 2) + mod_id = check["mod_id"] + mod = home / "horizon-mods" / mod_id + on = (home / "horizon-mods.txt").read_text() + report("install", folder=str(mod.relative_to(home)), turned_on=f"mod.{mod_id}:true" in on, + seconds=summary["install_s"]) + if not (mod / "horizon-mod.toml").is_file() or f"mod.{mod_id}:true" not in on: + raise Failed("not installed or not turned on") + + os.chdir(home) + took, state = game.launch("browser") + summary["world_load_with_mod_s"] = round(took, 2) + summary["resources_with_mod_s"] = resources_loaded(home / "ctl.log") + log = (home / "ctl.log").read_text(errors="replace") + loaded = "content from 1 mod(s)" in log + report("world", seconds=round(took, 2), content_loaded=loaded, screen=state.get("screen")) + if not loaded: + raise Failed("the game didn't load the mod's content") + + x, y, z = (int(v // 1) for v in state["player"]["position"]) + block = pick_block(mod) + game("command", "gamemode survival") + game("command", f"fill {x + 1} {y - 1} {z - 2} {x + 4} {y - 1} {z + 2} minecraft:stone") + game("command", f"fill {x + 1} {y} {z - 2} {x + 4} {y + 2} {z + 2} minecraft:air") + game("give", block, 1) + game("select", block) + held = game("state")["held"] + placed = game("place", x + 2, y, z, "--item", block) + at = game("block", x + 2, y, z) + report("place", block=block, held=held, placed=at.get("block")) + if at.get("block") != block: + raise Failed(f"placing {block} left {at.get('block')}") + + recipe = pick_recipe(mod, mod_id) + result = recipe["result"]["id"] + # A server command: horizon-ctl's own setblock changes the client's world only. + game("command", f"setblock {x + 2} {y} {z + 2} minecraft:crafting_table") + cells = grid_cells(recipe) + game("select", 9) + game("interact", x + 2, y, z + 2) + if game("screen").get("craft_size") != 3: + raise Failed("the crafting table didn't open") + # One item at a time, picked up and put down with left clicks: quick right clicks can + # cross the server's answers and be dropped. + for slot, item in cells: + game("give", item, 1) + time.sleep(0.2) + game("click-slot", inventory_slot(game, item)) + time.sleep(0.2) + game("click-slot", slot) + time.sleep(0.2) + screen = game("screen") + out = next((s for s in screen.get("slots", []) if s.get("slot") == 200), {}) + report("craft", recipe=result, result_slot=out.get("item"), count=out.get("count")) + if out.get("item") != result: + raise Failed(f"the table shows {out.get('item')}, not {result}") + game("click-slot", 200, "shift") + time.sleep(0.3) + counts = game("inventory")["counts"] + report("crafted", inventory=counts.get(result, 0)) + if counts.get(result, 0) < 1: + raise Failed(f"{result} didn't reach the inventory") + game("close") + ok = True + except Failed as e: + report("failed", why=str(e)) + finally: + game.quit() + summary["ok"] = ok + print(json.dumps({"summary": summary}), flush=True) + if args.keep: + print(f"kept {home}", file=sys.stderr) + else: + shutil.rmtree(home, ignore_errors=True) + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) From f926d1d2b13fafc6d82c8abc7e893b650e631a03 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:28:58 +0200 Subject: [PATCH 3/8] Modpacks from the mod browser: ported in the same step, mods loaded together Importing a modpack from the Mods page (or `mods install`) now ports it right away for the selected version, so Play works straight after. port-pack leaves a namespace two carried mods both claim to none of them (YUNG's mods all ship `yungsapi`, which made the game drop three of them), and the launcher applies the game's first-wins rule before it starts a pack, listing what it left off. Measured on Vanilla Perfected, Slimes Adventure and All the Mods 10 (docs/modpacks.md): 2, 20 and 42 mods ported, every world loads, no crash. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-port-pack/src/carry.rs | 12 +++ crates/horizon-port-pack/src/pipeline.rs | 114 ++++++++++++++++++++++- docs/mod-browser.md | 11 ++- docs/modpacks.md | 29 ++++++ launcher/src/cli.rs | 14 +-- launcher/src/modpacks.rs | 63 ++++++++++++- launcher/src/modpacks_page.rs | 3 + launcher/src/mods_page.rs | 11 ++- website/docs/modpacks.md | 6 +- 9 files changed, 243 insertions(+), 20 deletions(-) diff --git a/crates/horizon-port-pack/src/carry.rs b/crates/horizon-port-pack/src/carry.rs index c1fe2b32a..03b5d7dc9 100644 --- a/crates/horizon-port-pack/src/carry.rs +++ b/crates/horizon-port-pack/src/carry.rs @@ -582,6 +582,18 @@ fn extra_namespaces( claimed } +/// The namespaces besides `id` the mod in `jar` would own ([`extra_namespaces`]), for the +/// pack to find the ones several mods claim. +pub fn namespaces_claimed( + jar: &Path, + id: &str, + pack_mod: &dyn Fn(&str) -> bool, +) -> Result, String> { + let file = fs::File::open(jar).map_err(|e| format!("{}: {e}", jar.display()))?; + let mut zip = zip::ZipArchive::new(file).map_err(|e| format!("{}: {e}", jar.display()))?; + Ok(extra_namespaces(&mut zip, id, pack_mod)) +} + fn contains(haystack: &[u8], needle: &[u8]) -> bool { haystack.windows(needle.len()).any(|w| w == needle) } diff --git a/crates/horizon-port-pack/src/pipeline.rs b/crates/horizon-port-pack/src/pipeline.rs index 26744d7bd..c34f58411 100644 --- a/crates/horizon-port-pack/src/pipeline.rs +++ b/crates/horizon-port-pack/src/pipeline.rs @@ -150,7 +150,16 @@ pub fn carry( }) .flatten() .collect(); - let in_pack = |m: &str| in_pack.contains(m); + // A namespace two carried mods both claim (a library's they both use, such as YUNG's API's + // `yungsapi`, whose own jar isn't read) is neither's: either would refuse the other in the + // game. Its files stay outside both. + let claims = records.iter().filter(|r| chosen(r)).filter_map(|r| { + let (jar, info) = (r.jar.as_ref()?, r.info.as_ref()?); + let owned = carry::namespaces_claimed(jar, &info.mod_id, &|m| in_pack.contains(m)).ok()?; + Some((r.key.as_str(), owned)) + }); + let shared = shared_namespaces(claims, log); + let in_pack = |m: &str| in_pack.contains(m) || shared.contains(m); let pack = carry::Pack { loaded: &loaded, mods: &in_pack, @@ -219,6 +228,61 @@ pub fn carry( Ok(done) } +/// Which carried-over mods (`mods//carry` folders) the game loads together, in the order +/// given: as the game does, the first of two owning one namespace stays on. Returns the ones on, +/// and a line for each one left off. +pub fn loadable(mods: &[std::path::PathBuf]) -> (Vec, Vec) { + let mut on: Vec<(std::path::PathBuf, String, Vec)> = Vec::new(); + let mut off = Vec::new(); + for path in mods { + let manifest = fs::read_to_string(path.join(horizon_mods::manifest::MANIFEST)) + .map_err(|e| e.to_string()) + .and_then(|t| horizon_mods::Manifest::parse(&t)); + let m = match manifest { + Ok(m) => m, + Err(e) => { + off.push(format!("{}: {e}", path.display())); + continue; + } + }; + let owned: Vec = std::iter::once(m.id.clone()) + .chain(m.namespaces.iter().cloned()) + .collect(); + let clash = on + .iter() + .find_map(|(_, id, ns)| horizon_mods::content::clash((id, ns), (&m.id, &owned))); + match clash { + Some(why) => off.push(format!("{} not loaded: {why}", m.id)), + None => on.push((path.clone(), m.id, owned)), + } + } + (on.into_iter().map(|(p, _, _)| p).collect(), off) +} + +/// The namespaces more than one mod claims, from each mod's (key, claimed namespaces). +fn shared_namespaces<'a>( + claims: impl Iterator)>, + log: Log, +) -> BTreeSet { + let mut by_namespace: BTreeMap> = BTreeMap::new(); + for (key, owned) in claims { + for ns in owned { + by_namespace.entry(ns).or_default().push(key); + } + } + by_namespace + .into_iter() + .filter(|(_, mods)| mods.len() > 1) + .map(|(ns, mods)| { + log(format!( + "namespace {ns} is claimed by {}: left to none of them", + mods.join(", ") + )); + ns + }) + .collect() +} + /// The pack's title as the report heads it. pub fn title(pack: &import::Pack) -> String { format!( @@ -279,3 +343,51 @@ pub fn port( } Ok((fetched, report(work, log)?)) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_namespace_two_mods_claim_is_neither_s() { + let claims = [ + ("betterdungeons", vec!["yungsapi".to_owned()]), + ("betterfortresses", vec!["yungsapi".to_owned()]), + ( + "towns_and_towers", + vec!["kaisyn".to_owned(), "towns_and_towers".to_owned()], + ), + ]; + let shared = shared_namespaces(claims.into_iter(), &|_| {}); + assert_eq!(shared.into_iter().collect::>(), ["yungsapi"]); + } + + #[test] + fn the_first_of_two_mods_owning_a_namespace_loads() { + let root = std::env::temp_dir().join(format!("port-pack-loadable-{}", std::process::id())); + let _ = fs::remove_dir_all(&root); + let carry = |key: &str, namespaces: &str| { + let dir = root.join(key).join("carry"); + fs::create_dir_all(&dir).unwrap(); + fs::write( + dir.join("horizon-mod.toml"), + format!( + "id = \"{key}\"\nname = \"{key}\"\nversion = \"1\"\napi = \"0.2\"\n\ + capabilities = [\"registries\"]\nnamespaces = [{namespaces}]\n" + ), + ) + .unwrap(); + dir + }; + let mods = [ + carry("betterdungeons", "\"yungsapi\""), + carry("betterfortresses", "\"yungsapi\""), + carry("ruby", ""), + ]; + let (on, off) = loadable(&mods); + assert_eq!(on, [mods[0].clone(), mods[2].clone()]); + assert_eq!(off.len(), 1); + assert!(off[0].starts_with("betterfortresses not loaded"), "{off:?}"); + fs::remove_dir_all(&root).unwrap(); + } +} diff --git a/docs/mod-browser.md b/docs/mod-browser.md index 74959ab4c..8c29e0dd6 100644 --- a/docs/mod-browser.md +++ b/docs/mod-browser.md @@ -118,15 +118,20 @@ same name the player put there is never overwritten. Packs, as in vanilla and Iris. - **Update** installs the newer version and removes the old file. - **Remove** deletes the file and its entry. Packs installed as dependencies stay. -- **A modpack** is downloaded (checked the same way) into `ports/downloads/` and imported; the Modpacks tab says - what of it Horizon runs and ports it. +- **A modpack** is downloaded (checked the same way) into `ports/downloads/`, imported, and ported straight away + for the selected version when it is prepared (`modpacks::import_and_port`): the jars of the mods Horizon can carry + over come from the pack's links or Modrinth, and the message says how many of its files play. **Play** on the + Modpacks tab starts it with every ported mod on together. A ported mod that owns a namespace an earlier one owns + (the game would refuse it) is left off before the game starts and listed under the pack ("Left off: ..."); the + coverage list says why each mod that didn't port didn't. A port that fails leaves the pack imported, with **Port** + to try again. Measured on three packs in [modpacks.md](modpacks.md#three-packs-through-the-mod-browser-measured). From a terminal (or an agent): ```sh horizon-launcher mods search faithful # --kind mod|modpack|resource|shader, --everything, --source horizon-launcher mods check macaws-fences-and-walls # a Java mod's verdict, what ports and what doesn't (--json) -horizon-launcher mods install faithful-32x # id or slug; a Java mod is ported, a modpack imported +horizon-launcher mods install faithful-32x # id or slug; a Java mod is ported, a modpack imported and ported horizon-launcher mods list # --json horizon-launcher mods update [faithful-32x] [--check] horizon-launcher mods remove faithful-32x diff --git a/docs/modpacks.md b/docs/modpacks.md index d062eb941..e233f176c 100644 --- a/docs/modpacks.md +++ b/docs/modpacks.md @@ -240,6 +240,10 @@ How the game loads them, without touching vanilla's path: when its Java original ships them so (`namespaces = ["kaisyn", "towns_and_towers"]` for Towns and Towers, mod id `t_and_t`; docs/mods.md). No namespace has two owners: a mod whose id or listed namespace another mod that is on already owns is left out, with both named in the log. + `horizon-port-pack` avoids that inside a pack: a namespace two carried mods both claim (YUNG's + structure mods all ship files under `yungsapi`, the namespace of YUNG's API, whose own jar isn't + carried) is left to none of them, and its files stay outside. The launcher applies the game's + rule before it starts a pack (`pipeline::loadable`) and lists what it left off. - The overlay is built on the first start with a set of mods and kept for later starts. A stamp (`horizon-mod-content.stamp`) hashes the game executable, the base tables and every mod file's size and mtime. Adding, removing or editing a mod, or a new build of Horizon, rebuilds it. For @@ -1047,6 +1051,31 @@ What is missing, ranked by how much a player notices: 6. **Smaller gaps:** Mo' Structures' mixin that keeps lakes out of its structures and its two block-ageing processors, and the 5 missing block models. +### Three packs through the mod browser, measured + +2026-10-22, Mac mini M4 Pro (offscreen, a debug launcher, release-profile `horizon-port-pack` and game), while +other sessions kept the CPU at 85 to 93 %, so times are upper bounds. The two Modrinth packs were imported with +`horizon-launcher mods install ` (the browser's Import), ATM10 from its CurseForge zip with `modpack import` +(CurseForge isn't searchable without a key), then each ported with `--resources` for 26.3 and opened as a survival +world with every ported mod on (horizon-ctl `launch --dev-mod ...`, from a scratch script). + +| Pack | Files | Import | Port | Ported mods | Coverage | Declared | World load (resources) | Crashes | +|---|---|---|---|---|---|---|---|---| +| Vanilla Perfected 26.3 (Fabric, small) | 108 | 30.4 s | 1.0 s | 2 | 2 partial, 79 not yet, 27 not needed | 0 blocks (a trail effect, a torch-flower texture) | 5.2 s (2.1 s) | none | +| Slimes Adventure 1.13.4 (Fabric 1.21.1, medium) | 105 | 18.8 s | 22.8 s | 20 | 20 partial, 46 not yet, 39 not needed | 8,208 blocks, 41 items, 1,376 recipes | 23.0 s (15.0 s) | none | +| All the Mods 10 8.3 (NeoForge 1.21.1, CurseForge zip) | 496 | 3.1 s | 29.4 s | 42 | 42 partial, 333 not yet, 121 not needed | 10,000 blocks, 1,273 items, 4,178 recipes | 38.3 s (28.7 s) | none | + +The same world with no mods loads in 5.4 s (resources 1.1 s). Import is the pack download and Modrinth's answers by +file hash; Vanilla Perfected's 30 s is mostly its 108 lookups and its download. Port is the jars (downloaded for the +two Modrinth packs; ATM10's 42 copied from a folder, 12 more are on CurseForge only and need its key) and the carry, +which writes tens of thousands of small files. World load grows with the blocks the pack declares: ATM10 adds +about 155,000 block states, and loading their models and textures is most of its 28.7 s. + +Every world loaded and stayed up, with no panic and no error in the log. Before the namespace fix above, Slimes +Adventure lost three of YUNG's mods at start ("both use the namespace yungsapi"); after it all 20 load. As a share of +the mods that need porting (not counting performance mods and libraries), 2 of 81 ported partly, 20 of 66 and 42 of +375. Every ported mod is Partial: their Java code (block entities, entities, Mixins) doesn't come along. + ## Open questions for Ludovic - Contacting authors of permissively licensed mods to publish ports (none contacted). diff --git a/launcher/src/cli.rs b/launcher/src/cli.rs index ebcc95cd6..089cf8284 100644 --- a/launcher/src/cli.rs +++ b/launcher/src/cli.rs @@ -1053,13 +1053,13 @@ fn mods(action: ModsCmd, as_json: bool) -> Result<(), String> { let file = installed::fetch_modpack(&*source, &p, &folder, &progress(p.title.clone()))?; let log = &|line: String| eprintln!("{line}"); - let pack = modpacks::import(&file, log)?; - println!( - "Imported {} ({}): horizon-launcher modpack port {}", - pack.title(), - pack.summary(), - pack.slug - ); + let resources = backend::resolve_version(None) + .ok() + .filter(|t| backend::is_prepared(t)) + .map(|t| backend::resources(&t)); + let pack = modpacks::import_and_port(&file, resources.as_deref(), log)?; + println!("{} ({})", modpacks::outcome(&pack), pack.summary()); + println!("Play it: horizon-launcher modpack play {}", pack.slug); return Ok(()); } let done = installed::install(&*source, &p, everything, &home, &line)?; diff --git a/launcher/src/modpacks.rs b/launcher/src/modpacks.rs index 2495e1ad6..67325e624 100644 --- a/launcher/src/modpacks.rs +++ b/launcher/src/modpacks.rs @@ -45,6 +45,8 @@ pub struct Imported { pub fetched: Fetched, /// The carried-over mods the profile loads. pub mods: Vec, + /// Carried-over mods left off because an earlier one owns a namespace of theirs, with why. + pub left_off: Vec, /// The mods the port step gets and carries over (content, data tier). pub portable: usize, } @@ -105,6 +107,7 @@ pub fn load(work: &Path) -> Result { let rows: Vec = pipeline::read_json(&work.join("coverage.json")).unwrap_or_default(); let records: Vec = pipeline::read_json(&work.join("inventory.json")).unwrap_or_default(); + let (mods, left_off) = pipeline::loadable(&carried(work)); Ok(Imported { slug: work .file_name() @@ -114,7 +117,8 @@ pub fn load(work: &Path) -> Result { pack, rows, fetched: Fetched::load(work), - mods: carried(work), + mods, + left_off, portable: records.iter().filter(|r| pipeline::portable(r)).count(), }) } @@ -176,6 +180,47 @@ pub fn port( load(work) } +/// What the mod browser does with a modpack: imports it, then ports what this computer can +/// (from the pack's links and Modrinth), so it plays straight away. A port that fails leaves the +/// pack imported; the error says so. +pub fn import_and_port( + path: &Path, + resources: Option<&Path>, + log: Log, +) -> Result { + let pack = import(path, log)?; + if pack.portable == 0 { + return Ok(pack); + } + port(&pack.work, resources, None, log) + .map_err(|e| format!("{} is imported, but porting it failed: {e}", pack.title())) +} + +/// One line on what an import and port gave: the mods carried over and the ones that weren't. +pub fn outcome(pack: &Imported) -> String { + let not = pack.count(Status::NotYet); + format!( + "{} is imported{}: {} of its {} files play in Horizon ({} ported, {} partly), {} don't \ + yet, {} aren't needed{}. Its list says why.", + pack.title(), + if pack.mods.is_empty() { + "" + } else { + " and ported" + }, + pack.count(Status::Ported) + pack.count(Status::Partial), + pack.rows.len(), + pack.count(Status::Ported), + pack.count(Status::Partial), + not, + pack.count(Status::NotNeeded), + match pack.left_off.len() { + 0 => String::new(), + n => format!("; {n} ported mods are left off, sharing a namespace with another"), + }, + ) +} + /// Removes an imported pack: its jars, ports and the profile's saves. pub fn remove(work: &Path) -> Result<(), String> { if work.parent() != Some(ports_dir().as_path()) { @@ -192,7 +237,8 @@ pub fn profile_home(work: &Path) -> PathBuf { /// Turns the game's command into this pack's profile: its own home (the player's options.txt /// copied in the first time, so keys and video settings carry over) and its carried-over mods. pub fn apply_profile(cmd: &mut Command, work: &Path) -> Result<(), String> { - let mods = carried(work); + // Only mods that load together: the game would refuse the later of two sharing a namespace. + let (mods, _) = pipeline::loadable(&carried(work)); if mods.is_empty() { return Err("Nothing is ported yet: port the pack first.".into()); } @@ -229,12 +275,19 @@ mod tests { let work = root.join("ports/small"); let mut cmd = Command::new("true"); assert!(apply_profile(&mut cmd, &work).is_err()); - for id in ["a", "b"] { + for (id, namespaces) in [("a", ""), ("b", ""), ("d", "\"a\"")] { let carry = work.join("mods").join(id).join("carry"); fs::create_dir_all(&carry).unwrap(); - fs::write(carry.join("horizon-mod.toml"), "").unwrap(); + fs::write( + carry.join("horizon-mod.toml"), + format!( + "id = \"{id}\"\nname = \"{id}\"\nversion = \"1\"\napi = \"0.2\"\n\ + capabilities = [\"registries\"]\nnamespaces = [{namespaces}]\n" + ), + ) + .unwrap(); } - // Left out: no manifest. + // Left out: no manifest (c), and one owning a namespace of an earlier mod (d). fs::create_dir_all(work.join("mods/c/carry")).unwrap(); apply_profile(&mut cmd, &work).unwrap(); let args: Vec = cmd diff --git a/launcher/src/modpacks_page.rs b/launcher/src/modpacks_page.rs index b776321b3..f3e2f1d7b 100644 --- a/launcher/src/modpacks_page.rs +++ b/launcher/src/modpacks_page.rs @@ -327,6 +327,9 @@ impl Launcher { if let Some(jars) = p.jars() { notes.push(jars); } + for line in &p.left_off { + notes.push(format!("Left off: {line}.")); + } if !can_play { notes.push("Playing needs a prepared Horizon version (Play page).".into()); } diff --git a/launcher/src/mods_page.rs b/launcher/src/mods_page.rs index e66c02490..214048519 100644 --- a/launcher/src/mods_page.rs +++ b/launcher/src/mods_page.rs @@ -294,6 +294,11 @@ impl Launcher { self.mods.busy = Some((id.clone(), format!("Asking {}…", source.label()), 0.)); self.mods.updates.remove(&(source, id.clone())); let everything = self.mods.everything || self.mods.browse.filters.other_version(); + // A modpack is ported for the selected version as soon as it is imported. + let resources = self + .selected() + .filter(|tag| backend::is_prepared(tag)) + .map(|tag| backend::resources(tag)); let tx = self.mods.tx.clone(); std::thread::spawn(move || { let last = std::cell::Cell::new(-1); @@ -318,10 +323,10 @@ impl Launcher { progress(&p.title, f) })?; let log = |line: String| progress(&line, 1.); - let pack = modpacks::import(&file, &log)?; + let pack = modpacks::import_and_port(&file, resources.as_deref(), &log)?; return Ok(format!( - "{} is imported: it is listed above, ready to port.", - pack.title() + "{} Play it from the list above.", + modpacks::outcome(&pack) )); } let home = backend::game_home(); diff --git a/website/docs/modpacks.md b/website/docs/modpacks.md index e3ed053d9..a1aa1e2ea 100644 --- a/website/docs/modpacks.md +++ b/website/docs/modpacks.md @@ -8,7 +8,7 @@ Java mod jars do not run in Horizon. Instead, the launcher reads a modpack, tell Open **Modpacks** in the launcher. (From the next launcher release, it is the **Modpacks** tab of the **Mods** page.) -1. **Import a pack.** Choose **Import a pack file…** for a Modrinth `.mrpack` or a CurseForge zip, or **Import an instance folder…** for a Prism, MultiMC or CurseForge instance. The **Mods** page can also find a Modrinth modpack and import it for you (see [Mods and data packs](/docs/mods#finding-resource-packs-shader-packs-and-modpacks)). +1. **Import a pack.** Choose **Import a pack file…** for a Modrinth `.mrpack` or a CurseForge zip, or **Import an instance folder…** for a Prism, MultiMC or CurseForge instance. The **Mods** page can also find a Modrinth modpack and import it for you (see [Mods and data packs](/docs/mods#finding-resource-packs-shader-packs-and-modpacks)). From the next launcher release it also ports it in the same step, so you can press **Play** right away. 2. **Read the coverage report.** For every mod in the pack it says **Ported**, **Partial**, **Not yet** or **Not needed**, with the reason. You can filter the list by status. 3. **Port.** **Port N mods** gets the jars of the mods this computer can carry over today, and carries them over for the version you picked. It copies the jars from a folder you have, or downloads them from the pack's own links (or the same file from Modrinth's download network), and checks their hashes. CurseForge downloads are coming soon, so for files that only CurseForge has, choose **Use jars from my instance…** and point it at your installed instance. 4. **Play.** **Play with N mods** starts the game as the pack's own profile, with its own saves and options, and those mods on. @@ -29,6 +29,10 @@ Nothing is uploaded or shared. The mod jars and what is made from them stay on y So a pack is a list of mods, and today Horizon can bring over a part of that list. The coverage report is the honest answer for your pack. +If two ported mods use the same namespace, the game can only load one of them. The launcher keeps the first, leaves the other off, and lists it under the pack. + +Measured on three packs: Vanilla Perfected (108 files) ports 2 mods, Slimes Adventure (105) ports 20, and All the Mods 10 (496) ports 42. Each opens a world, with no crash. Every ported mod is partial: its blocks, items, recipes and structures come over, its Java code doesn't. A world with All the Mods 10's 42 ported mods takes about 40 seconds to load, against about 5 without mods. + ## Example: All the Mods 10 All the Mods 10 has 495 jars. Horizon's report sorts them: about 290 add content, 80 are libraries, and the rest are integrations, interface mods, tools and performance mods. A few dozen of the content mods carry over today. The measured numbers are in [docs/modpacks.md](https://github.com/ludovic111/horizon/blob/main/docs/modpacks.md). From da991045f9e30c210cf8168b213849b76d18b3a9 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:54:33 +0200 Subject: [PATCH 4/8] Modpacks: jars only CurseForge serves are left off, "CurseForge downloads coming soon" The pack lists them with the mods left off, the port's log and fetch label say the same, and docs/modpacks.md no longer asks for a key. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-port-pack/src/fetch.rs | 6 ++-- docs/modpacks.md | 12 +++---- launcher/src/modpacks.rs | 45 ++++++++++++++++++++++++--- 3 files changed, 50 insertions(+), 13 deletions(-) diff --git a/crates/horizon-port-pack/src/fetch.rs b/crates/horizon-port-pack/src/fetch.rs index f8f7b2dc7..f2803baa2 100644 --- a/crates/horizon-port-pack/src/fetch.rs +++ b/crates/horizon-port-pack/src/fetch.rs @@ -40,7 +40,7 @@ pub enum How { Downloaded, /// Copied from the player's folder. Local, - /// Only on CurseForge, whose downloads need an API key. + /// Only on CurseForge, whose downloads are coming soon (its API needs a paid key). NeedsKey, NotFound, Failed, @@ -52,7 +52,7 @@ impl How { How::Present => "already here", How::Downloaded => "downloaded", How::Local => "copied from your folder", - How::NeedsKey => "needs a CurseForge key", + How::NeedsKey => "CurseForge downloads coming soon", How::NotFound => "not found", How::Failed => "failed", } @@ -163,7 +163,7 @@ pub fn fetch( } write_json(&work.join("fetch.json"), &fetched)?; log(format!( - "jars: {} downloaded, {} copied, {} already here, {} need a CurseForge key, {} not found, {} failed", + "jars: {} downloaded, {} copied, {} already here, {} on CurseForge only (CurseForge downloads coming soon), {} not found, {} failed", fetched.count(How::Downloaded), fetched.count(How::Local), fetched.count(How::Present), diff --git a/docs/modpacks.md b/docs/modpacks.md index e233f176c..c15d0767e 100644 --- a/docs/modpacks.md +++ b/docs/modpacks.md @@ -326,8 +326,8 @@ Fabric API doesn't run (its id has a dash, which content mods can't use). A chec by SHA-1, or through a CurseForge app instance's `minecraftinstance.json`), else downloaded from the link the pack itself gives (a `.mrpack`'s, on the format's hosts only) or, for a CurseForge pack, the same file on Modrinth's public CDN, found by its SHA-1. A jar is kept only when its -hash matches. A file only CurseForge has is listed as needing a CurseForge API key (Ludovic's -decision; open question below), with the player's installed instance as the way round it. +hash matches. A file only CurseForge has is listed as "CurseForge downloads coming soon" (its API +needs a paid key; Ludovic, 2026-10-22), with the player's installed instance as the way round it. `/fetch.json` says what happened to each file. On All the Mods 10 8.3's CurseForge zip (2026-10-17): of the 54 data-tier content mods, 42 come from Modrinth's CDN (84 MB) and 12 need the key or the instance; `port` then carries the 42 over (0 ported, 42 partial: the harness is a @@ -1056,7 +1056,7 @@ What is missing, ranked by how much a player notices: 2026-10-22, Mac mini M4 Pro (offscreen, a debug launcher, release-profile `horizon-port-pack` and game), while other sessions kept the CPU at 85 to 93 %, so times are upper bounds. The two Modrinth packs were imported with `horizon-launcher mods install ` (the browser's Import), ATM10 from its CurseForge zip with `modpack import` -(CurseForge isn't searchable without a key), then each ported with `--resources` for 26.3 and opened as a survival +(CurseForge support is coming soon), then each ported with `--resources` for 26.3 and opened as a survival world with every ported mod on (horizon-ctl `launch --dev-mod ...`, from a scratch script). | Pack | Files | Import | Port | Ported mods | Coverage | Declared | World load (resources) | Crashes | @@ -1067,7 +1067,7 @@ world with every ported mod on (horizon-ctl `launch --dev-mod ...`, from a scrat The same world with no mods loads in 5.4 s (resources 1.1 s). Import is the pack download and Modrinth's answers by file hash; Vanilla Perfected's 30 s is mostly its 108 lookups and its download. Port is the jars (downloaded for the -two Modrinth packs; ATM10's 42 copied from a folder, 12 more are on CurseForge only and need its key) and the carry, +two Modrinth packs; ATM10's 42 copied from a folder, 12 more are on CurseForge only: CurseForge downloads coming soon) and the carry, which writes tens of thousands of small files. World load grows with the blocks the pack declares: ATM10 adds about 155,000 block states, and loading their models and textures is most of its 28.7 s. @@ -1081,5 +1081,5 @@ the mods that need porting (not counting performance mods and libraries), 2 of 8 - Contacting authors of permissively licensed mods to publish ports (none contacted). - Whether players should run code-port agents themselves (their own Claude Code or Codex login, as Zenith does) or only download published ports. -- A CurseForge API key for the launcher (CurseForge grants them to launchers on request). Until - then, CurseForge packs import from an installed instance. +- CurseForge: dropped for now (its API needs a paid key; Ludovic, 2026-10-22), shown as coming + soon. CurseForge packs import from their zip or an installed instance. diff --git a/launcher/src/modpacks.rs b/launcher/src/modpacks.rs index 67325e624..909c6cfae 100644 --- a/launcher/src/modpacks.rs +++ b/launcher/src/modpacks.rs @@ -45,7 +45,8 @@ pub struct Imported { pub fetched: Fetched, /// The carried-over mods the profile loads. pub mods: Vec, - /// Carried-over mods left off because an earlier one owns a namespace of theirs, with why. + /// Mods left off, with why: a ported one an earlier one owns a namespace of, or one whose + /// jar only CurseForge serves (CurseForge downloads coming soon). pub left_off: Vec, /// The mods the port step gets and carries over (content, data tier). pub portable: usize, @@ -107,7 +108,15 @@ pub fn load(work: &Path) -> Result { let rows: Vec = pipeline::read_json(&work.join("coverage.json")).unwrap_or_default(); let records: Vec = pipeline::read_json(&work.join("inventory.json")).unwrap_or_default(); - let (mods, left_off) = pipeline::loadable(&carried(work)); + let (mods, mut left_off) = pipeline::loadable(&carried(work)); + let fetched = Fetched::load(work); + left_off.extend( + fetched + .files + .iter() + .filter(|o| o.how == How::NeedsKey) + .map(|o| format!("{}: CurseForge downloads coming soon", o.key)), + ); Ok(Imported { slug: work .file_name() @@ -116,7 +125,7 @@ pub fn load(work: &Path) -> Result { work: work.to_path_buf(), pack, rows, - fetched: Fetched::load(work), + fetched, mods, left_off, portable: records.iter().filter(|r| pipeline::portable(r)).count(), @@ -216,7 +225,7 @@ pub fn outcome(pack: &Imported) -> String { pack.count(Status::NotNeeded), match pack.left_off.len() { 0 => String::new(), - n => format!("; {n} ported mods are left off, sharing a namespace with another"), + n => format!("; {n} are left off (listed under the pack)"), }, ) } @@ -267,6 +276,34 @@ mod tests { assert_eq!(slug(" "), "pack"); } + #[test] + fn jars_only_curseforge_serves_are_left_off_as_coming_soon() { + use horizon_port_pack::fetch::Outcome; + let work = std::env::temp_dir().join(format!("horizon-launcher-cf-{}", std::process::id())); + let _ = fs::remove_dir_all(&work); + fs::create_dir_all(&work).unwrap(); + pipeline::write_json(&work.join("pack.json"), &Pack::default()).unwrap(); + let fetched = Fetched { + files: vec![Outcome { + key: "allthemodium".into(), + file: "mods/allthemodium.jar".into(), + how: How::NeedsKey, + detail: String::new(), + }], + }; + pipeline::write_json(&work.join("fetch.json"), &fetched).unwrap(); + let pack = load(&work).unwrap(); + assert_eq!( + pack.left_off, + ["allthemodium: CurseForge downloads coming soon"] + ); + assert!(pack + .jars() + .unwrap() + .contains("CurseForge downloads coming soon")); + fs::remove_dir_all(&work).unwrap(); + } + #[test] fn a_profile_has_its_own_home_and_mods() { let root = From 270ba3fea97b77803cc29d1f4b95461c3a6f18a5 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Thu, 22 Oct 2026 18:00:36 +0200 Subject: [PATCH 5/8] Launcher: a blocky, Minecraft-like look in dark and light Blocks palettes (dark default, light, or the system's, chosen in Settings), Pixelify Sans for headings and controls, Pixelarticons icons, square panels with an outline, a bevel and a lip, inventory tabs, and pixel art drawn in code: a stone texture behind the pages and a landscape behind Home. A top bar with the pages and the account chip, and a sidebar with the instances (Horizon shows the grass block the mod browser draws from the player's prepared textures), the hosted servers, and the launcher's version and channel. The mod browser's pages (kinds as tabs, project icons in slots, verdicts as badges, the project page) are restyled, not rebuilt. The benchmark also measures scrolling the Versions list. Co-Authored-By: Claude Opus 5.5 --- THIRD_PARTY_NOTICES.md | 12 + crates/horizon-theme/Cargo.toml | 2 +- .../horizon-theme/fonts/OFL-PixelifySans.txt | 93 ++ .../horizon-theme/fonts/PixelifySans-Bold.ttf | Bin 0 -> 95056 bytes .../fonts/PixelifySans-Medium.ttf | Bin 0 -> 94248 bytes .../fonts/PixelifySans-SemiBold.ttf | Bin 0 -> 97492 bytes .../icons/pixel/LICENSE-pixelarticons.txt | 21 + crates/horizon-theme/icons/pixel/archive.svg | 3 + .../horizon-theme/icons/pixel/arrow-left.svg | 4 + .../horizon-theme/icons/pixel/book-open.svg | 3 + crates/horizon-theme/icons/pixel/box.svg | 4 + crates/horizon-theme/icons/pixel/bug.svg | 5 + crates/horizon-theme/icons/pixel/check.svg | 3 + .../icons/pixel/chevron-down.svg | 3 + .../icons/pixel/chevron-right.svg | 3 + crates/horizon-theme/icons/pixel/clock.svg | 3 + crates/horizon-theme/icons/pixel/close.svg | 3 + crates/horizon-theme/icons/pixel/download.svg | 5 + .../icons/pixel/external-link.svg | 4 + crates/horizon-theme/icons/pixel/folder.svg | 3 + crates/horizon-theme/icons/pixel/gamepad.svg | 4 + crates/horizon-theme/icons/pixel/globe.svg | 4 + crates/horizon-theme/icons/pixel/heart.svg | 3 + crates/horizon-theme/icons/pixel/home.svg | 3 + crates/horizon-theme/icons/pixel/image.svg | 4 + crates/horizon-theme/icons/pixel/info-box.svg | 3 + crates/horizon-theme/icons/pixel/message.svg | 3 + crates/horizon-theme/icons/pixel/monitor.svg | 3 + crates/horizon-theme/icons/pixel/notes.svg | 4 + crates/horizon-theme/icons/pixel/package.svg | 4 + crates/horizon-theme/icons/pixel/play.svg | 3 + crates/horizon-theme/icons/pixel/plus.svg | 3 + crates/horizon-theme/icons/pixel/reload.svg | 5 + crates/horizon-theme/icons/pixel/search.svg | 3 + crates/horizon-theme/icons/pixel/server.svg | 3 + crates/horizon-theme/icons/pixel/sliders.svg | 1 + crates/horizon-theme/icons/pixel/terminal.svg | 3 + crates/horizon-theme/icons/pixel/trash.svg | 3 + crates/horizon-theme/icons/pixel/user.svg | 3 + crates/horizon-theme/icons/pixel/zap.svg | 3 + crates/horizon-theme/src/lib.rs | 223 +++ docs/design-system.md | 39 +- launcher/src/art.rs | 323 +++++ launcher/src/backend.rs | 39 + launcher/src/blocks.rs | 232 +++ launcher/src/main.rs | 1238 ++++++++++++----- launcher/src/modpacks_page.rs | 24 +- launcher/src/mods_page.rs | 150 +- launcher/src/mods_project.rs | 29 +- launcher/src/perf.rs | 73 +- launcher/src/scrollbar.rs | 21 +- launcher/src/servers_page.rs | 12 + launcher/src/theme.rs | 95 +- 53 files changed, 2250 insertions(+), 487 deletions(-) create mode 100644 crates/horizon-theme/fonts/OFL-PixelifySans.txt create mode 100644 crates/horizon-theme/fonts/PixelifySans-Bold.ttf create mode 100644 crates/horizon-theme/fonts/PixelifySans-Medium.ttf create mode 100644 crates/horizon-theme/fonts/PixelifySans-SemiBold.ttf create mode 100644 crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt create mode 100644 crates/horizon-theme/icons/pixel/archive.svg create mode 100644 crates/horizon-theme/icons/pixel/arrow-left.svg create mode 100644 crates/horizon-theme/icons/pixel/book-open.svg create mode 100644 crates/horizon-theme/icons/pixel/box.svg create mode 100644 crates/horizon-theme/icons/pixel/bug.svg create mode 100644 crates/horizon-theme/icons/pixel/check.svg create mode 100644 crates/horizon-theme/icons/pixel/chevron-down.svg create mode 100644 crates/horizon-theme/icons/pixel/chevron-right.svg create mode 100644 crates/horizon-theme/icons/pixel/clock.svg create mode 100644 crates/horizon-theme/icons/pixel/close.svg create mode 100644 crates/horizon-theme/icons/pixel/download.svg create mode 100644 crates/horizon-theme/icons/pixel/external-link.svg create mode 100644 crates/horizon-theme/icons/pixel/folder.svg create mode 100644 crates/horizon-theme/icons/pixel/gamepad.svg create mode 100644 crates/horizon-theme/icons/pixel/globe.svg create mode 100644 crates/horizon-theme/icons/pixel/heart.svg create mode 100644 crates/horizon-theme/icons/pixel/home.svg create mode 100644 crates/horizon-theme/icons/pixel/image.svg create mode 100644 crates/horizon-theme/icons/pixel/info-box.svg create mode 100644 crates/horizon-theme/icons/pixel/message.svg create mode 100644 crates/horizon-theme/icons/pixel/monitor.svg create mode 100644 crates/horizon-theme/icons/pixel/notes.svg create mode 100644 crates/horizon-theme/icons/pixel/package.svg create mode 100644 crates/horizon-theme/icons/pixel/play.svg create mode 100644 crates/horizon-theme/icons/pixel/plus.svg create mode 100644 crates/horizon-theme/icons/pixel/reload.svg create mode 100644 crates/horizon-theme/icons/pixel/search.svg create mode 100644 crates/horizon-theme/icons/pixel/server.svg create mode 100644 crates/horizon-theme/icons/pixel/sliders.svg create mode 100644 crates/horizon-theme/icons/pixel/terminal.svg create mode 100644 crates/horizon-theme/icons/pixel/trash.svg create mode 100644 crates/horizon-theme/icons/pixel/user.svg create mode 100644 crates/horizon-theme/icons/pixel/zap.svg create mode 100644 launcher/src/art.rs create mode 100644 launcher/src/blocks.rs diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index 368813ecf..88d3197af 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -36,3 +36,15 @@ SOFTWARE. developers, with one upstream fix applied ([gfx-rs/wgpu#10124](https://github.com/gfx-rs/wgpu/pull/10124)). It is licensed under MIT or Apache-2.0; both licenses are in that folder (`LICENSE.MIT`, `LICENSE.APACHE`). + +## Pixelify Sans + +`crates/horizon-theme/fonts/PixelifySans-*.ttf` is [Pixelify Sans](https://github.com/eifetx/Pixelify-Sans) +by The Pixelify Sans Project Authors, licensed under the SIL Open Font License 1.1 +(`crates/horizon-theme/fonts/OFL-PixelifySans.txt`). + +## Pixelarticons + +`crates/horizon-theme/icons/pixel/*.svg` are icons from +[Pixelarticons](https://github.com/halfmage/pixelarticons) by Gerrit Halfmann, under the MIT +license (`crates/horizon-theme/icons/pixel/LICENSE-pixelarticons.txt`). diff --git a/crates/horizon-theme/Cargo.toml b/crates/horizon-theme/Cargo.toml index a2fc2eead..3cc6d2324 100644 --- a/crates/horizon-theme/Cargo.toml +++ b/crates/horizon-theme/Cargo.toml @@ -3,6 +3,6 @@ name = "horizon-theme" version = "0.1.0" edition = "2021" license = "MIT" -description = "Horizon's design tokens and fonts, shared by the launcher and the game's menus" +description = "Horizon's design tokens, fonts and icons, shared by the launcher, the website and the game's menus" [dependencies] diff --git a/crates/horizon-theme/fonts/OFL-PixelifySans.txt b/crates/horizon-theme/fonts/OFL-PixelifySans.txt new file mode 100644 index 000000000..6aa5bfbae --- /dev/null +++ b/crates/horizon-theme/fonts/OFL-PixelifySans.txt @@ -0,0 +1,93 @@ +Copyright 2021 The Pixelify Sans Project Authors (https://github.com/eifetx/Pixelify-Sans) + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +https://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/crates/horizon-theme/fonts/PixelifySans-Bold.ttf b/crates/horizon-theme/fonts/PixelifySans-Bold.ttf new file mode 100644 index 0000000000000000000000000000000000000000..5f8040b71be14d283010c4a646eac856a03988bf GIT binary patch literal 95056 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


|G2G{{AWR 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 literal 0 HcmV?d00001 diff --git a/crates/horizon-theme/fonts/PixelifySans-Medium.ttf b/crates/horizon-theme/fonts/PixelifySans-Medium.ttf new file mode 100644 index 0000000000000000000000000000000000000000..5bf69961d9a3e4f83a3440319ec37cf7c49e30a7 GIT binary patch literal 94248 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 + + diff --git a/crates/horizon-theme/icons/pixel/arrow-left.svg b/crates/horizon-theme/icons/pixel/arrow-left.svg new file mode 100644 index 000000000..96905e6c1 --- /dev/null +++ b/crates/horizon-theme/icons/pixel/arrow-left.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/horizon-theme/icons/pixel/book-open.svg b/crates/horizon-theme/icons/pixel/book-open.svg new file mode 100644 index 000000000..b9b134ccc --- /dev/null +++ b/crates/horizon-theme/icons/pixel/book-open.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/horizon-theme/icons/pixel/box.svg b/crates/horizon-theme/icons/pixel/box.svg new file mode 100644 index 000000000..68f58c8ed --- /dev/null +++ b/crates/horizon-theme/icons/pixel/box.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/horizon-theme/icons/pixel/bug.svg b/crates/horizon-theme/icons/pixel/bug.svg new file mode 100644 index 000000000..cdc707a44 --- /dev/null +++ b/crates/horizon-theme/icons/pixel/bug.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/horizon-theme/icons/pixel/check.svg b/crates/horizon-theme/icons/pixel/check.svg new file mode 100644 index 000000000..e8dec6fe8 --- /dev/null +++ b/crates/horizon-theme/icons/pixel/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/horizon-theme/icons/pixel/chevron-down.svg b/crates/horizon-theme/icons/pixel/chevron-down.svg new file mode 100644 index 000000000..472e5e0d0 --- /dev/null +++ b/crates/horizon-theme/icons/pixel/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/horizon-theme/icons/pixel/chevron-right.svg b/crates/horizon-theme/icons/pixel/chevron-right.svg new file mode 100644 index 000000000..ad8a9142c --- /dev/null +++ b/crates/horizon-theme/icons/pixel/chevron-right.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/horizon-theme/icons/pixel/clock.svg b/crates/horizon-theme/icons/pixel/clock.svg new file mode 100644 index 000000000..e17594f98 --- /dev/null +++ b/crates/horizon-theme/icons/pixel/clock.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/horizon-theme/icons/pixel/close.svg b/crates/horizon-theme/icons/pixel/close.svg new file mode 100644 index 000000000..6e6d81257 --- /dev/null +++ b/crates/horizon-theme/icons/pixel/close.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/horizon-theme/icons/pixel/download.svg b/crates/horizon-theme/icons/pixel/download.svg new file mode 100644 index 000000000..122cfbd80 --- /dev/null +++ b/crates/horizon-theme/icons/pixel/download.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/horizon-theme/icons/pixel/external-link.svg b/crates/horizon-theme/icons/pixel/external-link.svg new file mode 100644 index 000000000..7058bc5b7 --- /dev/null +++ b/crates/horizon-theme/icons/pixel/external-link.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/horizon-theme/icons/pixel/folder.svg b/crates/horizon-theme/icons/pixel/folder.svg new file mode 100644 index 000000000..40533e4bb --- /dev/null +++ b/crates/horizon-theme/icons/pixel/folder.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/horizon-theme/icons/pixel/gamepad.svg b/crates/horizon-theme/icons/pixel/gamepad.svg new file mode 100644 index 000000000..9936d5e66 --- /dev/null +++ b/crates/horizon-theme/icons/pixel/gamepad.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/horizon-theme/icons/pixel/globe.svg b/crates/horizon-theme/icons/pixel/globe.svg new file mode 100644 index 000000000..3b984aa0c --- /dev/null +++ b/crates/horizon-theme/icons/pixel/globe.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/horizon-theme/icons/pixel/heart.svg b/crates/horizon-theme/icons/pixel/heart.svg new file mode 100644 index 000000000..7c0ccb9eb --- /dev/null +++ b/crates/horizon-theme/icons/pixel/heart.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/horizon-theme/icons/pixel/home.svg b/crates/horizon-theme/icons/pixel/home.svg new file mode 100644 index 000000000..f71cb8c00 --- /dev/null +++ b/crates/horizon-theme/icons/pixel/home.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/horizon-theme/icons/pixel/image.svg b/crates/horizon-theme/icons/pixel/image.svg new file mode 100644 index 000000000..cf2892770 --- /dev/null +++ b/crates/horizon-theme/icons/pixel/image.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/horizon-theme/icons/pixel/info-box.svg b/crates/horizon-theme/icons/pixel/info-box.svg new file mode 100644 index 000000000..04128032b --- /dev/null +++ b/crates/horizon-theme/icons/pixel/info-box.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/horizon-theme/icons/pixel/message.svg b/crates/horizon-theme/icons/pixel/message.svg new file mode 100644 index 000000000..c5dc11a7e --- /dev/null +++ b/crates/horizon-theme/icons/pixel/message.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/horizon-theme/icons/pixel/monitor.svg b/crates/horizon-theme/icons/pixel/monitor.svg new file mode 100644 index 000000000..52edf4d4f --- /dev/null +++ b/crates/horizon-theme/icons/pixel/monitor.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/horizon-theme/icons/pixel/notes.svg b/crates/horizon-theme/icons/pixel/notes.svg new file mode 100644 index 000000000..beb8d97e5 --- /dev/null +++ b/crates/horizon-theme/icons/pixel/notes.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/horizon-theme/icons/pixel/package.svg b/crates/horizon-theme/icons/pixel/package.svg new file mode 100644 index 000000000..cc6ce0788 --- /dev/null +++ b/crates/horizon-theme/icons/pixel/package.svg @@ -0,0 +1,4 @@ + + + + diff --git a/crates/horizon-theme/icons/pixel/play.svg b/crates/horizon-theme/icons/pixel/play.svg new file mode 100644 index 000000000..02e3c17aa --- /dev/null +++ b/crates/horizon-theme/icons/pixel/play.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/horizon-theme/icons/pixel/plus.svg b/crates/horizon-theme/icons/pixel/plus.svg new file mode 100644 index 000000000..cd35642b9 --- /dev/null +++ b/crates/horizon-theme/icons/pixel/plus.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/horizon-theme/icons/pixel/reload.svg b/crates/horizon-theme/icons/pixel/reload.svg new file mode 100644 index 000000000..3458d03f0 --- /dev/null +++ b/crates/horizon-theme/icons/pixel/reload.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/horizon-theme/icons/pixel/search.svg b/crates/horizon-theme/icons/pixel/search.svg new file mode 100644 index 000000000..7dce1b5ea --- /dev/null +++ b/crates/horizon-theme/icons/pixel/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/horizon-theme/icons/pixel/server.svg b/crates/horizon-theme/icons/pixel/server.svg new file mode 100644 index 000000000..7979820e7 --- /dev/null +++ b/crates/horizon-theme/icons/pixel/server.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/horizon-theme/icons/pixel/sliders.svg b/crates/horizon-theme/icons/pixel/sliders.svg new file mode 100644 index 000000000..c2b4912d0 --- /dev/null +++ b/crates/horizon-theme/icons/pixel/sliders.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/horizon-theme/icons/pixel/terminal.svg b/crates/horizon-theme/icons/pixel/terminal.svg new file mode 100644 index 000000000..e000cd104 --- /dev/null +++ b/crates/horizon-theme/icons/pixel/terminal.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/horizon-theme/icons/pixel/trash.svg b/crates/horizon-theme/icons/pixel/trash.svg new file mode 100644 index 000000000..f67fe4eca --- /dev/null +++ b/crates/horizon-theme/icons/pixel/trash.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/horizon-theme/icons/pixel/user.svg b/crates/horizon-theme/icons/pixel/user.svg new file mode 100644 index 000000000..13773d116 --- /dev/null +++ b/crates/horizon-theme/icons/pixel/user.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/horizon-theme/icons/pixel/zap.svg b/crates/horizon-theme/icons/pixel/zap.svg new file mode 100644 index 000000000..93ab75cbf --- /dev/null +++ b/crates/horizon-theme/icons/pixel/zap.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/horizon-theme/src/lib.rs b/crates/horizon-theme/src/lib.rs index ced8731c1..035651275 100644 --- a/crates/horizon-theme/src/lib.rs +++ b/crates/horizon-theme/src/lib.rs @@ -11,6 +11,9 @@ use std::time::Duration; pub const FONT: &str = "Geist"; pub const MONO: &str = "Geist Mono"; +/// The launcher's and the website's display face (headings, navigation, buttons, badges only): +/// Pixelify Sans, bundled under the SIL Open Font License (`fonts/OFL-PixelifySans.txt`). +pub const PIXEL: &str = "Pixelify Sans"; /// The bundled font files. pub mod fonts { @@ -18,6 +21,62 @@ pub mod fonts { pub const GEIST_MEDIUM: &[u8] = include_bytes!("../fonts/Geist-Medium.ttf"); pub const GEIST_SEMIBOLD: &[u8] = include_bytes!("../fonts/Geist-SemiBold.ttf"); pub const GEIST_MONO: &[u8] = include_bytes!("../fonts/GeistMono-Regular.ttf"); + pub const PIXEL_MEDIUM: &[u8] = include_bytes!("../fonts/PixelifySans-Medium.ttf"); + pub const PIXEL_SEMIBOLD: &[u8] = include_bytes!("../fonts/PixelifySans-SemiBold.ttf"); + pub const PIXEL_BOLD: &[u8] = include_bytes!("../fonts/PixelifySans-Bold.ttf"); +} + +/// The launcher's pixel icons: Pixelarticons (MIT, `icons/pixel/LICENSE-pixelarticons.txt`), 24 +/// unit SVGs filled with `currentColor`, by name (`"home"`, `"play"`…). +pub mod icons { + macro_rules! icons { + ($($name:literal),* $(,)?) => { + /// Every icon, `(name, svg)`. + pub const ALL: &[(&str, &[u8])] = &[ + $(($name, include_bytes!(concat!("../icons/pixel/", $name, ".svg")))),* + ]; + }; + } + icons!( + "archive", + "arrow-left", + "book-open", + "box", + "bug", + "check", + "chevron-down", + "chevron-right", + "clock", + "close", + "download", + "external-link", + "folder", + "gamepad", + "globe", + "heart", + "home", + "image", + "info-box", + "message", + "monitor", + "notes", + "package", + "play", + "plus", + "reload", + "search", + "server", + "sliders", + "terminal", + "trash", + "user", + "zap", + ); + + /// An icon's SVG by name. + pub fn get(name: &str) -> Option<&'static [u8]> { + ALL.iter().find(|(n, _)| *n == name).map(|(_, svg)| *svg) + } } /// Horizon's icon at 64 px (PNG), for window title bars and task bars at run time. Rendered from @@ -275,6 +334,113 @@ pub const DARK: Palette = Palette { dark: true, }; +/// The launcher's and the website's blocky look ("Blocks" in `docs/design-system.md`): stone-dark +/// (or lavender-light) neutrals with a violet cast, panels with a dark outline, a light bevel on top +/// and a darker lip below, and the violet `face` of the one big action. The game's menus keep +/// [`LIGHT`] and [`DARK`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Blocks { + /// The window behind everything, tiled with a faint stone texture. + pub page: u32, + /// The top bar and the sidebar. + pub side: u32, + /// Panels: rows, cards, dialogs. + pub panel: u32, + /// A selected or hovered panel or row, the open tab. + pub raised: u32, + /// Buttons and the chosen segment. + pub button: u32, + /// A button's top bevel. + pub button_bevel: u32, + /// Sunk wells: fields, tracks, switch and checkbox boxes, segmented tracks. + pub well: u32, + /// The light top (and left) edge of a panel. + pub bevel: u32, + /// The darker bottom edge of a panel or button. + pub lip: u32, + /// The 2 px outline around panels, buttons and wells. + pub outline: u32, + /// Rules between rows inside a panel. + pub rule: u32, + pub ink: u32, + pub ink_soft: u32, + pub muted: u32, + /// Links, focus, progress, the selection marker; text-safe on every background. + pub accent: u32, + /// The main action's face (Play, Install, Download), its top bevel and bottom lip. + pub face: u32, + pub face_bevel: u32, + pub face_lip: u32, + pub on_face: u32, + pub danger: u32, + pub success: u32, + /// "Partly": a mod or pack that runs in part. + pub warn: u32, + pub dark: bool, +} + +pub const BLOCKS_DARK: Blocks = Blocks { + page: 0x0f0d17, + side: 0x0b0a11, + panel: 0x1a1727, + raised: 0x221e34, + button: 0x2b2641, + button_bevel: 0x3d3560, + well: 0x0c0b12, + bevel: 0x342d52, + lip: 0x07060b, + outline: 0x05040a, + rule: 0x120f1c, + ink: 0xecedf3, + ink_soft: 0xc5c7d1, + muted: 0xa29cb8, + accent: 0x9a86ff, + face: 0x5b3fd6, + face_bevel: 0x8a74ff, + face_lip: 0x3a2694, + on_face: 0xffffff, + danger: 0xf07a68, + success: 0x45c27a, + warn: 0xf0b84f, + dark: true, +}; + +pub const BLOCKS_LIGHT: Blocks = Blocks { + page: 0xece7f7, + side: 0xe2dbf2, + panel: 0xf8f6fd, + raised: 0xe9e3f8, + button: 0xddd4f3, + button_bevel: 0xf4f0fe, + well: 0xf3f0fb, + bevel: 0xffffff, + lip: 0xc3b8e2, + outline: 0x2b2244, + rule: 0xe4ddf4, + ink: 0x1b1530, + ink_soft: 0x3b3354, + muted: 0x585071, + accent: 0x5333c9, + face: 0x5b3fd6, + face_bevel: 0x8069ef, + face_lip: 0x3a2694, + on_face: 0xffffff, + danger: 0xa3301d, + success: 0x136336, + warn: 0x7a4f00, + dark: false, +}; + +/// Pixel sizes of the blocky look: outlines, bevels and lips, the cut corner, and the stone tile. +pub const BLOCK_OUTLINE: f32 = 2.; +pub const BLOCK_BEVEL: f32 = 2.; +pub const BLOCK_LIP: f32 = 4.; +/// The stone texture: a 16 by 16 tile, each texel this many logical pixels. +pub const TILE_TEXELS: usize = 16; +pub const TILE_TEXEL: f32 = 4.; +/// The texture's shades: the page colour moved this many levels (of 255) lighter or darker. +pub const TILE_SHADES: [i16; 5] = [0, 2, -2, 5, -4]; + /// How strongly the game's menus veil the live backdrop (the title panorama, or the world behind /// the pause and options screens) with the page colour. Every text colour the menus use keeps /// WCAG AA over it, even on a selected row's plate, with a backdrop as bright as @@ -458,6 +624,63 @@ mod tests { } } + /// The blocky look: every text colour on every background it is drawn on reaches AA, the + /// main action's label too, and the focus ring (the accent) reaches 3:1 (WCAG 1.4.11). + #[test] + fn blocks_text_meets_wcag_aa_in_both_appearances() { + for p in [BLOCKS_DARK, BLOCKS_LIGHT] { + let theme = if p.dark { "dark" } else { "light" }; + let backgrounds = [ + ("page", p.page), + ("side", p.side), + ("panel", p.panel), + ("raised", p.raised), + ("button", p.button), + ("well", p.well), + ]; + let texts = [ + ("ink", p.ink), + ("ink_soft", p.ink_soft), + ("muted", p.muted), + ("accent", p.accent), + ("danger", p.danger), + ("success", p.success), + ("warn", p.warn), + ]; + for (bn, bg) in backgrounds { + for (tn, fg) in texts { + let ratio = contrast(fg, bg); + assert!(ratio >= 4.5, "{theme} {tn} on {bn}: {ratio:.2}"); + } + // The texture's lightest shade under the page's text. + let lifted = TILE_SHADES.iter().max().copied().unwrap_or(0) as i32; + let shade = [16, 8, 0].iter().fold(0, |out, s| { + let c = (((p.page >> s) & 255) as i32 + lifted).clamp(0, 255) as u32; + out | c << s + }); + for (tn, fg) in texts { + let ratio = contrast(fg, shade); + assert!( + ratio >= 4.5, + "{theme} {tn} on the stone texture: {ratio:.2}" + ); + } + } + let label = contrast(p.on_face, p.face); + assert!(label >= 4.5, "{theme} the main action's label: {label:.2}"); + } + } + + #[test] + fn every_icon_is_a_pixel_svg() { + for (name, svg) in icons::ALL { + let svg = std::str::from_utf8(svg).unwrap(); + assert!(svg.contains("viewBox=\"0 0 24 24\""), "{name}"); + assert!(svg.contains("currentColor"), "{name}"); + } + assert!(icons::get("play").is_some()); + } + /// The game's menus over the brightest backdrop: the page colour veiled over it, and a /// selected row's plate (ink at 8 %) on top. #[test] diff --git a/docs/design-system.md b/docs/design-system.md index 40b5c1b84..2746de371 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -133,12 +133,12 @@ Geist (400, 500, 600) and Geist Mono (400), bundled under the SIL Open Font Lice ## Surfaces -- **Launcher** (`launcher/`): top bar; Play is a page header (version as the title, its notes' - first line under it), the client/server segmented control, the version picker and Play, then - an index list of Resources, Minecraft folder (and the server rows). Versions and Patch notes - are index lists with the release name as the title; Settings is an index list of switches and - values. The opening is the faint wordmark drawn letter by letter, then the interface arrives. - The sunset photograph is gone: the window is the page colour, light or dark with the system. +- **Launcher** (`launcher/`): drawn in the Blocks look below. A top bar (wordmark, the pages as + tabs, the account chip, Settings), a sidebar (the instances, the hosted servers, the launcher's + own version and channel at its foot), and the page over a stone texture. Home opens on a pixel + landscape banner with the hero, the version picker and Play; Mods holds the resource pack, + shader pack and modpack tabs; Versions and Patch notes are lists of panels; Settings is + grouped panels of rows. Dark by default, Light or System in Settings (Appearance). - **Website** (`website/`): the same page structure as before (what it is, screenshots, parity, launcher, patch notes, community), each section a `micro` mono label, a `display` heading and a short paragraph, its content an index list. Screenshots stay as images (radius @@ -150,6 +150,33 @@ Geist (400, 500, 600) and Geist Mono (400), bundled under the SIL Open Font Lice in mono, the control right). The pause menu is a centred column of plain rows over a darker veil. +## Blocks (the launcher) + +The launcher's look since the gaming redesign: Minecraft's inventory rather than a document. +Its tokens are `horizon_theme::Blocks` (`BLOCKS_DARK`, `BLOCKS_LIGHT`); the palettes above stay +what the game's menus read. + +- **Pixel type for what you act on.** Headings, tabs, buttons, badges and the wordmark use Pixelify + Sans (Medium, SemiBold, Bold); paragraphs, details and numbers stay in Geist and Geist Mono so long + text reads easily. +- **Blocks, not rounded cards.** Every surface is a square panel: a 2 px dark outline, a 2 px light + bevel along the top and a darker lip along the bottom. The main action (Play, Install) is the + violet `face` with its own bevel and a 4 px lip; secondary buttons are raised `button` blocks; + fields and tracks are sunk `well`s. +- **Inventory tabs** switch the kinds in Mods: the open tab joins the panel under it. +- **Slots** hold pictures: a mod's or pack's own icon and gallery images from its source, + downloaded when first shown and kept on disk (`launcher/src/browser/images.rs`); the launcher + ships no third-party pictures. The Horizon instance shows a grass block drawn from the player's + own prepared Minecraft textures. Sources are named in text ("from Modrinth"): Modrinth's logo is + all rights reserved, so it is not shown. +- **Pixel art made in code** (`launcher/src/art.rs`): the stone behind the pages (16 texel tiles in + five shades of the page colour) and the landscape behind Home (a sunset in dark, a dawn in light), + baked at the window's scale so each texel covers whole device pixels. +- **Icons**: Pixelarticons (MIT), 24 unit pixel SVGs in `crates/horizon-theme/icons/pixel/`, tinted + with the text colour. +- **Contrast**: every text colour meets WCAG AA (4.5:1) on the panels, the page and the lightest + stone shade in both palettes (`blocks_text_meets_wcag_aa_in_both_appearances`). + ## The mark **Horizon line.** A sun half set below a horizon, drawn as three horizontal bars of 4, 8 and diff --git a/launcher/src/art.rs b/launcher/src/art.rs new file mode 100644 index 000000000..ecae80714 --- /dev/null +++ b/launcher/src/art.rs @@ -0,0 +1,323 @@ +//! The launcher's pixel art, drawn here rather than shipped: the stone texture behind the pages +//! and the landscape behind Home (the vanilla grass block is `browser::images::grass_block`). +//! GPUI samples images smoothly, so every image is baked at the window's scale with each texel a +//! whole block of device pixels, and drawn at exactly its size: the edges stay sharp. Each image +//! is made once per appearance and scale, then reused. +use gpui::RenderImage; +use horizon_theme as tokens; +use std::collections::HashMap; +use std::sync::Arc; + +/// What an image is, to find it again. +#[derive(Clone, PartialEq, Eq, Hash)] +enum Key { + Stone { dark: bool, scale: u32 }, + Scene { dark: bool, scale: u32 }, +} + +#[derive(Default)] +pub struct Art { + made: HashMap>, +} + +/// The stone texture comes in squares of this many logical pixels (4 by 4 tiles). +pub const STONE: f32 = tokens::TILE_TEXEL * (tokens::TILE_TEXELS * 4) as f32; +/// Home's landscape: texels of this many logical pixels, and its size in texels. +pub const SCENE_TEXEL: f32 = 6.; +pub const SCENE_SIZE: (usize, usize) = (220, 56); + +impl Art { + /// The page's stone texture, for a window at `scale`. + pub fn stone(&mut self, page: u32, dark: bool, scale: f32) -> Arc { + let key = Key::Stone { + dark, + scale: scale_key(scale), + }; + self.made + .entry(key) + .or_insert_with(|| { + let n = tokens::TILE_TEXELS * 4; + bake(&opaque(&stone(page, n)), n, n, tokens::TILE_TEXEL, scale) + }) + .clone() + } + + /// Home's landscape: a sunset in the dark appearance, a dawn in the light one. + pub fn scene(&mut self, dark: bool, scale: f32) -> Arc { + let key = Key::Scene { + dark, + scale: scale_key(scale), + }; + self.made + .entry(key) + .or_insert_with(|| { + let (w, h) = SCENE_SIZE; + let palette = if dark { &SUNSET } else { &DAWN }; + bake(&opaque(&scene(palette, 3, w, h)), w, h, SCENE_TEXEL, scale) + }) + .clone() + } +} + +/// `0xRRGGBB` texels, opaque. +fn opaque(texels: &[u32]) -> Vec<[u8; 4]> { + texels + .iter() + .map(|c| [(c >> 16) as u8, (c >> 8) as u8, *c as u8, 255]) + .collect() +} + +/// The colour at the top of Home's sky, for the banner above the landscape when it is taller. +pub fn sky_top(dark: bool) -> u32 { + if dark { + SUNSET.sky[0] + } else { + DAWN.sky[0] + } +} + +fn scale_key(scale: f32) -> u32 { + (scale * 100.).round() as u32 +} + +/// `texels` (RGBA, row by row) as an image whose texels are `texel` logical pixels square at +/// `scale`. Texel edges fall on whole device pixels where the scale allows (1×, 2×, 3×). +pub fn bake(texels: &[[u8; 4]], w: usize, h: usize, texel: f32, scale: f32) -> Arc { + let size = |n: usize| ((n as f32 * texel * scale).round() as u32).max(1); + let (dw, dh) = (size(w), size(h)); + let mut buf = image::RgbaImage::new(dw, dh); + for (y, row) in buf.rows_mut().enumerate() { + let ty = (y * h / dh as usize).min(h - 1); + for (x, px) in row.enumerate() { + let tx = (x * w / dw as usize).min(w - 1); + let [r, g, b, a] = texels[ty * w + tx]; + // GPUI wants BGRA, its alpha premultiplied. + let pre = |c: u8| (c as u32 * a as u32 / 255) as u8; + *px = image::Rgba([pre(b), pre(g), pre(r), a]); + } + } + Arc::new(RenderImage::new([image::Frame::new(buf)])) +} + +/// A small, fixed random sequence (xorshift), so the art is the same on every start. +struct Rng(u32); + +impl Rng { + fn next(&mut self) -> f32 { + let mut x = self.0; + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + self.0 = x; + (x >> 8) as f32 / (1 << 24) as f32 + } +} + +fn shade(c: u32, by: i16) -> u32 { + [16, 8, 0].iter().fold(0, |out, s| { + let v = (((c >> s) & 255) as i16 + by).clamp(0, 255) as u32; + out | v << s + }) +} + +fn mix(a: u32, b: u32, t: f32) -> u32 { + [16, 8, 0].iter().fold(0, |out, s| { + let (x, y) = (((a >> s) & 255) as f32, ((b >> s) & 255) as f32); + out | ((x + (y - x) * t).round() as u32) << s + }) +} + +/// `n` by `n` texels of stone: the page colour in a few close shades, with darker flecks. +fn stone(page: u32, n: usize) -> Vec { + let shades = tokens::TILE_SHADES.map(|by| shade(page, by)); + let mut rng = Rng(0x9e37_79b9); + let mut out: Vec = (0..n * n) + .map(|_| { + let v = rng.next(); + shades[if v < 0.5 { + 0 + } else if v < 0.78 { + 1 + } else if v < 0.93 { + 2 + } else { + 3 + }] + }) + .collect(); + for _ in 0..n * n / 50 { + let (x, y) = ( + (rng.next() * n as f32) as usize, + (rng.next() * n as f32) as usize, + ); + let len = 1 + (rng.next() * 3.) as usize; + for i in 0..len { + out[y * n + (x + i) % n] = shades[4]; + out[((y + 1) % n) * n + (x + i + 1) % n] = shades[4]; + } + } + out +} + +/// A landscape's colours: the sky from the top down, the sun's bands, then the hills from far +/// to near, the near hill's top edge, and the trees. +pub struct Palette { + sky: &'static [u32], + sun: [u32; 3], + far: u32, + mid: u32, + near: u32, + top: u32, + trees: u32, +} + +/// The sunset of Horizon's first icon: night blue to violet to coral, and a sun in three bands. +pub const SUNSET: Palette = Palette { + sky: &[ + 0x0a0f2c, 0x26206a, 0x4b2a7e, 0x7c3a88, 0xc4527a, 0xee6e5a, 0xffb25e, + ], + sun: [0xfff6cf, 0xffd272, 0xff9a44], + far: 0x4a2f7e, + mid: 0x2b1f5e, + near: 0x151a45, + top: 0x3d2f80, + trees: 0x0e1236, +}; + +/// The light appearance's morning: lavender to peach, soft violet hills. +pub const DAWN: Palette = Palette { + sky: &[ + 0x8d7fd8, 0xa596e2, 0xbcaeea, 0xd2c4f0, 0xe7d2ee, 0xf6dce4, 0xffe6d6, + ], + sun: [0xffffff, 0xfff1d0, 0xffd9a8], + far: 0xa898dc, + mid: 0x8676c8, + near: 0x6a5ab4, + top: 0x9a8ae0, + trees: 0x4c3e94, +}; + +/// A blocky landscape, `w` by `h` texels: banded sky, a sun half set behind the far hills and cut +/// into slices like Horizon's mark, flat clouds, three ridges of hills and a few square trees. +pub fn scene(p: &Palette, seed: u32, w: usize, h: usize) -> Vec { + let mut rng = Rng(seed.wrapping_mul(0x2545_f491) | 1); + let mut out = vec![0; w * h]; + let horizon = h * 62 / 100; + let band = horizon as f32 / p.sky.len() as f32; + for y in 0..h { + let f = y as f32 / band; + let i = (f as usize).min(p.sky.len() - 1); + let dither = f - i as f32 > 0.88 && i + 1 < p.sky.len(); + for x in 0..w { + out[y * w + x] = if dither && (x + y) % 2 == 1 { + p.sky[i + 1] + } else { + p.sky[i] + }; + } + } + let rect = |out: &mut Vec, x: isize, y: isize, rw: usize, rh: usize, c: u32| { + for yy in y.max(0) as usize..((y + rh as isize).max(0) as usize).min(h) { + for xx in x.max(0) as usize..((x + rw as isize).max(0) as usize).min(w) { + out[yy * w + xx] = c; + } + } + }; + for _ in 0..4 { + let cx = (rng.next() * w as f32) as isize; + let cy = (horizon as f32 * (0.12 + rng.next() * 0.4)) as isize; + let cw = 10 + (rng.next() * 14.) as usize; + let under = p.sky[((cy as f32 / band) as usize + 1).min(p.sky.len() - 1)]; + let c = mix(under, 0xffffff, 0.12); + rect(&mut out, cx, cy, cw, 2, c); + rect(&mut out, cx + 3, cy - 2, cw.saturating_sub(6), 2, c); + } + let r = (h as f32 * 0.2) as isize; + let (cx, cy) = ( + (w as f32 * 0.68) as isize, + horizon as isize - (h as f32 * 0.11) as isize, + ); + for dy in -r..=0 { + let half = ((r * r - dy * dy) as f32).sqrt().round() as isize; + let t = (dy + r) as f32 / r as f32; + let c = p.sun[if t < 0.4 { + 0 + } else if t < 0.75 { + 1 + } else { + 2 + }]; + if (cy + dy) % 5 != 0 || (dy as f32) < -r as f32 * 0.6 { + rect(&mut out, cx - half, cy + dy, (half * 2) as usize, 1, c); + } + } + let ridge = |rng: &mut Rng, base: f32, amp: f32, rough: f32| -> Vec { + let (mut y, mut v) = (base, 0.); + (0..w) + .map(|x| { + if x % 2 == 0 { + v = (v + (rng.next() - 0.5) * rough) * 0.86; + y += v + (base - y) * 0.06; + } + y.clamp(base - amp, base + amp).round().max(0.) as usize + }) + .collect() + }; + let hf = h as f32; + let layers = [ + (p.far, horizon as f32 - hf * 0.1, hf * 0.12, 3.2, None), + (p.mid, horizon as f32 + hf * 0.02, hf * 0.09, 2.6, None), + ( + p.near, + horizon as f32 + hf * 0.18, + hf * 0.07, + 2.2, + Some(p.top), + ), + ]; + let mut near = Vec::new(); + for (c, base, amp, rough, top) in layers { + let hs = ridge(&mut rng, base, amp, rough); + for (x, &y) in hs.iter().enumerate() { + for yy in y.min(h)..h { + out[yy * w + x] = c; + } + if let (Some(top), true) = (top, y < h) { + out[y * w + x] = top; + } + } + near = hs; + } + for _ in 0..w / 26 { + let tx = 4 + (rng.next() * (w - 10) as f32) as isize; + let ty = near[tx as usize] as isize; + rect(&mut out, tx - 2, ty - 8, 5, 5, p.trees); + rect(&mut out, tx - 1, ty - 10, 3, 2, p.trees); + rect(&mut out, tx, ty - 3, 1, 3, p.trees); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn art_is_the_same_on_every_start() { + assert_eq!(scene(&SUNSET, 3, 40, 20), scene(&SUNSET, 3, 40, 20)); + assert_eq!(stone(0x0f0d17, 16), stone(0x0f0d17, 16)); + } + + #[test] + fn the_stone_stays_within_its_shades() { + let shades = tokens::TILE_SHADES.map(|by| shade(0x0f0d17, by)); + assert!(stone(0x0f0d17, 64).iter().all(|c| shades.contains(c))); + } + + #[test] + fn texels_cover_whole_device_pixels() { + let image = bake(&opaque(&[0x112233; 4]), 2, 2, 4., 2.); + let size = image.size(0); + assert_eq!((size.width.0, size.height.0), (16, 16)); + } +} diff --git a/launcher/src/backend.rs b/launcher/src/backend.rs index 8cbc6be2f..28634ba2c 100644 --- a/launcher/src/backend.rs +++ b/launcher/src/backend.rs @@ -2195,6 +2195,17 @@ pub fn spawn(job: Job, mut cmd: Command, tx: Sender) -> Result, @@ -2219,6 +2230,9 @@ pub struct Settings { /// builds" switch instead; [`Settings::parse`] reads its `beta_builds: true` as Alpha. #[serde(default)] pub update_channel: UpdateChannel, + /// Dark, Light, or the system's: the launcher's look. Dark by default. + #[serde(default)] + pub appearance: Appearance, /// The player has read what the "I'm playing" ping holds (`telemetry::PING`): on the /// first-run question, or on the Play page once if they answered that before it existed. #[serde(default)] @@ -2247,6 +2261,13 @@ impl Settings { { fields.remove("update_channel"); } + // The same for an appearance from a newer launcher. + if fields + .get("appearance") + .is_some_and(|a| serde_json::from_value::(a.clone()).is_err()) + { + fields.remove("appearance"); + } } serde_json::from_value(value).unwrap_or_default() } @@ -2497,6 +2518,24 @@ mod tests { ); } + #[test] + fn the_appearance_is_dark_unless_picked_and_an_unknown_one_keeps_the_rest() { + assert_eq!(Settings::parse("{}").appearance, Appearance::Dark); + let light = Settings::parse(r#"{"appearance": "light", "game_args": "--x"}"#); + assert_eq!(light.appearance, Appearance::Light); + let future = Settings::parse(r#"{"appearance": "sepia", "game_args": "--x"}"#); + assert_eq!( + (future.appearance, future.game_args.as_str()), + (Appearance::Dark, "--x") + ); + let settings = Settings { + appearance: Appearance::System, + ..Settings::default() + }; + let text = serde_json::to_string(&settings).unwrap(); + assert!(text.contains(r#""appearance":"system""#), "{text}"); + } + #[test] fn the_update_channel_is_stored_by_name_and_the_old_beta_switch_reads_as_alpha() { let read = |text: &str| Settings::parse(text).update_channel; diff --git a/launcher/src/blocks.rs b/launcher/src/blocks.rs new file mode 100644 index 000000000..1f3a36523 --- /dev/null +++ b/launcher/src/blocks.rs @@ -0,0 +1,232 @@ +//! The blocky look's parts (`docs/design-system.md`, "Blocks"): pixel type, pixel icons, panels +//! with a dark outline, a light bevel on top and a darker lip below, sunk wells, badges, the +//! inventory tabs, and the slots that hold icons downloaded at run time. +use crate::theme::{self, Theme}; +use gpui::{ + div, img, prelude::*, px, svg, AnyElement, App, ClickEvent, Div, FontWeight, Hsla, ImageSource, + ObjectFit, SharedString, Stateful, Svg, Window, +}; +use std::borrow::Cow; + +/// The icons, served to `svg()` as `icons/.svg`. +pub struct Assets; + +impl gpui::AssetSource for Assets { + fn load(&self, path: &str) -> gpui::Result>> { + let name = path + .strip_prefix("icons/") + .and_then(|p| p.strip_suffix(".svg")); + Ok(name.and_then(horizon_theme::icons::get).map(Cow::Borrowed)) + } + + fn list(&self, _path: &str) -> gpui::Result> { + Ok(horizon_theme::icons::ALL + .iter() + .map(|(name, _)| format!("icons/{name}.svg").into()) + .collect()) + } +} + +/// A pixel icon (`horizon_theme::icons`) in `color`. +pub fn icon(name: &'static str, size: f32, color: Hsla) -> Svg { + svg() + .path(format!("icons/{name}.svg")) + .flex_none() + .size(px(size)) + .text_color(color) +} + +/// Text in the pixel face: headings, navigation, buttons, badges. +pub fn pixel(size: f32, weight: FontWeight) -> Div { + div() + .font_family(theme::PIXEL) + .font_weight(weight) + .text_size(px(size)) + .line_height(px((size * 1.2).round())) +} + +/// The light edge along the top of a raised block and the darker lip along its bottom; the +/// block must be `relative`. +pub fn edges(top: Hsla, bottom: Hsla, lip: f32) -> [AnyElement; 2] { + [ + div() + .absolute() + .top_0() + .left_0() + .right_0() + .h(px(theme::BEVEL)) + .bg(top) + .into_any_element(), + div() + .absolute() + .bottom_0() + .left_0() + .right_0() + .h(px(lip)) + .bg(bottom) + .into_any_element(), + ] +} + +/// A panel: rows, cards, notices, dialogs. +pub fn panel(t: &Theme) -> Div { + div() + .relative() + .bg(t.panel) + .border_2() + .border_color(t.outline) + .children(edges(t.bevel, t.lip, theme::BEVEL)) +} + +/// A small label in a block of its colour: "Runs", "Partly", a version. +pub fn badge(label: impl Into, color: Hsla, t: &Theme) -> Div { + let tint = if t.dark { 0.14 } else { 0.12 }; + div() + .flex() + .flex_none() + .items_center() + .gap(px(6.)) + .h(px(22.)) + .px(px(8.)) + .bg(color.opacity(tint)) + .border_2() + .border_color(t.outline) + .child(div().size(px(6.)).bg(color)) + .child( + pixel(12., FontWeight::MEDIUM) + .text_color(color) + .child(label.into()), + ) +} + +/// Inventory tabs: a row of tabs on top of a panel, the open one joined to it. +pub fn tabs( + id: &'static str, + options: &[(T, &'static str, &'static str)], + current: T, + t: &Theme, + on_pick: impl Fn(T, &mut Window, &mut App) + Clone + 'static, +) -> Div { + let t = *t; + div() + .flex() + .items_end() + .gap(px(4.)) + .children(options.iter().map(|&(value, label, icon_name)| { + let open = current == value; + let pick = on_pick.clone(); + let tab = div() + .id(SharedString::from(format!("{id}-{label}"))) + .relative() + .flex() + .items_center() + .gap(px(8.)) + .h(px(if open { 42. } else { 38. })) + .px(px(14.)) + .border_2() + .border_b_0() + .border_color(t.outline) + .cursor_pointer() + .bg(if open { t.panel } else { t.side }) + .child( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .h(px(theme::BEVEL)) + .bg(if open { t.bevel } else { t.raised }), + ) + .child(icon(icon_name, 18., if open { t.accent } else { t.muted })) + .child( + pixel(15., FontWeight::MEDIUM) + .text_color(if open { t.ink } else { t.muted }) + .child(label), + ) + .when(!open, |d| d.hover(move |s| s.bg(t.raised))) + // The open tab covers the panel's outline under it. + .when(open, |d| d.mb(px(-2.))) + .on_click(move |_: &ClickEvent, window, cx| pick(value, window, cx)); + crate::keyboard(tab, &t) + })) +} + +/// A square slot for an icon: the picture at `source` when there is one, else the first letter of +/// `name` on a raised block. +pub fn slot(source: Option, name: &str, size: f32, t: &Theme) -> Div { + let frame = div() + .flex() + .flex_none() + .items_center() + .justify_center() + .size(px(size)) + .overflow_hidden() + .border_2() + .border_color(t.outline) + .bg(t.well); + match source { + Some(source) => frame.child(img(source).size(px(size - 4.)).object_fit(ObjectFit::Cover)), + None => frame.bg(t.raised).child( + pixel((size * 0.42).round(), FontWeight::BOLD) + .text_color(t.muted) + .child( + name.chars() + .find(|c| c.is_alphanumeric()) + .unwrap_or('?') + .to_uppercase() + .to_string(), + ), + ), + } +} + +/// Lays the stone texture over an area of `w` by `h` logical pixels. +pub fn stone(image: std::sync::Arc, w: f32, h: f32) -> Div { + let step = crate::art::STONE; + let (cols, rows) = ((w / step).ceil() as usize, (h / step).ceil() as usize); + let mut layer = div() + .absolute() + .top_0() + .left_0() + .size_full() + .overflow_hidden(); + for y in 0..rows { + for x in 0..cols { + layer = layer.child( + img(image.clone()) + .absolute() + .left(px(x as f32 * step)) + .top(px(y as f32 * step)) + .size(px(step)), + ); + } + } + layer +} + +/// A clickable block in the sidebar: an instance or a server. +pub fn side_item(id: SharedString, selected: bool, t: &Theme) -> Stateful
{ + let t = *t; + div() + .id(id) + .relative() + .flex() + .items_center() + .gap(px(10.)) + .p(px(6.)) + .cursor_pointer() + .when(selected, |d| { + d.bg(t.raised) + .children(edges(t.bevel, t.lip, theme::BEVEL)) + .child( + div() + .absolute() + .left(px(-10.)) + .top(px(8.)) + .bottom(px(8.)) + .w(px(4.)) + .bg(t.accent), + ) + }) + .when(!selected, |d| d.hover(move |s| s.bg(t.raised.opacity(0.6)))) +} diff --git a/launcher/src/main.rs b/launcher/src/main.rs index 5456c235a..365c952f4 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -1,6 +1,8 @@ //! Horizon launcher: installs Horizon builds, prepares them from the player's own Minecraft, //! and starts the client or a dedicated server. +mod art; +mod blocks; mod cli; mod collection; mod game_tools; @@ -37,6 +39,7 @@ use theme::Theme; enum Page { Play, Versions, + /// Mods, resource and shader packs, and modpacks (imported ones and the browser's). Mods, Servers, Notes, @@ -44,13 +47,28 @@ enum Page { } impl Page { - const ALL: [(Page, &'static str); 6] = [ - (Page::Play, "Play"), - (Page::Versions, "Versions"), - (Page::Mods, "Mods"), - (Page::Servers, "Servers"), - (Page::Notes, "Patch notes"), - (Page::Settings, "Settings"), + /// `HORIZON_LAUNCHER_PAGE=mods` (or `modpacks`, `servers`, `versions`, `notes`, `settings`) + /// opens on that page, for screenshots; Home otherwise. + fn from_env() -> Page { + match std::env::var("HORIZON_LAUNCHER_PAGE").as_deref() { + Ok("mods" | "modpacks") => Page::Mods, + Ok("servers") => Page::Servers, + Ok("versions") => Page::Versions, + Ok("notes") => Page::Notes, + Ok("settings") => Page::Settings, + _ => Page::Play, + } + } + + /// Every page, with its name and icon: the top bar shows all but Settings, which has its + /// own button. + const ALL: [(Page, &'static str, &'static str); 6] = [ + (Page::Play, "Home", "home"), + (Page::Mods, "Mods", "package"), + (Page::Servers, "Servers", "server"), + (Page::Versions, "Versions", "archive"), + (Page::Notes, "Patch notes", "notes"), + (Page::Settings, "Settings", "sliders"), ]; } @@ -184,13 +202,21 @@ struct Launcher { starting_pack: Option, /// The Mods page: the browser's search, installs and updates (`mods_page.rs`). mods: mods_page::Mods, + /// The name the game made up for an offline player (`horizon-player.txt`), for the top bar. + offline_name: Option, + /// The player's servers, for the sidebar until the Servers page has read them. + side_servers: Vec, + /// The pixel art made for this window (`art.rs`), and pictures downloaded by URL. + art: art::Art, + /// The window's scale, as of its last frame, for art drawn outside `render`'s reach. + scale: f32, } 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(window.appearance()); + this.theme = Theme::for_appearance(this.settings.appearance, window.appearance()); cx.notify(); }); cx.spawn(async move |this, cx| loop { @@ -226,8 +252,8 @@ impl Launcher { settings.selected = installed.first().cloned(); } let mut this = Self { - theme: Theme::for_appearance(window.appearance()), - page: Page::Play, + theme: Theme::for_appearance(settings.appearance, window.appearance()), + page: Page::from_env(), switches: 0, content_scroll: Scrollbar::default(), opening: (!settings.skip_opening).then(|| { @@ -296,6 +322,10 @@ impl Launcher { pack_error: None, starting_pack: None, mods: mods_page::Mods::new(cx), + offline_name: offline_name(), + side_servers: horizon_launcher::servers::list(), + art: art::Art::default(), + scale: window.scale_factor(), }; if this.bench.is_some() { this.perf.keep_local_log(false); @@ -321,6 +351,8 @@ impl Launcher { } fn go(&mut self, page: Page) { + // The game may have made up a name since: read it again (a small file, once a switch). + self.offline_name = offline_name(); if page != self.page { self.page = page; self.switches += 1; @@ -410,6 +442,10 @@ impl Launcher { self.go(Page::ALL[page % Page::ALL.len()].0); changed = true; } + Some(perf::Step::Scroll) => { + self.go(Page::Versions); + changed = true; + } Some(perf::Step::Quit) => cx.quit(), Some(perf::Step::Wait) | None => {} } @@ -865,11 +901,13 @@ fn hover_layer(id: &SharedString, color: Hsla, radius: f32, bleed: f32) -> AnyEl layer.into_any_element() } -/// Text set in one step of the type scale. +/// Text set in one step of the type scale: headings (heading, title, display) in the pixel face. fn text(step: theme::Step) -> Div { let d = div().text_size(px(step.px)).line_height(px(step.line_px())); let d = if step.mono { d.font_family(theme::MONO) + } else if step.px >= theme::HEADING.px { + d.font_family(theme::PIXEL) } else { d }; @@ -880,13 +918,14 @@ fn text(step: theme::Step) -> Div { } } -/// A list index: `01`, `02`… in mono, in the column on the left of an index row. -fn index(n: usize, t: &Theme) -> Div { - text(theme::MICRO) - .w(px(theme::INDEX)) - .flex_none() - .text_color(t.muted) - .child(format!("{n:02}")) +/// A release's tag as a title: mono, so its digits read at a glance. +fn version_tag(tag: &str) -> Div { + div() + .font_family(theme::MONO) + .text_size(px(22.)) + .line_height(px(28.)) + .font_weight(FontWeight::MEDIUM) + .child(tag.trim_start_matches('v').to_string()) } /// Horizon's mark: two stems, their reflection below, and the horizon across in the accent. @@ -945,6 +984,9 @@ fn keyboard(el: Stateful
, t: &Theme) -> Stateful
{ }) } +/// A button: a block with a light bevel on top and a darker lip below, its label in the pixel +/// face. Primary (the one main action) is the violet face; Secondary a raised block; Ghost only +/// its label until the pointer is over it. fn button( id: impl Into, label: impl Into, @@ -954,9 +996,9 @@ fn button( let id: SharedString = id.into(); let t = *t; let hover = match kind { - Kind::Primary => t.on_accent.opacity(0.12), - Kind::Secondary => t.plate, - Kind::Ghost => t.plate_soft, + Kind::Primary => t.on_face.opacity(0.10), + Kind::Secondary => t.bevel.opacity(if t.dark { 0.10 } else { 0.35 }), + Kind::Ghost => t.raised, }; let base = div() .id(id.clone()) @@ -966,56 +1008,91 @@ fn button( .items_center() .justify_center() .h(px(theme::CONTROL)) - .px(px(16.)) - .rounded(px(theme::RADIUS_PILL)) - .text_size(px(theme::LABEL.px)) + .px(px(14.)) + .font_family(theme::PIXEL) + .text_size(px(15.)) .font_weight(FontWeight::MEDIUM) .cursor_pointer() - .child(hover_layer(&id, hover, theme::RADIUS_PILL, 0.)) - .child(div().relative().child(label.into())); - let base = keyboard(track_hover(base, id), &t); - match kind { + .child(hover_layer(&id, hover, 0., 0.)); + let base = match kind { Kind::Primary => base - .bg(t.accent) - .text_color(t.on_accent) - .active(|s| s.opacity(theme::PRESSED)), + .border_2() + .border_color(t.outline) + .children(blocks::edges(t.face_bevel, t.face_lip, theme::LIP)) + .bg(t.face) + .text_color(t.on_face), Kind::Secondary => base - .bg(t.plate_soft) - .text_color(t.ink) - .active(|s| s.opacity(theme::PRESSED)), - Kind::Ghost => base - .text_color(t.muted) - .hover(move |s| s.text_color(t.ink)) - .active(|s| s.opacity(theme::PRESSED)), - } + .border_2() + .border_color(t.outline) + .children(blocks::edges(t.button_bevel, t.lip, theme::LIP)) + .bg(t.button) + .text_color(t.ink), + Kind::Ghost => base.text_color(t.muted).hover(move |s| s.text_color(t.ink)), + }; + // The label sits on the block's face, above its lip; pressing pushes it down. + let base = base.child( + div() + .relative() + .flex() + .items_center() + .gap(px(8.)) + .pb(px(if kind == Kind::Ghost { 0. } else { 2. })) + .child(label.into()), + ); + keyboard(track_hover(base, id), &t).active(|s| s.opacity(theme::PRESSED).top(px(1.))) } -/// An on/off switch whose knob slides. +/// An on/off switch: a square knob sliding in a sunk track that turns violet when on. fn switch(id: &'static str, on: bool, t: &Theme) -> Stateful
{ - keyboard(div().id(id), t) + let t = *t; + let knob = if on { t.on_face } else { t.muted }; + keyboard(div().id(id), &t) .relative() .flex_none() - .w(px(36.)) - .h(px(20.)) - .rounded_full() + .w(px(44.)) + .h(px(24.)) + .border_2() + .border_color(t.outline) .cursor_pointer() - .bg(if on { t.accent } else { t.plate }) + .bg(if on { t.face } else { t.well }) .child( div() .absolute() .top(px(2.)) .size(px(16.)) - .rounded_full() - .bg(gpui::white()) + .bg(knob) + .child( + div() + .absolute() + .bottom_0() + .left_0() + .right_0() + .h(px(2.)) + .bg(if on { t.face_lip } else { t.lip }), + ) .with_animation( ElementId::named_usize(id, on as usize), Animation::new(theme::MOVE).with_easing(ease_out_quint()), - move |d, p| d.left(px(if on { 2. + 16. * p } else { 18. - 16. * p })), + move |d, p| d.left(px(if on { 2. + 20. * p } else { 22. - 20. * p })), ), ) } -/// A segmented control: a soft pill track, the chosen segment an ink pill. +/// A section's title inside a page: a pixel icon in the accent and the name in the pixel face. +fn section_title(label: &'static str, icon: &'static str, t: &Theme) -> Div { + div() + .flex() + .items_center() + .gap(px(10.)) + .child(blocks::icon(icon, 20., t.accent)) + .child( + blocks::pixel(18., FontWeight::SEMIBOLD) + .text_color(t.ink) + .child(label), + ) +} + +/// A segmented control: a sunk track, the chosen segment a raised block. fn segments( id: &'static str, options: &[(T, &'static str)], @@ -1030,25 +1107,34 @@ fn segments( .flex_none() .p(px(2.)) .gap(px(2.)) - .rounded(px(theme::RADIUS_PILL)) - .bg(t.plate_soft) + .border_2() + .border_color(t.outline) + .bg(t.well) .children(options.iter().map(|&(value, label)| { let active = current == value; let pick = on_pick.clone(); keyboard(div().id(SharedString::from(format!("{id}-{label}"))), &t) + .relative() .flex() .items_center() .h(px(theme::SEGMENT)) .px(px(14.)) - .rounded(px(theme::RADIUS_PILL)) - .text_size(px(theme::LABEL.px)) + .pb(px(2.)) + .font_family(theme::PIXEL) + .text_size(px(14.)) .font_weight(FontWeight::MEDIUM) .cursor_pointer() - .when(active, |d| d.bg(t.ink).text_color(t.page)) + .when(active, |d| { + d.bg(t.button).text_color(t.ink).children(blocks::edges( + t.button_bevel, + t.lip, + 3., + )) + }) .when(!active, |d| { d.text_color(t.muted).hover(move |s| s.text_color(t.ink)) }) - .child(label) + .child(div().relative().child(label)) .on_click(move |_: &ClickEvent, window, cx| pick(value, window, cx)) })) } @@ -1057,14 +1143,14 @@ fn disabled(b: Stateful
) -> Stateful
{ b.opacity(theme::DISABLED).cursor_default().tab_stop(false) } -/// A page's title in display type and one line on what it is for. +/// A page's title in the pixel face and one line on what it is for. fn page_header(title: &str, description: &str, t: &Theme) -> Div { div() .flex() .flex_col() - .gap(px(12.)) + .gap(px(10.)) .child( - text(theme::DISPLAY) + blocks::pixel(36., FontWeight::SEMIBOLD) .text_color(t.ink) .child(title.to_string()), ) @@ -1076,24 +1162,36 @@ fn page_header(title: &str, description: &str, t: &Theme) -> Div { ) } -/// One row of an index list: its number, a name with an optional detail under it, then a -/// value or a control on the right, and a rule below. +/// One row of a list, as its own panel: a name in the pixel face with an optional detail under +/// it, then a value or a control on the right. Rows were numbered in the quiet look; `_n` keeps +/// the call sites as they are. fn index_row( - n: usize, + _n: usize, name: impl IntoElement, detail: Option, right: Option, t: &Theme, ) -> Div { - div() + slot_row(None, name, detail, right, t) +} + +/// A row with an icon slot (`blocks::slot`) on its left: a project in the Mods page's lists. +fn slot_row( + slot: Option
, + name: impl IntoElement, + detail: Option, + right: Option, + t: &Theme, +) -> Div { + blocks::panel(t) .flex() .items_center() .gap(px(16.)) .min_h(px(theme::ROW)) + .mb(px(8.)) + .px(px(16.)) .py(px(12.)) - .border_b_1() - .border_color(t.rule) - .child(index(n, t)) + .children(slot) .child( div() .flex() @@ -1101,7 +1199,11 @@ fn index_row( .flex_1() .min_w_0() .gap(px(2.)) - .child(text(theme::UI).text_color(t.ink).child(name)) + .child( + blocks::pixel(16., FontWeight::MEDIUM) + .text_color(t.ink) + .child(name), + ) .children(detail.map(|d| { text(theme::CAPTION) .text_color(t.muted) @@ -1114,7 +1216,7 @@ fn index_row( /// A plate that stands apart from the page: notices, the console, the agents note. fn plate(t: &Theme) -> Div { - div().rounded(px(theme::RADIUS_PLATE)).bg(t.plate_soft) + blocks::panel(t) } /// Something went wrong: a dot in the danger colour, then what happened. @@ -1126,14 +1228,17 @@ fn notice(text_: String, t: &Theme) -> Div { .text_size(px(theme::UI.px)) .line_height(px(21.)) .text_color(t.ink) + .border_l(px(6.)) .child( div() - .flex_none() - .mt(px(7.)) - .size(px(6.)) - .rounded_full() + .absolute() + .top(px(-2.)) + .bottom(px(-2.)) + .left(px(-6.)) + .w(px(6.)) .bg(t.danger), ) + .child(blocks::icon("info-box", 20., t.danger)) .child(div().flex_1().min_w_0().child(text_)) } @@ -1148,6 +1253,14 @@ fn centered(text_: &str, t: &Theme) -> Div { .child(text(theme::UI).text_color(t.muted).child(text_.to_string())) } +/// The name the game made up for a player without an account, once it has run. +fn offline_name() -> Option { + std::fs::read_to_string(backend::game_home().join("horizon-player.txt")) + .ok() + .map(|n| n.trim().to_string()) + .filter(|n| !n.is_empty()) +} + /// `1234` as `1,234`. fn thousands(n: u64) -> String { let digits = n.to_string(); @@ -1181,11 +1294,15 @@ fn megabytes(bytes: u64) -> String { // ----- Pages ------------------------------------------------------------------------------------- impl Launcher { - /// The top bar: the mark and the name on the left, the pages on the right. - fn topbar(&self, cx: &mut Context) -> Div { + /// The top bar: the mark and the name over the sidebar, the pages with their icons, then + /// the account playing and the Settings button. + fn topbar(&mut self, cx: &mut Context) -> Div { let t = self.theme; - let mut nav = div().flex().items_center().gap(px(2.)); - for (page, label) in Page::ALL { + let mut nav = div().flex().h_full().gap(px(2.)); + for (page, label, icon) in Page::ALL { + if page == Page::Settings { + continue; + } let active = self.page == page; let id = SharedString::from(format!("nav-{label}")); let item = div() @@ -1193,98 +1310,401 @@ impl Launcher { .relative() .flex() .items_center() - .h(px(theme::CONTROL)) - .px(px(12.)) - .rounded(px(theme::RADIUS_PILL)) - .text_size(px(theme::LABEL.px)) + .gap(px(8.)) + .px(px(14.)) .cursor_pointer() - .when(active, |d| d.bg(t.plate_soft)) - .when(!active, |d| { - d.child(hover_layer(&id, t.plate_soft, theme::RADIUS_PILL, 0.)) + .when(active, |d| { + d.bg(t.raised).child( + div() + .absolute() + .left(px(8.)) + .right(px(8.)) + .bottom_0() + .h(px(4.)) + .bg(t.accent), + ) }) + .when(!active, |d| d.child(hover_layer(&id, t.raised, 0., 0.))) + .child(div().relative().child(blocks::icon( + icon, + 18., + if active { t.accent } else { t.muted }, + ))) .child( - div() + blocks::pixel(16., FontWeight::MEDIUM) .relative() - .when(active, |d| { - d.text_color(t.ink).font_weight(FontWeight::MEDIUM) - }) - .when(!active, |d| d.text_color(t.muted)) + .text_color(if active { t.ink } else { t.muted }) .child(label), ) .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { this.go(page); cx.notify(); })); - nav = nav.child(track_hover(item, id)); + nav = nav.child(keyboard(track_hover(item, id), &t)); } - div() + // Only a signed-in account says "Microsoft account"; otherwise the name the game made up. + let (name, how) = match self.accounts.selected() { + Some(a) => (a.name.clone(), "Microsoft account"), + None => ( + self.offline_name.clone().unwrap_or_else(|| "Player".into()), + "Offline", + ), + }; + let account = keyboard(div().id("account"), &t) + .relative() .flex() - .flex_none() .items_center() - .justify_between() - .h(px(72.)) - .px(px(theme::GUTTER)) + .gap(px(10.)) + .h(px(40.)) + .pl(px(4.)) + .pr(px(12.)) + .bg(t.panel) + .border_2() + .border_color(t.outline) + .cursor_pointer() + .hover(move |s| s.bg(t.raised)) .child( div() .flex() .items_center() - .gap(px(10.)) - .child(mark(18., &t)) - .child( - text(theme::LABEL) - .font_weight(FontWeight::MEDIUM) - .text_color(t.ink) - .child("Horizon"), - ), + .justify_center() + .size(px(30.)) + .bg(t.well) + .child(blocks::icon("user", 18., t.muted)), ) - .child(nav) - } - - /// The footer: the launcher's version (or its update), and the community links. - fn footer(&self, cx: &mut Context) -> Div { - let t = self.theme; - let link = |id: &'static str, label: &'static str, url: String| { - caps(label) - .id(id) - .text_color(t.muted) - .cursor_pointer() - .hover(move |s| s.text_color(t.ink)) - .on_click(move |_, _, cx| cx.open_url(&url)) - }; - let version = match &self.self_update { - SelfUpdate::Ready(version) => div() - .flex() - .gap(px(12.)) - .child(caps(format!("Launcher updated to {version}")).text_color(t.muted)) - .child( - caps("Restart") - .id("restart") - .text_color(t.accent) - .cursor_pointer() - .hover(|s| s.opacity(0.8)) - .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { - this.self_update = SelfUpdate::Failed(backend::restart()); - cx.notify(); - })), - ), - _ => caps(format!("Launcher {}", backend::VERSION)).text_color(t.muted), - }; - let mut links = div().flex().gap(px(20.)); - if !self.links.discord.is_empty() { - links = links.child(link("discord", "Discord", self.links.discord.clone())); - } - if !self.links.website.is_empty() { - links = links.child(link("website", "Website", self.links.website.clone())); - } + .child( + div() + .flex() + .flex_col() + .child( + blocks::pixel(14., FontWeight::MEDIUM) + .text_color(t.ink) + .child(name), + ) + .child(text(theme::CAPTION).text_color(t.muted).child(how)), + ) + .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + this.go(Page::Settings); + cx.notify(); + })); + let settings_open = self.page == Page::Settings; + let settings = keyboard(div().id("nav-Settings"), &t) + .relative() + .flex() + .items_center() + .justify_center() + .size(px(40.)) + .bg(if settings_open { t.raised } else { t.panel }) + .border_2() + .border_color(t.outline) + .cursor_pointer() + .hover(move |s| s.bg(t.raised)) + .when(settings_open, |d| { + d.child( + div() + .absolute() + .left_0() + .right_0() + .bottom_0() + .h(px(4.)) + .bg(t.accent), + ) + }) + .child(blocks::icon( + "sliders", + 20., + if settings_open { t.accent } else { t.ink_soft }, + )) + .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + this.go(Page::Settings); + cx.notify(); + })); div() .flex() .flex_none() .items_center() - .justify_between() - .h(px(48.)) - .px(px(theme::GUTTER)) - .child(version) - .child(links) + .h(px(theme::TOPBAR)) + .pl(px(18.)) + .pr(px(12.)) + .bg(t.side) + .border_b_2() + .border_color(t.outline) + .child( + div() + .flex() + .flex_none() + .items_center() + .gap(px(10.)) + .w(px(theme::SIDEBAR - 18.)) + .child(mark(24., &t)) + .child( + blocks::pixel(20., FontWeight::SEMIBOLD) + .text_color(t.ink) + .child("HORIZON"), + ), + ) + .child(nav) + .child( + div() + .ml_auto() + .flex() + .items_center() + .gap(px(8.)) + .child(account) + .child(settings), + ) + } + + /// The sidebar: Horizon and the imported modpacks, the player's servers, then the + /// launcher's own update, the community links and its version. + fn sidebar(&mut self, cx: &mut Context) -> Stateful
{ + let t = self.theme; + self.load_packs(); + let heading = |label: &'static str| { + div() + .flex() + .items_center() + .justify_between() + .px(px(6.)) + .pt(px(10.)) + .pb(px(6.)) + .child( + blocks::pixel(13., FontWeight::MEDIUM) + .text_color(t.muted) + .child(label), + ) + }; + let sub = |s: String| text(theme::CAPTION).text_color(t.muted).truncate().child(s); + let title = |s: String| { + blocks::pixel(15., FontWeight::MEDIUM) + .text_color(t.ink) + .truncate() + .child(s) + }; + let dot = |c: Hsla| { + div() + .absolute() + .left(px(42.)) + .top(px(42.)) + .size(px(10.)) + .bg(c) + .border_2() + .border_color(t.side) + }; + let add = keyboard(div().id("add-pack"), &t) + .flex() + .items_center() + .justify_center() + .size(px(24.)) + .bg(t.panel) + .border_2() + .border_color(t.outline) + .cursor_pointer() + .hover(move |s| s.bg(t.raised)) + .child(blocks::icon("plus", 14., t.ink_soft)) + .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + this.show_modpack(None); + cx.notify(); + })); + let mut list = div() + .flex() + .flex_col() + .gap(px(4.)) + .child(heading("INSTANCES").child(add)); + // Horizon itself, with a grass block from the player's own prepared files. + let tag = self.selected().cloned(); + let prepared = tag.as_deref().is_some_and(backend::is_prepared); + let grass = self.vanilla_icon(); + let on_play = self.page == Page::Play; + list = list.child( + blocks::side_item("side-horizon".into(), on_play, &t) + .child(match grass { + Some(g) => div() + .flex() + .items_center() + .justify_center() + .size(px(44.)) + .child(gpui::img(g).size(px(40.))), + None => blocks::slot(None, "Horizon", 44., &t), + }) + .child(dot(if prepared { t.success } else { t.warn })) + .child( + div() + .flex() + .flex_col() + .min_w_0() + .child(title("Horizon".into())) + .child(sub(if prepared { + "Vanilla · ready" + } else { + "Needs preparing" + } + .into())), + ) + .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + this.go(Page::Play); + cx.notify(); + })), + ); + let on_packs = self.page == Page::Mods && self.mods.on_modpacks(); + for p in &self.packs { + let slug = p.slug.clone(); + let open = on_packs && self.pack_open.as_ref() == Some(&p.slug); + list = list.child( + blocks::side_item(format!("side-pack-{slug}").into(), open, &t) + .child(blocks::slot(None, p.title(), 44., &t)) + .child( + div() + .flex() + .flex_col() + .min_w_0() + .child(title(p.title().to_string())) + .child(sub(p.summary())), + ) + .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { + this.show_modpack(Some(slug.clone())); + cx.notify(); + })), + ); + } + let servers: Vec<_> = self + .servers + .sidebar_list() + .map(<[_]>::to_vec) + .unwrap_or_else(|| self.side_servers.clone()); + if !servers.is_empty() { + list = list.child(heading("SERVERS")); + } + for e in servers { + let up = self.servers.is_up(&e.dir); + let open = self.page == Page::Servers && self.servers.open.as_ref() == Some(&e.dir); + let dir = e.dir.clone(); + list = list.child( + blocks::side_item(format!("side-server-{}", e.dir.display()).into(), open, &t) + .child( + div() + .flex() + .items_center() + .justify_center() + .size(px(44.)) + .bg(t.well) + .border_2() + .border_color(t.outline) + .child(blocks::icon("server", 22., t.ink_soft)), + ) + .child(dot(if up { t.success } else { t.muted })) + .child( + div() + .flex() + .flex_col() + .min_w_0() + .child(title(e.name.clone())) + .child(sub(if up { "Running" } else { "Stopped" }.into())), + ) + .on_click(cx.listener(move |this, _: &ClickEvent, _, cx| { + this.servers.open = Some(dir.clone()); + this.go(Page::Servers); + cx.notify(); + })), + ); + } + let link = |id: &'static str, label: &'static str, icon: &'static str, url: String| { + div() + .id(id) + .flex() + .items_center() + .gap(px(10.)) + .px(px(8.)) + .py(px(6.)) + .text_color(t.muted) + .cursor_pointer() + .hover(move |s| s.text_color(t.ink).bg(t.raised.opacity(0.6))) + .child(blocks::icon(icon, 18., t.muted)) + .child(blocks::pixel(14., FontWeight::MEDIUM).child(label)) + .on_click(move |_, _, cx| cx.open_url(&url)) + }; + let mut foot = div().mt_auto().flex().flex_col().gap(px(2.)).pt(px(12.)); + match &self.self_update { + SelfUpdate::Downloading(version, f) => { + foot = foot.child( + blocks::panel(&t) + .flex() + .flex_col() + .gap(px(8.)) + .p(px(10.)) + .mb(px(6.)) + .child( + blocks::pixel(13., FontWeight::MEDIUM) + .text_color(t.ink) + .child(format!("Launcher {version} {:.0}%", f * 100.)), + ) + .child(div().flex().child(progress_bar(Some((*f, *f)), &t))), + ) + } + SelfUpdate::Ready(version) => { + foot = foot.child( + blocks::panel(&t) + .flex() + .flex_col() + .gap(px(8.)) + .p(px(10.)) + .mb(px(6.)) + .child( + blocks::pixel(13., FontWeight::MEDIUM) + .text_color(t.ink) + .child(format!("Launcher updated to {version}")), + ) + .child(button("restart", "Restart", Kind::Primary, &t).on_click( + cx.listener(|this, _: &ClickEvent, _, cx| { + this.self_update = SelfUpdate::Failed(backend::restart()); + cx.notify(); + }), + )), + ) + } + _ => {} + } + if !self.links.discord.is_empty() { + foot = foot.child(link( + "discord", + "Discord", + "message", + self.links.discord.clone(), + )); + } + if !self.links.website.is_empty() { + foot = foot.child(link( + "website", + "Website", + "globe", + self.links.website.clone(), + )); + } + foot = foot.child( + caps(format!( + "Launcher {} · {}", + backend::VERSION, + self.settings.update_channel.label() + )) + .px(px(8.)) + .pt(px(6.)) + .text_color(t.muted), + ); + div() + .id("sidebar") + .flex() + .flex_col() + .flex_none() + .w(px(theme::SIDEBAR)) + .h_full() + .px(px(10.)) + .pb(px(12.)) + .bg(t.side) + .border_r_2() + .border_color(t.outline) + .overflow_y_scroll() + .child(list) + .child(foot) } fn picker(&self, cx: &mut Context) -> Div { @@ -1293,23 +1713,41 @@ impl Launcher { .selected() .cloned() .unwrap_or_else(|| "No version".into()); + // A block as tall as Play: "VERSION" over the tag, and a chevron. let mut wrap = div().relative().child( - div() - .id("picker") + keyboard(div().id("picker"), &t) + .relative() .flex() .items_center() - .gap(px(8.)) - .h(px(theme::CONTROL_LARGE)) - .px(px(16.)) - .rounded(px(theme::RADIUS_PILL)) - .bg(t.plate_soft) - .font_family(theme::MONO) - .text_size(px(theme::CAPTION.px)) - .text_color(t.ink) + .gap(px(16.)) + .h(px(60.)) + .pl(px(14.)) + .pr(px(12.)) + .bg(t.panel) + .border_2() + .border_color(t.outline) + .children(blocks::edges(t.bevel, t.lip, theme::BEVEL)) .cursor_pointer() - .hover(move |s| s.bg(t.plate)) - .child(label) - .child(div().text_color(t.muted).text_size(px(10.)).child("▾")) + .hover(move |s| s.bg(t.raised)) + .child( + div() + .flex() + .flex_col() + .gap(px(4.)) + .child( + blocks::pixel(11., FontWeight::MEDIUM) + .text_color(t.muted) + .child("VERSION"), + ) + .child( + div() + .font_family(theme::MONO) + .text_size(px(15.)) + .text_color(t.ink) + .child(label), + ), + ) + .child(blocks::icon("chevron-down", 20., t.muted)) .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { this.picker_open = !this.picker_open; cx.notify(); @@ -1318,13 +1756,12 @@ impl Launcher { if self.picker_open { let mut menu = div() .absolute() - .top(px(theme::CONTROL_LARGE + 6.)) + .top(px(66.)) .right_0() .w(px(240.)) .p(px(6.)) - .rounded(px(theme::RADIUS_PLATE)) - .border_1() - .border_color(t.rule) + .border_2() + .border_color(t.outline) .bg(t.surface) .flex() .flex_col(); @@ -1410,10 +1847,18 @@ impl Launcher { let running = self.client.is_some() || self.starting_client.is_some(); let play_label = if running { "Running" } else { "Play" }; let mut play = button("play", play_label, Kind::Primary, &t) - .min_w(px(132.)) - .h(px(theme::CONTROL_LARGE)) + .min_w(px(200.)) + .h(px(60.)) .px(px(28.)) - .text_size(px(theme::UI.px)); + .text_size(px(28.)) + .font_weight(FontWeight::SEMIBOLD) + .child( + div() + .absolute() + .left(px(26.)) + .top(px(17.)) + .child(blocks::icon("play", 22., t.on_face)), + ); if !prepared || self.preparing || running { play = disabled(play); } else { @@ -1483,8 +1928,7 @@ impl Launcher { let mut rows = div() .flex() .flex_col() - .border_t_1() - .border_color(t.rule) + .child(section_title("Ready to play", "check", &t).mb(px(12.))) .child(index_row( 1, "Resources", @@ -1504,33 +1948,74 @@ impl Launcher { &t, )); { - let who = match self.accounts.selected() { - Some(a) => format!("{} (Microsoft account)", a.name), - None => "Offline: online servers need a Microsoft account.".to_string(), - }; rows = rows.child(self.join_friend_row(prepared && !running, cx)); - rows = rows.child(index_row( - 4, - "Playing as", - Some(div().child(who).into_any_element()), - Some( - button("accounts", "Accounts…", Kind::Ghost, &t) - .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { - this.go(Page::Settings); - cx.notify(); - })) - .into_any_element(), - ), - &t, - )); + // The top bar shows who plays; a row only when there is something to do about it: + // offline, in a build that can sign in. + if self.accounts.selected().is_none() && account::client_id().is_some() { + rows = rows.child(index_row( + 4, + "Playing offline", + Some( + div() + .child("Online servers need a Microsoft account.") + .into_any_element(), + ), + Some( + button("accounts", "Accounts…", Kind::Ghost, &t) + .on_click(cx.listener(|this, _: &ClickEvent, _, cx| { + this.go(Page::Settings); + cx.notify(); + })) + .into_any_element(), + ), + &t, + )); + } } - let mut page = div() + // The banner: Horizon's landscape (`art.rs`) under a veil of the page colour, the hero and + // the Play row over it. It reaches the content's edges and the top bar. + let scene = self.art.scene(t.dark, self.scale); + let veil = |angle: f32, from: f32, to: f32, at: f32| { + div() + .absolute() + .top_0() + .left_0() + .size_full() + .bg(gpui::linear_gradient( + angle, + gpui::linear_color_stop(t.page.opacity(from), 0.), + gpui::linear_color_stop(t.page.opacity(to), at), + )) + }; + let (w, h) = art::SCENE_SIZE; + let banner = div() + .relative() .flex() .flex_col() - .gap(px(32.)) - .child(hero) - .child(actions); + .justify_end() + .min_h(px(340.)) + .mx(px(-32.)) + .px(px(32.)) + .pb(px(28.)) + .gap(px(24.)) + .overflow_hidden() + .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(w as f32 * art::SCENE_TEXEL)) + .h(px(h as f32 * art::SCENE_TEXEL)), + ) + .child(veil(90., 0.82, 0., 0.62)) + .child(veil(180., 0., 0.75, 1.)) + .child(div().relative().child(hero)) + .child(div().relative().child(actions)); + let mut page = div().flex().flex_col().gap(px(28.)).child(banner); // The preparation's progress and error right under the buttons, where the player // looks after pressing Prepare, not below the fold (#666). if self.preparing { @@ -1564,19 +2049,7 @@ impl Launcher { if let Some(crash) = self.crash.clone() { page = page.child(self.crash_notice(crash, cx)); } - // The signature: the name, huge and faint, at the foot of the page. - page.child( - div() - .mt(px(32.)) - .ml(px(-14.)) - .h(px(theme::WORDMARK.px * 0.74)) - .overflow_hidden() - .text_size(px(theme::WORDMARK.px)) - .line_height(px(theme::WORDMARK.px)) - .font_weight(FontWeight::MEDIUM) - .text_color(if t.dark { t.plate_soft } else { t.plate }) - .child("horizon"), - ) + page } /// "Horizon crashed": the report and the logs, and where to report it. @@ -1704,43 +2177,61 @@ impl Launcher { }; let title = "Horizon"; // Never squeezed by the rows below: the ping notice can make it tall. - let mut hero = - div() - .flex() - .flex_none() - .flex_col() - .gap(px(14.)) - .pt(px(24.)) - .child( - div() - .flex() - .items_center() - .gap(px(10.)) - .child(caps(format!("Minecraft {version}")).text_color(t.muted)) - .child(div().size(px(4.)).rounded_full().bg(t.rule)) - .child( - caps(if prepared { "Ready" } else { "Needs preparing" }) - .text_color(if prepared { t.success } else { t.muted }), - ) - .children(self.playing.map(|n| { - div() - .flex() - .items_center() - .gap(px(10.)) - .child(div().size(px(4.)).rounded_full().bg(t.rule)) - .child( - caps(format!("{} playing now", thousands(n))) - .text_color(t.muted), - ) - })), - ) - .child(text(theme::DISPLAY).text_color(t.ink).child(title)) - .child(text(theme::BODY).max_w(px(560.)).text_color(t.muted).child( - match summary { - Some(line) => format!("{}. {line}", tag.trim_start_matches('v')), - None => tag.trim_start_matches('v').to_string(), - }, - )); + let mut hero = div() + .flex() + .flex_none() + .flex_col() + .gap(px(12.)) + .child( + div() + .flex() + .child( + div() + .flex() + .flex_none() + .items_center() + .gap(px(10.)) + .px(px(10.)) + .py(px(5.)) + .bg(t.side.opacity(0.85)) + .border_2() + .border_color(t.outline) + .child( + div() + .size(px(8.)) + .bg(if prepared { t.success } else { t.warn }), + ) + .child(caps(format!("Minecraft {version}")).text_color(t.ink_soft)) + .child(div().size(px(4.)).bg(t.muted)) + .child( + caps(if prepared { "Ready" } else { "Needs preparing" }) + .text_color(if prepared { t.success } else { t.warn }), + ), + ) + .children(self.playing.map(|n| { + div() + .flex() + .items_center() + .gap(px(10.)) + .child(div().size(px(4.)).rounded_full().bg(t.rule)) + .child( + caps(format!("{} playing now", thousands(n))).text_color(t.muted), + ) + })), + ) + .child( + blocks::pixel(60., FontWeight::BOLD) + .line_height(px(62.)) + .text_color(t.ink) + .child(title), + ) + // The version is in the picker beside Play: here only what it brings. + .children(summary.map(|line| { + text(theme::BODY) + .max_w(px(560.)) + .text_color(t.ink_soft) + .child(line) + })); if let Some(release) = &self.updates.horizon { let installing = self.installing.as_ref().filter(|(t, _)| *t == release.tag); let line = div() @@ -1976,6 +2467,30 @@ impl Launcher { .on_click(|_, _, cx| cx.open_url(backend::README_URL)), ); let (accounts, n) = self.account_rows(cx); + let appearance = segments( + "appearance", + &[ + (backend::Appearance::Dark, "Dark"), + (backend::Appearance::Light, "Light"), + (backend::Appearance::System, "System"), + ], + self.settings.appearance, + &t, + { + let this = cx.entity().downgrade(); + move |choice, window, cx| { + let _ = this.update(cx, |this, cx| { + this.settings.appearance = choice; + this.settings.save(); + this.theme = Theme::for_appearance(choice, window.appearance()); + cx.notify(); + }); + } + }, + ); + let group = |label: &'static str, icon: &'static str| { + section_title(label, icon, &t).mt(px(20.)).mb(px(12.)) + }; div() .flex() .flex_col() @@ -1989,9 +2504,9 @@ impl Launcher { div() .flex() .flex_col() - .border_t_1() - .border_color(t.rule) + .child(group("Account", "user").mt_0()) .child(accounts) + .child(group("Updates", "reload")) .child(index_row( n + 1, "Install new versions automatically", @@ -2017,6 +2532,14 @@ impl Launcher { Some(update_channel.into_any_element()), &t, )) + .child(group("Launcher", "monitor")) + .child(index_row( + n + 3, + "Appearance", + Some(plain("Dark by default. System follows your computer's light or dark.")), + Some(appearance.into_any_element()), + &t, + )) .child(index_row( n + 3, "Show the opening", @@ -2024,6 +2547,7 @@ impl Launcher { Some(opening.into_any_element()), &t, )) + .child(group("Game", "gamepad")) .child(index_row( n + 4, "Game menus", @@ -2034,6 +2558,7 @@ impl Launcher { &t, )) .child(self.game_args_row(n + 5, cx)) + .child(group("About", "info-box")) .child(index_row(n + 6, "Horizon", Some(plain(&horizon)), horizon_action, &t)) .child(index_row( n + 7, @@ -2099,10 +2624,9 @@ impl Launcher { .h(px(30.)) .px(px(12.)) .overflow_hidden() - .rounded(px(6.)) - .border_1() - .border_color(if focused { t.accent } else { t.rule }) - .bg(t.surface) + .border_2() + .border_color(if focused { t.accent } else { t.outline }) + .bg(t.well) .font_family(theme::MONO) .text_size(px(12.)) .cursor_text() @@ -2340,10 +2864,9 @@ impl Launcher { .h(px(30.)) .px(px(12.)) .overflow_hidden() - .rounded(px(6.)) - .border_1() - .border_color(if focused { t.accent } else { t.rule }) - .bg(t.surface) + .border_2() + .border_color(if focused { t.accent } else { t.outline }) + .bg(t.well) .font_family(theme::MONO) .text_size(px(12.)) .cursor_text() @@ -2451,8 +2974,8 @@ impl Launcher { Load::Ready(r) if r.is_empty() => return page.child(centered("No releases yet.", &t)), Load::Ready(r) => r.clone(), }; - let mut list = div().flex().flex_col().border_t_1().border_color(t.rule); - for (i, release) in releases.into_iter().enumerate() { + let mut list = div().flex().flex_col(); + for release in releases.into_iter() { let tag = release.tag.clone(); let installed = self.installed.contains(&tag); let selected = self.selected() == Some(&tag); @@ -2563,22 +3086,20 @@ impl Launcher { .flex() .flex_col() .gap(px(10.)) - .py(px(16.)) - .border_b_1() - .border_color(t.rule) - .child(hover_layer( - &id, - t.plate_soft, - theme::RADIUS_PLATE, - theme::PLATE_BLEED, - )) + .py(px(14.)) + .px(px(16.)) + .mb(px(8.)) + .bg(t.panel) + .border_2() + .border_color(t.outline) + .children(blocks::edges(t.bevel, t.lip, theme::BEVEL)) + .child(hover_layer(&id, t.raised, 0., 0.)) .child( div() .relative() .flex() .items_center() .gap(px(16.)) - .child(index(i + 1, &t)) .child( div() .flex() @@ -2586,11 +3107,7 @@ impl Launcher { .flex_1() .min_w_0() .gap(px(4.)) - .child( - text(theme::TITLE) - .text_color(t.ink) - .child(tag.trim_start_matches('v').to_string()), - ) + .child(version_tag(&tag).text_color(t.ink)) .child( text(theme::UI) .text_color(t.muted) @@ -2681,8 +3198,8 @@ impl Launcher { } Load::Ready(r) => r.clone(), }; - let mut list = div().flex().flex_col().border_t_1().border_color(t.rule); - for (i, release) in releases.iter().enumerate() { + let mut list = div().flex().flex_col(); + for release in releases.iter() { let summary = release .name .split_once(": ") @@ -2692,16 +3209,19 @@ impl Launcher { div() .flex() .flex_col() - .gap(px(20.)) - .py(px(28.)) - .border_b_1() - .border_color(t.rule) + .relative() + .gap(px(16.)) + .p(px(20.)) + .mb(px(10.)) + .bg(t.panel) + .border_2() + .border_color(t.outline) + .children(blocks::edges(t.bevel, t.lip, theme::BEVEL)) .child( div() .flex() .items_center() .gap(px(16.)) - .child(index(i + 1, &t)) .child( div() .flex() @@ -2709,11 +3229,7 @@ impl Launcher { .flex_1() .min_w_0() .gap(px(4.)) - .child( - text(theme::TITLE) - .text_color(t.ink) - .child(release.tag.trim_start_matches('v').to_string()), - ) + .child(version_tag(&release.tag).text_color(t.ink)) .when(!summary.is_empty(), |d| { d.child(text(theme::UI).text_color(t.muted).child(summary)) }), @@ -2722,8 +3238,7 @@ impl Launcher { ) .child( div() - .pl(px(theme::INDEX + 16.)) - .max_w(px(theme::INDEX + 16. + 640.)) + .max_w(px(640.)) .child(if release.body.trim().is_empty() { text(theme::UI) .text_color(t.muted) @@ -2740,19 +3255,22 @@ impl Launcher { } /// A thin bar. A known fraction eases from its previous value; an unknown one shuttles slowly. +/// Progress: a sunk track filling with the accent; work of unknown length shuttles a block. fn progress_bar(fraction: Option<(f32, f32)>, t: &Theme) -> Div { let bar = div() .absolute() .top_0() - .h(px(2.)) - .rounded_full() - .bg(t.accent); + .bottom_0() + .bg(t.accent) + .border_b_2() + .border_color(t.face_lip); let track = div() .relative() .flex_1() - .h(px(2.)) - .rounded_full() - .bg(t.rule) + .h(px(12.)) + .border_2() + .border_color(t.outline) + .bg(t.well) .overflow_hidden(); match fraction { Some((from, to)) => { @@ -2778,7 +3296,11 @@ fn progress_bar(fraction: Option<(f32, f32)>, t: &Theme) -> Div { impl Render for Launcher { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let t = self.theme; + self.scale = window.scale_factor(); self.perf.frame(); + if self.bench.as_ref().is_some_and(perf::Bench::scrolling) { + self.bench_scroll(window); + } self.servers.note_focus(window); self.friend_focused = self.friend_focus.is_focused(window); self.mods.note_focus(window); @@ -2809,6 +3331,10 @@ impl Render for Launcher { if self.opening.is_none() && window.focused(cx).is_none() { window.focus(&self.root_focus); } + let size = window.viewport_size(); + let stone = self.art.stone(t.page_rgb, t.dark, window.scale_factor()); + let topbar = self.topbar(cx); + let sidebar = self.sidebar(cx); let mut root = div() .id("root") .track_focus(&self.root_focus) @@ -2838,6 +3364,7 @@ impl Render for Launcher { .font_family(theme::FONT) .text_color(t.ink) .overflow_hidden() + .child(blocks::stone(stone, size.width.into(), size.height.into())) .child( div() .relative() @@ -2846,29 +3373,35 @@ impl Render for Launcher { .size_full() .opacity(ui) .top(px(theme::RISE * (1. - ui))) - .child(self.topbar(cx)) + .child(topbar) .child( - self.content_scroll - .wrap( - div() - .id("content") - .relative() - .flex_1() - .min_h_0() - .overflow_y_scroll() - .child( - div() - .flex() - .px(px(theme::GUTTER)) - .pt(px(24.)) - .pb(px(48.)) - .child(content), - ), - &t, - ) - .flex_1(), - ) - .child(self.footer(cx)), + div().flex().flex_1().min_h_0().child(sidebar).child( + self.content_scroll + .wrap( + div() + .id("content") + .relative() + .flex_1() + .min_h_0() + .overflow_y_scroll() + .child( + div() + .flex() + .px(px(32.)) + .pt(px(if self.page == Page::Play { + 0. + } else { + 28. + })) + .pb(px(48.)) + .child(content), + ), + &t, + ) + .flex_1() + .min_w_0(), + ), + ), ); if self.page == Page::Servers { if let Some(dialog) = self.create_dialog(cx) { @@ -2884,11 +3417,12 @@ impl Render for Launcher { let gone = 1. - ease_out_cubic(reveal / 0.6); let word = div() .flex() - .text_size(px(theme::WORDMARK.px * 0.6)) - .line_height(px(theme::WORDMARK.px * 0.6)) - .font_weight(FontWeight::MEDIUM) + .font_family(theme::PIXEL) + .text_size(px(theme::WORDMARK.px * 0.5)) + .line_height(px(theme::WORDMARK.px * 0.5)) + .font_weight(FontWeight::BOLD) .text_color(t.ink) - .children("horizon".chars().enumerate().map(|(i, c)| { + .children("HORIZON".chars().enumerate().map(|(i, c)| { let at = held - i as f32 * theme::STAGGER.as_secs_f32() * 1.5; let p = ease_out_cubic(at / theme::ENTER.as_secs_f32()); div() @@ -2958,6 +3492,19 @@ impl Launcher { (reveal, held.as_secs_f32()) } + /// `horizon-launcher benchmark`: scroll the page a little on every frame, back to the top at + /// the bottom, so the frame times measure scrolling. + fn bench_scroll(&mut self, window: &mut Window) { + let handle = &self.content_scroll.handle; + let mut offset = handle.offset(); + offset.y -= px(24.); + if -offset.y > handle.max_offset().height { + offset.y = px(0.); + } + handle.set_offset(offset); + window.request_animation_frame(); + } + fn skip_opening(&mut self) { if let Some(opening) = &mut self.opening { opening.reveal.get_or_insert_with(Instant::now); @@ -2971,38 +3518,43 @@ fn main() { if let Some(code) = cli::run() { std::process::exit(code); } - Application::new().run(|cx: &mut App| { - cx.text_system() - .add_fonts(vec![ - Cow::Borrowed(horizon_theme::fonts::GEIST_REGULAR), - Cow::Borrowed(horizon_theme::fonts::GEIST_MEDIUM), - Cow::Borrowed(horizon_theme::fonts::GEIST_SEMIBOLD), - Cow::Borrowed(horizon_theme::fonts::GEIST_MONO), - ]) - .expect("bundled fonts"); - let bounds = Bounds::centered(None, size(px(1040.), px(700.)), cx); - cx.open_window( - WindowOptions { - window_bounds: Some(WindowBounds::Windowed(bounds)), - window_min_size: Some(size(px(820.), px(560.))), - titlebar: Some(TitlebarOptions { - title: Some("Horizon".into()), + Application::new() + .with_assets(blocks::Assets) + .run(|cx: &mut App| { + cx.text_system() + .add_fonts(vec![ + Cow::Borrowed(horizon_theme::fonts::GEIST_REGULAR), + Cow::Borrowed(horizon_theme::fonts::GEIST_MEDIUM), + Cow::Borrowed(horizon_theme::fonts::GEIST_SEMIBOLD), + Cow::Borrowed(horizon_theme::fonts::GEIST_MONO), + Cow::Borrowed(horizon_theme::fonts::PIXEL_MEDIUM), + Cow::Borrowed(horizon_theme::fonts::PIXEL_SEMIBOLD), + Cow::Borrowed(horizon_theme::fonts::PIXEL_BOLD), + ]) + .expect("bundled fonts"); + let bounds = Bounds::centered(None, size(px(1180.), px(760.)), cx); + cx.open_window( + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + window_min_size: Some(size(px(960.), px(600.))), + titlebar: Some(TitlebarOptions { + title: Some("Horizon".into()), + ..Default::default() + }), + app_id: Some("horizon-launcher".into()), ..Default::default() - }), - app_id: Some("horizon-launcher".into()), - ..Default::default() - }, - |window, cx| cx.new(|cx| Launcher::new(window, cx)), - ) - .expect("window"); - cx.on_window_closed(|cx| { - servers_page::stop_on_quit(); - telemetry::send_before_exit(); - cx.quit() - }) - .detach(); - cx.activate(true); - }); + }, + |window, cx| cx.new(|cx| Launcher::new(window, cx)), + ) + .expect("window"); + cx.on_window_closed(|cx| { + servers_page::stop_on_quit(); + telemetry::send_before_exit(); + cx.quit() + }) + .detach(); + cx.activate(true); + }); } #[cfg(test)] diff --git a/launcher/src/modpacks_page.rs b/launcher/src/modpacks_page.rs index f3e2f1d7b..ccd0b3a4e 100644 --- a/launcher/src/modpacks_page.rs +++ b/launcher/src/modpacks_page.rs @@ -124,9 +124,8 @@ impl Launcher { self.start_client(tag); } - /// The imported packs, above the Modpacks tab's search. - pub(super) fn imported_packs(&mut self, cx: &mut Context) -> Div { - let t = self.theme; + /// Reads the imported packs, once (the sidebar and the Modpacks tab both show them). + pub(super) fn load_packs(&mut self) { if self.packs_loaded.is_none() { self.packs = modpacks::list(); if self.pack_open.is_none() { @@ -134,6 +133,12 @@ impl Launcher { } self.packs_loaded = Some(Instant::now()); } + } + + /// The imported packs, above the Modpacks tab's search. + pub(super) fn imported_packs(&mut self, cx: &mut Context) -> Div { + let t = self.theme; + self.load_packs(); let busy = self.pack_busy; let pick = |id: &'static str, label: &'static str, folder: bool| { let b = button(id, label, Kind::Secondary, &t); @@ -153,12 +158,17 @@ impl Launcher { .gap(px(8.)) .child(pick("import-file", "Import a pack file…", false)) .child(pick("import-folder", "Import an instance folder…", true)) - .child(text(theme::CAPTION).text_color(t.muted).child( + .child(text(theme::CAPTION).w_full().text_color(t.muted).child( ".mrpack (Modrinth), .zip (CurseForge), or a Prism, MultiMC or CurseForge instance. \ Horizon ports what it can on this computer, from your own downloads, and plays \ the pack as its own profile. Nothing is uploaded or shared.", )); - let mut page = div().flex().flex_col().gap(px(32.)).child(actions); + let mut page = div() + .flex() + .flex_col() + .gap(px(16.)) + .child(section_title("Your modpacks", "archive", &t)) + .child(actions); if busy || !self.pack_log.is_empty() { page = page.child(self.pack_log_plate(&t)); } @@ -173,7 +183,7 @@ impl Launcher { ); } if self.packs.len() > 1 { - let mut list = div().flex().flex_col().border_t_1().border_color(t.rule); + let mut list = div().flex().flex_col(); for (i, p) in self.packs.iter().enumerate() { let open = self.pack_open.as_ref() == Some(&p.slug); let slug = p.slug.clone(); @@ -348,7 +358,7 @@ impl Launcher { }); } }); - let mut list = div().flex().flex_col().border_t_1().border_color(t.rule); + let mut list = div().flex().flex_col(); for (i, r) in rows.iter().enumerate() { let color = match r.status { Status::Ported => t.success, diff --git a/launcher/src/mods_page.rs b/launcher/src/mods_page.rs index 214048519..f2dde2447 100644 --- a/launcher/src/mods_page.rs +++ b/launcher/src/mods_page.rs @@ -11,11 +11,12 @@ use horizon_launcher::modpacks; use horizon_port_pack::check::Check; use std::collections::HashMap; -const KINDS: [(PackKind, &str); 4] = [ - (PackKind::Mod, "Mods"), - (PackKind::Modpack, "Modpacks"), - (PackKind::ResourcePack, "Resource packs"), - (PackKind::ShaderPack, "Shader packs"), +/// The kinds, as the Mods page's tabs: name and icon. +const KINDS: [(PackKind, &str, &str); 4] = [ + (PackKind::Mod, "Mods", "box"), + (PackKind::Modpack, "Modpacks", "archive"), + (PackKind::ResourcePack, "Resource packs", "image"), + (PackKind::ShaderPack, "Shader packs", "zap"), ]; /// Java mods checked at once. const CHECKING: usize = 2; @@ -72,10 +73,19 @@ pub(super) struct Mods { } impl Mods { + /// Whether the Modpacks tab is open (the sidebar marks the pack shown there). + pub(super) fn on_modpacks(&self) -> bool { + self.kind == PackKind::Modpack + } + pub(super) fn new(cx: &mut App) -> Self { let (tx, rx) = channel(); Self { - kind: PackKind::Mod, + kind: if std::env::var("HORIZON_LAUNCHER_PAGE").as_deref() == Ok("modpacks") { + PackKind::Modpack + } else { + PackKind::Mod + }, source: SourceId::Modrinth, text: String::new(), focus: cx.focus_handle().tab_index(0).tab_stop(true), @@ -143,6 +153,23 @@ fn short_sum(checksum: &str) -> String { } impl Launcher { + /// Opens the Mods page on its Modpacks tab, with the pack `slug` open when given (the + /// sidebar's packs and its + button). + pub(super) fn show_modpack(&mut self, slug: Option) { + self.go(Page::Mods); + if slug.is_some() { + self.pack_open = slug; + } + if self.mods.kind != PackKind::Modpack { + self.mods.kind = PackKind::Modpack; + self.mods.browse.filters = Default::default(); + self.mods.browse.open = None; + if self.mods.loaded { + self.mods_search(0); + } + } + } + /// Called with the launcher's other work: what the background sent. pub(super) fn mods_tick(&mut self) -> bool { let mut changed = false; @@ -374,16 +401,17 @@ impl Launcher { .track_focus(&m.focus) .flex() .items_center() + .gap(px(10.)) .w(px(320.)) - .h(px(theme::FIELD)) + .h(px(theme::FIELD + 4.)) .px(px(12.)) .overflow_hidden() - .rounded(px(theme::RADIUS)) - .border_1() - .border_color(if focused { t.accent } else { t.rule }) - .bg(t.surface) + .border_2() + .border_color(if focused { t.accent } else { t.outline }) + .bg(t.well) .text_size(px(theme::UI.px)) .cursor_text() + .child(blocks::icon("search", 18., t.muted)) .child(if m.text.is_empty() && !focused { div().text_color(t.muted).child("Search, then Enter") } else { @@ -442,7 +470,7 @@ impl Launcher { &t, ); let this = cx.entity().downgrade(); - let kinds = segments("mods-kind", &KINDS, self.mods.kind, &t, { + let kinds = blocks::tabs("mods-kind", &KINDS, self.mods.kind, &t, { let this = this.clone(); move |kind, _, cx| { let _ = this.update(cx, |this, cx| { @@ -477,29 +505,7 @@ impl Launcher { })); // CurseForge support is coming soon: shown, inert, never searched (like the Microsoft // account's "Coming soon"). - let source_note = div() - .flex() - .flex_wrap() - .items_center() - .gap(px(8.)) - .child(segments("mods-source", &SOURCES, self.mods.source, &t, { - let this = this.clone(); - move |source, _, cx| { - let _ = this.update(cx, |this, cx| { - if this.mods.source != source { - this.mods.source = source; - this.mods_search(0); - } - cx.notify(); - }); - } - })) - .child(disabled(button( - "mods-source-curseforge", - "CurseForge (coming soon)", - Kind::Ghost, - &t, - ))); + let source_note = div().flex().child(source_switch(&t)); let controls = div() .flex() .flex_col() @@ -510,35 +516,36 @@ impl Launcher { .flex_wrap() .items_center() .gap(px(12.)) - .child(kinds) .child(self.mods_field(cx)) .child(toggle), ) .child(source_note) .child(self.mods_filters(cx)); - if let Some(project) = self.mods_project_page(cx) { - return div() + // The kinds are inventory tabs on the panel that holds the open one. + let body = blocks::panel(&t).flex().flex_col().gap(px(24.)).p(px(20.)); + let page = |body: Div| { + div() .flex() .flex_col() .gap(px(32.)) .child(header) - .children(self.mods_busy_plate()) - .child(project); + .child(div().flex().flex_col().child(kinds).child(body)) + }; + if let Some(project) = self.mods_project_page(cx) { + return page(body.children(self.mods_busy_plate()).child(project)); } - let mut page = div() - .flex() - .flex_col() - .gap(px(32.)) - .child(header) - .child(controls); - page = page.children(self.mods_busy_plate()); + let mut body = body.children(self.mods_busy_plate()); if self.mods.kind == PackKind::Modpack { - page = page.child(self.imported_packs(cx)); + body = body + .child(self.imported_packs(cx)) + .child(div().h(px(2.)).bg(t.rule)) + .child(section_title("Browse modpacks", "search", &t)); } + body = body.child(controls); if let Some(list) = self.mods_installed_list(cx) { - page = page.child(list); + body = body.child(list); } - page.child(self.mods_results(cx)) + page(body.child(self.mods_results(cx))) } /// The install under way and the last install's answer. @@ -589,7 +596,7 @@ impl Launcher { return None; } let busy = self.mods.busy.is_some(); - let mut list = div().flex().flex_col().border_t_1().border_color(t.rule); + let mut list = div().flex().flex_col(); for (i, e) in entries.into_iter().enumerate() { let key = (e.source, e.project.clone()); let mut right = div().flex().items_center().gap(px(8.)); @@ -691,7 +698,7 @@ impl Launcher { fn mods_results(&mut self, cx: &mut Context) -> Div { let t = self.theme; let found = self.mods.results.clone(); - let icons: Vec = found.iter().map(|p| self.mods_icon(p, 40.)).collect(); + let icons: Vec = found.iter().map(|p| self.mods_icon(p, 56.)).collect(); let m = &self.mods; if let Some(e) = &m.error { return div().child(notice(e.clone(), &t)); @@ -708,7 +715,7 @@ impl Launcher { } let busy = m.busy.as_ref().map(|b| b.0.as_str()); let mut icons = icons.into_iter(); - let mut list = div().flex().flex_col().border_t_1().border_color(t.rule); + let mut list = div().flex().flex_col(); for (i, p) in m.results.iter().enumerate() { let support = m.support(p); let checking = p.kind == PackKind::Mod @@ -719,14 +726,14 @@ impl Launcher { && p.support().installable(); let color = match support { Support::Works => t.success, - Support::Partial(_) => t.accent, + Support::Partial(_) => t.warn, Support::No(_) => t.muted, }; - let mut right = div() - .flex() - .items_center() - .gap(px(8.)) - .child(caps(support.label()).flex_none().text_color(color)); + let mut right = div().flex().items_center().gap(px(8.)).child(blocks::badge( + support.label(), + color, + &t, + )); let page_url = p.page.clone(); if !page_url.is_empty() { right = right.child( @@ -826,6 +833,31 @@ impl Launcher { } } +/// The source switch while Modrinth is the only source: Modrinth chosen, CurseForge shown as +/// coming soon and not clickable, like the Microsoft account's "Coming soon". +fn source_switch(t: &Theme) -> Stateful
{ + let soon = div() + .flex() + .items_center() + .h(px(theme::SEGMENT)) + .px(px(14.)) + .pb(px(2.)) + .opacity(theme::DISABLED) + .child( + blocks::pixel(14., FontWeight::MEDIUM) + .text_color(t.muted) + .child("CurseForge (coming soon)"), + ); + segments( + "mods-source", + &SOURCES, + SourceId::Modrinth, + t, + |_, _, _| {}, + ) + .child(soon) +} + #[cfg(test)] mod tests { use super::*; diff --git a/launcher/src/mods_project.rs b/launcher/src/mods_project.rs index ef1d97975..c66cf174a 100644 --- a/launcher/src/mods_project.rs +++ b/launcher/src/mods_project.rs @@ -167,28 +167,7 @@ impl Launcher { .icon .clone() .and_then(|url| self.mods_image(&url, images::ICON)); - let frame = plate(&t) - .flex() - .flex_none() - .items_center() - .justify_center() - .size(px(side)) - .overflow_hidden(); - match path { - Some(path) => frame.child(img(path).size(px(side))).into_any_element(), - None => frame - .text_color(t.muted) - .child( - text(theme::UI).child( - p.title - .chars() - .next() - .map(|c| c.to_uppercase().to_string()) - .unwrap_or_default(), - ), - ) - .into_any_element(), - } + blocks::slot(path.map(gpui::ImageSource::from), &p.title, side, &t).into_any_element() } /// The values the filters can take for the current kind and source, asked for once. @@ -357,7 +336,7 @@ impl Launcher { } /// Vanilla's mark, drawn from the player's prepared game files when there are some. - fn vanilla_icon(&self) -> Option { + pub(super) fn vanilla_icon(&self) -> Option { let tag = self.selected().filter(|tag| backend::is_prepared(tag))?; images::grass_block(&backend::game_home(), &backend::resources(tag)) } @@ -371,7 +350,7 @@ impl Launcher { let support = self.mods.support(&p); let color = match support { Support::Works => t.success, - Support::Partial(_) => t.accent, + Support::Partial(_) => t.warn, Support::No(_) => t.muted, }; let back = button("mods-back", "Back to results", Kind::Ghost, &t).on_click(cx.listener( @@ -573,7 +552,7 @@ impl Launcher { None => centered("Loading…", &t), Some(Err(e)) => notice(e.clone(), &t), Some(Ok(list)) => { - let mut rows = div().flex().flex_col().border_t_1().border_color(t.rule); + let mut rows = div().flex().flex_col(); for (i, v) in list.iter().take(50).enumerate() { let file = v.file(); let mut detail = vec![ diff --git a/launcher/src/perf.rs b/launcher/src/perf.rs index 7d652283f..f46393542 100644 --- a/launcher/src/perf.rs +++ b/launcher/src/perf.rs @@ -154,21 +154,27 @@ pub enum Step { Wait, /// Show this page (an index into `Page::ALL`, wrapping). Go(usize), + /// Show the longest list (Versions) and scroll it on every frame while [`Bench::scrolling`]. + Scroll, Quit, } const SWITCHING: Duration = Duration::from_secs(4); +const SCROLLING: Duration = Duration::from_secs(3); const IDLE: Duration = Duration::from_secs(3); /// The environment variable that makes a launcher window measure itself into the file it names. const BENCH_ENV: &str = "HORIZON_LAUNCHER_BENCH"; /// A launcher window measuring itself: after its first frame it switches pages every tick for -/// a few seconds (each switch animates, so the UI draws continuously), then rests, then writes -/// what it measured for `benchmark` and quits. +/// a few seconds (each switch animates, so the UI draws continuously), scrolls the longest list +/// on every frame for a few more, then rests, then writes what it measured for `benchmark` and +/// quits. pub struct Bench { out: PathBuf, began: Option, page: usize, + /// When scrolling began, and the UI frames drawn by then and by its end. + scroll: Option<(Instant, usize, Option)>, idle_cpu: Option<(Instant, f64)>, } @@ -179,10 +185,16 @@ impl Bench { out: out.into(), began: None, page: 0, + scroll: None, idle_cpu: None, }) } + /// The window scrolls its content on every frame now. + pub fn scrolling(&self) -> bool { + matches!(self.scroll, Some((_, _, None))) + } + pub fn tick(&mut self, perf: &Perf) -> Step { let Some(first) = perf.first_frame else { return Step::Wait; @@ -192,6 +204,20 @@ impl Bench { self.page += 1; return Step::Go(self.page); } + let frames = perf.ui_frame_times().len(); + match &mut self.scroll { + None => { + self.scroll = Some((Instant::now(), frames, None)); + return Step::Scroll; + } + Some((at, _, end @ None)) => { + if at.elapsed() < SCROLLING { + return Step::Wait; + } + *end = Some(frames); + } + Some(_) => {} + } let pid = std::process::id(); let Some((rested, cpu)) = self.idle_cpu else { self.idle_cpu = Some(( @@ -212,9 +238,15 @@ impl Bench { .unwrap_or_default(); (now.saturating_sub(ago)).as_secs_f64() * 1000.0 }; + let ui = perf.ui_frame_times(); + let scroll = match self.scroll { + Some((_, from, Some(to))) => &ui[from.min(to)..to.min(ui.len())], + _ => &[], + }; let result = serde_json::json!({ "first_frame_epoch_ms": epoch_ms(first), - "ui_ms": perf.ui_frame_times(), + "ui_ms": ui, + "scroll_ms": scroll, "rss_mb": horizon_bench::rss_bytes(pid).map(|b| b as f64 / f64::from(1 << 20)), "cpu_idle_pct": cpu_idle_pct, }); @@ -270,20 +302,36 @@ pub fn benchmark(repeats: usize) -> Result { } rss.extend(result["rss_mb"].as_f64()); cpu.extend(result["cpu_idle_pct"].as_f64()); - let ui: Vec = result["ui_ms"] - .as_array() - .into_iter() - .flatten() - .filter_map(|v| v.as_f64()) - .map(|v| v as f32) - .collect(); + let times = |key: &str| -> Vec { + result[key] + .as_array() + .into_iter() + .flatten() + .filter_map(|v| v.as_f64()) + .map(|v| v as f32) + .collect() + }; + let (ui, scroll) = (times("ui_ms"), times("scroll_ms")); report .frames .extend(Frames::of("pages", repeat as u32, &ui, &[], &[])); + report + .frames + .extend(Frames::of("scroll", repeat as u32, &scroll, &[], &[])); + let mut sorted = scroll.clone(); + sorted.sort_by(f32::total_cmp); + let at = |q: f32| { + sorted + .get(((sorted.len() as f32 - 1.) * q) as usize) + .copied() + }; eprintln!( - "start {}: {:.0} ms to the first frame", + "start {}: {:.0} ms to the first frame; scrolling {} frames, median {:.1} ms, p95 {:.1} ms", repeat + 1, - starts.last().unwrap_or(&0.0) + starts.last().unwrap_or(&0.0), + sorted.len(), + at(0.5).unwrap_or(0.), + at(0.95).unwrap_or(0.) ); } report.metric("launcher.cold_start_ms", &starts[..starts.len().min(1)]); @@ -364,6 +412,7 @@ mod tests { began: None, page: 0, idle_cpu: None, + scroll: None, }; assert!(matches!(bench.tick(&perf), Step::Wait)); perf.frame(); diff --git a/launcher/src/scrollbar.rs b/launcher/src/scrollbar.rs index 87cdbee77..757d3525e 100644 --- a/launcher/src/scrollbar.rs +++ b/launcher/src/scrollbar.rs @@ -49,7 +49,7 @@ impl Scrollbar { /// Wrap the scroll container, reserving a narrow gutter so the bar never covers controls. pub fn wrap(&self, content: Stateful
, theme: &Theme) -> Div { let state = self.clone(); - let track_color = theme.rule; + let track_color = theme.well; let thumb_color = theme.muted; div() .relative() @@ -83,17 +83,14 @@ impl Scrollbar { return; }; let hitbox = hitbox.unwrap(); - window.paint_quad(fill(bounds, track_color).corner_radii(px(3.))); - window.paint_quad( - fill( - Bounds::new( - point(bounds.left() + px(3.), bounds.top() + px(g.top)), - size(bounds.size.width - px(6.), px(g.thumb)), - ), - thumb_color, - ) - .corner_radii(px(3.)), - ); + window.paint_quad(fill(bounds, track_color)); + window.paint_quad(fill( + Bounds::new( + point(bounds.left() + px(3.), bounds.top() + px(g.top)), + size(bounds.size.width - px(6.), px(g.thumb)), + ), + thumb_color, + )); window.on_mouse_event({ let state = state.clone(); move |ev: &MouseDownEvent, phase, window, cx| { diff --git a/launcher/src/servers_page.rs b/launcher/src/servers_page.rs index b1338f1aa..67523464b 100644 --- a/launcher/src/servers_page.rs +++ b/launcher/src/servers_page.rs @@ -114,6 +114,18 @@ pub(super) struct Create { eula_missing: bool, } +/// What the sidebar shows of the Servers page: its list once the page has read it, and whether +/// a server answers. +impl Servers { + pub(super) fn sidebar_list(&self) -> Option<&[Entry]> { + self.loaded.then_some(&self.list[..]) + } + + pub(super) fn is_up(&self, dir: &std::path::Path) -> bool { + self.statuses.get(dir).is_some_and(|s| s.phase == "running") + } +} + pub(super) struct Servers { list: Vec, loaded: bool, diff --git a/launcher/src/theme.rs b/launcher/src/theme.rs index 579259661..6425232c5 100644 --- a/launcher/src/theme.rs +++ b/launcher/src/theme.rs @@ -1,19 +1,29 @@ //! The launcher's view of Horizon's design tokens (`crates/horizon-theme`, described in -//! `docs/design-system.md`), which the game's menus and the website share: cool neutrals in two -//! appearances and one violet accent, Geist and Geist Mono (bundled, SIL Open Font License). +//! `docs/design-system.md`): the blocky look it shares with the website ("Blocks"), stone-dark or +//! lavender-light with Horizon's violet, Pixelify Sans for headings and controls, Geist and Geist +//! Mono for text (all bundled, SIL Open Font License). use gpui::{rgb, Hsla, WindowAppearance}; +use horizon_launcher::backend::Appearance; use horizon_theme as tokens; use std::time::Duration; pub use tokens::{Step, BODY, CAPTION, DISPLAY, HEADING, LABEL, MICRO, TITLE, UI, WORDMARK}; pub const FONT: &str = tokens::FONT; pub const MONO: &str = tokens::MONO; +pub const PIXEL: &str = tokens::PIXEL; +pub use tokens::{BLOCK_BEVEL as BEVEL, BLOCK_LIP as LIP}; /// Shapes and controls, shared with the game's menus and the website. -pub use tokens::{ - CONTROL, CONTROL_LARGE, DISABLED, FIELD, GUTTER, INDEX, PLATE_BLEED, PRESSED, RADIUS, - RADIUS_PILL, RADIUS_PLATE, RISE, ROW, SEGMENT, -}; +pub use tokens::{CONTROL_LARGE, DISABLED, FIELD, PRESSED, RISE, ROW, SEGMENT}; + +/// Blocks have square corners: these stay for the places that ask for a radius. +pub const RADIUS: f32 = 0.; +pub const RADIUS_PLATE: f32 = 0.; +/// Buttons are a little taller than the quiet look's, for their lip. +pub const CONTROL: f32 = 34.; +/// The top bar, and the sidebar of instances and servers. +pub const TOPBAR: f32 = 56.; +pub const SIDEBAR: f32 = 248.; /// Motion: short and eased out, never bouncy. pub const QUICK: Duration = tokens::QUICK.duration(); @@ -23,9 +33,15 @@ pub const STAGGER: Duration = tokens::STAGGER; #[derive(Clone, Copy)] pub struct Theme { + /// The page behind everything (under its stone texture), and its colour as `0xRRGGBB` for + /// the texture. pub page: Hsla, + pub page_rgb: u32, + /// Hover plates, fields and the console: the raised panel colour. pub plate_soft: Hsla, + /// Pressed plates, a switch that is off: the button colour. pub plate: Hsla, + /// Dialogs and menus above the page: the panel colour. pub surface: Hsla, pub rule: Hsla, pub ink: Hsla, @@ -35,6 +51,21 @@ pub struct Theme { pub on_accent: Hsla, pub danger: Hsla, pub success: Hsla, + pub warn: Hsla, + /// The blocky parts (`horizon_theme::Blocks`). + pub side: Hsla, + pub panel: Hsla, + pub raised: Hsla, + pub button: Hsla, + pub button_bevel: Hsla, + pub well: Hsla, + pub bevel: Hsla, + pub lip: Hsla, + pub outline: Hsla, + pub face: Hsla, + pub face_bevel: Hsla, + pub face_lip: Hsla, + pub on_face: Hsla, pub dark: bool, } @@ -43,42 +74,61 @@ fn c(hex: u32) -> Hsla { } impl Theme { - /// The system's appearance; `HORIZON_LAUNCHER_APPEARANCE=light` or `dark` overrides it (for + /// The player's choice in Settings (dark unless they picked another), the system's when they + /// asked to follow it. `HORIZON_LAUNCHER_APPEARANCE=light` or `dark` overrides both (for /// screenshots and tests). - pub fn for_appearance(appearance: WindowAppearance) -> Self { + pub fn for_appearance(choice: Appearance, system: WindowAppearance) -> Self { match std::env::var("HORIZON_LAUNCHER_APPEARANCE").as_deref() { Ok("light") => return Self::light(), Ok("dark") => return Self::dark(), _ => {} } - match appearance { - WindowAppearance::Light | WindowAppearance::VibrantLight => Self::light(), + match (choice, system) { + (Appearance::Light, _) => Self::light(), + (Appearance::System, WindowAppearance::Light | WindowAppearance::VibrantLight) => { + Self::light() + } _ => Self::dark(), } } pub fn dark() -> Self { - Self::from(tokens::DARK) + Self::from(tokens::BLOCKS_DARK) } pub fn light() -> Self { - Self::from(tokens::LIGHT) + Self::from(tokens::BLOCKS_LIGHT) } - fn from(p: tokens::Palette) -> Self { + fn from(p: tokens::Blocks) -> Self { Self { page: c(p.page), - plate_soft: c(p.plate_soft), - plate: c(p.plate), - surface: c(p.surface), + page_rgb: p.page, + plate_soft: c(p.raised), + plate: c(p.button), + surface: c(p.panel), rule: c(p.rule), ink: c(p.ink), ink_soft: c(p.ink_soft), muted: c(p.muted), accent: c(p.accent), - on_accent: c(p.on_accent), + on_accent: c(p.on_face), danger: c(p.danger), success: c(p.success), + warn: c(p.warn), + side: c(p.side), + panel: c(p.panel), + raised: c(p.raised), + button: c(p.button), + button_bevel: c(p.button_bevel), + well: c(p.well), + bevel: c(p.bevel), + lip: c(p.lip), + outline: c(p.outline), + face: c(p.face), + face_bevel: c(p.face_bevel), + face_lip: c(p.face_lip), + on_face: c(p.on_face), dark: p.dark, } } @@ -105,13 +155,14 @@ mod tests { /// field (WCAG 1.4.11 asks 3:1 for those), and text over a selected suggestion. #[test] fn marks_and_selections_read_clearly() { - for p in [tokens::LIGHT, tokens::DARK] { + for p in [tokens::BLOCKS_LIGHT, tokens::BLOCKS_DARK] { let theme = if p.dark { "dark" } else { "light" }; for (name, bg) in [ ("page", p.page), - ("plate_soft", p.plate_soft), - ("plate", p.plate), - ("surface", p.surface), + ("raised", p.raised), + ("button", p.button), + ("panel", p.panel), + ("well", p.well), ] { let ring = tokens::contrast(p.accent, bg); assert!(ring >= 3.0, "{theme} focus ring on {name}: {ring:.2}"); @@ -121,7 +172,7 @@ mod tests { "{theme} refused field on {name}: {refused:.2}" ); } - let selection = blend(p.accent, p.surface, SELECTION); + let selection = blend(p.accent, p.panel, SELECTION); let ratio = tokens::contrast(p.ink, selection); assert!(ratio >= 4.5, "{theme} text on a selection: {ratio:.2}"); } From 2f8d913b851865ad31fb4981fd44f30e1304cede Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:14:29 +0200 Subject: [PATCH 6/8] Launcher: long lists draw only the rows near the screen Versions and Patch notes list a hundred releases, and every frame laid out and painted all of them. Rows more than a screen away from the viewport are now empty blocks as tall as when they were last drawn (cull.rs), so the page keeps its length; a row being installed or showing an error is always drawn. Scrolling Versions on the Mac mini: median frame 8.2 -> 7.0 ms. HORIZON_LAUNCHER_PROJECT= opens a project's page on the Mods page, for screenshots, as HORIZON_LAUNCHER_PAGE opens a page. Co-Authored-By: Claude Opus 5.5 --- launcher/src/cull.rs | 119 +++++++++++++++++++++ launcher/src/main.rs | 219 +++++++++++++++++++++----------------- launcher/src/mods_page.rs | 25 +++-- 3 files changed, 257 insertions(+), 106 deletions(-) create mode 100644 launcher/src/cull.rs diff --git a/launcher/src/cull.rs b/launcher/src/cull.rs new file mode 100644 index 000000000..41df05610 --- /dev/null +++ b/launcher/src/cull.rs @@ -0,0 +1,119 @@ +//! Long lists in the page's scroll area (the hundred releases on Versions and Patch notes) draw +//! only the rows near what is on screen. A row far off screen becomes an empty block as tall as +//! it was the last time it was drawn, so the page keeps its length and the scrollbar its place. +use gpui::{div, px, Bounds, Div, IntoElement, ParentElement, Pixels, ScrollHandle, Styled}; +use std::{cell::RefCell, rc::Rc}; + +#[derive(Clone, Default)] +pub struct Cull(Rc>); + +/// Where each row was in the last frame, and the scroll offset and the viewport's width then +/// (a narrower window wraps text, and the heights no longer hold). +#[derive(Default)] +struct Drawn { + rows: Vec>, + offset: Pixels, + width: Pixels, +} + +impl Cull { + /// A column for `rows` rows that remembers where they were drawn. Rows come from `row(i)`, or + /// an empty block when row `i` was more than a screen away from the viewport last frame and + /// `keep(i)` is false (a row being installed or showing an error is always drawn). + pub fn column( + &self, + handle: &ScrollHandle, + rows: usize, + gap: f32, + keep: impl Fn(usize) -> bool, + mut row: impl FnMut(usize) -> E, + ) -> Div { + let far = self.far(handle, rows); + let skipped: Vec> = (0..rows).map(|i| far(i).filter(|_| !keep(i))).collect(); + let gaps = skipped.iter().any(Option::is_some); + let state = self.0.clone(); + let scroll = handle.clone(); + div() + .flex() + .flex_col() + .children(skipped.iter().enumerate().map(|(i, skip)| match skip { + Some(height) => div().flex_none().h(*height).mb(px(gap)).into_any_element(), + None => row(i).into_any_element(), + })) + .on_children_prepainted(move |bounds, window, _| { + let view = scroll.bounds(); + // A jump of more than a screen (the scrollbar dragged) can bring an empty block + // into view: draw again, with the rows where they are now. + if gaps + && bounds + .iter() + .zip(&skipped) + .any(|(b, skip)| skip.is_some() && !away(*b, px(0.), view)) + { + window.refresh(); + } + let mut drawn = state.borrow_mut(); + drawn.rows = bounds; + drawn.offset = scroll.offset().y; + drawn.width = view.size.width; + }) + } + + /// For row `i`: its height when it was more than a screen away from the viewport. + fn far(&self, handle: &ScrollHandle, rows: usize) -> impl Fn(usize) -> Option { + let drawn = self.0.borrow(); + let view = handle.bounds(); + // The rows moved by however far the page scrolled since they were drawn. + let moved = handle.offset().y - drawn.offset; + let known = + drawn.rows.len() == rows && view.size.height > px(0.) && drawn.width == view.size.width; + let rows = if known { + drawn.rows.clone() + } else { + Vec::new() + }; + move |i| { + let b = *rows.get(i)?; + away(b, moved, view).then_some(b.size.height) + } + } +} + +/// Whether a row drawn at `row`, since moved down by `moved`, is more than a screen above or +/// below `view`. +fn away(row: Bounds, moved: Pixels, view: Bounds) -> bool { + row.bottom() + moved < view.top() - view.size.height + || row.top() + moved > view.bottom() + view.size.height +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{point, size}; + + fn at(y: f32, h: f32) -> Bounds { + Bounds::new(point(px(0.), px(y)), size(px(10.), px(h))) + } + + #[test] + fn only_rows_more_than_a_screen_away_are_skipped() { + let view = at(100., 500.); + assert!(!away(at(100., 100.), px(0.), view)); + assert!(!away(at(1000., 100.), px(0.), view)); + assert!(away(at(1200., 100.), px(0.), view)); + assert!(away(at(-600., 100.), px(0.), view)); + // Scrolled 700 px down since: the row that was far below is now on screen. + assert!(!away(at(1200., 100.), px(-700.), view)); + } + + #[test] + fn nothing_is_skipped_before_the_rows_are_known() { + let cull = Cull::default(); + cull.0.borrow_mut().rows = (0..10).map(|i| at(i as f32 * 2000., 100.)).collect(); + // No viewport yet (the first frame), or another number of rows: every row is drawn. + let far = cull.far(&ScrollHandle::new(), 10); + assert!((0..10).all(|i| far(i).is_none())); + let far = cull.far(&ScrollHandle::new(), 11); + assert!((0..11).all(|i| far(i).is_none())); + } +} diff --git a/launcher/src/main.rs b/launcher/src/main.rs index 365c952f4..cfee78d63 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -5,6 +5,7 @@ mod art; mod blocks; mod cli; mod collection; +mod cull; mod game_tools; mod internet; mod markdown; @@ -123,6 +124,8 @@ struct Launcher { /// How many times the page changed, for the page transition. switches: usize, content_scroll: Scrollbar, + /// The rows of the page's long list (Versions, Patch notes) last drawn. + long_list: cull::Cull, opening: Option, updates: Updates, checked: Option, @@ -256,6 +259,7 @@ impl Launcher { page: Page::from_env(), switches: 0, content_scroll: Scrollbar::default(), + long_list: cull::Cull::default(), opening: (!settings.skip_opening).then(|| { let focus = cx.focus_handle(); window.focus(&focus); @@ -357,6 +361,7 @@ impl Launcher { self.page = page; self.switches += 1; self.content_scroll = Scrollbar::default(); + self.long_list = cull::Cull::default(); } self.picker_open = false; } @@ -2974,23 +2979,36 @@ impl Launcher { Load::Ready(r) if r.is_empty() => return page.child(centered("No releases yet.", &t)), Load::Ready(r) => r.clone(), }; - let mut list = div().flex().flex_col(); - for release in releases.into_iter() { - let tag = release.tag.clone(); - let installed = self.installed.contains(&tag); - let selected = self.selected() == Some(&tag); - let channel = backend::UpdateChannel::of(&release).label(); - let mut meta = vec![channel.to_string(), release.date.clone()]; - if let Some((_, size)) = release.asset { - meta.push(megabytes(size)); - } - let summary = release - .name - .split_once(": ") - .map(|(_, rest)| rest.to_string()) - .unwrap_or_else(|| release.name.clone()); - let right: gpui::AnyElement = - if let Some((_, p)) = self.installing.as_ref().filter(|(t, _)| *t == tag) { + // A hundred releases: only the rows near the screen are drawn. + let keep = |i: usize| { + let tag = &releases[i].tag; + self.installing.as_ref().is_some_and(|(t, _)| t == tag) + || self.install_error.as_ref().is_some_and(|(t, _)| t == tag) + || self.confirm_remove.as_ref() == Some(tag) + }; + let list = self.long_list.clone().column( + &self.content_scroll.handle, + releases.len(), + 8., + keep, + |i| { + let release = releases[i].clone(); + let tag = release.tag.clone(); + let installed = self.installed.contains(&tag); + let selected = self.selected() == Some(&tag); + let channel = backend::UpdateChannel::of(&release).label(); + let mut meta = vec![channel.to_string(), release.date.clone()]; + if let Some((_, size)) = release.asset { + meta.push(megabytes(size)); + } + let summary = release + .name + .split_once(": ") + .map(|(_, rest)| rest.to_string()) + .unwrap_or_else(|| release.name.clone()); + let right: gpui::AnyElement = if let Some((_, p)) = + self.installing.as_ref().filter(|(t, _)| *t == tag) + { div() .flex() .items_center() @@ -3067,76 +3085,77 @@ impl Launcher { } b.into_any_element() }; - let status = if selected { - Some(caps("In use").text_color(t.accent)) - } else if installed { - Some(caps("Installed").text_color(t.ink_soft)) - } else { - None - }; - let error = self - .install_error - .as_ref() - .filter(|(t, _)| *t == tag) - .map(|(_, e)| e.clone()); - let id = SharedString::from(format!("release-{tag}")); - let row = div() - .id(id.clone()) - .relative() - .flex() - .flex_col() - .gap(px(10.)) - .py(px(14.)) - .px(px(16.)) - .mb(px(8.)) - .bg(t.panel) - .border_2() - .border_color(t.outline) - .children(blocks::edges(t.bevel, t.lip, theme::BEVEL)) - .child(hover_layer(&id, t.raised, 0., 0.)) - .child( - div() - .relative() - .flex() - .items_center() - .gap(px(16.)) - .child( - div() - .flex() - .flex_col() - .flex_1() - .min_w_0() - .gap(px(4.)) - .child(version_tag(&tag).text_color(t.ink)) - .child( - text(theme::UI) - .text_color(t.muted) - .truncate() - .child(summary), - ), - ) - .child( - div() - .flex() - .flex_col() - .items_end() - .gap(px(2.)) - .flex_none() - .children(status) - .child(caps(meta.join(" · ")).text_color(t.muted)), - ) - .child( - div() - .flex() - .justify_end() - .w(px(190.)) - .flex_none() - .child(right), - ), - ) - .children(error.map(|e| notice(e, &t))); - list = list.child(track_hover(row, id)); - } + let status = if selected { + Some(caps("In use").text_color(t.accent)) + } else if installed { + Some(caps("Installed").text_color(t.ink_soft)) + } else { + None + }; + let error = self + .install_error + .as_ref() + .filter(|(t, _)| *t == tag) + .map(|(_, e)| e.clone()); + let id = SharedString::from(format!("release-{tag}")); + let row = div() + .id(id.clone()) + .relative() + .flex() + .flex_col() + .gap(px(10.)) + .py(px(14.)) + .px(px(16.)) + .mb(px(8.)) + .bg(t.panel) + .border_2() + .border_color(t.outline) + .children(blocks::edges(t.bevel, t.lip, theme::BEVEL)) + .child(hover_layer(&id, t.raised, 0., 0.)) + .child( + div() + .relative() + .flex() + .items_center() + .gap(px(16.)) + .child( + div() + .flex() + .flex_col() + .flex_1() + .min_w_0() + .gap(px(4.)) + .child(version_tag(&tag).text_color(t.ink)) + .child( + text(theme::UI) + .text_color(t.muted) + .truncate() + .child(summary), + ), + ) + .child( + div() + .flex() + .flex_col() + .items_end() + .gap(px(2.)) + .flex_none() + .children(status) + .child(caps(meta.join(" · ")).text_color(t.muted)), + ) + .child( + div() + .flex() + .justify_end() + .w(px(190.)) + .flex_none() + .child(right), + ), + ) + .children(error.map(|e| notice(e, &t))); + track_hover(row, id) + }, + ); page.child(list) } @@ -3198,14 +3217,18 @@ impl Launcher { } Load::Ready(r) => r.clone(), }; - let mut list = div().flex().flex_col(); - for release in releases.iter() { - let summary = release - .name - .split_once(": ") - .map(|(_, rest)| rest.to_string()) - .unwrap_or_default(); - list = list.child( + let list = self.long_list.clone().column( + &self.content_scroll.handle, + releases.len(), + 10., + |_| false, + |i| { + let release = &releases[i]; + let summary = release + .name + .split_once(": ") + .map(|(_, rest)| rest.to_string()) + .unwrap_or_default(); div() .flex() .flex_col() @@ -3247,9 +3270,9 @@ impl Launcher { } else { markdown::render(&release.body, &t) }), - ), - ); - } + ) + }, + ); page.child(list) } } diff --git a/launcher/src/mods_page.rs b/launcher/src/mods_page.rs index f2dde2447..9cb834797 100644 --- a/launcher/src/mods_page.rs +++ b/launcher/src/mods_page.rs @@ -220,6 +220,22 @@ impl Launcher { Event::Loaded(l) => loaded.push(l), } } + // `HORIZON_LAUNCHER_PROJECT=` (with `HORIZON_LAUNCHER_PAGE=mods`): opens that + // project's page once a search finds it, for screenshots. + if self.mods.browse.open.is_none() { + if let Ok(want) = std::env::var("HORIZON_LAUNCHER_PROJECT") { + if let Some(p) = self + .mods + .results + .iter() + .find(|p| p.id == want || p.slug == want) + .cloned() + { + std::env::remove_var("HORIZON_LAUNCHER_PROJECT"); + self.mods_open(p); + } + } + } self.mods_check(to_check); for l in loaded { self.mods_loaded(l); @@ -848,14 +864,7 @@ fn source_switch(t: &Theme) -> Stateful
{ .text_color(t.muted) .child("CurseForge (coming soon)"), ); - segments( - "mods-source", - &SOURCES, - SourceId::Modrinth, - t, - |_, _, _| {}, - ) - .child(soon) + segments("mods-source", &SOURCES, SourceId::Modrinth, t, |_, _, _| {}).child(soon) } #[cfg(test)] From f1f8170c586bc4eefb14f0b4f7968b69a6829bdb Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:33:35 +0200 Subject: [PATCH 7/8] Launcher: the offline name without its key, and Home before any version The game keeps the offline name as "name:Player12345" in horizon-player.txt; the top bar and the friends-only server list showed the whole line. Both now read the name after the key. With no version installed, Home has no landscape: its title starts below the top bar like the other pages, and the sidebar says "No version installed" instead of "Needs preparing". Co-Authored-By: Claude Opus 5.5 --- launcher/src/main.rs | 31 +++++++++++++++++++++++++++---- launcher/src/servers_page.rs | 10 ++++------ 2 files changed, 31 insertions(+), 10 deletions(-) diff --git a/launcher/src/main.rs b/launcher/src/main.rs index cfee78d63..78e6f6d50 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -1260,10 +1260,17 @@ fn centered(text_: &str, t: &Theme) -> Div { /// The name the game made up for a player without an account, once it has run. fn offline_name() -> Option { - std::fs::read_to_string(backend::game_home().join("horizon-player.txt")) - .ok() - .map(|n| n.trim().to_string()) + player_name(&std::fs::read_to_string(backend::game_home().join("horizon-player.txt")).ok()?) +} + +/// The name in `horizon-player.txt`: the game writes `name:Player12345` (horizon-client's +/// offline_name.rs). +fn player_name(text: &str) -> Option { + text.lines() + .find_map(|line| line.trim().strip_prefix("name:")) + .map(str::trim) .filter(|n| !n.is_empty()) + .map(str::to_owned) } /// `1234` as `1,234`. @@ -1539,7 +1546,9 @@ impl Launcher { .flex_col() .min_w_0() .child(title("Horizon".into())) - .child(sub(if prepared { + .child(sub(if tag.is_none() { + "No version installed" + } else if prepared { "Vanilla · ready" } else { "Needs preparing" @@ -1826,10 +1835,12 @@ impl Launcher { fn play_page(&mut self, cx: &mut Context) -> Div { let t = self.theme; let Some(tag) = self.selected().cloned() else { + // No hero without a version: the page starts below the top bar like the others. return div() .flex() .flex_col() .gap(px(24.)) + .pt(px(28.)) .child(page_header( "Play", "Start the game, or run a server for others to join.", @@ -3582,6 +3593,18 @@ fn main() { #[cfg(test)] mod tests { + #[test] + fn the_offline_name_is_read_from_its_key() { + use super::player_name; + assert_eq!( + player_name("name:Player63010\n").as_deref(), + Some("Player63010") + ); + assert_eq!(player_name("name:Steve\r\n").as_deref(), Some("Steve")); + assert_eq!(player_name("Player63010\n"), None); + assert_eq!(player_name(""), None); + } + #[test] fn counts_get_thousands_separators() { let shown: Vec = [0, 7, 999, 1000, 12_300, 1_234_500] diff --git a/launcher/src/servers_page.rs b/launcher/src/servers_page.rs index 67523464b..b1e7eb775 100644 --- a/launcher/src/servers_page.rs +++ b/launcher/src/servers_page.rs @@ -284,12 +284,10 @@ pub(super) fn stop_on_quit() { /// The player's own name, to let them in on a friends-only server: the Microsoft account that plays, /// else the name the game made up for them (`horizon-player.txt`). fn own_name(accounts: &Accounts) -> Option { - accounts.selected().map(|a| a.name.clone()).or_else(|| { - std::fs::read_to_string(backend::game_home().join("horizon-player.txt")) - .ok() - .map(|n| n.trim().to_string()) - .filter(|n| !n.is_empty()) - }) + accounts + .selected() + .map(|a| a.name.clone()) + .or_else(crate::offline_name) } fn bytes(n: u64) -> String { From a4e68e6a53a9ac54773cd13cd31e182a993890c5 Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Fri, 23 Oct 2026 01:20:51 +0200 Subject: [PATCH 8/8] Mount inventories for a server's players, and llama inventories A server's players (single player on its server thread, LAN guests, dedicated-server players) get the horse inventory: shift + use on a tamed horse, donkey, mule, camel or llama, or the inventory key while riding one (ServerboundPlayerCommandPacket OPEN_INVENTORY), opens HorseInventoryMenu over the mount with ClientboundMountScreenOpenPacket. Clicks run on the menu engine's new Kind::Mount (saddle and body ArmorSlots that take what the mount may wear, the chest, AbstractMountInventoryMenu.quickMoveStack), and the slots go back to the mount with the item's equip sound. The client shows a server's mount screen, predicts its clicks with the same engine and sends OPEN_INVENTORY when the inventory key is pressed while riding. Llamas now have their inventory in the window too: the carpet slot and a chest of `strength` columns, no saddle slot. lan_e2e `mounts` checks it for the host and a guest, with and without --server-thread. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game.rs | 14 ++ crates/horizon-client/src/game/lan_actions.rs | 21 +- .../horizon-client/src/game/lan_mount_menu.rs | 222 ++++++++++++++++++ crates/horizon-client/src/game/llamas.rs | 3 +- crates/horizon-client/src/game/mount_menu.rs | 206 +++++++++++++--- crates/horizon-client/src/game/remote.rs | 45 ++++ crates/horizon-client/src/lan/containers.rs | 17 ++ crates/horizon-client/src/lan/hub.rs | 4 + crates/horizon-client/src/menu.rs | 138 ++++++++++- crates/horizon-protocol/src/packets/play.rs | 9 + tools/lan_e2e.py | 102 +++++++- 11 files changed, 745 insertions(+), 36 deletions(-) create mode 100644 crates/horizon-client/src/game/lan_mount_menu.rs diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 52bbad605..72017eea7 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -72,6 +72,7 @@ mod lan_chat; mod lan_crafting; mod lan_entities; mod lan_mobs; +mod lan_mount_menu; mod lan_pvp; mod lan_screen; mod lan_stations; @@ -5488,6 +5489,16 @@ impl Game<'_> { let mut v = vec![None; 45]; v.extend((0..9).map(Some)); (Kind::ItemPicker, v) + } else if let Some(columns) = self + .mount_menu_columns() + .filter(|_| self.remote.is_some() && self.mount_active()) + { + // A server's HorseInventoryMenu: the saddle, the body armour, + // the chest, then the inventory. + let mut v = vec![Some(mount_menu::SADDLE), Some(mount_menu::ARMOR)]; + v.extend((0..columns * 3).map(|i| Some(mount_menu::CHEST + i))); + v.extend((9..36).chain(0..9).map(Some)); + (Kind::Mount(columns), v) } else if merchant_open { // MerchantMenu: two payment slots, the result, then the inventory. let mut v: Vec> = vec![ @@ -5620,6 +5631,9 @@ impl Game<'_> { None => game_of.iter().position(|x| *x == Some(g)), }; let mut menu = Menu::new(kind); + if matches!(kind, Kind::Mount(_)) { + menu.mount = self.mount_menu_entity(); + } let total = menu.slot_count(); let before: Vec> = game_of .iter() diff --git a/crates/horizon-client/src/game/lan_actions.rs b/crates/horizon-client/src/game/lan_actions.rs index 569f4937b..0d70b16f6 100644 --- a/crates/horizon-client/src/game/lan_actions.rs +++ b/crates/horizon-client/src/game/lan_actions.rs @@ -57,6 +57,8 @@ pub(super) struct GuestPlay { pub(super) crafting: super::lan_crafting::GuestCrafting, /// The workstation menu the guest has open (game/lan_stations.rs). pub(super) station: Option, + /// The mount inventory the guest has open (game/lan_mount_menu.rs). + pub(super) mount_window: Option, /// The lectern the guest reads (game/lan_stations.rs). pub(super) lectern: Option, /// What the guest's body was given for its effects and enchantments. @@ -493,7 +495,9 @@ impl Game<'_> { _ => None, }; self.command_block_screen = host_command_block; - self.mount_menu = host_mount_menu; + // A mount inventory the guest's action opened (shift + use, or the + // inventory key while riding). + let guest_mount = std::mem::replace(&mut self.mount_menu, host_mount_menu).opened_mob(); self.keys = host_keys; let g = &mut host.hub.guests[i]; let left_vehicle = g.vehicle.filter(|_| guest_vehicle.is_none()); @@ -616,6 +620,9 @@ impl Game<'_> { if opened_table { self.lan_open_crafting(host, conn); } + if let Some(mob) = guest_mount { + self.lan_open_mount(host, conn, mob); + } } /// `ServerLevel.players()` in turn: the host's own player, then each @@ -971,6 +978,14 @@ impl Game<'_> { S::ContainerClick(p) if self.lan_machine_click(host, conn, &p) => {} S::ContainerClick(p) if self.lan_station_click(host, conn, &p) => {} S::ContainerClick(p) if self.lan_lectern_click(host, conn, &p) => {} + S::ContainerClick(p) if self.lan_mount_click(host, conn, &p) => {} + // ServerboundPlayerCommandPacket OPEN_INVENTORY: the ridden + // mount's inventory (`HasCustomInventoryScreen`). + S::PlayerCommand(p) if p.action == 5 => { + self.as_guest(host, conn, |g| { + g.open_ridden_mount_menu(); + }); + } S::ContainerButtonClick(p) => { self.lan_station_button(host, conn, p.container, p.button); } @@ -988,7 +1003,8 @@ impl Game<'_> { } S::ContainerClose(p) if self.lan_crafting_closed(host, conn, Some(p.container)) - || self.lan_station_closed(host, conn, Some(p.container)) => + || self.lan_station_closed(host, conn, Some(p.container)) + || self.lan_mount_closed(host, conn, Some(p.container)) => { let g = &mut host.hub.guests[gi]; if let Some(c) = g.carried.take() { @@ -1528,6 +1544,7 @@ impl Game<'_> { pub(super) fn tick_guests(&mut self, host: &mut LanHost) { self.lan_merchant_tick(host); self.lan_stations_tick(host); + self.tick_lan_mount_menus(host); self.announce_guest_advancements(host); let conns: Vec = host .hub diff --git a/crates/horizon-client/src/game/lan_mount_menu.rs b/crates/horizon-client/src/game/lan_mount_menu.rs new file mode 100644 index 000000000..78b306252 --- /dev/null +++ b/crates/horizon-client/src/game/lan_mount_menu.rs @@ -0,0 +1,222 @@ +//! The horse inventory for a server's players (`ServerPlayer.openHorseInventory`): +//! shift + use on a tamed horse, donkey, mule, camel or llama, or the +//! inventory key while riding one (`ServerboundPlayerCommandPacket` +//! OPEN_INVENTORY), opens `HorseInventoryMenu` over the mount with +//! `ClientboundMountScreenOpenPacket`. Clicks run on the menu engine +//! (`Kind::Mount`) with the mount's saddle, body item and chest copied in and +//! written back, and the slots go to the client after each change, as +//! `broadcastChanges` does. +use super::{Game, lan::LanHost, lan_crafting::input}; +use horizon_client::{ + items::ItemStack, + menu::{Kind, Menu}, +}; +use horizon_protocol::packets::play::{self, Clientbound as C}; + +/// A guest's open mount menu. +pub(super) struct GuestMount { + window: i32, + mob: u32, + columns: usize, + state: i32, + /// The menu's slots and the carried stack the client was last sent. + sent: Option<(Vec>, Option)>, +} + +impl Game<'_> { + /// `ServerPlayer.openHorseInventory` for mount `mob`: the open menu + /// closes, the client gets the mount screen and the menu's slots. + pub(super) fn lan_open_mount(&mut self, host: &mut LanHost, conn: u32, mob: u32) { + let Some(index) = self.mobs.iter().position(|m| m.id == mob) else { + return; + }; + let columns = super::mount_menu::mount_columns(&self.mobs[index]); + self.lan_mount_closed(host, conn, None); + self.lan_station_closed(host, conn, None); + self.lan_crafting_closed(host, conn, None); + self.guest_container_closed(host, conn); + let Some(window) = host.hub.open_mount_window(conn, columns as i32, mob as i32) else { + return; + }; + host.play.entry(conn).or_default().mount_window = Some(GuestMount { + window, + mob, + columns, + state: 0, + sent: None, + }); + self.lan_send_mount(host, conn); + } + + /// `HorseInventoryMenu` over mount `index`, with the player's inventory + /// loaded by the caller. + fn mount_engine_menu(&self, index: usize, columns: usize) -> Menu { + let env = self.menu_env(); + let mut menu = Menu::new(Kind::Mount(columns)); + menu.mount = Some(self.mobs[index].kind.id.to_string()); + let (saddle, body, items) = self.mount_contents(index); + menu.set_slot(&env, 0, saddle); + menu.set_slot(&env, 1, body.flatten()); + for (k, item) in items.into_iter().take(columns * 3).enumerate() { + menu.set_slot(&env, 2 + k, item); + } + menu + } + + /// The menu's slots (the mount's, then the inventory and the hotbar) + /// and the carried stack, sent when they changed since the last time. + fn lan_send_mount(&mut self, host: &mut LanHost, conn: u32) { + let Some(gm) = host.play.get(&conn).and_then(|p| p.mount_window.as_ref()) else { + return; + }; + let (mob, columns) = (gm.mob, gm.columns); + let Some(index) = self.mobs.iter().position(|m| m.id == mob) else { + return; + }; + let Some(g) = host.hub.guests.iter().find(|g| g.conn == conn) else { + return; + }; + let (saddle, body, items) = self.mount_contents(index); + let mut slots = vec![saddle, body.flatten()]; + slots.extend(items.into_iter().take(columns * 3)); + slots.extend((9..36).chain(0..9).map(|i| g.inventory[i].clone())); + let carried = g.carried.clone(); + let now = Some((slots, carried)); + let Some(gm) = host + .play + .get_mut(&conn) + .and_then(|p| p.mount_window.as_mut()) + else { + return; + }; + if gm.sent == now { + return; + } + gm.state = (gm.state + 1) & 0x7FFF; + let (window, state) = (gm.window, gm.state); + let (slots, carried) = now.clone().unwrap_or_default(); + gm.sent = now; + let content = C::ContainerSetContent(play::ContainerSetContent { + container: window, + state, + items: slots.iter().map(|s| host.hub.stack(s)).collect(), + carried: host.hub.stack(&carried), + }); + host.hub.send_to(conn, &content); + } + + /// A click on the guest's mount window; false for other windows. + pub(super) fn lan_mount_click( + &mut self, + host: &mut LanHost, + conn: u32, + p: &play::ContainerClick, + ) -> bool { + let Some((mob, columns)) = host + .play + .get(&conn) + .and_then(|pl| pl.mount_window.as_ref()) + .filter(|m| m.window == p.container) + .map(|m| (m.mob, m.columns)) + else { + return false; + }; + if let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) + && !host.hub.guests[gi].spectator() + && let Some(index) = self.mobs.iter().position(|m| m.id == mob) + { + let mut menu = self.mount_engine_menu(index, columns); + self.guest_menu_op(host, gi, &mut menu, |menu, env| { + let _ = menu.click(env, i32::from(p.slot), i32::from(p.button), input(p.input)); + }); + self.lan_mount_store(index, columns, &menu); + } + // The client is told what the server has, whatever it predicted. + if let Some(gm) = host + .play + .get_mut(&conn) + .and_then(|pl| pl.mount_window.as_mut()) + { + gm.sent = None; + } + self.lan_send_mount(host, conn); + true + } + + /// The engine menu's mount slots back on mount `index`. + fn lan_store_slots(menu: &Menu, columns: usize) -> Vec> { + (0..columns * 3) + .map(|k| menu.slot(2 + k).cloned()) + .collect() + } + fn lan_mount_store(&mut self, index: usize, columns: usize, menu: &Menu) { + let (_, body, _) = self.mount_contents(index); + let body = body.map(|_| menu.slot(1).cloned()); + let items = Self::lan_store_slots(menu, columns); + self.mount_store(index, menu.slot(0).cloned(), body, items); + } + + /// `AbstractContainerMenu.removed` of the guest's mount menu (`window`; + /// None closes it whichever it is): the carried stack goes back to the + /// inventory. False when no such window is open. + pub(super) fn lan_mount_closed( + &mut self, + host: &mut LanHost, + conn: u32, + window: Option, + ) -> bool { + let closed = host + .play + .get_mut(&conn) + .and_then(|p| { + p.mount_window + .take_if(|m| window.is_none_or(|w| m.window == w)) + }) + .is_some(); + if closed + && let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) + && let Some(carried) = host.hub.guests[gi].carried.take() + { + self.lan_place_back(host, gi, vec![carried]); + } + closed + } + + /// Each tick: a mount menu whose mount died or is out of reach closes + /// (`AbstractMountInventoryMenu.stillValid`: alive, within the entity + /// interaction range plus 4 of the eyes), and the others follow the + /// mount's and the inventory's slots. + pub(super) fn tick_lan_mount_menus(&mut self, host: &mut LanHost) { + let open: Vec<(u32, u32, i32)> = host + .play + .iter() + .filter_map(|(c, p)| p.mount_window.as_ref().map(|m| (*c, m.mob, m.window))) + .collect(); + for (conn, mob, window) in open { + let Some(g) = host.hub.guests.iter().find(|g| g.conn == conn) else { + continue; + }; + let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]]; + let valid = self.mobs.iter().find(|m| m.id == mob).is_some_and(|m| { + let (w, h) = (f64::from(m.width()) / 2.0, f64::from(m.height())); + let p = [m.position.x, m.position.y, m.position.z]; + let lo = [p[0] - w, p[1], p[2] - w]; + let hi = [p[0] + w, p[1] + h, p[2] + w]; + let d: f64 = (0..3) + .map(|k| (eye[k] - eye[k].clamp(lo[k], hi[k])).powi(2)) + .sum(); + // Player.ENTITY_INTERACTION_RANGE 3, plus 4. + m.alive() && d < 7.0 * 7.0 + }); + if valid { + self.lan_send_mount(host, conn); + } else { + self.lan_mount_closed(host, conn, Some(window)); + host.hub.send_to( + conn, + &C::ContainerClose(play::ContainerClose { container: window }), + ); + } + } + } +} diff --git a/crates/horizon-client/src/game/llamas.rs b/crates/horizon-client/src/game/llamas.rs index b9be87e3e..1ce518f31 100644 --- a/crates/horizon-client/src/game/llamas.rs +++ b/crates/horizon-client/src/game/llamas.rs @@ -93,7 +93,8 @@ impl Game<'_> { return false; } if tamed && sneaking { - // The horse inventory screen is not available yet. + // AbstractHorse.openCustomInventoryScreen. + self.open_mount_menu(index); return true; } let m = &self.mobs[index]; diff --git a/crates/horizon-client/src/game/mount_menu.rs b/crates/horizon-client/src/game/mount_menu.rs index 1db2210d3..e9e1e380b 100644 --- a/crates/horizon-client/src/game/mount_menu.rs +++ b/crates/horizon-client/src/game/mount_menu.rs @@ -1,12 +1,15 @@ //! The horse inventory (`HorseInventoryMenu`, `HorseInventoryScreen`): the -//! saddle and body armour slots, the 3x5 chest of donkeys and mules and the -//! mount drawn following the cursor. It opens with E while riding a tamed -//! mount or with shift + use on a tamed one. +//! saddle and body armour slots (a llama's carpet), the chest of donkeys, +//! mules and llamas and the mount drawn following the cursor. It opens with +//! E while riding a tamed mount or with shift + use on a tamed one. On a +//! server the screen shows the server's menu (`ClientboundMountScreenOpenPacket`) +//! and its clicks go out as container clicks; a server's players get theirs +//! from game/lan_mount_menu.rs. use super::{Game, Screen}; use horizon_client::{ hud::{HudBatch, rgba}, items::ItemStack, - mobs::{Mob, Species, equine}, + mobs::{Mob, Species, equine, llama}, }; /// Menu slot ids: the saddle, the body armour and the chest from 710. @@ -17,11 +20,43 @@ pub(super) const CHEST: usize = 710; const CHEST_SIZE: usize = 15; const COLUMNS: usize = 5; +/// `AbstractHorse.getInventoryColumns`: five for a donkey's or mule's chest, +/// a llama's strength for its chest, else none. +pub(super) fn mount_columns(m: &Mob) -> usize { + let species = m.species(); + if llama::is_llama(species) { + llama::inventory_columns(m).clamp(0, 5) as usize + } else if m.extra.chested && matches!(species, Species::Donkey | Species::Mule) { + COLUMNS + } else { + 0 + } +} +/// Whether the mount has a horse inventory (`AbstractHorse`). +pub(super) fn has_mount_menu(species: Species) -> bool { + equine::is_equine(species) || llama::is_llama(species) +} +/// The saddle slot is active (`canUseSlot(SADDLE)` and `CAN_EQUIP_SADDLE`): +/// not on llamas. +fn shows_saddle(species: Species) -> bool { + !llama::is_llama(species) +} +/// The body slot is active: horse armour, or a llama's carpet. +fn shows_body(species: Species) -> bool { + can_wear_horse_armor(species) || llama::is_llama(species) +} + /// The open mount menu: copies of the mount's slots, written back on every change. #[derive(Default)] pub(super) struct MountMenu { open: Option, } +impl MountMenu { + /// The mount whose menu is open. + pub(super) fn opened_mob(&self) -> Option { + self.open.as_ref().map(|o| o.mob) + } +} struct Open { mob: u32, species: Species, @@ -40,6 +75,9 @@ fn can_wear_horse_armor(species: Species) -> bool { } /// Whether `item` fits the body slot of the species (`isEquippableInSlot`). fn body_armor_fits(species: Species, item: &str) -> bool { + if llama::is_llama(species) { + return item.ends_with("_carpet"); + } can_wear_horse_armor(species) && item.ends_with("_horse_armor") } @@ -55,18 +93,16 @@ impl Game<'_> { pub(super) fn open_mount_menu(&mut self, index: usize) { let m = &self.mobs[index]; let species = m.species(); - if !equine::is_equine(species) { + if !has_mount_menu(species) { return; } - let chested = m.extra.chested && matches!(species, Species::Donkey | Species::Mule); - let columns = if chested { COLUMNS } else { 0 }; let mut items = m.extra.items.clone(); items.resize(CHEST_SIZE, None); - let show_armor = can_wear_horse_armor(species); + let show_armor = shows_body(species); self.mount_menu.open = Some(Open { mob: m.id, species, - columns, + columns: mount_columns(m), saddle: m.saddle_item(), armor: m .extra @@ -75,10 +111,40 @@ impl Game<'_> { .filter(|_| show_armor) .map(|i| ItemStack::new(i, 1)), items, - show_saddle: true, + show_saddle: shows_saddle(species), show_armor, }); - self.carried = None; + self.show_mount_menu(); + } + /// `ClientPacketListener.handleMountScreenOpen`: a server's horse + /// inventory over mount `entity`, its slots to come in the menu's content. + pub(super) fn open_remote_mount_menu(&mut self, entity: u32, columns: usize) -> bool { + let Some(species) = self + .mobs + .iter() + .find(|m| m.id == entity) + .map(Mob::species) + .filter(|s| has_mount_menu(*s)) + else { + return false; + }; + self.mount_menu.open = Some(Open { + mob: entity, + species, + columns: columns.min(COLUMNS), + saddle: None, + armor: None, + items: vec![None; CHEST_SIZE], + show_saddle: shows_saddle(species), + show_armor: shows_body(species), + }); + self.show_mount_menu(); + true + } + fn show_mount_menu(&mut self) { + if self.remote.is_none() && self.lan_acting.is_none() { + self.carried = None; + } self.station = None; self.container = None; self.craft_size = 2; @@ -94,15 +160,39 @@ impl Game<'_> { return false; }; let m = &self.mobs[index]; - if !equine::is_equine(m.species()) || !m.extra.tame || !m.alive() { + if !has_mount_menu(m.species()) || !m.extra.tame || !m.alive() { return false; } self.open_mount_menu(index); true } + /// Riding a mount that has a horse inventory (`HasCustomInventoryScreen`). + pub(super) fn riding_mount_with_menu(&self) -> bool { + self.riding + .and_then(|id| self.mount_mob_index(id)) + .is_some_and(|i| has_mount_menu(self.mobs[i].species())) + } + /// A server closed its window: the screen's copy goes. + pub(super) fn mount_menu_reset(&mut self) { + self.mount_menu.open = None; + } + /// The entity type of the open mount menu's mount (`minecraft:horse`). + pub(super) fn mount_menu_entity(&self) -> Option { + let open = self.mount_menu.open.as_ref()?; + let m = self.mobs.iter().find(|m| m.id == open.mob)?; + Some(m.kind.id.to_string()) + } + /// The chest columns of the open mount menu (the server's window's). + pub(super) fn mount_menu_columns(&self) -> Option { + self.mount_menu.open.as_ref().map(|o| o.columns) + } /// The menu ends when the mount dies or is more than 4 blocks away /// (`AbstractMountInventoryMenu.stillValid`). pub(super) fn tick_mount_menu(&mut self) { + // A server's menu ends when the server closes it. + if self.remote.is_some() { + return; + } let Some(open) = self.mount_menu.open.as_ref() else { return; }; @@ -126,8 +216,12 @@ impl Game<'_> { self.mount_sync(); self.mount_menu.open = None; } - /// Writes the menu slots to the mount, with the equip sounds. + /// Writes the menu slots to the mount, with the equip sounds. A + /// server's menu is the server's to write. fn mount_sync(&mut self) { + if self.remote.is_some() { + return; + } let Some(open) = self.mount_menu.open.as_ref() else { return; }; @@ -135,25 +229,66 @@ impl Game<'_> { return; }; let (saddle, armor) = (open.saddle.clone(), open.armor.clone()); + let body = open.show_armor.then_some(armor); let items = open.items.clone(); - let m = &mut self.mobs[index]; + self.mount_store(index, saddle, body, items); + } + /// The slots of mount `index` as its menu shows them: the saddle, the + /// body item (None when the slot isn't active), the chest. + pub(super) fn mount_contents( + &self, + index: usize, + ) -> ( + Option, + Option>, + Vec>, + ) { + let m = &self.mobs[index]; + let species = m.species(); + let body = + shows_body(species).then(|| m.extra.head.as_deref().map(|i| ItemStack::new(i, 1))); + let mut items = m.extra.items.clone(); + items.resize(mount_columns(m) * 3, None); + (m.saddle_item(), body, items) + } + /// The menu's slots back on mount `index` (`body` None: not its slot), + /// with the equip sound of what was put on (`Equippable.equipSound`). + pub(super) fn mount_store( + &mut self, + index: usize, + saddle: Option, + body: Option>, + items: Vec>, + ) { + let m = &self.mobs[index]; let at = m.center(); - let sound = if saddle.is_some() && !m.extra.saddled { - Some("minecraft:entity.horse.saddle") - } else if armor.is_some() && m.extra.head.is_none() { - Some("minecraft:entity.horse.armor") + let put_on = if saddle.is_some() && !m.extra.saddled { + saddle.as_ref() } else { - None + body.as_ref() + .and_then(Option::as_ref) + .filter(|_| m.extra.head.is_none()) }; + let sound = put_on.map(|s| { + self.res + .items() + .get(&s.item) + .and_then(|i| i.equip_sound.clone()) + .unwrap_or_else(|| "minecraft:item.armor.equip_generic".into()) + }); + let m = &mut self.mobs[index]; m.set_saddle(saddle); - if open.show_armor { - m.extra.head = armor.map(|a| a.item); + if let Some(body) = body { + m.extra.head = body.map(|a| a.item); } + let mut items = items; + let keep = m.extra.items.len().max(CHEST_SIZE); + items.resize(keep, None); m.extra.items = items; let (x, z) = (at[0].floor() as i32 >> 4, at[2].floor() as i32 >> 4); self.world.modified.insert((x, z)); if let Some(sound) = sound { - self.audio.play(sound, Some(at), 0.5, 1.0); + self.audio.play(&sound, Some(at), 0.5, 1.0); } } pub(super) fn mount_slots(&self) -> Option> { @@ -214,7 +349,8 @@ impl Game<'_> { } /// A click in the mount menu before the generic handling. pub(super) fn mount_click(&mut self, right: bool) -> bool { - if !self.mount_active() { + // A server's menu: the click goes out like any container click. + if !self.mount_active() || self.remote.is_some() { return false; } let Some(r) = self.renderer.as_ref() else { @@ -314,12 +450,12 @@ impl Game<'_> { if open.show_armor { batch.sprite(atlas, "gui/sprites/container/slot", ox + 7.0, oy + 35.0); if open.armor.is_none() { - batch.sprite( - atlas, - "gui/sprites/container/slot/horse_armor", - ox + 8.0, - oy + 36.0, - ); + let sprite = if llama::is_llama(open.species) { + "gui/sprites/container/slot/llama_armor" + } else { + "gui/sprites/container/slot/horse_armor" + }; + batch.sprite(atlas, sprite, ox + 8.0, oy + 36.0); } } let grey = rgba(64, 64, 64, 255); @@ -448,4 +584,16 @@ mod tests { "minecraft:iron_chestplate" )); } + + #[test] + fn llamas_wear_carpets_and_no_saddle() { + assert!(body_armor_fits(Species::Llama, "minecraft:red_carpet")); + assert!(!body_armor_fits( + Species::Llama, + "minecraft:iron_horse_armor" + )); + assert!(shows_body(Species::TraderLlama)); + assert!(!shows_saddle(Species::Llama)); + assert!(shows_saddle(Species::Donkey) && !shows_body(Species::Donkey)); + } } diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index c482d9728..7ae8b9ed3 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -183,6 +183,9 @@ const CHUNK_PACKETS_PER_FRAME: Duration = Duration::from_millis(1); /// whatever their cost: the packets after a slow machine's chunks never /// wait more than a tick. const BACKLOG_LIMIT: Duration = Duration::from_millis(50); +/// The open menu name of a server's horse inventory, then its chest's +/// columns (the packet names no `MenuType`). +const MOUNT_MENU: &str = "horizon:mount"; fn face_direction(face: [i32; 3]) -> u8 { match face { @@ -1440,6 +1443,19 @@ impl Game<'_> { // CrafterMenu: the grid, the inventory, then the result (the // client's own). "minecraft:crafter_3x3" => slots(9), + // HorseInventoryMenu: the saddle, the body armour, the chest, + // then the inventory. + m if m.starts_with(MOUNT_MENU) => { + let n = m[MOUNT_MENU.len()..].parse::().ok()? * 3; + match s { + 0 => Some(super::mount_menu::SADDLE), + 1 => Some(super::mount_menu::ARMOR), + _ if s < 2 + n => Some(super::mount_menu::CHEST + s - 2), + _ if s < 2 + n + 27 => Some(9 + s - 2 - n), + _ if s < 2 + n + 36 => Some(s - 2 - n - 27), + _ => None, + } + } // A workstation: its inputs, its result, then the inventory. m => { let kind = Self::remote_station_kind(m)?; @@ -2204,6 +2220,7 @@ impl Game<'_> { } } C::OpenScreen(p) => self.remote_open_screen(p), + C::MountScreenOpen(p) => self.remote_open_mount(p), C::RecipeBookAdd(p) => self.remote_recipe_book_add(p), C::RecipeBookRemove(p) => self.remote_recipe_book_remove(p), C::RecipeBookSettings(p) => self.remote_recipe_book_settings(p), @@ -4099,6 +4116,18 @@ impl Game<'_> { } Ok(true) } + // MultiPlayerGameMode.isServerControlledInventory: riding a + // horse or llama, the server opens its inventory + // (ServerboundPlayerCommandPacket OPEN_INVENTORY). + KeyCode::KeyE if self.riding_mount_with_menu() => { + let id = self.remote.as_ref().map_or(0, |r| r.entity_id); + self.send(S::PlayerCommand(play::PlayerCommand { + id, + action: 5, + data: 0, + })); + Ok(true) + } KeyCode::KeyE => { // The inventory opens without telling the server. self.screen = if self.creative { @@ -4343,6 +4372,7 @@ impl Game<'_> { r.menu = None; r.menu_title = None; } + self.mount_menu_reset(); // The client's menu has no level access: what it holds is the server's. self.station = None; self.craft_size = 2; @@ -4527,6 +4557,21 @@ impl Game<'_> { self.screen = Screen::Inventory; self.capture(false); } + /// `ClientPacketListener.handleMountScreenOpen`: a horse's or llama's + /// inventory; for another entity the window is closed again. + fn remote_open_mount(&mut self, p: play::MountScreenOpen) { + let columns = usize::try_from(p.columns).unwrap_or(0).min(5); + if !self.open_remote_mount_menu(p.entity as u32, columns) { + self.send(S::ContainerClose(play::ContainerClose { + container: p.container, + })); + return; + } + if let Some(r) = self.remote.as_mut() { + r.menu = Some((p.container, format!("{MOUNT_MENU}{columns}"))); + r.menu_title = None; + } + } /// The type of the menu a server opened (`MenuType`'s id). pub(super) fn remote_menu_type(&self) -> Option<&str> { Some(self.remote.as_ref()?.menu.as_ref()?.1.as_str()) diff --git a/crates/horizon-client/src/lan/containers.rs b/crates/horizon-client/src/lan/containers.rs index 88f50aa3b..d43cb5c27 100644 --- a/crates/horizon-client/src/lan/containers.rs +++ b/crates/horizon-client/src/lan/containers.rs @@ -234,6 +234,23 @@ impl Hub { Some(window) } + /// ServerPlayer.openHorseInventory: the open container closes, a new + /// window id opens mount `entity`'s inventory with its chest's columns + /// (ClientboundMountScreenOpenPacket). + pub fn open_mount_window(&mut self, conn: u32, columns: i32, entity: i32) -> Option { + let i = self.guests.iter().position(|g| g.conn == conn)?; + self.close_container_at(i); + let g = &mut self.guests[i]; + g.next_window = g.next_window % 100 + 1; + let window = g.next_window; + g.send(&C::MountScreenOpen(play::MountScreenOpen { + container: window, + columns, + entity, + })); + Some(window) + } + fn send_menu_content(&mut self, i: usize, mut items: Vec>) { for j in 0..36 { items.push(self.guests[i].inventory[player_slot(j)].clone()); diff --git a/crates/horizon-client/src/lan/hub.rs b/crates/horizon-client/src/lan/hub.rs index 8d7b5e29a..bf66f979b 100644 --- a/crates/horizon-client/src/lan/hub.rs +++ b/crates/horizon-client/src/lan/hub.rs @@ -1972,6 +1972,10 @@ impl Hub { self.guests[i].sneaking = p.flags & 32 != 0; self.guests[i].sprinting = p.flags & 64 != 0 && p.flags & 1 != 0; } + // OPEN_INVENTORY is the game's: the ridden mount's inventory. + S::PlayerCommand(p) if p.action == 5 => { + events.push(HostEvent::Packet(conn, Box::new(S::PlayerCommand(p)))); + } S::PlayerCommand(p) => match p.action { 1 => self.guests[i].sprinting = true, 2 => self.guests[i].sprinting = false, diff --git a/crates/horizon-client/src/menu.rs b/crates/horizon-client/src/menu.rs index cb804e15b..cdaf73eed 100644 --- a/crates/horizon-client/src/menu.rs +++ b/crates/horizon-client/src/menu.rs @@ -114,6 +114,10 @@ pub enum Kind { Lectern, /// `MerchantMenu`: two payment slots, the result, then the inventory. Merchant, + /// `HorseInventoryMenu`: the saddle and body armour slots (`ArmorSlot`s + /// of the mount, whose entity type is `Menu::mount`), a chest of three + /// rows of this many columns (`getInventoryColumns`), then the inventory. + Mount(usize), /// `CreativeModeInventoryScreen.ItemPickerMenu`: an item tab's 45 /// display slots, then the hotbar. ItemPicker, @@ -124,6 +128,9 @@ impl Kind { if let Some(rows) = name.strip_prefix("chest") { return rows.parse().ok().map(Self::Chest); } + if let Some(columns) = name.strip_prefix("mount") { + return columns.parse().ok().map(Self::Mount); + } Some(match name { "inventory" => Self::Inventory, "crafting" => Self::Crafting, @@ -207,6 +214,9 @@ enum Role { /// `CustomCreativeSlot`: a creative tab's display slot, which the /// screen clicks itself; drags and PICKUP_ALL pass it by. CreativeDisplay, + /// An `ArmorSlot` of a mount for this `EquipmentSlot` ("saddle" or + /// "body"): `isEquippableInSlot` for `Menu::mount`, one item. + MountEquip(&'static str), } #[derive(Clone, Copy)] @@ -298,6 +308,9 @@ pub struct Menu { pub enchant_button: Option, /// `Player.mayBuild` (not adventure mode): the lectern's Take Book. pub may_build: bool, + /// A mount menu's entity type (`minecraft:horse`): what its saddle and + /// body slots take. + pub mount: Option, } /// `AnvilMenu`'s state besides its slots and `cost`. @@ -560,6 +573,22 @@ impl Menu { // The open page (LecternBlockEntity.dataAccess). data = 1; } + Kind::Mount(columns) => { + containers[MAIN] = vec![None; 2 + columns * 3]; + for (index, slot) in ["saddle", "body"].into_iter().enumerate() { + slots.push(Slot { + container: MAIN, + index, + role: Role::MountEquip(slot), + }); + } + slots.extend((2..2 + columns * 3).map(|index| Slot { + container: MAIN, + index, + role: Role::Plain, + })); + player(&mut slots); + } Kind::ItemPicker => { containers[MAIN] = vec![None; 45]; slots.extend(main(45, Role::CreativeDisplay)); @@ -654,6 +683,7 @@ impl Menu { enchant_changed: false, enchant_button: None, may_build: true, + mount: None, } .with_initial_data() } @@ -1186,6 +1216,15 @@ impl Menu { Some(slot == eq && allowed) }) .unwrap_or(false), + // LivingEntity.isEquippableInSlot: the item's equippable slot, + // and the mount among its allowed entities. + Role::MountEquip(slot) => env.items.get(&stack.item).is_some_and(|i| { + i.equip_slot.as_deref() == Some(slot) + && self + .mount + .as_deref() + .is_some_and(|m| i.equip_allowed.as_ref().is_none_or(|a| a.contains(m))) + }), Role::Shulker => Self::fits_inside(stack), Role::Fuel => Self::is_fuel(env, stack) || Self::is_bucket(stack), Role::Output | Role::CraftResult | Role::Inert => false, @@ -1257,7 +1296,7 @@ impl Menu { ); o.satisfied_by(env.items, a, b) || o.satisfied_by(env.items, b, a) }), - Role::Armor(_) => match self.slot(s) { + Role::Armor(_) | Role::MountEquip(_) => match self.slot(s) { Some(stack) => { self.creative || stack.enchantment_level("minecraft:binding_curse") == 0 } @@ -1270,7 +1309,11 @@ impl Menu { /// `Slot.getMaxStackSize(stack)`. fn slot_max(&self, env: &Env, s: usize, stack: &ItemStack) -> u32 { let own = match self.slots[s].role { - Role::Armor(_) | Role::Potion | Role::EnchantItem | Role::BeaconPay => 1, + Role::Armor(_) + | Role::MountEquip(_) + | Role::Potion + | Role::EnchantItem + | Role::BeaconPay => 1, // The lectern's bookAccess holds one. Role::Plain if self.kind == Kind::Lectern && self.slots[s].container == MAIN => 1, Role::Fuel if Self::is_bucket(stack) => return 1, @@ -2026,6 +2069,38 @@ impl Menu { } // LecternMenu.quickMoveStack moves nothing. Kind::Lectern => return None, + Kind::Mount(columns) => { + // AbstractMountInventoryMenu.quickMoveStack: no count check + // and no onTake. + check_count = false; + let start = 2 + columns * 3; + if index < start { + if !self.move_to(env, &mut stack, start, total, true) { + return None; + } + } else if self.may_place(env, 1, &stack) && self.containers[MAIN][1].is_none() { + if !self.move_to(env, &mut stack, 1, 2, false) { + return None; + } + } else if self.may_place(env, 0, &stack) && self.containers[MAIN][0].is_none() { + if !self.move_to(env, &mut stack, 0, 1, false) { + return None; + } + } else if columns == 0 || !self.move_to(env, &mut stack, 2, start, false) { + // Between the inventory and the hotbar: the stack shrinks + // in its slot and EMPTY is returned either way. + let (main_end, hotbar_end) = (start + 27, start + 36); + if (main_end..hotbar_end).contains(&index) { + self.move_to(env, &mut stack, start, main_end, false); + } else if (start..main_end).contains(&index) { + self.move_to(env, &mut stack, main_end, hotbar_end, false); + } + let slot = self.slots[index]; + self.containers[slot.container][slot.index] = + (stack.count > 0).then(|| stack.clone()); + return None; + } + } Kind::Beacon => { // BeaconMenu.quickMoveStack. after_take = true; @@ -2946,6 +3021,65 @@ pub(crate) mod tests { test(&env, &mut menu); } + /// `HorseInventoryMenu`: a shift-click wears horse armour, then saddles, + /// then fills the chest; the body slot takes only what the mount may wear. + #[test] + fn mount_menu_slots_take_what_the_mount_wears() { + with_menu(Kind::Mount(5), |env, menu| { + menu.creative = false; + menu.mount = Some("minecraft:donkey".into()); + // 2 saddle and body, 15 chest, then the inventory from 17. + assert_eq!(menu.slot_count(), 2 + 15 + 36); + let hotbar = 17 + 27; + menu.set_slot(env, hotbar, stack("iron_horse_armor", 1)); + menu.click(env, hotbar as i32, 0, Input::QuickMove).unwrap(); + // A donkey wears no horse armour: it goes in the chest. + assert_eq!(menu.slot(1), None); + assert_eq!( + menu.slot(2).map(|s| &*s.item), + Some("minecraft:iron_horse_armor") + ); + menu.set_slot(env, hotbar, stack("saddle", 1)); + menu.click(env, hotbar as i32, 0, Input::QuickMove).unwrap(); + assert_eq!(menu.slot(0).map(|s| &*s.item), Some("minecraft:saddle")); + // Back out of the mount: to the inventory, from the hotbar's end. + menu.click(env, 0, 0, Input::QuickMove).unwrap(); + assert_eq!(menu.slot(0), None); + assert_eq!( + menu.slot(17 + 35).map(|s| &*s.item), + Some("minecraft:saddle") + ); + }); + with_menu(Kind::Mount(0), |env, menu| { + menu.creative = false; + menu.mount = Some("minecraft:horse".into()); + let hotbar = 2 + 27; + menu.set_slot(env, hotbar, stack("diamond_horse_armor", 1)); + menu.click(env, hotbar as i32, 0, Input::QuickMove).unwrap(); + assert_eq!( + menu.slot(1).map(|s| &*s.item), + Some("minecraft:diamond_horse_armor") + ); + // No chest: dirt goes between the hotbar and the inventory. + menu.set_slot(env, hotbar, stack("dirt", 3)); + menu.click(env, hotbar as i32, 0, Input::QuickMove).unwrap(); + assert_eq!( + menu.slot(2).map(|s| (&*s.item, s.count)), + Some(("minecraft:dirt", 3)) + ); + assert_eq!(menu.slot(hotbar), None); + // Placing by hand: a saddle doesn't go in the body slot. + menu.carried = stack("saddle", 1); + menu.click(env, 1, 0, Input::Pickup).unwrap(); + assert_eq!( + menu.slot(1).map(|s| &*s.item), + Some("minecraft:diamond_horse_armor") + ); + menu.click(env, 0, 0, Input::Pickup).unwrap(); + assert_eq!(menu.slot(0).map(|s| &*s.item), Some("minecraft:saddle")); + }); + } + /// `onEquipItem` runs from the armour and off-hand slots' `setByPlayer` /// when the item changes: never from `Inventory.setItem` (a swap's source). #[test] diff --git a/crates/horizon-protocol/src/packets/play.rs b/crates/horizon-protocol/src/packets/play.rs index e737258c5..24eec6a05 100644 --- a/crates/horizon-protocol/src/packets/play.rs +++ b/crates/horizon-protocol/src/packets/play.rs @@ -458,6 +458,9 @@ move_entity!(MoveEntityPos, pos: true, rot: false); move_entity!(MoveEntityPosRot, pos: true, rot: true); move_entity!(MoveEntityRot, pos: false, rot: true); packet!(OpenScreen { container: i32 => VarInt, menu: i32 => VarInt, title: Tag => Nbt }); +// ClientboundMountScreenOpenPacket: a horse's or llama's inventory, with its +// chest's columns (`getInventoryColumns`). +packet!(MountScreenOpen { container: i32 => VarInt, columns: i32 => VarInt, entity: i32 => Int }); packet!(PlayerAbilities { flags: i8 => Byte, flying_speed: f32 => Float, walking_speed: f32 => Float }); /// MessageSignature.Packed: an index into the last-seen cache or a full signature. pub struct PackedSignature; @@ -1497,6 +1500,7 @@ packet_enum!(Clientbound, crate::ids::play::clientbound { MoveEntityPosRot(MoveEntityPosRot) = MOVE_ENTITY_POS_ROT, MoveEntityRot(MoveEntityRot) = MOVE_ENTITY_ROT, MerchantOffers(MerchantOffers) = MERCHANT_OFFERS, + MountScreenOpen(MountScreenOpen) = MOUNT_SCREEN_OPEN, OpenScreen(OpenScreen) = OPEN_SCREEN, Ping(Ping) = PING, PongResponse(crate::packets::status::Ping) = PONG_RESPONSE, @@ -2179,6 +2183,11 @@ mod tests { menu: 2, title: literal("Chest"), }), + Clientbound::MountScreenOpen(MountScreenOpen { + container: 4, + columns: 5, + entity: 300, + }), Clientbound::MerchantOffers(MerchantOffers { container: 3, offers: vec![NetOffer { diff --git a/tools/lan_e2e.py b/tools/lan_e2e.py index 894bb8627..c5a90150f 100755 --- a/tools/lan_e2e.py +++ b/tools/lan_e2e.py @@ -123,6 +123,9 @@ address. The script then checks, from both sides, what a guest's game used to ge it (#1222). - frames: the host and the guest each put a diamond in an item frame and turn it, and put a helmet on an armor stand and take it back (#1222). +- mounts: the host and the guest each open a tamed horse's, donkey's and llama's inventory + (shift + right click, then the inventory key while riding), put on a saddle, armour + or a carpet and fill the chest by shift-clicking. - riding: the host and the guest each saddle and ride a horse (walk, jump), a pig and a strider (with their sticks), a camel and a donkey, sit on a llama and fly a happy ghast, and get off by sneaking; the other side sees the animal move. @@ -169,7 +172,7 @@ ROOT = Path(__file__).resolve().parents[1] GUEST_NAME = 'Guest' # The host plays single player on its server thread (--server-thread). SERVER_THREAD = False -CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'arrows', 'kills', 'hurt', 'respawn', 'containers', 'feedback', 'titles', 'chat', 'commands', 'recipes', 'machines', 'brewing', 'loom', 'stonecutter', 'grindstone', 'anvil', 'smithing', 'enchanting', 'beacon', 'crafter', 'lectern', 'cartography', 'trials', 'clicks', 'sleep', 'predict', 'push', 'eat', 'eating', 'xp', 'objects', 'projectiles', 'worn', 'blast', 'barter', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'effects', 'portals', 'distance', 'growth', 'flight', 'ladder', 'boats', 'minecarts', 'frames', 'lightning', 'riding'] +CHECKS = ['tab', 'self', 'armour', 'nametags', 'walls', 'chest', 'crafting', 'sign', 'damage', 'mobs', 'hazards', 'pvp', 'arrows', 'kills', 'hurt', 'respawn', 'containers', 'feedback', 'titles', 'chat', 'commands', 'recipes', 'machines', 'brewing', 'loom', 'stonecutter', 'grindstone', 'anvil', 'smithing', 'enchanting', 'beacon', 'crafter', 'lectern', 'cartography', 'trials', 'clicks', 'sleep', 'predict', 'push', 'eat', 'eating', 'xp', 'objects', 'projectiles', 'worn', 'blast', 'barter', 'deflect', 'fireball', 'light', 'time', 'swings', 'cracks', 'plates', 'effects', 'portals', 'distance', 'growth', 'flight', 'ladder', 'boats', 'minecarts', 'frames', 'lightning', 'riding', 'mounts'] class Game: @@ -2638,6 +2641,101 @@ def check_riding(host, guest, out): return result +# Mount inventories: (kind, summon NBT, what goes in by shift-click and the menu slot it lands +# in: 700 the saddle, 701 the body armour or carpet, 710 the chest's first slot). +MOUNT_MENUS = [ + ('horse', '{Tame:1b,NoAI:1b}', [('minecraft:saddle', 700), ('minecraft:iron_horse_armor', 701)]), + ('donkey', '{Tame:1b,NoAI:1b,ChestedHorse:1b}', [('minecraft:saddle', 700), ('minecraft:dirt', 710)]), + ('llama', '{Tame:1b,NoAI:1b,ChestedHorse:1b,Strength:3}', + [('minecraft:red_carpet', 701), ('minecraft:dirt', 710)]), +] + + +def mount_screen(game, ticks=20): + """The open mount menu's slots and items ({700: 'minecraft:saddle', ...}), waiting up to + `ticks` for it to open; None when no mount menu opened.""" + for _ in range(ticks // 2): + slots = {} + for w in game.raw('ui').get('widgets', []): + wid = w.get('id', '') + if wid.startswith('slot-') and wid[5:].isdigit() and int(wid[5:]) >= 700: + slots[int(wid[5:])] = (w.get('stack') or {}).get('item') + if slots: + return slots + game.wait(2) + return None + + +def check_mounts(host, guest, out): + """The host and the guest each open a tamed horse's, donkey's and llama's inventory with + shift + right click (`ClientboundMountScreenOpenPacket` for a server's player), put on a + saddle, horse armour or a carpet and fill the chest by shift-clicking, close it, then ride the + mount and open its inventory with the inventory key (OPEN_INVENTORY): it shows what was put + in, and the server took the items from the inventory.""" + spots = apart(host, guest) + result = {} + for label, player in (('host', host), ('guest', guest)): + x, y, z = spots[label] + player('command', command='gamemode survival') + result[label] = {} + for kind, nbt, puts in MOUNT_MENUS: + player('command', command='clear @s') + for item, _ in puts: + player('command', command=f'give @s {item}') + player('command', command=f'summon minecraft:{kind} {x + 0.5} {y} {z + 2.5} {nbt}') + player.wait(5) + found = [m for m in player.raw('mobs', 16).get('near', []) + if m['kind'].endswith(kind) and m['health'] > 0] + assert found, f'the {label} sees no {kind}' + mob = found[0] + p = mob['pos'] + aim = (f'{p[0]:.2f}', f'{p[1] + HEIGHTS[kind] / 2:.2f}', f'{p[2]:.2f}') + try: + player.raw('look-at', *aim) + player.raw('key', 'shift', 'down') + player.wait(3) + player.raw('mouse', 'right', 'click') + player.wait(5) + player.raw('key', 'shift', 'up') + slots = mount_screen(player) + assert slots is not None, f"the {label}'s shift + right click on the {kind} opened no inventory" + for item, _ in puts: + player.raw('click-slot', hotbar_slot(player, item), 'shift') + player.wait(5) + slots = mount_screen(player) + for item, slot in puts: + assert slots.get(slot) == item, f"the {label}'s {kind}: {item} shift-clicked, slot {slot} holds {slots.get(slot)}" + player.raw('key', 'esc', 'tap') + player.wait(5) + for item, _ in puts: + left = server_count(player, item.split(':')[1]) + assert left == 0, f'the server still counts {left} {item} on the {label}' + player.raw('look-at', *aim) + player.raw('mouse', 'right', 'click') + player.wait(8) + riding = player('status').get('riding') + assert riding == mob['id'], f'the {label} right-clicked its {kind}: riding {riding}' + player.raw('key', 'e', 'tap') + again = mount_screen(player) + assert again is not None, f'the inventory key on the {label}\'s {kind} opened no inventory' + for item, slot in puts: + assert again.get(slot) == item, f"the {label}'s {kind} reopened: slot {slot} holds {again.get(slot)}, not {item}" + player.raw('key', 'esc', 'tap') + player.wait(3) + hold(player, 'shift', 10) + player.wait(5) + result[label][kind] = {slot: item for item, slot in puts} + finally: + back_to_game(player) + player('command', command=f'kill @e[type=minecraft:{kind},x={x},y={y},z={z},distance=..16]') + player.wait(2) + player('command', command=f'kill @e[type=minecraft:item,x={x},y={y},z={z},distance=..16]') + player('command', command='clear @s') + player('command', command=f'tp @s {x + 0.5} {y} {z + 0.5} 0 0') + player.wait(5) + return result + + def check_lightning(host, guest, out): """A bolt summoned beside each player: both sides get the bolt entity and flash the sky (the host's window too on its server thread, FEATURES 4z(f)).""" @@ -2977,7 +3075,7 @@ CHECK_FNS = {'tab': check_tab, 'self': check_self, 'armour': check_armour, 'name 'xp': check_xp, 'objects': check_objects, 'projectiles': check_projectiles, 'blast': check_blast, 'light': check_light, 'plates': check_plates, 'swings': check_swings, 'cracks': check_cracks, - 'time': check_time, 'growth': check_growth, 'flight': check_flight, 'fireball': check_fireball, 'deflect': check_deflect, 'barter': check_barter, 'worn': check_worn, 'kills': check_kills, 'arrows': check_arrows, 'predict': check_predict, 'ladder': check_ladder, 'boats': check_boats, 'minecarts': check_minecarts, 'frames': check_frames, 'lightning': check_lightning, 'riding': check_riding} + 'time': check_time, 'growth': check_growth, 'flight': check_flight, 'fireball': check_fireball, 'deflect': check_deflect, 'barter': check_barter, 'worn': check_worn, 'kills': check_kills, 'arrows': check_arrows, 'predict': check_predict, 'ladder': check_ladder, 'boats': check_boats, 'minecarts': check_minecarts, 'frames': check_frames, 'lightning': check_lightning, 'riding': check_riding, 'mounts': check_mounts} def run(ctl, out, resources, checks):