Files
RobustToolbox/SS14.Client/UserInterface/Controls/OutputPanel.cs
2019-02-21 18:13:03 +01:00

446 lines
16 KiB
C#

using System;
using System.Collections.Generic;
using System.Diagnostics.Contracts;
using SS14.Client.Graphics;
using SS14.Client.Graphics.Drawing;
using SS14.Client.Input;
using SS14.Client.Utility;
using SS14.Shared.Maths;
using SS14.Shared.Utility;
namespace SS14.Client.UserInterface.Controls
{
/// <summary>
/// A control to handle output of message-by-message output panels, like the debug console and chat panel.
/// </summary>
public class OutputPanel : Control
{
private static readonly FormattedMessage.TagColor TagWhite = new FormattedMessage.TagColor(Color.White);
private readonly List<Entry> _entries = new List<Entry>();
private bool _isAtBottom = true;
private int _mouseWheelOffset;
private RichTextLabel _richTextLabel;
private int _totalContentHeight;
private bool rtlFirstLine = true;
public bool ScrollFollowing { get; set; } = true;
private int ScrollLimit => _totalContentHeight - (int) Size.Y + 1;
public void Clear()
{
if (GameController.OnGodot)
{
_richTextLabel.Clear();
rtlFirstLine = true;
}
else
{
_entries.Clear();
_totalContentHeight = 0;
_mouseWheelOffset = 0;
}
}
public void RemoveLine(int line)
{
if (GameController.OnGodot)
{
_richTextLabel.RemoveLine(line);
return;
}
var entry = _entries[line];
_entries.RemoveAt(line);
var font = _getFont();
_totalContentHeight -= entry.Height + font.LineSeparation;
}
public void AddMessage(FormattedMessage message)
{
if (GameController.OnGodot)
{
_addMessageGodot(message);
return;
}
var entry = new Entry(message);
_updateEntry(ref entry);
_entries.Add(entry);
var font = _getFont();
_totalContentHeight += font.LineSeparation + entry.Height;
if (_isAtBottom && ScrollFollowing)
{
_mouseWheelOffset = ScrollLimit;
}
}
public void ScrollToBottom()
{
_mouseWheelOffset = ScrollLimit;
_isAtBottom = true;
}
protected override void Initialize()
{
base.Initialize();
if (GameController.OnGodot)
{
_richTextLabel = new RichTextLabel();
AddChild(_richTextLabel);
_richTextLabel.SetAnchorAndMarginPreset(LayoutPreset.Wide);
_richTextLabel.ScrollFollowing = true;
}
}
protected internal override void Draw(DrawingHandleScreen handle)
{
base.Draw(handle);
if (GameController.OnGodot)
{
return;
}
var font = _getFont();
var contentBox = UIBox2.FromDimensions(Vector2.Zero, Size);
var entryOffset = 0;
// 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 formatStack = new Stack<FormattedMessage.Tag>(2);
foreach (var entry in _entries)
{
if (entryOffset - _mouseWheelOffset < 0)
{
entryOffset += entry.Height + font.LineSeparation;
continue;
}
if (entryOffset + entry.Height - _mouseWheelOffset > contentBox.Height)
{
break;
}
// The tag currently doing color.
var currentColorTag = TagWhite;
var globalBreakCounter = 0;
var lineBreakIndex = 0;
var baseLine = contentBox.TopLeft + new Vector2(0, font.Ascent + entryOffset - _mouseWheelOffset);
formatStack.Clear();
foreach (var tag in entry.Message.Tags)
{
switch (tag)
{
case FormattedMessage.TagColor tagColor:
formatStack.Push(currentColorTag);
currentColorTag = tagColor;
break;
case FormattedMessage.TagPop _:
var popped = formatStack.Pop();
switch (popped)
{
case FormattedMessage.TagColor tagColor:
currentColorTag = tagColor;
break;
default:
throw new InvalidOperationException();
}
break;
case FormattedMessage.TagText tagText:
{
var text = tagText.Text;
for (var i = 0; i < text.Length; i++, globalBreakCounter++)
{
var chr = text[i];
if (lineBreakIndex < entry.LineBreaks.Count &&
entry.LineBreaks[lineBreakIndex] == globalBreakCounter)
{
baseLine = new Vector2(contentBox.Left, baseLine.Y + font.LineHeight);
lineBreakIndex += 1;
}
var advance = font.DrawChar(handle, chr, baseLine, currentColorTag.Color);
baseLine += new Vector2(advance, 0);
}
break;
}
}
}
entryOffset += entry.Height + font.LineSeparation;
}
}
protected internal override void MouseWheel(GUIMouseWheelEventArgs args)
{
base.MouseWheel(args);
if (GameController.OnGodot)
{
return;
}
if (args.WheelDirection == Mouse.Wheel.Up)
{
_mouseWheelOffset = Math.Max(0, _mouseWheelOffset - 10);
_isAtBottom = false;
}
else if (args.WheelDirection == Mouse.Wheel.Down)
{
var limit = ScrollLimit;
_mouseWheelOffset = Math.Min(_mouseWheelOffset + 10, limit);
if (limit == _mouseWheelOffset)
{
_isAtBottom = true;
}
}
}
private void _addMessageGodot(FormattedMessage message)
{
DebugTools.Assert(GameController.OnGodot);
if (!rtlFirstLine)
{
_richTextLabel.NewLine();
}
else
{
rtlFirstLine = false;
}
var pushCount = 0;
foreach (var tag in message.Tags)
switch (tag)
{
case FormattedMessage.TagText text:
_richTextLabel.AddText(text.Text);
break;
case FormattedMessage.TagColor color:
_richTextLabel.PushColor(color.Color);
pushCount++;
break;
case FormattedMessage.TagPop _:
if (pushCount <= 0) throw new InvalidOperationException();
_richTextLabel.Pop();
pushCount--;
break;
}
for (; pushCount > 0; pushCount--)
{
_richTextLabel.Pop();
}
}
/// <summary>
/// Recalculate line dimensions and where it has line breaks for word wrapping.
/// </summary>
private void _updateEntry(ref Entry entry)
{
DebugTools.Assert(!GameController.OnGodot);
// 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.
var font = _getFont();
var contentBox = UIBox2.FromDimensions(Vector2.Zero, Size);
// Horizontal size we have to work with here.
var sizeX = contentBox.Width;
entry.Height = font.Height;
entry.LineBreaks.Clear();
// Index we put into the LineBreaks list when a line break should occur.
var breakIndexCounter = 0;
// If the CURRENT processing word ends up too long, this is the index to put a line break.
int? wordStartBreakIndex = null;
// Word size in pixels.
var wordSizePixels = 0;
// The horizontal position of the text cursor.
var posX = 0;
var lastChar = 'A';
// If a word is larger than sizeX, we split it.
// We need to keep track of some data to split it into two words.
(int breakIndex, int wordSizePixels)? forceSplitData = null;
// Go over every text tag.
// We treat multiple text tags as one continuous one.
// So changing color inside a single word doesn't create a word break boundary.
foreach (var tag in entry.Message.Tags)
{
if (!(tag is FormattedMessage.TagText tagText))
{
continue;
}
var text = tagText.Text;
// And go over every character.
for (var i = 0; i < text.Length; i++, breakIndexCounter++)
{
var chr = text[i];
if (IsWordBoundary(lastChar, chr) || chr == '\n')
{
// Word boundary means we know where the word ends.
if (posX > sizeX)
{
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.");
// 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.
entry.LineBreaks.Add(wordStartBreakIndex.Value);
entry.Height += font.LineHeight;
posX = wordSizePixels;
}
// Start a new word since we hit a word boundary.
//wordSize = 0;
wordSizePixels = 0;
wordStartBreakIndex = breakIndexCounter;
forceSplitData = null;
// Just manually handle newlines.
if (chr == '\n')
{
entry.LineBreaks.Add(breakIndexCounter);
entry.Height += font.LineHeight;
posX = 0;
lastChar = chr;
wordStartBreakIndex = null;
continue;
}
}
// Uh just skip unknown characters I guess.
if (!font.TryGetCharMetrics(chr, out var metrics))
{
lastChar = chr;
continue;
}
// 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 > sizeX)
{
if (!forceSplitData.HasValue)
{
forceSplitData = (breakIndexCounter, oldWordSizePixels);
}
// Oh hey we get to break a word that doesn't fit on a single line.
if (wordSizePixels > sizeX)
{
var (breakIndex, splitWordSize) = forceSplitData.Value;
if (splitWordSize == 0) return;
// Reset forceSplitData so that we can split again if necessary.
forceSplitData = null;
entry.LineBreaks.Add(breakIndex);
entry.Height += font.LineHeight;
wordSizePixels -= splitWordSize;
wordStartBreakIndex = null;
posX = wordSizePixels;
}
}
lastChar = chr;
}
}
// This needs to happen because word wrapping doesn't get checked for the last word.
if (posX > sizeX)
{
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.");
entry.LineBreaks.Add(wordStartBreakIndex.Value);
entry.Height += font.LineHeight;
}
}
protected override void Resized()
{
base.Resized();
if (GameController.OnGodot)
{
return;
}
_invalidateEntries();
}
private void _invalidateEntries()
{
_totalContentHeight = 0;
var font = _getFont();
for (var i = 0; i < _entries.Count; i++)
{
var entry = _entries[i];
_updateEntry(ref entry);
_entries[i] = entry;
_totalContentHeight += entry.Height + font.LineSeparation;
}
if (_isAtBottom && ScrollFollowing)
{
_mouseWheelOffset = ScrollLimit;
}
}
[Pure]
private static bool IsWordBoundary(char a, char b)
{
return a == ' ' || b == ' ' || a == '-' || b == '-';
}
[Pure]
private Font _getFont()
{
if (TryGetStyleProperty("font", out Font font))
{
return font;
}
return UserInterfaceManager.ThemeDefaults.DefaultFont;
}
private struct Entry
{
public readonly FormattedMessage Message;
/// <summary>
/// The size of this line, in pixels.
/// </summary>
public int Height;
/// <summary>
/// The combined text indices in the message's text tags to put line breaks.
/// </summary>
public readonly List<int> LineBreaks;
public Entry(FormattedMessage message)
{
Message = message;
Height = 0;
LineBreaks = new List<int>();
}
}
}
}