Chairbender Chat (#3794)

* #272 restructure and restyle chat line edit section

* #272 no arrow, actually change id on channel changer

* #272 nice round chat channel picker

* #272 add chat channel selection logic, and auto-select
when a prefix is entered

* #272 consistent width of chat channel btn

* #272 only show admin channel filter if asay perms

* #272 add tutorial info on chat prefixes

* #272 added chat filter button

* #272 added chat filter button

* #272 WIP on filter popup

* #272 fix filter popup pressed / unpressed logic

* #272 fix filter popup positioning and layout

* #272 WIP channel filter logic

* #272 WIP channel filter logic

* #272 WIP refactoring how chatbox / manager manages available filters and channels to send on

* #272 WIP implementing filtering UI / logic
and refactoring how chat UI is managed

* #272 fix various bugs with new chat filter / selector logic

* #272 remove outdated todos

* #272 WIP working chat window resize

* #272 bounded chatbox resizing

* #272 alertUI moves with resized chat

* #272 WIP making alertUI not be too large when changing size / UIScale

* #272 WIP fixing window / uiscale adjustment

* #272 WIP hacky approach for resizing, will try another approach

* #272 implement hacky approach for bounded chat resize

* #272 no resizing of lobby chat

* #272 WIP adding unread marker to chat filters

* #272 basic working unread chat message indicators

* #272 WIP adding horizontal channel selector items

* #272 horizontal channel selector popup

* #272 workaround for chat selector staying highlighted when right clicking it while toggled

* #272 workaround for chat selector staying highlighted when right clicking it while toggled

* #272 wip trying to add tests for chatbox

* #272 remove test, not really possible with current system

* #272 merge latest

* #272 merge latest

* #272 fix csproj changes

* It works if you disable the lobby

* Fixes lobby chat

* Adds more channel focusses

* Channel cycler

* Address review

* Address nitpicks

* Address more of the review

* Fix chat post-viewport

* Finalize review stuff

Co-authored-by: chairbender <kwhipke1@gmail.com>
Co-authored-by: ike709 <sparebytes@protonmail.com>
This commit is contained in:
ike709
2021-04-20 18:39:39 -05:00
committed by GitHub
parent d7d37f10d1
commit 055059ab5c
24 changed files with 1435 additions and 279 deletions

View File

@@ -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<ChatResizedEventArgs>? OnResized;
// order in which the available channel filters show up when available
public static readonly IReadOnlyList<ChatChannel> ChannelFilterOrder = new List<ChatChannel>
{
ChatChannel.Local, ChatChannel.Emotes, ChatChannel.Radio, ChatChannel.OOC, ChatChannel.Dead, ChatChannel.AdminChat,
ChatChannel.Server
};
// order in which the channels show up in the channel selector
private static readonly IReadOnlyList<ChatChannel> ChannelSelectorOrder = new List<ChatChannel>
{
ChatChannel.Local, ChatChannel.Emotes, ChatChannel.Radio, ChatChannel.OOC, ChatChannel.Dead, ChatChannel.AdminChat
};
private const float FilterPopupWidth = 110;
private const int DragMarginSize = 7;
private const int MinDistanceFromBottom = 255;
private const int MinLeft = 500;
/// <summary>
/// Will be Unspecified if set to Console
/// </summary>
public ChatChannel SelectedChannel;
/// <summary>
/// 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<ChatChannel> SelectableChannels = new();
/// <summary>
/// When lobbyMode is false, will position / add to correct location in StateRoot and
/// be resizable.
/// wWen true, will leave layout up to parent and not be resizable.
/// </summary>
public ChatBox()
{
//TODO Paul needs to fix xaml ctor args so we can pass this instead of resolving it.
var stateManager = IoCManager.Resolve<IStateManager>();
_lobbyMode = stateManager.CurrentState is LobbyState;
// TODO: Revisit the resizing stuff after https://github.com/space-wizards/RobustToolbox/issues/1392 is done,
// Probably not "supposed" to inject IClyde, but I give up.
// I can't find any other way to allow this control to properly resize when the
// window is resized. Resized() isn't reliably called when resizing the window,
// and layoutcontainer anchor / margin don't seem to adjust how we need
// them to when the window is resized. We need it to be able to resize
// within some bounds so that it doesn't overlap other UI elements, while still
// being freely resizable within those bounds.
_clyde = IoCManager.Resolve<IClyde>();
MouseFilter = MouseFilterMode.Stop;
LayoutContainer.SetMarginLeft(this, 4);
LayoutContainer.SetMarginRight(this, 4);
MinHeight = 128;
MinWidth = 200;
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
/// <summary>
/// Update the available filters / selectable channels and the current filter settings using the provided
/// data.
/// </summary>
/// <param name="selectableChannels">channels currently selectable to send on</param>
/// <param name="filterableChannels">channels currently able ot filter on</param>
/// <param name="channelFilters">current settings for the channel filters, this SHOULD always have an entry if
/// there is a corresponding entry in filterableChannels, but it may also have additional
/// entries (which should not be presented to the user)</param>
/// <param name="unreadMessages">unread message counts for each disabled channel, values 10 or higher will show as 9+</param>
public void SetChannelPermissions(List<ChatChannel> selectableChannels, IReadOnlySet<ChatChannel> filterableChannels,
IReadOnlyDictionary<ChatChannel, bool> channelFilters, IReadOnlyDictionary<ChatChannel, byte> unreadMessages)
{
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);
/// <summary>
/// Update the unread message counts in the filters based on the provided data.
/// </summary>
/// <param name="unreadMessages">counts for each channel, any values above 9 will show as 9+</param>
public void UpdateUnreadMessageCounts(IReadOnlyDictionary<ChatChannel, byte> unreadMessages)
{
foreach (var channelFilter in _filterVBox.Children)
{
if (channelFilter is not ChannelFilterCheckbox filterCheckbox) continue;
if (unreadMessages.TryGetValue(filterCheckbox.Channel, out var unread))
{
filterCheckbox.UpdateUnreadCount(unread);
}
else
{
filterCheckbox.UpdateUnreadCount(null);
}
}
}
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();
}
/// <summary>
/// Selects the indicated channel, clearing out any temporarily-selected channel
/// (any currently entered text is preserved). If the specified channel is not selectable,
/// will just maintain current selection.
/// </summary>
public void SelectChannel(ChatChannel toSelect)
{
_savedSelectedChannel = null;
SafelySelectChannel(toSelect);
}
private bool SafelySelectChannel(ChatChannel toSelect)
{
if (toSelect == ChatChannel.Unspecified ||
SelectableChannels.Contains(toSelect))
{
SelectedChannel = toSelect;
_channelSelector.Text = ChannelSelectorName(toSelect);
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)
/// <summary>
/// Only needed to avoid the issue where right click on the button closes the popup
/// but leaves the button highlighted.
/// </summary>
public sealed class ChannelSelectorButton : Button
{
public ChannelSelectorButton()
{
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<IResourceCache>()
.GetTexture("/Textures/Interface/Nano/filter.svg.96dpi.png");
// needed for same reason as ChannelSelectorButton
Mode = ActionMode.Press;
EnableAllKeybinds = true;
AddChild(
(_textureRect = new TextureRect
{
Texture = filterTexture,
SizeFlagsVertical = SizeFlags.ShrinkCenter,
SizeFlagsHorizontal = SizeFlags.ShrinkCenter
})
);
ToggleMode = true;
}
protected override void KeyBindDown(GUIBoundKeyEventArgs args)
{
// needed since we need EnableAllKeybinds - don't double-send both UI click and Use
if (args.Function == EngineKeyFunctions.Use) return;
base.KeyBindDown(args);
}
private void UpdateChildColors()
{
if (_textureRect == null) return;
switch (DrawMode)
{
case DrawModeEnum.Normal:
_textureRect.ModulateSelfOverride = ColorNormal;
break;
case DrawModeEnum.Pressed:
_textureRect.ModulateSelfOverride = ColorPressed;
break;
case DrawModeEnum.Hover:
_textureRect.ModulateSelfOverride = ColorHovered;
break;
case DrawModeEnum.Disabled:
break;
}
}
protected override void DrawModeChanged()
{
base.DrawModeChanged();
UpdateChildColors();
}
protected override void StylePropertiesChanged()
{
base.StylePropertiesChanged();
UpdateChildColors();
}
}
public sealed class ChannelItemButton : Button
{
public readonly ChatChannel Channel;
public ChannelItemButton(ChatChannel channel)
{
Channel = channel;
AddStyleClass(StyleNano.StyleClassChatChannelSelectorButton);
Text = ChatBox.ChannelSelectorName(channel);
}
}
public sealed class ChannelFilterCheckbox : CheckBox
{
public readonly ChatChannel Channel;
public ChannelFilterCheckbox(ChatChannel channel, byte? unreadCount)
{
Channel = channel;
UpdateText(unreadCount);
}
private void UpdateText(byte? unread)
{
var name = Channel switch
{
ChatChannel.AdminChat => Loc.GetString("hud-chatbox-admin"),
ChatChannel.Unspecified => throw new InvalidOperationException(
"cannot create chat filter for Unspecified"),
_ => Loc.GetString(Channel.ToString())
};
if (unread > 0)
{
Text = name + " (" + (unread > 9 ? "9+" : unread) + ")";
}
else
{
Text = name;
}
}
public void UpdateUnreadCount(byte? unread)
{
UpdateText(unread);
}
}
public readonly struct ChatResizedEventArgs
{
/// new bottom that the chat rect is going to have in virtual pixels
/// after the imminent relayout
public readonly float NewBottom;
public ChatResizedEventArgs(float newBottom)
{
NewBottom = newBottom;
}
}
}

View File

@@ -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
/// </summary>
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<StoredChatMessage> _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<ChatChannel, bool> _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<ChatChannel> _filterableChannels = new();
private readonly List<ChatChannel> _selectableChannels = new();
// Flag Enums for holding filtered channels
private ChatChannel _filteredChannels;
/// <summary>
/// For currently disabled chat filters,
/// unread messages (messages received since the channel has been filtered
/// out). Never goes above 10 (9+ should be shown when at 10)
/// </summary>
private readonly Dictionary<ChatChannel, byte> _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;
/// <summary>
/// Current chat box control. This can be modified, so do not depend on saving a reference to this.
/// </summary>
public ChatBox? CurrentChatBox { get; private set; }
/// <summary>
/// Invoked when CurrentChatBox is resized (including after setting initial default size)
/// </summary>
public event Action<ChatResizedEventArgs>? OnChatBoxResized;
private Control _speechBubbleRoot = null!;
/// <summary>
@@ -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<GhostComponent>() ?? 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<GhostComponent>() ?? 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);
}
/// <summary>
/// Adds the channel to the set of filterable channels, defaulting it as enabled
/// if it doesn't currently have an explicit enable/disable setting
/// </summary>
private void AddFilterableChannel(ChatChannel channel)
{
if (!_channelFilters.ContainsKey(channel))
_channelFilters[channel] = true;
_filterableChannels.Add(channel);
}
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<StoredChatMessage> 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

View File

@@ -32,6 +32,11 @@ namespace Content.Client.Chat
/// </summary>
public Color MessageColorOverride { get; set; }
/// <summary>
/// Whether the user has read this message at least once.
/// </summary>
public bool Read { get; set; }
/// <summary>
/// Constructor to copy a net message into stored client variety
/// </summary>

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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);
/// <summary>
/// Current chat box control. This can be modified, so do not depend on saving a reference to this.
/// </summary>
ChatBox? CurrentChatBox { get; }
/// <summary>
/// Invoked when CurrentChatBox is resized (including after setting initial default size)
/// </summary>
event Action<ChatResizedEventArgs>? OnChatBoxResized;
}
}

View File

@@ -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)

View File

@@ -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();

View File

@@ -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
/// </summary>
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<IChatManager>();
_chatManager.OnChatBoxResized += OnChatResized;
OnChatResized(new ChatResizedEventArgs(ChatBox.InitialChatBottom));
}
protected override void ExitedTree()
{
base.ExitedTree();
var _chatManager = IoCManager.Resolve<IChatManager>();
_chatManager.OnChatBoxResized -= OnChatResized;
}
private void OnChatResized(ChatResizedEventArgs chatResizedEventArgs)
{
// resize us to fit just below the chatbox
var _chatManager = IoCManager.Resolve<IChatManager>();
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()

View File

@@ -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);

View File

@@ -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[]
{

View File

@@ -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);
}
}

View File

@@ -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";

View File

@@ -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.

View File

@@ -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

View File

@@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 B

View File

@@ -0,0 +1,71 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="22.252764"
height="18.237368"
viewBox="0 0 22.252764 18.237368"
fill="none"
version="1.1"
id="svg8"
sodipodi:docname="filter.svg"
inkscape:export-filename="C:\ss14\space-station-14\Resources\Textures\Interface\Nano\filter.svg.96dpi.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96"
inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)">
<metadata
id="metadata14">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs12" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="2560"
inkscape:window-height="1377"
id="namedview10"
showgrid="false"
inkscape:pagecheckerboard="true"
inkscape:zoom="11.879394"
inkscape:cx="13.573766"
inkscape:cy="12.823837"
inkscape:window-x="1912"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="svg8"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0" />
<path
d="M 1.0666567,0.5 H 21.184687 l -8.5628,10.267 -0.1508,0.1809 v 0.2355 l 0.0011,6.1228 -2.7097003,-1.7953 v -4.3276 -0.2351 l -0.1504,-0.1807 z"
stroke="#ffffff"
stroke-width="1.3"
id="path4" />
<path
d="M 4.4535967,4.1833 H 17.786887"
stroke="#ffffff"
stroke-width="1.3"
stroke-linecap="square"
id="path6" />
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 B

View File

@@ -0,0 +1,64 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
version="1.1"
id="svg4"
sodipodi:docname="chat_filter_button.svg"
inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)">
<metadata
id="metadata10">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs8" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1291"
inkscape:window-height="991"
id="namedview6"
showgrid="false"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
inkscape:zoom="11.136932"
inkscape:cx="15.825633"
inkscape:cy="16.930202"
inkscape:window-x="2766"
inkscape:window-y="0"
inkscape:window-maximized="0"
inkscape:current-layer="svg4" />
<rect
x="0"
y="0"
width="32"
height="32"
rx="5"
fill="#ffffff"
id="rect2" />
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 B

View File

@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="32"
height="32"
viewBox="0 0 32 32"
fill="none"
version="1.1"
id="svg4"
sodipodi:docname="rounded_button_bordered.svg"
inkscape:version="1.0.1 (3bc2e813f5, 2020-09-07)"
inkscape:export-filename="C:\ss14\space-station-14\Resources\Textures\Interface\Nano\rounded_button_bordered.svg.96dpi.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96">
<metadata
id="metadata10">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<defs
id="defs8" />
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="2560"
inkscape:window-height="1377"
id="namedview6"
showgrid="false"
fit-margin-top="0"
fit-margin-left="0"
fit-margin-right="0"
fit-margin-bottom="0"
inkscape:zoom="11.136932"
inkscape:cx="15.825633"
inkscape:cy="16.930202"
inkscape:window-x="1912"
inkscape:window-y="-8"
inkscape:window-maximized="1"
inkscape:current-layer="svg4"
inkscape:document-rotation="0"
inkscape:pagecheckerboard="true" />
<rect
x="0"
y="0"
width="32"
height="32"
rx="5"
fill="#ffffff"
id="rect2"
style="stroke:#cfcfcf;stroke-opacity:1;fill:#ffffff;fill-opacity:1" />
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 370 B

View File

@@ -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