diff --git a/crates/horizon-client/src/game/agent.rs b/crates/horizon-client/src/game/agent.rs index 27e00e391..2686b85fd 100644 --- a/crates/horizon-client/src/game/agent.rs +++ b/crates/horizon-client/src/game/agent.rs @@ -686,6 +686,18 @@ impl Game<'_> { v["craft_size"] = json!(self.craft_size); v["station"] = json!(self.station.as_ref().map(|s| format!("{:?}", s.kind))); v["container"] = json!(self.container); + // The title a server gave the menu, span by span as it is drawn. + if let Some(title) = self.remote_menu_title() { + let spans = title.spans.iter().map(|s| { + json!({ + "text": s.text, + "color": s.style.color.map(|c| format!("#{c:06X}")), + "bold": s.style.is_bold(), + "italic": s.style.is_italic(), + }) + }); + v["title"] = json!(spans.collect::>()); + } // The recipe book's buttons (GUI pixels): the book button, then // the recipe buttons of the page, page arrows, filter and tabs. let book = self.recipe_book_targets(); diff --git a/crates/horizon-client/src/game/machines.rs b/crates/horizon-client/src/game/machines.rs index 6866552c1..8742962e9 100644 --- a/crates/horizon-client/src/game/machines.rs +++ b/crates/horizon-client/src/game/machines.rs @@ -923,15 +923,16 @@ impl Game<'_> { batch.rect_uv(ox, oy, 176.0, height, r, u32::MAX); } let grey = horizon_client::hud::rgba(64, 64, 64, 255); - let title = self - .remote_menu_title() - .unwrap_or_else(|| self.container_title(self.translate(key, fallback))); + let title = &self.remote_menu_title().unwrap_or_else(|| { + horizon_client::text::Text::literal(self.container_title(self.translate(key, fallback))) + .resolve_legacy() + }); let title_x = if screen == Screen::Hopper { 8.0 } else { - (176.0 - horizon_client::hud::HudBatch::text_width(atlas, &title)) / 2.0 + (176.0 - atlas.font.width_text(title)) / 2.0 }; - batch.text(atlas, &title, ox + title_x, oy + 6.0, grey, false); + batch.text_styled(atlas, title, ox + title_x, oy + 6.0, grey, false); let inventory = self.translate("container.inventory", "Inventory"); batch.text(atlas, &inventory, ox + 8.0, oy + height - 94.0, grey, false); true diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index 0856537e2..5c05ad024 100644 --- a/crates/horizon-client/src/game/remote.rs +++ b/crates/horizon-client/src/game/remote.rs @@ -58,6 +58,12 @@ fn remote_text(tag: &Tag) -> horizon_client::text::Text { comp_text(&tag_to_json(tag)) } +/// `ClientboundOpenScreenPacket.title` as a container screen draws it: the +/// component's styles, and the `ยง` codes a literal holds (`StringDecomposer`). +fn menu_title(tag: &Tag) -> horizon_client::text::Text { + remote_text(tag).resolve_legacy() +} + /// A section's sky and block light layers from the wire. type LightLayers = (Option>, Option>); @@ -130,8 +136,8 @@ pub(super) struct Remote { predictions: HashMap<[i32; 3], (i32, i32)>, /// Open container: (window id, menu name). pub(super) menu: Option<(i32, String)>, - /// The open menu's title as the server named it. - pub(super) menu_title: Option, + /// The open menu's title as the server named it, with its styles. + pub(super) menu_title: Option, state_id: i32, use_target: Option<[i32; 3]>, entities: HashMap, @@ -4793,14 +4799,7 @@ impl Game<'_> { return; } if let Some(kind) = Self::remote_station_kind(&menu) { - let title = plain( - &p.title, - &self - .remote - .as_ref() - .map(|r| r.lang.clone()) - .unwrap_or_default(), - ); + let title = menu_title(&p.title); let pos = target.map_or([0, 0, 0], |t| [t.0, t.1, t.2]); self.remote_open_station(p.container, menu, kind, title, pos); return; @@ -4865,14 +4864,7 @@ impl Game<'_> { return; } }; - let title = plain( - &p.title, - &self - .remote - .as_ref() - .map(|r| r.lang.clone()) - .unwrap_or_default(), - ); + let title = menu_title(&p.title); if let Some(r) = self.remote.as_mut() { r.menu = Some((p.container, menu.clone())); r.menu_title = Some(title); @@ -4916,7 +4908,7 @@ impl Game<'_> { Some(self.remote.as_ref()?.menu.as_ref()?.1.as_str()) } /// The title the server gave the menu it opened. - pub(super) fn remote_menu_title(&self) -> Option { + pub(super) fn remote_menu_title(&self) -> Option { self.remote.as_ref()?.menu_title.clone() } /// A language change reaches the server-sent components too: their titles @@ -4929,7 +4921,7 @@ impl Game<'_> { } } /// A server's chest-like menu (`MenuType.GENERIC_9xN`): its rows and title. - pub(super) fn remote_chest_menu(&self) -> Option<(usize, String)> { + pub(super) fn remote_chest_menu(&self) -> Option<(usize, horizon_client::text::Text)> { let r = self.remote.as_ref()?; let rows = r .menu @@ -4938,7 +4930,12 @@ impl Game<'_> { .strip_prefix("minecraft:generic_9x")? .parse() .ok()?; - Some((rows, r.menu_title.clone().unwrap_or_default())) + Some(( + rows, + r.menu_title + .clone() + .unwrap_or_else(horizon_client::text::Text::empty), + )) } /// ServerboundPlayerCommandPacket STOP_SLEEPING. pub(super) fn remote_stop_sleeping(&mut self) { diff --git a/crates/horizon-client/src/game/remote_stations.rs b/crates/horizon-client/src/game/remote_stations.rs index b46725345..5c532f7f5 100644 --- a/crates/horizon-client/src/game/remote_stations.rs +++ b/crates/horizon-client/src/game/remote_stations.rs @@ -18,7 +18,7 @@ impl Game<'_> { container: i32, menu: String, kind: Kind, - title: String, + title: horizon_client::text::Text, pos: [i32; 3], ) { if let Some(r) = self.remote.as_mut() { diff --git a/crates/horizon-client/src/game/stations.rs b/crates/horizon-client/src/game/stations.rs index 171f860f4..f7f05e1c4 100644 --- a/crates/horizon-client/src/game/stations.rs +++ b/crates/horizon-client/src/game/stations.rs @@ -11,6 +11,7 @@ use horizon_client::{ hud::{HudBatch, rgba}, items::ItemStack, stations::{self, AnvilResult, EnchantOffers}, + text::Text, }; use horizon_core::random::{LegacyRandom, RandomSource}; @@ -1160,15 +1161,15 @@ impl Game<'_> { } } let default = self.translate("container.brewing", "Brewing Stand"); - let title = self - .station - .as_ref() - .and_then(|s| self.world.block_names.get(&(s.pos[0], s.pos[1], s.pos[2]))) - .cloned() - .or_else(|| self.remote_menu_title()) - .unwrap_or(default); - let tw = HudBatch::text_width(atlas, &title); - batch.text( + let title = self.remote_menu_title().unwrap_or_else(|| { + let named = self + .station + .as_ref() + .and_then(|s| self.world.block_names.get(&(s.pos[0], s.pos[1], s.pos[2]))); + Text::literal(named.cloned().unwrap_or(default)).resolve_legacy() + }); + let tw = atlas.font.width_text(&title); + batch.text_styled( atlas, &title, ox + (176.0 - tw) / 2.0, @@ -1195,7 +1196,10 @@ impl Game<'_> { } } } else if let Some(kind) = self.station.as_ref().map(|s| s.kind) { - let title = self.translate(kind.title_key(), ""); + // A server's menu has the title it was given. + let title = self + .remote_menu_title() + .unwrap_or_else(|| Text::literal(self.translate(kind.title_key(), ""))); { let atlas = &self.gui.atlas; Self::part( @@ -1207,7 +1211,7 @@ impl Game<'_> { (ox, oy), ); let (tx, ty) = kind.title_pos(); - batch.text(atlas, &title, ox + tx, oy + ty, GREY, false); + batch.text_styled(atlas, &title, ox + tx, oy + ty, GREY, false); batch.text(atlas, &inventory_label, ox + 8.0, oy + 72.0, GREY, false); } if kind == Kind::Cartography { diff --git a/crates/horizon-client/src/game/storage.rs b/crates/horizon-client/src/game/storage.rs index bc9b1a6fb..1bcb6a524 100644 --- a/crates/horizon-client/src/game/storage.rs +++ b/crates/horizon-client/src/game/storage.rs @@ -2,7 +2,7 @@ //! kept in the dropped item's `container` component) and ender chests (the //! player's `PlayerEnderChestContainer`, saved as `EnderItems`). use super::Game; -use horizon_client::{containers::BlockEntity, items::ItemStack}; +use horizon_client::{containers::BlockEntity, items::ItemStack, text::Text}; pub(super) fn is_shulker_box(name: &str) -> bool { name.ends_with("shulker_box") @@ -233,7 +233,7 @@ impl Game<'_> { generic.then(|| self.container_size() / 9) } /// The title of the open chest-like menu. - fn chest_title(&self, rows: usize) -> String { + fn chest_title(&self, rows: usize) -> Text { if let Some((_, title)) = self.remote_chest_menu() { return title; } @@ -246,7 +246,7 @@ impl Game<'_> { _ if rows > 3 => ("container.chestDouble", "Large Chest"), _ => ("container.chest", "Chest"), }; - self.container_title(self.translate(key, fallback)) + Text::literal(self.container_title(self.translate(key, fallback))).resolve_legacy() } /// `ContainerScreen` / `ShulkerBoxScreen`: the background with its /// container rows, the title and the inventory label. @@ -294,7 +294,7 @@ impl Game<'_> { r.u1 = r.u0 + 176.0 * du; batch.rect_uv(ox, oy + top, 176.0, 96.0, r, u32::MAX); } - batch.text( + batch.text_styled( atlas, &self.chest_title(rows), ox + 8.0, diff --git a/tools/server_e2e.py b/tools/server_e2e.py index 590072249..a13b13bfc 100644 --- a/tools/server_e2e.py +++ b/tools/server_e2e.py @@ -15,6 +15,8 @@ Checks: payload (the server's "Received custom click action" line), `wait_for_response` leaves the waiting screen, and `/dialog clear` closes a dialog (servers used to see nothing: the game dropped the packets). +- titles: a chest named with styles (`CustomName`, as a plugin's menu is): its screen's title keeps + the colours and bold (it used to be flattened to plain grey). The game runs with fresh HORIZON_HOME, launcher and XDG folders and without audio. Screenshots and both logs go to --out. @@ -137,7 +139,28 @@ def check_dialog(server, game, out): return shown -CHECKS = {'dialog': check_dialog} +def check_titles(server, game, out): + # AbstractContainerScreen draws the title the server sent, its styles included (#1645). A named + # chest is what a plugin's menu looks like: its CustomName is the open_screen title. + x, y, z = (int(v // 1) for v in game('state')['player']['position']) + chest = (x + 2, y, z) + name = [{'text': 'Server ', 'color': 'gold', 'bold': True}, {'text': 'Selector', 'color': 'aqua'}] + server.console(f'setblock {chest[0]} {chest[1]} {chest[2]} minecraft:chest{{CustomName:{snbt(name)}}}') + game.wait(10) + game.raw('interact', *chest) + game.raw('wait-for', 'screen', 'container', '--timeout', 30) + game.wait(5) + title = game('screen').get('title') + game.shot(out, 'titles') + game.raw('key', 'esc', 'tap') + # A list is its first component with the rest as its children: they inherit its bold. + want = [{'text': 'Server ', 'color': '#FFAA00', 'bold': True, 'italic': False}, + {'text': 'Selector', 'color': '#55FFFF', 'bold': True, 'italic': False}] + assert title == want, title + return {'title': title} + + +CHECKS = {'dialog': check_dialog, 'titles': check_titles} def run(bins, out, checks):