diff --git a/src/EchoHub.Client/UI/Dialogs/SearchDialog.cs b/src/EchoHub.Client/UI/Dialogs/SearchDialog.cs new file mode 100644 index 0000000..6f7b930 --- /dev/null +++ b/src/EchoHub.Client/UI/Dialogs/SearchDialog.cs @@ -0,0 +1,160 @@ +using EchoHub.Client.UI.ListSources; + +using System.Collections; +using System.Collections.Specialized; +using System.Diagnostics; + +using Terminal.Gui.App; +using Terminal.Gui.Drawing; +using Terminal.Gui.Input; +using Terminal.Gui.Text; +using Terminal.Gui.ViewBase; +using Terminal.Gui.Views; + +namespace EchoHub.Client.UI.Dialogs; + +public enum SearchResultType +{ + Channel, + Action +} + +public record SearchResult(SearchResultType Type, string Key, string Label); + +/// +/// Command-palette style search dialog (Ctrl+K) for navigating channels and triggering app actions. +/// +public static class SearchDialog +{ + private static readonly IReadOnlyList DefaultActions = [ + new(SearchResultType.Action, "connect", "Connect to Server"), + new(SearchResultType.Action, "disconnect", "Disconnect"), + new(SearchResultType.Action, "logout", "Logout"), + new(SearchResultType.Action, "profile", "My Profile"), + new(SearchResultType.Action, "status", "Set Status"), + new(SearchResultType.Action, "create-channel", "Create Channel"), + new(SearchResultType.Action, "delete-channel", "Delete Channel"), + new(SearchResultType.Action, "servers", "Saved Servers"), + new(SearchResultType.Action, "toggle-users", "Toggle Users Panel"), + new(SearchResultType.Action, "updates", "Check for Updates"), + new(SearchResultType.Action, "quit", "Quit"), + ]; + + public static SearchResult? Show(IApplication app, IReadOnlyList channels) + { + SearchResult? result = null; + var source = new SearchListSource(BuildAllItems(channels)); + + var dialog = new Dialog + { + Title = "Search", + Width = 59, + Height = 22, + }; + + var hintLabel = new Label + { + Text = "Channels and actions \u2502 \u2193 to navigate \u2502 Enter to select", + X = 1, + Y = 1, + }; + + var searchField = new TextField + { + X = 1, + Y = 2, + Title = "Search", + Width = Dim.Fill(2), + }; + + var resultList = new ListView + { + X = 1, + Y = 4, + Width = Dim.Fill(2), + Height = Dim.Fill(3), + Source = source + }; + + var cancelButton = new Button + { + Text = "Cancel", + X = Pos.Center(), + Y = Pos.AnchorEnd(1), + }; + + if (source.Count > 0) + resultList.SelectedItem = 0; + + searchField.KeyDown += (s, e) => + { + if (e.KeyCode == Key.K.WithCtrl) + { + e.Handled = true; + app.RequestStop(); + } + }; + + searchField.TextChanged += (s, e) => + { + source.Filter(searchField.Text ?? string.Empty); + resultList.Source = source; + if (source.Count > 0) + resultList.SelectedItem = 0; + }; + + searchField.Accepting += (s, e) => TryConfirm(e); + + resultList.Accepting += (s, e) => TryConfirm(e); + + resultList.KeystrokeNavigator.SearchStringChanged += (s, e) => + { + app.Invoke(() => + { + searchField.SetFocus(); + }); + }; + + cancelButton.Accepting += (s, e) => + { + result = null; + e.Handled = true; + app.RequestStop(); + }; + + dialog.KeyDown += (s, e) => + { + if (e.KeyCode == Key.K.WithCtrl) + { + e.Handled = true; + app.RequestStop(); + } + }; + + dialog.Add(hintLabel, searchField, resultList, cancelButton); + searchField.SetFocus(); + app.Run(dialog); + + return result; + + void TryConfirm(CommandEventArgs e) + { + var idx = resultList.SelectedItem ?? 0; + if (source.Count > 0 && idx >= 0 && idx < source.Count) + { + result = source.GetItem(idx); + e.Handled = true; + app.RequestStop(); + } + } + } + + private static List BuildAllItems(IReadOnlyList channels) + { + var items = new List(); + foreach (var ch in channels) + items.Add(new SearchResult(SearchResultType.Channel, ch, $"#{ch}")); + items.AddRange(DefaultActions); + return items; + } +} diff --git a/src/EchoHub.Client/UI/ListSources/SearchListSource.cs b/src/EchoHub.Client/UI/ListSources/SearchListSource.cs new file mode 100644 index 0000000..bc57a7a --- /dev/null +++ b/src/EchoHub.Client/UI/ListSources/SearchListSource.cs @@ -0,0 +1,87 @@ +using EchoHub.Client.UI.Chat; +using EchoHub.Client.UI.Dialogs; + +using System.Collections; +using System.Collections.Specialized; + +using Terminal.Gui.Drawing; +using Terminal.Gui.Text; +using Terminal.Gui.Views; + +using Attribute = Terminal.Gui.Drawing.Attribute; + +namespace EchoHub.Client.UI.ListSources; + +/// +/// List data source for the search dialog with filtering and colored rendering. +/// +public class SearchListSource(List items) : IListDataSource +{ + private readonly List _allItems = items; + private List _filtered = [.. items]; + + private static readonly Attribute ChannelAttribute = new(Color.BrightCyan, Color.None); + private static readonly Attribute ActionAttribute = new(Color.White, Color.None); + + public event NotifyCollectionChangedEventHandler? CollectionChanged; + public int Count => _filtered.Count; + public int MaxItemLength => _filtered.Count > 0 ? _filtered.Max(i => i.Label.GetColumns()) : 0; + public bool SuspendCollectionChangedEvent { get; set; } + + public void Filter(string query) + { + if (string.IsNullOrWhiteSpace(query)) + { + _filtered = [.. _allItems]; + } + else + { + _filtered = [.. _allItems.Where(i => + i.Label.Contains(query, StringComparison.OrdinalIgnoreCase) + || i.Key.Contains(query, StringComparison.OrdinalIgnoreCase))]; + } + + if (!SuspendCollectionChangedEvent) + CollectionChanged?.Invoke(this, new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } + + public SearchResult? GetItem(int index) => index >= 0 && index < _filtered.Count ? _filtered[index] : null; + + public bool IsMarked(int item) => false; + public void SetMark(int item, bool value) { } + public IList ToList() => _filtered.Select(i => (object)i.Label).ToList(); + + public void Render(ListView listView, bool selected, int item, int col, int row, int width, int viewportX = 0) + { + listView.Move(Math.Max(col - viewportX, 0), row); + + var entry = _filtered[item]; + var fillAttr = listView.GetAttributeForRole(selected ? VisualRole.Focus : VisualRole.Normal); + + Attribute itemAttr; + if (selected) + { + itemAttr = fillAttr; + } + else + { + var raw = entry.Type switch + { + SearchResultType.Channel => ChannelAttribute, + SearchResultType.Action => ActionAttribute, + _ => fillAttr + }; + itemAttr = raw.Background == Color.None ? raw with { Background = fillAttr.Background } : raw; + } + + listView.SetAttribute(itemAttr); + + var drawn = RenderHelpers.WriteText(listView, entry.Label, 0, width); + + listView.SetAttribute(fillAttr); + for (var i = drawn; i < width; i++) + listView.AddStr(" "); + } + + public void Dispose() { } +} diff --git a/src/EchoHub.Client/UI/MainWindow.cs b/src/EchoHub.Client/UI/MainWindow.cs index 77ebece..17ed1e0 100644 --- a/src/EchoHub.Client/UI/MainWindow.cs +++ b/src/EchoHub.Client/UI/MainWindow.cs @@ -48,6 +48,7 @@ public sealed partial class MainWindow : Runnable private static readonly Key NewlineKey = Key.N.WithCtrl; private static readonly Key AltQKey = Key.Q.WithAlt; private static readonly Key TabKey = Key.Tab; + private static readonly Key CtrlKKey = Key.K.WithCtrl; // Available slash commands for Tab autocomplete private static readonly string[] SlashCommands = @@ -222,7 +223,7 @@ public sealed partial class MainWindow : Runnable // Bottom input area _inputFrame = new FrameView { - Title = "Message \u2502 Enter=send \u2502 Ctrl+N=newline \u2502 Tab=complete", + Title = "Message \u2502 Enter=send \u2502 Ctrl+N=newline \u2502 Tab=complete \u2502 Ctrl+K=search", X = 22, Y = Pos.Bottom(_chatFrame), Width = Dim.Fill(UsersPanelWidth), @@ -513,6 +514,11 @@ public sealed partial class MainWindow : Runnable _app.RequestStop(); e.Handled = true; } + else if (e.KeyCode == CtrlKKey.KeyCode) + { + ShowSearchDialog(); + e.Handled = true; + } } private bool _suppressEmojiReplace; @@ -600,6 +606,11 @@ public sealed partial class MainWindow : Runnable ToggleUsersPanel(); e.Handled = true; } + else if (e.KeyCode == CtrlKKey.KeyCode) + { + ShowSearchDialog(); + e.Handled = true; + } } private void OnMessagesChanged(string channelName) @@ -899,6 +910,40 @@ public sealed partial class MainWindow : Runnable SetNeedsDraw(); } + /// + /// Open the command-palette search dialog (Ctrl+K) and dispatch the selected result. + /// + private void ShowSearchDialog() + { + var result = Dialogs.SearchDialog.Show(_app, _channelNames.AsReadOnly()); + if (result is null) return; + + switch (result.Type) + { + case Dialogs.SearchResultType.Channel: + SwitchToChannel(result.Key); + OnChannelSelected?.Invoke(result.Key); + break; + + case Dialogs.SearchResultType.Action: + switch (result.Key) + { + case "connect": OnConnectRequested?.Invoke(); break; + case "disconnect": OnDisconnectRequested?.Invoke(); break; + case "logout": OnLogoutRequested?.Invoke(); break; + case "profile": OnProfileRequested?.Invoke(); break; + case "status": OnStatusRequested?.Invoke(); break; + case "create-channel": OnCreateChannelRequested?.Invoke(); break; + case "delete-channel": OnDeleteChannelRequested?.Invoke(); break; + case "servers": OnSavedServersRequested?.Invoke(); break; + case "toggle-users": ToggleUsersPanel(); break; + case "updates": OnCheckForUpdatesRequested?.Invoke(); break; + case "quit": _app.RequestStop(); break; + } + break; + } + } + /// /// Toggle the online users panel visibility (F2). ///