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:
715
Content.Client/UserInterface/Systems/Chat/ChatUIController.cs
Normal file
715
Content.Client/UserInterface/Systems/Chat/ChatUIController.cs
Normal file
@@ -0,0 +1,715 @@
|
||||
using System.Linq;
|
||||
using Content.Client.Administration.Managers;
|
||||
using Content.Client.Chat;
|
||||
using Content.Client.Chat.Managers;
|
||||
using Content.Client.Chat.TypingIndicator;
|
||||
using Content.Client.Chat.UI;
|
||||
using Content.Client.Examine;
|
||||
using Content.Client.Gameplay;
|
||||
using Content.Client.Ghost;
|
||||
using Content.Client.UserInterface.Systems.Chat.Widgets;
|
||||
using Content.Shared.Administration;
|
||||
using Content.Shared.CCVar;
|
||||
using Content.Shared.Chat;
|
||||
using Content.Shared.Examine;
|
||||
using Content.Shared.Input;
|
||||
using Robust.Client.Graphics;
|
||||
using Robust.Client.Input;
|
||||
using Robust.Client.Player;
|
||||
using Robust.Client.State;
|
||||
using Robust.Client.UserInterface;
|
||||
using Robust.Client.UserInterface.Controllers;
|
||||
using Robust.Client.UserInterface.Controls;
|
||||
using Robust.Shared.Configuration;
|
||||
using Robust.Shared.Input.Binding;
|
||||
using Robust.Shared.Map;
|
||||
using Robust.Shared.Network;
|
||||
using Robust.Shared.Timing;
|
||||
using Robust.Shared.Utility;
|
||||
|
||||
namespace Content.Client.UserInterface.Systems.Chat;
|
||||
|
||||
public sealed class ChatUIController : UIController
|
||||
{
|
||||
[Dependency] private readonly IClientAdminManager _admin = default!;
|
||||
[Dependency] private readonly IChatManager _manager = default!;
|
||||
[Dependency] private readonly IConfigurationManager _config = default!;
|
||||
[Dependency] private readonly IEntityManager _entities = default!;
|
||||
[Dependency] private readonly IEyeManager _eye = default!;
|
||||
[Dependency] private readonly IInputManager _input = default!;
|
||||
[Dependency] private readonly IClientNetManager _net = default!;
|
||||
[Dependency] private readonly IPlayerManager _player = default!;
|
||||
[Dependency] private readonly IStateManager _state = default!;
|
||||
|
||||
[UISystemDependency] private readonly ExamineSystem? _examine = default;
|
||||
[UISystemDependency] private readonly GhostSystem? _ghost = default;
|
||||
[UISystemDependency] private readonly TypingIndicatorSystem? _typingIndicator = default;
|
||||
|
||||
private ISawmill _sawmill = default!;
|
||||
|
||||
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);
|
||||
|
||||
/// <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;
|
||||
|
||||
private LayoutContainer _speechBubbleRoot = default!;
|
||||
|
||||
/// <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();
|
||||
|
||||
private readonly HashSet<ChatBox> _chats = new();
|
||||
|
||||
/// <summary>
|
||||
/// The max amount of characters an entity can send in one message
|
||||
/// </summary>
|
||||
public int MaxMessageLength => _config.GetCVar(CCVars.ChatMaxMessageLength);
|
||||
|
||||
/// <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 readonly List<StoredChatMessage> History = 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.
|
||||
public ChatSelectChannel CanSendChannels { get; private set; }
|
||||
public ChatChannel FilterableChannels { get; private set; }
|
||||
public ChatSelectChannel SelectableChannels { get; private set; }
|
||||
private ChatSelectChannel PreferredChannel { get; set; } = ChatSelectChannel.OOC;
|
||||
|
||||
public event Action<ChatSelectChannel>? CanSendChannelsChanged;
|
||||
public event Action<ChatChannel>? FilterableChannelsChanged;
|
||||
public event Action<ChatSelectChannel>? SelectableChannelsChanged;
|
||||
public event Action<ChatChannel, int?>? UnreadMessageCountsUpdated;
|
||||
public event Action<StoredChatMessage>? MessageAdded;
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
_sawmill = Logger.GetSawmill("chat");
|
||||
_sawmill.Level = LogLevel.Info;
|
||||
_admin.AdminStatusUpdated += UpdateChannelPermissions;
|
||||
_player.LocalPlayerChanged += OnLocalPlayerChanged;
|
||||
_state.OnStateChanged += StateChanged;
|
||||
_net.RegisterNetMessage<MsgChatMessage>(OnChatMessage);
|
||||
|
||||
_speechBubbleRoot = new LayoutContainer();
|
||||
|
||||
OnLocalPlayerChanged(new LocalPlayerChangedEventArgs(null, _player.LocalPlayer));
|
||||
|
||||
_input.SetInputCommand(ContentKeyFunctions.FocusChat,
|
||||
InputCmdHandler.FromDelegate(_ => FocusChat()));
|
||||
|
||||
_input.SetInputCommand(ContentKeyFunctions.FocusLocalChat,
|
||||
InputCmdHandler.FromDelegate(_ => FocusChannel(ChatSelectChannel.Local)));
|
||||
|
||||
_input.SetInputCommand(ContentKeyFunctions.FocusWhisperChat,
|
||||
InputCmdHandler.FromDelegate(_ => FocusChannel(ChatSelectChannel.Whisper)));
|
||||
|
||||
_input.SetInputCommand(ContentKeyFunctions.FocusOOC,
|
||||
InputCmdHandler.FromDelegate(_ => FocusChannel(ChatSelectChannel.OOC)));
|
||||
|
||||
_input.SetInputCommand(ContentKeyFunctions.FocusAdminChat,
|
||||
InputCmdHandler.FromDelegate(_ => FocusChannel(ChatSelectChannel.Admin)));
|
||||
|
||||
_input.SetInputCommand(ContentKeyFunctions.FocusRadio,
|
||||
InputCmdHandler.FromDelegate(_ => FocusChannel(ChatSelectChannel.Radio)));
|
||||
|
||||
_input.SetInputCommand(ContentKeyFunctions.FocusDeadChat,
|
||||
InputCmdHandler.FromDelegate(_ => FocusChannel(ChatSelectChannel.Dead)));
|
||||
|
||||
_input.SetInputCommand(ContentKeyFunctions.FocusConsoleChat,
|
||||
InputCmdHandler.FromDelegate(_ => FocusChannel(ChatSelectChannel.Console)));
|
||||
|
||||
_input.SetInputCommand(ContentKeyFunctions.CycleChatChannelForward,
|
||||
InputCmdHandler.FromDelegate(_ => CycleChatChannel(true)));
|
||||
|
||||
_input.SetInputCommand(ContentKeyFunctions.CycleChatChannelBackward,
|
||||
InputCmdHandler.FromDelegate(_ => CycleChatChannel(false)));
|
||||
}
|
||||
|
||||
private void FocusChat()
|
||||
{
|
||||
foreach (var chat in _chats)
|
||||
{
|
||||
if (!chat.Main)
|
||||
continue;
|
||||
|
||||
chat.Focus();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void FocusChannel(ChatSelectChannel channel)
|
||||
{
|
||||
foreach (var chat in _chats)
|
||||
{
|
||||
if (!chat.Main)
|
||||
continue;
|
||||
|
||||
chat.Focus(channel);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void CycleChatChannel(bool forward)
|
||||
{
|
||||
foreach (var chat in _chats)
|
||||
{
|
||||
if (!chat.Main)
|
||||
continue;
|
||||
|
||||
chat.CycleChatChannel(forward);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private void StateChanged(StateChangedEventArgs args)
|
||||
{
|
||||
if (args.NewState is GameplayState)
|
||||
{
|
||||
PreferredChannel = ChatSelectChannel.Local;
|
||||
}
|
||||
|
||||
UpdateChannelPermissions();
|
||||
|
||||
if (_speechBubbleRoot.Parent == UIManager.StateRoot)
|
||||
return;
|
||||
|
||||
_speechBubbleRoot.Orphan();
|
||||
LayoutContainer.SetAnchorPreset(_speechBubbleRoot, LayoutContainer.LayoutPreset.Wide);
|
||||
UIManager.StateRoot.AddChild(_speechBubbleRoot);
|
||||
_speechBubbleRoot.SetPositionFirst();
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
private void AddSpeechBubble(MsgChatMessage msg, SpeechBubble.SpeechType speechType)
|
||||
{
|
||||
if (!_entities.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 void CreateSpeechBubble(EntityUid entity, SpeechBubbleData speechData)
|
||||
{
|
||||
var bubble =
|
||||
SpeechBubble.CreateSpeechBubble(speechData.Type, speechData.Message, entity, _eye, _manager, _entities);
|
||||
|
||||
bubble.OnDied += SpeechBubbleDied;
|
||||
|
||||
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 void SpeechBubbleDied(EntityUid entity, SpeechBubble bubble)
|
||||
{
|
||||
RemoveSpeechBubble(entity, bubble);
|
||||
}
|
||||
|
||||
private void EnqueueSpeechBubble(EntityUid entity, string contents, SpeechBubble.SpeechType speechType)
|
||||
{
|
||||
// Don't enqueue speech bubbles for other maps. TODO: Support multiple viewports/maps?
|
||||
if (_entities.GetComponent<TransformComponent>(entity).MapID != _eye.CurrentMap)
|
||||
return;
|
||||
|
||||
if (!_queuedSpeechBubbles.TryGetValue(entity, out var queueData))
|
||||
{
|
||||
queueData = new SpeechBubbleQueueData();
|
||||
_queuedSpeechBubbles.Add(entity, queueData);
|
||||
}
|
||||
|
||||
queueData.MessageQueue.Enqueue(new SpeechBubbleData(contents, speechType));
|
||||
}
|
||||
|
||||
public void RemoveSpeechBubble(EntityUid entityUid, SpeechBubble bubble)
|
||||
{
|
||||
bubble.Dispose();
|
||||
|
||||
var list = _activeSpeechBubbles[entityUid];
|
||||
list.Remove(bubble);
|
||||
|
||||
if (list.Count == 0)
|
||||
{
|
||||
_activeSpeechBubbles.Remove(entityUid);
|
||||
}
|
||||
}
|
||||
|
||||
public static string GetChannelSelectorName(ChatSelectChannel channelSelector)
|
||||
{
|
||||
return channelSelector.ToString();
|
||||
}
|
||||
|
||||
public static char GetChannelSelectorPrefix(ChatSelectChannel channelSelector)
|
||||
{
|
||||
return channelSelector switch
|
||||
{
|
||||
ChatSelectChannel.Local => '.',
|
||||
ChatSelectChannel.Whisper => ',',
|
||||
ChatSelectChannel.Radio => ';',
|
||||
ChatSelectChannel.LOOC => '(',
|
||||
ChatSelectChannel.OOC => '[',
|
||||
ChatSelectChannel.Emotes => '@',
|
||||
ChatSelectChannel.Dead => '\\',
|
||||
ChatSelectChannel.Admin => ']',
|
||||
ChatSelectChannel.Console => '/',
|
||||
_ => ' '
|
||||
};
|
||||
}
|
||||
|
||||
private void UpdateChannelPermissions()
|
||||
{
|
||||
CanSendChannels = default;
|
||||
FilterableChannels = default;
|
||||
|
||||
// Can always send console stuff.
|
||||
CanSendChannels |= ChatSelectChannel.Console;
|
||||
|
||||
// can always send/recieve OOC
|
||||
CanSendChannels |= ChatSelectChannel.OOC;
|
||||
CanSendChannels |= ChatSelectChannel.LOOC;
|
||||
FilterableChannels |= ChatChannel.OOC;
|
||||
FilterableChannels |= ChatChannel.LOOC;
|
||||
|
||||
// can always hear server (nobody can actually send server messages).
|
||||
FilterableChannels |= ChatChannel.Server;
|
||||
|
||||
if (_state.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 (_ghost is not {IsGhost: true})
|
||||
{
|
||||
CanSendChannels |= ChatSelectChannel.Local;
|
||||
CanSendChannels |= ChatSelectChannel.Whisper;
|
||||
CanSendChannels |= ChatSelectChannel.Radio;
|
||||
CanSendChannels |= ChatSelectChannel.Emotes;
|
||||
}
|
||||
}
|
||||
|
||||
// Only ghosts and admins can send / see deadchat.
|
||||
if (_admin.HasFlag(AdminFlags.Admin) || _ghost is {IsGhost: true})
|
||||
{
|
||||
FilterableChannels |= ChatChannel.Dead;
|
||||
CanSendChannels |= ChatSelectChannel.Dead;
|
||||
}
|
||||
|
||||
// only admins can see / filter asay
|
||||
if (_admin.HasFlag(AdminFlags.Admin))
|
||||
{
|
||||
FilterableChannels |= ChatChannel.Admin;
|
||||
CanSendChannels |= ChatSelectChannel.Admin;
|
||||
}
|
||||
|
||||
SelectableChannels = CanSendChannels & ~ChatSelectChannel.Console;
|
||||
|
||||
// Necessary so that we always have a channel to fall back to.
|
||||
DebugTools.Assert((CanSendChannels & ChatSelectChannel.OOC) != 0, "OOC must always be available");
|
||||
DebugTools.Assert((FilterableChannels & ChatChannel.OOC) != 0, "OOC must always be available");
|
||||
DebugTools.Assert((SelectableChannels & ChatSelectChannel.OOC) != 0, "OOC must always be available");
|
||||
|
||||
// let our chatbox know all the new settings
|
||||
CanSendChannelsChanged?.Invoke(CanSendChannels);
|
||||
FilterableChannelsChanged?.Invoke(FilterableChannels);
|
||||
SelectableChannelsChanged?.Invoke(SelectableChannels);
|
||||
}
|
||||
|
||||
public void ClearUnfilteredUnreads(ChatChannel channels)
|
||||
{
|
||||
foreach (var channel in _unreadMessages.Keys.ToArray())
|
||||
{
|
||||
if ((channels & channel) == 0)
|
||||
continue;
|
||||
|
||||
_unreadMessages[channel] = 0;
|
||||
UnreadMessageCountsUpdated?.Invoke(channel, 0);
|
||||
}
|
||||
}
|
||||
|
||||
public override void FrameUpdate(FrameEventArgs delta)
|
||||
{
|
||||
UpdateQueuedSpeechBubbles(delta);
|
||||
}
|
||||
|
||||
private void UpdateQueuedSpeechBubbles(FrameEventArgs delta)
|
||||
{
|
||||
// Update queued speech bubbles.
|
||||
if (_queuedSpeechBubbles.Count == 0 || _examine == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var (entity, queueData) in _queuedSpeechBubbles.ShallowClone())
|
||||
{
|
||||
if (!_entities.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);
|
||||
}
|
||||
|
||||
var player = _player.LocalPlayer?.ControlledEntity;
|
||||
var predicate = static (EntityUid uid, (EntityUid compOwner, EntityUid? attachedEntity) data)
|
||||
=> uid == data.compOwner || uid == data.attachedEntity;
|
||||
var playerPos = player != null
|
||||
? _entities.GetComponent<TransformComponent>(player.Value).MapPosition
|
||||
: MapCoordinates.Nullspace;
|
||||
|
||||
var occluded = player != null && _examine.IsOccluded(player.Value);
|
||||
|
||||
foreach (var (ent, bubs) in _activeSpeechBubbles)
|
||||
{
|
||||
if (_entities.Deleted(ent))
|
||||
{
|
||||
SetBubbles(bubs, false);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ent == player)
|
||||
{
|
||||
SetBubbles(bubs, true);
|
||||
continue;
|
||||
}
|
||||
|
||||
var otherPos = _entities.GetComponent<TransformComponent>(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 visible)
|
||||
{
|
||||
foreach (var bubble in bubbles)
|
||||
{
|
||||
bubble.Visible = visible;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
public ChatSelectChannel MapLocalIfGhost(ChatSelectChannel channel)
|
||||
{
|
||||
if (channel == ChatSelectChannel.Local && _ghost is {IsGhost: true})
|
||||
return ChatSelectChannel.Dead;
|
||||
|
||||
return channel;
|
||||
}
|
||||
|
||||
public (ChatSelectChannel channel, ReadOnlyMemory<char> text) SplitInputContents(string inputText)
|
||||
{
|
||||
var text = inputText.AsMemory().Trim();
|
||||
if (text.Length == 0)
|
||||
return default;
|
||||
|
||||
var prefixChar = text.Span[0];
|
||||
var channel = PrefixToChannel.GetValueOrDefault(prefixChar);
|
||||
|
||||
if ((CanSendChannels & 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());
|
||||
}
|
||||
|
||||
public void SendMessage(ChatBox box, ChatSelectChannel channel)
|
||||
{
|
||||
_typingIndicator?.ClientSubmittedChatText();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(box.ChatInput.Input.Text))
|
||||
{
|
||||
var (prefixChannel, text) = SplitInputContents(box.ChatInput.Input.Text);
|
||||
|
||||
// Check if message is longer than the character limit
|
||||
if (text.Length > MaxMessageLength)
|
||||
{
|
||||
var locWarning = Loc.GetString("chat-manager-max-message-length",
|
||||
("maxMessageLength", MaxMessageLength));
|
||||
box.AddLine(locWarning, Color.Orange);
|
||||
return;
|
||||
}
|
||||
|
||||
_manager.SendMessage(text, prefixChannel == 0 ? channel : prefixChannel);
|
||||
}
|
||||
|
||||
box.ChatInput.Input.Clear();
|
||||
box.UpdateSelectedChannel();
|
||||
box.ChatInput.ReleaseKeyboardFocus();
|
||||
}
|
||||
|
||||
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(msg.Channel, count);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 (_ghost is not {IsGhost: true})
|
||||
break;
|
||||
|
||||
AddSpeechBubble(msg, SpeechBubble.SpeechType.Say);
|
||||
break;
|
||||
|
||||
case ChatChannel.Emotes:
|
||||
AddSpeechBubble(msg, SpeechBubble.SpeechType.Emote);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public char GetPrefixFromChannel(ChatSelectChannel channel)
|
||||
{
|
||||
return ChannelPrefixes.GetValueOrDefault(channel);
|
||||
}
|
||||
|
||||
public void RegisterChat(ChatBox chat)
|
||||
{
|
||||
_chats.Add(chat);
|
||||
}
|
||||
|
||||
public void UnregisterChat(ChatBox chat)
|
||||
{
|
||||
_chats.Remove(chat);
|
||||
}
|
||||
|
||||
public ChatSelectChannel GetPreferredChannel()
|
||||
{
|
||||
return MapLocalIfGhost(PreferredChannel);
|
||||
}
|
||||
|
||||
private readonly record struct SpeechBubbleData(string Message, SpeechBubble.SpeechType Type);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using Content.Client.Resources;
|
||||
using Robust.Client.ResourceManagement;
|
||||
using Robust.Client.UserInterface;
|
||||
using Robust.Client.UserInterface.Controls;
|
||||
using Robust.Shared.Input;
|
||||
|
||||
namespace Content.Client.UserInterface.Systems.Chat.Controls;
|
||||
|
||||
public sealed class ChannelFilterButton : 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 readonly ChannelFilterPopup ChatFilterPopup;
|
||||
private readonly ChatUIController _chatUIController;
|
||||
private const int FilterDropdownOffset = 120;
|
||||
|
||||
public ChannelFilterButton()
|
||||
{
|
||||
_chatUIController = UserInterfaceManager.GetUIController<ChatUIController>();
|
||||
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;
|
||||
OnToggled += OnFilterButtonToggled;
|
||||
ChatFilterPopup = UserInterfaceManager.CreatePopup<ChannelFilterPopup>();
|
||||
ChatFilterPopup.OnVisibilityChanged += PopupVisibilityChanged;
|
||||
|
||||
_chatUIController.FilterableChannelsChanged += ChatFilterPopup.SetChannels;
|
||||
_chatUIController.UnreadMessageCountsUpdated += ChatFilterPopup.UpdateUnread;
|
||||
ChatFilterPopup.SetChannels(_chatUIController.FilterableChannels);
|
||||
}
|
||||
|
||||
private void PopupVisibilityChanged(Control control)
|
||||
{
|
||||
Pressed = control.Visible;
|
||||
}
|
||||
|
||||
private void OnFilterButtonToggled(ButtonToggledEventArgs args)
|
||||
{
|
||||
if (args.Pressed)
|
||||
{
|
||||
var globalPos = GlobalPosition;
|
||||
var (minX, minY) = ChatFilterPopup.MinSize;
|
||||
var box = UIBox2.FromDimensions(globalPos - (FilterDropdownOffset, 0),
|
||||
(Math.Max(minX, ChatFilterPopup.MinWidth), minY));
|
||||
ChatFilterPopup.Open(box);
|
||||
}
|
||||
else
|
||||
{
|
||||
ChatFilterPopup.Close();
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
|
||||
if (!disposing)
|
||||
return;
|
||||
|
||||
_chatUIController.FilterableChannelsChanged -= ChatFilterPopup.SetChannels;
|
||||
_chatUIController.UnreadMessageCountsUpdated -= ChatFilterPopup.UpdateUnread;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
using Content.Shared.Chat;
|
||||
using Robust.Client.UserInterface.Controls;
|
||||
|
||||
namespace Content.Client.UserInterface.Systems.Chat.Controls;
|
||||
|
||||
public sealed class ChannelFilterCheckbox : CheckBox
|
||||
{
|
||||
public readonly ChatChannel Channel;
|
||||
|
||||
public bool IsHidden => Parent == null;
|
||||
|
||||
public ChannelFilterCheckbox(ChatChannel channel)
|
||||
{
|
||||
Channel = channel;
|
||||
Text = Loc.GetString($"hud-chatbox-channel-{Channel}");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<controls:ChannelFilterPopup
|
||||
xmlns="https://spacestation14.io"
|
||||
xmlns:controls="clr-namespace:Content.Client.UserInterface.Systems.Chat.Controls">
|
||||
<PanelContainer Name="FilterPopupPanel" StyleClasses="BorderedWindowPanel">
|
||||
<BoxContainer Orientation="Horizontal">
|
||||
<Control MinSize="4 0"/>
|
||||
<BoxContainer Name="FilterVBox" MinWidth="110" Margin="0 10" Orientation="Vertical" SeparationOverride="4"/>
|
||||
</BoxContainer>
|
||||
</PanelContainer>
|
||||
</controls:ChannelFilterPopup>
|
||||
@@ -0,0 +1,94 @@
|
||||
using Content.Shared.Chat;
|
||||
using Robust.Client.AutoGenerated;
|
||||
using Robust.Client.UserInterface.Controls;
|
||||
using Robust.Client.UserInterface.XAML;
|
||||
using static Robust.Client.UserInterface.Controls.BaseButton;
|
||||
|
||||
namespace Content.Client.UserInterface.Systems.Chat.Controls;
|
||||
|
||||
[GenerateTypedNameReferences]
|
||||
public sealed partial class ChannelFilterPopup : Popup
|
||||
{
|
||||
// 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.LOOC,
|
||||
ChatChannel.OOC,
|
||||
ChatChannel.Dead,
|
||||
ChatChannel.Admin,
|
||||
ChatChannel.Server
|
||||
};
|
||||
|
||||
private readonly Dictionary<ChatChannel, ChannelFilterCheckbox> _filterStates = new();
|
||||
|
||||
public event Action<ChatChannel, bool>? OnChannelFilter;
|
||||
|
||||
public ChannelFilterPopup()
|
||||
{
|
||||
RobustXamlLoader.Load(this);
|
||||
}
|
||||
|
||||
public bool IsActive(ChatChannel channel)
|
||||
{
|
||||
return _filterStates.TryGetValue(channel, out var checkbox) && checkbox.Pressed;
|
||||
}
|
||||
|
||||
public ChatChannel GetActive()
|
||||
{
|
||||
ChatChannel active = 0;
|
||||
|
||||
foreach (var (key, value) in _filterStates)
|
||||
{
|
||||
if (value.IsHidden || !value.Pressed)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
active |= key;
|
||||
}
|
||||
|
||||
return active;
|
||||
}
|
||||
|
||||
public void SetChannels(ChatChannel channels)
|
||||
{
|
||||
foreach (var channel in ChannelFilterOrder)
|
||||
{
|
||||
if (!_filterStates.TryGetValue(channel, out var checkbox))
|
||||
{
|
||||
checkbox = new ChannelFilterCheckbox(channel);
|
||||
_filterStates.Add(channel, checkbox);
|
||||
checkbox.OnPressed += CheckboxPressed;
|
||||
checkbox.Pressed = true;
|
||||
}
|
||||
|
||||
if ((channels & channel) == 0)
|
||||
{
|
||||
if (checkbox.Parent == FilterVBox)
|
||||
{
|
||||
FilterVBox.RemoveChild(checkbox);
|
||||
}
|
||||
}
|
||||
else if (checkbox.IsHidden)
|
||||
{
|
||||
FilterVBox.AddChild(checkbox);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void CheckboxPressed(ButtonEventArgs args)
|
||||
{
|
||||
var checkbox = (ChannelFilterCheckbox) args.Button;
|
||||
OnChannelFilter?.Invoke(checkbox.Channel, checkbox.Pressed);
|
||||
}
|
||||
|
||||
public void UpdateUnread(ChatChannel channel, int? unread)
|
||||
{
|
||||
if (_filterStates.TryGetValue(channel, out var checkbox))
|
||||
checkbox.UpdateUnreadCount(unread);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using Content.Shared.Chat;
|
||||
using Robust.Client.UserInterface;
|
||||
using Robust.Client.UserInterface.Controls;
|
||||
using Robust.Shared.Input;
|
||||
|
||||
namespace Content.Client.UserInterface.Systems.Chat.Controls;
|
||||
|
||||
/// <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
|
||||
{
|
||||
private readonly ChannelSelectorPopup _channelSelectorPopup;
|
||||
public event Action<ChatSelectChannel>? OnChannelSelect;
|
||||
|
||||
public ChatSelectChannel SelectedChannel { get; private set; }
|
||||
|
||||
private const int SelectorDropdownOffset = 38;
|
||||
|
||||
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
|
||||
Name = "ChannelSelector";
|
||||
Mode = ActionMode.Press;
|
||||
EnableAllKeybinds = true;
|
||||
ToggleMode = true;
|
||||
OnToggled += OnSelectorButtonToggled;
|
||||
_channelSelectorPopup = UserInterfaceManager.CreatePopup<ChannelSelectorPopup>();
|
||||
_channelSelectorPopup.Selected += OnChannelSelected;
|
||||
_channelSelectorPopup.OnVisibilityChanged += OnPopupVisibilityChanged;
|
||||
|
||||
if (_channelSelectorPopup.FirstChannel is { } firstSelector)
|
||||
{
|
||||
Select(firstSelector);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnChannelSelected(ChatSelectChannel channel)
|
||||
{
|
||||
Select(channel);
|
||||
}
|
||||
|
||||
private void OnPopupVisibilityChanged(Control control)
|
||||
{
|
||||
Pressed = control.Visible;
|
||||
}
|
||||
|
||||
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 void Select(ChatSelectChannel channel)
|
||||
{
|
||||
if (_channelSelectorPopup.Visible)
|
||||
{
|
||||
_channelSelectorPopup.Close();
|
||||
}
|
||||
|
||||
if (SelectedChannel == channel) return;
|
||||
SelectedChannel = channel;
|
||||
UpdateChannelSelectButton(channel);
|
||||
|
||||
OnChannelSelect?.Invoke(channel);
|
||||
}
|
||||
|
||||
public string ChannelSelectorName(ChatSelectChannel channel)
|
||||
{
|
||||
return Loc.GetString($"hud-chatbox-select-channel-{channel}");
|
||||
}
|
||||
|
||||
public 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 UpdateChannelSelectButton(ChatSelectChannel channel)
|
||||
{
|
||||
Text = ChannelSelectorName(channel);
|
||||
Modulate = ChannelSelectColor(channel);
|
||||
}
|
||||
|
||||
private void OnSelectorButtonToggled(ButtonToggledEventArgs args)
|
||||
{
|
||||
if (args.Pressed)
|
||||
{
|
||||
var globalLeft = GlobalPosition.X;
|
||||
var globalBot = GlobalPosition.Y + Height;
|
||||
var box = UIBox2.FromDimensions((globalLeft, globalBot), (SizeBox.Width, SelectorDropdownOffset));
|
||||
_channelSelectorPopup.Open(box);
|
||||
}
|
||||
else
|
||||
{
|
||||
_channelSelectorPopup.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
using Content.Client.Stylesheets;
|
||||
using Content.Shared.Chat;
|
||||
using Robust.Client.UserInterface.Controls;
|
||||
|
||||
namespace Content.Client.UserInterface.Systems.Chat.Controls;
|
||||
|
||||
public sealed class ChannelSelectorItemButton : Button
|
||||
{
|
||||
public readonly ChatSelectChannel Channel;
|
||||
|
||||
public bool IsHidden => Parent == null;
|
||||
|
||||
public ChannelSelectorItemButton(ChatSelectChannel selector)
|
||||
{
|
||||
Channel = selector;
|
||||
AddStyleClass(StyleNano.StyleClassChatChannelSelectorButton);
|
||||
Text = ChatUIController.GetChannelSelectorName(selector);
|
||||
var prefix = ChatUIController.GetChannelSelectorPrefix(selector);
|
||||
if (prefix != default) Text = Loc.GetString("hud-chatbox-select-name-prefixed", ("name", Text), ("prefix", prefix));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
using Content.Shared.Chat;
|
||||
using Robust.Client.UserInterface.Controls;
|
||||
using static Robust.Client.UserInterface.Controls.BaseButton;
|
||||
|
||||
namespace Content.Client.UserInterface.Systems.Chat.Controls;
|
||||
|
||||
public sealed class ChannelSelectorPopup : Popup
|
||||
{
|
||||
// order in which the channels show up in the channel selector
|
||||
public 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 /.
|
||||
};
|
||||
|
||||
private readonly BoxContainer _channelSelectorHBox;
|
||||
private readonly Dictionary<ChatSelectChannel, ChannelSelectorItemButton> _selectorStates = new();
|
||||
private readonly ChatUIController _chatUIController;
|
||||
|
||||
public event Action<ChatSelectChannel>? Selected;
|
||||
|
||||
public ChannelSelectorPopup()
|
||||
{
|
||||
_channelSelectorHBox = new BoxContainer
|
||||
{
|
||||
Orientation = BoxContainer.LayoutOrientation.Horizontal,
|
||||
SeparationOverride = 1
|
||||
};
|
||||
|
||||
_chatUIController = UserInterfaceManager.GetUIController<ChatUIController>();
|
||||
_chatUIController.SelectableChannelsChanged += SetChannels;
|
||||
SetChannels(_chatUIController.SelectableChannels);
|
||||
|
||||
AddChild(_channelSelectorHBox);
|
||||
}
|
||||
|
||||
public ChatSelectChannel? FirstChannel
|
||||
{
|
||||
get
|
||||
{
|
||||
foreach (var selector in _selectorStates.Values)
|
||||
{
|
||||
if (!selector.IsHidden)
|
||||
return selector.Channel;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/*public ChatSelectChannel NextChannel()
|
||||
{
|
||||
var nextChannel = ChatUIController.GetNextChannelSelector(_activeSelector);
|
||||
var index = 0;
|
||||
while (_selectorStates[(int)nextChannel].IsHidden && index <= _selectorStates.Count)
|
||||
{
|
||||
nextChannel = ChatUIController.GetNextChannelSelector(nextChannel);
|
||||
index++;
|
||||
}
|
||||
_activeSelector = nextChannel;
|
||||
return nextChannel;
|
||||
}
|
||||
|
||||
|
||||
private void SetupChannels(ChatUIController.ChannelSelectorSetup[] selectorData)
|
||||
{
|
||||
_channelSelectorHBox.DisposeAllChildren(); //cleanup old toggles
|
||||
_selectorStates.Clear();
|
||||
foreach (var channelSelectorData in selectorData)
|
||||
{
|
||||
var newSelectorButton = new ChannelSelectorItemButton(channelSelectorData);
|
||||
_selectorStates.Add(newSelectorButton);
|
||||
if (!newSelectorButton.IsHidden)
|
||||
{
|
||||
_channelSelectorHBox.AddChild(newSelectorButton);
|
||||
}
|
||||
newSelectorButton.OnPressed += OnSelectorPressed;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSelectorPressed(BaseButton.ButtonEventArgs args)
|
||||
{
|
||||
if (_selectorButton == null) return;
|
||||
_selectorButton.SelectedChannel = ((ChannelSelectorItemButton) args.Button).Channel;
|
||||
}
|
||||
|
||||
public void HideChannels(params ChatChannel[] channels)
|
||||
{
|
||||
foreach (var channel in channels)
|
||||
{
|
||||
if (!ChatUIController.ChannelToSelector.TryGetValue(channel, out var selector)) continue;
|
||||
var selectorbutton = _selectorStates[(int)selector];
|
||||
if (!selectorbutton.IsHidden)
|
||||
{
|
||||
_channelSelectorHBox.RemoveChild(selectorbutton);
|
||||
if (_activeSelector != selector) continue; // do nothing
|
||||
if (_channelSelectorHBox.Children.First() is ChannelSelectorItemButton button)
|
||||
{
|
||||
_activeSelector = button.Channel;
|
||||
}
|
||||
else
|
||||
{
|
||||
_activeSelector = ChatSelectChannel.None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
private bool IsPreferredAvailable()
|
||||
{
|
||||
var preferred = _chatUIController.MapLocalIfGhost(_chatUIController.GetPreferredChannel());
|
||||
return _selectorStates.TryGetValue(preferred, out var selector) &&
|
||||
!selector.IsHidden;
|
||||
}
|
||||
|
||||
public void SetChannels(ChatSelectChannel channels)
|
||||
{
|
||||
var wasPreferredAvailable = IsPreferredAvailable();
|
||||
|
||||
_channelSelectorHBox.RemoveAllChildren();
|
||||
|
||||
foreach (var channel in ChannelSelectorOrder)
|
||||
{
|
||||
if (!_selectorStates.TryGetValue(channel, out var selector))
|
||||
{
|
||||
selector = new ChannelSelectorItemButton(channel);
|
||||
_selectorStates.Add(channel, selector);
|
||||
selector.OnPressed += OnSelectorPressed;
|
||||
}
|
||||
|
||||
if ((channels & channel) == 0)
|
||||
{
|
||||
if (selector.Parent == _channelSelectorHBox)
|
||||
{
|
||||
_channelSelectorHBox.RemoveChild(selector);
|
||||
}
|
||||
}
|
||||
else if (selector.IsHidden)
|
||||
{
|
||||
_channelSelectorHBox.AddChild(selector);
|
||||
}
|
||||
}
|
||||
|
||||
var isPreferredAvailable = IsPreferredAvailable();
|
||||
if (!wasPreferredAvailable && isPreferredAvailable)
|
||||
{
|
||||
Select(_chatUIController.GetPreferredChannel());
|
||||
}
|
||||
else if (wasPreferredAvailable && !isPreferredAvailable)
|
||||
{
|
||||
Select(ChatSelectChannel.OOC);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSelectorPressed(ButtonEventArgs args)
|
||||
{
|
||||
var button = (ChannelSelectorItemButton) args.Button;
|
||||
Select(button.Channel);
|
||||
}
|
||||
|
||||
private void Select(ChatSelectChannel channel)
|
||||
{
|
||||
Selected?.Invoke(channel);
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
|
||||
if (!disposing)
|
||||
return;
|
||||
|
||||
_chatUIController.SelectableChannelsChanged -= SetChannels;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Content.Shared.Chat;
|
||||
using Robust.Client.UserInterface.Controls;
|
||||
|
||||
namespace Content.Client.UserInterface.Systems.Chat.Controls;
|
||||
|
||||
[Virtual]
|
||||
public class ChatInputBox : PanelContainer
|
||||
{
|
||||
public readonly ChannelSelectorButton ChannelSelector;
|
||||
public readonly HistoryLineEdit Input;
|
||||
public readonly ChannelFilterButton FilterButton;
|
||||
protected readonly BoxContainer Container;
|
||||
protected ChatChannel ActiveChannel { get; private set; } = ChatChannel.Local;
|
||||
|
||||
public ChatInputBox()
|
||||
{
|
||||
Container = new BoxContainer
|
||||
{
|
||||
Orientation = BoxContainer.LayoutOrientation.Horizontal,
|
||||
SeparationOverride = 4
|
||||
};
|
||||
AddChild(Container);
|
||||
|
||||
ChannelSelector = new ChannelSelectorButton
|
||||
{
|
||||
Name = "ChannelSelector",
|
||||
ToggleMode = true,
|
||||
StyleClasses = {"chatSelectorOptionButton"},
|
||||
MinWidth = 75
|
||||
};
|
||||
Container.AddChild(ChannelSelector);
|
||||
Input = new HistoryLineEdit
|
||||
{
|
||||
Name = "Input",
|
||||
PlaceHolder = Loc.GetString("hud-chatbox-info"),
|
||||
HorizontalExpand = true,
|
||||
StyleClasses = {"chatLineEdit"}
|
||||
};
|
||||
Container.AddChild(Input);
|
||||
FilterButton = new ChannelFilterButton
|
||||
{
|
||||
Name = "FilterButton",
|
||||
StyleClasses = {"chatFilterOptionButton"}
|
||||
};
|
||||
Container.AddChild(FilterButton);
|
||||
ChannelSelector.OnChannelSelect += UpdateActiveChannel;
|
||||
}
|
||||
|
||||
private void UpdateActiveChannel(ChatSelectChannel selectedChannel)
|
||||
{
|
||||
ActiveChannel = (ChatChannel) selectedChannel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<widgets:ChatBox
|
||||
xmlns="https://spacestation14.io"
|
||||
xmlns:graphics="clr-namespace:Robust.Client.Graphics;assembly=Robust.Client"
|
||||
xmlns:widgets="clr-namespace:Content.Client.UserInterface.Systems.Chat.Widgets"
|
||||
xmlns:controls="clr-namespace:Content.Client.UserInterface.Systems.Chat.Controls"
|
||||
MouseFilter="Stop"
|
||||
MinSize="465 225">
|
||||
<PanelContainer>
|
||||
<PanelContainer.PanelOverride>
|
||||
<graphics:StyleBoxFlat BackgroundColor="#25252AAA" />
|
||||
</PanelContainer.PanelOverride>
|
||||
|
||||
<BoxContainer Orientation="Vertical" SeparationOverride="4">
|
||||
<OutputPanel Name="Contents" VerticalExpand="True" />
|
||||
<controls:ChatInputBox Name="ChatInput" Access="Public" Margin="2"/>
|
||||
</BoxContainer>
|
||||
</PanelContainer>
|
||||
</widgets:ChatBox>
|
||||
@@ -0,0 +1,214 @@
|
||||
using Content.Client.Chat;
|
||||
using Content.Client.Chat.TypingIndicator;
|
||||
using Content.Client.UserInterface.Systems.Chat.Controls;
|
||||
using Content.Shared.Chat;
|
||||
using Content.Shared.Input;
|
||||
using Robust.Client.AutoGenerated;
|
||||
using Robust.Client.UserInterface;
|
||||
using Robust.Client.UserInterface.XAML;
|
||||
using Robust.Shared.Input;
|
||||
using Robust.Shared.Utility;
|
||||
using static Robust.Client.UserInterface.Controls.LineEdit;
|
||||
|
||||
namespace Content.Client.UserInterface.Systems.Chat.Widgets;
|
||||
|
||||
[GenerateTypedNameReferences]
|
||||
#pragma warning disable RA0003
|
||||
public partial class ChatBox : Control
|
||||
#pragma warning restore RA0003
|
||||
{
|
||||
private readonly ChatUIController _controller;
|
||||
|
||||
public bool Main { get; set; }
|
||||
|
||||
public ChatSelectChannel SelectedChannel => ChatInput.ChannelSelector.SelectedChannel;
|
||||
|
||||
public ChatBox()
|
||||
{
|
||||
RobustXamlLoader.Load(this);
|
||||
|
||||
ChatInput.Input.OnTextEntered += OnTextEntered;
|
||||
ChatInput.Input.OnKeyBindDown += OnKeyBindDown;
|
||||
ChatInput.Input.OnTextChanged += OnTextChanged;
|
||||
ChatInput.ChannelSelector.OnChannelSelect += OnChannelSelect;
|
||||
ChatInput.FilterButton.ChatFilterPopup.OnChannelFilter += OnChannelFilter;
|
||||
|
||||
_controller = UserInterfaceManager.GetUIController<ChatUIController>();
|
||||
_controller.MessageAdded += OnMessageAdded;
|
||||
_controller.RegisterChat(this);
|
||||
}
|
||||
|
||||
private void OnTextEntered(LineEditEventArgs args)
|
||||
{
|
||||
_controller.SendMessage(this, SelectedChannel);
|
||||
}
|
||||
|
||||
private void OnMessageAdded(StoredChatMessage msg)
|
||||
{
|
||||
var text = FormattedMessage.EscapeText(msg.Message);
|
||||
if (!string.IsNullOrEmpty(msg.MessageWrap))
|
||||
{
|
||||
text = string.Format(msg.MessageWrap, text);
|
||||
}
|
||||
|
||||
Logger.DebugS("chat", $"{msg.Channel}: {text}");
|
||||
if (!ChatInput.FilterButton.ChatFilterPopup.IsActive(msg.Channel))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
msg.Read = true;
|
||||
|
||||
var color = msg.MessageColorOverride != Color.Transparent
|
||||
? msg.MessageColorOverride
|
||||
: msg.Channel.TextColor();
|
||||
|
||||
AddLine(text, color);
|
||||
}
|
||||
|
||||
private void OnChannelSelect(ChatSelectChannel channel)
|
||||
{
|
||||
UpdateSelectedChannel();
|
||||
}
|
||||
|
||||
private void OnChannelFilter(ChatChannel channel, bool active)
|
||||
{
|
||||
Contents.Clear();
|
||||
|
||||
foreach (var message in _controller.History)
|
||||
{
|
||||
OnMessageAdded(message);
|
||||
}
|
||||
|
||||
if (active)
|
||||
{
|
||||
_controller.ClearUnfilteredUnreads(channel);
|
||||
}
|
||||
}
|
||||
|
||||
public void AddLine(string message, Color color)
|
||||
{
|
||||
var formatted = new FormattedMessage(3);
|
||||
formatted.PushColor(color);
|
||||
formatted.AddMarkup(message);
|
||||
formatted.Pop();
|
||||
Contents.AddMessage(formatted);
|
||||
}
|
||||
|
||||
public void UpdateSelectedChannel()
|
||||
{
|
||||
var (prefixChannel, _) = _controller.SplitInputContents(ChatInput.Input.Text);
|
||||
var channel = prefixChannel == 0 ? SelectedChannel : prefixChannel;
|
||||
|
||||
ChatInput.ChannelSelector.UpdateChannelSelectButton(channel);
|
||||
}
|
||||
|
||||
public void Focus(ChatSelectChannel? channel = null)
|
||||
{
|
||||
var input = ChatInput.Input;
|
||||
var selectStart = Index.End;
|
||||
|
||||
if (channel != null)
|
||||
{
|
||||
channel = _controller.MapLocalIfGhost(channel.Value);
|
||||
|
||||
// Channel not selectable, just do NOTHING (not even focus).
|
||||
if ((_controller.SelectableChannels & channel.Value) == 0)
|
||||
return;
|
||||
|
||||
var (_, text) = _controller.SplitInputContents(input.Text);
|
||||
|
||||
var newPrefix = _controller.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);
|
||||
}
|
||||
|
||||
ChatInput.ChannelSelector.Select(channel.Value);
|
||||
}
|
||||
|
||||
input.IgnoreNext = true;
|
||||
input.GrabKeyboardFocus();
|
||||
|
||||
input.CursorPosition = input.Text.Length;
|
||||
input.SelectionStart = selectStart.GetOffset(input.Text.Length);
|
||||
}
|
||||
|
||||
public void CycleChatChannel(bool forward)
|
||||
{
|
||||
var idx = Array.IndexOf(ChannelSelectorPopup.ChannelSelectorOrder, SelectedChannel);
|
||||
do
|
||||
{
|
||||
// go over every channel until we find one we can actually select.
|
||||
idx += forward ? 1 : -1;
|
||||
idx = MathHelper.Mod(idx, ChannelSelectorPopup.ChannelSelectorOrder.Length);
|
||||
} while ((_controller.SelectableChannels & ChannelSelectorPopup.ChannelSelectorOrder[idx]) == 0);
|
||||
|
||||
SafelySelectChannel(ChannelSelectorPopup.ChannelSelectorOrder[idx]);
|
||||
}
|
||||
|
||||
public void SafelySelectChannel(ChatSelectChannel toSelect)
|
||||
{
|
||||
toSelect = _controller.MapLocalIfGhost(toSelect);
|
||||
if ((_controller.SelectableChannels & toSelect) == 0)
|
||||
return;
|
||||
|
||||
ChatInput.ChannelSelector.Select(toSelect);
|
||||
}
|
||||
|
||||
private void OnKeyBindDown(GUIBoundKeyEventArgs args)
|
||||
{
|
||||
if (args.Function == EngineKeyFunctions.TextReleaseFocus)
|
||||
{
|
||||
ChatInput.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 void OnTextChanged(LineEditEventArgs args)
|
||||
{
|
||||
// Update channel select button to correct channel if we have a prefix.
|
||||
UpdateSelectedChannel();
|
||||
|
||||
// Warn typing indicator about change
|
||||
EntitySystem.Get<TypingIndicatorSystem>().ClientChangedChatText();
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
base.Dispose(disposing);
|
||||
|
||||
if (!disposing) return;
|
||||
_controller.UnregisterChat(this);
|
||||
ChatInput.Input.OnTextEntered -= OnTextEntered;
|
||||
ChatInput.Input.OnKeyBindDown -= OnKeyBindDown;
|
||||
ChatInput.Input.OnTextChanged -= OnTextChanged;
|
||||
ChatInput.ChannelSelector.OnChannelSelect -= OnChannelSelect;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
using Robust.Client.Graphics;
|
||||
using Robust.Client.UserInterface;
|
||||
using Robust.Client.UserInterface.Controls;
|
||||
using Robust.Shared.Input;
|
||||
using Robust.Shared.Timing;
|
||||
|
||||
namespace Content.Client.UserInterface.Systems.Chat.Widgets;
|
||||
|
||||
public sealed class ResizableChatBox : ChatBox
|
||||
{
|
||||
public ResizableChatBox()
|
||||
{
|
||||
IoCManager.InjectDependencies(this);
|
||||
}
|
||||
// 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!;
|
||||
|
||||
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)
|
||||
{
|
||||
|
||||
if (args.Function != EngineKeyFunctions.UIClick)
|
||||
return;
|
||||
if (_currentDrag != DragMode.None)
|
||||
{
|
||||
_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();
|
||||
}
|
||||
|
||||
base.KeyBindUp(args);
|
||||
}
|
||||
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
protected override void MouseExited()
|
||||
{
|
||||
base.MouseExited();
|
||||
|
||||
if (_currentDrag == DragMode.None)
|
||||
DefaultCursorShape = CursorShape.Arrow;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user