mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 08:36:11 +02:00
feat: search dialog to navigate app
This commit is contained in:
@@ -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() { }
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// Toggle the online users panel visibility (F2).
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user