using System; using System.Collections; using System.Collections.Generic; using System.IO; using System.Text; namespace Consolus { using static ConsoleStyle; public struct ConsoleStyleMarker { public ConsoleStyleMarker(ConsoleStyle style, bool enabled) { Style = style; Enabled = enabled; } public readonly ConsoleStyle Style; public readonly bool Enabled; public static implicit operator ConsoleStyleMarker(ConsoleStyle style) { return new ConsoleStyleMarker(style, true); } } public class ConsoleText : IList { private readonly List _chars; private readonly List _leadingStyles; private readonly List _trailingStyles; private bool _changedCalled; private readonly StringBuilder _currentEscape; private EscapeState _escapeState; public ConsoleText() { _chars = new List(); _leadingStyles = new List(); _trailingStyles = new List(); SelectionEnd = -1; _currentEscape = new StringBuilder(); } public ConsoleText(string text) : this() { Append(text); } public Action Changed { get; set; } public int VisibleCharacterStart { get; internal set; } public int VisibleCharacterEnd { get; internal set; } public int SelectionStart { get; set; } public int SelectionEnd { get; set; } public void Add(ConsoleChar item) { Insert(Count, item); } public void Clear() { ResetAndSet(null); NotifyChanged(); } private void ResetAndSet(string text) { _leadingStyles.Clear(); _chars.Clear(); _trailingStyles.Clear(); SelectionStart = 0; SelectionEnd = -1; if (text != null) { foreach (var c in text) { _chars.Add(c); } } } public void ReplaceBy(string text) { if (text == null) throw new ArgumentNullException(nameof(text)); ResetAndSet(text); NotifyChanged(); } public void ClearSelection() { SelectionStart = 0; SelectionEnd = -1; } public bool HasSelection => SelectionStart >= 0 && SelectionEnd <= Count && SelectionStart <= SelectionEnd; public void ClearStyles() { _leadingStyles.Clear(); _trailingStyles.Clear(); for (var i = 0; i < _chars.Count; i++) { var consoleChar = _chars[i]; if (consoleChar.StyleMarkers != null) { consoleChar.StyleMarkers.Clear(); _chars[i] = consoleChar; } } } public bool ClearStyle(ConsoleStyle style) { var removed = RemoveStyle(style, _leadingStyles); removed = RemoveStyle(style, _trailingStyles) || removed; for (var i = 0; i < _chars.Count; i++) { var consoleChar = _chars[i]; if (consoleChar.StyleMarkers != null) { removed = RemoveStyle(style, consoleChar.StyleMarkers) || removed; } } return removed; } private static bool RemoveStyle(ConsoleStyle style, List markers) { bool styleRemoved = false; if (markers == null) return false; for (var i = markers.Count - 1; i >= 0; i--) { var consoleStyleMarker = markers[i]; if (consoleStyleMarker.Style == style) { markers.RemoveAt(i); styleRemoved = true; } } return styleRemoved; } public bool Contains(ConsoleChar item) { return _chars.Contains(item); } public void CopyTo(ConsoleChar[] array, int arrayIndex) { _chars.CopyTo(array, arrayIndex); } public bool Remove(ConsoleChar item) { return _chars.Remove(item); } public int Count => _chars.Count; public bool IsReadOnly => false; public int IndexOf(ConsoleChar item) { return _chars.IndexOf(item); } public void Insert(int index, ConsoleChar item) { InsertInternal(index, item); NotifyChanged(); } public void Insert(int index, string text) { if (text == null) throw new ArgumentNullException(nameof(text)); for (int i = 0; i < text.Length; i++) { InsertInternal(index + i, text[i]); } NotifyChanged(); } private enum EscapeState { None, Escape, EscapeCsiParameterBytes, EscapeCsiIntermediateBytes, EscapeCsiFinalByte, } private void InsertInternal(int index, ConsoleChar item) { if (item.Value == '\x1b') { _currentEscape.Append(item.Value); _escapeState = EscapeState.Escape; return; } if (_escapeState != EscapeState.None) { bool isCharInvalidValid = false; // All sequences start with followed by a char in the range 0x40–0x5F: (ASCII @A–Z[\]^_) var c = item.Value; if (_escapeState >= EscapeState.EscapeCsiParameterBytes) { // CSI: ESC [ followed by // - by any number (including none) of "parameter bytes", char in the 0x30–0x3F: (ASCII 0–9:;<=>?) // - then by any number of "intermediate bytes" in the range 0x20–0x2F (ASCII space and !"#$%&'()*+,-./), // - then finally by a single "final byte" in the range 0x40–0x7E (ASCII @A–Z[\]^_`a–z{|}~) switch (_escapeState) { case EscapeState.EscapeCsiParameterBytes: if (c >= 0x30 && c <= 0x3F) { _currentEscape.Append(c); } else { goto case EscapeState.EscapeCsiIntermediateBytes; } break; case EscapeState.EscapeCsiIntermediateBytes: if (c >= 0x20 && c <= 0x2F) { _escapeState = EscapeState.EscapeCsiIntermediateBytes; _currentEscape.Append(c); } else { goto case EscapeState.EscapeCsiFinalByte; } break; case EscapeState.EscapeCsiFinalByte: if (c >= 0x40 && c <= 0x7E) { _currentEscape.Append(c); } else { isCharInvalidValid = true; } var styleAsText = _currentEscape.ToString(); _currentEscape.Length = 0; _escapeState = EscapeState.None; InsertInternal(index, new ConsoleStyleMarker(Inline(styleAsText), true)); break; } } else { if (_currentEscape.Length == 1) { _currentEscape.Append(c); if (c == '[') { _escapeState = EscapeState.EscapeCsiParameterBytes; } else { var styleAsText = _currentEscape.ToString(); _currentEscape.Length = 0; _escapeState = EscapeState.None; InsertInternal(index, new ConsoleStyleMarker(Inline(styleAsText), true)); } } } if (!isCharInvalidValid) { return; } // otherwise the character hasn't been consumed, so we propagate it as a real char. } // Copy any leading/trailing escapes bool isFirstInsert = index == 0 && Count == 0; bool isLastInsert = Count > 0 && index == Count; List copyFrom = isFirstInsert ? _leadingStyles : isLastInsert ? _trailingStyles : null; if (copyFrom != null && copyFrom.Count > 0) { var escapes = item.StyleMarkers; if (escapes == null) { escapes = new List(); item.StyleMarkers = escapes; } for (int i = 0; i < copyFrom.Count; i++) { escapes.Insert(i, copyFrom[i]); } copyFrom.Clear(); } _chars.Insert(index, item); } public void InsertRange(int index, string text, int textIndex, int length) { if (text == null) throw new ArgumentNullException(nameof(text)); var cursorIndex = index; var end = textIndex + length; for (int i = textIndex; i < end; i++) { var c = text[i]; InsertInternal(cursorIndex, c); cursorIndex++; } NotifyChanged(); } private void NotifyChanged() { var changed = Changed; // Avoid recursive change if (changed != null && !_changedCalled) { _changedCalled = true; try { changed(); } finally { _changedCalled = false; } } } public void RemoveAt(int index) { _chars.RemoveAt(index); NotifyChanged(); } public void RemoveRangeAt(int index, int length) { for (int i = 0; i < length; i++) { _chars.RemoveAt(index); } NotifyChanged(); } public ConsoleChar this[int index] { get => _chars[index]; set => _chars[index] = value; } public void Add(ConsoleStyle style) { Add(style, true); } public void Add(ConsoleStyle style, bool enabled) { if (enabled) EnableStyleAt(Count, style); else DisableStyleAt(Count, style); } public ConsoleText Append(char c) { Add(c); return this; } public ConsoleText Begin(ConsoleStyle style) { Add(style); return this; } public ConsoleText End(ConsoleStyle style) { Add(style, false); return this; } public ConsoleText AppendLine(string text) { if (text == null) throw new ArgumentNullException(nameof(text)); AddInternal(text); Add('\n'); return this; } public ConsoleText AppendLine() { Add('\n'); return this; } public void AddRange(ConsoleText text) { if (text == null) throw new ArgumentNullException(nameof(text)); foreach(var c in text._leadingStyles) { InsertInternal(Count, c); } foreach(var c in text._chars) { InsertInternal(Count, c); } foreach (var c in text._trailingStyles) { InsertInternal(Count, c); } } public ConsoleText Append(ConsoleStyle style, bool enabled) { Add(style, enabled); return this; } public ConsoleText Append(string text) { if (text == null) throw new ArgumentNullException(nameof(text)); AddInternal(text); NotifyChanged(); return this; } private void AddInternal(string text) { foreach (var c in text) { InsertInternal(Count, c); } } public void EnableStyleAt(int index, ConsoleStyle style) { InsertInternal(index, style); } public void DisableStyleAt(int index, ConsoleStyle style) { InsertInternal(index, new ConsoleStyleMarker(style, false)); } private void InsertInternal(int index, ConsoleStyleMarker marker) { if ((uint)index > (uint)Count) throw new ArgumentOutOfRangeException($"Invalid character index {index} not within range [0, {Count}]"); var isFirst = index == 0 && Count == 0; var isLast = index == Count; List list; if (isFirst) { list = _leadingStyles; } else if (isLast) { list = _trailingStyles; } else { var c = this[index]; list = c.StyleMarkers; if (list == null) { list = new List(); c.StyleMarkers = list; this[index] = c; } } list.Add(marker); } private void RenderLeadingTrailingStyles(TextWriter writer, bool displayStyle, bool leading, RunningStyles runningStyles) { var styles = leading ? _leadingStyles : _trailingStyles; foreach (var consoleStyle in styles) { runningStyles.ApplyStyle(consoleStyle); if (displayStyle) { runningStyles.Render(writer); } } } public void Render(ConsoleTextWriter writer, bool renderEscape = true) { VisibleCharacterStart = writer.VisibleCharacterCount; if (HasSelection) { RenderWithSelection(writer, renderEscape); } else { var styles = new RunningStyles(); if (renderEscape) RenderLeadingTrailingStyles(writer, true, true, styles); RenderInternal(writer, 0, Count, renderEscape, styles); if (renderEscape) RenderLeadingTrailingStyles(writer, true, false, styles); } VisibleCharacterEnd = writer.VisibleCharacterCount - 1; } private void RenderWithSelection(ConsoleTextWriter writer, bool renderEscape = true) { if (writer == null) throw new ArgumentNullException(nameof(writer)); // TODO: TLS cache var pendingStyles = renderEscape ? new RunningStyles() : null; if (renderEscape) { RenderLeadingTrailingStyles(writer, true, true, pendingStyles); } // Display text before without selection RenderInternal(writer, 0, SelectionStart, renderEscape, pendingStyles); if (renderEscape) { // Disable any attribute sequences Reset.Render(writer); Reversed.Render(writer); } // Render the string with reverse video RenderInternal(writer, SelectionStart, SelectionEnd, false, pendingStyles); if (renderEscape) { // Disable any attribute sequences Reset.Render(writer); pendingStyles.Render(writer); } // Display text after without selection RenderInternal(writer, SelectionEnd, this.Count, renderEscape, pendingStyles); if (renderEscape) RenderLeadingTrailingStyles(writer, true, false, pendingStyles); } private void RenderInternal(ConsoleTextWriter writer, int start, int end, bool displayStyle, RunningStyles runningStyles) { for(int i = start; i < end; i++) { var c = this[i]; if ((displayStyle || runningStyles != null) && c.StyleMarkers != null) { foreach (var esc in c.StyleMarkers) { runningStyles.ApplyStyle(esc); if (displayStyle) { runningStyles.Render(writer); } } } var charValue = c.Value; // Fill the remaining line with space to clear the space if (charValue == '\n') { var toComplete = Console.BufferWidth - writer.VisibleCharacterCount % Console.BufferWidth; for (int j = 0; j < toComplete; j++) { writer.Write(' '); } writer.VisibleCharacterCount += toComplete; } if (charValue == '\n' || charValue >= ' ') { writer.Write(charValue); } if (charValue != '\n' || charValue >= ' ') { writer.VisibleCharacterCount++; } } } public static implicit operator ConsoleText(string text) { return new ConsoleText(text); } public IEnumerator GetEnumerator() { return _chars.GetEnumerator(); } public override string ToString() { var builder = new StringBuilder(); foreach (var c in this) { builder.Append(c.Value); } return builder.ToString(); } IEnumerator IEnumerable.GetEnumerator() { return ((IEnumerable) _chars).GetEnumerator(); } private class RunningStyles : Dictionary> { public void Render(TextWriter writer) { // Disable any attribute sequences Reset.Render(writer); foreach (var stylePair in this) { var list = stylePair.Value; if (stylePair.Key == ConsoleStyleKind.Color) { if (list.Count > 0) { list[list.Count - 1].Render(writer); } } else if (stylePair.Key == ConsoleStyleKind.Format) { foreach (var item in list) { item.Render(writer); } } } } public void ApplyStyle(ConsoleStyleMarker styleMarker) { var style = styleMarker.Style; switch (style.Kind) { case ConsoleStyleKind.Color: case ConsoleStyleKind.Format: if (!TryGetValue(style.Kind, out var list)) { list = new List(); Add(style.Kind, list); } if (styleMarker.Enabled) { list.Add(style); } else { for (var i = list.Count - 1; i >= 0; i--) { var item = list[i]; if (item == style) { list.RemoveAt(i); break; } } } break; case ConsoleStyleKind.Reset: // An inline reset applies only to inline styles if (style.IsInline) { // Clear only inline styles (and not regular) foreach(var keyPair in this) { var styleList = keyPair.Value; for(int i = styleList.Count - 1; i >= 0; i--) { var styleIn = styleList[i]; if (styleIn.IsInline) { styleList.RemoveAt(i); } } } } else { Clear(); } break; default: throw new ArgumentOutOfRangeException(); } } } } }