Merge pull request #1741 from ludovic111/feat/styled-menu-titles

🤖 Agent: server dialogs stack brought up to date with main (merge commits); verify passed on this head.
This commit is contained in:
Ludovic Marie
2026-10-26 04:31:44 +01:00
committed by GitHub
7 changed files with 80 additions and 43 deletions
+12
View File
@@ -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::<Vec<_>>());
}
// 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();
+6 -5
View File
@@ -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
+18 -21
View File
@@ -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<Vec<u8>>, Option<Vec<u8>>);
@@ -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<String>,
/// The open menu's title as the server named it, with its styles.
pub(super) menu_title: Option<horizon_client::text::Text>,
state_id: i32,
use_target: Option<[i32; 3]>,
entities: HashMap<i32, Tracked>,
@@ -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<String> {
pub(super) fn remote_menu_title(&self) -> Option<horizon_client::text::Text> {
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) {
@@ -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() {
+15 -11
View File
@@ -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 {
+4 -4
View File
@@ -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,
+24 -1
View File
@@ -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):