feat: Enhance chat UI with topic display and command autocomplete functionality

This commit is contained in:
HueByte
2026-02-19 05:50:24 +01:00
parent 0c2a94ef17
commit b45b4c3139
4 changed files with 262 additions and 106 deletions
+5 -1
View File
@@ -203,7 +203,11 @@ public sealed class AppOrchestrator : IDisposable
try
{
await _apiClient!.UpdateChannelTopicAsync(channel, topic);
InvokeUI(() => _mainWindow.AddSystemMessage(channel, $"Topic set to: {topic}"));
InvokeUI(() =>
{
_mainWindow.SetChannelTopic(channel, topic);
_mainWindow.AddSystemMessage(channel, $"Topic set to: {topic}");
});
}
catch (Exception ex)
{
+9
View File
@@ -190,6 +190,15 @@ public class ChatListSource : IListDataSource
public void Dispose() { }
}
/// <summary>
/// Shared color attributes for chat rendering (timestamps, system messages).
/// </summary>
public static class ChatColors
{
public static readonly Attribute TimestampAttr = new(Color.DarkGray, Color.Black);
public static readonly Attribute SystemAttr = new(new Color(0, 180, 180), Color.Black);
}
/// <summary>
/// Helper to parse hex colors to Terminal.Gui Attributes.
/// </summary>
+166 -25
View File
@@ -22,15 +22,27 @@ public sealed class MainWindow : Runnable
private readonly TextView _inputField;
private readonly FrameView _chatFrame;
private readonly Label _statusLabel;
private readonly Label _topicLabel;
private MenuBar _menuBar;
// Cached Key constants — compare via .KeyCode to avoid Key.Equals (which also checks Handled)
private static readonly Key EnterKey = Key.Enter;
private static readonly Key AltEnterKey = Key.Enter.WithAlt;
private static readonly Key CtrlCKey = Key.C.WithCtrl;
private static readonly Key TabKey = Key.Tab;
// Available slash commands for Tab autocomplete
private static readonly string[] SlashCommands =
[
"/status", "/nick", "/color", "/theme", "/send",
"/profile", "/servers", "/join", "/leave", "/topic",
"/users", "/quit", "/help"
];
private readonly List<string> _channelNames = [];
private readonly Dictionary<string, List<ChatLine>> _channelMessages = [];
private readonly Dictionary<string, int> _channelUnread = [];
private readonly Dictionary<string, string?> _channelTopics = [];
private string _currentChannel = string.Empty;
private string _currentUser = string.Empty;
@@ -110,12 +122,24 @@ public sealed class MainWindow : Runnable
channelsFrame.Add(_channelList);
Add(channelsFrame);
// Topic bar — sits above the chat frame in the right column
_topicLabel = new Label
{
Text = "",
X = 25,
Y = 1,
Width = Dim.Fill(),
Height = 1,
Visible = false
};
Add(_topicLabel);
// Center panel - messages
_chatFrame = new FrameView
{
Title = "Chat",
X = 25,
Y = 1, // below menu bar
Y = 1, // below menu bar (shifts to 2 when topic is visible)
Width = Dim.Fill(),
Height = Dim.Fill(6) // leave room for input area and status bar
};
@@ -134,7 +158,7 @@ public sealed class MainWindow : Runnable
// Bottom input area
var inputFrame = new FrameView
{
Title = "Message (Enter=send, Alt+Enter=newline)",
Title = "Message (Enter=send, Tab=autocomplete)",
X = 25,
Y = Pos.Bottom(_chatFrame),
Width = Dim.Fill(),
@@ -168,7 +192,7 @@ public sealed class MainWindow : Runnable
// Apply our custom color schemes to all views
ApplyColorSchemes();
// Window-level key handling for Ctrl+S (send) and Ctrl+C (quit)
// Window-level key handling for Ctrl+C (quit)
KeyDown += OnWindowKeyDown;
}
@@ -188,7 +212,7 @@ public sealed class MainWindow : Runnable
// Propagate to all child views that should use the base scheme
foreach (var sub in SubViews)
{
if (sub != _menuBar && sub != _statusLabel)
if (sub != _menuBar && sub != _statusLabel && sub != _topicLabel)
sub.SetScheme(baseScheme);
}
}
@@ -197,6 +221,7 @@ public sealed class MainWindow : Runnable
{
_menuBar.SetScheme(menuScheme);
_statusLabel.SetScheme(menuScheme);
_topicLabel.SetScheme(menuScheme);
}
}
@@ -282,7 +307,12 @@ public sealed class MainWindow : Runnable
private void OnInputKeyDown(object? sender, Key e)
{
if (e.KeyCode == AltEnterKey.KeyCode)
if (e.KeyCode == TabKey.KeyCode)
{
TryAutocompleteCommand();
e.Handled = true;
}
else if (e.KeyCode == AltEnterKey.KeyCode)
{
_inputField.InsertText("\n");
e.Handled = true;
@@ -304,6 +334,40 @@ public sealed class MainWindow : Runnable
}
}
/// <summary>
/// Tab-complete slash commands in the input field.
/// </summary>
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;
}
}
private void OnWindowKeyDown(object? sender, Key e)
{
// Ctrl+C quits from anywhere
@@ -316,6 +380,7 @@ public sealed class MainWindow : Runnable
/// <summary>
/// Add a message to the specified channel's message list and refresh if it is the current channel.
/// Tracks unread count for non-active channels.
/// </summary>
public void AddMessage(MessageDto message)
{
@@ -335,20 +400,33 @@ public sealed class MainWindow : Runnable
{
RefreshMessages();
}
else
{
// Increment unread count for non-active channels
_channelUnread.TryGetValue(message.ChannelName, out var count);
_channelUnread[message.ChannelName] = count + 1;
RefreshChannelList();
}
}
/// <summary>
/// Add a system/informational message to a channel.
/// Add a system/informational message to a channel with colored styling.
/// </summary>
public void AddSystemMessage(string channelName, string text)
{
var formatted = $"[{DateTimeOffset.Now:HH:mm}] ** {text}";
var time = DateTimeOffset.Now.ToString("HH:mm");
var segments = new List<ChatSegment>
{
new($"[{time}] ", ChatColors.TimestampAttr),
new($"** {text}", ChatColors.SystemAttr)
};
if (!_channelMessages.TryGetValue(channelName, out var messages))
{
messages = [];
_channelMessages[channelName] = messages;
}
messages.Add(new ChatLine(formatted));
messages.Add(new ChatLine(segments));
if (channelName == _currentChannel)
{
@@ -357,17 +435,23 @@ public sealed class MainWindow : Runnable
}
/// <summary>
/// Add a status change message to a channel.
/// Add a status change message to a channel with colored styling.
/// </summary>
public void AddStatusMessage(string channelName, string username, string status)
{
var formatted = $"[{DateTimeOffset.Now:HH:mm}] ** {username} is now {status}";
var time = DateTimeOffset.Now.ToString("HH:mm");
var segments = new List<ChatSegment>
{
new($"[{time}] ", ChatColors.TimestampAttr),
new($"** {username} is now {status}", ChatColors.SystemAttr)
};
if (!_channelMessages.TryGetValue(channelName, out var messages))
{
messages = [];
_channelMessages[channelName] = messages;
}
messages.Add(new ChatLine(formatted));
messages.Add(new ChatLine(segments));
if (channelName == _currentChannel)
{
@@ -376,18 +460,30 @@ public sealed class MainWindow : Runnable
}
/// <summary>
/// Set the list of available channels and refresh the channel list view.
/// Set the list of available channels, storing topics, and refresh the channel list view.
/// </summary>
public void SetChannels(List<ChannelDto> channels)
{
_channelNames.Clear();
_channelTopics.Clear();
foreach (var ch in channels)
{
_channelNames.Add(ch.Name);
_channelTopics[ch.Name] = ch.Topic;
if (!_channelMessages.ContainsKey(ch.Name))
_channelMessages[ch.Name] = [];
}
_channelList.SetSource(new ObservableCollection<string>(_channelNames));
RefreshChannelList();
}
/// <summary>
/// Update the topic for a specific channel.
/// </summary>
public void SetChannelTopic(string channelName, string? topic)
{
_channelTopics[channelName] = topic;
if (channelName == _currentChannel)
UpdateTopicBar();
}
/// <summary>
@@ -427,13 +523,19 @@ public sealed class MainWindow : Runnable
public IReadOnlyList<string> GetChannelNames() => _channelNames.AsReadOnly();
/// <summary>
/// Switch the chat view to the given channel.
/// Switch the chat view to the given channel, resetting its unread count and updating the topic bar.
/// </summary>
public void SwitchToChannel(string channelName)
{
_currentChannel = channelName;
_chatFrame.Title = $"#{channelName}";
// Reset unread count for this channel
_channelUnread[channelName] = 0;
RefreshChannelList();
RefreshMessages();
UpdateTopicBar();
// Update channel list selection
var idx = _channelNames.IndexOf(channelName);
@@ -469,10 +571,14 @@ public sealed class MainWindow : Runnable
{
_channelNames.Clear();
_channelMessages.Clear();
_channelUnread.Clear();
_channelTopics.Clear();
_currentChannel = string.Empty;
_currentUser = string.Empty;
_channelList.SetSource(new ObservableCollection<string>(_channelNames));
_chatFrame.Title = "Chat";
_topicLabel.Visible = false;
_chatFrame.Y = 1;
RefreshMessages();
}
@@ -500,8 +606,47 @@ public sealed class MainWindow : Runnable
}
}
/// <summary>
/// Refresh the channel list view, showing unread counts next to channel names.
/// </summary>
private void RefreshChannelList()
{
var displayNames = _channelNames.Select(name =>
{
_channelUnread.TryGetValue(name, out var unread);
return unread > 0 ? $"#{name} ({unread})" : $"#{name}";
}).ToList();
_channelList.SetSource(new ObservableCollection<string>(displayNames));
// Restore selection to current channel
var idx = _channelNames.IndexOf(_currentChannel);
if (idx >= 0)
_channelList.SelectedItem = idx;
}
/// <summary>
/// Show or hide the topic bar based on the current channel's topic.
/// </summary>
private void UpdateTopicBar()
{
_channelTopics.TryGetValue(_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;
}
}
/// <summary>
/// Format a message DTO into one or more display lines based on its MessageType.
/// Timestamps are dimmed and sender names are colored.
/// </summary>
private static List<ChatLine> FormatMessage(MessageDto message)
{
@@ -514,7 +659,7 @@ public sealed class MainWindow : Runnable
switch (message.Type)
{
case MessageType.Image:
lines.Add(BuildChatLine($"[{time}] ", senderName, senderColor, " [Image]"));
lines.Add(BuildChatLine(time, senderName, senderColor, " [Image]"));
// Content IS the ASCII art — add each line as a separate list item
if (!string.IsNullOrWhiteSpace(message.Content))
{
@@ -533,16 +678,15 @@ public sealed class MainWindow : Runnable
case MessageType.File:
var fileName = message.AttachmentFileName ?? "unknown";
var fileContent = !string.IsNullOrWhiteSpace(message.Content) ? $" {message.Content}" : "";
lines.Add(BuildChatLine($"[{time}] ", senderName, senderColor, $" [File: {fileName}]{fileContent}"));
lines.Add(BuildChatLine(time, senderName, senderColor, $" [File: {fileName}]{fileContent}"));
break;
case MessageType.Text:
default:
var contentLines = message.Content.Split('\n');
var firstLine = contentLines[0].TrimEnd('\r');
lines.Add(BuildChatLine($"[{time}] ", senderName, senderColor, $" {firstLine}"));
lines.Add(BuildChatLine(time, senderName, senderColor, $" {firstLine}"));
// Continuation lines indented to align with first line's content
// Prefix is: [HH:mm] + space + senderName + space
var indent = new string(' ', $"[{time}] {senderName} ".Length);
for (int i = 1; i < contentLines.Length; i++)
{
@@ -555,17 +699,14 @@ public sealed class MainWindow : Runnable
}
/// <summary>
/// Build a chat line with an optionally colored sender name.
/// Build a chat line with a dimmed timestamp and optionally colored sender name.
/// </summary>
private static ChatLine BuildChatLine(string prefix, string senderName, Attribute? senderColor, string suffix)
private static ChatLine BuildChatLine(string time, string senderName, Attribute? senderColor, string suffix)
{
if (senderColor is null)
return new ChatLine($"{prefix}{senderName}{suffix}");
var segments = new List<ChatSegment>
{
new(prefix, null),
new(senderName, senderColor.Value),
new($"[{time}] ", ChatColors.TimestampAttr),
new(senderName, senderColor),
new(suffix, null)
};
return new ChatLine(segments);