using System.Diagnostics; using System.Text.RegularExpressions; using EchoHub.Client.Services; using EchoHub.Client.Themes; using EchoHub.Client.UI.Chat; using EchoHub.Client.UI.Helpers; using EchoHub.Client.UI.ListSources; using EchoHub.Core.DTOs; using EchoHub.Core.Models; using Serilog; using Terminal.Gui.App; using Terminal.Gui.Configuration; using Terminal.Gui.Drawing; using Terminal.Gui.Drivers; using Terminal.Gui.Input; using Terminal.Gui.Text; using Terminal.Gui.ViewBase; using Terminal.Gui.Views; using Attribute = Terminal.Gui.Drawing.Attribute; namespace EchoHub.Client.UI; /// /// Main Terminal.Gui window for the EchoHub chat client. /// public sealed partial class MainWindow : Runnable { private readonly IApplication _app; private readonly ListView _channelList; private readonly ListView _messageList; private readonly TextView _inputField; private readonly FrameView _chatFrame; private readonly FrameView _inputFrame; private readonly Label _statusLabel; private readonly Label _topicLabel; private MenuBar _menuBar; // Online users panel private readonly FrameView _usersFrame; private readonly ListView _usersList; private readonly UserListSource _usersListSource; private bool _usersPanelVisible = true; private const int UsersPanelWidth = 22; private const string DefaultInputTitle = "Message │ Enter=send │ Tab=complete │ Ctrl+K=search │ F6=pick message"; private const KeyCode F2Key = KeyCode.F2; private bool _hasStagedAttachments; internal static readonly string AppVersion = typeof(MainWindow).Assembly.GetName().Version?.ToString(3) ?? "?"; // Key bindings as KeyCode constants: comparing raw KeyCodes avoids Key.Equals (which also // checks Handled), and constants make them usable as switch case labels. private const KeyCode EnterKey = KeyCode.Enter; private const KeyCode NewlineKey = KeyCode.N | KeyCode.CtrlMask; private const KeyCode AltQKey = KeyCode.Q | KeyCode.AltMask; private const KeyCode TabKey = KeyCode.Tab; private const KeyCode CtrlKKey = KeyCode.K | KeyCode.CtrlMask; private const KeyCode CtrlVKey = KeyCode.V | KeyCode.CtrlMask; private const KeyCode CtrlXKey = KeyCode.X | KeyCode.CtrlMask; private const KeyCode CtrlCKey = KeyCode.C | KeyCode.CtrlMask; private const KeyCode CtrlYKey = KeyCode.Y | KeyCode.CtrlMask; private const KeyCode F6Key = KeyCode.F6; // Available slash commands for Tab autocomplete private static readonly string[] SlashCommands = [ "/status", "/nick", "/color", "/theme", "/send", "/me", "/banner", "/avatar", "/profile", "/servers", "/join", "/passwd", "/leave", "/clear", "/size", "/downloadpath", "/topic", "/users", "/kick", "/ban", "/unban", "/mute", "/unmute", "/role", "/invite", "/export", "/deleteaccount", "/nuke", "/test-sound", "/quit", "/help" ]; private readonly List _channelNames = []; private readonly Dictionary _channelTopics = []; private readonly Dictionary _channelPublic = []; private readonly HashSet _channelProtected = []; private readonly HashSet _systemChannels = []; private readonly ChannelListSource _channelListSource; private readonly ChatMessageManager _messageManager; private string _connectionStatus = "Disconnected"; private int _lastChatWidth; /// /// Fired when the user selects a channel. Parameter is the channel name. /// public event Action? OnChannelSelected; /// /// Fired when the user presses Enter in the input field. Parameters: channel name, message content. /// public event Action? OnMessageSubmitted; /// /// Fired when local files arrive via paste or drag-and-drop to be staged as attachments. /// Parameters: channel name, absolute paths of existing files. /// public event Action>? OnFilesStaged; /// /// Fired when raw image data is pasted from the clipboard (e.g. copied from a browser or a /// screenshot tool). Parameters: channel name, PNG-encoded image bytes. /// public event Action? OnImagePasted; /// /// Fired when the user requests to connect via the menu. /// public event Action? OnConnectRequested; /// /// Fired when the user requests to disconnect via the menu. /// public event Action? OnDisconnectRequested; /// /// Fired when the user requests to logout (disconnect + revoke session). /// public event Action? OnLogoutRequested; /// /// Fired when the user requests to open their profile panel. /// public event Action? OnProfileRequested; /// /// Fired when the user requests to set their status. /// public event Action? OnStatusRequested; /// /// Fired when the user selects a theme from the menu. Parameter is the theme name. /// public event Action? OnThemeSelected; /// /// Fired when the user requests to check for updates. /// public event Action? OnCheckForUpdatesRequested; /// /// Fired when the user requests to view saved servers. /// public event Action? OnSavedServersRequested; /// /// Fired when the user scrolls to the top of the message list and older messages should be loaded. /// public event Action? OnLoadMoreRequested; /// /// Fired when the user requests to create a new channel. /// public event Action? OnCreateChannelRequested; /// /// Fired when the user requests to delete the current channel. /// public event Action? OnDeleteChannelRequested; /// /// Fired when the user requests to rollback to the previous version. /// public event Action? OnRollbackRequested; /// /// Fired when the user activates (Enter/click) an audio message. Parameters: attachmentUrl, fileName. /// public event Action? OnAudioPlayRequested; /// /// Fired when the user activates (Enter/click) a file message. Parameters: attachmentUrl, fileName. /// public event Action? OnFileDownloadRequested; /// /// Fired when the user activates an image's "[save original]" line. Parameters: attachmentUrl, fileName. /// public event Action? OnImageSaveRequested; /// /// Fired when the user activates an image's "[open]" action to view it without saving. /// Parameters: attachmentUrl, fileName. /// public event Action? OnImageOpenRequested; /// /// Fired when the user presses Delete on the selected message. Parameter is the message id. /// public event Action? OnDeleteMessageRequested; /// /// Fired when the user picks "Reply" on a message. Parameters: message id, sender username, /// a short plain-text snippet for the reply strip. /// public event Action? OnReplyRequested; /// /// Fired when the user cancels a pending reply (Esc in the input field). /// public event Action? OnReplyCancelRequested; /// /// Fired when the user activates a username (in userlist or message). Parameter is the username. /// public event Action? OnUserProfileRequested; /// /// Fired when the user activates a #channel reference in a message. Parameter is the channel name. /// public event Action? OnChannelJoinRequested; /// /// Fired when the user requests to open the search dialog (via menu or Ctrl+K). /// public event Action? OnSearchRequested; public MainWindow(IApplication app, ChatMessageManager messageManager) { _app = app; _messageManager = messageManager; _messageManager.MessagesChanged += OnMessagesChanged; _messageManager.HistoryPrepended += OnHistoryPrepended; Arrangement = ViewArrangement.Fixed; // Menu bar at the top _menuBar = BuildMenuBar(); Add(_menuBar); // Left panel - channels var channelsFrame = new FrameView { Title = "Channels", X = 0, Y = 1, // below menu bar Width = 22, Height = Dim.Fill(1) // leave room for status bar }; _channelList = new ListView { X = 0, Y = 0, Width = Dim.Fill(), Height = Dim.Fill() }; _channelListSource = new ChannelListSource(); _channelList.Source = _channelListSource; _channelList.ValueChanged += OnChannelListSelectionChanged; channelsFrame.Add(_channelList); Add(channelsFrame); // Topic bar — sits above the chat frame in the right column _topicLabel = new Label { Text = "", X = 22, Y = 1, Width = Dim.Fill(UsersPanelWidth), Height = 1, Visible = false }; Add(_topicLabel); // Center panel - messages _chatFrame = new FrameView { Title = "Chat", X = 22, Y = 1, // below menu bar (shifts to 2 when topic is visible) Width = Dim.Fill(UsersPanelWidth), Height = Dim.Fill(6) // leave room for input area and status bar }; _messageList = new ListView { X = 0, Y = 0, Width = Dim.Fill(), Height = Dim.Fill() }; _messageList.Source = new ChatListSource(); _messageList.Accepting += OnMessageListAccepting; _messageList.KeyDown += OnMessageListKeyDown; _messageList.MouseEvent += OnMessageListMouseEvent; _messageList.VerticalScrollBar.Scrolled += OnMessageListVerticalScrollBarScrolled; _messageList.VerticalScrollBar.Visible = true; _chatFrame.Add(_messageList); Add(_chatFrame); // Bottom input area _inputFrame = new FrameView { Title = DefaultInputTitle, X = 22, Y = Pos.Bottom(_chatFrame), Width = Dim.Fill(UsersPanelWidth), Height = 5 }; _inputField = new TextView { X = 0, Y = 0, Width = Dim.Fill(), Height = Dim.Fill(), WordWrap = true }; // Terminal.Gui binds Ctrl+W to Command.Cut, whose OS clipboard write can throw // Win32Exception when another process holds the clipboard, crashing the app. // Rebind it to delete-word-backward (readline behavior), which never touches the clipboard. _inputField.KeyBindings.ReplaceCommands(Key.W.WithCtrl, Command.KillWordLeft); _inputField.KeyDown += OnInputKeyDown; _inputField.ContentsChanged += OnInputContentsChanged; _inputFrame.Add(_inputField); Add(_inputFrame); // Right panel - online users _usersFrame = new FrameView { Title = "Users", X = Pos.AnchorEnd(UsersPanelWidth), Y = 1, Width = UsersPanelWidth, Height = Dim.Fill(1) }; _usersList = new ListView { X = 0, Y = 0, Width = Dim.Fill(), Height = Dim.Fill() }; _usersListSource = new UserListSource(); _usersList.Source = _usersListSource; _usersList.Accepting += OnUsersListAccepting; _usersFrame.Add(_usersList); Add(_usersFrame); // Status bar at the very bottom — custom drawing for colored connection state _statusLabel = new Label { Text = "", X = 0, Y = Pos.AnchorEnd(1), Width = Dim.Fill(), Height = 1 }; _statusLabel.SetScheme(SchemeManager.GetScheme("Menu")); _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(); // Re-wrap messages when the chat area is resized // Subscribe to both ListView and FrameView viewport changes for reliable resize detection _messageList.ViewportChanged += (_, _) => OnChatViewportChanged(); _chatFrame.ViewportChanged += (_, _) => OnChatViewportChanged(); // Window-level key handling for Alt+Q (quit), F2 (toggle users panel) KeyDown += OnWindowKeyDown; } private string? _stagedTitleFragment; private string? _replyTitleFragment; /// /// Updates the attachment staging indicator shown on the input frame's title, including the /// current ASCII-art size for images. Passing an empty list restores the default hint. /// public void SetStagedAttachments(IReadOnlyList fileNames, string asciiSizeLabel) { _hasStagedAttachments = fileNames.Count > 0; if (fileNames.Count == 0) { _stagedTitleFragment = null; } else { var names = string.Join(", ", fileNames); if (names.Length > 45) names = names[..42] + "..."; _stagedTitleFragment = $"📎 {fileNames.Count}: {names} │ art: {asciiSizeLabel} (/size) │ Enter=send │ /clear"; } UpdateInputTitle(); } /// /// Shows/clears the "replying to" strip on the input frame's title. Pass null to clear. /// public void SetReplyingTo(string? label) { _replyTitleFragment = label is null ? null : $"↩ Replying to {label} │ Esc=cancel"; UpdateInputTitle(); } public bool HasPendingReplyIndicator => _replyTitleFragment is not null; private void UpdateInputTitle() { // Read-only channels (the live log room) override any reply/staged hint. if (IsCurrentChannelReadOnly) { _inputFrame.Title = "Read-only channel — you cannot type here"; _inputFrame.SetNeedsDraw(); return; } _inputFrame.Title = (_replyTitleFragment, _stagedTitleFragment) switch { (null, null) => DefaultInputTitle, ({ } reply, null) => reply, (null, { } staged) => staged, ({ } reply, { } staged) => $"{reply} │ {staged}", }; _inputFrame.SetNeedsDraw(); } /// /// Applies the currently registered color schemes to all views. /// Call after theme changes to refresh colors. /// public void ApplyColorSchemes() { var baseScheme = SchemeManager.GetScheme("Base"); var menuScheme = SchemeManager.GetScheme("Menu"); var borderScheme = SchemeManager.GetScheme("Border") ?? baseScheme; if (baseScheme is not null) { this.SetScheme(baseScheme); // Propagate to all child views that should use the base scheme foreach (var sub in SubViews) { if (sub != _menuBar && sub != _statusLabel && sub != _topicLabel) sub.SetScheme(baseScheme); // Frame borders (and their titles) take the theme's border colors, so // themes can tone them down independently of text (e.g. transparent // themes use dim gray instead of eye-catching white) if (sub is FrameView frame && borderScheme is not null) frame.Border?.SetScheme(borderScheme); } } if (menuScheme is not null) { _menuBar.SetScheme(menuScheme); _statusLabel.SetScheme(menuScheme); _topicLabel.SetScheme(menuScheme); } } /// /// Builds the menu bar with File, Server, User menus and a theme submenu. /// private MenuBar BuildMenuBar() { // Build theme menu items and prepend them with a separator header var themeItems = new List(); foreach (var t in Themes.ThemeManager.GetAvailableThemes()) { var name = t.Name; themeItems.Add(new MenuItem(name, "", () => OnThemeSelected?.Invoke(name), Key.Empty)); } // Combine user items with theme items, separated by a line var allUserItems = new List { new MenuItem("_My Profile", "Open your profile panel", () => OnProfileRequested?.Invoke(), Key.Empty), new MenuItem("Set _Status...", "Set your status", () => OnStatusRequested?.Invoke(), Key.Empty), new Line() }; allUserItems.AddRange(themeItems); var fileItems = new List(); if (UpdateBackupService.BackupExists()) { var info = UpdateBackupService.GetBackupInfo(); var label = info is not null ? $"_Rollback to v{info.Version}..." : "_Rollback Update..."; fileItems.Add(new MenuItem(label, "Restore previous version", () => OnRollbackRequested?.Invoke(), Key.Empty)); fileItems.Add(new Line()); } fileItems.Add(new MenuItem($"_Check for Updates", "Check for new version", () => OnCheckForUpdatesRequested?.Invoke(), Key.Empty)); fileItems.Add(new MenuItem("_Quit", "Quit EchoHub", () => _app.RequestStop(), Key.Empty)); var menuBar = new MenuBar( [ new MenuBarItem("_File", fileItems), new MenuBarItem("_Server", new View[] { new MenuItem("_Connect...", "Connect to a server", () => OnConnectRequested?.Invoke(), Key.Empty), new MenuItem("_Disconnect", "Disconnect from server", () => OnDisconnectRequested?.Invoke(), Key.Empty), new MenuItem("_Logout", "Logout and clear session", () => OnLogoutRequested?.Invoke(), Key.Empty), new Line(), new MenuItem("New C_hannel...", "Create a new channel", () => OnCreateChannelRequested?.Invoke(), Key.Empty), new MenuItem("_Delete Channel", "Delete the current channel", () => OnDeleteChannelRequested?.Invoke(), Key.Empty), new Line(), new MenuItem("_Saved Servers...", "View saved servers", () => OnSavedServersRequested?.Invoke(), Key.Empty), new Line(), new MenuItem("Toggle _Users Panel", "Toggle online users (F2)", () => ToggleUsersPanel(), Key.Empty) }), new MenuBarItem("_User", allUserItems) ]); menuBar.X = 0; menuBar.Y = 0; menuBar.Width = Dim.Fill(); // Workaround: Make CommandView (title text area) mouse-transparent on each MenuBarItem. // Without this, clicks on the text hit the CommandView sub-view whose Source propagates // as a plain View — MenuBar.OnAccepting checks "sourceView is MenuBarItem" which fails. // Making CommandView transparent lets clicks pass through to the MenuBarItem itself. foreach (var mbi in menuBar.SubViews.OfType()) { mbi.CommandView.ViewportSettings |= ViewportSettingsFlags.TransparentMouse; } return menuBar; } /// /// Rebuilds and replaces the menu bar (e.g., after theme list changes). /// public void RefreshMenuBar() { Remove(_menuBar); _menuBar = BuildMenuBar(); Add(_menuBar); ApplyColorSchemes(); SetNeedsDraw(); } private void OnChannelListSelectionChanged(object? sender, ValueChangedEventArgs e) { var index = e.NewValue; if (index.HasValue && index.Value >= 0 && index.Value < _channelNames.Count) { var channelName = _channelNames[index.Value]; if (channelName != _messageManager.CurrentChannel) { SwitchToChannel(channelName); OnChannelSelected?.Invoke(channelName); } } } private void OnMessageListAccepting(object? sender, CommandEventArgs e) { if (_messageList.Source is not ChatListSource source) return; var index = _messageList.SelectedItem; if (!index.HasValue || index.Value < 0 || index.Value >= source.Count) return; var line = source.GetLine(index.Value); if (line is null) return; // A reply's quote line jumps to the original message (if it's in the buffer) if (line.JumpToMessageId is { } jumpTarget) { ScrollToMessage(jumpTarget); e.Handled = true; return; } // Audio/file attachments take priority if (line.AttachmentUrl is not null && line.AttachmentFileName is not null) { if (line.AttachmentKind == AttachmentKind.Audio) { OnAudioPlayRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName); e.Handled = true; return; } if (line.AttachmentKind == AttachmentKind.File) { OnFileDownloadRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName); e.Handled = true; return; } if (line.AttachmentKind == AttachmentKind.Image) { // Keyboard/default activation opens the image for viewing; // saving is the mouse span or the context menu. OnImageOpenRequested?.Invoke(line.AttachmentUrl, line.AttachmentFileName); e.Handled = true; return; } } var lineText = line.ToString(); // Check for @mention — open mentioned user's profile // Negative lookbehind prevents matching emails (user@domain) var mentionMatch = ClickMentionRegex().Match(lineText); if (mentionMatch.Success) { OnUserProfileRequested?.Invoke(mentionMatch.Groups[1].Value); e.Handled = true; return; } // Check for #channel — join/switch to that channel // Require at least one letter to avoid matching hex colors (#ff0000) or issue numbers (#123) var channelMatch = ClickChannelRegex().Match(lineText); if (channelMatch.Success) { OnChannelJoinRequested?.Invoke(channelMatch.Groups[1].Value); e.Handled = true; return; } // Default: open sender's profile if (line.SenderUsername is not null) { OnUserProfileRequested?.Invoke(line.SenderUsername); e.Handled = true; } } private void OnMessageListKeyDown(object? sender, Key e) { // F6 returns focus to the input box. if (e.KeyCode == F6Key) { _inputField.SetFocus(); e.Handled = true; return; } if (e.KeyCode != Key.Delete.KeyCode && e.KeyCode != Key.Backspace.KeyCode) return; if (_messageList.Source is not ChatListSource source) return; var index = _messageList.SelectedItem; if (!index.HasValue || index.Value < 0 || index.Value >= source.Count) return; var line = source.GetLine(index.Value); if (line?.MessageId is not { } messageId) return; // Server enforces the real permission (own message, or Mod+ over a lower role); // the client just confirms intent and lets the server reject if disallowed. ConfirmDeleteMessage(messageId); e.Handled = true; } private void OnMessageListMouseEvent(object? sender, Mouse e) { var leftClick = e.Flags.HasFlag(MouseFlags.LeftButtonClicked); if (!leftClick && !e.Flags.HasFlag(MouseFlags.RightButtonClicked)) return; if (_messageList.Source is not ChatListSource source || source.Count == 0 || e.Position is not { } pos) return; var index = _messageList.TopItem + pos.Y; if (index < 0 || index >= source.Count) return; // Left-click only activates the "[open]" / "[save original]" brackets on an // attachment action line; anywhere else it falls through to normal selection. if (leftClick) { var clicked = source.GetLine(index); if (clicked?.ActionSpans is { } spans && clicked.AttachmentUrl is { } url && clicked.AttachmentFileName is { } name) { foreach (var span in spans) { if (pos.X < span.StartCol || pos.X > span.EndCol) continue; if (span.Action == AttachmentAction.OpenImage) OnImageOpenRequested?.Invoke(url, name); else OnImageSaveRequested?.Invoke(url, name); e.Handled = true; return; } } return; } // Select the right-clicked row (so the menu acts on it and it highlights), then show the menu. _messageList.SelectedItem = index; _messageList.SetFocus(); var line = source.GetLine(index); if (line is null) return; ShowMessageContextMenu(line, e.ScreenPosition); e.Handled = true; } /// /// Builds and shows a right-click context menu for a message line: attachment actions, /// mention/profile for the sender, copy, and delete (permission enforced server-side). /// private void ShowMessageContextMenu(ChatLine line, System.Drawing.Point screenPosition) { var items = new List(); var sender = line.SenderUsername; if (line.AttachmentKind is { } kind && line.AttachmentUrl is { } url && line.AttachmentFileName is { } name) { switch (kind) { case AttachmentKind.Image: items.Add(new MenuItem("Open image", "", () => OnImageOpenRequested?.Invoke(url, name), Key.Empty)); items.Add(new MenuItem("Save original image", "", () => OnImageSaveRequested?.Invoke(url, name), Key.Empty)); break; case AttachmentKind.Audio: items.Add(new MenuItem("Play audio", "", () => OnAudioPlayRequested?.Invoke(url, name), Key.Empty)); break; default: items.Add(new MenuItem("Download file", "", () => OnFileDownloadRequested?.Invoke(url, name), Key.Empty)); break; } } if (sender is not null && line.MessageId is { } replyTargetId) { items.Add(new MenuItem("Reply", "", () => { // Strip the "HH:mm nick │ " header so the strip shows just the text var snippet = line.ToString(); var railIdx = snippet.IndexOf(" │ ", StringComparison.Ordinal); if (railIdx >= 0) snippet = snippet[(railIdx + 3)..]; OnReplyRequested?.Invoke(replyTargetId, sender, snippet.Trim()); _inputField.SetFocus(); }, Key.Empty)); } if (sender is not null) { items.Add(new MenuItem($"Mention @{sender}", "", () => MentionUser(sender), Key.Empty)); items.Add(new MenuItem($"View {sender}'s profile", "", () => OnUserProfileRequested?.Invoke(sender), Key.Empty)); } items.Add(new MenuItem("Copy text", "", () => CopyToClipboard(line.ToString()), Key.Empty)); if (line.MessageId is { } messageId) { items.Add(new MenuItem("Copy message ID", "", () => CopyToClipboard(messageId.ToString()), Key.Empty)); items.Add(new Line()); items.Add(new MenuItem("Delete message", "", () => ConfirmDeleteMessage(messageId), Key.Empty)); } if (items.Count == 0) return; var menu = new PopoverMenu(items); _app.Popovers?.Register(menu); menu.MakeVisible(screenPosition); } private void MentionUser(string username) { _inputField.InsertText($"@{username} "); _inputField.SetFocus(); } private void CopyToClipboard(string text) { try { _app.Clipboard?.TrySetClipboardData(text); } catch (Exception ex) { Log.Warning(ex, "Copy to clipboard failed"); } } /// /// Scrolls the message list to a message's first line (used by reply quote lines). /// No-op when the message isn't in the loaded buffer. /// private void ScrollToMessage(Guid messageId) { if (_messageList.Source is not ChatListSource source) return; for (int i = 0; i < source.Count; i++) { var line = source.GetLine(i); // Match the message's own lines, not other replies' quote lines pointing at it if (line?.MessageId == messageId && line.JumpToMessageId is null) { _messageList.SelectedItem = i; _messageList.TopItem = Math.Max(0, i - 3); _messageList.SetFocus(); _messageList.SetNeedsDraw(); return; } } } private void ConfirmDeleteMessage(Guid messageId) { var confirm = MessageBox.Query(_app, "Delete Message", "Delete this message?", "Delete", "Cancel"); if (confirm == 0) OnDeleteMessageRequested?.Invoke(messageId); } private void OnMessageListVerticalScrollBarScrolled(object? sender, EventArgs e) { if (_messageList.VerticalScrollBar.Value == 0) OnLoadMoreRequested?.Invoke(); } private void OnUsersListAccepting(object? sender, CommandEventArgs e) { var index = _usersList.SelectedItem; if (!index.HasValue || index.Value < 0 || index.Value >= _usersListSource.Count) return; var username = _usersListSource.GetUsername(index.Value); if (username is not null) { OnUserProfileRequested?.Invoke(username); e.Handled = true; } } private void OnInputKeyDown(object? sender, Key e) { switch (e.KeyCode) { case TabKey: TryAutocompleteCommand(); break; case KeyCode.Esc when HasPendingReplyIndicator: OnReplyCancelRequested?.Invoke(); break; case NewlineKey: _inputField.InsertText("\n"); break; case EnterKey: if (IsCurrentChannelReadOnly) break; var text = _inputField.Text?.Trim() ?? string.Empty; // Send when there's text, or when only attachments are staged (empty caption). if ((!string.IsNullOrEmpty(text) || _hasStagedAttachments) && !string.IsNullOrEmpty(_messageManager.CurrentChannel)) { OnMessageSubmitted?.Invoke(_messageManager.CurrentChannel, text); _inputField.Text = string.Empty; } break; case AltQKey: _app.RequestStop(); break; case CtrlKKey: ShowSearchDialog(); break; case F6Key: // Move focus into the message list so you can select a message (arrows) and // delete it (Delete). F6 again returns focus here. (Esc is the app quit key.) FocusMessageList(); break; case CtrlVKey: case CtrlYKey: // Read-only channels can't receive text or attachments. if (IsCurrentChannelReadOnly) break; // Discord-style paste priority. Copied files in the OS file manager put a file // list (not text) on the clipboard — attach them all. Copied image data (browser // right-click copy, screenshot tools) is attached as a PNG. Otherwise paste text. // Terminals never deliver either of the first two as text, so this is the only path. if (ClipboardFiles.TryGetFiles(out var pastedFiles)) StageFiles(pastedFiles); else if (!string.IsNullOrEmpty(_messageManager.CurrentChannel) && ClipboardImage.TryGetPng(out var pastedPng)) OnImagePasted?.Invoke(_messageManager.CurrentChannel, pastedPng); else GuardedClipboardAction(() => _inputField.Paste(), "paste"); break; case CtrlXKey: GuardedClipboardAction(() => _inputField.Cut(), "cut"); break; case CtrlCKey: GuardedClipboardAction(() => _inputField.Copy(), "copy"); break; default: return; // not one of ours — leave e.Handled false so the key types normally } e.Handled = true; } /// /// Runs a clipboard-backed edit action, swallowing transient OS clipboard failures /// (e.g. another process holding the Windows clipboard) that would otherwise /// propagate out of the input loop and crash the app. /// private static void GuardedClipboardAction(Action action, string operation) { try { action(); } catch (Exception ex) { Log.Warning(ex, "Clipboard {Operation} failed", operation); } } private bool _suppressEmojiReplace; private void OnInputContentsChanged(object? sender, ContentsChangedEventArgs e) { if (_suppressEmojiReplace) return; var text = _inputField.Text; if (string.IsNullOrEmpty(text)) return; // A file dropped onto the terminal is delivered as its absolute path inserted into the // input — often character by character (this Terminal.Gui build has no bracketed-paste // coalescing). As soon as the input resolves to existing file path(s), route them // through /send (which stages them) instead of leaving a raw path to be sent as a message. if (DroppedFileParser.LooksLikePath(text) && DroppedFileParser.TryGetFiles(text, out var droppedFiles) && !string.IsNullOrEmpty(_messageManager.CurrentChannel)) { _suppressEmojiReplace = true; try { _inputField.Text = string.Empty; } finally { _suppressEmojiReplace = false; } StageFiles(droppedFiles); return; } var replaced = EmojiHelper.ReplaceEmoji(text); if (replaced == text) return; // Calculate where cursor should land after replacement var lengthDelta = replaced.Length - text.Length; var newCol = Math.Max(0, _inputField.CurrentColumn + lengthDelta); _suppressEmojiReplace = true; try { _inputField.Text = replaced; _inputField.InsertionPoint = new System.Drawing.Point(newCol, _inputField.CurrentRow); } finally { _suppressEmojiReplace = false; } } /// /// Tab-complete slash commands in the input field. /// private void TryAutocompleteCommand() { var text = _inputField.Text ?? string.Empty; if (!text.StartsWith('/') || text.Contains(' ')) return; var matches = SlashCommands .Where(c => c.StartsWith(text, StringComparison.OrdinalIgnoreCase)) .ToList(); if (matches.Count == 1) { _inputField.Text = matches[0] + " "; } else if (matches.Count > 1) { // Complete to longest common prefix var prefix = matches[0]; foreach (var m in matches.Skip(1)) { var len = 0; while (len < prefix.Length && len < m.Length && char.ToLowerInvariant(prefix[len]) == char.ToLowerInvariant(m[len])) len++; prefix = prefix[..len]; } if (prefix.Length > text.Length) _inputField.Text = prefix; } // Move cursor to end after autocomplete _inputField.InsertionPoint = new System.Drawing.Point(_inputField.Text?.Length ?? 0, 0); } /// /// Stages files (from a drop or a file-clipboard paste) as attachments in one batch; the /// next Enter sends them with any typed caption. /// private void StageFiles(IReadOnlyList files) { var channel = _messageManager.CurrentChannel; if (string.IsNullOrEmpty(channel)) return; OnFilesStaged?.Invoke(channel, files); } private void OnChatViewportChanged() { var newWidth = _messageList.Viewport.Width; if (newWidth > 0 && newWidth != _lastChatWidth) { _lastChatWidth = newWidth; _messageManager.SetChatWidth(newWidth); RefreshMessages(); } } private void OnWindowKeyDown(object? sender, Key e) { switch (e.KeyCode) { case AltQKey: _app.RequestStop(); break; case F2Key: ToggleUsersPanel(); break; case CtrlKKey: ShowSearchDialog(); break; default: return; } e.Handled = true; } private void ShowSearchDialog() { OnSearchRequested?.Invoke(); } private void OnMessagesChanged(string channelName) { if (channelName == _messageManager.CurrentChannel) RefreshMessages(); else RefreshChannelList(); // Background-channel activity feeds the status bar's Act segment _statusLabel.SetNeedsDraw(); } private void OnHistoryPrepended(string channelName) { if (channelName != _messageManager.CurrentChannel) return; var messages = _messageManager.GetMessages(channelName); if (messages is null) return; var oldCount = (_messageList.Source as ChatListSource)?.Count ?? 0; RefreshMessages(); // Scroll to the item that was at the top before the prepend so the user // stays at their previous reading position rather than jumping to the top. var prependedCount = (_messageList.Source as ChatListSource)?.Count - oldCount; if (prependedCount > 0) _messageList.SelectedItem = prependedCount; } /// /// Set the list of available channels, storing topics, and refresh the channel list view. /// public void SetChannels(List channels) { _channelNames.Clear(); _channelTopics.Clear(); _channelPublic.Clear(); _channelProtected.Clear(); _systemChannels.Clear(); foreach (var ch in channels) { _channelNames.Add(ch.Name); _channelTopics[ch.Name] = ch.Topic; _channelPublic[ch.Name] = ch.IsPublic; if (ch.IsProtected) _channelProtected.Add(ch.Name); if (ch.IsSystem) _systemChannels.Add(ch.Name); } RefreshChannelList(); } /// /// Ensure a channel exists in the left panel list (used for private channels joined via /join). /// public void EnsureChannelInList(string channelName, bool? isPublic = null, bool? isProtected = null, bool? isSystem = null) { if (isPublic.HasValue) _channelPublic[channelName] = isPublic.Value; if (isProtected.HasValue) { if (isProtected.Value) _channelProtected.Add(channelName); else _channelProtected.Remove(channelName); } if (isSystem.HasValue) { if (isSystem.Value) _systemChannels.Add(channelName); else _systemChannels.Remove(channelName); } if (_channelNames.Contains(channelName)) { if (isProtected.HasValue || isSystem.HasValue) RefreshChannelList(); return; } _channelNames.Add(channelName); RefreshChannelList(); } /// /// Remove a channel from the left panel list. /// public void RemoveChannel(string channelName) { _channelNames.Remove(channelName); _channelTopics.Remove(channelName); _channelPublic.Remove(channelName); _channelProtected.Remove(channelName); _systemChannels.Remove(channelName); RefreshChannelList(); } /// /// Update the topic for a specific channel. /// public void SetChannelTopic(string channelName, string? topic) { _channelTopics[channelName] = topic; if (channelName == _messageManager.CurrentChannel) UpdateTopicBar(); } /// /// Show an error message to the user. /// public void ShowError(string message) { MessageBox.ErrorQuery(_app, "Error", message, "OK"); } /// /// Update the connection status displayed in the status bar. /// public void UpdateStatusBar(string status) { _connectionStatus = status; UpdateSpinner(); _statusLabel.SetNeedsDraw(); } private static readonly Attribute StatusConnectedAttr = new(new Color(0, 200, 0), Color.None); 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) { var menuScheme = SchemeManager.GetScheme("Menu"); var normalAttr = menuScheme?.Normal ?? _statusLabel.GetAttributeForRole(VisualRole.Normal); var width = _statusLabel.Viewport.Width; if (width <= 0) return; // Resolve None background for colored segments var bg = normalAttr.Background; Attribute Resolve(Attribute a) => a.Background == Color.None ? a with { Background = bg } : a; int col = 0; void Write(string text, Attribute attr) { _statusLabel.SetAttribute(Resolve(attr)); foreach (var g in GraphemeHelper.GetGraphemes(text)) { var cols = Math.Max(g.GetColumns(), 1); if (col + cols > width) return; _statusLabel.Move(col, 0); _statusLabel.AddStr(g); col += cols; } } // EchoHub branding Write(" EchoHub", Resolve(StatusBrandAttr)); Write($" \u2502 v{AppVersion} \u2502 ", normalAttr); // 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 var currentUser = _messageManager.CurrentUser; if (!string.IsNullOrEmpty(currentUser)) Write($" \u2502 User: {currentUser}", normalAttr); // Channel + type var currentChannel = _messageManager.CurrentChannel; if (!string.IsNullOrEmpty(currentChannel)) { _channelPublic.TryGetValue(currentChannel, out var isPublic); var typeSuffix = isPublic ? "public" : "private"; if (_channelProtected.Contains(currentChannel)) typeSuffix += " +k"; 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) { _statusLabel.Move(col, 0); _statusLabel.AddStr(" "); col++; } e.Cancel = true; } /// /// Set the current user name (delegates to message manager for @mention detection). /// public void SetCurrentUser(string username) { _messageManager.SetCurrentUser(username); } /// /// Get the current channel name. /// public string CurrentChannel => _messageManager.CurrentChannel; /// /// Get all channel names that have message buffers (for broadcasting status changes). /// public IReadOnlyList GetChannelNames() => _channelNames.AsReadOnly(); /// /// Switch the chat view to the given channel, resetting its unread count and updating the topic bar. /// public void SwitchToChannel(string channelName) { _messageManager.CurrentChannel = channelName; _chatFrame.Title = $"#{channelName}"; _messageManager.ClearUnread(channelName); RefreshChannelList(); RefreshMessages(); UpdateTopicBar(); UpdateInputReadOnly(); _statusLabel.SetNeedsDraw(); // Update channel list selection var idx = _channelNames.IndexOf(channelName); if (idx >= 0) _channelList.SelectedItem = idx; } /// Whether the active channel is read-only (a system channel like the log room). private bool IsCurrentChannelReadOnly => _systemChannels.Contains(_messageManager.CurrentChannel); /// /// Disables the input for read-only (system) channels so nothing can be typed there, and /// reflects the state in the input frame title. /// private void UpdateInputReadOnly() { _inputField.ReadOnly = IsCurrentChannelReadOnly; UpdateInputTitle(); } /// /// Clear all messages and channels (used on disconnect). /// public void ClearAll() { _channelNames.Clear(); _messageManager.ClearAll(); _channelTopics.Clear(); _channelPublic.Clear(); _channelProtected.Clear(); _systemChannels.Clear(); _channelListSource.Update([], [], string.Empty); _channelList.Source = _channelListSource; _chatFrame.Title = "Chat"; _topicLabel.Visible = false; _chatFrame.Y = 1; _usersListSource.Update([]); _usersList.Source = _usersListSource; _usersFrame.Title = "Users"; RefreshMessages(); } /// /// Focus the input field for typing. /// public void FocusInput() { _inputField.SetFocus(); } /// /// Moves focus into the message list for selection (arrows) and deletion (Delete). Selects the /// most recent message when nothing is selected. No-op when the channel has no messages. /// private void FocusMessageList() { if (_messageList.Source is not ChatListSource source || source.Count == 0) return; if (!_messageList.SelectedItem.HasValue || _messageList.SelectedItem < 0 || _messageList.SelectedItem >= source.Count) { _messageList.SelectedItem = source.Count - 1; } _messageList.SetFocus(); _messageList.SetNeedsDraw(); } 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 source = new ChatListSource(); if (width > 0) { foreach (var line in messages) { if (line.RuleLabel is not null) source.Add(ExpandRule(line, width)); else source.AddRange(line.Wrap(width, line.ContinuationIndent)); } } else { source.AddRange(messages); } _messageList.Source = source; if (source.Count > 0) _messageList.SelectedItem = source.Count - 1; } else { // 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() { // Pin system channels (e.g. the live log room) to the very top, keeping the server's // relative order otherwise. OrderBy is stable, so alphabetical order is preserved within // each group. Reordering in place keeps _channelNames the source of truth for selection // lookups. System channels are private by nature but shouldn't get the private (~) glyph, // so exclude them from the private set. var ordered = _channelNames.OrderBy(n => _systemChannels.Contains(n) ? 0 : 1).ToList(); _channelNames.Clear(); _channelNames.AddRange(ordered); var privateChannels = _channelNames .Where(n => !_systemChannels.Contains(n) && _channelPublic.TryGetValue(n, out var isPublic) && !isPublic) .ToHashSet(); _channelListSource.Update(_channelNames, _messageManager.GetUnreadCounts(), _messageManager.CurrentChannel, _channelProtected, _messageManager.MentionChannels, privateChannels, _systemChannels); _channelList.Source = _channelListSource; // Restore selection to current channel var idx = _channelNames.IndexOf(_messageManager.CurrentChannel); if (idx >= 0) _channelList.SelectedItem = idx; } /// /// Show or hide the topic bar based on the current channel's topic. /// private void UpdateTopicBar() { _channelTopics.TryGetValue(_messageManager.CurrentChannel, out var topic); if (!string.IsNullOrWhiteSpace(topic)) { _topicLabel.Text = $" Topic: {topic}"; _topicLabel.Visible = true; _chatFrame.Y = 2; } else { _topicLabel.Visible = false; _chatFrame.Y = 1; } } /// /// Adjusts widths of chat, topic, and input frames based on users panel visibility. /// private void UpdateLayout() { var rightMargin = _usersPanelVisible ? UsersPanelWidth : 0; _chatFrame.Width = Dim.Fill(rightMargin); _topicLabel.Width = Dim.Fill(rightMargin); _inputFrame.Width = Dim.Fill(rightMargin); _usersFrame.Visible = _usersPanelVisible; SetNeedsDraw(); } /// /// Toggle the online users panel visibility (F2). /// public void ToggleUsersPanel() { _usersPanelVisible = !_usersPanelVisible; UpdateLayout(); } /// /// Update the online users list display. /// public void UpdateOnlineUsers(List users) { var displayItems = users.Select(u => { var statusIcon = u.Status switch { UserStatus.Online => "\u25cf", // ● UserStatus.Away => "\u25cb", // ○ UserStatus.DoNotDisturb => "\u25d0", // ◐ UserStatus.Invisible => "\u25cc", // ◌ _ => " " }; var name = u.DisplayName ?? u.Username; var roleTag = u.Role switch { ServerRole.Owner => "\u2605", // ★ ServerRole.Admin => "\u2666", // ♦ ServerRole.Mod => "\u2740", // ❀ _ => "" }; var text = roleTag.Length > 0 ? $"{statusIcon} {roleTag} {name}" : $"{statusIcon} {name}"; // Users connected only via the IRC gateway get a tag — they lack client features // (encryption, attachments, profiles), which is useful context in conversation. if (u.IsIrc) text += " [irc]"; // 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); _usersList.Source = _usersListSource; _usersFrame.Title = $"Users ({users.Count})"; } // @mention — not preceded by a word char (avoids emails) [GeneratedRegex(@"(?