using System.Collections; using System.Collections.Specialized; using System.Text; using System.Text.RegularExpressions; using Terminal.Gui.Drawing; using Terminal.Gui.Views; using Attribute = Terminal.Gui.Drawing.Attribute; namespace EchoHub.Client.UI; /// /// A colored text segment within a chat line. /// public record ChatSegment(string Text, Attribute? Color); /// /// A single line in the chat, composed of colored segments. /// public partial class ChatLine { public List Segments { get; } public int TextLength { get; } public ChatLine(string plainText) { Segments = [new ChatSegment(plainText, null)]; TextLength = plainText.Length; } public ChatLine(List segments) { Segments = segments; TextLength = segments.Sum(s => s.Text.Length); } public override string ToString() => string.Concat(Segments.Select(s => s.Text)); /// /// Wrap this line into multiple lines that fit within the given width. /// Continuation lines are indented with the specified number of spaces. /// public List Wrap(int width, int continuationIndent = 0) { if (width <= 0 || TextLength <= width) return [this]; var results = new List(); var currentSegments = new List(); int col = 0; foreach (var segment in Segments) { int segPos = 0; while (segPos < segment.Text.Length) { int remaining = width - col; if (remaining <= 0) { // Emit current line and start a new one results.Add(new ChatLine(currentSegments)); currentSegments = []; // Add indent for continuation if (continuationIndent > 0) { currentSegments.Add(new ChatSegment(new string(' ', continuationIndent), null)); col = continuationIndent; } else { col = 0; } remaining = width - col; } int take = Math.Min(segment.Text.Length - segPos, remaining); currentSegments.Add(new ChatSegment(segment.Text.Substring(segPos, take), segment.Color)); col += take; segPos += take; } } if (currentSegments.Count > 0) results.Add(new ChatLine(currentSegments)); return results; } /// /// Parse a string containing ANSI 24-bit color escape codes into colored segments. /// Format: \x1b[38;2;R;G;Bm (foreground color), \x1b[0m (reset) /// public static ChatLine FromAnsi(string ansiText, Attribute? defaultAttr = null) { var segments = new List(); var regex = AnsiColorRegex(); int lastIndex = 0; Attribute? currentColor = defaultAttr; foreach (Match match in regex.Matches(ansiText)) { // Add any text before this escape sequence if (match.Index > lastIndex) { var text = ansiText[lastIndex..match.Index]; if (text.Length > 0) segments.Add(new ChatSegment(text, currentColor)); } // Parse the escape sequence if (match.Groups[1].Value == "0") { // Reset currentColor = defaultAttr; } else if (match.Groups[2].Success) { // 38;2;R;G;B — 24-bit foreground color var r = int.Parse(match.Groups[3].Value); var g = int.Parse(match.Groups[4].Value); var b = int.Parse(match.Groups[5].Value); currentColor = new Attribute(new Color(r, g, b), Color.Black); } lastIndex = match.Index + match.Length; } // Add remaining text if (lastIndex < ansiText.Length) { var text = ansiText[lastIndex..]; if (text.Length > 0) segments.Add(new ChatSegment(text, currentColor)); } return segments.Count > 0 ? new ChatLine(segments) : new ChatLine(""); } // Matches: \x1b[0m (reset) or \x1b[38;2;R;G;Bm (24-bit foreground) [GeneratedRegex(@"\x1b\[(?:(0)|(?:(38;2);(\d{1,3});(\d{1,3});(\d{1,3})))m")] private static partial Regex AnsiColorRegex(); } /// /// Custom list data source for chat messages with per-character coloring. /// public class ChatListSource : IListDataSource { private readonly List _lines = []; public event NotifyCollectionChangedEventHandler? CollectionChanged; public int Count => _lines.Count; public int MaxItemLength { get; private set; } public bool SuspendCollectionChangedEvent { get; set; } public void Add(ChatLine line) { _lines.Add(line); UpdateMaxLength(line); RaiseCollectionChanged(); } public void AddRange(IEnumerable lines) { foreach (var line in lines) { _lines.Add(line); UpdateMaxLength(line); } RaiseCollectionChanged(); } public void InsertRange(int index, IEnumerable lines) { var items = lines.ToList(); _lines.InsertRange(index, items); foreach (var line in items) UpdateMaxLength(line); RaiseCollectionChanged(); } public void Clear() { _lines.Clear(); MaxItemLength = 0; RaiseCollectionChanged(); } public bool IsMarked(int item) => false; public void SetMark(int item, bool value) { } public IList ToList() => _lines.Select(l => l.ToString()).ToList(); public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0) { listView.Move(Math.Max(col - viewportX, 0), row); var chatLine = _lines[item]; var normalAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal); int charPos = 0; int drawnChars = 0; foreach (var segment in chatLine.Segments) { var attr = segment.Color ?? normalAttr; listView.SetAttribute(attr); foreach (var ch in segment.Text) { if (charPos >= viewportX && drawnChars < width) { listView.AddRune(new Rune(ch)); drawnChars++; } charPos++; } } // Fill remaining width with spaces using default colors listView.SetAttribute(normalAttr); while (drawnChars < width) { listView.AddRune(new Rune(' ')); drawnChars++; } } private void UpdateMaxLength(ChatLine line) { if (line.TextLength > MaxItemLength) MaxItemLength = line.TextLength; } private void RaiseCollectionChanged() { if (!SuspendCollectionChangedEvent) CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); } public void Dispose() { } } /// /// Shared color attributes for chat rendering (timestamps, system messages). /// public static class ChatColors { public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.Black); public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.Black); } /// /// Helper to parse hex colors to Terminal.Gui Attributes. /// public static class ColorHelper { public static Attribute? ParseHexColor(string? hex) { if (string.IsNullOrWhiteSpace(hex)) return null; hex = hex.TrimStart('#'); if (hex.Length != 6) return null; try { var r = Convert.ToInt32(hex[..2], 16); var g = Convert.ToInt32(hex[2..4], 16); var b = Convert.ToInt32(hex[4..6], 16); return new Attribute(new Color(r, g, b), Color.Black); } catch { return null; } } }