diff --git a/Content.Client/Alerts/UI/AlertsUI.cs b/Content.Client/Alerts/UI/AlertsUI.cs index f5064438ee..46e6721acd 100644 --- a/Content.Client/Alerts/UI/AlertsUI.cs +++ b/Content.Client/Alerts/UI/AlertsUI.cs @@ -48,7 +48,7 @@ namespace Content.Client.Alerts.UI base.EnteredTree(); var _chatManager = IoCManager.Resolve(); _chatManager.OnChatBoxResized += OnChatResized; - OnChatResized(new ChatResizedEventArgs(ChatBox.InitialChatBottom)); + OnChatResized(new ChatResizedEventArgs(HudChatBox.InitialChatBottom)); } protected override void ExitedTree() diff --git a/Content.Client/Chat/ChatHelper.cs b/Content.Client/Chat/ChatHelper.cs index 076a6995cf..350ab6aa1c 100644 --- a/Content.Client/Chat/ChatHelper.cs +++ b/Content.Client/Chat/ChatHelper.cs @@ -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 }; } diff --git a/Content.Client/Chat/ChatInput.cs b/Content.Client/Chat/ChatInput.cs new file mode 100644 index 0000000000..541cd8dc9f --- /dev/null +++ b/Content.Client/Chat/ChatInput.cs @@ -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))); + } + } +} diff --git a/Content.Client/Chat/Managers/ChatManager.cs b/Content.Client/Chat/Managers/ChatManager.cs index 6ec3cfffa7..78f3fd9269 100644 --- a/Content.Client/Chat/Managers/ChatManager.cs +++ b/Content.Client/Chat/Managers/ChatManager.cs @@ -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 /// /// The max amount of characters an entity can send in one message /// - 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 _history = new(); + public IReadOnlyList History => _history; - private readonly List _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 _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 _filterableChannels = new(); - private readonly List _selectableChannels = new(); - - // Flag Enums for holding filtered channels - private ChatChannel _filteredChannels; + public ChatSelectChannel SelectableChannels { get; private set; } + public ChatChannel FilterableChannels { get; private set; } /// /// 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). /// - private readonly Dictionary _unreadMessages = new(); + private readonly Dictionary _unreadMessages = new(); + + public IReadOnlyDictionary 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. /// public ChatBox? CurrentChatBox { get; private set; } + /// /// Invoked when CurrentChatBox is resized (including after setting initial default size) /// public event Action? OnChatBoxResized; + public event Action? ChatPermissionsUpdated; + public event Action? UnreadMessageCountsUpdated; + public event Action? MessageAdded; + public event Action? FiltersUpdated; + private Control _speechBubbleRoot = null!; /// @@ -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() ?? 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() ?? 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); - } - - /// - /// Adds the channel to the set of filterable channels, defaulting it as enabled - /// if it doesn't currently have an explicit enable/disable setting - /// - 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() ?? 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) - { - CurrentChatBox.TextSubmitted += OnChatBoxTextSubmitted; - CurrentChatBox.FilterToggled += OnFilterButtonToggled; - CurrentChatBox.OnResized += ChatBoxOnResized; - - CurrentChatBox.SetChannelPermissions(_selectableChannels, _filterableChannels, _channelFilters, _unreadMessages, false); - } - - RepopulateChat(_filteredHistory); } - private void ChatBoxOnResized(ChatResizedEventArgs chatResizedEventArgs) + public void ClearUnfilteredUnreads() + { + foreach (var channel in _unreadMessages.Keys.ToArray()) + { + if ((ChannelFilters & channel) != 0) + _unreadMessages.Remove(channel); + } + } + + 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 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) + switch (channel) { - 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: - { + 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)}\""); - break; - } - case AdminChatAlias: - { - var conInput = text.Substring(1); - if (string.IsNullOrWhiteSpace(conInput)) - return; - if (_adminMgr.HasFlag(AdminFlags.Admin)) - { - _consoleHost.ExecuteCommand($"asay \"{CommandParsing.Escape(conInput)}\""); - } - else - { - _consoleHost.ExecuteCommand($"ooc \"{CommandParsing.Escape(conInput)}\""); - } + case ChatSelectChannel.OOC: + _consoleHost.ExecuteCommand($"ooc \"{CommandParsing.Escape(str)}\""); break; - } - case MeAlias: - { - var conInput = text.Substring(1); - if (string.IsNullOrWhiteSpace(conInput)) - return; - _consoleHost.ExecuteCommand($"me \"{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 + 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 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 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() ?? 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 { /// diff --git a/Content.Client/Chat/Managers/IChatManager.cs b/Content.Client/Chat/Managers/IChatManager.cs index 64367365f2..2c25b1b7ee 100644 --- a/Content.Client/Chat/Managers/IChatManager.cs +++ b/Content.Client/Chat/Managers/IChatManager.cs @@ -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 /// ChatBox? CurrentChatBox { get; } + IReadOnlyDictionary UnreadMessages { get; } + IReadOnlyList History { get; } + int MaxMessageLength { get; } + bool IsGhost { get; } + /// /// Invoked when CurrentChatBox is resized (including after setting initial default size) /// event Action? OnChatBoxResized; + + event Action? ChatPermissionsUpdated; + event Action? UnreadMessageCountsUpdated; + event Action? MessageAdded; + event Action? FiltersUpdated; + + void ClearUnfilteredUnreads(); + void ChatBoxOnResized(ChatResizedEventArgs chatResizedEventArgs); + void OnChatBoxTextSubmitted(ChatBox chatBox, ReadOnlyMemory text, ChatSelectChannel channel); + void OnFilterButtonToggled(ChatChannel channel, bool enabled); + } + + public struct ChatPermissionsUpdatedEventArgs + { + public ChatSelectChannel OldSelectableChannels; } } diff --git a/Content.Client/Chat/UI/ChatBox.cs b/Content.Client/Chat/UI/ChatBox.cs deleted file mode 100644 index f94cdcaded..0000000000 --- a/Content.Client/Chat/UI/ChatBox.cs +++ /dev/null @@ -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? OnResized; - - // order in which the available channel filters show up when available - public static readonly IReadOnlyList ChannelFilterOrder = new List - { - 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 ChannelSelectorOrder = new List - { - 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; - - /// - /// Will be Unspecified if set to Console - /// - public ChatChannel SelectedChannel = ChatChannel.Unspecified; - - /// - /// Default formatting string for the ClientChatConsole. - /// - 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 SelectableChannels = new(); - - /// - /// 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. - /// - public ChatBox() - { - //TODO Paul needs to fix xaml ctor args so we can pass this instead of resolving it. - var stateManager = IoCManager.Resolve(); - _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(); - 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; - } - } - - - /// - /// Update the available filters / selectable channels and the current filter settings using the provided - /// data. - /// - /// channels currently selectable to send on - /// channels currently able ot filter on - /// 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) - /// unread message counts for each disabled channel, values 10 or higher will show as 9+ - public void SetChannelPermissions(List selectableChannels, IReadOnlySet filterableChannels, - IReadOnlyDictionary channelFilters, IReadOnlyDictionary 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)}); - } - - /// - /// Update the unread message counts in the filters based on the provided data. - /// - /// counts for each channel, any values above 9 will show as 9+ - public void UpdateUnreadMessageCounts(IReadOnlyDictionary 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(); - } - - - /// - /// 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. - /// - 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(); - } - } - } - - /// - /// Only needed to avoid the issue where right click on the button closes the popup - /// but leaves the button highlighted. - /// - 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() - .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; - } - } -} diff --git a/Content.Client/Chat/UI/ChatBox.xaml b/Content.Client/Chat/UI/ChatBox.xaml new file mode 100644 index 0000000000..3f66665d95 --- /dev/null +++ b/Content.Client/Chat/UI/ChatBox.xaml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + diff --git a/Content.Client/Chat/UI/ChatBox.xaml.cs b/Content.Client/Chat/UI/ChatBox.xaml.cs new file mode 100644 index 0000000000..ee3ff56d9f --- /dev/null +++ b/Content.Client/Chat/UI/ChatBox.xaml.cs @@ -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 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 ChannelPrefixes = + PrefixToChannel.ToDictionary(kv => kv.Value, kv => kv.Key); + + private const float FilterPopupWidth = 110; + + /// + /// The currently default channel that will be used if no prefix is specified. + /// + public ChatSelectChannel SelectedChannel { get; private set; } = ChatSelectChannel.OOC; + + /// + /// 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. + /// + 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; + + /// + /// 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. + /// + 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 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; + } + } + + /// + /// Only needed to avoid the issue where right click on the button closes the popup + /// but leaves the button highlighted. + /// + 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() + .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; + } + } +} diff --git a/Content.Client/Chat/UI/HudChatBox.cs b/Content.Client/Chat/UI/HudChatBox.cs new file mode 100644 index 0000000000..824a906d01 --- /dev/null +++ b/Content.Client/Chat/UI/HudChatBox.cs @@ -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; + } + } +} diff --git a/Content.Client/EscapeMenu/UI/OptionsMenu.KeyRebind.cs b/Content.Client/EscapeMenu/UI/OptionsMenu.KeyRebind.cs index ec11304ffe..b3d82ff264 100644 --- a/Content.Client/EscapeMenu/UI/OptionsMenu.KeyRebind.cs +++ b/Content.Client/EscapeMenu/UI/OptionsMenu.KeyRebind.cs @@ -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); diff --git a/Content.Client/Input/ContentContexts.cs b/Content.Client/Input/ContentContexts.cs index 80e4cc96f4..494a85befd 100644 --- a/Content.Client/Input/ContentContexts.cs +++ b/Content.Client/Input/ContentContexts.cs @@ -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); diff --git a/Content.Client/Lobby/LobbyState.cs b/Content.Client/Lobby/LobbyState.cs index 6689c0f7c9..fb85b825e3 100644 --- a/Content.Client/Lobby/LobbyState.cs +++ b/Content.Client/Lobby/LobbyState.cs @@ -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(); diff --git a/Content.Client/Viewport/GameScreen.cs b/Content.Client/Viewport/GameScreen.cs index 2052f41317..522f7e5f0b 100644 --- a/Content.Client/Viewport/GameScreen.cs +++ b/Content.Client/Viewport/GameScreen.cs @@ -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; - } /// @@ -120,23 +107,17 @@ namespace Content.Client.Viewport internal static void FocusChat(ChatBox chat) { if (chat.UserInterfaceManager.KeyboardFocused != null) - { return; - } - chat.Input.IgnoreNext = true; - chat.Input.GrabKeyboardFocus(); + chat.Focus(); } - 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) diff --git a/Content.Server/Chat/Managers/ChatManager.cs b/Content.Server/Chat/Managers/ChatManager.cs index 8bc32991b4..9ea60b2a35 100644 --- a/Content.Server/Chat/Managers/ChatManager.cs +++ b/Content.Server/Chat/Managers/ChatManager.cs @@ -325,7 +325,7 @@ namespace Content.Server.Chat.Managers var msg = _netManager.CreateNetMessage(); - 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(); - 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"))); diff --git a/Content.Shared/Chat/ChatChannel.cs b/Content.Shared/Chat/ChatChannel.cs index ba5afe0be2..fb93d172db 100644 --- a/Content.Shared/Chat/ChatChannel.cs +++ b/Content.Shared/Chat/ChatChannel.cs @@ -6,60 +6,60 @@ namespace Content.Shared.Chat /// Represents chat channels that the player can filter chat tabs by. /// [Flags] - public enum ChatChannel : short + public enum ChatChannel : ushort { None = 0, /// /// Chat heard by players within earshot /// - Local = 1, + Local = 1 << 0, /// /// Messages from the server /// - Server = 2, + Server = 1 << 1, /// /// Damage messages /// - Damage = 4, + Damage = 1 << 2, /// /// Radio messages /// - Radio = 8, + Radio = 1 << 3, /// /// Out-of-character channel /// - OOC = 16, + OOC = 1 << 4, /// /// Visual events the player can see. /// Basically like visual_message in SS13. /// - Visual = 32, + Visual = 1 << 5, /// /// Emotes /// - Emotes = 64, + Emotes = 1 << 6, /// /// Deadchat /// - Dead = 128, + Dead = 1 << 7, /// /// Admin chat /// - AdminChat = 256, + Admin = 1 << 8, /// /// Unspecified. /// - Unspecified = 512, + Unspecified = 1 << 9, /// /// Channels considered to be IC. diff --git a/Content.Shared/Chat/ChatSelectChannel.cs b/Content.Shared/Chat/ChatSelectChannel.cs new file mode 100644 index 0000000000..56d70cf1d1 --- /dev/null +++ b/Content.Shared/Chat/ChatSelectChannel.cs @@ -0,0 +1,48 @@ +using System; + +namespace Content.Shared.Chat +{ + /// + /// Chat channels that the player can select in the chat box. + /// + /// + /// Maps to , giving better names. + /// + [Flags] + public enum ChatSelectChannel : ushort + { + None = 0, + + /// + /// Chat heard by players within earshot + /// + Local = ChatChannel.Local, + + /// + /// Radio messages + /// + Radio = ChatChannel.Radio, + + /// + /// Out-of-character channel + /// + OOC = ChatChannel.OOC, + + /// + /// Emotes + /// + Emotes = ChatChannel.Emotes, + + /// + /// Deadchat + /// + Dead = ChatChannel.Dead, + + /// + /// Admin chat + /// + Admin = ChatChannel.Admin, + + Console = ChatChannel.Unspecified + } +} diff --git a/Content.Shared/Chat/MsgChatMessage.cs b/Content.Shared/Chat/MsgChatMessage.cs index f2ca14a048..eae2733c76 100644 --- a/Content.Shared/Chat/MsgChatMessage.cs +++ b/Content.Shared/Chat/MsgChatMessage.cs @@ -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; diff --git a/Content.Shared/Input/ContentKeyFunctions.cs b/Content.Shared/Input/ContentKeyFunctions.cs index 5a86bc6615..c5327ba431 100644 --- a/Content.Shared/Input/ContentKeyFunctions.cs +++ b/Content.Shared/Input/ContentKeyFunctions.cs @@ -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"; diff --git a/Resources/Locale/en-US/chat/ui/chat-box.ftl b/Resources/Locale/en-US/chat/ui/chat-box.ftl index aa9d912893..84a7d09283 100644 --- a/Resources/Locale/en-US/chat/ui/chat-box.ftl +++ b/Resources/Locale/en-US/chat/ui/chat-box.ftl @@ -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 diff --git a/Resources/Locale/en-US/escape-menu/ui/options-menu.ftl b/Resources/Locale/en-US/escape-menu/ui/options-menu.ftl index d2746a3c6e..812fbbb050 100644 --- a/Resources/Locale/en-US/escape-menu/ui/options-menu.ftl +++ b/Resources/Locale/en-US/escape-menu/ui/options-menu.ftl @@ -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 @@ -130,4 +132,4 @@ ui-options-function-loadout5 = Hotbar Loadout 5 ui-options-function-loadout6 = Hotbar Loadout 6 ui-options-function-loadout7 = Hotbar Loadout 7 ui-options-function-loadout8 = Hotbar Loadout 8 -ui-options-function-loadout9 = Hotbar Loadout 9 \ No newline at end of file +ui-options-function-loadout9 = Hotbar Loadout 9 diff --git a/Resources/keybinds.yml b/Resources/keybinds.yml index 7de1f3b1d0..6a91958376 100644 --- a/Resources/keybinds.yml +++ b/Resources/keybinds.yml @@ -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 diff --git a/SpaceStation14.sln.DotSettings b/SpaceStation14.sln.DotSettings index 4e62aebed4..5b58264536 100644 --- a/SpaceStation14.sln.DotSettings +++ b/SpaceStation14.sln.DotSettings @@ -63,6 +63,7 @@ UTF UV VP + UI True True True