diff --git a/src/Conclave.App/Commands/AppCommand.cs b/src/Conclave.App/Commands/AppCommand.cs new file mode 100644 index 0000000..67e7a5b --- /dev/null +++ b/src/Conclave.App/Commands/AppCommand.cs @@ -0,0 +1,12 @@ +namespace Conclave.App.Commands; + +// One thing the user can invoke from the palette (or, later, a hotkey). `CanExecute` +// gates visibility/availability — e.g. "Cancel turn" only surfaces when a session has +// an in-flight CTS. Named `AppCommand` to avoid colliding with the BCL's +// System.Windows.Input.ICommand-style "Command" type some Avalonia code picks up. +public sealed record AppCommand( + string Id, + string Title, + string? Group, + Func CanExecute, + Action Execute); diff --git a/src/Conclave.App/Commands/CommandRegistry.cs b/src/Conclave.App/Commands/CommandRegistry.cs new file mode 100644 index 0000000..ae5fe44 --- /dev/null +++ b/src/Conclave.App/Commands/CommandRegistry.cs @@ -0,0 +1,23 @@ +namespace Conclave.App.Commands; + +// Owns the catalog of static commands (palette.open, prefs.open, ...). Dynamic +// per-session commands ("Switch to ") are produced on demand by the palette +// VM and not registered here — they'd churn every time a session is added/removed. +public sealed class CommandRegistry +{ + private readonly Dictionary _byId = new(); + + public IReadOnlyCollection All => _byId.Values; + + public void Register(AppCommand cmd) => _byId[cmd.Id] = cmd; + + public AppCommand? Get(string id) => _byId.GetValueOrDefault(id); + + public bool TryExecute(string id) + { + if (!_byId.TryGetValue(id, out var cmd)) return false; + if (!cmd.CanExecute()) return false; + cmd.Execute(); + return true; + } +} diff --git a/src/Conclave.App/Commands/FuzzyMatch.cs b/src/Conclave.App/Commands/FuzzyMatch.cs new file mode 100644 index 0000000..d7ee3c5 --- /dev/null +++ b/src/Conclave.App/Commands/FuzzyMatch.cs @@ -0,0 +1,50 @@ +namespace Conclave.App.Commands; + +// Subsequence-based fuzzy matcher for the command palette. All query chars must +// appear in order in the candidate. Score rewards consecutive matches and matches +// at word boundaries — the "ns" in "**N**ew **s**ession" beats "**n**ew sessio**n**s" +// even though both match. +// +// Returns (score, matchedIndices) so the UI can highlight the matched chars. Score +// of 0 means no match; higher is better. +public static class FuzzyMatch +{ + public static (int Score, int[] Indices) Score(string query, string candidate) + { + if (string.IsNullOrEmpty(query)) return (1, Array.Empty()); + if (string.IsNullOrEmpty(candidate)) return (0, Array.Empty()); + + var indices = new int[query.Length]; + int score = 0; + int qi = 0; + int lastMatch = -2; + + for (int ci = 0; ci < candidate.Length && qi < query.Length; ci++) + { + char qc = char.ToLowerInvariant(query[qi]); + char cc = char.ToLowerInvariant(candidate[ci]); + if (qc != cc) continue; + + indices[qi] = ci; + // Bonus: consecutive match (no gap from previous match). + if (ci == lastMatch + 1) score += 5; + // Bonus: matched at start of string. + if (ci == 0) score += 8; + // Bonus: matched at a word boundary (after space/punctuation). + else if (IsBoundary(candidate[ci - 1])) score += 4; + // Base: every match contributes a small amount so longer matches outscore shorter + // (when ties happen on bonuses). + score += 1; + lastMatch = ci; + qi++; + } + + if (qi < query.Length) return (0, Array.Empty()); // unmatched chars left + // Penalty: shorter candidates of equal match are preferable. Subtract a tiny bit + // per unused char so "new session" beats "newest sessions" for "new s". + score -= candidate.Length / 8; + return (Math.Max(score, 1), indices); + } + + private static bool IsBoundary(char c) => c is ' ' or '-' or '_' or '.' or '/' or ':'; +} diff --git a/src/Conclave.App/Commands/KeyChord.cs b/src/Conclave.App/Commands/KeyChord.cs new file mode 100644 index 0000000..1df457a --- /dev/null +++ b/src/Conclave.App/Commands/KeyChord.cs @@ -0,0 +1,95 @@ +using Avalonia.Input; + +namespace Conclave.App.Commands; + +// A single key + modifier combo, normalised so equality works regardless of how the +// chord was entered. `cmd` parses to Meta on macOS and Control elsewhere — this is +// the same convention VS Code uses, and it's what users expect when sharing keymaps +// across machines. +public readonly record struct KeyChord(Key Key, KeyModifiers Modifiers) +{ + public static KeyChord FromEvent(KeyEventArgs e) => new(e.Key, NormaliseModifiers(e.KeyModifiers)); + + // Strip Shift when the underlying key already encodes case-sensitivity (letters/digits)? + // No — we want Shift to be significant for chords like "shift+G". Just clear stray + // modifier bits Avalonia might emit (none today, future-proofing only). + private static KeyModifiers NormaliseModifiers(KeyModifiers m) => m; + + // "cmd+k", "ctrl+shift+p", "esc". Returns null on parse failure rather than + // throwing — bad keymaps shouldn't crash the app, they should just be ignored. + public static KeyChord? Parse(string spec) + { + if (string.IsNullOrWhiteSpace(spec)) return null; + var parts = spec.Split('+', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length == 0) return null; + var mods = KeyModifiers.None; + Key? key = null; + foreach (var raw in parts) + { + switch (raw.ToLowerInvariant()) + { + case "cmd": + case "meta": + case "win": + mods |= OperatingSystem.IsMacOS() ? KeyModifiers.Meta : KeyModifiers.Control; + break; + case "ctrl": + case "control": + mods |= KeyModifiers.Control; + break; + case "shift": + mods |= KeyModifiers.Shift; + break; + case "alt": + case "option": + mods |= KeyModifiers.Alt; + break; + default: + if (!TryParseKey(raw, out var parsed)) return null; + key = parsed; + break; + } + } + return key is null ? null : new KeyChord(key.Value, mods); + } + + private static bool TryParseKey(string raw, out Key key) + { + // Single-char shortcuts: "k", "p". Avalonia's Key enum names letters as A..Z. + if (raw.Length == 1 && char.IsLetter(raw[0])) + return Enum.TryParse(raw.ToUpperInvariant(), out key); + + // Common aliases users will type before the formal name. + switch (raw.ToLowerInvariant()) + { + case "esc": key = Key.Escape; return true; + case "enter": + case "return": key = Key.Enter; return true; + case "space": key = Key.Space; return true; + } + + return Enum.TryParse(raw, ignoreCase: true, out key); + } + + public string Display + { + get + { + var parts = new List(4); + if (Modifiers.HasFlag(KeyModifiers.Control)) parts.Add(OperatingSystem.IsMacOS() ? "⌃" : "Ctrl"); + if (Modifiers.HasFlag(KeyModifiers.Alt)) parts.Add(OperatingSystem.IsMacOS() ? "⌥" : "Alt"); + if (Modifiers.HasFlag(KeyModifiers.Shift)) parts.Add(OperatingSystem.IsMacOS() ? "⇧" : "Shift"); + if (Modifiers.HasFlag(KeyModifiers.Meta)) parts.Add(OperatingSystem.IsMacOS() ? "⌘" : "Win"); + parts.Add(KeyDisplay(Key)); + return OperatingSystem.IsMacOS() ? string.Concat(parts) : string.Join("+", parts); + } + } + + private static string KeyDisplay(Key k) => k switch + { + Key.Escape => "Esc", + Key.Enter => "↵", + Key.Space => "Space", + _ => k.ToString(), + }; +} diff --git a/src/Conclave.App/Commands/KeyMap.cs b/src/Conclave.App/Commands/KeyMap.cs new file mode 100644 index 0000000..c0c6d2c --- /dev/null +++ b/src/Conclave.App/Commands/KeyMap.cs @@ -0,0 +1,69 @@ +using System.Text.Json; + +namespace Conclave.App.Commands; + +// Maps key chords to command ids. Defaults are baked in code; user overrides come from +// the settings table as JSON of the form `[ { "key": "cmd+k", "command": "palette.open" } ]`. +// Overrides are merged on top of defaults — a user can rebind without losing the rest. +public sealed class KeyMap +{ + private readonly Dictionary _bindings = new(); + + public IReadOnlyDictionary Bindings => _bindings; + + public void Bind(string chord, string commandId) + { + if (KeyChord.Parse(chord) is { } parsed) _bindings[parsed] = commandId; + } + + public void Bind(KeyChord chord, string commandId) => _bindings[chord] = commandId; + + public string? Lookup(KeyChord chord) => _bindings.GetValueOrDefault(chord); + + // First chord bound to the given command id, for display purposes. Linear scan — + // we never have enough bindings for a reverse index to be worth the maintenance. + public KeyChord? FindForCommand(string commandId) + { + foreach (var (chord, id) in _bindings) + if (id == commandId) return chord; + return null; + } + + // Defaults — kept minimal until we agree on the catalog. Cmd-K is the only one + // we're confident in shipping right now. + public static KeyMap Defaults() + { + var map = new KeyMap(); + map.Bind("cmd+k", "palette.open"); + return map; + } + + // Manual JsonDocument walk rather than JsonSerializer.Deserialize so we don't pull + // in reflection-based deserialization — the project publishes with NativeAOT and the + // schema is trivial enough that hand-rolling is cleaner than wiring up a source-gen + // JsonSerializerContext. + public void ApplyOverridesJson(string? json) + { + if (string.IsNullOrWhiteSpace(json)) return; + try + { + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.ValueKind != JsonValueKind.Array) return; + foreach (var entry in doc.RootElement.EnumerateArray()) + { + if (entry.ValueKind != JsonValueKind.Object) continue; + if (!entry.TryGetProperty("key", out var keyEl) || keyEl.ValueKind != JsonValueKind.String) continue; + if (!entry.TryGetProperty("command", out var cmdEl) || cmdEl.ValueKind != JsonValueKind.String) continue; + var keyStr = keyEl.GetString(); + var cmdStr = cmdEl.GetString(); + if (string.IsNullOrWhiteSpace(keyStr) || string.IsNullOrWhiteSpace(cmdStr)) continue; + if (KeyChord.Parse(keyStr) is { } chord) _bindings[chord] = cmdStr; + } + } + catch (JsonException) + { + // Bad JSON — keep defaults. Users get told via a settings UI later; for now + // we just don't apply broken overrides. + } + } +} diff --git a/src/Conclave.App/Commands/KeyRouter.cs b/src/Conclave.App/Commands/KeyRouter.cs new file mode 100644 index 0000000..4404e71 --- /dev/null +++ b/src/Conclave.App/Commands/KeyRouter.cs @@ -0,0 +1,47 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Interactivity; + +namespace Conclave.App.Commands; + +// Window-level keyboard router. Listens on the tunnel phase so app-level chords with a +// modifier (Cmd-K, Ctrl-Shift-P) win even when a TextBox would otherwise consume the +// keystroke. Plain-key chords (no modifier) are handled on the bubble phase so typing +// in inputs still works normally. +public static class KeyRouter +{ + public static void Attach(Window window, CommandRegistry registry, KeyMap map) + { + window.AddHandler(InputElement.KeyDownEvent, (s, e) => OnKey(e, registry, map, tunneling: true), + RoutingStrategies.Tunnel); + window.AddHandler(InputElement.KeyDownEvent, (s, e) => OnKey(e, registry, map, tunneling: false), + RoutingStrategies.Bubble); + } + + private static void OnKey(KeyEventArgs e, CommandRegistry registry, KeyMap map, bool tunneling) + { + if (e.Handled) return; + + var chord = KeyChord.FromEvent(e); + // Modifier keys arriving on their own (e.g. holding ⌘) come through as + // Key.LeftCommand / LWin / etc. Skip — they aren't a chord on their own. + if (IsModifierKey(e.Key)) return; + + bool hasGlobalModifier = chord.Modifiers.HasFlag(KeyModifiers.Meta) + || chord.Modifiers.HasFlag(KeyModifiers.Control); + + // Tunnel phase only handles modifier-bearing chords — that's the whole point of + // grabbing them early. Plain-letter chords fall through to the bubble pass. + if (tunneling && !hasGlobalModifier) return; + if (!tunneling && hasGlobalModifier) return; // already considered on tunnel + + if (map.Lookup(chord) is not { } commandId) return; + if (registry.TryExecute(commandId)) e.Handled = true; + } + + private static bool IsModifierKey(Key k) => k + is Key.LeftCtrl or Key.RightCtrl + or Key.LeftShift or Key.RightShift + or Key.LeftAlt or Key.RightAlt + or Key.LWin or Key.RWin; +} diff --git a/src/Conclave.App/MainWindow.axaml.cs b/src/Conclave.App/MainWindow.axaml.cs index 58274c5..3e843cc 100644 --- a/src/Conclave.App/MainWindow.axaml.cs +++ b/src/Conclave.App/MainWindow.axaml.cs @@ -1,6 +1,7 @@ using Avalonia; using Avalonia.Controls; using Conclave.App.Claude; +using Conclave.App.Commands; using Conclave.App.Design; using Conclave.App.Platform; using Conclave.App.Sessions; @@ -41,6 +42,7 @@ public partial class MainWindow : Window private NewFusionProjectModal? _newFusionModal; private PreferencesModal? _preferencesModal; private AboutModal? _aboutModal; + private CommandPaletteModal? _commandPaletteModal; public MainWindow() { @@ -112,6 +114,11 @@ public MainWindow() DataContext = _shell; + // Window-level keyboard router. Owns the tunnel-phase pass for modifier-bearing + // chords (Cmd-K and friends) so they win even over a focused TextBox; bare-key + // chords still bubble normally so typing into inputs is unaffected. + KeyRouter.Attach(this, _shell.Commands, _shell.KeyMap); + Activated += (_, _) => _isWindowActive = true; Deactivated += (_, _) => _isWindowActive = false; @@ -164,6 +171,8 @@ private void OnShellPropertyChanged(object? sender, System.ComponentModel.Proper EnsureModal(ref _preferencesModal); else if (e.PropertyName == nameof(ShellVm.IsAboutOpen) && _shell?.IsAboutOpen == true) EnsureModal(ref _aboutModal); + else if (e.PropertyName == nameof(ShellVm.IsCommandPaletteOpen) && _shell?.IsCommandPaletteOpen == true) + EnsureModal(ref _commandPaletteModal); } private void EnsureModal(ref T? slot) where T : Control, new() diff --git a/src/Conclave.App/Sessions/SettingsKeys.cs b/src/Conclave.App/Sessions/SettingsKeys.cs index d222df8..748a684 100644 --- a/src/Conclave.App/Sessions/SettingsKeys.cs +++ b/src/Conclave.App/Sessions/SettingsKeys.cs @@ -8,6 +8,7 @@ public static class SettingsKeys public const string NotificationsEnabled = "notifications.enabled"; public const string ClaudeVersion = "claude.version"; public const string AutoResumeStalledSessions = "stall_detection.auto_resume"; + public const string KeybindingsJson = "keybindings.json"; public const int DefaultAutoCleanupDays = 7; diff --git a/src/Conclave.App/ViewModels/CommandPaletteVm.cs b/src/Conclave.App/ViewModels/CommandPaletteVm.cs new file mode 100644 index 0000000..7c5c4db --- /dev/null +++ b/src/Conclave.App/ViewModels/CommandPaletteVm.cs @@ -0,0 +1,108 @@ +using System.Collections.ObjectModel; +using Conclave.App.Commands; +using Conclave.App.Design; + +namespace Conclave.App.ViewModels; + +public sealed class CommandPaletteVm : Views.Observable +{ + private readonly ShellVm _shell; + + public Tokens Tokens => _shell.Tokens; + public ObservableCollection Results { get; } = new(); + + private string _query = ""; + public string Query + { + get => _query; + set + { + if (Set(ref _query, value)) + { + Recompute(); + Notify(nameof(HasQuery)); + } + } + } + public bool HasQuery => !string.IsNullOrEmpty(_query); + + private int _selectedIndex; + public int SelectedIndex + { + get => _selectedIndex; + set + { + // Clamp to current results — arrow keys past the ends shouldn't select something + // that isn't there. Empty result set leaves the index at 0. + int clamped = Results.Count == 0 ? 0 : Math.Clamp(value, 0, Results.Count - 1); + Set(ref _selectedIndex, clamped); + } + } + + public CommandPaletteVm(ShellVm shell) + { + _shell = shell; + Recompute(); + } + + public void MoveSelection(int delta) + { + if (Results.Count == 0) return; + int next = SelectedIndex + delta; + // Wrap so down-at-bottom goes to top — palette UX expects this. + if (next < 0) next = Results.Count - 1; + else if (next >= Results.Count) next = 0; + SelectedIndex = next; + } + + public void ExecuteSelected() + { + if (_selectedIndex < 0 || _selectedIndex >= Results.Count) return; + var result = Results[_selectedIndex]; + _shell.CloseCommandPalette(); + // Defer execution slightly: the palette is closing, and some actions (e.g. + // OpenPreferences) immediately open another modal that wants focus. Letting + // the close finish first avoids focus thrash on the same UI tick. + Avalonia.Threading.Dispatcher.UIThread.Post(result.Execute); + } + + private void Recompute() + { + Results.Clear(); + + var pool = new List(); + foreach (var cmd in _shell.Commands.All) + { + if (!cmd.CanExecute()) continue; + var (score, _) = FuzzyMatch.Score(_query, cmd.Title); + if (score == 0) continue; + var shortcut = _shell.KeyMap.FindForCommand(cmd.Id)?.Display; + pool.Add(new CommandResultVm(cmd.Title, cmd.Group, shortcut, cmd.Execute, score)); + } + + // Synthetic per-session "Switch to" entries — generated on the fly so we don't + // have to invalidate the registry when sessions come and go. + foreach (var project in _shell.Projects) + { + foreach (var session in project.Sessions) + { + if (ReferenceEquals(session, _shell.ActiveSession)) continue; + var title = $"Switch to: {session.Title}"; + var (score, _) = FuzzyMatch.Score(_query, title); + if (score == 0) continue; + var sessionRef = session; + pool.Add(new CommandResultVm( + title, + project.Name, + null, + () => _shell.ActiveSession = sessionRef, + score)); + } + } + + pool.Sort((a, b) => b.Score.CompareTo(a.Score)); + foreach (var result in pool) Results.Add(result); + + SelectedIndex = 0; + } +} diff --git a/src/Conclave.App/ViewModels/CommandResultVm.cs b/src/Conclave.App/ViewModels/CommandResultVm.cs new file mode 100644 index 0000000..b55ed7a --- /dev/null +++ b/src/Conclave.App/ViewModels/CommandResultVm.cs @@ -0,0 +1,13 @@ +using Conclave.App.Commands; + +namespace Conclave.App.ViewModels; + +// One row in the command palette. Wraps a static AppCommand or a synthetic action +// (e.g. "Switch to "). The palette VM materialises these on every query +// change, so it's a plain immutable record — no Observable plumbing needed. +public sealed record CommandResultVm( + string Title, + string? Subtitle, + string? Shortcut, + Action Execute, + int Score); diff --git a/src/Conclave.App/ViewModels/ShellVm.cs b/src/Conclave.App/ViewModels/ShellVm.cs index 733cdc8..e960f6f 100644 --- a/src/Conclave.App/ViewModels/ShellVm.cs +++ b/src/Conclave.App/ViewModels/ShellVm.cs @@ -2,6 +2,7 @@ using System.Collections.Specialized; using System.Reflection; using Conclave.App.Claude; +using Conclave.App.Commands; using Conclave.App.Design; using Conclave.App.Sessions; @@ -15,6 +16,12 @@ public sealed class ShellVm : Views.Observable public SessionManager Manager { get; } public ClaudeCapabilities Claude { get; } + // Command catalog + key bindings. Both are app-global; live here because the + // palette VM (which is also owned by ShellVm) is the only consumer and the + // command actions close over `this`. + public CommandRegistry Commands { get; } = new(); + public KeyMap KeyMap { get; } = KeyMap.Defaults(); + public ObservableCollection Projects => Manager.Projects; public ObservableCollection Filters { get; } = new(); @@ -140,6 +147,25 @@ public bool IsAboutOpen public void OpenAbout() => IsAboutOpen = true; public void CloseAbout() => IsAboutOpen = false; + // --- Command palette --- + + private CommandPaletteVm? _commandPalette; + public CommandPaletteVm? CommandPalette + { + get => _commandPalette; + private set { if (Set(ref _commandPalette, value)) Notify(nameof(IsCommandPaletteOpen)); } + } + public bool IsCommandPaletteOpen => _commandPalette is not null; + + public void OpenCommandPalette() + { + // Re-create on each open so result list and selection start fresh. The cost + // is negligible — building the result pool is a single pass over commands + + // sessions. + CommandPalette = new CommandPaletteVm(this); + } + public void CloseCommandPalette() => CommandPalette = null; + // Reads the assembly's InformationalVersion (stamped by `dotnet publish -p:Version=...`) // and trims the trailing `+commit` build metadata SourceLink appends. Falls back to the // numeric assembly version, then a dev sentinel. @@ -319,6 +345,10 @@ public ShellVm(Tokens tokens, SessionManager manager, ClaudeCapabilities claude) Tokens = tokens; Manager = manager; Claude = claude; + RegisterCommands(); + // User overrides merge over defaults; broken JSON is silently ignored so a + // typo can't lock the user out of the palette. + KeyMap.ApplyOverridesJson(manager.Db.GetSetting(SettingsKeys.KeybindingsJson)); BuildFilters(); RecalcFilterCounts(); @@ -339,6 +369,26 @@ public ShellVm(Tokens tokens, SessionManager manager, ClaudeCapabilities claude) selected:; } + private void RegisterCommands() + { + AppCommand C(string id, string title, string? group, Func can, Action exec) + => new(id, title, group, can, exec); + bool Always() => true; + bool HasSession() => HasActiveSession; + + Commands.Register(C("palette.open", "Open command palette", "View", Always, OpenCommandPalette)); + Commands.Register(C("session.new", "New session", "Session", Always, OpenNewSession)); + Commands.Register(C("fusion.new", "New fusion project", "Session", Always, OpenNewFusionProject)); + Commands.Register(C("session.cancel", "Cancel current turn", "Session", HasSession, CancelActiveTurn)); + Commands.Register(C("view.transcript", "Show transcript", "View", HasSession, () => ActiveView = MainView.Terminal)); + Commands.Register(C("view.plan", "Show plan", "View", HasSession, () => ActiveView = MainView.Plan)); + Commands.Register(C("view.logs", "Show logs", "View", HasSession, () => ActiveView = MainView.Logs)); + Commands.Register(C("panel.toggle", "Toggle right panel", "View", HasSession, + () => RightPanelVisible = !RightPanelVisible)); + Commands.Register(C("prefs.open", "Preferences", "App", Always, OpenPreferences)); + Commands.Register(C("about.open", "About Conclave", "App", Always, OpenAbout)); + } + private void HookSession(SessionVm s) => s.PropertyChanged += OnSessionPropertyChanged; private void UnhookSession(SessionVm s) => s.PropertyChanged -= OnSessionPropertyChanged; diff --git a/src/Conclave.App/Views/Shell/CommandPaletteModal.axaml b/src/Conclave.App/Views/Shell/CommandPaletteModal.axaml new file mode 100644 index 0000000..174fb4f --- /dev/null +++ b/src/Conclave.App/Views/Shell/CommandPaletteModal.axaml @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Conclave.App/Views/Shell/CommandPaletteModal.axaml.cs b/src/Conclave.App/Views/Shell/CommandPaletteModal.axaml.cs new file mode 100644 index 0000000..da7167b --- /dev/null +++ b/src/Conclave.App/Views/Shell/CommandPaletteModal.axaml.cs @@ -0,0 +1,76 @@ +using Avalonia.Controls; +using Avalonia.Input; +using Avalonia.Markup.Xaml; +using Avalonia.Threading; +using Conclave.App.ViewModels; + +namespace Conclave.App.Views.Shell; + +public partial class CommandPaletteModal : UserControl +{ + public CommandPaletteModal() + { + InitializeComponent(); + // Auto-focus the query input every time the palette opens. The IsVisible + // toggle that drives this fires before the input is laid out, so we post the + // focus to the next dispatcher tick instead of attempting it inline. + DataContextChanged += (_, _) => FocusOnOpen(); + this.PropertyChanged += (_, e) => + { + if (e.Property == IsVisibleProperty && IsVisible) FocusOnOpen(); + }; + } + + private void FocusOnOpen() + { + if (DataContext is not ShellVm shell || !shell.IsCommandPaletteOpen) return; + Dispatcher.UIThread.Post(() => + { + this.FindControl("QueryInput")?.Focus(); + }); + } + + private void OnBackdropPressed(object? sender, PointerPressedEventArgs e) + { + if (DataContext is ShellVm shell) shell.CloseCommandPalette(); + } + + private void OnResultPressed(object? sender, PointerReleasedEventArgs e) + { + if (e.InitialPressMouseButton != MouseButton.Left) return; + if (DataContext is not ShellVm shell || shell.CommandPalette is not { } palette) return; + // The clicked row is selected before the release fires (ListBox handles the + // pointer-pressed), so we just execute the current selection. + palette.ExecuteSelected(); + e.Handled = true; + } + + private void OnModalKeyDown(object? sender, KeyEventArgs e) + { + if (DataContext is not ShellVm shell || !shell.IsCommandPaletteOpen) return; + var palette = shell.CommandPalette; + if (palette is null) return; + + switch (e.Key) + { + case Key.Escape: + shell.CloseCommandPalette(); + e.Handled = true; + break; + case Key.Down: + palette.MoveSelection(1); + e.Handled = true; + break; + case Key.Up: + palette.MoveSelection(-1); + e.Handled = true; + break; + case Key.Enter: + palette.ExecuteSelected(); + e.Handled = true; + break; + } + } + + private void InitializeComponent() => AvaloniaXamlLoader.Load(this); +}