diff --git a/Content.Client/UserInterface/Systems/Chat/ChatUIController.cs b/Content.Client/UserInterface/Systems/Chat/ChatUIController.cs index e13fd8f3bec..af37d1e4df7 100644 --- a/Content.Client/UserInterface/Systems/Chat/ChatUIController.cs +++ b/Content.Client/UserInterface/Systems/Chat/ChatUIController.cs @@ -172,6 +172,8 @@ private readonly Dictionary _queuedSpeechBubbl public ChatSelectChannel SelectableChannels { get; private set; } private ChatSelectChannel PreferredChannel { get; set; } = ChatSelectChannel.OOC; + public ChatMessage? LastMessage = null; + public event Action? CanSendChannelsChanged; public event Action? FilterableChannelsChanged; public event Action? SelectableChannelsChanged; @@ -880,6 +882,21 @@ public void ProcessChatMessage(ChatMessage msg, bool speechBubble = true) } } + if (LastMessage != null && msg.Message == LastMessage.Message) + { + LastMessage.repeat++; + LastMessage.WrappedMessage = msg.WrappedMessage + $" x{LastMessage.repeat}"; + + foreach (var chat in _chats) + { + // chat._controller.History[index].Item2 + chat.UpdateMessage(chat.GetHistoryLength() - 1, LastMessage); + } + return; + } + + LastMessage = msg; + // Log all incoming chat to repopulate when filter is un-toggled if (!msg.HideChat) { diff --git a/Content.Client/UserInterface/Systems/Chat/Widgets/ChatBox.xaml b/Content.Client/UserInterface/Systems/Chat/Widgets/ChatBox.xaml index 47286fbc26e..9b3a9840ddf 100644 --- a/Content.Client/UserInterface/Systems/Chat/Widgets/ChatBox.xaml +++ b/Content.Client/UserInterface/Systems/Chat/Widgets/ChatBox.xaml @@ -2,6 +2,7 @@ xmlns="https://spacestation14.io" xmlns:widgets="clr-namespace:Content.Client.UserInterface.Systems.Chat.Widgets" xmlns:controls="clr-namespace:Content.Client.UserInterface.Systems.Chat.Controls" + xmlns:controls1="clr-namespace:Content.Client._Sunrise.UI.Controls" MouseFilter="Stop" HorizontalExpand="True" VerticalExpand="True" @@ -9,7 +10,7 @@ - + diff --git a/Content.Client/UserInterface/Systems/Chat/Widgets/ChatBox.xaml.cs b/Content.Client/UserInterface/Systems/Chat/Widgets/ChatBox.xaml.cs index d55b3f01dfa..54b372b546a 100644 --- a/Content.Client/UserInterface/Systems/Chat/Widgets/ChatBox.xaml.cs +++ b/Content.Client/UserInterface/Systems/Chat/Widgets/ChatBox.xaml.cs @@ -104,6 +104,29 @@ private void OnChannelFilter(ChatChannel channel, bool active) } } + public int GetHistoryLength() + { + return _controller.History.Count; + } + + public void UpdateMessage(int index, ChatMessage message) + { + var updatedTuple = (_controller.History[index].Item1, message); + _controller.History.Pop(); + _controller.History.Add(updatedTuple); + + var color = message.MessageColorOverride != null + ? message.MessageColorOverride.Value + : message.Channel.TextColor(); + + var formatted = new FormattedMessage(3); + + formatted.AddMarkup(message.WrappedMessage); + formatted.PushColor(color); + + Contents.UpdateLastMessage(formatted); + } + public void AddLine(string message, Color color) { var formatted = new FormattedMessage(3); diff --git a/Content.Client/_Sunrise/UI/Controls/SunriseOutputPanel.cs b/Content.Client/_Sunrise/UI/Controls/SunriseOutputPanel.cs new file mode 100644 index 00000000000..01ab0107360 --- /dev/null +++ b/Content.Client/_Sunrise/UI/Controls/SunriseOutputPanel.cs @@ -0,0 +1,289 @@ +using System.Numerics; +using Robust.Client.Graphics; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.Controls; +using Robust.Client.UserInterface.RichText; +using Robust.Shared.Utility; + +namespace Content.Client._Sunrise.UI.Controls; + +[Virtual] +public sealed class SunriseOutputPanel : Control +{ + [Dependency] private readonly MarkupTagManager _tagManager = default!; + + public const string StylePropertyStyleBox = "stylebox"; + + private readonly SunriseRingBufferList _entries = new(); + private bool _isAtBottom = true; + + private int _totalContentHeight; + private bool _firstLine = true; + private StyleBox? _styleBoxOverride; + private VScrollBar _scrollBar; + + public bool ScrollFollowing { get; set; } = true; + + private bool _invalidOnVisible; + + public SunriseOutputPanel() + { + IoCManager.InjectDependencies(this); + MouseFilter = Control.MouseFilterMode.Pass; + RectClipContent = true; + + _scrollBar = new VScrollBar + { + Name = "_v_scroll", + HorizontalAlignment = Control.HAlignment.Right + }; + AddChild(_scrollBar); + _scrollBar.OnValueChanged += _ => _isAtBottom = _scrollBar.IsAtEnd; + } + + public int EntryCount => _entries.Count; + + public void UpdateLastMessage(FormattedMessage message) + { + var newEnt = new SunriseRichTextEntry(message, this, _tagManager, null); + newEnt.Update(_tagManager, _getFont(), _getContentBox().Width, UIScale); + _entries[_entries.Count - 1] = newEnt; + } + + public StyleBox? StyleBoxOverride + { + get => _styleBoxOverride; + set + { + _styleBoxOverride = value; + InvalidateMeasure(); + _invalidateEntries(); + } + } + + public void Clear() + { + _firstLine = true; + _entries.Clear(); + _totalContentHeight = 0; + _scrollBar.MaxValue = Math.Max(_scrollBar.Page, _totalContentHeight); + _scrollBar.Value = 0; + } + + public void RemoveEntry(Index index) + { + var entry = _entries[index]; + _entries.RemoveAt(index.GetOffset(_entries.Count)); + + var font = _getFont(); + _totalContentHeight -= entry.Height + font.GetLineSeparation(UIScale); + if (_entries.Count == 0) + { + Clear(); + } + + _scrollBar.MaxValue = Math.Max(_scrollBar.Page, _totalContentHeight); + } + + public void AddText(string text) + { + var msg = new FormattedMessage(); + msg.AddText(text); + AddMessage(msg); + } + + public void AddMessage(FormattedMessage message) + { + var entry = new SunriseRichTextEntry(message, this, _tagManager, null); + + entry.Update(_tagManager, _getFont(), _getContentBox().Width, UIScale); + + _entries.Add(entry); + var font = _getFont(); + _totalContentHeight += entry.Height; + if (_firstLine) + { + _firstLine = false; + } + else + { + _totalContentHeight += font.GetLineSeparation(UIScale); + } + + _scrollBar.MaxValue = Math.Max(_scrollBar.Page, _totalContentHeight); + if (_isAtBottom && ScrollFollowing) + { + _scrollBar.MoveToEnd(); + } + } + + public void ScrollToBottom() + { + _scrollBar.MoveToEnd(); + _isAtBottom = true; + } + + protected override void Draw(DrawingHandleScreen handle) + { + base.Draw(handle); + + var style = _getStyleBox(); + var font = _getFont(); + var lineSeparation = font.GetLineSeparation(UIScale); + style?.Draw(handle, PixelSizeBox, UIScale); + var contentBox = _getContentBox(); + + var entryOffset = -_scrollBar.Value; + + // A stack for format tags. + // This stack contains the format tag to RETURN TO when popped off. + // So when a new color tag gets hit this stack gets the previous color pushed on. + var context = new MarkupDrawingContext(2); + + foreach (ref var entry in _entries) + { + if (entryOffset + entry.Height < 0) + { + // Controls within the entry are the children of this control, which means they are drawn separately + // after this Draw call, so we have to mark them as invisible to prevent them from being drawn. + // + // An alternative option is to ensure that the control position updating logic in entry.Draw is always + // run, and then setting RectClipContent = true to use scissor box testing to handle the controls + // visibility + entry.HideControls(); + entryOffset += entry.Height + lineSeparation; + continue; + } + + if (entryOffset > contentBox.Height) + { + entry.HideControls(); + continue; + } + + entry.Draw(_tagManager, handle, font, contentBox, entryOffset, context, UIScale); + + entryOffset += entry.Height + lineSeparation; + } + } + + protected override void MouseWheel(GUIMouseWheelEventArgs args) + { + base.MouseWheel(args); + + if (MathHelper.CloseToPercent(0, args.Delta.Y)) + { + return; + } + + _scrollBar.ValueTarget -= _getScrollSpeed() * args.Delta.Y; + } + + protected override void Resized() + { + base.Resized(); + + var styleBoxSize = _getStyleBox()?.MinimumSize.Y ?? 0; + + _scrollBar.Page = UIScale * (Height - styleBoxSize); + _invalidateEntries(); + } + + protected override Vector2 MeasureOverride(Vector2 availableSize) + { + return _getStyleBox()?.MinimumSize ?? Vector2.Zero; + } + + private void _invalidateEntries() + { + _totalContentHeight = 0; + var font = _getFont(); + var sizeX = _getContentBox().Width; + foreach (ref var entry in _entries) + { + entry.Update(_tagManager, font, sizeX, UIScale); + _totalContentHeight += entry.Height + font.GetLineSeparation(UIScale); + } + + _scrollBar.MaxValue = Math.Max(_scrollBar.Page, _totalContentHeight); + if (_isAtBottom && ScrollFollowing) + { + _scrollBar.MoveToEnd(); + } + } + + [System.Diagnostics.Contracts.Pure] + private Font _getFont() + { + if (TryGetStyleProperty("font", out var font)) + { + return font; + } + + return UserInterfaceManager.ThemeDefaults.DefaultFont; + } + + [System.Diagnostics.Contracts.Pure] + private StyleBox? _getStyleBox() + { + if (StyleBoxOverride != null) + { + return StyleBoxOverride; + } + + TryGetStyleProperty(StylePropertyStyleBox, out var box); + return box; + } + + [System.Diagnostics.Contracts.Pure] + private float _getScrollSpeed() + { + // The scroll speed depends on the UI scale because the scroll bar is working with physical pixels. + return GetScrollSpeed(_getFont(), UIScale); + } + + [System.Diagnostics.Contracts.Pure] + private UIBox2 _getContentBox() + { + var style = _getStyleBox(); + var box = style?.GetContentBox(PixelSizeBox, UIScale) ?? PixelSizeBox; + box.Right = Math.Max(box.Left, box.Right - _scrollBar.DesiredPixelSize.X); + return box; + } + + protected override void UIScaleChanged() + { + // If this control isn't visible, don't invalidate entries immediately. + // This saves invalidating the debug console if it's hidden, + // which is a huge boon as auto-scaling changes UI scale a lot in that scenario. + if (!VisibleInTree) + _invalidOnVisible = true; + else + _invalidateEntries(); + + base.UIScaleChanged(); + } + + internal static float GetScrollSpeed(Font font, float scale) + { + return font.GetLineHeight(scale) * 2; + } + + protected override void EnteredTree() + { + base.EnteredTree(); + // Due to any number of reasons the entries may be invalidated if added when not visible in the tree. + // e.g. the control has not had its UI scale set and the messages were added, but the + // existing ones were valid when the UI scale was set. + _invalidateEntries(); + } + + protected override void VisibilityChanged(bool newVisible) + { + if (newVisible && _invalidOnVisible) + { + _invalidateEntries(); + _invalidOnVisible = false; + } + } +} diff --git a/Content.Client/_Sunrise/UI/Controls/SunriseRichTextEntry.cs b/Content.Client/_Sunrise/UI/Controls/SunriseRichTextEntry.cs new file mode 100644 index 00000000000..e84cfce9ed1 --- /dev/null +++ b/Content.Client/_Sunrise/UI/Controls/SunriseRichTextEntry.cs @@ -0,0 +1,260 @@ +using System.Numerics; +using System.Text; +using Robust.Client.Graphics; +using Robust.Client.UserInterface; +using Robust.Client.UserInterface.Controls; +using Robust.Client.UserInterface.RichText; +using Robust.Shared.Collections; +using Robust.Shared.Utility; + +namespace Content.Client._Sunrise.UI.Controls; + +internal struct SunriseRichTextEntry +{ + private readonly Color _defaultColor; + private readonly Type[]? _tagsAllowed; + + public readonly FormattedMessage Message; + + /// + /// The vertical size of this entry, in pixels. + /// + public int Height; + + /// + /// The horizontal size of this entry, in pixels. + /// + public int Width; + + /// + /// The combined text indices in the message's text tags to put line breaks. + /// + public ValueList LineBreaks; + + private readonly Dictionary? _tagControls; + + public SunriseRichTextEntry(FormattedMessage message, Control parent, MarkupTagManager tagManager, Type[]? tagsAllowed = null, Color? defaultColor = null) + { + Message = message; + Height = 0; + Width = 0; + LineBreaks = default; + _defaultColor = defaultColor ?? new(200, 200, 200); + _tagsAllowed = tagsAllowed; + Dictionary? tagControls = null; + + var nodeIndex = -1; + foreach (var node in Message) + { + nodeIndex++; + + if (node.Name == null) + continue; + + if (!tagManager.TryGetMarkupTag(node.Name, _tagsAllowed, out var tag) || !tag.TryGetControl(node, out var control)) + continue; + + parent.Children.Add(control); + tagControls ??= new Dictionary(); + tagControls.Add(nodeIndex, control); + } + + _tagControls = tagControls; + } + + /// + /// Recalculate line dimensions and where it has line breaks for word wrapping. + /// + /// The font being used for display. + /// The maximum horizontal size of the container of this entry. + /// + /// + public void Update(MarkupTagManager tagManager, Font defaultFont, float maxSizeX, float uiScale, float lineHeightScale = 1) + { + // This method is gonna suck due to complexity. + // Bear with me here. + // I am so deeply sorry for the person adding stuff to this in the future. + + Height = defaultFont.GetHeight(uiScale); + LineBreaks.Clear(); + + int? breakLine; + var wordWrap = new SunriseWordWrap(maxSizeX); + var context = new MarkupDrawingContext(); + context.Font.Push(defaultFont); + context.Color.Push(_defaultColor); + + // Go over every node. + // Nodes can change the markup drawing context and return additional text. + // It's also possible for nodes to return inline controls. They get treated as one large rune. + var nodeIndex = -1; + foreach (var node in Message) + { + nodeIndex++; + var text = ProcessNode(tagManager, node, context); + + if (!context.Font.TryPeek(out var font)) + font = defaultFont; + + // And go over every character. + foreach (var rune in text.EnumerateRunes()) + { + if (ProcessRune(ref this, rune, out breakLine)) + continue; + + // Uh just skip unknown characters I guess. + if (!font.TryGetCharMetrics(rune, uiScale, out var metrics)) + continue; + + if (ProcessMetric(ref this, metrics, out breakLine)) + return; + } + + if (_tagControls == null || !_tagControls.TryGetValue(nodeIndex, out var control)) + continue; + + control.Measure(new Vector2(Width, Height)); + + var desiredSize = control.DesiredPixelSize; + var controlMetrics = new CharMetrics( + 0, 0, + desiredSize.X, + desiredSize.X, + desiredSize.Y); + + if (ProcessMetric(ref this, controlMetrics, out breakLine)) + return; + } + + Width = wordWrap.FinalizeText(out breakLine); + CheckLineBreak(ref this, breakLine); + + bool ProcessRune(ref SunriseRichTextEntry src, Rune rune, out int? outBreakLine) + { + wordWrap.NextRune(rune, out breakLine, out var breakNewLine, out var skip); + CheckLineBreak(ref src, breakLine); + CheckLineBreak(ref src, breakNewLine); + outBreakLine = breakLine; + return skip; + } + + bool ProcessMetric(ref SunriseRichTextEntry src, CharMetrics metrics, out int? outBreakLine) + { + wordWrap.NextMetrics(metrics, out breakLine, out var abort); + CheckLineBreak(ref src, breakLine); + outBreakLine = breakLine; + return abort; + } + + void CheckLineBreak(ref SunriseRichTextEntry src, int? line) + { + if (line is { } l) + { + src.LineBreaks.Add(l); + if (!context.Font.TryPeek(out var font)) + font = defaultFont; + + src.Height += GetLineHeight(font, uiScale, lineHeightScale); + } + } + } + + internal readonly void HideControls() + { + if (_tagControls == null) + return; + foreach (var control in _tagControls.Values) + { + control.Visible = false; + } + } + + public readonly void Draw( + MarkupTagManager tagManager, + DrawingHandleBase handle, + Font defaultFont, + UIBox2 drawBox, + float verticalOffset, + MarkupDrawingContext context, + float uiScale, + float lineHeightScale = 1) + { + context.Clear(); + context.Color.Push(_defaultColor); + context.Font.Push(defaultFont); + + var globalBreakCounter = 0; + var lineBreakIndex = 0; + var baseLine = drawBox.TopLeft + new Vector2(0, defaultFont.GetAscent(uiScale) + verticalOffset); + var controlYAdvance = 0f; + + var nodeIndex = -1; + foreach (var node in Message) + { + nodeIndex++; + var text = ProcessNode(tagManager, node, context); + if (!context.Color.TryPeek(out var color) || !context.Font.TryPeek(out var font)) + { + color = _defaultColor; + font = defaultFont; + } + + foreach (var rune in text.EnumerateRunes()) + { + if (lineBreakIndex < LineBreaks.Count && + LineBreaks[lineBreakIndex] == globalBreakCounter) + { + baseLine = new Vector2(drawBox.Left, baseLine.Y + GetLineHeight(font, uiScale, lineHeightScale) + controlYAdvance); + controlYAdvance = 0; + lineBreakIndex += 1; + } + + var advance = font.DrawChar(handle, rune, baseLine, uiScale, color); + baseLine += new Vector2(advance, 0); + + globalBreakCounter += 1; + } + + if (_tagControls == null || !_tagControls.TryGetValue(nodeIndex, out var control)) + continue; + + // Controls may have been previously hidden via HideControls due to being "out-of frame". + // If this ever gets replaced with RectClipContents / scissor box testing, this can be removed. + control.Visible = true; + + var invertedScale = 1f / uiScale; + var pos = new Vector2(baseLine.X * invertedScale, (baseLine.Y - defaultFont.GetAscent(uiScale)) * invertedScale); + LayoutContainer.SetPosition(control, pos); + control.Measure(new Vector2(Width, Height)); + var advanceX = control.DesiredPixelSize.X; + controlYAdvance = Math.Max(0f, (control.DesiredPixelSize.Y - GetLineHeight(font, uiScale, lineHeightScale)) * invertedScale); + baseLine += new Vector2(advanceX, 0); + } + } + + private readonly string ProcessNode(MarkupTagManager tagManager, MarkupNode node, MarkupDrawingContext context) + { + // If a nodes name is null it's a text node. + if (node.Name == null) + return node.Value.StringValue ?? ""; + + //Skip the node if there is no markup tag for it. + if (!tagManager.TryGetMarkupTag(node.Name, _tagsAllowed, out var tag)) + return ""; + + if (!node.Closing) + { + tag.PushDrawContext(node, context); + return tag.TextBefore(node); + } + + tag.PopDrawContext(node, context); + return tag.TextAfter(node); + } + + private static int GetLineHeight(Font font, float uiScale, float lineHeightScale) + { + var height = font.GetLineHeight(uiScale); + return (int)(height * lineHeightScale); + } +} diff --git a/Content.Client/_Sunrise/UI/Controls/SunriseRingBufferList.cs b/Content.Client/_Sunrise/UI/Controls/SunriseRingBufferList.cs new file mode 100644 index 00000000000..33d068f992d --- /dev/null +++ b/Content.Client/_Sunrise/UI/Controls/SunriseRingBufferList.cs @@ -0,0 +1,295 @@ +using System.Collections; +using System.Runtime.CompilerServices; +using Robust.Shared.Utility; + +namespace Content.Client._Sunrise.UI.Controls; + +public sealed class SunriseRingBufferList : IList +{ + private T[] _items; + private int _read; + private int _write; + + public SunriseRingBufferList(int capacity) + { + _items = new T[capacity]; + } + + public SunriseRingBufferList() + { + _items = []; + } + + public int Capacity => _items.Length; + + private bool IsFull => _items.Length == 0 || NextIndex(_write) == _read; + + public void Add(T item) + { + if (IsFull) + Expand(); + + DebugTools.Assert(!IsFull); + + _items[_write] = item; + _write = NextIndex(_write); + } + + public void Clear() + { + _read = 0; + _write = 0; + if (RuntimeHelpers.IsReferenceOrContainsReferences()) + Array.Clear(_items); + } + + public bool Contains(T item) + { + return IndexOf(item) >= 0; + } + + public void CopyTo(T[] array, int arrayIndex) + { + ArgumentOutOfRangeException.ThrowIfNegative(arrayIndex); + + CopyTo(array.AsSpan(arrayIndex)); + } + + private void CopyTo(Span dest) + { + if (dest.Length < Count) + throw new ArgumentException("Not enough elements in destination!"); + + var i = 0; + foreach (var item in this) + { + dest[i++] = item; + } + } + + public bool Remove(T item) + { + var index = IndexOf(item); + if (index < 0) + return false; + + RemoveAt(index); + return true; + } + + public int Count + { + get + { + var length = _write - _read; + if (length >= 0) + return length; + + return length + _items.Length; + } + } + + public bool IsReadOnly => false; + + public int IndexOf(T item) + { + var i = 0; + foreach (var containedItem in this) + { + if (EqualityComparer.Default.Equals(item, containedItem)) + return i; + + i += 1; + } + + return -1; + } + + public void Insert(int index, T item) + { + throw new NotSupportedException(); + } + + public void RemoveAt(int index) + { + var length = Count; + ArgumentOutOfRangeException.ThrowIfNegative(index); + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(index, length); + + if (index == 0) + { + if (RuntimeHelpers.IsReferenceOrContainsReferences()) + _items[_read] = default!; + + _read = NextIndex(_read); + } + else if (index == length - 1) + { + _write = WrapInv(_write - 1); + + if (RuntimeHelpers.IsReferenceOrContainsReferences()) + _items[_write] = default!; + } + else + { + // If past me had better foresight I wouldn't be spending so much effort writing this right now. + + var realIdx = RealIndex(index); + var origValue = _items[realIdx]; + T result; + + if (realIdx < _read) + { + // Scenario one: to-remove index is after break. + // One shift is needed. + // v + // X X X O X X + // W R + DebugTools.Assert(_write < _read); + + result = ShiftDown(_items.AsSpan()[realIdx.._write], default!); + } + else if (_write < _read) + { + // Scenario two: to-remove index is before break, but write is after. + // Two shifts are needed. + // v + // X O X X X X + // W R + + var fromEnd = ShiftDown(_items.AsSpan(0, _write), default!); + result = ShiftDown(_items.AsSpan(realIdx), fromEnd); + } + else + { + // Scenario two: array is contiguous. + // One shift is needed. + // v + // X X X X O O + // R W + + result = ShiftDown(_items.AsSpan()[realIdx.._write], default!); + } + + // Just make sure we didn't bulldozer something. + DebugTools.Assert(EqualityComparer.Default.Equals(origValue, result)); + + _write = WrapInv(_write - 1); + } + } + + private static T ShiftDown(Span span, T substitution) + { + if (span.Length == 0) + return substitution; + + var first = span[0]; + span[1..].CopyTo(span[..^1]); + span[^1] = substitution!; + return first; + } + + private T GetSlot(int index) + { + ArgumentOutOfRangeException.ThrowIfNegative(index); + ArgumentOutOfRangeException.ThrowIfGreaterThanOrEqual(index, Count); + + return _items[RealIndex(index)]; + } + + public T this[int index] + { + get => GetSlot(index); + set => _items[RealIndex(index)] = value; + } + + private int RealIndex(int index) + { + return Wrap(index + _read); + } + + private int NextIndex(int index) => Wrap(index + 1); + + private int Wrap(int index) + { + if (index >= _items.Length) + index -= _items.Length; + + return index; + } + + private int WrapInv(int index) + { + if (index < 0) + index = _items.Length - 1; + + return index; + } + + private void Expand() + { + var prevSize = _items.Length; + var newSize = Math.Max(4, prevSize * 2); + Array.Resize(ref _items, newSize); + + if (_write >= _read) + return; + + // Write is behind read pointer, so we need to copy the items to be after the read pointer. + var toCopy = _items.AsSpan(0, _write); + var copyDest = _items.AsSpan(prevSize); + toCopy.CopyTo(copyDest); + + if (RuntimeHelpers.IsReferenceOrContainsReferences()) + toCopy.Clear(); + + _write += prevSize; + } + + public Enumerator GetEnumerator() + { + return new Enumerator(this); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + public struct Enumerator : IEnumerator + { + private readonly SunriseRingBufferList _ringBufferList; + private int _readPos; + + internal Enumerator(SunriseRingBufferList ringBufferList) + { + _ringBufferList = ringBufferList; + _readPos = _ringBufferList._read - 1; + } + + public bool MoveNext() + { + _readPos = _ringBufferList.NextIndex(_readPos); + return _readPos != _ringBufferList._write; + } + + public void Reset() + { + this = new Enumerator(_ringBufferList); + } + + public ref T Current => ref _ringBufferList._items[_readPos]; + + T IEnumerator.Current => Current; + object? IEnumerator.Current => Current; + + void IDisposable.Dispose() + { + } + } +} diff --git a/Content.Client/_Sunrise/UI/Controls/SunriseWordWrap.cs b/Content.Client/_Sunrise/UI/Controls/SunriseWordWrap.cs new file mode 100644 index 00000000000..da14e701edd --- /dev/null +++ b/Content.Client/_Sunrise/UI/Controls/SunriseWordWrap.cs @@ -0,0 +1,170 @@ +using System.Diagnostics.Contracts; +using System.Text; +using Robust.Client.Graphics; +using Robust.Shared.Utility; + +namespace Content.Client._Sunrise.UI.Controls; + +internal struct SunriseWordWrap +{ + private readonly float _maxSizeX; + + public float MaxUsedWidth; + // Index we put into the LineBreaks list when a line break should occur. + public int BreakIndexCounter; + public int NextBreakIndexCounter; + // If the CURRENT processing word ends up too long, this is the index to put a line break. + public (int index, float lineSize)? WordStartBreakIndex; + // Word size in pixels. + public int WordSizePixels; + // The horizontal position of the text cursor. + public int PosX; + public Rune LastRune; + // If a word is larger than maxSizeX, we split it. + // We need to keep track of some data to split it into two words. + public (int breakIndex, int wordSizePixels)? ForceSplitData = null; + + public SunriseWordWrap(float maxSizeX) + { + this = default; + _maxSizeX = maxSizeX; + LastRune = new Rune('A'); + } + + public void NextRune(Rune rune, out int? breakLine, out int? breakNewLine, out bool skip) + { + BreakIndexCounter = NextBreakIndexCounter; + NextBreakIndexCounter += rune.Utf16SequenceLength; + + breakLine = null; + breakNewLine = null; + skip = false; + + if (IsWordBoundary(LastRune, rune) || rune == new Rune('\n')) + { + // Word boundary means we know where the word ends. + if (PosX > _maxSizeX && LastRune != new Rune(' ')) + { + DebugTools.Assert(WordStartBreakIndex.HasValue, + "wordStartBreakIndex can only be null if the word begins at a new line, in which case this branch shouldn't be reached as the word would be split due to being longer than a single line."); + //Ensure the assert had a chance to run and then just return + if (!WordStartBreakIndex.HasValue) + return; + + // We ran into a word boundary and the word is too big to fit the previous line. + // So we insert the line break BEFORE the last word. + breakLine = WordStartBreakIndex!.Value.index; + MaxUsedWidth = Math.Max(MaxUsedWidth, WordStartBreakIndex.Value.lineSize); + PosX = WordSizePixels; + } + + // Start a new word since we hit a word boundary. + //wordSize = 0; + WordSizePixels = 0; + WordStartBreakIndex = (BreakIndexCounter, PosX); + ForceSplitData = null; + + // Just manually handle newlines. + if (rune == new Rune('\n')) + { + MaxUsedWidth = Math.Max(MaxUsedWidth, PosX); + PosX = 0; + WordStartBreakIndex = null; + skip = true; + breakNewLine = BreakIndexCounter; + } + } + + LastRune = rune; + } + + public void NextMetrics(in CharMetrics metrics, out int? breakLine, out bool abort) + { + abort = false; + breakLine = null; + + // Increase word size and such with the current character. + var oldWordSizePixels = WordSizePixels; + WordSizePixels += metrics.Advance; + // TODO: Theoretically, does it make sense to break after the glyph's width instead of its advance? + // It might result in some more tight packing but I doubt it'd be noticeable. + // Also definitely even more complex to implement. + PosX += metrics.Advance; + + if (PosX <= _maxSizeX) + return; + + if (!ForceSplitData.HasValue) + { + ForceSplitData = (BreakIndexCounter, oldWordSizePixels); + } + + // Oh hey we get to break a word that doesn't fit on a single line. + if (WordSizePixels > _maxSizeX) + { + var (breakIndex, splitWordSize) = ForceSplitData.Value; + if (splitWordSize == 0) + { + // Happens if there's literally not enough space for a single character so uh... + // Yeah just don't. + abort = true; + return; + } + + // Reset forceSplitData so that we can split again if necessary. + ForceSplitData = null; + breakLine = breakIndex; + WordSizePixels -= splitWordSize; + WordStartBreakIndex = null; + MaxUsedWidth = Math.Max(MaxUsedWidth, _maxSizeX); + PosX = WordSizePixels; + } + } + + public int FinalizeText(out int? breakLine) + { + // This needs to happen because word wrapping doesn't get checked for the last word. + if (PosX > _maxSizeX) + { + if (!WordStartBreakIndex.HasValue) + { + Logger.Error( + "Assert fail inside RichTextEntry.Update, " + + "wordStartBreakIndex is null on method end w/ word wrap required. " + + "Dumping relevant stuff. Send this to PJB."); + // Logger.Error($"Message: {Message}"); + Logger.Error($"maxSizeX: {_maxSizeX}"); + Logger.Error($"maxUsedWidth: {MaxUsedWidth}"); + Logger.Error($"breakIndexCounter: {BreakIndexCounter}"); + Logger.Error("wordStartBreakIndex: null (duh)"); + Logger.Error($"wordSizePixels: {WordSizePixels}"); + Logger.Error($"posX: {PosX}"); + Logger.Error($"lastChar: {LastRune}"); + Logger.Error($"forceSplitData: {ForceSplitData}"); + // Logger.Error($"LineBreaks: {string.Join(", ", LineBreaks)}"); + + throw new Exception( + "wordStartBreakIndex can only be null if the word begins at a new line," + + "in which case this branch shouldn't be reached as" + + "the word would be split due to being longer than a single line."); + } + + breakLine = WordStartBreakIndex.Value.index; + MaxUsedWidth = Math.Max(MaxUsedWidth, WordStartBreakIndex.Value.lineSize); + } + else + { + breakLine = null; + MaxUsedWidth = Math.Max(MaxUsedWidth, PosX); + } + + return (int)MaxUsedWidth; + } + + [Pure] + private static bool IsWordBoundary(Rune a, Rune b) + { + return a == new Rune(' ') || b == new Rune(' ') || a == new Rune('-') || b == new Rune('-'); + } + +} diff --git a/Content.Shared/Chat/MsgChatMessage.cs b/Content.Shared/Chat/MsgChatMessage.cs index 85367ffb739..5bf6ff45718 100644 --- a/Content.Shared/Chat/MsgChatMessage.cs +++ b/Content.Shared/Chat/MsgChatMessage.cs @@ -37,6 +37,7 @@ public sealed class ChatMessage public Color? MessageColorOverride; public string? AudioPath; public float AudioVolume; + public int repeat = 1; [NonSerialized] public bool Read;