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",
"/avatar", "/profile", "/servers", "/join", "/passwd", "/leave", "/clear", "/size", "/downloadpath",
"/topic", "/users", "/kick", "/ban", "/unban",
"/mute", "/unmute", "/role", "/nuke", "/test-sound", "/quit", "/help"
];
private readonly List _channelNames = [];
private readonly Dictionary _channelTopics = [];
private readonly Dictionary _channelPublic = [];
private readonly HashSet _channelProtected = [];
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 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;
}
///
/// 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)
{
_inputFrame.Title = DefaultInputTitle;
}
else
{
var names = string.Join(", ", fileNames);
if (names.Length > 45)
names = names[..42] + "...";
_inputFrame.Title = $"📎 {fileNames.Count}: {names} │ art: {asciiSizeLabel} (/size) │ Enter=send │ /clear";
}
_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