From aa6599a4e0547d327421160029879a5c13bc9d56 Mon Sep 17 00:00:00 2001
From: Stone_Red <56473591+Stone-Red-Code@users.noreply.github.com>
Date: Thu, 26 Feb 2026 16:47:58 +0100
Subject: [PATCH] feat: search dialog to navigate app
---
src/EchoHub.Client/UI/Dialogs/SearchDialog.cs | 160 ++++++++++++++++++
.../UI/ListSources/SearchListSource.cs | 87 ++++++++++
src/EchoHub.Client/UI/MainWindow.cs | 47 ++++-
3 files changed, 293 insertions(+), 1 deletion(-)
create mode 100644 src/EchoHub.Client/UI/Dialogs/SearchDialog.cs
create mode 100644 src/EchoHub.Client/UI/ListSources/SearchListSource.cs
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).
///