Add hex color parsing helper and implement custom list sources for channels and users

- Introduced HexColorHelper for parsing hex color strings to Terminal.Gui Attributes and Colors.
- Created ChannelListSource for managing and rendering a list of channels with unread counts and active indicators.
- Developed UserListSource for displaying online users with per-user nickname colors.
- Refactored MainWindow to utilize ChatMessageManager for handling messages and channel state.
- Updated project references and removed unused content from the server project.
- Adjusted tests to reflect changes in namespaces and structure.
This commit is contained in:
HueByte
2026-02-22 08:08:21 +01:00
parent c5d8e25a86
commit c8ea124198
32 changed files with 1869 additions and 1586 deletions
+49
View File
@@ -0,0 +1,49 @@
using System.Text.RegularExpressions;
using Terminal.Gui.Drawing;
using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.UI.Chat;
/// <summary>
/// Shared color attributes for chat rendering (timestamps, system messages).
/// </summary>
public static partial class ChatColors
{
public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.None);
public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.None);
public static readonly Attribute MentionHighlightAttr = new(Color.White, new Color(80, 40, 0));
public static readonly Attribute MentionTextAttr = new(new Color(255, 180, 50), Color.None);
public static readonly Attribute EmbedBorderAttr = new(new Color(91, 155, 213), Color.None);
public static readonly Attribute EmbedTitleAttr = new(Color.White, Color.None);
public static readonly Attribute EmbedDescAttr = new(new Color(160, 160, 160), Color.None);
public static readonly Attribute EmbedUrlAttr = new(new Color(100, 100, 100), Color.None);
public static readonly Attribute AudioAttr = new(new Color(180, 100, 255), Color.None);
public static readonly Attribute FileAttr = new(new Color(100, 180, 255), Color.None);
/// <summary>
/// Split text around @mentions, giving each @word the MentionTextAttr accent color.
/// Non-mention text uses the provided default color.
/// </summary>
public static List<ChatSegment> SplitMentions(string text, Attribute? defaultColor = null)
{
var segments = new List<ChatSegment>();
int lastIndex = 0;
foreach (Match match in MentionRegex().Matches(text))
{
if (match.Index > lastIndex)
segments.Add(new ChatSegment(text[lastIndex..match.Index], defaultColor));
segments.Add(new ChatSegment(match.Value, MentionTextAttr));
lastIndex = match.Index + match.Length;
}
if (lastIndex < text.Length)
segments.Add(new ChatSegment(text[lastIndex..], defaultColor));
return segments;
}
[GeneratedRegex(@"@[\w-]+")]
private static partial Regex MentionRegex();
}
+177
View File
@@ -0,0 +1,177 @@
using System.Text.RegularExpressions;
using EchoHub.Core.Models;
using Terminal.Gui.Drawing;
using Terminal.Gui.Text;
using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.UI.Chat;
/// <summary>
/// A single line in the chat, composed of colored segments.
/// </summary>
public partial class ChatLine
{
public List<ChatSegment> Segments { get; }
public int TextLength { get; }
public Guid? MessageId { get; set; }
public bool IsMention { get; set; }
public string? AttachmentUrl { get; set; }
public string? AttachmentFileName { get; set; }
public MessageType? Type { get; set; }
public ChatLine(string plainText)
{
Segments = [new ChatSegment(plainText, null)];
TextLength = plainText.GetColumns();
}
public ChatLine(List<ChatSegment> segments)
{
Segments = segments;
TextLength = segments.Sum(s => s.Text.GetColumns());
}
public override string ToString() => string.Concat(Segments.Select(s => s.Text));
/// <summary>
/// Wrap this line into multiple lines that fit within the given width.
/// Continuation lines are indented with the specified number of spaces.
/// </summary>
public List<ChatLine> Wrap(int width, int continuationIndent = 0)
{
if (width <= 0 || TextLength <= width)
return [this];
var results = new List<ChatLine>();
var currentSegments = new List<ChatSegment>();
int col = 0;
foreach (var segment in Segments)
{
var text = segment.Text;
int chunkStart = 0;
int charPos = 0;
foreach (var grapheme in GraphemeHelper.GetGraphemes(text))
{
var graphemeCols = Math.Max(grapheme.GetColumns(), 1);
if (col + graphemeCols > width)
{
if (charPos > chunkStart)
currentSegments.Add(new ChatSegment(text[chunkStart..charPos], segment.Color));
results.Add(new ChatLine(currentSegments));
currentSegments = [];
if (continuationIndent > 0)
{
currentSegments.Add(new ChatSegment(new string(' ', continuationIndent), null));
col = continuationIndent;
}
else
{
col = 0;
}
chunkStart = charPos;
}
col += graphemeCols;
charPos += grapheme.Length;
}
if (chunkStart < text.Length)
currentSegments.Add(new ChatSegment(text[chunkStart..], segment.Color));
}
if (currentSegments.Count > 0)
results.Add(new ChatLine(currentSegments));
// Propagate attachment/type metadata to all wrapped lines so they remain clickable
foreach (var wrapped in results)
{
wrapped.AttachmentUrl = AttachmentUrl;
wrapped.AttachmentFileName = AttachmentFileName;
wrapped.Type = Type;
wrapped.MessageId = MessageId;
}
return results;
}
/// <summary>
/// Returns true if a line contains printable color tags.
/// </summary>
public static bool HasColorTags(string text) =>
text.Contains("{F:") || text.Contains("{B:") || text.Contains("{X}");
/// <summary>
/// Remove all color tags from text, returning only the visible characters.
/// </summary>
public static string StripColorTags(string text) =>
ColorTagRegex().Replace(text, "");
/// <summary>
/// Parse a string containing printable color tags into colored segments.
/// Format: {F:RRGGBB} (foreground), {B:RRGGBB} (background), {X} (reset).
/// </summary>
public static ChatLine FromColoredText(string text, Attribute? defaultAttr = null)
{
var segments = new List<ChatSegment>();
int lastIndex = 0;
Color? currentFg = null;
Color? currentBg = null;
var defaultFg = defaultAttr?.Foreground;
var defaultBg = defaultAttr?.Background ?? Color.None;
Attribute? BuildAttr()
{
if (currentFg is null && currentBg is null) return defaultAttr;
var fg = currentFg ?? defaultFg ?? Color.White;
var bg = currentBg ?? defaultBg;
return new Attribute(fg, bg);
}
foreach (Match match in ColorTagRegex().Matches(text))
{
if (match.Index > lastIndex)
{
var t = text[lastIndex..match.Index];
if (t.Length > 0)
segments.Add(new ChatSegment(t, BuildAttr()));
}
if (match.Groups[1].Success)
{
currentFg = null;
currentBg = null;
}
else if (match.Groups[2].Success)
{
var hex = match.Groups[3].Value;
var r = Convert.ToInt32(hex[..2], 16);
var g = Convert.ToInt32(hex[2..4], 16);
var b = Convert.ToInt32(hex[4..6], 16);
if (match.Groups[2].Value == "F")
currentFg = new Color(r, g, b);
else
currentBg = new Color(r, g, b);
}
lastIndex = match.Index + match.Length;
}
if (lastIndex < text.Length)
{
var t = text[lastIndex..];
if (t.Length > 0)
segments.Add(new ChatSegment(t, BuildAttr()));
}
return segments.Count > 0 ? new ChatLine(segments) : new ChatLine("");
}
[GeneratedRegex(@"\{(?:(X)|(?:(F|B):([0-9A-Fa-f]{6})))\}")]
private static partial Regex ColorTagRegex();
}
@@ -0,0 +1,113 @@
using System.Collections;
using System.Collections.Specialized;
using Terminal.Gui.Drawing;
using Terminal.Gui.Text;
using Terminal.Gui.Views;
using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.UI.Chat;
/// <summary>
/// Custom list data source for chat messages with per-segment coloring.
/// </summary>
public class ChatListSource : IListDataSource
{
private readonly List<ChatLine> _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<ChatLine> lines)
{
foreach (var line in lines)
{
_lines.Add(line);
UpdateMaxLength(line);
}
RaiseCollectionChanged();
}
public void InsertRange(int index, IEnumerable<ChatLine> 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 ChatLine? GetLine(int index) => index >= 0 && index < _lines.Count ? _lines[index] : null;
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(VisualRole.Normal);
var mentionBg = chatLine.IsMention ? ChatColors.MentionHighlightAttr.Background : (Color?)null;
int charPos = 0;
int drawnChars = 0;
foreach (var segment in chatLine.Segments)
{
var attr = segment.Color ?? normalAttr;
if (attr.Background == Color.None)
attr = attr with { Background = normalAttr.Background };
if (mentionBg.HasValue)
attr = attr with { Background = mentionBg.Value };
listView.SetAttribute(attr);
foreach (var grapheme in GraphemeHelper.GetGraphemes(segment.Text))
{
var cols = Math.Max(grapheme.GetColumns(), 1);
if (charPos >= viewportX && drawnChars + cols <= width)
{
listView.AddStr(grapheme);
drawnChars += cols;
}
charPos += cols;
}
}
var fillAttr = mentionBg.HasValue ? new Attribute(normalAttr.Foreground, mentionBg.Value) : normalAttr;
listView.SetAttribute(fillAttr);
for (int i = drawnChars; i < width; i++)
listView.AddStr(" ");
}
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() { }
}
@@ -0,0 +1,400 @@
using System.Text.RegularExpressions;
using EchoHub.Client.UI.Helpers;
using EchoHub.Core.DTOs;
using EchoHub.Core.Models;
using Terminal.Gui.Drawing;
using Terminal.Gui.Text;
using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.UI.Chat;
/// <summary>
/// Owns chat message storage, formatting, and mutation.
/// Fires <see cref="MessagesChanged"/> when a channel's message list is modified
/// so the UI layer can refresh.
/// </summary>
public sealed class ChatMessageManager
{
private readonly Dictionary<string, List<ChatLine>> _channelMessages = [];
private readonly Dictionary<string, int> _channelUnread = [];
private string _currentUser = string.Empty;
private string _currentChannel = string.Empty;
private int _chatWidth;
/// <summary>
/// Fired after any mutation to a channel's messages. Parameter is the channel name.
/// </summary>
public event Action<string>? MessagesChanged;
/// <summary>
/// The currently active channel (used for unread tracking and @mention detection).
/// </summary>
public string CurrentChannel
{
get => _currentChannel;
set => _currentChannel = value;
}
public string CurrentUser => _currentUser;
public void SetCurrentUser(string username) => _currentUser = username;
public void SetChatWidth(int width) => _chatWidth = width;
// ── Queries ──────────────────────────────────────────────────────
public List<ChatLine>? GetMessages(string channelName)
{
return _channelMessages.TryGetValue(channelName, out var messages) ? messages : null;
}
public int GetUnreadCount(string channelName)
{
return _channelUnread.TryGetValue(channelName, out var count) ? count : 0;
}
public void ClearUnread(string channelName)
{
_channelUnread[channelName] = 0;
}
internal Dictionary<string, int> GetUnreadCounts() => _channelUnread;
// ── Mutations ────────────────────────────────────────────────────
/// <summary>
/// Format and store a received message. Increments unread count if not the active channel.
/// </summary>
public void AddMessage(MessageDto message)
{
var lines = FormatMessage(message);
if (!_channelMessages.TryGetValue(message.ChannelName, out var messages))
{
messages = [];
_channelMessages[message.ChannelName] = messages;
}
foreach (var line in lines)
messages.Add(line);
if (message.ChannelName == _currentChannel)
{
MessagesChanged?.Invoke(message.ChannelName);
}
else
{
_channelUnread.TryGetValue(message.ChannelName, out var count);
_channelUnread[message.ChannelName] = count + 1;
MessagesChanged?.Invoke(message.ChannelName);
}
}
/// <summary>
/// Add a system/informational message to a channel with colored styling.
/// </summary>
public void AddSystemMessage(string channelName, string text)
{
if (!_channelMessages.TryGetValue(channelName, out var messages))
{
messages = [];
_channelMessages[channelName] = messages;
}
var time = DateTimeOffset.Now.ToString("HH:mm");
var textLines = text.Split('\n');
messages.Add(new ChatLine(
[
new($"[{time}] ", ChatColors.TimestampAttr),
new($"** {textLines[0].TrimEnd('\r')}", ChatColors.SystemAttr)
]));
var indent = new string(' ', $"[{time}] ** ".Length);
for (int i = 1; i < textLines.Length; i++)
{
var line = textLines[i].TrimEnd('\r');
if (string.IsNullOrWhiteSpace(line)) continue;
messages.Add(new ChatLine(
[
new($"{indent}{line}", ChatColors.SystemAttr)
]));
}
if (channelName == _currentChannel)
MessagesChanged?.Invoke(channelName);
}
/// <summary>
/// Add a status change message to a channel with colored styling.
/// </summary>
public void AddStatusMessage(string channelName, string username, string status)
{
var time = DateTimeOffset.Now.ToString("HH:mm");
var segments = new List<ChatSegment>
{
new($"[{time}] ", ChatColors.TimestampAttr),
new($"** {username} is now {status}", ChatColors.SystemAttr)
};
if (!_channelMessages.TryGetValue(channelName, out var messages))
{
messages = [];
_channelMessages[channelName] = messages;
}
messages.Add(new ChatLine(segments));
if (channelName == _currentChannel)
MessagesChanged?.Invoke(channelName);
}
/// <summary>
/// Remove all lines associated with a specific message ID.
/// </summary>
public void RemoveMessage(string channelName, Guid messageId)
{
if (_channelMessages.TryGetValue(channelName, out var messages))
{
messages.RemoveAll(l => l.MessageId == messageId);
if (channelName == _currentChannel)
MessagesChanged?.Invoke(channelName);
}
}
/// <summary>
/// Clear all messages from a specific channel.
/// </summary>
public void ClearChannelMessages(string channelName)
{
if (_channelMessages.TryGetValue(channelName, out var messages))
{
messages.Clear();
if (channelName == _currentChannel)
MessagesChanged?.Invoke(channelName);
}
}
/// <summary>
/// Load historical messages into a channel, replacing any existing messages.
/// </summary>
public void LoadHistory(string channelName, List<MessageDto> messages)
{
var formatted = messages.SelectMany(FormatMessage).ToList();
_channelMessages[channelName] = formatted;
if (channelName == _currentChannel)
MessagesChanged?.Invoke(channelName);
}
/// <summary>
/// Reset all message state (used on disconnect).
/// </summary>
public void ClearAll()
{
_channelMessages.Clear();
_channelUnread.Clear();
_currentChannel = string.Empty;
_currentUser = string.Empty;
}
// ── Formatting ───────────────────────────────────────────────────
private List<ChatLine> FormatMessage(MessageDto message)
{
var time = message.SentAt.ToLocalTime().ToString("HH:mm");
var senderName = message.SenderUsername + ":";
var senderColor = HexColorHelper.ParseHexColor(message.SenderNicknameColor);
var lines = new List<ChatLine>();
switch (message.Type)
{
case MessageType.Image:
lines.Add(BuildChatLine(time, senderName, senderColor, " [Image]"));
if (!string.IsNullOrWhiteSpace(message.Content))
{
foreach (var artLine in message.Content.Split('\n'))
{
var trimmed = artLine.TrimEnd('\r');
if (ChatLine.HasColorTags(trimmed))
lines.Add(ChatLine.FromColoredText(" " + trimmed));
else
lines.Add(new ChatLine($" {trimmed}"));
}
}
break;
case MessageType.Audio:
var audioName = message.AttachmentFileName ?? "unknown";
var audioSize = FormatFileSize(message.AttachmentFileSize);
var audioLine = BuildChatLineColored(time, senderName, senderColor,
$" \u266a [Audio: {audioName}] [{audioSize}]", ChatColors.AudioAttr);
audioLine.AttachmentUrl = message.AttachmentUrl;
audioLine.AttachmentFileName = audioName;
audioLine.Type = MessageType.Audio;
lines.Add(audioLine);
break;
case MessageType.File:
var fileName = message.AttachmentFileName ?? "unknown";
var fileSize = FormatFileSize(message.AttachmentFileSize);
var fileLine = BuildChatLineColored(time, senderName, senderColor,
$" [File: {fileName}] [{fileSize}]", ChatColors.FileAttr);
fileLine.AttachmentUrl = message.AttachmentUrl;
fileLine.AttachmentFileName = fileName;
fileLine.Type = MessageType.File;
lines.Add(fileLine);
break;
case MessageType.Text:
default:
var displayContent = EmojiHelper.ReplaceEmoji(message.Content);
var contentLines = displayContent.Split('\n');
var firstLine = contentLines[0].TrimEnd('\r');
lines.Add(BuildChatLineWithMentions(time, senderName, senderColor, $" {firstLine}"));
var indent = new string(' ', $"[{time}] {senderName} ".Length);
for (int i = 1; i < contentLines.Length; i++)
{
var contText = $"{indent}{contentLines[i].TrimEnd('\r')}";
lines.Add(new ChatLine(ChatColors.SplitMentions(contText)));
}
if (message.Embeds is { Count: > 0 })
{
var chatWidth = _chatWidth > 0 ? _chatWidth : 80;
foreach (var embed in message.Embeds)
lines.AddRange(FormatEmbed(embed, indent, chatWidth));
}
break;
}
foreach (var line in lines)
line.MessageId = message.Id;
if (!string.IsNullOrEmpty(_currentUser) && message.Type == MessageType.Text)
{
var pattern = $@"@{Regex.Escape(_currentUser)}\b";
if (Regex.IsMatch(message.Content, pattern, RegexOptions.IgnoreCase))
{
foreach (var line in lines)
line.IsMention = true;
}
}
return lines;
}
private static ChatLine BuildChatLine(string time, string senderName, Attribute? senderColor, string suffix)
{
var segments = new List<ChatSegment>
{
new($"[{time}] ", ChatColors.TimestampAttr),
new(senderName, senderColor),
new(suffix, null)
};
return new ChatLine(segments);
}
private static ChatLine BuildChatLineColored(string time, string senderName, Attribute? senderColor, string suffix, Attribute suffixColor)
{
var segments = new List<ChatSegment>
{
new($"[{time}] ", ChatColors.TimestampAttr),
new(senderName, senderColor),
new(suffix, suffixColor)
};
return new ChatLine(segments);
}
private static ChatLine BuildChatLineWithMentions(string time, string senderName, Attribute? senderColor, string suffix)
{
var segments = new List<ChatSegment>
{
new($"[{time}] ", ChatColors.TimestampAttr),
new(senderName, senderColor),
};
segments.AddRange(ChatColors.SplitMentions(suffix));
return new ChatLine(segments);
}
private static List<ChatLine> FormatEmbed(EmbedDto embed, string indent, int chatWidth)
{
var lines = new List<ChatLine>();
const string border = "\u258f "; // ▏ + space
const int borderCols = 2;
int indentCols = indent.GetColumns();
int textWidth = chatWidth - indentCols - borderCols;
if (textWidth < 20) textWidth = 20;
void AddTextLine(string text, Attribute? color)
{
lines.Add(new ChatLine(
[
new ChatSegment(indent, null),
new ChatSegment(border, ChatColors.EmbedBorderAttr),
new ChatSegment(text, color)
]));
}
if (!string.IsNullOrWhiteSpace(embed.SiteName))
AddTextLine(embed.SiteName, ChatColors.EmbedBorderAttr);
if (!string.IsNullOrWhiteSpace(embed.Title))
{
foreach (var wrapped in WordWrap(embed.Title, textWidth))
AddTextLine(wrapped, ChatColors.EmbedTitleAttr);
}
if (!string.IsNullOrWhiteSpace(embed.Description))
{
foreach (var wrapped in WordWrap(embed.Description, textWidth))
AddTextLine(wrapped, ChatColors.EmbedDescAttr);
}
return lines;
}
private static List<string> WordWrap(string text, int maxCols)
{
if (maxCols <= 0)
return [text];
var result = new List<string>();
var words = text.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var currentLine = "";
foreach (var word in words)
{
var candidate = currentLine.Length == 0 ? word : currentLine + " " + word;
if (candidate.GetColumns() <= maxCols)
{
currentLine = candidate;
}
else
{
if (currentLine.Length > 0)
result.Add(currentLine);
currentLine = word;
}
}
if (currentLine.Length > 0)
result.Add(currentLine);
return result;
}
internal static string FormatFileSize(long? bytes)
{
if (bytes is null or 0)
return "?";
return bytes.Value switch
{
< 1024 => $"{bytes.Value} B",
< 1024 * 1024 => $"{bytes.Value / 1024.0:F1} KB",
< 1024 * 1024 * 1024 => $"{bytes.Value / (1024.0 * 1024.0):F1} MB",
_ => $"{bytes.Value / (1024.0 * 1024.0 * 1024.0):F1} GB"
};
}
}
@@ -0,0 +1,8 @@
using Attribute = Terminal.Gui.Drawing.Attribute;
namespace EchoHub.Client.UI.Chat;
/// <summary>
/// A colored text segment within a chat line.
/// </summary>
public record ChatSegment(string Text, Attribute? Color);
@@ -0,0 +1,27 @@
using Terminal.Gui.Drawing;
using Terminal.Gui.Text;
using Terminal.Gui.Views;
namespace EchoHub.Client.UI.Chat;
/// <summary>
/// Shared rendering helpers for IListDataSource implementations.
/// </summary>
static class RenderHelpers
{
/// <summary>
/// Write text grapheme-by-grapheme to a ListView, respecting a width limit.
/// Returns the updated drawn-columns count.
/// </summary>
public static int WriteText(ListView lv, string text, int drawn, int maxWidth)
{
foreach (var grapheme in GraphemeHelper.GetGraphemes(text))
{
var cols = Math.Max(grapheme.GetColumns(), 1);
if (drawn + cols > maxWidth) break;
lv.AddStr(grapheme);
drawn += cols;
}
return drawn;
}
}