From 3886e7148cad10a03a4a565c4c89bf182e2dba00 Mon Sep 17 00:00:00 2001 From: HueByte Date: Thu, 16 Jul 2026 07:48:47 +0200 Subject: [PATCH] feat(chat): enhance chat UI with new features and visual improvements - Added new attributes for chat colors including rail, date rules, and unread markers. - Enhanced ChatLine class to support colored segments for continuation lines and horizontal separator rules. - Introduced a WelcomeBanner class to display a splash screen when no channel is selected. - Implemented NickColorHelper for deterministic per-nick colors, ensuring consistent user color representation. - Updated ChannelListSource to highlight channels with unread messages and mentions. - Improved MainWindow to include a spinner for transitional connection states and display activity in the status bar. - Added unit tests for NickColorHelper to ensure stable and predictable color indexing. --- src/EchoHub.Client/UI/Chat/ChatColors.cs | 9 + src/EchoHub.Client/UI/Chat/ChatLine.cs | 37 +- .../UI/Chat/ChatMessageManager.cs | 358 +++++++++++++----- src/EchoHub.Client/UI/Chat/WelcomeBanner.cs | 103 +++++ .../UI/Helpers/NickColorHelper.cs | 60 +++ .../UI/ListSources/ChannelListSource.cs | 17 +- src/EchoHub.Client/UI/MainWindow.cs | 121 +++++- src/EchoHub.Tests/NickColorHelperTests.cs | 55 +++ 8 files changed, 634 insertions(+), 126 deletions(-) create mode 100644 src/EchoHub.Client/UI/Chat/WelcomeBanner.cs create mode 100644 src/EchoHub.Client/UI/Helpers/NickColorHelper.cs create mode 100644 src/EchoHub.Tests/NickColorHelperTests.cs diff --git a/src/EchoHub.Client/UI/Chat/ChatColors.cs b/src/EchoHub.Client/UI/Chat/ChatColors.cs index 0eee5a6..50d7a37 100644 --- a/src/EchoHub.Client/UI/Chat/ChatColors.cs +++ b/src/EchoHub.Client/UI/Chat/ChatColors.cs @@ -21,6 +21,15 @@ public static partial class ChatColors 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); + /// The dim vertical rail (│) separating the nick column from message text. + public static readonly Attribute RailAttr = new(new Color(95, 95, 95), Color.None); + + /// Horizontal date-separator rules (── Wed, Jul 16 ──…). + public static readonly Attribute DateRuleAttr = new(new Color(120, 120, 120), Color.None); + + /// The irssi-style "new messages" unread marker rule. + public static readonly Attribute UnreadMarkerAttr = new(new Color(230, 140, 60), Color.None); + /// /// Split text around @mentions and #channels, giving each the appropriate accent color. /// Non-special text uses the provided default color. diff --git a/src/EchoHub.Client/UI/Chat/ChatLine.cs b/src/EchoHub.Client/UI/Chat/ChatLine.cs index 92120a2..f7eef86 100644 --- a/src/EchoHub.Client/UI/Chat/ChatLine.cs +++ b/src/EchoHub.Client/UI/Chat/ChatLine.cs @@ -23,6 +23,25 @@ public partial class ChatLine /// Number of spaces to prepend on continuation lines when this line is word-wrapped. public int ContinuationIndent { get; set; } + /// + /// Colored segments to prepend on continuation lines instead of plain spaces + /// (e.g. the nick-column rail " │ "). When set, takes + /// precedence over . + /// + public List? ContinuationPrefixSegments { get; set; } + + /// + /// When set, this line is a horizontal separator rule (date change, unread marker). + /// The view regenerates it to the current viewport width instead of word-wrapping. + /// + public string? RuleLabel { get; set; } + + /// Color for a rule line; null falls back to . + public Attribute? RuleAttr { get; set; } + + /// True for the "new messages" unread-marker rule so it can be removed on channel switch. + public bool IsUnreadMarker { get; set; } + public ChatLine(string plainText) { Segments = [new ChatSegment(plainText, null)]; @@ -43,9 +62,14 @@ public partial class ChatLine /// public List Wrap(int width, int continuationIndent = 0) { - if (width <= 0 || TextLength <= width) + // Rules are regenerated to viewport width by the view; never word-wrap them. + if (RuleLabel is not null || width <= 0 || TextLength <= width) return [this]; + var prefixSegments = ContinuationPrefixSegments; + if (prefixSegments is not null) + continuationIndent = prefixSegments.Sum(s => s.Text.GetColumns()); + var tokens = new List<(string grapheme, Attribute? color)>(); foreach (var segment in Segments) foreach (var g in GraphemeHelper.GetGraphemes(segment.Text)) @@ -91,8 +115,13 @@ public partial class ChatLine } var segments = new List(); - if (!firstLine && continuationIndent > 0) - segments.Add(new ChatSegment(new string(' ', continuationIndent), null)); + if (!firstLine) + { + if (prefixSegments is not null) + segments.AddRange(prefixSegments); + else if (continuationIndent > 0) + segments.Add(new ChatSegment(new string(' ', continuationIndent), null)); + } // Rebuild segments by grouping consecutive same-color tokens. var sb = new StringBuilder(); @@ -120,6 +149,7 @@ public partial class ChatLine return [this]; // Propagate metadata to all wrapped lines so they remain clickable + // and keep the mention highlight across continuation lines foreach (var wrapped in results) { wrapped.AttachmentUrl = AttachmentUrl; @@ -127,6 +157,7 @@ public partial class ChatLine wrapped.AttachmentKind = AttachmentKind; wrapped.MessageId = MessageId; wrapped.SenderUsername = SenderUsername; + wrapped.IsMention = IsMention; } return results; diff --git a/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs b/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs index de4805c..b3efb59 100644 --- a/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs +++ b/src/EchoHub.Client/UI/Chat/ChatMessageManager.cs @@ -1,3 +1,4 @@ +using System.Text; using System.Text.RegularExpressions; using EchoHub.Client.UI.Helpers; using EchoHub.Core.DTOs; @@ -15,8 +16,18 @@ namespace EchoHub.Client.UI.Chat; /// public sealed class ChatMessageManager { + /// Columns reserved for the right-aligned nick column (WeeChat-style). + public const int NickColWidth = 12; + + /// Columns before message text starts: "HH:mm " + nick column + " │ ". + public const int ContentIndentCols = 6 + NickColWidth + 3; + private readonly Dictionary> _channelMessages = []; private readonly Dictionary _channelUnread = []; + private readonly Dictionary _channelLastDate = []; + private readonly HashSet _markedChannels = []; + private readonly Dictionary _markerAnchor = []; + private readonly HashSet _mentionChannels = []; private string _currentUser = string.Empty; private string _currentChannel = string.Empty; @@ -33,7 +44,16 @@ public sealed class ChatMessageManager public string CurrentChannel { get => _currentChannel; - set => _currentChannel = value; + set + { + if (_currentChannel == value) + return; + + // Leaving a channel consumes its "new messages" marker so the next + // unread burst gets a fresh one (irssi behavior). + RemoveUnreadMarker(_currentChannel); + _currentChannel = value; + } } public string CurrentUser => _currentUser; @@ -57,10 +77,14 @@ public sealed class ChatMessageManager public void ClearUnread(string channelName) { _channelUnread[channelName] = 0; + _mentionChannels.Remove(channelName); } internal Dictionary GetUnreadCounts() => _channelUnread; + /// Channels with an unread @mention of the current user (cleared by ). + public IReadOnlySet MentionChannels => _mentionChannels; + // ── Mutations ──────────────────────────────────────────────────── /// @@ -75,19 +99,36 @@ public sealed class ChatMessageManager _channelMessages[message.ChannelName] = messages; } + // Day boundary → horizontal date rule + var msgDate = message.SentAt.ToLocalTime().Date; + if (!_channelLastDate.TryGetValue(message.ChannelName, out var lastDate) || lastDate != msgDate) + { + messages.Add(DateRule(msgDate)); + _channelLastDate[message.ChannelName] = msgDate; + } + + var isCurrent = message.ChannelName == _currentChannel; + + // First unread message in an inactive channel → "new messages" marker, + // anchored to this message so a history reload can re-place it + if (!isCurrent && _markedChannels.Add(message.ChannelName)) + { + messages.Add(UnreadMarkerRule()); + _markerAnchor[message.ChannelName] = message.Id; + } + foreach (var line in lines) messages.Add(line); - if (message.ChannelName == _currentChannel) - { - MessagesChanged?.Invoke(message.ChannelName); - } - else + if (!isCurrent) { _channelUnread.TryGetValue(message.ChannelName, out var count); _channelUnread[message.ChannelName] = count + 1; - MessagesChanged?.Invoke(message.ChannelName); + if (lines.Any(l => l.IsMention)) + _mentionChannels.Add(message.ChannelName); } + + MessagesChanged?.Invoke(message.ChannelName); } /// @@ -101,24 +142,20 @@ public sealed class ChatMessageManager _channelMessages[channelName] = messages; } - var time = FormatDateTime(DateTimeOffset.Now); + var time = FormatTime(DateTimeOffset.Now); var textLines = text.Split('\n'); - messages.Add(new ChatLine( - [ - new($"[{time}] ", ChatColors.TimestampAttr), - new($"** {textLines[0].TrimEnd('\r')}", ChatColors.SystemAttr) - ])); + var header = SystemHeaderSegments(time); + header.Add(new(textLines[0].TrimEnd('\r'), ChatColors.SystemAttr)); + messages.Add(new ChatLine(header) { ContinuationPrefixSegments = RailPrefix() }); - 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) - ])); + var segments = RailPrefix(); + segments.Add(new(line, ChatColors.SystemAttr)); + messages.Add(new ChatLine(segments) { ContinuationPrefixSegments = RailPrefix() }); } if (channelName == _currentChannel) @@ -130,19 +167,16 @@ public sealed class ChatMessageManager /// public void AddStatusMessage(string channelName, string username, string status) { - var time = FormatDateTime(DateTimeOffset.Now); - var segments = new List - { - new($"[{time}] ", ChatColors.TimestampAttr), - new($"** {username} is now {status}", ChatColors.SystemAttr) - }; + var time = FormatTime(DateTimeOffset.Now); + var segments = SystemHeaderSegments(time); + segments.Add(new($"{username} is now {status}", ChatColors.SystemAttr)); if (!_channelMessages.TryGetValue(channelName, out var messages)) { messages = []; _channelMessages[channelName] = messages; } - messages.Add(new ChatLine(segments)); + messages.Add(new ChatLine(segments) { ContinuationPrefixSegments = RailPrefix() }); if (channelName == _currentChannel) MessagesChanged?.Invoke(channelName); @@ -169,6 +203,9 @@ public sealed class ChatMessageManager if (_channelMessages.TryGetValue(channelName, out var messages)) { messages.Clear(); + _channelLastDate.Remove(channelName); + _markedChannels.Remove(channelName); + _markerAnchor.Remove(channelName); if (channelName == _currentChannel) MessagesChanged?.Invoke(channelName); } @@ -179,9 +216,35 @@ public sealed class ChatMessageManager /// public void LoadHistory(string channelName, List messages) { - var formatted = messages.SelectMany(FormatMessage).ToList(); + var formatted = FormatWithDateRules(messages, out var lastDate); + + // Re-place the "new messages" marker at its anchor — channel selection + // reloads history, which would otherwise wipe the marker right when the + // user switches in to read the unread backlog. + if (_markedChannels.Contains(channelName)) + { + var anchorIdx = _markerAnchor.TryGetValue(channelName, out var anchorId) + ? formatted.FindIndex(l => l.MessageId == anchorId) + : -1; + if (anchorIdx >= 0) + { + formatted.Insert(anchorIdx, UnreadMarkerRule()); + } + else + { + // Anchor fell outside the fetched history window — drop the marker + _markedChannels.Remove(channelName); + _markerAnchor.Remove(channelName); + } + } + _channelMessages[channelName] = formatted; + if (lastDate is { } date) + _channelLastDate[channelName] = date; + else + _channelLastDate.Remove(channelName); + if (channelName == _currentChannel) MessagesChanged?.Invoke(channelName); } @@ -200,14 +263,23 @@ public sealed class ChatMessageManager .Select(l => l.MessageId!.Value) .ToHashSet(); - var newLines = olderMessages - .Where(m => !existingIds.Contains(m.Id)) - .SelectMany(FormatMessage) - .ToList(); + var fresh = olderMessages.Where(m => !existingIds.Contains(m.Id)).ToList(); + var newLines = FormatWithDateRules(fresh, out var lastBatchDate); if (newLines.Count == 0) return; + // The buffer's leading date rule is redundant when the prepended batch + // ends on the same day — the batch already carries that day's rule. + if (lastBatchDate is { } batchDate + && existing.Count > 0 + && existing[0].RuleLabel is { } label + && !existing[0].IsUnreadMarker + && label == DateRuleLabel(batchDate)) + { + existing.RemoveAt(0); + } + existing.InsertRange(0, newLines); if (channelName == _currentChannel) @@ -226,6 +298,10 @@ public sealed class ChatMessageManager { _channelMessages.Clear(); _channelUnread.Clear(); + _channelLastDate.Clear(); + _markedChannels.Clear(); + _markerAnchor.Clear(); + _mentionChannels.Clear(); _currentChannel = string.Empty; _currentUser = string.Empty; } @@ -234,12 +310,9 @@ public sealed class ChatMessageManager private List FormatMessage(MessageDto message) { - var time = FormatDateTime(message.SentAt); - var senderName = message.SenderUsername + ":"; - var senderColor = HexColorHelper.ParseHexColor(message.SenderNicknameColor); - - var indent = new string(' ', $"[{time}] {senderName} ".Length); - var pad = new string(' ', 7); + var time = FormatTime(message.SentAt); + var senderColor = HexColorHelper.ParseHexColor(message.SenderNicknameColor) + ?? NickColorHelper.GetAttribute(message.SenderUsername); var lines = new List(); var hasContent = !string.IsNullOrWhiteSpace(message.Content); @@ -250,25 +323,35 @@ public sealed class ChatMessageManager { var displayContent = EmojiHelper.ReplaceEmoji(message.Content); var contentLines = displayContent.Split('\n'); - lines.Add(BuildChatLineWithMentions(time, senderName, senderColor, $" {contentLines[0].TrimEnd('\r')}")); + + var header = HeaderSegments(time, message.SenderUsername, senderColor); + header.AddRange(ChatColors.SplitMentions(contentLines[0].TrimEnd('\r'))); + lines.Add(new ChatLine(header)); + for (int i = 1; i < contentLines.Length; i++) - lines.Add(new ChatLine(ChatColors.SplitMentions($"{indent}{contentLines[i].TrimEnd('\r')}"))); + { + var segments = RailPrefix(); + segments.AddRange(ChatColors.SplitMentions(contentLines[i].TrimEnd('\r'))); + lines.Add(new ChatLine(segments)); + } } else { var summary = attachments.Count switch { 0 => " ", - 1 => $" [{attachments[0].Kind.ToString().ToLowerInvariant()}]", - _ => $" [{attachments.Count} attachments]", + 1 => $"[{attachments[0].Kind.ToString().ToLowerInvariant()}]", + _ => $"[{attachments.Count} attachments]", }; - lines.Add(BuildChatLine(time, senderName, senderColor, summary)); + var header = HeaderSegments(time, message.SenderUsername, senderColor); + header.Add(new(summary, null)); + lines.Add(new ChatLine(header)); } foreach (var l in lines) - l.ContinuationIndent = indent.Length; + l.ContinuationPrefixSegments = RailPrefix(); - // One block per attachment + // One block per attachment — every block hangs off the nick-column rail foreach (var attachment in attachments) { switch (attachment.Kind) @@ -279,24 +362,27 @@ public sealed class ChatMessageManager foreach (var artLine in attachment.AsciiPreview.Split('\n')) { var trimmed = artLine.TrimEnd('\r'); - lines.Add(ChatLine.HasColorTags(trimmed) - ? ChatLine.FromColoredText(pad + trimmed) - : new ChatLine($"{pad}{trimmed}")); + var segments = RailPrefix(); + if (ChatLine.HasColorTags(trimmed)) + segments.AddRange(ChatLine.FromColoredText(trimmed).Segments); + else + segments.Add(new(trimmed, null)); + lines.Add(new ChatLine(segments)); } } - lines.Add(AttachmentActionLine(pad, + lines.Add(AttachmentActionLine( $"[↓ save original] {attachment.FileName} [{FormatFileSize(attachment.FileSize)}]", ChatColors.FileAttr, attachment)); break; case Core.Models.AttachmentKind.Audio: - lines.Add(AttachmentActionLine(pad, + lines.Add(AttachmentActionLine( $"♪ [Audio: {attachment.FileName}] [{FormatFileSize(attachment.FileSize)}]", ChatColors.AudioAttr, attachment)); break; default: - lines.Add(AttachmentActionLine(pad, + lines.Add(AttachmentActionLine( $"[File: {attachment.FileName}] [{FormatFileSize(attachment.FileSize)}]", ChatColors.FileAttr, attachment)); break; @@ -308,7 +394,7 @@ public sealed class ChatMessageManager { var chatWidth = _chatWidth > 0 ? _chatWidth : 80; foreach (var embed in message.Embeds) - lines.AddRange(FormatEmbed(embed, indent, chatWidth)); + lines.AddRange(FormatEmbed(embed, chatWidth)); } foreach (var line in lines) @@ -334,71 +420,142 @@ public sealed class ChatMessageManager /// Builds a clickable attachment line carrying the metadata the message list uses to /// route activation (play audio, download file, save original image). /// - private static ChatLine AttachmentActionLine(string pad, string text, Attribute color, AttachmentDto attachment) + private static ChatLine AttachmentActionLine(string text, Attribute color, AttachmentDto attachment) { - var line = new ChatLine(new List + var segments = RailPrefix(); + segments.Add(new(text, color)); + return new ChatLine(segments) { - new(pad, null), - new(text, color), - }); - line.AttachmentUrl = attachment.Url; - line.AttachmentFileName = attachment.FileName; - line.AttachmentKind = attachment.Kind; - return line; - } - - private static ChatLine BuildChatLine(string time, string senderName, Attribute? senderColor, string suffix) - { - var segments = new List - { - new($"[{time}] ", ChatColors.TimestampAttr), - new(senderName, senderColor), - new(suffix, null) + AttachmentUrl = attachment.Url, + AttachmentFileName = attachment.FileName, + AttachmentKind = attachment.Kind, + ContinuationPrefixSegments = RailPrefix(), }; - return new ChatLine(segments); } - private static ChatLine BuildChatLineColored(string time, string senderName, Attribute? senderColor, string suffix, Attribute suffixColor) + /// + /// Leading segments of a message header line: dim "HH:mm ", the right-aligned + /// nick column, and the " │ " rail. Message text follows at . + /// + private static List HeaderSegments(string time, string nick, Attribute? nickColor) => + [ + new($"{time} ", ChatColors.TimestampAttr), + new(PadNick(nick), nickColor), + new(" │ ", ChatColors.RailAttr), + ]; + + /// Header variant for system/status lines: "--" in the nick column. + private static List SystemHeaderSegments(string time) => + [ + new($"{time} ", ChatColors.TimestampAttr), + new(PadNick("--"), ChatColors.TimestampAttr), + new(" │ ", ChatColors.RailAttr), + ]; + + /// + /// Indent segments aligning continuation/attachment/embed lines under the message + /// text, extending the │ rail. Returns a fresh mutable list each call. + /// + private static List RailPrefix() => + [ + new(new string(' ', 6 + NickColWidth + 1), null), + new("│ ", ChatColors.RailAttr), + ]; + + /// + /// Right-aligns a nick into the fixed nick column, truncating over-long nicks + /// with an ellipsis. Grapheme/column aware. + /// + internal static string PadNick(string nick) { - var segments = new List + var cols = nick.GetColumns(); + if (cols > NickColWidth) { - new($"[{time}] ", ChatColors.TimestampAttr), - new(senderName, senderColor), - new(suffix, suffixColor) - }; - return new ChatLine(segments); + var sb = new StringBuilder(); + int used = 0; + foreach (var g in GraphemeHelper.GetGraphemes(nick)) + { + var gCols = Math.Max(g.GetColumns(), 1); + if (used + gCols > NickColWidth - 1) break; + sb.Append(g); + used += gCols; + } + sb.Append('…'); + nick = sb.ToString(); + cols = used + 1; + } + return new string(' ', NickColWidth - cols) + nick; } - private static ChatLine BuildChatLineWithMentions(string time, string senderName, Attribute? senderColor, string suffix) + internal static string DateRuleLabel(DateTime date) => date.ToString("ddd, MMM d yyyy"); + + private static ChatLine DateRule(DateTime date) { - var segments = new List + var label = DateRuleLabel(date); + return new ChatLine([new($"── {label} ──", ChatColors.DateRuleAttr)]) { - new($"[{time}] ", ChatColors.TimestampAttr), - new(senderName, senderColor), + RuleLabel = label, + RuleAttr = ChatColors.DateRuleAttr, }; - segments.AddRange(ChatColors.SplitMentions(suffix)); - return new ChatLine(segments); } - private static List FormatEmbed(EmbedDto embed, string indent, int chatWidth) + private static ChatLine UnreadMarkerRule() => + new([new("── new messages ──", ChatColors.UnreadMarkerAttr)]) + { + RuleLabel = "new messages", + RuleAttr = ChatColors.UnreadMarkerAttr, + IsUnreadMarker = true, + }; + + private void RemoveUnreadMarker(string channel) + { + if (string.IsNullOrEmpty(channel) || !_markedChannels.Remove(channel)) + return; + + _markerAnchor.Remove(channel); + if (_channelMessages.TryGetValue(channel, out var messages)) + messages.RemoveAll(l => l.IsUnreadMarker); + } + + /// + /// Formats a chronological batch of messages, inserting a date rule before the + /// first message and at every day boundary. Outputs the batch's last local date. + /// + private List FormatWithDateRules(List messages, out DateTime? lastDate) + { + var lines = new List(); + lastDate = null; + + foreach (var message in messages) + { + var date = message.SentAt.ToLocalTime().Date; + if (lastDate != date) + { + lines.Add(DateRule(date)); + lastDate = date; + } + lines.AddRange(FormatMessage(message)); + } + + return lines; + } + + private static List FormatEmbed(EmbedDto embed, int chatWidth) { var lines = new List(); const string border = "\u258f "; // ▏ + space const int borderCols = 2; - int indentCols = indent.GetColumns(); - int textWidth = chatWidth - indentCols - borderCols; + int textWidth = chatWidth - ContentIndentCols - borderCols; if (textWidth < 20) textWidth = 20; var borderAttr = HexColorHelper.ParseHexColor(embed.ThemeColor) ?? ChatColors.EmbedBorderAttr; void AddTextLine(string text, Attribute? color) { - lines.Add(new ChatLine( - [ - new ChatSegment(indent, null), - new ChatSegment(border, borderAttr), - new ChatSegment(text, color) - ])); + var segments = RailPrefix(); + segments.Add(new ChatSegment(border, borderAttr)); + segments.Add(new ChatSegment(text, color)); + lines.Add(new ChatLine(segments)); } if (!string.IsNullOrWhiteSpace(embed.SiteName)) @@ -449,20 +606,11 @@ public sealed class ChatMessageManager return result; } - private static string FormatDateTime(DateTimeOffset timestamp) - { - // Server timestamps arrive in UTC; convert to local before deciding the calendar day, - // otherwise a "today" message near midnight is misclassified against the local date. - var local = timestamp.ToLocalTime(); - - // Today's messages show a compact date + short time; older messages fall back to the - // culture's general short date/time. Both always include the date so new messages - // are never left date-less. - if (local.Date == DateTimeOffset.Now.Date) - return $"{local:d} {local:t}"; - - return local.ToString("g"); - } + // Timestamps are compact HH:mm — the calendar day is carried by date rules, + // inserted at every local-day boundary. Convert to local first so a message + // near midnight lands under the right date rule. + private static string FormatTime(DateTimeOffset timestamp) => + timestamp.ToLocalTime().ToString("HH:mm"); internal static string FormatFileSize(long? bytes) { diff --git a/src/EchoHub.Client/UI/Chat/WelcomeBanner.cs b/src/EchoHub.Client/UI/Chat/WelcomeBanner.cs new file mode 100644 index 0000000..1555cfd --- /dev/null +++ b/src/EchoHub.Client/UI/Chat/WelcomeBanner.cs @@ -0,0 +1,103 @@ +using Terminal.Gui.Drawing; +using Terminal.Gui.Text; +using Attribute = Terminal.Gui.Drawing.Attribute; + +namespace EchoHub.Client.UI.Chat; + +/// +/// The MOTD-style splash rendered into the chat pane when no channel is selected — +/// a gold-gradient ASCII logo with version and key hints, in the spirit of classic +/// IRC client greetings. +/// +internal static class WelcomeBanner +{ + // "ECHOHUB" in FIGlet ANSI-Shadow (58 columns) + private static readonly string[] BigLogo = + [ + "███████╗ ██████╗██╗ ██╗ ██████╗ ██╗ ██╗██╗ ██╗██████╗ ", + "██╔════╝██╔════╝██║ ██║██╔═══██╗██║ ██║██║ ██║██╔══██╗", + "█████╗ ██║ ███████║██║ ██║███████║██║ ██║██████╔╝", + "██╔══╝ ██║ ██╔══██║██║ ██║██╔══██║██║ ██║██╔══██╗", + "███████╗╚██████╗██║ ██║╚██████╔╝██║ ██║╚██████╔╝██████╔╝", + "╚══════╝ ╚═════╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ", + ]; + + // Compact box-drawing fallback for narrow panes (21 columns) + private static readonly string[] SmallLogo = + [ + "┌─┐┌─┐┬ ┬┌─┐┬ ┬┬ ┬┌┐ ", + "├┤ │ ├─┤│ │├─┤│ │├┴┐", + "└─┘└─┘┴ ┴└─┘┴ ┴└─┘└─┘", + ]; + + // Vertical gold gradient, bright at the top fading to bronze — matches the + // EchoHub brand color used in the status bar. + private static readonly Color[] Gradient = + [ + new(255, 215, 105), + new(245, 199, 89), + new(232, 183, 74), + new(216, 165, 60), + new(198, 146, 48), + new(178, 128, 38), + ]; + + private static readonly Attribute HintKeyAttr = new(new Color(140, 170, 200), Color.None); + private static readonly Attribute HintTextAttr = new(new Color(120, 120, 120), Color.None); + private static readonly Attribute TaglineAttr = new(new Color(160, 160, 160), Color.None); + + private static readonly (string Key, string Hint)[] Hints = + [ + ("Server → Connect", "join a server"), + ("Ctrl+K ", "search channels & users"), + ("F2 ", "toggle the users panel"), + ("/help ", "all commands"), + ]; + + /// + /// Builds banner lines centered for the given viewport width. + /// + public static List Build(int width, string version) + { + var logo = width >= BigLogo[0].GetColumns() + 2 ? BigLogo : SmallLogo; + int logoWidth = logo[0].GetColumns(); + var pad = new string(' ', Math.Max((width - logoWidth) / 2, 0)); + + var lines = new List { new(""), new("") }; + + for (int i = 0; i < logo.Length; i++) + { + // Scale the gradient across however many rows the chosen logo has + var color = Gradient[Math.Min(i * Gradient.Length / logo.Length, Gradient.Length - 1)]; + lines.Add(new ChatLine([ + new ChatSegment(pad, null), + new ChatSegment(logo[i], new Attribute(color, Color.None)), + ])); + } + + lines.Add(new ChatLine("")); + + var tagline = $"v{version} — terminal chat with that old IRC soul"; + lines.Add(Centered(tagline, width, TaglineAttr)); + lines.Add(new ChatLine("")); + + int hintWidth = Hints.Max(h => h.Key.Length + 2 + h.Hint.Length); + var hintPad = new string(' ', Math.Max((width - hintWidth) / 2, 0)); + foreach (var (key, hint) in Hints) + { + lines.Add(new ChatLine([ + new ChatSegment(hintPad, null), + new ChatSegment(key, HintKeyAttr), + new ChatSegment(" " + hint, HintTextAttr), + ])); + } + + return lines; + } + + private static ChatLine Centered(string text, int width, Attribute attr) + { + var pad = new string(' ', Math.Max((width - text.GetColumns()) / 2, 0)); + return new ChatLine([new ChatSegment(pad, null), new ChatSegment(text, attr)]); + } +} diff --git a/src/EchoHub.Client/UI/Helpers/NickColorHelper.cs b/src/EchoHub.Client/UI/Helpers/NickColorHelper.cs new file mode 100644 index 0000000..f6c9218 --- /dev/null +++ b/src/EchoHub.Client/UI/Helpers/NickColorHelper.cs @@ -0,0 +1,60 @@ +using Terminal.Gui.Drawing; +using Attribute = Terminal.Gui.Drawing.Attribute; + +namespace EchoHub.Client.UI.Helpers; + +/// +/// Deterministic per-nick colors for users who haven't picked a nickname color. +/// The same nick always maps to the same palette entry (classic IRC client behavior), +/// so a busy channel stays scannable without any configuration. +/// +public static class NickColorHelper +{ + // Medium-saturation truecolor values chosen to stay readable on both dark and + // light backgrounds. Order matters: changing it re-colors everyone. + private static readonly Attribute[] Palette = + [ + new(new Color(224, 108, 117), Color.None), // soft red + new(new Color(152, 195, 121), Color.None), // green + new(new Color(229, 192, 123), Color.None), // sand + new(new Color(97, 175, 239), Color.None), // blue + new(new Color(198, 120, 221), Color.None), // magenta + new(new Color(86, 182, 194), Color.None), // teal + new(new Color(255, 160, 122), Color.None), // salmon + new(new Color(130, 170, 255), Color.None), // periwinkle + new(new Color(195, 232, 141), Color.None), // lime + new(new Color(137, 221, 255), Color.None), // sky + new(new Color(255, 203, 107), Color.None), // amber + new(new Color(240, 130, 170), Color.None), // rose + ]; + + /// + /// Stable palette index for a nick: case-insensitive FNV-1a over the nick, + /// reduced modulo . Pure function (no Terminal.Gui + /// types) so it is unit-testable without a display driver. + /// + public static int GetPaletteIndex(string nick, int paletteSize) + { + if (paletteSize <= 0) + return 0; + + const uint fnvOffset = 2166136261; + const uint fnvPrime = 16777619; + + uint hash = fnvOffset; + foreach (var ch in nick) + { + hash ^= char.ToLowerInvariant(ch); + hash *= fnvPrime; + } + + return (int)(hash % (uint)paletteSize); + } + + /// + /// The color attribute for a nick. Used as a fallback when the user has no + /// explicit nickname color set. + /// + public static Attribute GetAttribute(string nick) => + Palette[GetPaletteIndex(nick, Palette.Length)]; +} diff --git a/src/EchoHub.Client/UI/ListSources/ChannelListSource.cs b/src/EchoHub.Client/UI/ListSources/ChannelListSource.cs index 0b53d8c..5079c76 100644 --- a/src/EchoHub.Client/UI/ListSources/ChannelListSource.cs +++ b/src/EchoHub.Client/UI/ListSources/ChannelListSource.cs @@ -17,6 +17,7 @@ public class ChannelListSource : IListDataSource private readonly List _channelNames = []; private readonly Dictionary _unreadCounts = []; private readonly HashSet _protectedChannels = []; + private readonly HashSet _mentionChannels = []; private string _activeChannel = string.Empty; public event NotifyCollectionChangedEventHandler? CollectionChanged; @@ -28,9 +29,10 @@ public class ChannelListSource : IListDataSource private static readonly Attribute UnreadAttr = new(Color.BrightCyan, Color.None); private static readonly Attribute NormalAttr = new(Color.DarkGray, Color.None); private static readonly Attribute BadgeAttr = new(Color.BrightYellow, Color.None); + private static readonly Attribute MentionAttr = new(new Color(230, 140, 60), Color.None); public void Update(List channels, Dictionary unread, string activeChannel, - IReadOnlySet? protectedChannels = null) + IReadOnlySet? protectedChannels = null, IReadOnlySet? mentionChannels = null) { _channelNames.Clear(); _channelNames.AddRange(channels); @@ -40,6 +42,9 @@ public class ChannelListSource : IListDataSource _protectedChannels.Clear(); if (protectedChannels is not null) _protectedChannels.UnionWith(protectedChannels); + _mentionChannels.Clear(); + if (mentionChannels is not null) + _mentionChannels.UnionWith(mentionChannels); _activeChannel = activeChannel; MaxItemLength = channels.Count > 0 ? channels.Max(c => c.Length + 6) : 0; if (!SuspendCollectionChangedEvent) @@ -82,12 +87,18 @@ public class ChannelListSource : IListDataSource listView.SetAttribute(Resolve(isActive ? ActiveAttr : NormalAttr)); drawnChars = RenderHelpers.WriteText(listView, prefix, drawnChars, width); - listView.SetAttribute(Resolve(isActive ? ActiveAttr : hasUnread ? UnreadAttr : NormalAttr)); + // Mentions escalate above plain unread: the whole entry turns orange + var hasMention = _mentionChannels.Contains(name); + var nameAttr = isActive ? ActiveAttr + : hasMention ? MentionAttr + : hasUnread ? UnreadAttr + : NormalAttr; + listView.SetAttribute(Resolve(nameAttr)); drawnChars = RenderHelpers.WriteText(listView, channelText, drawnChars, width); if (hasUnread) { - listView.SetAttribute(Resolve(BadgeAttr)); + listView.SetAttribute(Resolve(hasMention ? MentionAttr : BadgeAttr)); drawnChars = RenderHelpers.WriteText(listView, badge, drawnChars, width); } } diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 8f18f68..a332de6 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -319,6 +319,12 @@ public sealed partial class MainWindow : Runnable _statusLabel.DrawingContent += OnStatusBarDrawContent; Add(_statusLabel); + // Rounded borders for a softer, modern frame look + channelsFrame.BorderStyle = LineStyle.Rounded; + _chatFrame.BorderStyle = LineStyle.Rounded; + _inputFrame.BorderStyle = LineStyle.Rounded; + _usersFrame.BorderStyle = LineStyle.Rounded; + // Apply our custom color schemes to all views ApplyColorSchemes(); @@ -1016,6 +1022,7 @@ public sealed partial class MainWindow : Runnable public void UpdateStatusBar(string status) { _connectionStatus = status; + UpdateSpinner(); _statusLabel.SetNeedsDraw(); } @@ -1023,6 +1030,38 @@ public sealed partial class MainWindow : Runnable private static readonly Attribute StatusDisconnectedAttr = new(new Color(220, 50, 50), Color.None); private static readonly Attribute StatusTransitionalAttr = new(new Color(220, 180, 0), Color.None); private static readonly Attribute StatusBrandAttr = new(new Color(218, 165, 32), Color.None); + private static readonly Attribute StatusActivityAttr = new(new Color(80, 200, 220), Color.None); + private static readonly Attribute StatusMentionAttr = new(new Color(230, 140, 60), Color.None); + + // Braille spinner shown while the connection is in a transitional state + private static readonly string[] SpinnerFrames = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; + private object? _spinnerToken; + private int _spinnerFrame; + + private bool IsTransitionalStatus => _connectionStatus is not ("Connected" or "Disconnected"); + + /// + /// Starts the spinner timer when entering a transitional connection state + /// (Connecting, Reconnecting, …); the timer stops itself once the state settles. + /// + private void UpdateSpinner() + { + if (!IsTransitionalStatus || _spinnerToken is not null) + return; + + _spinnerToken = _app.AddTimeout(TimeSpan.FromMilliseconds(120), () => + { + if (!IsTransitionalStatus) + { + _spinnerToken = null; + return false; + } + + _spinnerFrame = (_spinnerFrame + 1) % SpinnerFrames.Length; + _statusLabel.SetNeedsDraw(); + return true; + }); + } private void OnStatusBarDrawContent(object? sender, DrawEventArgs e) { @@ -1054,13 +1093,15 @@ public sealed partial class MainWindow : Runnable Write(" EchoHub", Resolve(StatusBrandAttr)); Write($" \u2502 v{AppVersion} \u2502 ", normalAttr); - // Connection state with color + // Connection state with color; transitional states get an animated spinner var statusAttr = _connectionStatus switch { "Connected" => StatusConnectedAttr, "Disconnected" => StatusDisconnectedAttr, _ => StatusTransitionalAttr // Connecting, Reconnecting, Authenticating, etc. }; + if (IsTransitionalStatus) + Write($"{SpinnerFrames[_spinnerFrame]} ", statusAttr); Write(_connectionStatus, Resolve(statusAttr)); // User @@ -1079,6 +1120,28 @@ public sealed partial class MainWindow : Runnable Write($" \u2502 #{currentChannel} - {typeSuffix}", normalAttr); } + // Activity segment (irssi-style): channels with unread messages, + // mention-channels highlighted in orange + var activity = _messageManager.GetUnreadCounts() + .Where(kv => kv.Value > 0) + .Select(kv => kv.Key) + .OrderBy(n => n, StringComparer.OrdinalIgnoreCase) + .ToList(); + if (activity.Count > 0) + { + const int maxShown = 4; + Write(" \u2502 Act: ", normalAttr); + var mentions = _messageManager.MentionChannels; + for (int i = 0; i < activity.Count && i < maxShown; i++) + { + if (i > 0) + Write(",", normalAttr); + Write($"#{activity[i]}", mentions.Contains(activity[i]) ? StatusMentionAttr : StatusActivityAttr); + } + if (activity.Count > maxShown) + Write($" +{activity.Count - maxShown}", normalAttr); + } + // Fill remaining space _statusLabel.SetAttribute(normalAttr); while (col < width) @@ -1181,24 +1244,29 @@ public sealed partial class MainWindow : Runnable private void RefreshMessages() { + var width = _messageList.Viewport.Width; + + // Update cached width when viewport reports a valid value; + // fall back to last known width if viewport hasn't been laid out yet. + if (width > 0) + _lastChatWidth = width; + else + width = _lastChatWidth; + var messages = _messageManager.GetMessages(_messageManager.CurrentChannel); if (messages is not null) { - var width = _messageList.Viewport.Width; - - // Update cached width when viewport reports a valid value; - // fall back to last known width if viewport hasn't been laid out yet. - if (width > 0) - _lastChatWidth = width; - else - width = _lastChatWidth; - var source = new ChatListSource(); if (width > 0) { foreach (var line in messages) - source.AddRange(line.Wrap(width, line.ContinuationIndent)); + { + if (line.RuleLabel is not null) + source.Add(ExpandRule(line, width)); + else + source.AddRange(line.Wrap(width, line.ContinuationIndent)); + } } else { @@ -1211,16 +1279,36 @@ public sealed partial class MainWindow : Runnable } else { - _messageList.Source = new ChatListSource(); + // No channel selected — greet with the MOTD-style splash + var source = new ChatListSource(); + if (width > 0) + source.AddRange(WelcomeBanner.Build(width, AppVersion)); + _messageList.Source = source; } } + /// + /// Regenerates a separator rule (date change / unread marker) to span the + /// current viewport width: "── label ────────…". + /// + private static ChatLine ExpandRule(ChatLine line, int width) + { + var attr = line.RuleAttr ?? ChatColors.DateRuleAttr; + var label = line.RuleLabel!; + var tailLen = Math.Max(width - 4 - label.GetColumns() - 1, 2); + return new ChatLine([new ChatSegment($"── {label} {new string('─', tailLen)}", attr)]) + { + IsUnreadMarker = line.IsUnreadMarker, + }; + } + /// /// Refresh the channel list view, showing unread counts next to channel names. /// private void RefreshChannelList() { - _channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel, _channelProtected); + _channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel, + _channelProtected, _messageManager.MentionChannels); _channelList.Source = _channelListSource; // Restore selection to current channel @@ -1296,8 +1384,11 @@ public sealed partial class MainWindow : Runnable var text = roleTag.Length > 0 ? $"{statusIcon} {roleTag} {name}" : $"{statusIcon} {name}"; - var nameColor = HexColorHelper.ParseHexColor(u.NicknameColor); - return (text, nameColor, u.Username); + // Fall back to the deterministic per-nick palette so user-list colors + // match the same user's messages in chat. + var nameColor = HexColorHelper.ParseHexColor(u.NicknameColor) + ?? NickColorHelper.GetAttribute(u.Username); + return (text, (Attribute?)nameColor, u.Username); }).ToList(); _usersListSource.Update(displayItems); diff --git a/src/EchoHub.Tests/NickColorHelperTests.cs b/src/EchoHub.Tests/NickColorHelperTests.cs new file mode 100644 index 0000000..26d0c82 --- /dev/null +++ b/src/EchoHub.Tests/NickColorHelperTests.cs @@ -0,0 +1,55 @@ +using EchoHub.Client.UI.Helpers; +using Xunit; + +namespace EchoHub.Tests; + +/// +/// Tests for the deterministic nick→palette-index hash. +/// Note: GetAttribute (Terminal.Gui Attribute) is excluded — Terminal.Gui's module +/// initializer requires a display driver unavailable in CI. +/// +public class NickColorHelperTests +{ + [Fact] + public void GetPaletteIndex_SameNick_IsStable() + { + var first = NickColorHelper.GetPaletteIndex("alice", 12); + var second = NickColorHelper.GetPaletteIndex("alice", 12); + Assert.Equal(first, second); + } + + [Fact] + public void GetPaletteIndex_IsCaseInsensitive() + { + Assert.Equal( + NickColorHelper.GetPaletteIndex("Alice", 12), + NickColorHelper.GetPaletteIndex("aLICE", 12)); + } + + [Theory] + [InlineData("alice")] + [InlineData("bob")] + [InlineData("charlie_long_nickname")] + [InlineData("")] + [InlineData("émile")] + public void GetPaletteIndex_AlwaysWithinRange(string nick) + { + var index = NickColorHelper.GetPaletteIndex(nick, 12); + Assert.InRange(index, 0, 11); + } + + [Fact] + public void GetPaletteIndex_DistributesAcrossPalette() + { + // Not a strict uniformity test — just that the hash isn't degenerate + var nicks = new[] { "alice", "bob", "carol", "dave", "erin", "frank", "grace", "heidi" }; + var distinct = nicks.Select(n => NickColorHelper.GetPaletteIndex(n, 12)).Distinct().Count(); + Assert.True(distinct >= 3, $"Expected at least 3 distinct palette slots, got {distinct}"); + } + + [Fact] + public void GetPaletteIndex_NonPositivePaletteSize_ReturnsZero() + { + Assert.Equal(0, NickColorHelper.GetPaletteIndex("alice", 0)); + } +}