Hud refactor (#7202)

Co-authored-by: DrSmugleaf <DrSmugleaf@users.noreply.github.com>
Co-authored-by: Jezithyr <jmaster9999@gmail.com>
Co-authored-by: Jezithyr <Jezithyr@gmail.com>
Co-authored-by: Visne <39844191+Visne@users.noreply.github.com>
Co-authored-by: wrexbe <wrexbe@protonmail.com>
Co-authored-by: wrexbe <81056464+wrexbe@users.noreply.github.com>
This commit is contained in:
Jezithyr
2022-10-12 01:16:23 -07:00
committed by GitHub
parent d09fbc1849
commit 571dd4e6d5
168 changed files with 6940 additions and 7817 deletions

View File

@@ -1,21 +0,0 @@
using Content.Shared.Chat;
using Robust.Shared.Maths;
namespace Content.Client.Chat
{
public sealed class ChatHelper
{
public static Color ChatColor(ChatChannel channel) =>
channel switch
{
ChatChannel.Server => Color.Orange,
ChatChannel.Radio => Color.LimeGreen,
ChatChannel.LOOC => Color.MediumTurquoise,
ChatChannel.OOC => Color.LightSkyBlue,
ChatChannel.Dead => Color.MediumPurple,
ChatChannel.Admin => Color.Red,
ChatChannel.Whisper => Color.DarkGray,
_ => Color.LightGray
};
}
}

View File

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

View File

@@ -1,65 +1,5 @@
using Content.Client.Chat.Managers;
using Content.Client.Chat.UI;
using Content.Client.Examine;
using Content.Shared.Chat;
using Content.Shared.Examine;
using Robust.Client.Player;
using Robust.Shared.Map;
namespace Content.Client.Chat;
public sealed class ChatSystem : SharedChatSystem
{
[Dependency] private readonly IChatManager _manager = default!;
[Dependency] private readonly IPlayerManager _player = default!;
[Dependency] private readonly ExamineSystem _examineSystem = default!;
public override void FrameUpdate(float frameTime)
{
base.FrameUpdate(frameTime);
var player = _player.LocalPlayer?.ControlledEntity;
var predicate = static (EntityUid uid, (EntityUid compOwner, EntityUid? attachedEntity) data)
=> uid == data.compOwner || uid == data.attachedEntity;
var bubbles = _manager.GetSpeechBubbles();
var playerPos = player != null ? Transform(player.Value).MapPosition : MapCoordinates.Nullspace;
var occluded = player != null && _examineSystem.IsOccluded(player.Value);
foreach (var (ent, bubs) in bubbles)
{
if (Deleted(ent))
{
SetBubbles(bubs, false);
continue;
}
if (ent == player)
{
SetBubbles(bubs, true);
continue;
}
var otherPos = Transform(ent).MapPosition;
if (occluded && !ExamineSystemShared.InRangeUnOccluded(
playerPos,
otherPos, 0f,
(ent, player), predicate))
{
SetBubbles(bubs, false);
continue;
}
SetBubbles(bubs, true);
}
}
private void SetBubbles(List<SpeechBubble> bubbles, bool value)
{
foreach (var bubble in bubbles)
{
bubble.Visible = value;
}
}
}
public sealed class ChatSystem : SharedChatSystem {}

View File

@@ -1,327 +1,29 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Content.Client.Administration.Managers;
using Content.Client.Chat.UI;
using Content.Client.Gameplay;
using Content.Client.Ghost;
using Content.Client.Viewport;
using Content.Shared.Administration;
using Content.Shared.CCVar;
using Content.Shared.Chat;
using Robust.Client.Console;
using Robust.Client.Graphics;
using Robust.Client.Player;
using Robust.Client.State;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using Robust.Shared.Configuration;
using Robust.Shared.GameObjects;
using Robust.Shared.IoC;
using Robust.Shared.Log;
using Robust.Shared.Network;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
namespace Content.Client.Chat.Managers
{
internal sealed class ChatManager : IChatManager, IPostInjectInit
internal sealed class ChatManager : IChatManager
{
private struct SpeechBubbleData
{
public string Message;
public SpeechBubble.SpeechType Type;
}
[Dependency] private readonly IClientConsoleHost _consoleHost = default!;
[Dependency] private readonly IClientAdminManager _adminMgr = default!;
[Dependency] private readonly IEntitySystemManager _systems = default!;
private ISawmill _sawmill = default!;
/// <summary>
/// The max amount of chars allowed to fit in a single speech bubble.
/// </summary>
private const int SingleBubbleCharLimit = 100;
/// <summary>
/// Base queue delay each speech bubble has.
/// </summary>
private const float BubbleDelayBase = 0.2f;
/// <summary>
/// Factor multiplied by speech bubble char length to add to delay.
/// </summary>
private const float BubbleDelayFactor = 0.8f / SingleBubbleCharLimit;
/// <summary>
/// The max amount of speech bubbles over a single entity at once.
/// </summary>
private const int SpeechBubbleCap = 4;
/// <summary>
/// The max amount of characters an entity can send in one message
/// </summary>
public int MaxMessageLength => _cfg.GetCVar(CCVars.ChatMaxMessageLength);
private readonly List<StoredChatMessage> _history = new();
public IReadOnlyList<StoredChatMessage> History => _history;
// currently enabled channel filters set by the user.
// All values default to on, even if they aren't a filterable chat channel currently.
// Note that these are persisted here, at the manager,
// rather than the chatbox so that these settings persist between instances of different
// chatboxes.
public ChatChannel ChannelFilters { get; private set; } = (ChatChannel) ushort.MaxValue;
// Maintains which channels a client should be able to filter (for showing in the chatbox)
// and select (for attempting to send on).
// 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.
public ChatSelectChannel SelectableChannels { get; private set; }
public ChatChannel FilterableChannels { get; private set; }
/// <summary>
/// For currently disabled chat filters,
/// unread messages (messages received since the channel has been filtered out).
/// </summary>
private readonly Dictionary<ChatChannel, int> _unreadMessages = new();
public IReadOnlyDictionary<ChatChannel, int> UnreadMessages => _unreadMessages;
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly IClientNetManager _netManager = default!;
[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 IClientAdminManager _adminMgr = default!;
[Dependency] private readonly IConfigurationManager _cfg = default!;
[Dependency] private readonly IStateManager _stateManager = default!;
/// <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;
public event Action<ChatPermissionsUpdatedEventArgs>? ChatPermissionsUpdated;
public event Action? UnreadMessageCountsUpdated;
public event Action<StoredChatMessage>? MessageAdded;
public event Action? FiltersUpdated;
private Control _speechBubbleRoot = null!;
/// <summary>
/// Speech bubbles that are currently visible on screen.
/// We track them to push them up when new ones get added.
/// </summary>
private readonly Dictionary<EntityUid, List<SpeechBubble>> _activeSpeechBubbles =
new();
/// <summary>
/// Speech bubbles that are to-be-sent because of the "rate limit" they have.
/// </summary>
private readonly Dictionary<EntityUid, SpeechBubbleQueueData> _queuedSpeechBubbles
= new();
public void Initialize()
{
_sawmill = Logger.GetSawmill("chat");
_sawmill.Level = LogLevel.Info;
_netManager.RegisterNetMessage<MsgChatMessage>(OnChatMessage);
_speechBubbleRoot = new LayoutContainer();
LayoutContainer.SetAnchorPreset(_speechBubbleRoot, LayoutContainer.LayoutPreset.Wide);
_userInterfaceManager.StateRoot.AddChild(_speechBubbleRoot);
_speechBubbleRoot.SetPositionFirst();
_stateManager.OnStateChanged += _ => UpdateChannelPermissions();
}
public IReadOnlyDictionary<EntityUid, List<SpeechBubble>> GetSpeechBubbles() => _activeSpeechBubbles;
public void PostInject()
public void SendMessage(ReadOnlyMemory<char> text, ChatSelectChannel channel)
{
_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()
{
var oldSelectable = SelectableChannels;
SelectableChannels = default;
FilterableChannels = default;
// Can always send console stuff.
SelectableChannels |= ChatSelectChannel.Console;
// can always send/recieve OOC
SelectableChannels |= ChatSelectChannel.OOC;
FilterableChannels |= ChatChannel.OOC;
SelectableChannels |= ChatSelectChannel.LOOC;
FilterableChannels |= ChatChannel.LOOC;
// can always hear server (nobody can actually send server messages).
FilterableChannels |= ChatChannel.Server;
if (_stateManager.CurrentState is GameplayStateBase)
{
// can always hear local / radio / emote when in the game
FilterableChannels |= ChatChannel.Local;
FilterableChannels |= ChatChannel.Whisper;
FilterableChannels |= ChatChannel.Radio;
FilterableChannels |= ChatChannel.Emotes;
// Can only send local / radio / emote when attached to a non-ghost entity.
// TODO: this logic is iffy (checking if controlling something that's NOT a ghost), is there a better way to check this?
if (!IsGhost)
{
SelectableChannels |= ChatSelectChannel.Local;
SelectableChannels |= ChatSelectChannel.Whisper;
SelectableChannels |= ChatSelectChannel.Radio;
SelectableChannels |= ChatSelectChannel.Emotes;
}
}
// Only ghosts and admins can send / see deadchat.
if (_adminMgr.HasFlag(AdminFlags.Admin) || IsGhost)
{
FilterableChannels |= ChatChannel.Dead;
SelectableChannels |= ChatSelectChannel.Dead;
}
// only admins can see / filter asay
if (_adminMgr.HasFlag(AdminFlags.Admin))
{
FilterableChannels |= ChatChannel.Admin;
SelectableChannels |= ChatSelectChannel.Admin;
}
// Necessary so that we always have a channel to fall back to.
DebugTools.Assert((SelectableChannels & ChatSelectChannel.OOC) != 0, "OOC must always be available");
DebugTools.Assert((FilterableChannels & ChatChannel.OOC) != 0, "OOC must always be available");
// let our chatbox know all the new settings
ChatPermissionsUpdated?.Invoke(new ChatPermissionsUpdatedEventArgs {OldSelectableChannels = oldSelectable});
}
public bool IsGhost => _playerManager.LocalPlayer?.ControlledEntity is {} uid &&
uid.IsValid() &&
_entityManager.HasComponent<GhostComponent>(uid);
public void FrameUpdate(FrameEventArgs delta)
{
// Update queued speech bubbles.
if (_queuedSpeechBubbles.Count == 0)
{
return;
}
foreach (var (entity, queueData) in _queuedSpeechBubbles.ShallowClone())
{
if (!_entityManager.EntityExists(entity))
{
_queuedSpeechBubbles.Remove(entity);
continue;
}
queueData.TimeLeft -= delta.DeltaSeconds;
if (queueData.TimeLeft > 0)
{
continue;
}
if (queueData.MessageQueue.Count == 0)
{
_queuedSpeechBubbles.Remove(entity);
continue;
}
var msg = queueData.MessageQueue.Dequeue();
queueData.TimeLeft += BubbleDelayBase + msg.Message.Length * BubbleDelayFactor;
// We keep the queue around while it has 0 items. This allows us to keep the timer.
// When the timer hits 0 and there's no messages left, THEN we can clear it up.
CreateSpeechBubble(entity, msg);
}
}
public void SetChatBox(ChatBox chatBox)
{
CurrentChatBox = chatBox;
}
public void ClearUnfilteredUnreads()
{
foreach (var channel in _unreadMessages.Keys.ToArray())
{
if ((ChannelFilters & channel) != 0)
_unreadMessages.Remove(channel);
}
}
public void ChatBoxOnResized(ChatResizedEventArgs chatResizedEventArgs)
{
OnChatBoxResized?.Invoke(chatResizedEventArgs);
}
public void RemoveSpeechBubble(EntityUid entityUid, SpeechBubble bubble)
{
bubble.Dispose();
var list = _activeSpeechBubbles[entityUid];
list.Remove(bubble);
if (list.Count == 0)
{
_activeSpeechBubbles.Remove(entityUid);
}
}
public void OnChatBoxTextSubmitted(ChatBox chatBox, ReadOnlyMemory<char> text, ChatSelectChannel channel)
{
DebugTools.Assert(chatBox == CurrentChatBox);
var str = text.ToString();
switch (channel)
{
case ChatSelectChannel.Console:
@@ -346,9 +48,10 @@ namespace Content.Client.Chat.Managers
break;
case ChatSelectChannel.Dead:
if (IsGhost)
if (_systems.GetEntitySystemOrNull<GhostSystem>() is {IsGhost: true})
goto case ChatSelectChannel.Local;
else if (_adminMgr.HasFlag(AdminFlags.Admin))
if (_adminMgr.HasFlag(AdminFlags.Admin))
_consoleHost.ExecuteCommand($"dsay \"{CommandParsing.Escape(str)}\"");
else
_sawmill.Warning("Tried to speak on deadchat without being ghost or admin.");
@@ -370,190 +73,5 @@ namespace Content.Client.Chat.Managers
throw new ArgumentOutOfRangeException(nameof(channel), channel, null);
}
}
public void OnFilterButtonToggled(ChatChannel channel, bool enabled)
{
if (enabled)
{
ChannelFilters |= channel;
_unreadMessages.Remove(channel);
UnreadMessageCountsUpdated?.Invoke();
}
else
{
ChannelFilters &= ~channel;
}
FiltersUpdated?.Invoke();
}
private void OnChatMessage(MsgChatMessage msg)
{
// Log all incoming chat to repopulate when filter is un-toggled
if (!msg.HideChat)
{
var storedMessage = new StoredChatMessage(msg);
_history.Add(storedMessage);
MessageAdded?.Invoke(storedMessage);
if (!storedMessage.Read)
{
_sawmill.Debug($"Message filtered: {storedMessage.Channel}: {storedMessage.Message}");
if (!_unreadMessages.TryGetValue(msg.Channel, out var count))
count = 0;
count += 1;
_unreadMessages[msg.Channel] = count;
UnreadMessageCountsUpdated?.Invoke();
}
}
// Local messages that have an entity attached get a speech bubble.
if (msg.SenderEntity == default)
return;
switch (msg.Channel)
{
case ChatChannel.Local:
AddSpeechBubble(msg, SpeechBubble.SpeechType.Say);
break;
case ChatChannel.Whisper:
AddSpeechBubble(msg, SpeechBubble.SpeechType.Whisper);
break;
case ChatChannel.Dead:
if (!IsGhost)
break;
AddSpeechBubble(msg, SpeechBubble.SpeechType.Say);
break;
case ChatChannel.Emotes:
AddSpeechBubble(msg, SpeechBubble.SpeechType.Emote);
break;
}
}
private void AddSpeechBubble(MsgChatMessage msg, SpeechBubble.SpeechType speechType)
{
if (!_entityManager.EntityExists(msg.SenderEntity))
{
_sawmill.Debug("Got local chat message with invalid sender entity: {0}", msg.SenderEntity);
return;
}
var messages = SplitMessage(FormattedMessage.RemoveMarkup(msg.Message));
foreach (var message in messages)
{
EnqueueSpeechBubble(msg.SenderEntity, message, speechType);
}
}
private List<string> SplitMessage(string msg)
{
// Split message into words separated by spaces.
var words = msg.Split(' ');
var messages = new List<string>();
var currentBuffer = new List<string>();
// Really shoddy way to approximate word length.
// Yes, I am aware of all the crimes here.
// TODO: Improve this to use actual glyph width etc..
var currentWordLength = 0;
foreach (var word in words)
{
// +1 for the space.
currentWordLength += word.Length + 1;
if (currentWordLength > SingleBubbleCharLimit)
{
// Too long for the current speech bubble, flush it.
messages.Add(string.Join(" ", currentBuffer));
currentBuffer.Clear();
currentWordLength = word.Length;
if (currentWordLength > SingleBubbleCharLimit)
{
// Word is STILL too long.
// Truncate it with an ellipse.
messages.Add($"{word.Substring(0, SingleBubbleCharLimit - 3)}...");
currentWordLength = 0;
continue;
}
}
currentBuffer.Add(word);
}
if (currentBuffer.Count != 0)
{
// Don't forget the last bubble.
messages.Add(string.Join(" ", currentBuffer));
}
return messages;
}
private void EnqueueSpeechBubble(EntityUid entity, string contents, SpeechBubble.SpeechType speechType)
{
// Don't enqueue speech bubbles for other maps. TODO: Support multiple viewports/maps?
if (_entityManager.GetComponent<TransformComponent>(entity).MapID != _eyeManager.CurrentMap)
return;
if (!_queuedSpeechBubbles.TryGetValue(entity, out var queueData))
{
queueData = new SpeechBubbleQueueData();
_queuedSpeechBubbles.Add(entity, queueData);
}
queueData.MessageQueue.Enqueue(new SpeechBubbleData
{
Message = contents,
Type = speechType,
});
}
private void CreateSpeechBubble(EntityUid entity, SpeechBubbleData speechData)
{
var bubble =
SpeechBubble.CreateSpeechBubble(speechData.Type, speechData.Message, entity, _eyeManager, this, _entityManager);
if (_activeSpeechBubbles.TryGetValue(entity, out var existing))
{
// Push up existing bubbles above the mob's head.
foreach (var existingBubble in existing)
{
existingBubble.VerticalOffset += bubble.ContentSize.Y;
}
}
else
{
existing = new List<SpeechBubble>();
_activeSpeechBubbles.Add(entity, existing);
}
existing.Add(bubble);
_speechBubbleRoot.AddChild(bubble);
if (existing.Count > SpeechBubbleCap)
{
// Get the oldest to start fading fast.
var last = existing[0];
last.FadeNow();
}
}
private sealed class SpeechBubbleQueueData
{
/// <summary>
/// Time left until the next speech bubble can appear.
/// </summary>
public float TimeLeft { get; set; }
public Queue<SpeechBubbleData> MessageQueue { get; } = new();
}
}
}

View File

@@ -1,55 +1,11 @@
using System;
using System.Collections.Generic;
using Content.Client.Chat.UI;
using Content.Shared.Chat;
using Robust.Shared.GameObjects;
using Robust.Shared.Timing;
namespace Content.Client.Chat.Managers
{
public interface IChatManager
{
ChatChannel ChannelFilters { get; }
ChatSelectChannel SelectableChannels { get; }
ChatChannel FilterableChannels { get; }
void Initialize();
void FrameUpdate(FrameEventArgs delta);
void SetChatBox(ChatBox chatBox);
void RemoveSpeechBubble(EntityUid entityUid, SpeechBubble bubble);
/// <summary>
/// Current chat box control. This can be modified, so do not depend on saving a reference to this.
/// </summary>
ChatBox? CurrentChatBox { get; }
IReadOnlyDictionary<EntityUid, List<SpeechBubble>> GetSpeechBubbles();
IReadOnlyDictionary<ChatChannel, int> UnreadMessages { get; }
IReadOnlyList<StoredChatMessage> History { get; }
int MaxMessageLength { get; }
bool IsGhost { get; }
/// <summary>
/// Invoked when CurrentChatBox is resized (including after setting initial default size)
/// </summary>
event Action<ChatResizedEventArgs>? OnChatBoxResized;
event Action<ChatPermissionsUpdatedEventArgs>? ChatPermissionsUpdated;
event Action? UnreadMessageCountsUpdated;
event Action<StoredChatMessage>? MessageAdded;
event Action? FiltersUpdated;
void ClearUnfilteredUnreads();
void ChatBoxOnResized(ChatResizedEventArgs chatResizedEventArgs);
void OnChatBoxTextSubmitted(ChatBox chatBox, ReadOnlyMemory<char> text, ChatSelectChannel channel);
void OnFilterButtonToggled(ChatChannel channel, bool enabled);
}
public struct ChatPermissionsUpdatedEventArgs
{
public ChatSelectChannel OldSelectableChannels;
public void SendMessage(ReadOnlyMemory<char> text, ChatSelectChannel channel);
}
}

View File

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

View File

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

View File

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

View File

@@ -1,12 +1,7 @@
using System;
using Content.Client.Chat.Managers;
using Content.Client.Viewport;
using Robust.Client.Graphics;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
using Robust.Shared.GameObjects;
using Robust.Shared.IoC;
using Robust.Shared.Maths;
using Robust.Shared.Timing;
namespace Content.Client.Chat.UI
@@ -48,6 +43,9 @@ namespace Content.Client.Chat.UI
public Vector2 ContentSize { get; private set; }
// man down
public event Action<EntityUid, SpeechBubble>? OnDied;
public static SpeechBubble CreateSpeechBubble(SpeechType type, string text, EntityUid senderEntity, IEyeManager eyeManager, IChatManager chatManager, IEntityManager entityManager)
{
switch (type)
@@ -148,7 +146,7 @@ namespace Content.Client.Chat.UI
return;
}
_chatManager.RemoveSpeechBubble(_senderEntity, this);
OnDied?.Invoke(_senderEntity, this);
}
/// <summary>
@@ -164,7 +162,6 @@ namespace Content.Client.Chat.UI
}
public sealed class TextSpeechBubble : SpeechBubble
{
public TextSpeechBubble(string text, EntityUid senderEntity, IEyeManager eyeManager, IChatManager chatManager, IEntityManager entityManager, string speechStyleClass)
: base(text, senderEntity, eyeManager, chatManager, entityManager, speechStyleClass)