Chat improvements. (#4283)

* UI is an abbreviation, in XAML.

* Chat improvements.

Changing the "selected" channel on the chat box is now only done via the tab cycle or clicking the button.

Prefix chars like [ will temporarily replace the active chat channel. This is based 100% on message box contents so there's no input eating garbage or anything.

Pressing specific channel focusing keys inserts the correct prefix character, potentially replacing an existing one. Existing chat contents are left in place just fine and selected so you can easily delete them (but are not forced to).

Channel focusing keys now match the QWERTY key codes.

Deadchat works now.

Console can no longer be selected as a chat channel, but you can still use it with the / prefix.

Refactored the connection between chat manager and chat box so that it's event based, reducing tons of spaghetti everywhere.

Main chat box control uses XAML now.

General cleanup.

Added focus hotkeys for deadchat/console. Also added prefix for deadchat.

Local chat is mapped to deadchat when a ghost.

Probably more stuff I can't think of right now.

* Add preferred channel system to chat box to automatically select local.

I can't actually test this works because the non-lobby chat box code is complete disastrous spaghetti and i need to refactor it.

* Move chatbox resizing and all that to a subclass.

Refine preferred channel & deadchat mapping code further.

* Don't do prefixes for channels you don't have access to.

* Change format on channel select popup.

* Clean up code with console handling somewhat.
This commit is contained in:
Pieter-Jan Briers
2021-07-20 10:29:09 +02:00
committed by GitHub
parent 4f08bc5243
commit b96d760043
22 changed files with 1300 additions and 1278 deletions

View File

@@ -48,7 +48,7 @@ namespace Content.Client.Alerts.UI
base.EnteredTree();
var _chatManager = IoCManager.Resolve<IChatManager>();
_chatManager.OnChatBoxResized += OnChatResized;
OnChatResized(new ChatResizedEventArgs(ChatBox.InitialChatBottom));
OnChatResized(new ChatResizedEventArgs(HudChatBox.InitialChatBottom));
}
protected override void ExitedTree()

View File

@@ -12,7 +12,7 @@ namespace Content.Client.Chat
ChatChannel.Radio => Color.Green,
ChatChannel.OOC => Color.LightSkyBlue,
ChatChannel.Dead => Color.MediumPurple,
ChatChannel.AdminChat => Color.Red,
ChatChannel.Admin => Color.Red,
_ => Color.DarkGray
};
}

View File

@@ -0,0 +1,42 @@
using Content.Client.Chat.UI;
using Content.Client.Viewport;
using Content.Shared.Chat;
using Content.Shared.Input;
using Robust.Client.Input;
using Robust.Shared.Input.Binding;
namespace Content.Client.Chat
{
public static class ChatInput
{
public static void SetupChatInputHandlers(IInputManager inputManager, ChatBox chatBox)
{
inputManager.SetInputCommand(ContentKeyFunctions.FocusChat,
InputCmdHandler.FromDelegate(_ => GameScreen.FocusChat(chatBox)));
inputManager.SetInputCommand(ContentKeyFunctions.FocusLocalChat,
InputCmdHandler.FromDelegate(_ => GameScreen.FocusChannel(chatBox, ChatSelectChannel.Local)));
inputManager.SetInputCommand(ContentKeyFunctions.FocusOOC,
InputCmdHandler.FromDelegate(_ => GameScreen.FocusChannel(chatBox, ChatSelectChannel.OOC)));
inputManager.SetInputCommand(ContentKeyFunctions.FocusAdminChat,
InputCmdHandler.FromDelegate(_ => GameScreen.FocusChannel(chatBox, ChatSelectChannel.Admin)));
inputManager.SetInputCommand(ContentKeyFunctions.FocusRadio,
InputCmdHandler.FromDelegate(_ => GameScreen.FocusChannel(chatBox, ChatSelectChannel.Radio)));
inputManager.SetInputCommand(ContentKeyFunctions.FocusDeadChat,
InputCmdHandler.FromDelegate(_ => GameScreen.FocusChannel(chatBox, ChatSelectChannel.Dead)));
inputManager.SetInputCommand(ContentKeyFunctions.FocusConsoleChat,
InputCmdHandler.FromDelegate(_ => GameScreen.FocusChannel(chatBox, ChatSelectChannel.Console)));
inputManager.SetInputCommand(ContentKeyFunctions.CycleChatChannelForward,
InputCmdHandler.FromDelegate(_ => chatBox.CycleChatChannel(true)));
inputManager.SetInputCommand(ContentKeyFunctions.CycleChatChannelBackward,
InputCmdHandler.FromDelegate(_ => chatBox.CycleChatChannel(false)));
}
}
}

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Content.Client.Administration.Managers;
using Content.Client.Chat.UI;
using Content.Client.Ghost;
@@ -14,9 +15,7 @@ using Robust.Client.UserInterface.Controls;
using Robust.Shared.Configuration;
using Robust.Shared.GameObjects;
using Robust.Shared.IoC;
using Robust.Shared.Localization;
using Robust.Shared.Log;
using Robust.Shared.Maths;
using Robust.Shared.Network;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
@@ -54,23 +53,17 @@ namespace Content.Client.Chat.Managers
/// <summary>
/// The max amount of characters an entity can send in one message
/// </summary>
private int MaxMessageLength => _cfg.GetCVar(CCVars.ChatMaxMessageLength);
public int MaxMessageLength => _cfg.GetCVar(CCVars.ChatMaxMessageLength);
public const char ConCmdSlash = '/';
public const char OOCAlias = '[';
public const char MeAlias = '@';
public const char AdminChatAlias = ']';
public const char RadioAlias = ';';
private readonly List<StoredChatMessage> _history = new();
public IReadOnlyList<StoredChatMessage> History => _history;
private readonly List<StoredChatMessage> _filteredHistory = new();
// currently enabled channel filters set by the user. If an entry is not in this
// list it has not been explicitly set yet, thus will default to enabled when it first
// becomes filterable (added to _filterableChannels)
// currently enabled channel filters set by the user.
// All values default to on, even if they aren't a filterable chat channel currently.
// Note that these are persisted here, at the manager,
// rather than the chatbox so that these settings persist between instances of different
// chatboxes.
public readonly Dictionary<ChatChannel, bool> _channelFilters = new();
public ChatChannel ChannelFilters { get; private set; } = (ChatChannel) ushort.MaxValue;
// Maintains which channels a client should be able to filter (for showing in the chatbox)
// and select (for attempting to send on).
@@ -82,18 +75,16 @@ namespace Content.Client.Chat.Managers
//
// Note that Command is an available selection in the chatbox channel selector,
// which is not actually a chat channel but is always available.
private readonly HashSet<ChatChannel> _filterableChannels = new();
private readonly List<ChatChannel> _selectableChannels = new();
// Flag Enums for holding filtered channels
private ChatChannel _filteredChannels;
public ChatSelectChannel SelectableChannels { get; private set; }
public ChatChannel FilterableChannels { get; private set; }
/// <summary>
/// For currently disabled chat filters,
/// unread messages (messages received since the channel has been filtered
/// out). Never goes above 10 (9+ should be shown when at 10)
/// unread messages (messages received since the channel has been filtered out).
/// </summary>
private readonly Dictionary<ChatChannel, byte> _unreadMessages = new();
private readonly Dictionary<ChatChannel, int> _unreadMessages = new();
public IReadOnlyDictionary<ChatChannel, int> UnreadMessages => _unreadMessages;
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly IClientNetManager _netManager = default!;
@@ -108,11 +99,17 @@ namespace Content.Client.Chat.Managers
/// Current chat box control. This can be modified, so do not depend on saving a reference to this.
/// </summary>
public ChatBox? CurrentChatBox { get; private set; }
/// <summary>
/// Invoked when CurrentChatBox is resized (including after setting initial default size)
/// </summary>
public event Action<ChatResizedEventArgs>? OnChatBoxResized;
public event Action<ChatPermissionsUpdatedEventArgs>? ChatPermissionsUpdated;
public event Action? UnreadMessageCountsUpdated;
public event Action<StoredChatMessage>? MessageAdded;
public event Action? FiltersUpdated;
private Control _speechBubbleRoot = null!;
/// <summary>
@@ -177,83 +174,58 @@ namespace Content.Client.Chat.Managers
// for any newly-granted channels
private void UpdateChannelPermissions()
{
var oldSelectable = SelectableChannels;
SelectableChannels = default;
FilterableChannels = default;
// Can always send console stuff.
SelectableChannels |= ChatSelectChannel.Console;
// can always send/recieve OOC
if (!_selectableChannels.Contains(ChatChannel.OOC))
{
_selectableChannels.Add(ChatChannel.OOC);
}
AddFilterableChannel(ChatChannel.OOC);
SelectableChannels |= ChatSelectChannel.OOC;
FilterableChannels |= ChatChannel.OOC;
// can always hear server (nobody can actually send server messages).
AddFilterableChannel(ChatChannel.Server);
FilterableChannels |= ChatChannel.Server;
// can always hear local / radio / emote
AddFilterableChannel(ChatChannel.Local);
AddFilterableChannel(ChatChannel.Radio);
AddFilterableChannel(ChatChannel.Emotes);
// todo: this makes no sense the lobby exists fix this.
FilterableChannels |= ChatChannel.Local;
FilterableChannels |= ChatChannel.Radio;
FilterableChannels |= ChatChannel.Emotes;
// Can only send local / radio / emote when attached to a non-ghost entity.
// TODO: this logic is iffy (checking if controlling something that's NOT a ghost), is there a better way to check this?
if (!_playerManager.LocalPlayer?.ControlledEntity?.HasComponent<GhostComponent>() ?? false)
if (!IsGhost)
{
_selectableChannels.Add(ChatChannel.Local);
_selectableChannels.Add(ChatChannel.Radio);
_selectableChannels.Add(ChatChannel.Emotes);
}
else
{
_selectableChannels.Remove(ChatChannel.Local);
_selectableChannels.Remove(ChatChannel.Radio);
_selectableChannels.Remove(ChatChannel.Emotes);
SelectableChannels |= ChatSelectChannel.Local;
SelectableChannels |= ChatSelectChannel.Radio;
SelectableChannels |= ChatSelectChannel.Emotes;
}
// Only ghosts and admins can send / see deadchat.
// TODO: Should spectators also be able to see deadchat?
if (_adminMgr.HasFlag(AdminFlags.Admin) ||
(_playerManager?.LocalPlayer?.ControlledEntity?.HasComponent<GhostComponent>() ?? false))
if (_adminMgr.HasFlag(AdminFlags.Admin) || IsGhost)
{
AddFilterableChannel(ChatChannel.Dead);
if (!_selectableChannels.Contains(ChatChannel.Dead))
{
_selectableChannels.Add(ChatChannel.Dead);
}
}
else
{
_filterableChannels.Remove(ChatChannel.Dead);
_selectableChannels.Remove(ChatChannel.Dead);
FilterableChannels |= ChatChannel.Dead;
SelectableChannels |= ChatSelectChannel.Dead;
}
// only admins can see / filter asay
if (_adminMgr.HasFlag(AdminFlags.Admin))
{
AddFilterableChannel(ChatChannel.AdminChat);
if (!_selectableChannels.Contains(ChatChannel.AdminChat))
{
_selectableChannels.Add(ChatChannel.AdminChat);
}
}
else
{
_selectableChannels.Remove(ChatChannel.AdminChat);
_filterableChannels.Remove(ChatChannel.AdminChat);
FilterableChannels |= ChatChannel.Admin;
SelectableChannels |= ChatSelectChannel.Admin;
}
// Necessary so that we always have a channel to fall back to.
DebugTools.Assert((SelectableChannels & ChatSelectChannel.OOC) != 0, "OOC must always be available");
DebugTools.Assert((FilterableChannels & ChatChannel.OOC) != 0, "OOC must always be available");
// let our chatbox know all the new settings
CurrentChatBox?.SetChannelPermissions(_selectableChannels, _filterableChannels, _channelFilters, _unreadMessages, true);
}
/// <summary>
/// Adds the channel to the set of filterable channels, defaulting it as enabled
/// if it doesn't currently have an explicit enable/disable setting
/// </summary>
private void AddFilterableChannel(ChatChannel channel)
{
if (!_channelFilters.ContainsKey(channel))
_channelFilters[channel] = true;
_filterableChannels.Add(channel);
ChatPermissionsUpdated?.Invoke(new ChatPermissionsUpdatedEventArgs {OldSelectableChannels = oldSelectable});
}
public bool IsGhost => _playerManager.LocalPlayer?.ControlledEntity?.HasComponent<GhostComponent>() ?? false;
public void FrameUpdate(FrameEventArgs delta)
{
@@ -295,27 +267,19 @@ namespace Content.Client.Chat.Managers
public void SetChatBox(ChatBox chatBox)
{
if (CurrentChatBox != null)
{
CurrentChatBox.TextSubmitted -= OnChatBoxTextSubmitted;
CurrentChatBox.FilterToggled -= OnFilterButtonToggled;
CurrentChatBox.OnResized -= ChatBoxOnResized;
}
CurrentChatBox = chatBox;
if (CurrentChatBox != null)
}
public void ClearUnfilteredUnreads()
{
CurrentChatBox.TextSubmitted += OnChatBoxTextSubmitted;
CurrentChatBox.FilterToggled += OnFilterButtonToggled;
CurrentChatBox.OnResized += ChatBoxOnResized;
CurrentChatBox.SetChannelPermissions(_selectableChannels, _filterableChannels, _channelFilters, _unreadMessages, false);
foreach (var channel in _unreadMessages.Keys.ToArray())
{
if ((ChannelFilters & channel) != 0)
_unreadMessages.Remove(channel);
}
}
RepopulateChat(_filteredHistory);
}
private void ChatBoxOnResized(ChatResizedEventArgs chatResizedEventArgs)
public void ChatBoxOnResized(ChatResizedEventArgs chatResizedEventArgs)
{
OnChatBoxResized?.Invoke(chatResizedEventArgs);
}
@@ -333,158 +297,86 @@ namespace Content.Client.Chat.Managers
}
}
private void WriteChatMessage(StoredChatMessage message)
{
Logger.Debug($"{message.Channel}: {message.Message}");
if (IsFiltered(message.Channel))
{
Logger.Debug($"Message filtered: {message.Channel}: {message.Message}");
// accumulate unread
if (message.Read) return;
if (!_unreadMessages.TryGetValue(message.Channel, out var count))
{
count = 0;
}
count = (byte) Math.Min(count + 1, 10);
_unreadMessages[message.Channel] = count;
CurrentChatBox?.UpdateUnreadMessageCounts(_unreadMessages);
return;
}
var color = Color.DarkGray;
var messageText = FormattedMessage.EscapeText(message.Message);
if (!string.IsNullOrEmpty(message.MessageWrap))
{
messageText = string.Format(message.MessageWrap, messageText);
}
if (message.MessageColorOverride != Color.Transparent)
{
color = message.MessageColorOverride;
}
else
{
color = ChatHelper.ChatColor(message.Channel);
}
if (CurrentChatBox == null) return;
CurrentChatBox.AddLine(messageText, message.Channel, color);
// TODO: Can make this "smarter" later by only setting it false when the message has been scrolled to
message.Read = true;
}
private void OnChatBoxTextSubmitted(ChatBox chatBox, string text)
public void OnChatBoxTextSubmitted(ChatBox chatBox, ReadOnlyMemory<char> text, ChatSelectChannel channel)
{
DebugTools.Assert(chatBox == CurrentChatBox);
if (string.IsNullOrWhiteSpace(text))
return;
var str = text.ToString();
// Check if message is longer than the character limit
if (text.Length > MaxMessageLength)
{
if (CurrentChatBox != null)
{
string locWarning = Loc.GetString("chat-manager-max-message-length",
("maxMessageLength", MaxMessageLength));
CurrentChatBox.AddLine(locWarning, ChatChannel.Server, Color.Orange);
CurrentChatBox.ClearOnEnter = false; // The text shouldn't be cleared if it hasn't been sent
}
return;
}
switch (text[0])
{
case ConCmdSlash:
switch (channel)
{
case ChatSelectChannel.Console:
// run locally
var conInput = text.Substring(1);
_consoleHost.ExecuteCommand(conInput);
_consoleHost.ExecuteCommand(text.ToString());
break;
}
case OOCAlias:
{
var conInput = text.Substring(1);
if (string.IsNullOrWhiteSpace(conInput))
return;
_consoleHost.ExecuteCommand($"ooc \"{CommandParsing.Escape(conInput)}\"");
case ChatSelectChannel.OOC:
_consoleHost.ExecuteCommand($"ooc \"{CommandParsing.Escape(str)}\"");
break;
}
case AdminChatAlias:
{
var conInput = text.Substring(1);
if (string.IsNullOrWhiteSpace(conInput))
return;
if (_adminMgr.HasFlag(AdminFlags.Admin))
{
_consoleHost.ExecuteCommand($"asay \"{CommandParsing.Escape(conInput)}\"");
}
case ChatSelectChannel.Admin:
_consoleHost.ExecuteCommand($"asay \"{CommandParsing.Escape(str)}\"");
break;
case ChatSelectChannel.Emotes:
_consoleHost.ExecuteCommand($"me \"{CommandParsing.Escape(str)}\"");
break;
case ChatSelectChannel.Dead:
if (IsGhost)
goto case ChatSelectChannel.Local;
else if (_adminMgr.HasFlag(AdminFlags.Admin))
_consoleHost.ExecuteCommand($"dsay \"{CommandParsing.Escape(str)}\"");
else
{
_consoleHost.ExecuteCommand($"ooc \"{CommandParsing.Escape(conInput)}\"");
}
Logger.WarningS("chat", "Tried to speak on deadchat without being ghost or admin.");
break;
case ChatSelectChannel.Radio:
_consoleHost.ExecuteCommand($"say \";{CommandParsing.Escape(str)}\"");
break;
}
case MeAlias:
{
var conInput = text.Substring(1);
if (string.IsNullOrWhiteSpace(conInput))
return;
_consoleHost.ExecuteCommand($"me \"{CommandParsing.Escape(conInput)}\"");
case ChatSelectChannel.Local:
_consoleHost.ExecuteCommand($"say \"{CommandParsing.Escape(str)}\"");
break;
}
default:
{
var conInput = CurrentChatBox?.DefaultChatFormat != null
? string.Format(CurrentChatBox.DefaultChatFormat, CommandParsing.Escape(text))
: text;
_consoleHost.ExecuteCommand(conInput);
break;
}
throw new ArgumentOutOfRangeException(nameof(channel), channel, null);
}
}
private void OnFilterButtonToggled(ChatChannel channel, bool enabled)
public void OnFilterButtonToggled(ChatChannel channel, bool enabled)
{
if (enabled)
{
_channelFilters[channel] = true;
_filteredChannels &= ~channel;
ChannelFilters |= channel;
_unreadMessages.Remove(channel);
CurrentChatBox?.UpdateUnreadMessageCounts(_unreadMessages);
UnreadMessageCountsUpdated?.Invoke();
}
else
{
_channelFilters[channel] = false;
_filteredChannels |= channel;
ChannelFilters &= ~channel;
}
RepopulateChat(_filteredHistory);
}
private void RepopulateChat(IEnumerable<StoredChatMessage> filteredMessages)
{
if (CurrentChatBox == null)
{
return;
}
CurrentChatBox.Contents.Clear();
foreach (var msg in filteredMessages)
{
WriteChatMessage(msg);
}
FiltersUpdated?.Invoke();
}
private void OnChatMessage(MsgChatMessage msg)
{
// Log all incoming chat to repopulate when filter is un-toggled
var storedMessage = new StoredChatMessage(msg);
_filteredHistory.Add(storedMessage);
WriteChatMessage(storedMessage);
_history.Add(storedMessage);
MessageAdded?.Invoke(storedMessage);
if (!storedMessage.Read)
{
Logger.Debug($"Message filtered: {storedMessage.Channel}: {storedMessage.Message}");
if (!_unreadMessages.TryGetValue(msg.Channel, out var count))
count = 0;
count += 1;
_unreadMessages[msg.Channel] = count;
UnreadMessageCountsUpdated?.Invoke();
}
// Local messages that have an entity attached get a speech bubble.
if (msg.SenderEntity == default)
@@ -497,7 +389,7 @@ namespace Content.Client.Chat.Managers
break;
case ChatChannel.Dead:
if (!_playerManager.LocalPlayer?.ControlledEntity?.HasComponent<GhostComponent>() ?? true)
if (!IsGhost)
break;
AddSpeechBubble(msg, SpeechBubble.SpeechType.Say);
@@ -592,7 +484,8 @@ namespace Content.Client.Chat.Managers
private void CreateSpeechBubble(IEntity entity, SpeechBubbleData speechData)
{
var bubble = SpeechBubble.CreateSpeechBubble(speechData.Type, speechData.Message, entity, _eyeManager, this);
var bubble =
SpeechBubble.CreateSpeechBubble(speechData.Type, speechData.Message, entity, _eyeManager, this);
if (_activeSpeechBubbles.TryGetValue(entity.Uid, out var existing))
{
@@ -619,11 +512,6 @@ namespace Content.Client.Chat.Managers
}
}
private bool IsFiltered(ChatChannel channel)
{
return _filteredChannels.HasFlag(channel);
}
private sealed class SpeechBubbleQueueData
{
/// <summary>

View File

@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using Content.Client.Chat.UI;
using Content.Shared.Chat;
using Robust.Shared.GameObjects;
using Robust.Shared.Timing;
@@ -7,6 +9,10 @@ namespace Content.Client.Chat.Managers
{
public interface IChatManager
{
ChatChannel ChannelFilters { get; }
ChatSelectChannel SelectableChannels { get; }
ChatChannel FilterableChannels { get; }
void Initialize();
void FrameUpdate(FrameEventArgs delta);
@@ -20,9 +26,29 @@ namespace Content.Client.Chat.Managers
/// </summary>
ChatBox? CurrentChatBox { get; }
IReadOnlyDictionary<ChatChannel, int> UnreadMessages { get; }
IReadOnlyList<StoredChatMessage> History { get; }
int MaxMessageLength { get; }
bool IsGhost { get; }
/// <summary>
/// Invoked when CurrentChatBox is resized (including after setting initial default size)
/// </summary>
event Action<ChatResizedEventArgs>? OnChatBoxResized;
event Action<ChatPermissionsUpdatedEventArgs>? ChatPermissionsUpdated;
event Action? UnreadMessageCountsUpdated;
event Action<StoredChatMessage>? MessageAdded;
event Action? FiltersUpdated;
void ClearUnfilteredUnreads();
void ChatBoxOnResized(ChatResizedEventArgs chatResizedEventArgs);
void OnChatBoxTextSubmitted(ChatBox chatBox, ReadOnlyMemory<char> text, ChatSelectChannel channel);
void OnFilterButtonToggled(ChatChannel channel, bool enabled);
}
public struct ChatPermissionsUpdatedEventArgs
{
public ChatSelectChannel OldSelectableChannels;
}
}

View File

@@ -1,979 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Content.Client.Alerts.UI;
using Content.Client.Chat.Managers;
using Content.Client.Lobby;
using Content.Client.Resources;
using Content.Client.Stylesheets;
using Content.Shared.Chat;
using Content.Shared.Input;
using Robust.Client.Graphics;
using Robust.Client.ResourceManagement;
using Robust.Client.State;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using Robust.Shared.Input;
using Robust.Shared.IoC;
using Robust.Shared.Localization;
using Robust.Shared.Maths;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
using static Robust.Client.UserInterface.Controls.BoxContainer;
namespace Content.Client.Chat.UI
{
public class ChatBox : Control
{
public const float InitialChatBottom = 235;
public delegate void TextSubmitHandler(ChatBox chatBox, string text);
public delegate void FilterToggledHandler(ChatChannel toggled, bool enabled);
public event TextSubmitHandler? TextSubmitted;
public event FilterToggledHandler? FilterToggled;
public HistoryLineEdit Input { get; private set; }
public OutputPanel Contents { get; }
public event Action<ChatResizedEventArgs>? OnResized;
// order in which the available channel filters show up when available
public static readonly IReadOnlyList<ChatChannel> ChannelFilterOrder = new List<ChatChannel>
{
ChatChannel.Local, ChatChannel.Emotes, ChatChannel.Radio, ChatChannel.OOC, ChatChannel.Dead, ChatChannel.AdminChat,
ChatChannel.Server
};
// order in which the channels show up in the channel selector
private static readonly IReadOnlyList<ChatChannel> ChannelSelectorOrder = new List<ChatChannel>
{
ChatChannel.Local, ChatChannel.Emotes, ChatChannel.Radio, ChatChannel.OOC, ChatChannel.Dead, ChatChannel.AdminChat
};
private const float FilterPopupWidth = 110;
private const int DragMarginSize = 7;
private const int MinDistanceFromBottom = 255;
private const int MinLeft = 500;
/// <summary>
/// Will be Unspecified if set to Console
/// </summary>
public ChatChannel SelectedChannel = ChatChannel.Unspecified;
/// <summary>
/// Default formatting string for the ClientChatConsole.
/// </summary>
public string DefaultChatFormat { get; set; } = string.Empty;
public bool ReleaseFocusOnEnter { get; set; } = true;
public bool ClearOnEnter { get; set; } = true;
// when channel is changed temporarily due to typing an alias
// prefix, we save the current channel selection here to restore it when
// the message is sent
private ChatChannel? _savedSelectedChannel;
private readonly Popup _channelSelectorPopup;
private readonly Button _channelSelector;
private readonly BoxContainer _channelSelectorHBox;
private readonly FilterButton _filterButton;
private readonly Popup _filterPopup;
private readonly PanelContainer _filterPopupPanel;
private readonly BoxContainer _filterVBox;
private DragMode _currentDrag = DragMode.None;
private Vector2 _dragOffsetTopLeft;
private Vector2 _dragOffsetBottomRight;
private readonly IClyde _clyde;
private readonly bool _lobbyMode;
private byte _clampIn;
// currently known selectable channels as provided by ChatManager,
// never contains Unspecified (which corresponds to Console which is always available)
public List<ChatChannel> SelectableChannels = new();
/// <summary>
/// When lobbyMode is false, will position / add to correct location in StateRoot and
/// be resizable.
/// wWen true, will leave layout up to parent and not be resizable.
/// </summary>
public ChatBox()
{
//TODO Paul needs to fix xaml ctor args so we can pass this instead of resolving it.
var stateManager = IoCManager.Resolve<IStateManager>();
_lobbyMode = stateManager.CurrentState is LobbyState;
// TODO: Revisit the resizing stuff after https://github.com/space-wizards/RobustToolbox/issues/1392 is done,
// Probably not "supposed" to inject IClyde, but I give up.
// I can't find any other way to allow this control to properly resize when the
// window is resized. Resized() isn't reliably called when resizing the window,
// and layoutcontainer anchor / margin don't seem to adjust how we need
// them to when the window is resized. We need it to be able to resize
// within some bounds so that it doesn't overlap other UI elements, while still
// being freely resizable within those bounds.
_clyde = IoCManager.Resolve<IClyde>();
MouseFilter = MouseFilterMode.Stop;
LayoutContainer.SetMarginLeft(this, 4);
LayoutContainer.SetMarginRight(this, 4);
MinHeight = 128;
MinWidth = 200;
AddChild(new PanelContainer
{
PanelOverride = new StyleBoxFlat {BackgroundColor = Color.FromHex("#25252aaa")},
VerticalExpand = true,
HorizontalExpand = true,
Children =
{
new BoxContainer
{
Orientation = LayoutOrientation.Vertical,
Children =
{
(Contents = new OutputPanel
{
VerticalExpand = true,
}),
new PanelContainer
{
StyleClasses = { StyleNano.StyleClassChatSubPanel },
HorizontalExpand = true,
Children =
{
new BoxContainer
{
Orientation = LayoutOrientation.Horizontal,
HorizontalExpand = true,
SeparationOverride = 4,
Children =
{
(_channelSelector = new ChannelSelectorButton
{
StyleClasses = { StyleNano.StyleClassChatChannelSelectorButton },
MinWidth = 75,
Text = Loc.GetString("hud-chatbox-ooc"),
ToggleMode = true
}),
(Input = new HistoryLineEdit
{
PlaceHolder = Loc.GetString("hud-chatbox-info"),
HorizontalExpand = true,
StyleClasses = { StyleNano.StyleClassChatLineEdit }
}),
(_filterButton = new FilterButton
{
StyleClasses = { StyleNano.StyleClassChatFilterOptionButton }
})
}
}
}
}
}
}
}
});
_filterPopup = new Popup
{
Children =
{
(_filterPopupPanel = new PanelContainer
{
StyleClasses = {StyleNano.StyleClassBorderedWindowPanel},
Children =
{
new BoxContainer
{
Orientation = LayoutOrientation.Horizontal,
Children =
{
new Control{MinSize = (4,0)},
(_filterVBox = new BoxContainer
{
Orientation = LayoutOrientation.Vertical,
SeparationOverride = 4
})
}
}
}
})
}
};
_channelSelectorPopup = new Popup
{
Children =
{
(_channelSelectorHBox = new BoxContainer
{
Orientation = LayoutOrientation.Horizontal,
SeparationOverride = 1
})
}
};
if (!_lobbyMode)
{
UserInterfaceManager.StateRoot.AddChild(this);
LayoutContainer.SetAnchorAndMarginPreset(this, LayoutContainer.LayoutPreset.TopRight, margin: 10);
LayoutContainer.SetAnchorAndMarginPreset(this, LayoutContainer.LayoutPreset.TopRight, margin: 10);
LayoutContainer.SetMarginLeft(this, -475);
LayoutContainer.SetMarginBottom(this, InitialChatBottom);
OnResized?.Invoke(new ChatResizedEventArgs(InitialChatBottom));
}
}
protected override void EnteredTree()
{
base.EnteredTree();
_channelSelector.OnToggled += OnChannelSelectorToggled;
_filterButton.OnToggled += OnFilterButtonToggled;
Input.OnKeyBindDown += InputKeyBindDown;
Input.OnTextEntered += Input_OnTextEntered;
Input.OnTextChanged += InputOnTextChanged;
Input.OnFocusExit += InputOnFocusExit;
_channelSelectorPopup.OnPopupHide += OnChannelSelectorPopupHide;
_filterPopup.OnPopupHide += OnFilterPopupHide;
_clyde.OnWindowResized += ClydeOnOnWindowResized;
}
protected override void ExitedTree()
{
base.ExitedTree();
_channelSelector.OnToggled -= OnChannelSelectorToggled;
_filterButton.OnToggled -= OnFilterButtonToggled;
Input.OnKeyBindDown -= InputKeyBindDown;
Input.OnTextEntered -= Input_OnTextEntered;
Input.OnTextChanged -= InputOnTextChanged;
Input.OnFocusExit -= InputOnFocusExit;
_channelSelectorPopup.OnPopupHide -= OnChannelSelectorPopupHide;
_filterPopup.OnPopupHide -= OnFilterPopupHide;
_clyde.OnWindowResized -= ClydeOnOnWindowResized;
UnsubFilterItems();
UnsubChannelItems();
}
private void UnsubFilterItems()
{
foreach (var child in _filterVBox.Children)
{
if (child is not ChannelFilterCheckbox checkbox) continue;
checkbox.OnToggled -= OnFilterCheckboxToggled;
}
}
private void UnsubChannelItems()
{
foreach (var child in _channelSelectorHBox.Children)
{
if (child is not ChannelItemButton button) continue;
button.OnPressed -= OnChannelSelectorItemPressed;
}
}
/// <summary>
/// Update the available filters / selectable channels and the current filter settings using the provided
/// data.
/// </summary>
/// <param name="selectableChannels">channels currently selectable to send on</param>
/// <param name="filterableChannels">channels currently able ot filter on</param>
/// <param name="channelFilters">current settings for the channel filters, this SHOULD always have an entry if
/// there is a corresponding entry in filterableChannels, but it may also have additional
/// entries (which should not be presented to the user)</param>
/// <param name="unreadMessages">unread message counts for each disabled channel, values 10 or higher will show as 9+</param>
public void SetChannelPermissions(List<ChatChannel> selectableChannels, IReadOnlySet<ChatChannel> filterableChannels,
IReadOnlyDictionary<ChatChannel, bool> channelFilters, IReadOnlyDictionary<ChatChannel, byte> unreadMessages, bool switchIfConsole)
{
SelectableChannels = selectableChannels;
// update the channel selector
UnsubChannelItems();
_channelSelectorHBox.RemoveAllChildren();
foreach (var selectableChannel in ChannelSelectorOrder)
{
if (!selectableChannels.Contains(selectableChannel)) continue;
var newButton = new ChannelItemButton(selectableChannel);
newButton.OnPressed += OnChannelSelectorItemPressed;
_channelSelectorHBox.AddChild(newButton);
}
// console channel is always selectable and represented via Unspecified
var consoleButton = new ChannelItemButton(ChatChannel.Unspecified);
consoleButton.OnPressed += OnChannelSelectorItemPressed;
_channelSelectorHBox.AddChild(consoleButton);
if (_savedSelectedChannel.HasValue && _savedSelectedChannel.Value != ChatChannel.Unspecified &&
!selectableChannels.Contains(_savedSelectedChannel.Value))
{
// we just lost our saved selected channel, the current one will become permanent
_savedSelectedChannel = null;
}
if (!selectableChannels.Contains(SelectedChannel) && (switchIfConsole || SelectedChannel != ChatChannel.Unspecified))
{
// our previously selected channel no longer exists or we are still on console channel because we just joined
if ((SelectedChannel & ChatChannel.IC) != 0 || SelectedChannel == ChatChannel.Unspecified)
{
if (!SafelySelectChannel(ChatChannel.Local))
SafelySelectChannel(ChatChannel.Dead);
}
else if (selectableChannels.Contains(ChatChannel.OOC))
{
SafelySelectChannel(ChatChannel.OOC);
}
else //This shouldn't happen but better to be safe than sorry
{
SafelySelectChannel(selectableChannels.First());
}
}
else
{
SafelySelectChannel(SelectedChannel);
}
// update the channel filters
UnsubFilterItems();
_filterVBox.Children.Clear();
_filterVBox.AddChild(new Control {CustomMinimumSize = (10, 0)});
foreach (var channelFilter in ChannelFilterOrder)
{
if (!filterableChannels.Contains(channelFilter)) continue;
byte? unreadCount = null;
if (unreadMessages.TryGetValue(channelFilter, out var unread))
{
unreadCount = unread;
}
var newCheckBox = new ChannelFilterCheckbox(channelFilter, unreadCount)
{
// shouldn't happen, but if there's no explicit enable setting provided, default to enabled
Pressed = !channelFilters.TryGetValue(channelFilter, out var enabled) || enabled
};
newCheckBox.OnToggled += OnFilterCheckboxToggled;
_filterVBox.AddChild(newCheckBox);
}
_filterVBox.AddChild(new Control {CustomMinimumSize = (10, 0)});
}
/// <summary>
/// Update the unread message counts in the filters based on the provided data.
/// </summary>
/// <param name="unreadMessages">counts for each channel, any values above 9 will show as 9+</param>
public void UpdateUnreadMessageCounts(IReadOnlyDictionary<ChatChannel, byte> unreadMessages)
{
foreach (var channelFilter in _filterVBox.Children)
{
if (channelFilter is not ChannelFilterCheckbox filterCheckbox) continue;
if (unreadMessages.TryGetValue(filterCheckbox.Channel, out var unread))
{
filterCheckbox.UpdateUnreadCount(unread);
}
else
{
filterCheckbox.UpdateUnreadCount(null);
}
}
}
private void OnFilterCheckboxToggled(BaseButton.ButtonToggledEventArgs args)
{
if (args.Button is not ChannelFilterCheckbox checkbox) return;
FilterToggled?.Invoke(checkbox.Channel, checkbox.Pressed);
}
private void OnFilterButtonToggled(BaseButton.ButtonToggledEventArgs args)
{
if (args.Pressed)
{
var globalPos = _filterButton.GlobalPosition;
var (minX, minY) = _filterPopupPanel.CombinedMinimumSize;
var box = UIBox2.FromDimensions(globalPos - (FilterPopupWidth, 0), (Math.Max(minX, FilterPopupWidth), minY));
UserInterfaceManager.ModalRoot.AddChild(_filterPopup);
_filterPopup.Open(box);
}
else
{
_filterPopup.Close();
}
}
private void OnChannelSelectorToggled(BaseButton.ButtonToggledEventArgs args)
{
if (args.Pressed)
{
var globalLeft = GlobalPosition.X;
var globalBot = GlobalPosition.Y + Height;
var box = UIBox2.FromDimensions((globalLeft, globalBot), (SizeBox.Width, AlertsUI.ChatSeparation));
UserInterfaceManager.ModalRoot.AddChild(_channelSelectorPopup);
_channelSelectorPopup.Open(box);
}
else
{
_channelSelectorPopup.Close();
}
}
private void OnFilterPopupHide()
{
OnPopupHide(_filterPopup, _filterButton);
}
private void OnChannelSelectorPopupHide()
{
OnPopupHide(_channelSelectorPopup, _channelSelector);
}
private void OnPopupHide(Control popup, BaseButton button)
{
UserInterfaceManager.ModalRoot.RemoveChild(popup);
// this weird check here is because the hiding of the popup happens prior to the button
// receiving the keydown, which would cause it to then become unpressed
// and reopen immediately. To avoid this, if the popup was hidden due to clicking on the button,
// we will not auto-unpress the button, instead leaving it up to the button toggle logic
// (and this requires the button to be set to EnableAllKeybinds = true)
if (UserInterfaceManager.CurrentlyHovered != button)
{
button.Pressed = false;
}
}
private void OnChannelSelectorItemPressed(BaseButton.ButtonEventArgs obj)
{
if (obj.Button is not ChannelItemButton button) return;
SafelySelectChannel(button.Channel);
_channelSelectorPopup.Close();
}
/// <summary>
/// Selects the indicated channel, clearing out any temporarily-selected channel
/// (any currently entered text is preserved). If the specified channel is not selectable,
/// will just maintain current selection.
/// </summary>
public void SelectChannel(ChatChannel toSelect)
{
_savedSelectedChannel = null;
SafelySelectChannel(toSelect);
}
private bool SafelySelectChannel(ChatChannel toSelect)
{
if (toSelect == ChatChannel.Unspecified ||
SelectableChannels.Contains(toSelect))
{
SelectedChannel = toSelect;
_channelSelector.Text = ChannelSelectorName(toSelect);
_channelSelector.Modulate = ChatHelper.ChatColor(toSelect);
return true;
}
// keep current setting
return false;
}
protected override void KeyBindDown(GUIBoundKeyEventArgs args)
{
base.KeyBindDown(args);
if (args.Function == EngineKeyFunctions.UIClick && !_lobbyMode)
{
_currentDrag = GetDragModeFor(args.RelativePosition);
if (_currentDrag != DragMode.None)
{
_dragOffsetTopLeft = args.PointerLocation.Position / UIScale - Position;
_dragOffsetBottomRight = Position + Size - args.PointerLocation.Position / UIScale;
}
}
if (args.CanFocus)
{
Input.GrabKeyboardFocus();
}
}
protected override void KeyBindUp(GUIBoundKeyEventArgs args)
{
base.KeyBindUp(args);
if (args.Function != EngineKeyFunctions.UIClick || _lobbyMode)
{
return;
}
_dragOffsetTopLeft = _dragOffsetBottomRight = Vector2.Zero;
_currentDrag = DragMode.None;
// If this is done in MouseDown, Godot won't fire MouseUp as you need focus to receive MouseUps.
UserInterfaceManager.KeyboardFocused?.ReleaseKeyboardFocus();
}
public void CycleChatChannel(bool forward)
{
Input.IgnoreNext = true;
var channels = SelectableChannels;
var idx = channels.IndexOf(SelectedChannel);
if (forward)
{
idx++;
}
else
{
idx--;
}
idx = MathHelper.Mod(idx, channels.Count);
SelectChannel(channels[idx]);
}
private void InputKeyBindDown(GUIBoundKeyEventArgs args)
{
if (args.Function == EngineKeyFunctions.TextReleaseFocus)
{
Input.ReleaseKeyboardFocus();
args.Handle();
return;
}
if (args.Function == ContentKeyFunctions.CycleChatChannelForward)
{
CycleChatChannel(true);
args.Handle();
return;
}
if (args.Function == ContentKeyFunctions.CycleChatChannelBackward)
{
CycleChatChannel(false);
args.Handle();
return;
}
// if we temporarily selected another channel via a prefx, undo that when we backspace on an empty input
if (args.Function == EngineKeyFunctions.TextBackspace && Input.Text.Length == 0 && _savedSelectedChannel.HasValue)
{
SafelySelectChannel(_savedSelectedChannel.Value);
_savedSelectedChannel = null;
}
}
// TODO: this drag and drop stuff is somewhat duplicated from Robust BaseWindow but also modified
[Flags]
private enum DragMode : byte
{
None = 0,
Bottom = 1 << 1,
Left = 1 << 2
}
private DragMode GetDragModeFor(Vector2 relativeMousePos)
{
var mode = DragMode.None;
if (relativeMousePos.Y > Size.Y - DragMarginSize)
{
mode = DragMode.Bottom;
}
if (relativeMousePos.X < DragMarginSize)
{
mode |= DragMode.Left;
}
return mode;
}
protected override void MouseMove(GUIMouseMoveEventArgs args)
{
base.MouseMove(args);
if (Parent == null || _lobbyMode)
{
return;
}
if (_currentDrag == DragMode.None)
{
var cursor = CursorShape.Arrow;
var previewDragMode = GetDragModeFor(args.RelativePosition);
switch (previewDragMode)
{
case DragMode.Bottom:
cursor = CursorShape.VResize;
break;
case DragMode.Left:
cursor = CursorShape.HResize;
break;
case DragMode.Bottom | DragMode.Left:
cursor = CursorShape.Crosshair;
break;
}
DefaultCursorShape = cursor;
}
else
{
var top = Rect.Top;
var bottom = Rect.Bottom;
var left = Rect.Left;
var right = Rect.Right;
var (minSizeX, minSizeY) = CombinedMinimumSize;
if ((_currentDrag & DragMode.Bottom) == DragMode.Bottom)
{
bottom = Math.Max(args.GlobalPosition.Y + _dragOffsetBottomRight.Y, top + minSizeY);
}
if ((_currentDrag & DragMode.Left) == DragMode.Left)
{
var maxX = right - minSizeX;
left = Math.Min(args.GlobalPosition.X - _dragOffsetTopLeft.X, maxX);
}
ClampSize(left, bottom);
}
}
protected override void UIScaleChanged()
{
base.UIScaleChanged();
ClampAfterDelay();
}
private void ClydeOnOnWindowResized(WindowResizedEventArgs obj)
{
ClampAfterDelay();
}
private void ClampAfterDelay()
{
if (!_lobbyMode)
_clampIn = 2;
}
protected override void FrameUpdate(FrameEventArgs args)
{
base.FrameUpdate(args);
if (_lobbyMode) return;
// we do the clamping after a delay (after UI scale / window resize)
// because we need to wait for our parent container to properly resize
// first, so we can calculate where we should go. If we do it right away,
// we won't have the correct values from the parent to know how to adjust our margins.
if (_clampIn <= 0) return;
_clampIn -= 1;
if (_clampIn == 0) ClampSize();
}
private void ClampSize(float? desiredLeft = null, float? desiredBottom = null)
{
if (Parent == null || _lobbyMode) return;
var top = Rect.Top;
var right = Rect.Right;
var left = desiredLeft ?? Rect.Left;
var bottom = desiredBottom ?? Rect.Bottom;
// clamp so it doesn't go too high or low (leave space for alerts UI)
var maxBottom = Parent.Size.Y - MinDistanceFromBottom;
if (maxBottom <= MinHeight)
{
// we can't fit in our given space (window made awkwardly small), so give up
// and overlap at our min height
bottom = MinHeight;
}
else
{
bottom = Math.Clamp(bottom, MinHeight, maxBottom);
}
var maxLeft = Parent.Size.X - MinWidth;
if (maxLeft <= MinLeft)
{
// window too narrow, give up and overlap at our max left
left = maxLeft;
}
else
{
left = Math.Clamp(left, MinLeft, maxLeft);
}
LayoutContainer.SetMarginLeft(this, -((right + 10) - left));
LayoutContainer.SetMarginBottom(this, bottom);
OnResized?.Invoke(new ChatResizedEventArgs(bottom));
}
protected override void MouseExited()
{
base.MouseExited();
if (_currentDrag == DragMode.None && !_lobbyMode)
{
DefaultCursorShape = CursorShape.Arrow;
}
}
private void InputOnTextChanged(LineEdit.LineEditEventArgs obj)
{
// switch temporarily to a different channel if an alias prefix has been entered.
// are we already temporarily switching to a channel?
if (_savedSelectedChannel.HasValue) return;
var trimmed = obj.Text.Trim();
if (trimmed.Length == 0 || trimmed.Length > 1) return;
var channel = GetChannelFromPrefix(trimmed[0]);
var prevChannel = SelectedChannel;
if (channel == null || !SafelySelectChannel(channel.Value)) return;
// we ate the prefix and auto-switched (temporarily) to the channel with that prefix
_savedSelectedChannel = prevChannel;
Input.Text = "";
}
private static ChatChannel? GetChannelFromPrefix(char prefix)
{
return prefix switch
{
ChatManager.MeAlias => ChatChannel.Emotes,
ChatManager.RadioAlias => ChatChannel.Radio,
ChatManager.AdminChatAlias => ChatChannel.AdminChat,
ChatManager.OOCAlias => ChatChannel.OOC,
ChatManager.ConCmdSlash => ChatChannel.Unspecified,
_ => null
};
}
private static string GetPrefixFromChannel(ChatChannel channel)
{
char? prefixChar = channel switch
{
ChatChannel.Emotes => ChatManager.MeAlias,
ChatChannel.Radio => ChatManager.RadioAlias,
ChatChannel.AdminChat => ChatManager.AdminChatAlias,
ChatChannel.OOC => ChatManager.OOCAlias,
ChatChannel.Unspecified => ChatManager.ConCmdSlash,
_ => null
};
return prefixChar.ToString() ?? string.Empty;
}
public static string ChannelSelectorName(ChatChannel channel)
{
return channel switch
{
ChatChannel.AdminChat => Loc.GetString("hud-chatbox-admin"),
ChatChannel.Unspecified => Loc.GetString("hud-chatbox-console"),
_ => Loc.GetString(channel.ToString())
};
}
public void AddLine(string message, ChatChannel channel, Color color)
{
if (Disposed)
{
return;
}
var formatted = new FormattedMessage(3);
formatted.PushColor(color);
formatted.AddMarkup(message);
formatted.Pop();
Contents.AddMessage(formatted);
}
private void InputOnFocusExit(LineEdit.LineEditEventArgs obj)
{
// undo the temporary selection, otherwise it will be odd if user
// comes back to it later only to have their selection cleared upon sending
if (!_savedSelectedChannel.HasValue) return;
SafelySelectChannel(_savedSelectedChannel.Value);
_savedSelectedChannel = null;
}
private void Input_OnTextEntered(LineEdit.LineEditEventArgs args)
{
// We set it there to true so it's set to false by TextSubmitted.Invoke if necessary
ClearOnEnter = true;
if (!string.IsNullOrWhiteSpace(args.Text))
{
TextSubmitted?.Invoke(this, GetPrefixFromChannel(SelectedChannel)
+ args.Text);
}
if (ClearOnEnter)
{
Input.Clear();
if (_savedSelectedChannel.HasValue)
{
SafelySelectChannel(_savedSelectedChannel.Value);
_savedSelectedChannel = null;
}
}
if (ReleaseFocusOnEnter)
{
Input.ReleaseKeyboardFocus();
}
}
}
/// <summary>
/// Only needed to avoid the issue where right click on the button closes the popup
/// but leaves the button highlighted.
/// </summary>
public sealed class ChannelSelectorButton : Button
{
public ChannelSelectorButton()
{
// needed so the popup is untoggled regardless of which key is pressed when hovering this button.
// If we don't have this, then right clicking the button while it's toggled on will hide
// the popup but keep the button toggled on
Mode = ActionMode.Press;
EnableAllKeybinds = true;
}
protected override void KeyBindDown(GUIBoundKeyEventArgs args)
{
// needed since we need EnableAllKeybinds - don't double-send both UI click and Use
if (args.Function == EngineKeyFunctions.Use) return;
base.KeyBindDown(args);
}
}
public sealed class FilterButton : ContainerButton
{
private static readonly Color ColorNormal = Color.FromHex("#7b7e9e");
private static readonly Color ColorHovered = Color.FromHex("#9699bb");
private static readonly Color ColorPressed = Color.FromHex("#789B8C");
private readonly TextureRect _textureRect;
public FilterButton()
{
var filterTexture = IoCManager.Resolve<IResourceCache>()
.GetTexture("/Textures/Interface/Nano/filter.svg.96dpi.png");
// needed for same reason as ChannelSelectorButton
Mode = ActionMode.Press;
EnableAllKeybinds = true;
AddChild(
(_textureRect = new TextureRect
{
Texture = filterTexture,
SizeFlagsVertical = SizeFlags.ShrinkCenter,
SizeFlagsHorizontal = SizeFlags.ShrinkCenter
})
);
ToggleMode = true;
}
protected override void KeyBindDown(GUIBoundKeyEventArgs args)
{
// needed since we need EnableAllKeybinds - don't double-send both UI click and Use
if (args.Function == EngineKeyFunctions.Use) return;
base.KeyBindDown(args);
}
private void UpdateChildColors()
{
if (_textureRect == null) return;
switch (DrawMode)
{
case DrawModeEnum.Normal:
_textureRect.ModulateSelfOverride = ColorNormal;
break;
case DrawModeEnum.Pressed:
_textureRect.ModulateSelfOverride = ColorPressed;
break;
case DrawModeEnum.Hover:
_textureRect.ModulateSelfOverride = ColorHovered;
break;
case DrawModeEnum.Disabled:
break;
}
}
protected override void DrawModeChanged()
{
base.DrawModeChanged();
UpdateChildColors();
}
protected override void StylePropertiesChanged()
{
base.StylePropertiesChanged();
UpdateChildColors();
}
}
public sealed class ChannelItemButton : Button
{
public readonly ChatChannel Channel;
public ChannelItemButton(ChatChannel channel)
{
Channel = channel;
AddStyleClass(StyleNano.StyleClassChatChannelSelectorButton);
Text = ChatBox.ChannelSelectorName(channel);
}
}
public sealed class ChannelFilterCheckbox : CheckBox
{
public readonly ChatChannel Channel;
public ChannelFilterCheckbox(ChatChannel channel, byte? unreadCount)
{
Channel = channel;
UpdateText(unreadCount);
}
private void UpdateText(byte? unread)
{
var name = Channel switch
{
ChatChannel.AdminChat => Loc.GetString("hud-chatbox-admin"),
ChatChannel.Unspecified => throw new InvalidOperationException(
"cannot create chat filter for Unspecified"),
_ => Loc.GetString(Channel.ToString())
};
if (unread > 0)
{
Text = name + " (" + (unread > 9 ? "9+" : unread) + ")";
}
else
{
Text = name;
}
}
public void UpdateUnreadCount(byte? unread)
{
UpdateText(unread);
}
}
public readonly struct ChatResizedEventArgs
{
/// new bottom that the chat rect is going to have in virtual pixels
/// after the imminent relayout
public readonly float NewBottom;
public ChatResizedEventArgs(float newBottom)
{
NewBottom = newBottom;
}
}
}

View File

@@ -0,0 +1,24 @@
<Control xmlns="https://spacestation14.io"
xmlns:gfx="clr-namespace:Robust.Client.Graphics;assembly=Robust.Client"
xmlns:chatUI="clr-namespace:Content.Client.Chat.UI"
MouseFilter="Stop"
MinSize="200 128">
<PanelContainer>
<PanelContainer.PanelOverride>
<gfx:StyleBoxFlat BackgroundColor="#25252AAA" />
</PanelContainer.PanelOverride>
<BoxContainer Orientation="Vertical">
<OutputPanel Name="Contents" VerticalExpand="True" />
<PanelContainer StyleClasses="ChatSubPanel">
<BoxContainer Orientation="Horizontal" SeparationOverride="4">
<chatUI:ChannelSelectorButton Name="ChannelSelector" ToggleMode="True"
StyleClasses="chatSelectorOptionButton" MinWidth="75" />
<HistoryLineEdit Name="Input" PlaceHolder="{Loc 'hud-chatbox-info'}" HorizontalExpand="True"
StyleClasses="chatLineEdit" />
<chatUI:FilterButton Name="FilterButton" StyleClasses="chatFilterOptionButton" />
</BoxContainer>
</PanelContainer>
</BoxContainer>
</PanelContainer>
</Control>

View File

@@ -0,0 +1,740 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Content.Client.Alerts.UI;
using Content.Client.Chat.Managers;
using Content.Client.Resources;
using Content.Client.Stylesheets;
using Content.Shared.Chat;
using Content.Shared.Input;
using Robust.Client.AutoGenerated;
using Robust.Client.ResourceManagement;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using Robust.Client.UserInterface.XAML;
using Robust.Shared.Input;
using Robust.Shared.IoC;
using Robust.Shared.Localization;
using Robust.Shared.Log;
using Robust.Shared.Maths;
using Robust.Shared.Utility;
namespace Content.Client.Chat.UI
{
[GenerateTypedNameReferences]
public partial class ChatBox : Control
{
[Dependency] protected readonly IChatManager ChatMgr = default!;
// order in which the available channel filters show up when available
private static readonly ChatChannel[] ChannelFilterOrder =
{
ChatChannel.Local,
ChatChannel.Emotes,
ChatChannel.Radio,
ChatChannel.OOC,
ChatChannel.Dead,
ChatChannel.Admin,
ChatChannel.Server
};
// order in which the channels show up in the channel selector
private static readonly ChatSelectChannel[] ChannelSelectorOrder =
{
ChatSelectChannel.Local,
ChatSelectChannel.Emotes,
ChatSelectChannel.Radio,
ChatSelectChannel.OOC,
ChatSelectChannel.Dead,
ChatSelectChannel.Admin
// NOTE: Console is not in there and it can never be permanently selected.
// You can, however, still submit commands as console by prefixing with /.
};
public const char AliasLocal = '.';
public const char AliasConsole = '/';
public const char AliasDead = '\\';
public const char AliasOOC = '[';
public const char AliasEmotes = '@';
public const char AliasAdmin = ']';
public const char AliasRadio = ';';
private static readonly Dictionary<char, ChatSelectChannel> PrefixToChannel = new()
{
{AliasLocal, ChatSelectChannel.Local},
{AliasConsole, ChatSelectChannel.Console},
{AliasOOC, ChatSelectChannel.OOC},
{AliasEmotes, ChatSelectChannel.Emotes},
{AliasAdmin, ChatSelectChannel.Admin},
{AliasRadio, ChatSelectChannel.Radio},
{AliasDead, ChatSelectChannel.Dead}
};
private static readonly Dictionary<ChatSelectChannel, char> ChannelPrefixes =
PrefixToChannel.ToDictionary(kv => kv.Value, kv => kv.Key);
private const float FilterPopupWidth = 110;
/// <summary>
/// The currently default channel that will be used if no prefix is specified.
/// </summary>
public ChatSelectChannel SelectedChannel { get; private set; } = ChatSelectChannel.OOC;
/// <summary>
/// The "preferred" channel. Will be switched to if permissions change and the channel becomes available,
/// such as by re-entering body. Gets changed if the user manually selects a channel with the buttons.
/// </summary>
public ChatSelectChannel PreferredChannel { get; set; } = ChatSelectChannel.OOC;
public bool ReleaseFocusOnEnter { get; set; } = true;
private readonly Popup _channelSelectorPopup;
private readonly BoxContainer _channelSelectorHBox;
private readonly Popup _filterPopup;
private readonly PanelContainer _filterPopupPanel;
private readonly BoxContainer _filterVBox;
/// <summary>
/// When lobbyMode is false, will position / add to correct location in StateRoot and
/// be resizable.
/// wWen true, will leave layout up to parent and not be resizable.
/// </summary>
public ChatBox()
{
IoCManager.InjectDependencies(this);
RobustXamlLoader.Load(this);
LayoutContainer.SetMarginLeft(this, 4);
LayoutContainer.SetMarginRight(this, 4);
_filterPopup = new Popup
{
Children =
{
(_filterPopupPanel = new PanelContainer
{
StyleClasses = {StyleNano.StyleClassBorderedWindowPanel},
Children =
{
new BoxContainer
{
Orientation = BoxContainer.LayoutOrientation.Horizontal,
Children =
{
new Control {MinSize = (4, 0)},
(_filterVBox = new BoxContainer
{
Margin = new Thickness(0, 10),
Orientation = BoxContainer.LayoutOrientation.Vertical,
SeparationOverride = 4
})
}
}
}
})
}
};
_channelSelectorPopup = new Popup
{
Children =
{
(_channelSelectorHBox = new BoxContainer
{
Orientation = BoxContainer.LayoutOrientation.Horizontal,
SeparationOverride = 1
})
}
};
ChannelSelector.OnToggled += OnChannelSelectorToggled;
FilterButton.OnToggled += OnFilterButtonToggled;
Input.OnKeyBindDown += InputKeyBindDown;
Input.OnTextEntered += Input_OnTextEntered;
Input.OnTextChanged += InputOnTextChanged;
_channelSelectorPopup.OnPopupHide += OnChannelSelectorPopupHide;
_filterPopup.OnPopupHide += OnFilterPopupHide;
}
protected override void EnteredTree()
{
base.EnteredTree();
ChatMgr.MessageAdded += WriteChatMessage;
ChatMgr.ChatPermissionsUpdated += OnChatPermissionsUpdated;
ChatMgr.UnreadMessageCountsUpdated += UpdateUnreadMessageCounts;
ChatMgr.FiltersUpdated += Repopulate;
// The chat manager may have messages logged from before there was a chat box.
// In this case, these messages will be marked as unread despite the filters allowing them through.
// Tell chat manager to clear these.
ChatMgr.ClearUnfilteredUnreads();
ChatPermissionsUpdated(0);
UpdateChannelSelectButton();
Repopulate();
}
protected override void ExitedTree()
{
base.ExitedTree();
ChatMgr.MessageAdded -= WriteChatMessage;
ChatMgr.ChatPermissionsUpdated -= OnChatPermissionsUpdated;
ChatMgr.UnreadMessageCountsUpdated -= UpdateUnreadMessageCounts;
ChatMgr.FiltersUpdated -= Repopulate;
}
private void OnChatPermissionsUpdated(ChatPermissionsUpdatedEventArgs eventArgs)
{
ChatPermissionsUpdated(eventArgs.OldSelectableChannels);
}
private void ChatPermissionsUpdated(ChatSelectChannel oldSelectable)
{
// update the channel selector
_channelSelectorHBox.Children.Clear();
foreach (var selectableChannel in ChannelSelectorOrder)
{
if ((ChatMgr.SelectableChannels & selectableChannel) == 0)
continue;
var newButton = new ChannelItemButton(selectableChannel);
newButton.OnPressed += OnChannelSelectorItemPressed;
_channelSelectorHBox.AddChild(newButton);
}
// Selected channel no longer available, switch to OOC?
if ((ChatMgr.SelectableChannels & SelectedChannel) == 0)
{
// Handle local -> dead mapping when you e.g. ghost.
// Only necessary for admins because they always have deadchat
// so the normal preferred check won't see it as newly available and do nothing.
var mappedSelect = MapLocalIfGhost(SelectedChannel);
if ((ChatMgr.SelectableChannels & mappedSelect) != 0)
SafelySelectChannel(mappedSelect);
else
SafelySelectChannel(ChatSelectChannel.OOC);
}
// If the preferred channel just became available, switch to it.
var pref = MapLocalIfGhost(PreferredChannel);
if ((oldSelectable & pref) == 0 && (ChatMgr.SelectableChannels & pref) != 0)
SafelySelectChannel(pref);
// update the channel filters
_filterVBox.Children.Clear();
foreach (var channelFilter in ChannelFilterOrder)
{
if ((ChatMgr.FilterableChannels & channelFilter) == 0)
continue;
int? unreadCount = null;
if (ChatMgr.UnreadMessages.TryGetValue(channelFilter, out var unread))
unreadCount = unread;
var newCheckBox = new ChannelFilterCheckbox(channelFilter, unreadCount)
{
Pressed = (ChatMgr.ChannelFilters & channelFilter) != 0
};
newCheckBox.OnToggled += OnFilterCheckboxToggled;
_filterVBox.AddChild(newCheckBox);
}
UpdateChannelSelectButton();
}
private void UpdateUnreadMessageCounts()
{
foreach (var channelFilter in _filterVBox.Children)
{
if (channelFilter is not ChannelFilterCheckbox filterCheckbox) continue;
if (ChatMgr.UnreadMessages.TryGetValue(filterCheckbox.Channel, out var unread))
{
filterCheckbox.UpdateUnreadCount(unread);
}
else
{
filterCheckbox.UpdateUnreadCount(null);
}
}
}
private void OnFilterCheckboxToggled(BaseButton.ButtonToggledEventArgs args)
{
if (args.Button is not ChannelFilterCheckbox checkbox)
return;
ChatMgr.OnFilterButtonToggled(checkbox.Channel, checkbox.Pressed);
}
private void OnFilterButtonToggled(BaseButton.ButtonToggledEventArgs args)
{
if (args.Pressed)
{
var globalPos = FilterButton.GlobalPosition;
var (minX, minY) = _filterPopupPanel.MinSize;
var box = UIBox2.FromDimensions(globalPos - (FilterPopupWidth, 0),
(Math.Max(minX, FilterPopupWidth), minY));
UserInterfaceManager.ModalRoot.AddChild(_filterPopup);
_filterPopup.Open(box);
}
else
{
_filterPopup.Close();
}
}
private void OnChannelSelectorToggled(BaseButton.ButtonToggledEventArgs args)
{
if (args.Pressed)
{
var globalLeft = GlobalPosition.X;
var globalBot = GlobalPosition.Y + Height;
var box = UIBox2.FromDimensions((globalLeft, globalBot), (SizeBox.Width, AlertsUI.ChatSeparation));
UserInterfaceManager.ModalRoot.AddChild(_channelSelectorPopup);
_channelSelectorPopup.Open(box);
}
else
{
_channelSelectorPopup.Close();
}
}
private void OnFilterPopupHide()
{
OnPopupHide(_filterPopup, FilterButton);
}
private void OnChannelSelectorPopupHide()
{
OnPopupHide(_channelSelectorPopup, ChannelSelector);
}
private void OnPopupHide(Control popup, BaseButton button)
{
UserInterfaceManager.ModalRoot.RemoveChild(popup);
// this weird check here is because the hiding of the popup happens prior to the button
// receiving the keydown, which would cause it to then become unpressed
// and reopen immediately. To avoid this, if the popup was hidden due to clicking on the button,
// we will not auto-unpress the button, instead leaving it up to the button toggle logic
// (and this requires the button to be set to EnableAllKeybinds = true)
if (UserInterfaceManager.CurrentlyHovered != button)
{
button.Pressed = false;
}
}
private void OnChannelSelectorItemPressed(BaseButton.ButtonEventArgs obj)
{
if (obj.Button is not ChannelItemButton button)
return;
PreferredChannel = button.Channel;
SafelySelectChannel(button.Channel);
_channelSelectorPopup.Close();
}
public bool SafelySelectChannel(ChatSelectChannel toSelect)
{
toSelect = MapLocalIfGhost(toSelect);
if ((ChatMgr.SelectableChannels & toSelect) == 0)
return false;
SelectedChannel = toSelect;
UpdateChannelSelectButton();
return true;
}
private void UpdateChannelSelectButton()
{
var (prefixChannel, _) = SplitInputContents();
var channel = prefixChannel == 0 ? SelectedChannel : prefixChannel;
ChannelSelector.Text = ChannelSelectorName(channel);
ChannelSelector.Modulate = ChannelSelectColor(channel);
}
protected override void KeyBindDown(GUIBoundKeyEventArgs args)
{
base.KeyBindDown(args);
if (args.CanFocus)
{
Input.GrabKeyboardFocus();
}
}
public void CycleChatChannel(bool forward)
{
Input.IgnoreNext = true;
var idx = Array.IndexOf(ChannelSelectorOrder, SelectedChannel);
do
{
// go over every channel until we find one we can actually select.
idx += forward ? 1 : -1;
idx = MathHelper.Mod(idx, ChannelSelectorOrder.Length);
} while ((ChatMgr.SelectableChannels & ChannelSelectorOrder[idx]) == 0);
SafelySelectChannel(ChannelSelectorOrder[idx]);
}
private void Repopulate()
{
Contents.Clear();
foreach (var msg in ChatMgr.History)
{
WriteChatMessage(msg);
}
}
private void WriteChatMessage(StoredChatMessage message)
{
Logger.DebugS("chat", $"{message.Channel}: {message.Message}");
if (IsFilteredOut(message.Channel))
return;
// TODO: Can make this "smarter" later by only setting it false when the message has been scrolled to
message.Read = true;
var messageText = FormattedMessage.EscapeText(message.Message);
if (!string.IsNullOrEmpty(message.MessageWrap))
{
messageText = string.Format(message.MessageWrap, messageText);
}
var color = message.MessageColorOverride != Color.Transparent
? message.MessageColorOverride
: ChatHelper.ChatColor(message.Channel);
AddLine(messageText, message.Channel, color);
}
private bool IsFilteredOut(ChatChannel channel)
{
return (ChatMgr.ChannelFilters & channel) == 0;
}
private void InputKeyBindDown(GUIBoundKeyEventArgs args)
{
if (args.Function == EngineKeyFunctions.TextReleaseFocus)
{
Input.ReleaseKeyboardFocus();
args.Handle();
return;
}
if (args.Function == ContentKeyFunctions.CycleChatChannelForward)
{
CycleChatChannel(true);
args.Handle();
return;
}
if (args.Function == ContentKeyFunctions.CycleChatChannelBackward)
{
CycleChatChannel(false);
args.Handle();
}
}
private (ChatSelectChannel selChannel, ReadOnlyMemory<char> text) SplitInputContents()
{
var text = Input.Text.AsMemory().Trim();
if (text.Length == 0)
return default;
var prefixChar = text.Span[0];
var channel = GetChannelFromPrefix(prefixChar);
if ((ChatMgr.SelectableChannels & channel) != 0)
// Cut off prefix if it's valid and we can use the channel in question.
text = text[1..];
else
channel = 0;
channel = MapLocalIfGhost(channel);
// Trim from start again to cut out any whitespace between the prefix and message, if any.
return (channel, text.TrimStart());
}
private void InputOnTextChanged(LineEdit.LineEditEventArgs obj)
{
// Update channel select button to correct channel if we have a prefix.
UpdateChannelSelectButton();
}
private static ChatSelectChannel GetChannelFromPrefix(char prefix)
{
return PrefixToChannel.GetValueOrDefault(prefix);
}
public static char GetPrefixFromChannel(ChatSelectChannel channel)
{
return ChannelPrefixes.GetValueOrDefault(channel);
}
public static string ChannelSelectorName(ChatSelectChannel channel)
{
return Loc.GetString($"hud-chatbox-select-channel-{channel}");
}
public static Color ChannelSelectColor(ChatSelectChannel channel)
{
return channel switch
{
ChatSelectChannel.Radio => Color.Green,
ChatSelectChannel.OOC => Color.LightSkyBlue,
ChatSelectChannel.Dead => Color.MediumPurple,
ChatSelectChannel.Admin => Color.Red,
_ => Color.DarkGray
};
}
public void AddLine(string message, ChatChannel channel, Color color)
{
DebugTools.Assert(!Disposed);
var formatted = new FormattedMessage(3);
formatted.PushColor(color);
formatted.AddMarkup(message);
formatted.Pop();
Contents.AddMessage(formatted);
}
private void Input_OnTextEntered(LineEdit.LineEditEventArgs args)
{
if (!string.IsNullOrWhiteSpace(args.Text))
{
var (prefixChannel, text) = SplitInputContents();
// Check if message is longer than the character limit
if (text.Length > ChatMgr.MaxMessageLength)
{
string locWarning = Loc.GetString(
"chat-manager-max-message-length",
("maxMessageLength", ChatMgr.MaxMessageLength));
AddLine(locWarning, ChatChannel.Server, Color.Orange);
return;
}
ChatMgr.OnChatBoxTextSubmitted(this, text, prefixChannel == 0 ? SelectedChannel : prefixChannel);
}
Input.Clear();
UpdateChannelSelectButton();
if (ReleaseFocusOnEnter)
Input.ReleaseKeyboardFocus();
}
public void Focus(ChatSelectChannel? channel = null)
{
var selectStart = Index.End;
if (channel != null)
{
channel = MapLocalIfGhost(channel.Value);
// Channel not selectable, just do NOTHING (not even focus).
if (!((ChatMgr.SelectableChannels & channel.Value) != 0))
return;
var (_, text) = SplitInputContents();
var newPrefix = GetPrefixFromChannel(channel.Value);
DebugTools.Assert(newPrefix != default, "Focus channel must have prefix!");
if (channel == SelectedChannel)
{
// New selected channel is just the selected channel,
// just remove prefix (if any) and leave text unchanged.
Input.Text = text.ToString();
selectStart = Index.Start;
}
else
{
// Change prefix to new focused channel prefix and leave text unchanged.
Input.Text = string.Concat(newPrefix.ToString(), " ", text.Span);
selectStart = Index.FromStart(2);
}
UpdateChannelSelectButton();
}
Input.IgnoreNext = true;
Input.GrabKeyboardFocus();
Input.CursorPosition = Input.Text.Length;
Input.SelectionStart = selectStart.GetOffset(Input.Text.Length);
}
private ChatSelectChannel MapLocalIfGhost(ChatSelectChannel channel)
{
if (channel == ChatSelectChannel.Local && ChatMgr.IsGhost)
return ChatSelectChannel.Dead;
return channel;
}
}
/// <summary>
/// Only needed to avoid the issue where right click on the button closes the popup
/// but leaves the button highlighted.
/// </summary>
public sealed class ChannelSelectorButton : Button
{
public ChannelSelectorButton()
{
// needed so the popup is untoggled regardless of which key is pressed when hovering this button.
// If we don't have this, then right clicking the button while it's toggled on will hide
// the popup but keep the button toggled on
Mode = ActionMode.Press;
EnableAllKeybinds = true;
}
protected override void KeyBindDown(GUIBoundKeyEventArgs args)
{
// needed since we need EnableAllKeybinds - don't double-send both UI click and Use
if (args.Function == EngineKeyFunctions.Use)
return;
base.KeyBindDown(args);
}
}
public sealed class FilterButton : ContainerButton
{
private static readonly Color ColorNormal = Color.FromHex("#7b7e9e");
private static readonly Color ColorHovered = Color.FromHex("#9699bb");
private static readonly Color ColorPressed = Color.FromHex("#789B8C");
private readonly TextureRect _textureRect;
public FilterButton()
{
var filterTexture = IoCManager.Resolve<IResourceCache>()
.GetTexture("/Textures/Interface/Nano/filter.svg.96dpi.png");
// needed for same reason as ChannelSelectorButton
Mode = ActionMode.Press;
EnableAllKeybinds = true;
AddChild(
(_textureRect = new TextureRect
{
Texture = filterTexture,
HorizontalAlignment = HAlignment.Center,
VerticalAlignment = VAlignment.Center
})
);
ToggleMode = true;
}
protected override void KeyBindDown(GUIBoundKeyEventArgs args)
{
// needed since we need EnableAllKeybinds - don't double-send both UI click and Use
if (args.Function == EngineKeyFunctions.Use) return;
base.KeyBindDown(args);
}
private void UpdateChildColors()
{
if (_textureRect == null) return;
switch (DrawMode)
{
case DrawModeEnum.Normal:
_textureRect.ModulateSelfOverride = ColorNormal;
break;
case DrawModeEnum.Pressed:
_textureRect.ModulateSelfOverride = ColorPressed;
break;
case DrawModeEnum.Hover:
_textureRect.ModulateSelfOverride = ColorHovered;
break;
case DrawModeEnum.Disabled:
break;
}
}
protected override void DrawModeChanged()
{
base.DrawModeChanged();
UpdateChildColors();
}
protected override void StylePropertiesChanged()
{
base.StylePropertiesChanged();
UpdateChildColors();
}
}
public sealed class ChannelItemButton : Button
{
public readonly ChatSelectChannel Channel;
public ChannelItemButton(ChatSelectChannel channel)
{
Channel = channel;
AddStyleClass(StyleNano.StyleClassChatChannelSelectorButton);
Text = ChatBox.ChannelSelectorName(channel);
var prefix = ChatBox.GetPrefixFromChannel(channel);
if (prefix != default)
Text = Loc.GetString("hud-chatbox-select-name-prefixed", ("name", Text), ("prefix", prefix));
}
}
public sealed class ChannelFilterCheckbox : CheckBox
{
public readonly ChatChannel Channel;
public ChannelFilterCheckbox(ChatChannel channel, int? unreadCount)
{
Channel = channel;
UpdateText(unreadCount);
}
private void UpdateText(int? unread)
{
var name = Loc.GetString($"hud-chatbox-channel-{Channel}");
if (unread > 0)
// todo: proper fluent stuff here.
name += " (" + (unread > 9 ? "9+" : unread) + ")";
Text = name;
}
public void UpdateUnreadCount(int? unread)
{
UpdateText(unread);
}
}
public readonly struct ChatResizedEventArgs
{
/// new bottom that the chat rect is going to have in virtual pixels
/// after the imminent relayout
public readonly float NewBottom;
public ChatResizedEventArgs(float newBottom)
{
NewBottom = newBottom;
}
}
}

View File

@@ -0,0 +1,235 @@
using System;
using Robust.Client.Graphics;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using Robust.Shared.Input;
using Robust.Shared.IoC;
using Robust.Shared.Maths;
using Robust.Shared.Timing;
namespace Content.Client.Chat.UI
{
public class HudChatBox : ChatBox
{
// TODO: Revisit the resizing stuff after https://github.com/space-wizards/RobustToolbox/issues/1392 is done,
// Probably not "supposed" to inject IClyde, but I give up.
// I can't find any other way to allow this control to properly resize when the
// window is resized. Resized() isn't reliably called when resizing the window,
// and layoutcontainer anchor / margin don't seem to adjust how we need
// them to when the window is resized. We need it to be able to resize
// within some bounds so that it doesn't overlap other UI elements, while still
// being freely resizable within those bounds.
[Dependency] private readonly IClyde _clyde = default!;
public const float InitialChatBottom = 235;
private const int DragMarginSize = 7;
private const int MinDistanceFromBottom = 255;
private const int MinLeft = 500;
private DragMode _currentDrag = DragMode.None;
private Vector2 _dragOffsetTopLeft;
private Vector2 _dragOffsetBottomRight;
private byte _clampIn;
protected override void EnteredTree()
{
base.EnteredTree();
_clyde.OnWindowResized += ClydeOnOnWindowResized;
}
protected override void ExitedTree()
{
base.ExitedTree();
_clyde.OnWindowResized -= ClydeOnOnWindowResized;
}
protected override void KeyBindDown(GUIBoundKeyEventArgs args)
{
if (args.Function == EngineKeyFunctions.UIClick)
{
_currentDrag = GetDragModeFor(args.RelativePosition);
if (_currentDrag != DragMode.None)
{
_dragOffsetTopLeft = args.PointerLocation.Position / UIScale - Position;
_dragOffsetBottomRight = Position + Size - args.PointerLocation.Position / UIScale;
}
}
base.KeyBindDown(args);
}
protected override void KeyBindUp(GUIBoundKeyEventArgs args)
{
base.KeyBindUp(args);
if (args.Function != EngineKeyFunctions.UIClick)
return;
_dragOffsetTopLeft = _dragOffsetBottomRight = Vector2.Zero;
_currentDrag = DragMode.None;
// If this is done in MouseDown, Godot won't fire MouseUp as you need focus to receive MouseUps.
UserInterfaceManager.KeyboardFocused?.ReleaseKeyboardFocus();
}
// TODO: this drag and drop stuff is somewhat duplicated from Robust BaseWindow but also modified
[Flags]
private enum DragMode : byte
{
None = 0,
Bottom = 1 << 1,
Left = 1 << 2
}
private DragMode GetDragModeFor(Vector2 relativeMousePos)
{
var mode = DragMode.None;
if (relativeMousePos.Y > Size.Y - DragMarginSize)
{
mode = DragMode.Bottom;
}
if (relativeMousePos.X < DragMarginSize)
{
mode |= DragMode.Left;
}
return mode;
}
protected override void MouseMove(GUIMouseMoveEventArgs args)
{
base.MouseMove(args);
if (Parent == null)
return;
if (_currentDrag == DragMode.None)
{
var cursor = CursorShape.Arrow;
var previewDragMode = GetDragModeFor(args.RelativePosition);
switch (previewDragMode)
{
case DragMode.Bottom:
cursor = CursorShape.VResize;
break;
case DragMode.Left:
cursor = CursorShape.HResize;
break;
case DragMode.Bottom | DragMode.Left:
cursor = CursorShape.Crosshair;
break;
}
DefaultCursorShape = cursor;
}
else
{
var top = Rect.Top;
var bottom = Rect.Bottom;
var left = Rect.Left;
var right = Rect.Right;
var (minSizeX, minSizeY) = MinSize;
if ((_currentDrag & DragMode.Bottom) == DragMode.Bottom)
{
bottom = Math.Max(args.GlobalPosition.Y + _dragOffsetBottomRight.Y, top + minSizeY);
}
if ((_currentDrag & DragMode.Left) == DragMode.Left)
{
var maxX = right - minSizeX;
left = Math.Min(args.GlobalPosition.X - _dragOffsetTopLeft.X, maxX);
}
ClampSize(left, bottom);
}
}
protected override void UIScaleChanged()
{
base.UIScaleChanged();
ClampAfterDelay();
}
private void ClydeOnOnWindowResized(WindowResizedEventArgs obj)
{
ClampAfterDelay();
}
private void ClampAfterDelay()
{
_clampIn = 2;
}
protected override void FrameUpdate(FrameEventArgs args)
{
base.FrameUpdate(args);
// we do the clamping after a delay (after UI scale / window resize)
// because we need to wait for our parent container to properly resize
// first, so we can calculate where we should go. If we do it right away,
// we won't have the correct values from the parent to know how to adjust our margins.
if (_clampIn <= 0)
return;
_clampIn -= 1;
if (_clampIn == 0)
ClampSize();
}
private void ClampSize(float? desiredLeft = null, float? desiredBottom = null)
{
if (Parent == null)
return;
// var top = Rect.Top;
var right = Rect.Right;
var left = desiredLeft ?? Rect.Left;
var bottom = desiredBottom ?? Rect.Bottom;
// clamp so it doesn't go too high or low (leave space for alerts UI)
var maxBottom = Parent.Size.Y - MinDistanceFromBottom;
if (maxBottom <= MinHeight)
{
// we can't fit in our given space (window made awkwardly small), so give up
// and overlap at our min height
bottom = MinHeight;
}
else
{
bottom = Math.Clamp(bottom, MinHeight, maxBottom);
}
var maxLeft = Parent.Size.X - MinWidth;
if (maxLeft <= MinLeft)
{
// window too narrow, give up and overlap at our max left
left = maxLeft;
}
else
{
left = Math.Clamp(left, MinLeft, maxLeft);
}
LayoutContainer.SetMarginLeft(this, -((right + 10) - left));
LayoutContainer.SetMarginBottom(this, bottom);
ChatMgr.ChatBoxOnResized(new ChatResizedEventArgs(bottom));
}
protected override void MouseExited()
{
base.MouseExited();
if (_currentDrag == DragMode.None)
DefaultCursorShape = CursorShape.Arrow;
}
}
}

View File

@@ -153,6 +153,8 @@ namespace Content.Client.EscapeMenu.UI
AddButton(ContentKeyFunctions.FocusRadio);
AddButton(ContentKeyFunctions.FocusOOC);
AddButton(ContentKeyFunctions.FocusAdminChat);
AddButton(ContentKeyFunctions.FocusDeadChat);
AddButton(ContentKeyFunctions.FocusConsoleChat);
AddButton(ContentKeyFunctions.CycleChatChannelForward);
AddButton(ContentKeyFunctions.CycleChatChannelBackward);
AddButton(ContentKeyFunctions.OpenCharacterMenu);

View File

@@ -17,6 +17,8 @@ namespace Content.Client.Input
common.AddFunction(ContentKeyFunctions.FocusRadio);
common.AddFunction(ContentKeyFunctions.FocusOOC);
common.AddFunction(ContentKeyFunctions.FocusAdminChat);
common.AddFunction(ContentKeyFunctions.FocusConsoleChat);
common.AddFunction(ContentKeyFunctions.FocusDeadChat);
common.AddFunction(ContentKeyFunctions.CycleChatChannelForward);
common.AddFunction(ContentKeyFunctions.CycleChatChannelBackward);
common.AddFunction(ContentKeyFunctions.ExamineEntity);

View File

@@ -1,5 +1,6 @@
using System;
using System.Linq;
using Content.Client.Chat;
using Content.Client.Chat.Managers;
using Content.Client.EscapeMenu.UI;
using Content.Client.GameTicking.Managers;
@@ -74,24 +75,9 @@ namespace Content.Client.Lobby
_chatManager.SetChatBox(_lobby.Chat);
_voteManager.SetPopupContainer(_lobby.VoteContainer);
_lobby.Chat.DefaultChatFormat = "ooc \"{0}\"";
_lobby.ServerName.Text = _baseClient.GameInfo?.ServerName;
_inputManager.SetInputCommand(ContentKeyFunctions.FocusChat,
InputCmdHandler.FromDelegate(_ => GameScreen.FocusChat(_lobby.Chat)));
_inputManager.SetInputCommand(ContentKeyFunctions.FocusOOC,
InputCmdHandler.FromDelegate(_ => GameScreen.FocusChannel(_lobby.Chat, ChatChannel.OOC)));
_inputManager.SetInputCommand(ContentKeyFunctions.FocusAdminChat,
InputCmdHandler.FromDelegate(_ => GameScreen.FocusChannel(_lobby.Chat, ChatChannel.AdminChat)));
_inputManager.SetInputCommand(ContentKeyFunctions.CycleChatChannelForward,
InputCmdHandler.FromDelegate(_ => _lobby.Chat.CycleChatChannel(true)));
_inputManager.SetInputCommand(ContentKeyFunctions.CycleChatChannelBackward,
InputCmdHandler.FromDelegate(_ => _lobby.Chat.CycleChatChannel(false)));
ChatInput.SetupChatInputHandlers(_inputManager, _lobby.Chat);
UpdateLobbyUi();

View File

@@ -1,4 +1,5 @@
using Content.Client.Administration.Managers;
using Content.Client.Chat;
using Content.Client.Chat.Managers;
using Content.Client.Chat.UI;
using Content.Client.Construction.UI;
@@ -6,13 +7,11 @@ using Content.Client.HUD;
using Content.Client.HUD.UI;
using Content.Client.Voting;
using Content.Shared.Chat;
using Content.Shared.Input;
using Robust.Client.Graphics;
using Robust.Client.Input;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using Robust.Shared.Configuration;
using Robust.Shared.Input.Binding;
using Robust.Shared.IoC;
using Robust.Shared.Maths;
using Robust.Shared.Timing;
@@ -43,7 +42,16 @@ namespace Content.Client.Viewport
{
base.Startup();
_gameChat = new ChatBox();
_gameChat = new HudChatBox {PreferredChannel = ChatSelectChannel.Local};
UserInterfaceManager.StateRoot.AddChild(_gameChat);
LayoutContainer.SetAnchorAndMarginPreset(_gameChat, LayoutContainer.LayoutPreset.TopRight, margin: 10);
LayoutContainer.SetAnchorAndMarginPreset(_gameChat, LayoutContainer.LayoutPreset.TopRight, margin: 10);
LayoutContainer.SetMarginLeft(_gameChat, -475);
LayoutContainer.SetMarginBottom(_gameChat, HudChatBox.InitialChatBottom);
_chatManager.ChatBoxOnResized(new ChatResizedEventArgs(HudChatBox.InitialChatBottom));
Viewport = new MainViewport
{
Viewport =
@@ -59,28 +67,8 @@ namespace Content.Client.Viewport
_userInterfaceManager.StateRoot.AddChild(_gameHud.RootControl);
_chatManager.SetChatBox(_gameChat);
_voteManager.SetPopupContainer(_gameHud.VoteContainer);
_gameChat.DefaultChatFormat = "say \"{0}\"";
_inputManager.SetInputCommand(ContentKeyFunctions.FocusChat,
InputCmdHandler.FromDelegate(_ => FocusChat(_gameChat)));
_inputManager.SetInputCommand(ContentKeyFunctions.FocusOOC,
InputCmdHandler.FromDelegate(_ => FocusChannel(_gameChat, ChatChannel.OOC)));
_inputManager.SetInputCommand(ContentKeyFunctions.FocusLocalChat,
InputCmdHandler.FromDelegate(_ => FocusChannel(_gameChat, ChatChannel.Local)));
_inputManager.SetInputCommand(ContentKeyFunctions.FocusRadio,
InputCmdHandler.FromDelegate(_ => FocusChannel(_gameChat, ChatChannel.Radio)));
_inputManager.SetInputCommand(ContentKeyFunctions.FocusAdminChat,
InputCmdHandler.FromDelegate(_ => FocusChannel(_gameChat, ChatChannel.AdminChat)));
_inputManager.SetInputCommand(ContentKeyFunctions.CycleChatChannelForward,
InputCmdHandler.FromDelegate(_ => _gameChat.CycleChatChannel(true)));
_inputManager.SetInputCommand(ContentKeyFunctions.CycleChatChannelBackward,
InputCmdHandler.FromDelegate(_ => _gameChat.CycleChatChannel(false)));
ChatInput.SetupChatInputHandlers(_inputManager, _gameChat);
SetupPresenters();
@@ -98,7 +86,6 @@ namespace Content.Client.Viewport
_gameHud.RootControl.Orphan();
// Clear viewport to some fallback, whatever.
_eyeManager.MainViewport = _userInterfaceManager.MainViewport;
}
/// <summary>
@@ -120,23 +107,17 @@ namespace Content.Client.Viewport
internal static void FocusChat(ChatBox chat)
{
if (chat.UserInterfaceManager.KeyboardFocused != null)
{
return;
chat.Focus();
}
chat.Input.IgnoreNext = true;
chat.Input.GrabKeyboardFocus();
}
internal static void FocusChannel(ChatBox chat, ChatChannel channel)
internal static void FocusChannel(ChatBox chat, ChatSelectChannel channel)
{
if (chat.UserInterfaceManager.KeyboardFocused != null)
{
return;
}
chat.SelectChannel(channel);
chat.Input.IgnoreNext = true;
chat.Input.GrabKeyboardFocus();
chat.Focus(channel);
}
public override void FrameUpdate(FrameEventArgs e)

View File

@@ -325,7 +325,7 @@ namespace Content.Server.Chat.Managers
var msg = _netManager.CreateNetMessage<MsgChatMessage>();
msg.Channel = ChatChannel.AdminChat;
msg.Channel = ChatChannel.Admin;
msg.Message = message;
msg.MessageWrap = Loc.GetString("chat-manager-send-admin-chat-wrap-message",
("adminChannelName", Loc.GetString("chat-manager-admin-channel-name")),
@@ -341,7 +341,7 @@ namespace Content.Server.Chat.Managers
var msg = _netManager.CreateNetMessage<MsgChatMessage>();
msg.Channel = ChatChannel.AdminChat;
msg.Channel = ChatChannel.Admin;
msg.Message = message;
msg.MessageWrap = Loc.GetString("chat-manager-send-admin-announcement-wrap-message",
("adminChannelName", Loc.GetString("chat-manager-admin-channel-name")));

View File

@@ -6,60 +6,60 @@ namespace Content.Shared.Chat
/// Represents chat channels that the player can filter chat tabs by.
/// </summary>
[Flags]
public enum ChatChannel : short
public enum ChatChannel : ushort
{
None = 0,
/// <summary>
/// Chat heard by players within earshot
/// </summary>
Local = 1,
Local = 1 << 0,
/// <summary>
/// Messages from the server
/// </summary>
Server = 2,
Server = 1 << 1,
/// <summary>
/// Damage messages
/// </summary>
Damage = 4,
Damage = 1 << 2,
/// <summary>
/// Radio messages
/// </summary>
Radio = 8,
Radio = 1 << 3,
/// <summary>
/// Out-of-character channel
/// </summary>
OOC = 16,
OOC = 1 << 4,
/// <summary>
/// Visual events the player can see.
/// Basically like visual_message in SS13.
/// </summary>
Visual = 32,
Visual = 1 << 5,
/// <summary>
/// Emotes
/// </summary>
Emotes = 64,
Emotes = 1 << 6,
/// <summary>
/// Deadchat
/// </summary>
Dead = 128,
Dead = 1 << 7,
/// <summary>
/// Admin chat
/// </summary>
AdminChat = 256,
Admin = 1 << 8,
/// <summary>
/// Unspecified.
/// </summary>
Unspecified = 512,
Unspecified = 1 << 9,
/// <summary>
/// Channels considered to be IC.

View File

@@ -0,0 +1,48 @@
using System;
namespace Content.Shared.Chat
{
/// <summary>
/// Chat channels that the player can select in the chat box.
/// </summary>
/// <remarks>
/// Maps to <see cref="ChatChannel"/>, giving better names.
/// </remarks>
[Flags]
public enum ChatSelectChannel : ushort
{
None = 0,
/// <summary>
/// Chat heard by players within earshot
/// </summary>
Local = ChatChannel.Local,
/// <summary>
/// Radio messages
/// </summary>
Radio = ChatChannel.Radio,
/// <summary>
/// Out-of-character channel
/// </summary>
OOC = ChatChannel.OOC,
/// <summary>
/// Emotes
/// </summary>
Emotes = ChatChannel.Emotes,
/// <summary>
/// Deadchat
/// </summary>
Dead = ChatChannel.Dead,
/// <summary>
/// Admin chat
/// </summary>
Admin = ChatChannel.Admin,
Console = ChatChannel.Unspecified
}
}

View File

@@ -51,7 +51,7 @@ namespace Content.Shared.Chat
{
case ChatChannel.Local:
case ChatChannel.Dead:
case ChatChannel.AdminChat:
case ChatChannel.Admin:
case ChatChannel.Emotes:
SenderEntity = buffer.ReadEntityUid();
break;
@@ -69,7 +69,7 @@ namespace Content.Shared.Chat
{
case ChatChannel.Local:
case ChatChannel.Dead:
case ChatChannel.AdminChat:
case ChatChannel.Admin:
case ChatChannel.Emotes:
buffer.Write(SenderEntity);
break;

View File

@@ -15,6 +15,8 @@ namespace Content.Shared.Input
public static readonly BoundKeyFunction FocusRadio = "FocusRadioWindow";
public static readonly BoundKeyFunction FocusOOC = "FocusOOCWindow";
public static readonly BoundKeyFunction FocusAdminChat = "FocusAdminChatWindow";
public static readonly BoundKeyFunction FocusDeadChat = "FocusDeadChatWindow";
public static readonly BoundKeyFunction FocusConsoleChat = "FocusConsoleChatWindow";
public static readonly BoundKeyFunction CycleChatChannelForward = "CycleChatChannelForward";
public static readonly BoundKeyFunction CycleChatChannelBackward = "CycleChatChannelBackward";
public static readonly BoundKeyFunction OpenCharacterMenu = "OpenCharacterMenu";

View File

@@ -1,4 +1,20 @@
hud-chatbox-info = T to talk, Tab to cycle channels.
hud-chatbox-admin = Admin
hud-chatbox-ooc = OOC
hud-chatbox-console = Console
hud-chatbox-select-name-prefixed = {$prefix} {$name}
hud-chatbox-select-channel-Admin = Admin
hud-chatbox-select-channel-Console = Console
hud-chatbox-select-channel-Dead = Dead
hud-chatbox-select-channel-Emotes = Emotes
hud-chatbox-select-channel-Local = Local
hud-chatbox-select-channel-OOC = OOC
hud-chatbox-select-channel-Radio = Radio
hud-chatbox-channel-Admin = Admin
hud-chatbox-channel-Dead = Dead
hud-chatbox-channel-Emotes = Emotes
hud-chatbox-channel-Local = Local
hud-chatbox-channel-OOC = OOC
hud-chatbox-channel-Radio = Radio
hud-chatbox-channel-Server = Server
hud-chatbox-channel-Visual = Visual
hud-chatbox-channel-Unspecified = Unspecified

View File

@@ -85,6 +85,8 @@ ui-options-function-focus-local-chat-window = Focus chat (IC)
ui-options-function-focus-radio-window = Focus chat (Radio)
ui-options-function-focus-ooc-window = Focus chat (OOC)
ui-options-function-focus-admin-chat-window = Focus chat (Admin)
ui-options-function-focus-dead-chat-window = Focus chat (Dead)
ui-options-function-focus-console-chat-window = Focus chat (Console)
ui-options-function-cycle-chat-channel-forward = Cycle channel (Forward)
ui-options-function-cycle-chat-channel-backward = Cycle channel (Backward)
ui-options-function-open-character-menu = Open character menu

View File

@@ -63,16 +63,22 @@ binds:
key: T
- function: FocusLocalChatWindow
type: State
key: LBracket
key: Period
- function: FocusRadioWindow
type: State
key: SemiColon
- function: FocusOOCWindow
type: State
key: RBracket
key: LBracket
- function: FocusAdminChatWindow
type: State
key: BackSlash
key: RBracket
- function: FocusDeadChatWindow
type: State
key: Backslash
- function: FocusConsoleChatWindow
type: State
key: Slash
- function: EditorLinePlace
type: State
key: MouseLeft

View File

@@ -63,6 +63,7 @@
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=UTF/@EntryIndexedValue">UTF</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=UV/@EntryIndexedValue">UV</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/CSharpNaming/Abbreviations/=VP/@EntryIndexedValue">VP</s:String>
<s:String x:Key="/Default/CodeStyle/Naming/XamlNaming/Abbreviations/=UI/@EntryIndexedValue">UI</s:String>
<s:Boolean x:Key="/Default/Environment/Filtering/ExcludeCoverageFilters/=Lidgren_002ENetwork_003B_002A_003B_002A_003B_002A/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EdotCover_002EIde_002ECore_002EFilterManagement_002EModel_002ESolutionFilterSettingsManagerMigrateSettings/@EntryIndexedValue">True</s:Boolean>
<s:Boolean x:Key="/Default/Environment/SettingsMigration/IsMigratorApplied/=JetBrains_002EReSharper_002EFeature_002EServices_002EDaemon_002ESettings_002EMigration_002ESwaWarningsModeSettingsMigrate/@EntryIndexedValue">True</s:Boolean>