diff --git a/crates/horizon-client/src/game.rs b/crates/horizon-client/src/game.rs index 7ec21608a..ce9183cac 100644 --- a/crates/horizon-client/src/game.rs +++ b/crates/horizon-client/src/game.rs @@ -71,6 +71,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; @@ -5476,6 +5477,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![ @@ -5608,6 +5619,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-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/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/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/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/docs/design-system.md b/docs/design-system.md index 1980adc9c..bd4b635cb 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -134,12 +134,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 Blocks look below. The same page structure as before (what it is, screenshots, parity, launcher, patch notes, community), headings in Pixelify Sans, lists as stacks of panels, the stone texture behind the page and Horizon's pixel landscape (a dawn or a @@ -162,11 +162,19 @@ of the main action. The game's menus keep `LIGHT` and `DARK`. - **Blocks, not rounded cards.** Every surface is square: a 2 px dark outline, a 2 px light bevel along the top and a darker lip along the bottom. The main action is the violet face with its own bevel and a 4 px lip; it sinks a pixel when pressed. Code and fields are sunk wells. -- **Pixel art drawn in code.** The stone behind the pages is 64 texels of the page colour in five - close shades (`TILE_SHADES`), never Minecraft's stone; the landscape is a banded sky, a sun cut in - slices like the mark, hills and square trees. `website/scripts/art.mjs` draws both. -- **Icons** (the launcher): Pixelarticons (MIT), 24 unit pixel SVGs in - `crates/horizon-theme/icons/pixel/`. +- **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 drawn in code.** The stone behind the pages is close shades of the page colour + (`TILE_SHADES`), never Minecraft's stone; the landscape is a banded sky, a sun cut in slices like + the mark, hills and square trees (a sunset in dark, a dawn in light). The launcher bakes them in + `launcher/src/art.rs` at the window's scale so each texel covers whole device pixels; the website + draws them in `website/scripts/art.mjs`. +- **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`). diff --git a/docs/mod-browser.md b/docs/mod-browser.md index 6601fea70..745f51d9b 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`, @@ -71,6 +84,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 @@ -83,15 +131,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..c15d0767e 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 @@ -322,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 @@ -1047,10 +1051,35 @@ 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 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 | +|---|---|---|---|---|---|---|---|---| +| 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: 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. + +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). - 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/Cargo.toml b/launcher/Cargo.toml index e147fbb65..a402d0e28 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/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 1afbbd331..e86bd7e87 100644 --- a/launcher/src/backend.rs +++ b/launcher/src/backend.rs @@ -2218,6 +2218,17 @@ pub fn spawn(job: Job, mut cmd: Command, tx: Sender) -> Result, @@ -2242,6 +2253,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)] @@ -2270,6 +2284,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() } @@ -2520,6 +2541,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/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/cli.rs b/launcher/src/cli.rs index d8ca4829c..88934f3a5 100644 --- a/launcher/src/cli.rs +++ b/launcher/src/cli.rs @@ -1036,13 +1036,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/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 956598cb2..2ffea2e98 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -1,14 +1,18 @@ //! 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 cull; mod game_tools; mod internet; mod markdown; mod mcp; mod modpacks_page; mod mods_page; +mod mods_project; mod perf; mod scrollbar; mod servers_page; @@ -36,6 +40,7 @@ use theme::Theme; enum Page { Play, Versions, + /// Mods, resource and shader packs, and modpacks (imported ones and the browser's). Mods, Servers, Notes, @@ -43,13 +48,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"), ]; } @@ -104,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, @@ -182,13 +204,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 { @@ -224,10 +254,11 @@ 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(), + long_list: cull::Cull::default(), opening: (!settings.skip_opening).then(|| { let focus = cx.focus_handle(); window.focus(&focus); @@ -293,6 +324,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); @@ -318,10 +353,13 @@ 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; self.content_scroll = Scrollbar::default(); + self.long_list = cull::Cull::default(); } self.picker_open = false; } @@ -407,6 +445,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 => {} } @@ -862,11 +904,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 }; @@ -877,13 +921,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. @@ -942,6 +987,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, @@ -951,9 +999,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()) @@ -963,56 +1011,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)], @@ -1027,25 +1110,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)) })) } @@ -1054,14 +1146,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()), ) @@ -1073,24 +1165,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() @@ -1098,7 +1202,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) @@ -1111,7 +1219,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. @@ -1123,14 +1231,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_)) } @@ -1145,6 +1256,21 @@ 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 { + 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`. fn thousands(n: u64) -> String { let digits = n.to_string(); @@ -1178,11 +1304,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() @@ -1190,98 +1320,403 @@ 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 tag.is_none() { + "No version installed" + } else 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 { @@ -1290,23 +1725,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(); @@ -1315,13 +1768,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(); @@ -1381,10 +1833,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.", @@ -1407,10 +1861,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 { @@ -1480,8 +1942,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", @@ -1501,33 +1962,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 { @@ -1561,19 +2063,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. @@ -1701,43 +2191,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() @@ -1955,6 +2463,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() @@ -1968,9 +2500,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", @@ -1996,6 +2528,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", @@ -2003,7 +2543,9 @@ impl Launcher { Some(opening.into_any_element()), &t, )) + .child(group("Game", "gamepad")) .child(self.game_args_row(n + 4, cx)) + .child(group("About", "info-box")) .child(index_row(n + 5, "Horizon", Some(plain(&horizon)), horizon_action, &t)) .child(index_row( n + 6, @@ -2069,10 +2611,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() @@ -2310,10 +2851,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() @@ -2421,23 +2961,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().border_t_1().border_color(t.rule); - for (i, release) in releases.into_iter().enumerate() { - 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() @@ -2514,82 +3067,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(16.)) - .border_b_1() - .border_color(t.rule) - .child(hover_layer( - &id, - t.plate_soft, - theme::RADIUS_PLATE, - theme::PLATE_BLEED, - )) - .child( - div() - .relative() - .flex() - .items_center() - .gap(px(16.)) - .child(index(i + 1, &t)) - .child( - div() - .flex() - .flex_col() - .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( - 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) } @@ -2651,27 +3199,34 @@ 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 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() - .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() @@ -2679,11 +3234,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)) }), @@ -2692,8 +3243,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) @@ -2702,27 +3252,30 @@ impl Launcher { } else { markdown::render(&release.body, &t) }), - ), - ); - } + ) + }, + ); page.child(list) } } /// 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)) => { @@ -2748,7 +3301,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); @@ -2779,6 +3336,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) @@ -2808,6 +3369,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() @@ -2816,29 +3378,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) { @@ -2854,11 +3422,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() @@ -2928,6 +3497,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); @@ -2941,42 +3523,59 @@ 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)] 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/modpacks.rs b/launcher/src/modpacks.rs index 2495e1ad6..909c6cfae 100644 --- a/launcher/src/modpacks.rs +++ b/launcher/src/modpacks.rs @@ -45,6 +45,9 @@ pub struct Imported { pub fetched: Fetched, /// The carried-over mods the profile loads. pub mods: Vec, + /// 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, } @@ -105,6 +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, 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() @@ -113,8 +125,9 @@ pub fn load(work: &Path) -> Result { work: work.to_path_buf(), pack, rows, - fetched: Fetched::load(work), - mods: carried(work), + fetched, + mods, + left_off, portable: records.iter().filter(|r| pipeline::portable(r)).count(), }) } @@ -176,6 +189,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} are left off (listed under the pack)"), + }, + ) +} + /// 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 +246,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()); } @@ -221,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 = @@ -229,12 +312,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..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(); @@ -327,6 +337,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()); } @@ -345,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 fd6a24b76..9cb834797 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; @@ -31,6 +32,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 +44,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,21 +60,32 @@ 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, } 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), @@ -90,6 +104,7 @@ impl Mods { updates: HashMap::new(), confirm_remove: None, checks: HashMap::new(), + browse: Default::default(), tx, rx, } @@ -99,14 +114,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; @@ -138,10 +153,28 @@ 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; 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 +217,33 @@ impl Launcher { }; m.checks.insert(key, state); } + 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); + } 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 +255,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 +282,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 +329,20 @@ 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(); + // 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); let progress = |line: &str, f: f32| { @@ -304,10 +366,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(); @@ -355,16 +417,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 { @@ -423,12 +486,15 @@ 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| { 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(); @@ -455,29 +521,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() @@ -488,19 +532,44 @@ impl Launcher { .flex_wrap() .items_center() .gap(px(12.)) - .child(kinds) .child(self.mods_field(cx)) .child(toggle), ) - .child(source_note); - let mut page = div() - .flex() - .flex_col() - .gap(px(32.)) - .child(header) - .child(controls); + .child(source_note) + .child(self.mods_filters(cx)); + // 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) + .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 body = body.children(self.mods_busy_plate()); + if self.mods.kind == PackKind::Modpack { + 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) { + body = body.child(list); + } + page(body.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 +586,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
{ @@ -551,7 +612,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.)); @@ -650,8 +711,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, 56.)).collect(); let m = &self.mods; if let Some(e) = &m.error { return div().child(notice(e.clone(), &t)); @@ -667,7 +730,8 @@ impl Launcher { return centered(line, &t); } let busy = m.busy.as_ref().map(|b| b.0.as_str()); - let mut list = div().flex().flex_col().border_t_1().border_color(t.rule); + let mut icons = icons.into_iter(); + 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 @@ -678,14 +742,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( @@ -746,9 +810,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, @@ -772,6 +849,24 @@ 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 new file mode 100644 index 000000000..c66cf174a --- /dev/null +++ b/launcher/src/mods_project.rs @@ -0,0 +1,596 @@ +//! 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)); + 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. + 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. + 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)) + } + + /// 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.warn, + 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(); + 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)) + } +} 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..b1e7eb775 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, @@ -272,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 { 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}"); } 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): 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()) 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).