feat: search dialog to navigate app

This commit is contained in:
Stone_Red
2026-04-20 17:42:04 +02:00
committed by HueByte
parent 3091a146eb
commit aa6599a4e0
3 changed files with 293 additions and 1 deletions
@@ -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);
/// <summary>
/// Command-palette style search dialog (Ctrl+K) for navigating channels and triggering app actions.
/// </summary>
public static class SearchDialog
{
private static readonly IReadOnlyList<SearchResult> 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<string> 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<SearchResult> BuildAllItems(IReadOnlyList<string> channels)
{
var items = new List<SearchResult>();
foreach (var ch in channels)
items.Add(new SearchResult(SearchResultType.Channel, ch, $"#{ch}"));
items.AddRange(DefaultActions);
return items;
}
}
@@ -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;
/// <summary>
/// List data source for the search dialog with filtering and colored rendering.
/// </summary>
public class SearchListSource(List<SearchResult> items) : IListDataSource
{
private readonly List<SearchResult> _allItems = items;
private List<SearchResult> _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() { }
}
+46 -1
View File
@@ -48,6 +48,7 @@ public sealed partial class MainWindow : Runnable
private static readonly Key NewlineKey = Key.N.WithCtrl; private static readonly Key NewlineKey = Key.N.WithCtrl;
private static readonly Key AltQKey = Key.Q.WithAlt; private static readonly Key AltQKey = Key.Q.WithAlt;
private static readonly Key TabKey = Key.Tab; private static readonly Key TabKey = Key.Tab;
private static readonly Key CtrlKKey = Key.K.WithCtrl;
// Available slash commands for Tab autocomplete // Available slash commands for Tab autocomplete
private static readonly string[] SlashCommands = private static readonly string[] SlashCommands =
@@ -222,7 +223,7 @@ public sealed partial class MainWindow : Runnable
// Bottom input area // Bottom input area
_inputFrame = new FrameView _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, X = 22,
Y = Pos.Bottom(_chatFrame), Y = Pos.Bottom(_chatFrame),
Width = Dim.Fill(UsersPanelWidth), Width = Dim.Fill(UsersPanelWidth),
@@ -513,6 +514,11 @@ public sealed partial class MainWindow : Runnable
_app.RequestStop(); _app.RequestStop();
e.Handled = true; e.Handled = true;
} }
else if (e.KeyCode == CtrlKKey.KeyCode)
{
ShowSearchDialog();
e.Handled = true;
}
} }
private bool _suppressEmojiReplace; private bool _suppressEmojiReplace;
@@ -600,6 +606,11 @@ public sealed partial class MainWindow : Runnable
ToggleUsersPanel(); ToggleUsersPanel();
e.Handled = true; e.Handled = true;
} }
else if (e.KeyCode == CtrlKKey.KeyCode)
{
ShowSearchDialog();
e.Handled = true;
}
} }
private void OnMessagesChanged(string channelName) private void OnMessagesChanged(string channelName)
@@ -899,6 +910,40 @@ public sealed partial class MainWindow : Runnable
SetNeedsDraw(); SetNeedsDraw();
} }
/// <summary>
/// Open the command-palette search dialog (Ctrl+K) and dispatch the selected result.
/// </summary>
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;
}
}
/// <summary> /// <summary>
/// Toggle the online users panel visibility (F2). /// Toggle the online users panel visibility (F2).
/// </summary> /// </summary>