From 1fdb119587b9cec955f8c925de403ceb85c1cc2a Mon Sep 17 00:00:00 2001 From: Ludovic Marie <136255825+ludovic111@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:30:04 +0100 Subject: [PATCH] Server menus keep their styled titles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A server's open_screen title is a text component, and container screens draw it as AbstractContainerScreen does: in the label grey, under the component's own colours, bold, italics and fonts, with the § codes a literal holds. Horizon flattened it to a plain string, so a plugin's gold and bold "Server Selector" menu showed in plain grey (#1645). Chests, hoppers, dispensers, droppers, brewing stands and the other workstations now draw the server's title. Before, the workstations ignored it and showed their default name. horizon-ctl's `screen` reports the title span by span (text, colour, bold, italic). server_e2e's new `titles` check names a chest on the vanilla server with a styled CustomName, opens it, and checks the title. Co-Authored-By: Claude Opus 5.5 --- crates/horizon-client/src/game/agent.rs | 12 ++++++ crates/horizon-client/src/game/machines.rs | 11 +++--- crates/horizon-client/src/game/remote.rs | 39 +++++++++---------- .../src/game/remote_stations.rs | 2 +- crates/horizon-client/src/game/stations.rs | 26 +++++++------ crates/horizon-client/src/game/storage.rs | 8 ++-- tools/server_e2e.py | 25 +++++++++++- 7 files changed, 80 insertions(+), 43 deletions(-) diff --git a/crates/horizon-client/src/game/agent.rs b/crates/horizon-client/src/game/agent.rs index dffa529b9..753f3e096 100644 --- a/crates/horizon-client/src/game/agent.rs +++ b/crates/horizon-client/src/game/agent.rs @@ -684,6 +684,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 8ce1940c9..1a53fcb7d 100644 --- a/crates/horizon-client/src/game/machines.rs +++ b/crates/horizon-client/src/game/machines.rs @@ -905,15 +905,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(title.to_owned())); + let title = &self.remote_menu_title().unwrap_or_else(|| { + horizon_client::text::Text::literal(self.container_title(title.to_owned())) + .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); batch.text( atlas, "Inventory", diff --git a/crates/horizon-client/src/game/remote.rs b/crates/horizon-client/src/game/remote.rs index d9ed5bb12..3b35d5679 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, @@ -4782,14 +4788,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; @@ -4853,14 +4852,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); @@ -4904,11 +4896,11 @@ 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 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 @@ -4917,7 +4909,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 ffe56d699..682f47508 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}; @@ -1164,15 +1165,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, @@ -1199,7 +1200,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( @@ -1211,7 +1215,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 9b28ce26b..cb8b57ee2 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") @@ -187,7 +187,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; } @@ -200,7 +200,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. @@ -247,7 +247,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 397e03549..ce09c519c 100644 --- a/tools/server_e2e.py +++ b/tools/server_e2e.py @@ -14,6 +14,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. @@ -126,7 +128,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):