diff --git a/pomme-client/src/app/core.rs b/pomme-client/src/app/core.rs index 89e91ed2..c97c726c 100644 --- a/pomme-client/src/app/core.rs +++ b/pomme-client/src/app/core.rs @@ -879,8 +879,8 @@ pub struct AppCore { /// A packed glyph's pixels changed (a head arrived), so the atlas needs /// rebuilding even though its key set is the same. inline_atlas_dirty: bool, - /// Whether the spectator menu's faces are in the atlas as packed. - spectator_faces_packed: bool, + /// Whether player faces are in the atlas as packed. + player_faces_packed: bool, /// The inline-object keys currently packed. game_dynamic_atlas_keys: HashSet, } @@ -1033,7 +1033,7 @@ impl AppCore { head_tx, head_rx, inline_atlas_dirty: false, - spectator_faces_packed: false, + player_faces_packed: false, game_dynamic_atlas_keys: HashSet::new(), } } @@ -1292,13 +1292,14 @@ impl AppCore { } /// Loads what the text drew last frame into the shared glyph atlas and - /// rebuilds it when its contents change. Spectator faces share the same - /// texture binding, so they are packed alongside. + /// rebuilds it when its contents change. Player faces (spectator menu, + /// tab list) share the same texture binding, so they are packed alongside + /// while `faces_shown`. pub fn sync_game_dynamic_atlas( &mut self, game: &GameState, renderer: &mut Renderer, - spectator_active: bool, + faces_shown: bool, ) { let now = Instant::now(); self.drain_head_results(); @@ -1360,11 +1361,11 @@ impl AppCore { .inline_objects .iter() .any(|(key, entry)| packable(entry) && !self.game_dynamic_atlas_keys.contains(key)) - || spectator_active != self.spectator_faces_packed - || (spectator_active && self.player_faces_dirty); + || faces_shown != self.player_faces_packed + || (faces_shown && self.player_faces_dirty); if rebuild { let mut entries = Vec::<(String, Vec, u32)>::new(); - if spectator_active { + if faces_shown { entries.extend( self.player_faces .iter() @@ -1410,8 +1411,8 @@ impl AppCore { renderer.update_face_atlas(&entries); } self.inline_atlas_dirty = false; - self.spectator_faces_packed = spectator_active; - if spectator_active { + self.player_faces_packed = faces_shown; + if faces_shown { self.player_faces_dirty = false; } } @@ -2826,6 +2827,7 @@ impl AppCore { game.recipe_book.reset_for_login(); game.player.entity_id = entity_id; game.hardcore = hardcore; + game.online_mode = online_mode; game.show_death_screen = show_death_screen; // The fresh LocalPlayer starts from supplier defaults. game.player.attributes = AttributeMap::player(); diff --git a/pomme-client/src/app/phases/in_game.rs b/pomme-client/src/app/phases/in_game.rs index 593ead39..5d019547 100644 --- a/pomme-client/src/app/phases/in_game.rs +++ b/pomme-client/src/app/phases/in_game.rs @@ -147,6 +147,8 @@ pub struct GameState { pub death_screen_open: bool, pub show_death_screen: bool, pub hardcore: bool, + /// The login packet's `onlineMode`: the tab list shows player heads. + pub online_mode: bool, pub death_message: String, pub death_screen_ticks: u32, pub death_confirm: bool, @@ -448,6 +450,7 @@ impl GameState { death_screen_open: false, show_death_screen: true, hardcore: false, + online_mode: false, death_message: String::new(), death_screen_ticks: 0, death_confirm: false, @@ -2542,11 +2545,9 @@ pub fn update_game( core.input.text_capture = game.wants_text_input() || game.chat.is_open(); core.input.menu_capture = game.gui_open() || game.death_screen_open; core.input.spectator = crate::player::is_spectator(game.player.game_mode); - core.sync_game_dynamic_atlas( - game, - &mut gfx.renderer, - core.input.spectator && game.spectator.is_menu_active(), - ); + let faces_shown = (core.input.spectator && game.spectator.is_menu_active()) + || (game.online_mode && core.input.performing_action(input::Action::ViewPlayerList)); + core.sync_game_dynamic_atlas(game, &mut gfx.renderer, faces_shown); // The F3+F4 switcher shows the mouse cursor while open. let switcher_open = game.game_mode_switcher.is_some(); @@ -2934,8 +2935,8 @@ pub fn update_game( sw, &game.tab_list, &game.scoreboard, + game.online_mode, gs, - &|t, s| r.menu_text_width(t, s), &|spans, s| r.menu_spans_width(spans, s), ); } diff --git a/pomme-client/src/net/scoreboard_wire.rs b/pomme-client/src/net/scoreboard_wire.rs index eb2614cd..d24ac273 100644 --- a/pomme-client/src/net/scoreboard_wire.rs +++ b/pomme-client/src/net/scoreboard_wire.rs @@ -142,7 +142,7 @@ fn styled_color(nbt: &[u8]) -> Option<[f32; 4]> { /// out-of-range ids fall back to BLACK (`ByIdMap` ZERO). fn team_color(id: u32) -> u32 { char::from_digit(id, 16) - .and_then(crate::ui::chat::legacy_rgb) + .and_then(crate::ui::text::legacy_rgb) .unwrap_or(0x000000) } diff --git a/pomme-client/src/player/tab_list.rs b/pomme-client/src/player/tab_list.rs index 658c610e..f466d4be 100644 --- a/pomme-client/src/player/tab_list.rs +++ b/pomme-client/src/player/tab_list.rs @@ -23,7 +23,6 @@ pub struct PlayerInfoEntry { #[derive(Clone, Debug)] pub struct TabListPlayer { - #[allow(dead_code)] pub uuid: Uuid, pub name: String, pub textures: Option, diff --git a/pomme-client/src/renderer/pipelines/menu_overlay.rs b/pomme-client/src/renderer/pipelines/menu_overlay.rs index 3e349d39..c38178fb 100644 --- a/pomme-client/src/renderer/pipelines/menu_overlay.rs +++ b/pomme-client/src/renderer/pipelines/menu_overlay.rs @@ -5,7 +5,7 @@ use std::time::{SystemTime, UNIX_EPOCH}; use crate::assets::{AssetIndex, resolve_asset_path}; use crate::renderer::{MAX_FRAMES_IN_FLIGHT, packing, rhi, shader, util}; use crate::ui::font::{FontSources, GLYPH_ATLAS_SIZE, GlyphAtlasPixels, GlyphInfo, GlyphMap}; -use crate::ui::text::{InlineObject, TextSpan}; +use crate::ui::text::{InlineObject, PREFIX_CODE, TextSpan, font_spans}; const FONT_BYTES: &[u8] = include_bytes!("../fonts/Montserrat-Medium.ttf"); const ICON_FONT_BYTES: &[u8] = include_bytes!("../fonts/fa-solid-900.ttf"); @@ -1001,6 +1001,11 @@ impl MenuGeometry { let Some(ref gm) = self.mc_glyph_map else { return plain_text_width(&self.atlas, text, scale); }; + if text.contains(PREFIX_CODE) { + let mut span = TextSpan::new(text.to_owned(), [1.0; 4]); + span.font = font.map(str::to_owned); + return self.spans_width(&[span], scale); + } (run_advance(gm, text, font, false, false) * scale / gm.cell_h as f32).ceil() } @@ -1010,7 +1015,7 @@ impl MenuGeometry { let Some(ref gm) = self.mc_glyph_map else { return 0.0; }; - let raw: f32 = spans + let raw: f32 = font_spans(spans) .iter() .map(|s| { run_advance( @@ -1449,6 +1454,7 @@ impl MenuGeometry { draw: McTextDraw, obfuscation_rng: &mut ObfuscationRng, ) { + let spans = &*font_spans(spans); if let Some(gm) = &self.mc_glyph_map { let sources = McTextSources { gm, diff --git a/pomme-client/src/ui/chat.rs b/pomme-client/src/ui/chat.rs index 9ced6a98..c09d79f6 100644 --- a/pomme-client/src/ui/chat.rs +++ b/pomme-client/src/ui/chat.rs @@ -23,7 +23,8 @@ use crate::net::sender::ChatMark; use crate::renderer::pipelines::menu_overlay::{MenuElement, SpriteId, TooltipLine}; use crate::ui::text::{ TextSpan, format_component_spans, format_component_spans_with_bindings, - format_component_spans_with_parent_and_bindings, parse_uuid_value, with_alpha, + format_component_spans_with_parent_and_bindings, legacy_format_spans, parse_uuid_value, + with_alpha, }; use crate::ui::text_edit::{ SystemClipboard, TextFieldState, TextInputEvent, truncate_to_utf16, utf16_len, @@ -3146,93 +3147,6 @@ fn slice_spans(spans: &[TextSpan], start: usize, end: usize) -> Vec { out } -fn legacy_color(code: char) -> Option<[f32; 4]> { - legacy_rgb(code).map(common::rgb) -} - -/// The color of legacy formatting code `code` (`ChatFormatting`'s `0`-`f`). -pub(crate) fn legacy_rgb(code: char) -> Option { - Some(match code.to_ascii_lowercase() { - '0' => 0x000000, - '1' => 0x0000aa, - '2' => 0x00aa00, - '3' => 0x00aaaa, - '4' => 0xaa0000, - '5' => 0xaa00aa, - '6' => 0xffaa00, - '7' => 0xaaaaaa, - '8' => 0x555555, - '9' => 0x5555ff, - 'a' => 0x55ff55, - 'b' => 0x55ffff, - 'c' => 0xff5555, - 'd' => 0xff55ff, - 'e' => 0xffff55, - 'f' => 0xffffff, - _ => return None, - }) -} - -/// Vanilla `StringDecomposer.iterateFormatted` over each span: legacy codes -/// never carry into the next span, whose own style is both the current and -/// the reset style. -fn legacy_format_spans(spans: &[TextSpan], colors_enabled: bool) -> Vec { - let mut out = Vec::new(); - for base in spans { - let mut current = base.with_text(String::new()); - let mut buffer = String::new(); - let flush = |out: &mut Vec, current: &TextSpan, buffer: &mut String| { - if !buffer.is_empty() { - out.push(current.with_text(std::mem::take(buffer))); - } - }; - - let mut chars = base.text.chars(); - while let Some(ch) = chars.next() { - if ch != '\u{00a7}' { - buffer.push(ch); - continue; - } - let Some(code) = chars.next() else { - // A trailing section sign ends the iteration, unemitted. - break; - }; - let lower = code.to_ascii_lowercase(); - let recognized = - legacy_color(lower).is_some() || matches!(lower, 'k' | 'l' | 'm' | 'n' | 'o' | 'r'); - if !recognized { - // StringDecomposer consumes even an unknown code pair. - continue; - } - flush(&mut out, ¤t, &mut buffer); - if !colors_enabled { - // Chat Colors off strips the code but keeps the base style. - continue; - } - if let Some(color) = legacy_color(lower) { - current.color = color; - current.bold = false; - current.italic = false; - current.strikethrough = false; - current.underline = false; - current.obfuscated = false; - continue; - } - match lower { - 'k' => current.obfuscated = true, - 'l' => current.bold = true, - 'm' => current.strikethrough = true, - 'n' => current.underline = true, - 'o' => current.italic = true, - 'r' => current = base.with_text(String::new()), - _ => unreachable!(), - } - } - flush(&mut out, ¤t, &mut buffer); - } - out -} - /// Word-wraps styled spans to `max_w` gui-space units like vanilla /// `Font.split`: `width0` measures styled runs at gui-scale 1, so fonts, bold /// and inline objects count as in `StringSplitter`. Returns one @@ -4771,32 +4685,6 @@ mod tests { assert_eq!(options.render_width_px(), 458.0); } - #[test] - fn legacy_formatting_matches_vanilla_segment_reset_rules() { - let white = common::WHITE; - let spans = legacy_format_spans(&[span("a§cb§lc§rd", white)], true); - assert_eq!(line_text(&spans), "abcd"); - assert_eq!(spans.len(), 4); - assert_eq!(spans[0].color, white); - assert_eq!(spans[1].color, common::rgb(0xff5555)); - assert!(!spans[1].bold); - assert_eq!(spans[2].color, common::rgb(0xff5555)); - assert!(spans[2].bold); - assert_eq!(spans[3].color, white); - assert!(!spans[3].bold); - } - - #[test] - fn chat_colors_off_strips_only_legacy_codes() { - let mut base = span("a§cb§lc§rd", common::rgb(0x55ffff)); - base.italic = true; - let spans = legacy_format_spans(&[base], false); - assert_eq!(line_text(&spans), "abcd"); - assert!(spans.iter().all(|span| span.color == common::rgb(0x55ffff))); - assert!(spans.iter().all(|span| span.italic)); - assert!(spans.iter().all(|span| !span.bold)); - } - #[test] fn visibility_matches_vanilla_chat_abilities() { let mut chat = ChatState::new(); diff --git a/pomme-client/src/ui/hud.rs b/pomme-client/src/ui/hud.rs index 31824b59..5b87666d 100644 --- a/pomme-client/src/ui/hud.rs +++ b/pomme-client/src/ui/hud.rs @@ -4,6 +4,7 @@ use azalea_inventory::ItemStack; use glam::DVec3; use super::common::{FONT_SIZE, TextWidthFn, WHITE, push_item_icon}; +use crate::entity::wrap_degrees; use crate::mob_effect::ActiveMobEffects; use crate::position::BlockPos; use crate::renderer::pipelines::menu_overlay::{MenuElement, SpriteId}; @@ -1620,9 +1621,6 @@ pub fn build_debug_overlay( let bz = pos.z.floor() as i32; let cx = bx.div_euclid(16); let cz = bz.div_euclid(16); - let facing = facing_name(info.y_rot_deg); - let y_rot_deg = info.y_rot_deg; - let x_rot_deg = info.x_rot_deg; let mut left_lines: Vec = vec![ format!("Pomme ({}fps)", info.fps), @@ -1636,7 +1634,7 @@ pub fn build_debug_overlay( cx, cz ), - format!("Facing: {} ({:.1} / {:.1})", facing, y_rot_deg, x_rot_deg), + facing_line(info.y_rot_deg, info.x_rot_deg), String::new(), format!("Chunks: {} loaded", info.chunk_count), format!( @@ -1724,14 +1722,24 @@ fn push_debug_lines( } } -fn facing_name(y_rot_deg: f32) -> &'static str { - let deg = y_rot_deg.rem_euclid(360.0) as u32; - match deg { - 315..=359 | 0..=44 => "South (+Z)", - 45..=134 => "West (-X)", - 135..=224 => "North (-Z)", - 225..=314 => "East (+X)", - _ => "South (+Z)", +/// Vanilla `DebugEntryPosition`: the direction, which way it points, and +/// both angles wrapped to [-180, 180). +fn facing_line(y_rot_deg: f32, x_rot_deg: f32) -> String { + let (direction, towards) = facing(y_rot_deg); + format!( + "Facing: {direction} ({towards}) ({:.1} / {:.1})", + wrap_degrees(y_rot_deg), + wrap_degrees(x_rot_deg) + ) +} + +/// Vanilla `Direction.fromYRot`. +fn facing(y_rot_deg: f32) -> (&'static str, &'static str) { + match ((y_rot_deg / 90.0 + 0.5).floor() as i32) & 3 { + 0 => ("south", "Towards positive Z"), + 1 => ("west", "Towards negative X"), + 2 => ("north", "Towards negative Z"), + _ => ("east", "Towards positive X"), } } @@ -1739,6 +1747,20 @@ fn facing_name(y_rot_deg: f32) -> &'static str { mod tests { use super::*; + #[test] + fn facing_angles_wrap_like_vanilla() { + assert_eq!( + facing_line(400.0, 0.0), + "Facing: south (Towards positive Z) (40.0 / 0.0)" + ); + assert_eq!( + facing_line(-190.0, 90.0), + "Facing: north (Towards negative Z) (170.0 / 90.0)" + ); + assert_eq!(facing(44.9).0, "south"); + assert_eq!(facing(45.0).0, "west"); + } + #[test] fn offhand_slot_sits_on_the_offhand_arms_side() { let cx = 400.0; diff --git a/pomme-client/src/ui/player_tab.rs b/pomme-client/src/ui/player_tab.rs index e35e6965..2ae05b9e 100644 --- a/pomme-client/src/ui/player_tab.rs +++ b/pomme-client/src/ui/player_tab.rs @@ -4,9 +4,10 @@ use crate::entity::EntityStore; use crate::player::tab_list::{TabList, TabListPlayer}; use crate::renderer::pipelines::menu_overlay::{MenuElement, SpriteId}; +use crate::ui::chat::wrap_spans; use crate::ui::common::{FONT_SIZE, LINE_HEIGHT}; use crate::ui::hud::Scoreboard; -use crate::ui::text::{TextSpan, span_text}; +use crate::ui::text::{TextSpan, font_spans}; const MAX_ROWS_PER_COL: usize = 20; const HEAD_COL_W: i32 = 9; @@ -14,17 +15,16 @@ const SPECTATOR_GAME_MODE: u8 = 3; const BG_BACKDROP: [f32; 4] = [0.0, 0.0, 0.0, 0.5]; // Integer.MIN_VALUE (0x80000000) const BG_ROW: [f32; 4] = [1.0, 1.0, 1.0, 0x20 as f32 / 255.0]; // 0x20FFFFFF -const COL_NAME: [f32; 4] = [1.0, 1.0, 1.0, 1.0]; -/// `text_width(text, FONT_SIZE)` must return the width in pixels at 1x GUI +/// `spans_width(spans, FONT_SIZE)` must return the width in pixels at 1x GUI /// scale. pub fn build_player_tab_overlay( elements: &mut Vec, screen_w: f32, tab_list: &TabList, scoreboard: &Scoreboard, + show_heads: bool, gs: f32, - text_width: &dyn Fn(&str, f32) -> f32, spans_width: crate::ui::common::SpansWidthFn<'_>, ) { let players = tab_list.sorted_listed(scoreboard); @@ -33,7 +33,6 @@ pub fn build_player_tab_overlay( } let gw = (screen_w / gs).floor(); - let font_w = |s: &str| text_width(s, FONT_SIZE); let display_names: Vec> = players .iter() @@ -54,28 +53,21 @@ pub fn build_player_tab_overlay( } let cols_i = cols as i32; - let slot_width = - ((cols_i * (HEAD_COL_W + max_name_w + 13)).min(gw as i32 - 50) / cols_i).max(1); + let head_w = if show_heads { HEAD_COL_W } else { 0 }; + let slot_width = ((cols_i * (head_w + max_name_w + 13)).min(gw as i32 - 50) / cols_i).max(1); let grid_w = slot_width * cols_i + (cols_i - 1) * 5; let xxo = (gw as i32) / 2 - grid_w / 2; let cx = (gw as i32) / 2; - let wrap_width_px = (gw - 50.0).max(1.0); - // TODO: wrapping flattens the header/footer to plain text, dropping - // their formatting; wrap the spans themselves to keep it. - let header_lines = tab_list - .header - .as_ref() - .map(|h| wrap_text(&span_text(h), wrap_width_px, &font_w)); - let footer_lines = tab_list - .footer - .as_ref() - .map(|f| wrap_text(&span_text(f), wrap_width_px, &font_w)); + let width0 = |spans: &[TextSpan]| spans_width(spans, FONT_SIZE); + let wrap = |text: &Vec| wrap_lines(text, gw - 50.0, &width0); + let header_lines = tab_list.header.as_ref().map(wrap); + let footer_lines = tab_list.footer.as_ref().map(wrap); let mut max_line_w = grid_w; for lines in [&header_lines, &footer_lines].into_iter().flatten() { for l in lines { - max_line_w = max_line_w.max(font_w(l).ceil() as i32); + max_line_w = max_line_w.max(width0(l).ceil() as i32); } } @@ -90,17 +82,6 @@ pub fn build_player_tab_overlay( color, }); }; - let push_text = - |elements: &mut Vec, s: String, x: i32, y: i32, color: [f32; 4]| { - elements.push(MenuElement::Text { - x: x as f32 * gs, - y: y as f32 * gs, - text: s, - scale: FONT_SIZE * gs, - color, - centered: false, - }); - }; let push_spans = |elements: &mut Vec, spans: Vec, x: i32, y: i32| { elements.push(MenuElement::TextSpans { x: x as f32 * gs, @@ -122,7 +103,7 @@ pub fn build_player_tab_overlay( }); }; - let draw_text_block = |elements: &mut Vec, lines: &[String], top: i32| { + let draw_text_block = |elements: &mut Vec, lines: &[Vec], top: i32| { push_fill( elements, cx - max_line_w / 2 - 1, @@ -132,13 +113,12 @@ pub fn build_player_tab_overlay( BG_BACKDROP, ); for (i, line) in lines.iter().enumerate() { - let lw = font_w(line).ceil() as i32; - push_text( + let lw = width0(line).ceil() as i32; + push_spans( elements, line.clone(), cx - lw / 2, top + i as i32 * LINE_HEIGHT, - COL_NAME, ); } }; @@ -172,9 +152,18 @@ pub fn build_player_tab_overlay( yo + 8, BG_ROW, ); - push_image(elements, row_left, yo, 8, 8, SpriteId::SteveHead); - let info = players[i]; + if show_heads { + // TODO: vanilla hides the hat layer per `showHat` and flips the + // face for Dinnerbone/Grumm; faces here always carry the hat. + elements.push(MenuElement::SkinFace { + x: row_left as f32 * gs, + y: yo as f32 * gs, + size: 8.0 * gs, + uuid: info.uuid.to_string(), + tint: [1.0; 4], + }); + } let mut spans = display_names[i].clone(); if info.game_mode == SPECTATOR_GAME_MODE { // Vanilla `decorateName`: spectators are italicized, nothing else. @@ -182,7 +171,7 @@ pub fn build_player_tab_overlay( span.italic = true; } } - push_spans(elements, spans, row_left + HEAD_COL_W, yo); + push_spans(elements, spans, row_left + head_w, yo); push_image( elements, row_left + slot_width - 11, @@ -199,6 +188,16 @@ pub fn build_player_tab_overlay( } } +/// Vanilla `Font.split` on the header or footer: it splits the decomposed +/// text, so a legacy code is never parted from its letter. +fn wrap_lines( + text: &[TextSpan], + max_w: f32, + width0: &dyn Fn(&[TextSpan]) -> f32, +) -> Vec> { + wrap_spans(&font_spans(text), max_w, width0) +} + fn display_name_for(p: &TabListPlayer, scoreboard: &Scoreboard) -> Vec { scoreboard.player_name(&p.name, p.display_name.as_deref()) } @@ -270,24 +269,26 @@ fn ping_sprite(latency: i32) -> SpriteId { } } -fn wrap_text(text: &str, max_width: f32, font_w: &dyn Fn(&str) -> f32) -> Vec { - let mut lines = Vec::new(); - for raw_line in text.split('\n') { - let mut current = String::new(); - for word in raw_line.split(' ') { - if current.is_empty() { - current.push_str(word); - continue; - } - if font_w(&format!("{current} {word}")) <= max_width { - current.push(' '); - current.push_str(word); - } else { - lines.push(std::mem::take(&mut current)); - current.push_str(word); - } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn header_lines_wrap_the_decoded_text() { + let width = |spans: &[TextSpan]| { + spans + .iter() + .map(|span| span.text.chars().count()) + .sum::() as f32 + * 6.0 + }; + let header = [TextSpan::new("\u{00a7}caaaa bbbb".to_owned(), [1.0; 4])]; + let lines = wrap_lines(&header, 30.0, &width); + let red = crate::ui::common::rgb(0xff5555); + assert_eq!(lines.len(), 2); + for (line, text) in lines.iter().zip(["aaaa", "bbbb"]) { + assert_eq!(crate::ui::text::span_text(line), text); + assert!(line.iter().all(|span| span.color == red)); } - lines.push(current); } - lines } diff --git a/pomme-client/src/ui/text.rs b/pomme-client/src/ui/text.rs index 86ff2ea3..06913fdd 100644 --- a/pomme-client/src/ui/text.rs +++ b/pomme-client/src/ui/text.rs @@ -1,9 +1,11 @@ +use std::borrow::Cow; use std::cell::RefCell; use std::sync::Arc; use azalea_chat::FormattedText; use azalea_chat::style::Style; +use super::common; use crate::app::input::KeyBindings; use crate::chat_component::{Component, ResolvedStyle}; @@ -421,6 +423,107 @@ fn style_to_rgba(style: &Style, base_color: [f32; 4]) -> [f32; 4] { } } +/// Vanilla `ChatFormatting.PREFIX_CODE`, the section sign that starts a +/// legacy formatting code. +pub(crate) const PREFIX_CODE: char = '\u{00a7}'; + +fn legacy_color(code: char) -> Option<[f32; 4]> { + legacy_rgb(code).map(common::rgb) +} + +/// The color of legacy formatting code `code` (`ChatFormatting`'s `0`-`f`). +pub(crate) fn legacy_rgb(code: char) -> Option { + Some(match code.to_ascii_lowercase() { + '0' => 0x000000, + '1' => 0x0000aa, + '2' => 0x00aa00, + '3' => 0x00aaaa, + '4' => 0xaa0000, + '5' => 0xaa00aa, + '6' => 0xffaa00, + '7' => 0xaaaaaa, + '8' => 0x555555, + '9' => 0x5555ff, + 'a' => 0x55ff55, + 'b' => 0x55ffff, + 'c' => 0xff5555, + 'd' => 0xff55ff, + 'e' => 0xffff55, + 'f' => 0xffffff, + _ => return None, + }) +} + +/// The spans as vanilla's `Font` draws them: legacy codes are decomposed in +/// every string it renders, not only chat. Borrows when there are none. +pub(crate) fn font_spans(spans: &[TextSpan]) -> Cow<'_, [TextSpan]> { + if spans.iter().any(|span| span.text.contains(PREFIX_CODE)) { + Cow::Owned(legacy_format_spans(spans, true)) + } else { + Cow::Borrowed(spans) + } +} + +/// Vanilla `StringDecomposer.iterateFormatted` over each span: legacy codes +/// never carry into the next span, whose own style is both the current and +/// the reset style. +pub(crate) fn legacy_format_spans(spans: &[TextSpan], colors_enabled: bool) -> Vec { + let mut out = Vec::new(); + for base in spans { + let mut current = base.with_text(String::new()); + let mut buffer = String::new(); + let flush = |out: &mut Vec, current: &TextSpan, buffer: &mut String| { + if !buffer.is_empty() { + out.push(current.with_text(std::mem::take(buffer))); + } + }; + + let mut chars = base.text.chars(); + while let Some(ch) = chars.next() { + if ch != PREFIX_CODE { + buffer.push(ch); + continue; + } + let Some(code) = chars.next() else { + // A trailing section sign ends the iteration, unemitted. + break; + }; + let lower = code.to_ascii_lowercase(); + let recognized = + legacy_color(lower).is_some() || matches!(lower, 'k' | 'l' | 'm' | 'n' | 'o' | 'r'); + if !recognized { + // StringDecomposer consumes even an unknown code pair. + continue; + } + flush(&mut out, ¤t, &mut buffer); + if !colors_enabled { + // Chat Colors off strips the code but keeps the base style. + continue; + } + if let Some(color) = legacy_color(lower) { + current.color = color; + current.bold = false; + current.italic = false; + current.strikethrough = false; + current.underline = false; + current.obfuscated = false; + continue; + } + match lower { + 'k' => current.obfuscated = true, + 'l' => current.bold = true, + 'm' => current.strikethrough = true, + 'n' => current.underline = true, + 'o' => current.italic = true, + 'r' => current = base.with_text(String::new()), + _ => unreachable!(), + } + } + flush(&mut out, ¤t, &mut buffer); + } + out +} + #[cfg(test)] mod tests { use serde::Deserialize; @@ -572,4 +675,41 @@ two" assert_eq!(child.click_event, parent.click_event); assert!(matches!(child.hover_event, Some(HoverEvent::Text(_)))); } + + #[test] + fn legacy_formatting_matches_vanilla_segment_reset_rules() { + let white = common::WHITE; + let spans = legacy_format_spans(&[TextSpan::new("a§cb§lc§rd".to_owned(), white)], true); + assert_eq!(span_text(&spans), "abcd"); + assert_eq!(spans.len(), 4); + assert_eq!(spans[0].color, white); + assert_eq!(spans[1].color, common::rgb(0xff5555)); + assert!(!spans[1].bold); + assert_eq!(spans[2].color, common::rgb(0xff5555)); + assert!(spans[2].bold); + assert_eq!(spans[3].color, white); + assert!(!spans[3].bold); + } + + #[test] + fn font_spans_consume_unknown_and_trailing_codes() { + let plain = [TextSpan::new("plain".to_owned(), common::WHITE)]; + assert!(matches!(font_spans(&plain), Cow::Borrowed(_))); + let coded = [TextSpan::new( + "a\u{00a7}zb\u{00a7}".to_owned(), + common::WHITE, + )]; + assert_eq!(span_text(&font_spans(&coded)), "ab"); + } + + #[test] + fn chat_colors_off_strips_only_legacy_codes() { + let mut base = TextSpan::new("a§cb§lc§rd".to_owned(), common::rgb(0x55ffff)); + base.italic = true; + let spans = legacy_format_spans(&[base], false); + assert_eq!(span_text(&spans), "abcd"); + assert!(spans.iter().all(|span| span.color == common::rgb(0x55ffff))); + assert!(spans.iter().all(|span| span.italic)); + assert!(spans.iter().all(|span| !span.bold)); + } } diff --git a/tests/ingame/ui/hud.toml b/tests/ingame/ui/hud.toml index 081825fb..94adf3b0 100644 --- a/tests/ingame/ui/hud.toml +++ b/tests/ingame/ui/hud.toml @@ -55,5 +55,15 @@ chat = "/bossbar remove pomme:test" [[step]] expect = { hud = { boss_bars = [] } } +# Vanilla's font decomposes legacy codes in every string, not only chat. +[[step]] +chat = "/title @a title \"\\u00a7cRed \\u00a7lBold\"" + +[[step]] +wait_ms = 500 + +[[step]] +screenshot = "legacy_codes" + [[step]] chat = "/title @a reset"