Chat option: filter the chat's lines both ways, as setVisibleMessageFilter

The chat keeps where each message came from and the Chat option's
abilities. A message the option hides is still dropped on arrival, but a
change of the option now also hides the lines already in the chat that
it no longer shows, and brings them back when it shows them again
(ChatComponent.setVisibleMessageFilter -> refreshTrimmedMessages), on
apply_settings. Every Chat::add names its source. The Restrictions
screen reads its two flags from ChatAbilities instead of its own copy.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ludovic Marie
2026-10-26 00:15:37 +01:00
parent b3807d6e50
commit 1e6f05ed5a
8 changed files with 127 additions and 30 deletions
@@ -319,6 +319,7 @@ fn chat(c: &Value, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
atlas,
comp(&m["text"]),
indicator,
horizon_client::chat::ChatSource::Client,
m["at"].as_u64().unwrap_or(0),
false,
);
+64 -8
View File
@@ -72,6 +72,14 @@ impl ChatAbilities {
},
}
}
/// `visibleMessagesFilter`: the client's own lines always show.
pub fn shows(&self, source: ChatSource) -> bool {
match source {
ChatSource::Player => self.receive_player_messages,
ChatSource::Server => self.receive_system_messages,
ChatSource::Client => true,
}
}
/// Whether the chat screen may send `input` (`hasAllowedInput`).
pub fn allows(&self, input: &str) -> bool {
if input.starts_with('/') {
@@ -82,6 +90,12 @@ impl ChatAbilities {
}
}
impl Default for ChatAbilities {
fn default() -> Self {
Self::new(ChatVisibility::Full)
}
}
/// Where a chat line came from (`GuiMessageSource`): the visibility filters
/// player messages and the server's, while the client's own always show.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
@@ -143,6 +157,7 @@ struct Message {
added: u64,
content: Text,
indicator: Option<u32>,
source: ChatSource,
}
/// A wrapped line (`GuiMessage.Line`).
#[derive(Clone, Debug)]
@@ -177,6 +192,8 @@ pub struct Chat {
pub log: bool,
/// Messages added since the start, for the control socket's events.
pub received: u64,
/// The Chat option's `visibleMessageFilter` (`set_abilities`).
abilities: ChatAbilities,
}
impl Chat {
pub fn lines_per_page(&self, focused: bool) -> usize {
@@ -186,16 +203,32 @@ impl Chat {
fn max_width(&self) -> i32 {
(f64::from(self.options.width_px()) / self.options.scale).floor() as i32
}
/// ChatComponent.addMessage: wrap, prepend and trim to 100 lines.
/// ChatComponent.addMessage: wrap, prepend and trim to 100 lines. A
/// message the Chat option hides is dropped, unlogged.
pub fn add(
&mut self,
atlas: &HudAtlas,
content: Text,
indicator: Option<u32>,
source: ChatSource,
ticks: u64,
focused: bool,
) {
// ChatComponent.logChatMessage: every message is in the log.
self.add_message(&atlas.font, content, indicator, source, ticks, focused);
}
fn add_message(
&mut self,
font: &crate::font::Font,
content: Text,
indicator: Option<u32>,
source: ChatSource,
ticks: u64,
focused: bool,
) {
if !self.abilities.shows(source) {
return;
}
// ChatComponent.logChatMessage: every message shown is in the log.
crate::logging::write_only(log::Level::Info, &format!("[CHAT] {}", content.plain()));
if self.log {
println!("[CHAT] {}", content.plain());
@@ -204,16 +237,17 @@ impl Chat {
added: ticks,
content,
indicator,
source,
};
self.add_lines(atlas, &message, focused);
self.add_lines(font, &message, focused);
self.messages.insert(0, message);
self.messages.truncate(100);
self.received += 1;
}
fn add_lines(&mut self, atlas: &HudAtlas, message: &Message, focused: bool) {
fn add_lines(&mut self, font: &crate::font::Font, message: &Message, focused: bool) {
let width = self.max_width();
let content = self.displayed(&message.content);
for line in atlas.font.wrap_components(&content, width) {
for line in font.wrap_components(&content, width) {
if focused && self.scroll > 0 {
self.new_since_scroll = true;
self.scroll_by(1, focused);
@@ -239,16 +273,38 @@ impl Chat {
content.strip_legacy()
}
}
/// Re-wrap after a width or scale change (ChatComponent.rescaleChat).
/// Re-wrap after a width or scale change (ChatComponent.rescaleChat),
/// keeping only the messages the Chat option shows.
pub fn rescale(&mut self, atlas: &HudAtlas) {
self.rewrap(&atlas.font);
}
fn rewrap(&mut self, font: &crate::font::Font) {
self.scroll = 0;
self.new_since_scroll = false;
self.lines.clear();
let messages: Vec<Message> = self.messages.iter().rev().cloned().collect();
let messages: Vec<Message> = self
.messages
.iter()
.rev()
.filter(|m| self.abilities.shows(m.source))
.cloned()
.collect();
for m in &messages {
self.add_lines(atlas, m, false);
self.add_lines(font, m, false);
}
}
/// `ChatComponent.setVisibleMessageFilter`: the Chat option changed;
/// the messages it now hides leave the chat, those it shows again return.
pub fn set_abilities(&mut self, font: &crate::font::Font, abilities: ChatAbilities) {
if self.abilities != abilities {
self.abilities = abilities;
self.rewrap(font);
}
}
/// Whether a message from `source` would show (`visibleMessagesFilter`).
pub fn shows(&self, source: ChatSource) -> bool {
self.abilities.shows(source)
}
pub fn clear(&mut self) {
self.messages.clear();
self.lines.clear();
+24
View File
@@ -412,3 +412,27 @@ fn restricted_input_says_why_it_will_not_send() {
.any(|line| line == "chat_screen.commands_not_allowed")
);
}
/// `ChatComponent.setVisibleMessageFilter`: a message the Chat option hides
/// on arrival is dropped; one already in the chat leaves it while the option
/// hides its kind and comes back when it shows it again.
#[test]
fn the_chat_option_filters_the_lines_both_ways() {
let font = font();
let mut chat = Chat::default();
let says = |chat: &mut Chat, text: &str, source| {
chat.add_message(&font, Text::literal(text), None, source, 0, false);
};
let shown =
|chat: &Chat| -> Vec<String> { chat.lines.iter().map(|l| l.content.plain()).collect() };
says(&mut chat, "player", ChatSource::Player);
says(&mut chat, "server", ChatSource::Server);
chat.set_abilities(&font, ChatAbilities::new(ChatVisibility::System));
assert_eq!(shown(&chat), ["server"]);
says(&mut chat, "dropped", ChatSource::Player);
chat.set_abilities(&font, ChatAbilities::new(ChatVisibility::Hidden));
says(&mut chat, "client", ChatSource::Client);
assert_eq!(shown(&chat), ["client"]);
chat.set_abilities(&font, ChatAbilities::new(ChatVisibility::Full));
assert_eq!(shown(&chat), ["client", "server", "player"]);
}
+3
View File
@@ -9136,6 +9136,9 @@ impl Game<'_> {
)
}
fn apply_settings(&mut self) {
// LocalPlayer.refreshChatAbilities: the chat shows what the Chat option allows.
let abilities = self.chat_abilities();
self.ui.chat.set_abilities(&self.gui.atlas.font, abilities);
if let Some(r) = &self.renderer {
self.settings
.clamp_gui_scale([r.config.width as i32, r.config.height as i32]);
+19 -9
View File
@@ -463,9 +463,8 @@ impl Game<'_> {
self.chat_line(text, ChatSource::Server);
return;
}
// ChatListener.handleSystemMessage: the Chat option's Hidden level
// takes the server's messages too.
if !self.chat_abilities().receive_system_messages {
// The Chat option's Hidden level takes the server's messages too.
if !self.ui.chat.shows(ChatSource::Server) {
return;
}
let focused = self.screen == Screen::Chat;
@@ -474,6 +473,7 @@ impl Game<'_> {
&self.gui.atlas,
text,
Some(SYSTEM_INDICATOR),
ChatSource::Server,
self.ticks,
focused,
);
@@ -610,9 +610,14 @@ impl Game<'_> {
);
let focused = self.screen == Screen::Chat;
let heard = self.mods.running().then(|| line.plain());
self.ui
.chat
.add(&self.gui.atlas, line, None, self.ticks, focused);
self.ui.chat.add(
&self.gui.atlas,
line,
None,
ChatSource::Player,
self.ticks,
focused,
);
if let Some(line) = heard {
self.mods_chat(&line);
}
@@ -916,9 +921,14 @@ impl Game<'_> {
Err(e) => Text::translate("screenshot.failure", &[Text::literal(e.clone())]),
};
let focused = self.screen == Screen::Chat;
self.ui
.chat
.add(&self.gui.atlas, message, None, self.ticks, focused);
self.ui.chat.add(
&self.gui.atlas,
message,
None,
ChatSource::Client,
self.ticks,
focused,
);
}
}
/// `yyyy-MM-dd_HH.mm.ss[_n].png` (Util.getFilenameFormattedDateTime, local time
+8 -3
View File
@@ -2836,9 +2836,14 @@ impl Game<'_> {
capture.push(line.clone());
}
let focused = self.screen == Screen::Chat;
self.ui
.chat
.add(&self.gui.atlas, line, None, self.ticks, focused);
self.ui.chat.add(
&self.gui.atlas,
line,
None,
horizon_client::chat::ChatSource::Player,
self.ticks,
focused,
);
Ok(1)
}
/// MessageArgument: `@p`-style selectors in a message become names.
+4 -1
View File
@@ -388,7 +388,10 @@ impl Restrictions {
1 => Some("chat_restriction.chat_disabled_by_options"),
_ => None,
};
let (chat, commands) = (chat_visibility == 0, chat_visibility < 2);
let abilities = horizon_client::chat::ChatAbilities::new(
horizon_client::chat::ChatVisibility::from_option(chat_visibility),
);
let (chat, commands) = (abilities.send_messages, abilities.send_commands);
// The body's widgets: text blocks (lines, top) and the action's top.
let mut blocks = Vec::new();
let mut action = None;
+4 -9
View File
@@ -798,20 +798,14 @@ impl Game<'_> {
Some(stack)
}
/// A line in the connection's chat, with its styles, click and hover
/// events (ChatComponent.addMessage). `ChatListener` drops the lines the
/// Chat option hides before they reach the chat.
/// events (ChatComponent.addMessage). The lines the Chat option hides
/// are dropped there (its `visibleMessageFilter`).
pub(super) fn chat_line(
&mut self,
content: horizon_client::text::Text,
source: horizon_client::chat::ChatSource,
) {
let abilities = self.chat_abilities();
let allowed = match source {
horizon_client::chat::ChatSource::Player => abilities.receive_player_messages,
horizon_client::chat::ChatSource::Server => abilities.receive_system_messages,
horizon_client::chat::ChatSource::Client => true,
};
if !allowed {
if !self.ui.chat.shows(source) {
return;
}
let text = content.plain();
@@ -820,6 +814,7 @@ impl Game<'_> {
&self.gui.atlas,
content,
None,
source,
self.ticks,
self.screen == Screen::Chat,
);