mirror of
https://github.com/RedWizardsLab/EchoHub.git
synced 2026-09-04 00:26:07 +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 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user