diff --git a/Content.Client/Chat/ChatBox.cs b/Content.Client/Chat/ChatBox.cs index 031362868c..efa993993c 100644 --- a/Content.Client/Chat/ChatBox.cs +++ b/Content.Client/Chat/ChatBox.cs @@ -1,29 +1,65 @@ -using Content.Shared.Chat; +using System; +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Linq; +using Content.Client.State; +using Content.Client.UserInterface; +using Content.Client.UserInterface.Stylesheets; +using Content.Client.Utility; +using Content.Shared.Chat; 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; namespace Content.Client.Chat { public class ChatBox : Control { + public const float InitialChatBottom = 235; + public delegate void TextSubmitHandler(ChatBox chatBox, string text); - public delegate void FilterToggledHandler(ChatBox chatBox, BaseButton.ButtonToggledEventArgs e); + 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; } - // Buttons for filtering - public Button AllButton { get; } - public Button LocalButton { get; } - public Button OOCButton { get; } - public Button AdminButton { get; } - public Button DeadButton { 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; /// /// Default formatting string for the ClientChatConsole. @@ -34,96 +70,431 @@ namespace Content.Client.Chat 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 HBoxContainer _channelSelectorHBox; + private readonly FilterButton _filterButton; + private readonly Popup _filterPopup; + private readonly PanelContainer _filterPopupPanel; + private readonly VBoxContainer _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; - var outerVBox = new VBoxContainer(); - - var panelContainer = new PanelContainer + AddChild(new PanelContainer { PanelOverride = new StyleBoxFlat {BackgroundColor = Color.FromHex("#25252aaa")}, - VerticalExpand = true + VerticalExpand = true, + HorizontalExpand = true, + Children = + { + new VBoxContainer + { + Children = + { + (Contents = new OutputPanel + { + VerticalExpand = true, + }), + new PanelContainer + { + StyleClasses = { StyleNano.StyleClassChatSubPanel }, + HorizontalExpand = true, + Children = + { + new HBoxContainer + { + 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 HBoxContainer + { + Children = + { + new Control{MinSize = (10,0)}, + (_filterVBox = new VBoxContainer + { + SeparationOverride = 10 + }) + } + } + } + }) + } }; - var vBox = new VBoxContainer(); - panelContainer.AddChild(vBox); - var hBox = new HBoxContainer(); - outerVBox.AddChild(panelContainer); - outerVBox.AddChild(hBox); + _channelSelectorPopup = new Popup + { + Children = + { + (_channelSelectorHBox = new HBoxContainer + { + SeparationOverride = 4 + }) + } + }; - Contents = new OutputPanel {Margin = new Thickness(4, 0), VerticalExpand = true}; - vBox.AddChild(Contents); + 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)); + } + } - Input = new HistoryLineEdit(); + protected override void EnteredTree() + { + base.EnteredTree(); + _channelSelector.OnToggled += OnChannelSelectorToggled; + _filterButton.OnToggled += OnFilterButtonToggled; Input.OnKeyBindDown += InputKeyBindDown; Input.OnTextEntered += Input_OnTextEntered; - vBox.AddChild(Input); + Input.OnTextChanged += InputOnTextChanged; + Input.OnFocusExit += InputOnFocusExit; + _channelSelectorPopup.OnPopupHide += OnChannelSelectorPopupHide; + _filterPopup.OnPopupHide += OnFilterPopupHide; + _clyde.OnWindowResized += ClydeOnOnWindowResized; + } - AllButton = new Button + 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) { - Text = Loc.GetString("All"), - Name = "ALL", - HorizontalExpand = true, - HorizontalAlignment = HAlignment.Right, - ToggleMode = true, - }; + if (child is not ChannelFilterCheckbox checkbox) continue; + checkbox.OnToggled -= OnFilterCheckboxToggled; + } + } - LocalButton = new Button + private void UnsubChannelItems() + { + foreach (var child in _channelSelectorHBox.Children) { - Text = Loc.GetString("Local"), - Name = "Local", - ToggleMode = true, - }; + if (child is not ChannelItemButton button) continue; + button.OnPressed -= OnChannelSelectorItemPressed; + } + } - OOCButton = new Button + + /// + /// 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) + { + SelectableChannels = selectableChannels; + // update the channel selector + UnsubChannelItems(); + _channelSelectorHBox.RemoveAllChildren(); + foreach (var selectableChannel in ChannelSelectorOrder) { - Text = Loc.GetString("OOC"), - Name = "OOC", - ToggleMode = true, - }; + 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); - AdminButton = new Button + + if (_savedSelectedChannel.HasValue && _savedSelectedChannel.Value != ChatChannel.Unspecified && + !selectableChannels.Contains(_savedSelectedChannel.Value)) { - Text = Loc.GetString("Admin"), - Name = "Admin", - ToggleMode = true, - Visible = false - }; + // we just lost our saved selected channel, the current one will become permanent + _savedSelectedChannel = null; + } - DeadButton = new Button + if (!selectableChannels.Contains(SelectedChannel) && SelectedChannel != ChatChannel.Unspecified) { - Text = Loc.GetString("Dead"), - Name = "Dead", - ToggleMode = true, - Visible = false - }; + // our previously selected channel no longer exists, default back to OOC, which should always be available + 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); + } - AllButton.OnToggled += OnFilterToggled; - LocalButton.OnToggled += OnFilterToggled; - OOCButton.OnToggled += OnFilterToggled; - AdminButton.OnToggled += OnFilterToggled; - DeadButton.OnToggled += OnFilterToggled; + // 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)}); + } - hBox.AddChild(AllButton); - hBox.AddChild(LocalButton); - hBox.AddChild(DeadButton); - hBox.AddChild(OOCButton); - hBox.AddChild(AdminButton); + /// + /// 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); + } + } + } - AddChild(outerVBox); + 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); + return true; + } + // keep current setting + return false; } protected override void KeyBindDown(GUIBoundKeyEventArgs args) { base.KeyBindDown(args); - if (!args.CanFocus) + 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; } - Input.GrabKeyboardFocus(); + _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(); } private void InputKeyBindDown(GUIBoundKeyEventArgs args) @@ -134,11 +505,225 @@ namespace Content.Client.Chat args.Handle(); return; } + + // if we temporarily selected another channel via a prefx, undo that when we backspace on an empty input + if (Input.Text.Length == 0 && _savedSelectedChannel.HasValue && + args.Function == EngineKeyFunctions.TextBackspace) + { + SafelySelectChannel(_savedSelectedChannel.Value); + _savedSelectedChannel = null; + } } - public event TextSubmitHandler? TextSubmitted; + // 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 + } - public event FilterToggledHandler? FilterToggled; + 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() + { + 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) { @@ -154,18 +739,13 @@ namespace Content.Client.Chat Contents.AddMessage(formatted); } - public void AddLine(FormattedMessage message, Color color) + private void InputOnFocusExit(LineEdit.LineEditEventArgs obj) { - if (Disposed) - { - return; - } - - var formatted = new FormattedMessage(3); - formatted.PushColor(color); - formatted.AddMessage(message); - formatted.Pop(); - Contents.AddMessage(formatted); + // 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) @@ -175,12 +755,18 @@ namespace Content.Client.Chat if (!string.IsNullOrWhiteSpace(args.Text)) { - TextSubmitted?.Invoke(this, args.Text); + TextSubmitted?.Invoke(this, GetPrefixFromChannel(SelectedChannel) + + args.Text); } if (ClearOnEnter) { Input.Clear(); + if (_savedSelectedChannel.HasValue) + { + SafelySelectChannel(_savedSelectedChannel.Value); + _savedSelectedChannel = null; + } } if (ReleaseFocusOnEnter) @@ -188,10 +774,160 @@ namespace Content.Client.Chat Input.ReleaseKeyboardFocus(); } } + } - private void OnFilterToggled(BaseButton.ButtonToggledEventArgs args) + /// + /// 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() { - FilterToggled?.Invoke(this, args); + // 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/ChatManager.cs b/Content.Client/Chat/ChatManager.cs index dbb5331da0..294ec47c6d 100644 --- a/Content.Client/Chat/ChatManager.cs +++ b/Content.Client/Chat/ChatManager.cs @@ -1,3 +1,4 @@ +using System; using System.Collections.Generic; using Content.Client.Administration; using Content.Client.GameObjects.Components.Observer; @@ -22,8 +23,6 @@ namespace Content.Client.Chat { internal sealed class ChatManager : IChatManager, IPostInjectInit { - [Dependency] private IPlayerManager _playerManager = default!; - private struct SpeechBubbleData { public string Message; @@ -55,32 +54,62 @@ namespace Content.Client.Chat /// private int _maxMessageLength = 1000; - private const char ConCmdSlash = '/'; - private const char OOCAlias = '['; - private const char MeAlias = '@'; - private const char AdminChatAlias = ']'; + public const char ConCmdSlash = '/'; + public const char OOCAlias = '['; + public const char MeAlias = '@'; + public const char AdminChatAlias = ']'; + public const char RadioAlias = ';'; private readonly List _filteredHistory = new(); - // Filter Button States - private bool _allState; - private bool _localState; - private bool _oocState; - private bool _adminState; - private bool _deadState; + // 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) + // 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(); + + // Maintains which channels a client should be able to filter (for showing in the chatbox) + // and select (for attempting to send on). + // This may not always actually match with what the server will actually allow them to + // send / receive on, it is only what the user can select in the UI. For example, + // if a user is silenced from speaking for some reason this may still contain ChatChannel.Local, it is left up + // to the server to handle invalid attempts to use particular channels and not send messages for + // channels the user shouldn't be able to hear. + // + // 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; + /// + /// 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) + /// + private readonly Dictionary _unreadMessages = new(); + + [Dependency] private readonly IPlayerManager _playerManager = default!; [Dependency] private readonly IClientNetManager _netManager = default!; [Dependency] private readonly IClientConsoleHost _consoleHost = default!; [Dependency] private readonly IEntityManager _entityManager = default!; [Dependency] private readonly IEyeManager _eyeManager = default!; [Dependency] private readonly IUserInterfaceManager _userInterfaceManager = default!; - [Dependency] private readonly IClientConGroupController _groupController = default!; [Dependency] private readonly IClientAdminManager _adminMgr = default!; - private ChatBox? _currentChatBox; + /// + /// 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; + private Control _speechBubbleRoot = null!; /// @@ -110,6 +139,123 @@ namespace Content.Client.Chat _netManager.Connected += RequestMaxLength; } + public void PostInject() + { + _adminMgr.AdminStatusUpdated += UpdateChannelPermissions; + _playerManager.LocalPlayerChanged += OnLocalPlayerChanged; + OnLocalPlayerChanged(new LocalPlayerChangedEventArgs(null, _playerManager.LocalPlayer)); + } + + private void OnLocalPlayerChanged(LocalPlayerChangedEventArgs obj) + { + if (obj.OldPlayer != null) + { + obj.OldPlayer.EntityAttached -= OnLocalPlayerEntityAttached; + obj.OldPlayer.EntityDetached -= OnLocalPlayerEntityDetached; + } + + if (obj.NewPlayer != null) + { + obj.NewPlayer.EntityAttached += OnLocalPlayerEntityAttached; + obj.NewPlayer.EntityDetached += OnLocalPlayerEntityDetached; + } + + UpdateChannelPermissions(); + } + + private void OnLocalPlayerEntityAttached(EntityAttachedEventArgs obj) + { + UpdateChannelPermissions(); + } + + private void OnLocalPlayerEntityDetached(EntityDetachedEventArgs obj) + { + UpdateChannelPermissions(); + } + + // go through all of the various channels and update filter / select permissions + // appropriately, also enabling them if our enabledChannels dict doesn't have an entry + // for any newly-granted channels + private void UpdateChannelPermissions() + { + // can always send/recieve OOC + if (!_selectableChannels.Contains(ChatChannel.OOC)) + { + _selectableChannels.Add(ChatChannel.OOC); + } + AddFilterableChannel(ChatChannel.OOC); + + // can always hear server (nobody can actually send server messages). + AddFilterableChannel(ChatChannel.Server); + + // can always hear local / radio / emote + AddFilterableChannel(ChatChannel.Local); + AddFilterableChannel(ChatChannel.Radio); + AddFilterableChannel(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) + { + _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); + } + + // 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)) + { + AddFilterableChannel(ChatChannel.Dead); + if (!_selectableChannels.Contains(ChatChannel.Dead)) + { + _selectableChannels.Add(ChatChannel.Dead); + } + } + else + { + _filterableChannels.Remove(ChatChannel.Dead); + _selectableChannels.Remove(ChatChannel.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); + } + + // let our chatbox know all the new settings + CurrentChatBox?.SetChannelPermissions(_selectableChannels, _filterableChannels, _channelFilters, _unreadMessages); + } + + /// + /// 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); + } + + public void FrameUpdate(FrameEventArgs delta) { // Update queued speech bubbles. @@ -150,29 +296,31 @@ namespace Content.Client.Chat public void SetChatBox(ChatBox chatBox) { - if (_currentChatBox != null) + if (CurrentChatBox != null) { - _currentChatBox.TextSubmitted -= OnChatBoxTextSubmitted; - _currentChatBox.FilterToggled -= OnFilterButtonToggled; + CurrentChatBox.TextSubmitted -= OnChatBoxTextSubmitted; + CurrentChatBox.FilterToggled -= OnFilterButtonToggled; + CurrentChatBox.OnResized -= ChatBoxOnResized; } - _currentChatBox = chatBox; - if (_currentChatBox != null) + CurrentChatBox = chatBox; + if (CurrentChatBox != null) { - _currentChatBox.TextSubmitted += OnChatBoxTextSubmitted; - _currentChatBox.FilterToggled += OnFilterButtonToggled; + CurrentChatBox.TextSubmitted += OnChatBoxTextSubmitted; + CurrentChatBox.FilterToggled += OnFilterButtonToggled; + CurrentChatBox.OnResized += ChatBoxOnResized; - _currentChatBox.AllButton.Pressed = !_allState; - _currentChatBox.LocalButton.Pressed = !_localState; - _currentChatBox.OOCButton.Pressed = !_oocState; - _currentChatBox.AdminButton.Pressed = !_adminState; - _currentChatBox.DeadButton.Pressed = !_deadState; - AdminStatusUpdated(); + CurrentChatBox.SetChannelPermissions(_selectableChannels, _filterableChannels, _channelFilters, _unreadMessages); } RepopulateChat(_filteredHistory); } + private void ChatBoxOnResized(ChatResizedEventArgs chatResizedEventArgs) + { + OnChatBoxResized?.Invoke(chatResizedEventArgs); + } + public void RemoveSpeechBubble(EntityUid entityUid, SpeechBubble bubble) { bubble.Dispose(); @@ -193,6 +341,15 @@ namespace Content.Client.Chat 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; } @@ -220,12 +377,15 @@ namespace Content.Client.Chat }; } - _currentChatBox?.AddLine(FormattedMessage.FromMarkup(messageText), color); + 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) { - DebugTools.Assert(chatBox == _currentChatBox); + DebugTools.Assert(chatBox == CurrentChatBox); if (string.IsNullOrWhiteSpace(text)) return; @@ -233,12 +393,12 @@ namespace Content.Client.Chat // Check if message is longer than the character limit if (text.Length > _maxMessageLength) { - if (_currentChatBox != null) + 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 + CurrentChatBox.AddLine(locWarning, ChatChannel.Server, Color.Orange); + CurrentChatBox.ClearOnEnter = false; // The text shouldn't be cleared if it hasn't been sent } return; } @@ -265,7 +425,7 @@ namespace Content.Client.Chat var conInput = text.Substring(1); if (string.IsNullOrWhiteSpace(conInput)) return; - if (_groupController.CanCommand("asay")) + if (_adminMgr.HasFlag(AdminFlags.Admin)) { _consoleHost.ExecuteCommand($"asay \"{CommandParsing.Escape(conInput)}\""); } @@ -286,8 +446,8 @@ namespace Content.Client.Chat } default: { - var conInput = _currentChatBox?.DefaultChatFormat != null - ? string.Format(_currentChatBox.DefaultChatFormat, CommandParsing.Escape(text)) + var conInput = CurrentChatBox?.DefaultChatFormat != null + ? string.Format(CurrentChatBox.DefaultChatFormat, CommandParsing.Escape(text)) : text; _consoleHost.ExecuteCommand(conInput); break; @@ -295,63 +455,19 @@ namespace Content.Client.Chat } } - private void OnFilterButtonToggled(ChatBox chatBox, BaseButton.ButtonToggledEventArgs e) + private void OnFilterButtonToggled(ChatChannel channel, bool enabled) { - switch (e.Button.Name) + if (enabled) { - case "Local": - _localState = !_localState; - if (_localState) - { - _filteredChannels |= ChatChannel.Local; - break; - } - else - { - _filteredChannels &= ~ChatChannel.Local; - break; - } - - case "OOC": - _oocState = !_oocState; - if (_oocState) - { - _filteredChannels |= ChatChannel.OOC; - break; - } - else - { - _filteredChannels &= ~ChatChannel.OOC; - break; - } - case "Admin": - _adminState = !_adminState; - if (_adminState) - { - _filteredChannels |= ChatChannel.AdminChat; - break; - } - else - { - _filteredChannels &= ~ChatChannel.AdminChat; - break; - } - case "Dead": - _deadState = !_deadState; - if (_deadState) - _filteredChannels |= ChatChannel.Dead; - else - _filteredChannels &= ~ChatChannel.Dead; - break; - - case "ALL": - chatBox.LocalButton.Pressed ^= true; - chatBox.OOCButton.Pressed ^= true; - if (chatBox.AdminButton != null) - chatBox.AdminButton.Pressed ^= true; - chatBox.DeadButton.Pressed ^= true; - _allState = !_allState; - break; + _channelFilters[channel] = true; + _filteredChannels &= ~channel; + _unreadMessages.Remove(channel); + CurrentChatBox?.UpdateUnreadMessageCounts(_unreadMessages); + } + else + { + _channelFilters[channel] = false; + _filteredChannels |= channel; } RepopulateChat(_filteredHistory); @@ -359,12 +475,12 @@ namespace Content.Client.Chat private void RepopulateChat(IEnumerable filteredMessages) { - if (_currentChatBox == null) + if (CurrentChatBox == null) { return; } - _currentChatBox.Contents.Clear(); + CurrentChatBox.Contents.Clear(); foreach (var msg in filteredMessages) { @@ -522,33 +638,7 @@ namespace Content.Client.Chat private bool IsFiltered(ChatChannel channel) { - // _allState works as inverter. - return _allState ^ _filteredChannels.HasFlag(channel); - } - - void IPostInjectInit.PostInject() - { - _adminMgr.AdminStatusUpdated += AdminStatusUpdated; - } - - private void AdminStatusUpdated() - { - if (_currentChatBox != null) - { - _currentChatBox.AdminButton.Visible = _adminMgr.HasFlag(AdminFlags.Admin); - _currentChatBox.DeadButton.Visible = _adminMgr.HasFlag(AdminFlags.Admin); - } - } - - public void ToggleDeadChatButtonVisibility(bool visibility) - { - if (_currentChatBox != null) - { - // If the user is an admin and returned to body, don't set the flag as null - if (!visibility && _adminMgr.HasFlag(AdminFlags.Admin)) - return; - _currentChatBox.DeadButton.Visible = visibility; - } + return _filteredChannels.HasFlag(channel); } private sealed class SpeechBubbleQueueData diff --git a/Content.Client/Chat/StoredChatMessage.cs b/Content.Client/Chat/StoredChatMessage.cs index eb70a96545..49264c8fb0 100644 --- a/Content.Client/Chat/StoredChatMessage.cs +++ b/Content.Client/Chat/StoredChatMessage.cs @@ -32,6 +32,11 @@ namespace Content.Client.Chat /// public Color MessageColorOverride { get; set; } + /// + /// Whether the user has read this message at least once. + /// + public bool Read { get; set; } + /// /// Constructor to copy a net message into stored client variety /// diff --git a/Content.Client/GameObjects/Components/Observer/GhostComponent.cs b/Content.Client/GameObjects/Components/Observer/GhostComponent.cs index 0cc4be41f6..c57b248b37 100644 --- a/Content.Client/GameObjects/Components/Observer/GhostComponent.cs +++ b/Content.Client/GameObjects/Components/Observer/GhostComponent.cs @@ -83,7 +83,6 @@ namespace Content.Client.GameObjects.Components.Observer _gameHud.HandsContainer.AddChild(_gui); SetGhostVisibility(true); _isAttached = true; - _chatManager.ToggleDeadChatButtonVisibility(true); break; @@ -91,7 +90,6 @@ namespace Content.Client.GameObjects.Components.Observer _gui!.Parent?.RemoveChild(_gui); SetGhostVisibility(false); _isAttached = false; - _chatManager.ToggleDeadChatButtonVisibility(false); break; } } diff --git a/Content.Client/Input/ContentContexts.cs b/Content.Client/Input/ContentContexts.cs index b9dc944bb3..80e4cc96f4 100644 --- a/Content.Client/Input/ContentContexts.cs +++ b/Content.Client/Input/ContentContexts.cs @@ -13,8 +13,12 @@ namespace Content.Client.Input { var common = contexts.GetContext("common"); common.AddFunction(ContentKeyFunctions.FocusChat); + common.AddFunction(ContentKeyFunctions.FocusLocalChat); + common.AddFunction(ContentKeyFunctions.FocusRadio); common.AddFunction(ContentKeyFunctions.FocusOOC); common.AddFunction(ContentKeyFunctions.FocusAdminChat); + common.AddFunction(ContentKeyFunctions.CycleChatChannelForward); + common.AddFunction(ContentKeyFunctions.CycleChatChannelBackward); common.AddFunction(ContentKeyFunctions.ExamineEntity); common.AddFunction(ContentKeyFunctions.OpenInfo); common.AddFunction(ContentKeyFunctions.TakeScreenshot); diff --git a/Content.Client/Interfaces/Chat/IChatManager.cs b/Content.Client/Interfaces/Chat/IChatManager.cs index 1036189093..f52151c92a 100644 --- a/Content.Client/Interfaces/Chat/IChatManager.cs +++ b/Content.Client/Interfaces/Chat/IChatManager.cs @@ -1,3 +1,4 @@ +using System; using Content.Client.Chat; using Robust.Shared.GameObjects; using Robust.Shared.Timing; @@ -14,6 +15,14 @@ namespace Content.Client.Interfaces.Chat void RemoveSpeechBubble(EntityUid entityUid, SpeechBubble bubble); - void ToggleDeadChatButtonVisibility(bool visibility); + /// + /// Current chat box control. This can be modified, so do not depend on saving a reference to this. + /// + ChatBox? CurrentChatBox { get; } + + /// + /// Invoked when CurrentChatBox is resized (including after setting initial default size) + /// + event Action? OnChatBoxResized; } } diff --git a/Content.Client/State/GameScreen.cs b/Content.Client/State/GameScreen.cs index 291c0e66da..3342ec211f 100644 --- a/Content.Client/State/GameScreen.cs +++ b/Content.Client/State/GameScreen.cs @@ -1,3 +1,4 @@ +using System.Linq; using Content.Client.Administration; using Content.Client.Chat; using Content.Client.Construction; @@ -5,6 +6,7 @@ using Content.Client.Interfaces.Chat; using Content.Client.UserInterface; using Content.Client.Voting; using Content.Shared; +using Content.Shared.Chat; using Content.Shared.Input; using Robust.Client.Graphics; using Robust.Client.Input; @@ -37,9 +39,6 @@ namespace Content.Client.State [ViewVariables] private ChatBox? _gameChat; private ConstructionMenuPresenter? _constructionMenu; - private bool _oocEnabled; - private bool _adminOocEnabled; - public MainViewport Viewport { get; private set; } = default!; public override void Startup() @@ -59,29 +58,31 @@ namespace Content.Client.State LayoutContainer.SetAnchorPreset(Viewport, LayoutContainer.LayoutPreset.Wide); Viewport.SetPositionFirst(); - _userInterfaceManager.StateRoot.AddChild(_gameChat); - LayoutContainer.SetAnchorAndMarginPreset(_gameChat, LayoutContainer.LayoutPreset.TopRight, margin: 10); - LayoutContainer.SetMarginLeft(_gameChat, -475); - LayoutContainer.SetMarginBottom(_gameChat, 235); - _userInterfaceManager.StateRoot.AddChild(_gameHud.RootControl); _chatManager.SetChatBox(_gameChat); _voteManager.SetPopupContainer(_gameHud.VoteContainer); _gameChat.DefaultChatFormat = "say \"{0}\""; - _gameChat.Input.PlaceHolder = Loc.GetString("Say something! [ for OOC"); _inputManager.SetInputCommand(ContentKeyFunctions.FocusChat, InputCmdHandler.FromDelegate(_ => FocusChat(_gameChat))); _inputManager.SetInputCommand(ContentKeyFunctions.FocusOOC, - InputCmdHandler.FromDelegate(_ => FocusOOC(_gameChat))); + 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(_ => FocusAdminChat(_gameChat))); + InputCmdHandler.FromDelegate(_ => FocusChannel(_gameChat, ChatChannel.AdminChat))); - _configurationManager.OnValueChanged(CCVars.OocEnabled, OnOocEnabledChanged, true); - _configurationManager.OnValueChanged(CCVars.AdminOocEnabled, OnAdminOocEnabledChanged, true); - _adminManager.AdminStatusUpdated += OnAdminStatusUpdated; + _inputManager.SetInputCommand(ContentKeyFunctions.CycleChatChannelForward, + InputCmdHandler.FromDelegate(_ => CycleChatChannel(_gameChat, true))); + + _inputManager.SetInputCommand(ContentKeyFunctions.CycleChatChannelBackward, + InputCmdHandler.FromDelegate(_ => CycleChatChannel(_gameChat, false))); SetupPresenters(); @@ -118,50 +119,9 @@ namespace Content.Client.State _constructionMenu?.Dispose(); } - - private void OnOocEnabledChanged(bool val) - { - _oocEnabled = val; - - if (_adminManager.IsActive()) - { - return; - } - - if(_gameChat is null) - return; - - _gameChat.Input.PlaceHolder = Loc.GetString(_oocEnabled ? "Say something! [ for OOC" : "Say something!"); - } - - private void OnAdminOocEnabledChanged(bool val) - { - _adminOocEnabled = val; - - if (!_adminManager.IsActive()) - { - return; - } - - if (_gameChat is null) - return; - - _gameChat.Input.PlaceHolder = Loc.GetString(_adminOocEnabled ? "Say something! [ for OOC" : "Say something!"); - } - - private void OnAdminStatusUpdated() - { - if (_gameChat is null) - return; - - _gameChat.Input.PlaceHolder = _adminManager.IsActive() - ? Loc.GetString(_adminOocEnabled ? "Say something! [ for OOC" : "Say something!") - : Loc.GetString(_oocEnabled ? "Say something! [ for OOC" : "Say something!"); - } - internal static void FocusChat(ChatBox chat) { - if (chat == null || chat.UserInterfaceManager.KeyboardFocused != null) + if (chat.UserInterfaceManager.KeyboardFocused != null) { return; } @@ -169,28 +129,34 @@ namespace Content.Client.State chat.Input.IgnoreNext = true; chat.Input.GrabKeyboardFocus(); } - internal static void FocusOOC(ChatBox chat) + internal static void FocusChannel(ChatBox chat, ChatChannel channel) { - if (chat == null || chat.UserInterfaceManager.KeyboardFocused != null) + if (chat.UserInterfaceManager.KeyboardFocused != null) { return; } chat.Input.IgnoreNext = true; - chat.Input.GrabKeyboardFocus(); - chat.Input.InsertAtCursor("["); + chat.SelectChannel(channel); } - internal static void FocusAdminChat(ChatBox chat) + internal static void CycleChatChannel(ChatBox chat, bool forward) { - if (chat == null || chat.UserInterfaceManager.KeyboardFocused != null) + chat.Input.IgnoreNext = true; + var channels = chat.SelectableChannels; + var idx = channels.IndexOf(chat.SelectedChannel); + if (forward) { - return; + idx++; + idx = MathHelper.Mod(idx, channels.Count()); + } + else + { + idx--; + idx = MathHelper.Mod(idx, channels.Count()); } - chat.Input.IgnoreNext = true; - chat.Input.GrabKeyboardFocus(); - chat.Input.InsertAtCursor("]"); + chat.SelectChannel(channels[idx]); } public override void FrameUpdate(FrameEventArgs e) diff --git a/Content.Client/State/LobbyState.cs b/Content.Client/State/LobbyState.cs index df15cb7553..6c75e9ab7e 100644 --- a/Content.Client/State/LobbyState.cs +++ b/Content.Client/State/LobbyState.cs @@ -1,9 +1,10 @@ -using System; +using System; using System.Linq; using Content.Client.Interfaces; using Content.Client.Interfaces.Chat; using Content.Client.UserInterface; using Content.Client.Voting; +using Content.Shared.Chat; using Content.Shared.Input; using Robust.Client; using Robust.Client.Console; @@ -76,10 +77,16 @@ namespace Content.Client.State InputCmdHandler.FromDelegate(_ => GameScreen.FocusChat(_lobby.Chat))); _inputManager.SetInputCommand(ContentKeyFunctions.FocusOOC, - InputCmdHandler.FromDelegate(_ => GameScreen.FocusOOC(_lobby.Chat))); + InputCmdHandler.FromDelegate(_ => GameScreen.FocusChannel(_lobby.Chat, ChatChannel.OOC))); _inputManager.SetInputCommand(ContentKeyFunctions.FocusAdminChat, - InputCmdHandler.FromDelegate(_ => GameScreen.FocusAdminChat(_lobby.Chat))); + InputCmdHandler.FromDelegate(_ => GameScreen.FocusChannel(_lobby.Chat, ChatChannel.AdminChat))); + + _inputManager.SetInputCommand(ContentKeyFunctions.CycleChatChannelForward, + InputCmdHandler.FromDelegate(_ => GameScreen.CycleChatChannel(_lobby.Chat, true))); + + _inputManager.SetInputCommand(ContentKeyFunctions.CycleChatChannelBackward, + InputCmdHandler.FromDelegate(_ => GameScreen.CycleChatChannel(_lobby.Chat, false))); UpdateLobbyUi(); diff --git a/Content.Client/UserInterface/AlertsUI.cs b/Content.Client/UserInterface/AlertsUI.cs index 30409963a4..9fe6398f4e 100644 --- a/Content.Client/UserInterface/AlertsUI.cs +++ b/Content.Client/UserInterface/AlertsUI.cs @@ -1,6 +1,10 @@ -using Content.Client.UserInterface.Stylesheets; +using Content.Client.Chat; +using Content.Client.Interfaces.Chat; +using Content.Client.UserInterface.Stylesheets; using Robust.Client.UserInterface; using Robust.Client.UserInterface.Controls; +using Robust.Shared.IoC; +using Robust.Shared.Maths; namespace Content.Client.UserInterface { @@ -9,6 +13,7 @@ namespace Content.Client.UserInterface /// public sealed class AlertsUI : Control { + public const float ChatSeparation = 38f; public GridContainer Grid { get; } public AlertsUI() @@ -39,6 +44,36 @@ namespace Content.Client.UserInterface MinSize = (64, 64); } + protected override void EnteredTree() + { + base.EnteredTree(); + var _chatManager = IoCManager.Resolve(); + _chatManager.OnChatBoxResized += OnChatResized; + OnChatResized(new ChatResizedEventArgs(ChatBox.InitialChatBottom)); + } + + protected override void ExitedTree() + { + base.ExitedTree(); + var _chatManager = IoCManager.Resolve(); + _chatManager.OnChatBoxResized -= OnChatResized; + } + + + private void OnChatResized(ChatResizedEventArgs chatResizedEventArgs) + { + // resize us to fit just below the chatbox + var _chatManager = IoCManager.Resolve(); + if (_chatManager.CurrentChatBox != null) + { + LayoutContainer.SetMarginTop(this, chatResizedEventArgs.NewBottom + ChatSeparation); + } + else + { + LayoutContainer.SetMarginTop(this, 250); + } + } + // This makes no sense but I'm leaving it in place in case I break anything by removing it. protected override void Resized() diff --git a/Content.Client/UserInterface/OptionsMenu.KeyRebind.cs b/Content.Client/UserInterface/OptionsMenu.KeyRebind.cs index 8cb3e82e9b..ab3a2b2bd0 100644 --- a/Content.Client/UserInterface/OptionsMenu.KeyRebind.cs +++ b/Content.Client/UserInterface/OptionsMenu.KeyRebind.cs @@ -141,8 +141,12 @@ namespace Content.Client.UserInterface AddHeader("ui-options-header-ui"); AddButton(ContentKeyFunctions.FocusChat); + AddButton(ContentKeyFunctions.FocusLocalChat); + AddButton(ContentKeyFunctions.FocusRadio); AddButton(ContentKeyFunctions.FocusOOC); AddButton(ContentKeyFunctions.FocusAdminChat); + AddButton(ContentKeyFunctions.CycleChatChannelForward); + AddButton(ContentKeyFunctions.CycleChatChannelBackward); AddButton(ContentKeyFunctions.OpenCharacterMenu); AddButton(ContentKeyFunctions.OpenContextMenu); AddButton(ContentKeyFunctions.OpenCraftingMenu); diff --git a/Content.Client/UserInterface/Stylesheets/StyleNano.cs b/Content.Client/UserInterface/Stylesheets/StyleNano.cs index efaac6f9d3..0af56865bd 100644 --- a/Content.Client/UserInterface/Stylesheets/StyleNano.cs +++ b/Content.Client/UserInterface/Stylesheets/StyleNano.cs @@ -1,5 +1,6 @@ #nullable enable using System.Linq; +using Content.Client.Chat; using Content.Client.GameObjects.EntitySystems; using Content.Client.UserInterface.Controls; using Content.Client.Utility; @@ -18,6 +19,7 @@ namespace Content.Client.UserInterface.Stylesheets public const string StyleClassBorderedWindowPanel = "BorderedWindowPanel"; public const string StyleClassInventorySlotBackground = "InventorySlotBackground"; public const string StyleClassHandSlotHighlight = "HandSlotHighlight"; + public const string StyleClassChatSubPanel = "ChatSubPanel"; public const string StyleClassTransparentBorderedWindowPanel = "TransparentBorderedWindowPanel"; public const string StyleClassHotbarPanel = "HotbarPanel"; public const string StyleClassTooltipPanel = "tooltipBox"; @@ -31,6 +33,9 @@ namespace Content.Client.UserInterface.Stylesheets public const string StyleClassHotbarSlotNumber = "hotbarSlotNumber"; public const string StyleClassActionSearchBox = "actionSearchBox"; public const string StyleClassActionMenuItemRevoked = "actionMenuItemRevoked"; + public const string StyleClassChatLineEdit = "chatLineEdit"; + public const string StyleClassChatChannelSelectorButton = "chatSelectorOptionButton"; + public const string StyleClassChatFilterOptionButton = "chatFilterOptionButton"; public const string StyleClassContextMenuCount = "contextMenuCount"; public const string StyleClassSliderRed = "Red"; @@ -208,6 +213,22 @@ namespace Content.Client.UserInterface.Stylesheets }; topButtonSquare.SetPatchMargin(StyleBox.Margin.Horizontal, 0); + var chatChannelButtonTex = resCache.GetTexture("/Textures/Interface/Nano/rounded_button.svg.96dpi.png"); + var chatChannelButton = new StyleBoxTexture + { + Texture = chatChannelButtonTex, + }; + chatChannelButton.SetPatchMargin(StyleBox.Margin.All, 5); + chatChannelButton.SetPadding(StyleBox.Margin.All, 2); + + var chatFilterButtonTex = resCache.GetTexture("/Textures/Interface/Nano/rounded_button_bordered.svg.96dpi.png"); + var chatFilterButton = new StyleBoxTexture + { + Texture = chatFilterButtonTex, + }; + chatFilterButton.SetPatchMargin(StyleBox.Margin.All, 5); + chatFilterButton.SetPadding(StyleBox.Margin.All, 2); + var textureInvertedTriangle = resCache.GetTexture("/Textures/Interface/Nano/inverted_triangle.svg.png"); var lineEditTex = resCache.GetTexture("/Textures/Interface/Nano/lineedit.png"); @@ -218,6 +239,13 @@ namespace Content.Client.UserInterface.Stylesheets lineEdit.SetPatchMargin(StyleBox.Margin.All, 3); lineEdit.SetContentMarginOverride(StyleBox.Margin.Horizontal, 5); + var chatSubBGTex = resCache.GetTexture("/Textures/Interface/Nano/chat_sub_background.png"); + var chatSubBG = new StyleBoxTexture + { + Texture = chatSubBGTex, + }; + chatSubBG.SetPatchMargin(StyleBox.Margin.All, 2); + var actionSearchBoxTex = resCache.GetTexture("/Textures/Interface/Nano/black_panel_dark_thin_border.png"); var actionSearchBox = new StyleBoxTexture { @@ -540,6 +568,20 @@ namespace Content.Client.UserInterface.Stylesheets { new StyleProperty("font-color", Color.Gray), }), + // Chat lineedit - we don't actually draw a stylebox around the lineedit itself, we put it around the + // input + other buttons, so we must clear the default stylebox + new StyleRule(new SelectorElement(typeof(LineEdit), new[] {StyleClassChatLineEdit}, null, null), + new[] + { + new StyleProperty(LineEdit.StylePropertyStyleBox, new StyleBoxEmpty()), + }), + + // chat subpanels (chat lineedit backing, popup backings) + new StyleRule(new SelectorElement(typeof(PanelContainer), new[] {StyleClassChatSubPanel}, null, null), + new[] + { + new StyleProperty(PanelContainer.StylePropertyPanel, chatSubBG), + }), // Action searchbox lineedit new StyleRule(new SelectorElement(typeof(LineEdit), new[] {StyleClassActionSearchBox}, null, null), @@ -931,6 +973,33 @@ namespace Content.Client.UserInterface.Stylesheets new StyleProperty(Slider.StylePropertyFill, sliderFillBlue), }), + // chat channel option selector + new StyleRule(new SelectorElement(typeof(Button), new[] {StyleClassChatChannelSelectorButton}, null, null), new[] + { + new StyleProperty(Button.StylePropertyStyleBox, chatChannelButton), + }), + // chat filter button + new StyleRule(new SelectorElement(typeof(ContainerButton), new[] {StyleClassChatFilterOptionButton}, null, null), new[] + { + new StyleProperty(ContainerButton.StylePropertyStyleBox, chatFilterButton), + }), + new StyleRule(new SelectorElement(typeof(ContainerButton), new[] {StyleClassChatFilterOptionButton}, null, new[] {ContainerButton.StylePseudoClassNormal}), new[] + { + new StyleProperty(Control.StylePropertyModulateSelf, ButtonColorDefault), + }), + new StyleRule(new SelectorElement(typeof(ContainerButton), new[] {StyleClassChatFilterOptionButton}, null, new[] {ContainerButton.StylePseudoClassHover}), new[] + { + new StyleProperty(Control.StylePropertyModulateSelf, ButtonColorHovered), + }), + new StyleRule(new SelectorElement(typeof(ContainerButton), new[] {StyleClassChatFilterOptionButton}, null, new[] {ContainerButton.StylePseudoClassPressed}), new[] + { + new StyleProperty(Control.StylePropertyModulateSelf, ButtonColorPressed), + }), + new StyleRule(new SelectorElement(typeof(ContainerButton), new[] {StyleClassChatFilterOptionButton}, null, new[] {ContainerButton.StylePseudoClassDisabled}), new[] + { + new StyleProperty(Control.StylePropertyModulateSelf, ButtonColorDisabled), + }), + // OptionButton new StyleRule(new SelectorElement(typeof(OptionButton), null, null, null), new[] { diff --git a/Content.Server/GameObjects/Components/Headset/HeadsetComponent.cs b/Content.Server/GameObjects/Components/Headset/HeadsetComponent.cs index 8b7ecb2de3..7b4505e897 100644 --- a/Content.Server/GameObjects/Components/Headset/HeadsetComponent.cs +++ b/Content.Server/GameObjects/Components/Headset/HeadsetComponent.cs @@ -66,7 +66,8 @@ namespace Content.Server.GameObjects.Components.Headset msg.Channel = ChatChannel.Radio; msg.Message = message; - msg.MessageWrap = Loc.GetString("chat-radio-message-wrap", ("channel", channel), ("name", source.Name)); + //Square brackets are added here to avoid issues with escaping + msg.MessageWrap = Loc.GetString("chat-radio-message-wrap", ("channel", $"[{channel}]"), ("name", source.Name)); _netManager.ServerSendMessage(msg, playerChannel); } } diff --git a/Content.Shared/Input/ContentKeyFunctions.cs b/Content.Shared/Input/ContentKeyFunctions.cs index 73dbd1356e..9a3f504f0d 100644 --- a/Content.Shared/Input/ContentKeyFunctions.cs +++ b/Content.Shared/Input/ContentKeyFunctions.cs @@ -11,9 +11,13 @@ namespace Content.Shared.Input public static readonly BoundKeyFunction ActivateItemInWorld = "ActivateItemInWorld"; // default action on world entity public static readonly BoundKeyFunction Drop = "Drop"; public static readonly BoundKeyFunction ExamineEntity = "ExamineEntity"; - public static readonly BoundKeyFunction FocusChat = "FocusChatWindow"; + public static readonly BoundKeyFunction FocusChat = "FocusChatInputWindow"; + public static readonly BoundKeyFunction FocusLocalChat = "FocusLocalChatWindow"; + public static readonly BoundKeyFunction FocusRadio = "FocusRadioWindow"; public static readonly BoundKeyFunction FocusOOC = "FocusOOCWindow"; public static readonly BoundKeyFunction FocusAdminChat = "FocusAdminChatWindow"; + public static readonly BoundKeyFunction CycleChatChannelForward = "CycleChatChannelForward"; + public static readonly BoundKeyFunction CycleChatChannelBackward = "CycleChatChannelBackward"; public static readonly BoundKeyFunction OpenCharacterMenu = "OpenCharacterMenu"; public static readonly BoundKeyFunction OpenContextMenu = "OpenContextMenu"; public static readonly BoundKeyFunction OpenCraftingMenu = "OpenCraftingMenu"; diff --git a/Resources/Locale/en-US/components/headset-component.ftl b/Resources/Locale/en-US/components/headset-component.ftl index daad4ed4a4..4190c526ff 100644 --- a/Resources/Locale/en-US/components/headset-component.ftl +++ b/Resources/Locale/en-US/components/headset-component.ftl @@ -1,5 +1,5 @@ # Chat window radio wrap (prefix and postfix) -chat-radio-message-wrap = \\[{$channel}\\] {$name} says, "{"{"}0{"}"}" +chat-radio-message-wrap = {$channel} {$name} says, "{"{"}0{"}"}" examine-radio-frequency = It is set to broadcast over the {$frequency} frequency. diff --git a/Resources/Locale/en-US/ui/hud.ftl b/Resources/Locale/en-US/ui/hud.ftl index bfacb5623d..1b272f143d 100644 --- a/Resources/Locale/en-US/ui/hud.ftl +++ b/Resources/Locale/en-US/ui/hud.ftl @@ -3,3 +3,9 @@ ## Combat mode hud-combat-enabled = Combat mode enabled! hud-combat-disabled = Combat mode disabled. + +## Chat box +hud-chatbox-info = Say something! T to talk, Tab to cycle channels. +hud-chatbox-admin = Admin +hud-chatbox-ooc = OOC +hud-chatbox-console = Console diff --git a/Resources/Locale/en-US/ui/options.ftl b/Resources/Locale/en-US/ui/options.ftl index 081e6f9455..857677c593 100644 --- a/Resources/Locale/en-US/ui/options.ftl +++ b/Resources/Locale/en-US/ui/options.ftl @@ -78,9 +78,13 @@ ui-options-function-move-pulled-object = Move pulled object ui-options-function-release-pulled-object = Release pulled object ui-options-function-point = Point at location -ui-options-function-focus-chat-window = Focus chat +ui-options-function-focus-chat-input-window = Focus chat +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-admin-chat-window = Focus chat (Admin) +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 ui-options-function-open-context-menu = Open context menu ui-options-function-open-crafting-menu = Open crafting menu diff --git a/Resources/Textures/Interface/Nano/chat_sub_background.png b/Resources/Textures/Interface/Nano/chat_sub_background.png new file mode 100644 index 0000000000..de59bb0b3a Binary files /dev/null and b/Resources/Textures/Interface/Nano/chat_sub_background.png differ diff --git a/Resources/Textures/Interface/Nano/filter.svg b/Resources/Textures/Interface/Nano/filter.svg new file mode 100644 index 0000000000..30f8728c83 --- /dev/null +++ b/Resources/Textures/Interface/Nano/filter.svg @@ -0,0 +1,71 @@ + + + + + + image/svg+xml + + + + + + + + + + diff --git a/Resources/Textures/Interface/Nano/filter.svg.96dpi.png b/Resources/Textures/Interface/Nano/filter.svg.96dpi.png new file mode 100644 index 0000000000..1482523cf2 Binary files /dev/null and b/Resources/Textures/Interface/Nano/filter.svg.96dpi.png differ diff --git a/Resources/Textures/Interface/Nano/rounded_button.svg b/Resources/Textures/Interface/Nano/rounded_button.svg new file mode 100644 index 0000000000..7f623f2980 --- /dev/null +++ b/Resources/Textures/Interface/Nano/rounded_button.svg @@ -0,0 +1,64 @@ + + + + + + image/svg+xml + + + + + + + + + diff --git a/Resources/Textures/Interface/Nano/rounded_button.svg.96dpi.png b/Resources/Textures/Interface/Nano/rounded_button.svg.96dpi.png new file mode 100644 index 0000000000..3e8c121eb5 Binary files /dev/null and b/Resources/Textures/Interface/Nano/rounded_button.svg.96dpi.png differ diff --git a/Resources/Textures/Interface/Nano/rounded_button_bordered.svg b/Resources/Textures/Interface/Nano/rounded_button_bordered.svg new file mode 100644 index 0000000000..46c5769fdb --- /dev/null +++ b/Resources/Textures/Interface/Nano/rounded_button_bordered.svg @@ -0,0 +1,70 @@ + + + + + + image/svg+xml + + + + + + + + + diff --git a/Resources/Textures/Interface/Nano/rounded_button_bordered.svg.96dpi.png b/Resources/Textures/Interface/Nano/rounded_button_bordered.svg.96dpi.png new file mode 100644 index 0000000000..f2ef523aa1 Binary files /dev/null and b/Resources/Textures/Interface/Nano/rounded_button_bordered.svg.96dpi.png differ diff --git a/Resources/keybinds.yml b/Resources/keybinds.yml index 962c2eb931..7de1f3b1d0 100644 --- a/Resources/keybinds.yml +++ b/Resources/keybinds.yml @@ -51,15 +51,28 @@ binds: - function: ShowEscapeMenu type: State key: Escape -- function: FocusChatWindow +- function: CycleChatChannelForward + type: State + key: Tab +- function: CycleChatChannelBackward + type: State + key: Tab + mod1: Control +- function: FocusChatInputWindow type: State key: T -- function: FocusOOCWindow +- function: FocusLocalChatWindow type: State key: LBracket -- function: FocusAdminChatWindow +- function: FocusRadioWindow + type: State + key: SemiColon +- function: FocusOOCWindow type: State key: RBracket +- function: FocusAdminChatWindow + type: State + key: BackSlash - function: EditorLinePlace type: State key: MouseLeft