diff --git a/docs/changelog/v0.2.9.md b/docs/changelog/v0.2.9.md index 3fc6db7..0aa286d 100644 --- a/docs/changelog/v0.2.9.md +++ b/docs/changelog/v0.2.9.md @@ -3,3 +3,7 @@ ## Bug Fixes - Fix Linux/macOS client install — enable single-file publish so the install script copies one self-contained binary instead of just the native host (which failed with "does not exist: EchoHub.Client.dll") + +## New Features + +- Command palette — press Ctrl+K to open a searchable command palette for quick navigation and actions (enter channel, open profile, etc.) diff --git a/docs/todo.md b/docs/todo.md index eb04e7d..a33c4ca 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -17,7 +17,7 @@ - that means basically multiple servers linked, so users can chat cross-server in this network - [x] when users clicks public -> private -> public checkbox in the channel creation, it ends up creating the channel on 3rd check switch - [ ] add keyboard only controls | at least for most important parts and the rest might be accessible with: (down) -- [ ] add search bar / search modal – that will allow users to instantly navigate to room / focus on app element & etc +- [x] add search bar / search modal – that will allow users to instantly navigate to room / focus on app element & etc - [ ] Actually smart data management – cache messages, lazy load messages on scroll (currently hardcoded 100msgs fetched + new ones) - [x] Another thing would be stateful userlist – basically fetch once and listen for userlist updates - [x] Send to EchohubSpace only state changes, currently we send user count periodically, instead of updating it on update diff --git a/src/EchoHub.Client/AppOrchestrator.cs b/src/EchoHub.Client/AppOrchestrator.cs index 5c1f52d..fc47c3c 100644 --- a/src/EchoHub.Client/AppOrchestrator.cs +++ b/src/EchoHub.Client/AppOrchestrator.cs @@ -91,6 +91,7 @@ private void WireMainWindowEvents() _mainWindow.OnRollbackRequested += HandleRollbackRequested; _mainWindow.OnUserProfileRequested += HandleViewProfile; _mainWindow.OnChannelJoinRequested += HandleChannelJoinFromMessage; + _mainWindow.OnSearchRequested += HandleSearchRequested; } // ── Command Handler Wiring ───────────────────────────────────────────── @@ -733,6 +734,38 @@ private void HandleChannelJoinFromMessage(string channelName) HandleChannelSelected(channelName); } + + private void HandleSearchRequested() + { + var result = SearchDialog.Show(_app, _mainWindow.GetChannelNames()); + if (result is null) return; + + switch (result.Type) + { + case SearchResultType.Channel: + _mainWindow.SwitchToChannel(result.Key); + HandleChannelSelected(result.Key); + break; + + case SearchResultType.Action: + switch (result.Key) + { + case "connect": HandleConnect(); break; + case "disconnect": HandleDisconnect(); break; + case "logout": HandleLogout(); break; + case "profile": HandleProfileRequested(); break; + case "status": HandleStatusRequested(); break; + case "create-channel": HandleCreateChannelRequested(); break; + case "delete-channel": HandleDeleteChannelRequested(); break; + case "servers": HandleSavedServersRequested(); break; + case "toggle-users": _mainWindow.ToggleUsersPanel(); break; + case "updates": HandleCheckForUpdatesRequested(); break; + case "quit": _app.RequestStop(); break; + } + break; + } + } + private void HandleProfileRequested() { HandleViewProfile(null); 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 f46e5cc..4bdaddf 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 = @@ -151,6 +152,11 @@ public sealed partial class MainWindow : Runnable /// 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; @@ -222,7 +228,7 @@ public MainWindow(IApplication app, ChatMessageManager messageManager) // 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 +519,11 @@ private void OnInputKeyDown(object? sender, Key e) _app.RequestStop(); e.Handled = true; } + else if (e.KeyCode == CtrlKKey.KeyCode) + { + ShowSearchDialog(); + e.Handled = true; + } } private bool _suppressEmojiReplace; @@ -597,6 +608,16 @@ private void OnWindowKeyDown(object? sender, Key e) ToggleUsersPanel(); e.Handled = true; } + else if (e.KeyCode == CtrlKKey.KeyCode) + { + ShowSearchDialog(); + e.Handled = true; + } + } + + private void ShowSearchDialog() + { + OnSearchRequested?.Invoke(); } private void OnMessagesChanged(string channelName)