Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/changelog/v0.2.9.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.)
2 changes: 1 addition & 1 deletion docs/todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions src/EchoHub.Client/AppOrchestrator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ private void WireMainWindowEvents()
_mainWindow.OnRollbackRequested += HandleRollbackRequested;
_mainWindow.OnUserProfileRequested += HandleViewProfile;
_mainWindow.OnChannelJoinRequested += HandleChannelJoinFromMessage;
_mainWindow.OnSearchRequested += HandleSearchRequested;
}

// ── Command Handler Wiring ─────────────────────────────────────────────
Expand Down Expand Up @@ -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);
Expand Down
160 changes: 160 additions & 0 deletions src/EchoHub.Client/UI/Dialogs/SearchDialog.cs
Original file line number Diff line number Diff line change
@@ -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;
}
}
87 changes: 87 additions & 0 deletions src/EchoHub.Client/UI/ListSources/SearchListSource.cs
Original file line number Diff line number Diff line change
@@ -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() { }
}
23 changes: 22 additions & 1 deletion src/EchoHub.Client/UI/MainWindow.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -151,6 +152,11 @@ public sealed partial class MainWindow : Runnable
/// </summary>
public event Action<string>? OnChannelJoinRequested;

/// <summary>
/// Fired when the user requests to open the search dialog (via menu or Ctrl+K).
/// </summary>
public event Action? OnSearchRequested;

public MainWindow(IApplication app, ChatMessageManager messageManager)
{
_app = app;
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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)
Expand Down