From 34e64cbe4dc7f134fe37e4ea12940cefcaaf3821 Mon Sep 17 00:00:00 2001 From: Vidit Kulshrestha Date: Mon, 27 Jul 2026 09:53:29 +0530 Subject: [PATCH] Add keyboard navigation for command suggestions on macOS The command suggestion panel was click-only (#233). On macOS, arrow keys now move a highlight through the suggestions and return/tab insert the highlighted command; typing continues to filter as before. Arrow keys never reach SwiftUI modifiers while the composer's single-line field editor has focus (it consumes moveUp:/moveDown: itself, and the macOS 13 deployment target rules out .onKeyPress), so navigation uses an NSEvent local monitor installed while the panel is visible and removed when it disappears. The monitor only consumes plain up/down/return/tab while the selectable list is showing: modified keys pass through, the usage-reminder row stays informational (return still sends the composed command), and iOS behavior is untouched. Fixes #233 --- .../Components/CommandSuggestionsView.swift | 87 ++++++++++++++++++- 1 file changed, 84 insertions(+), 3 deletions(-) diff --git a/bitchat/Views/Components/CommandSuggestionsView.swift b/bitchat/Views/Components/CommandSuggestionsView.swift index cc4f9609..863c9dfe 100644 --- a/bitchat/Views/Components/CommandSuggestionsView.swift +++ b/bitchat/Views/Components/CommandSuggestionsView.swift @@ -6,6 +6,9 @@ // import SwiftUI +#if os(macOS) +import AppKit +#endif struct CommandSuggestionsView: View { @EnvironmentObject private var privateConversationModel: PrivateConversationModel @@ -14,6 +17,16 @@ struct CommandSuggestionsView: View { @Binding var messageText: String + /// Row highlighted for keyboard navigation (macOS). Reset whenever the + /// filtered list changes so the highlight never outlives its list. + @State private var selectedIndex = 0 + #if os(macOS) + /// Arrow keys never reach SwiftUI while the composer's field editor has + /// focus (the single-line field consumes moveUp:/moveDown: itself), so + /// navigation uses a local key monitor scoped to the panel's lifetime. + @State private var keyMonitor: Any? + #endif + /// The command already typed in full, once arguments have begun. private var typedCommandAlias: String? { guard messageText.hasPrefix("/"), @@ -43,23 +56,91 @@ struct CommandSuggestionsView: View { // off-center. if !filteredCommands.isEmpty { let isUsageReminder = typedCommandAlias != nil + let commands = filteredCommands VStack(alignment: .leading, spacing: 0) { - ForEach(filteredCommands) { command in + ForEach(Array(commands.enumerated()), id: \.element.id) { index, command in Button { // In usage-reminder mode the row is informational; an // insert here would wipe the arguments being typed. guard !isUsageReminder else { return } - messageText = command.alias + " " + accept(command) } label: { buttonRow(for: command) } .buttonStyle(.plain) + .background(rowHighlight(index: index, isUsageReminder: isUsageReminder)) } } .themedOverlayPanel() + .onChange(of: commands) { _ in + selectedIndex = 0 + } + #if os(macOS) + .onAppear { installKeyMonitor() } + .onDisappear { removeKeyMonitor() } + #endif } } - + + /// Inserts the command with a trailing space, ready for arguments. + private func accept(_ command: CommandInfo) { + messageText = command.alias + " " + } + + private func rowHighlight(index: Int, isUsageReminder: Bool) -> Color { + #if os(macOS) + if !isUsageReminder && index == selectedIndex { + return palette.secondary.opacity(0.15) + } + #endif + return .clear + } + + #if os(macOS) + private func installKeyMonitor() { + guard keyMonitor == nil else { return } + keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in + handleKeyDown(event) + } + } + + private func removeKeyMonitor() { + if let keyMonitor { + NSEvent.removeMonitor(keyMonitor) + } + keyMonitor = nil + } + + /// Standard autocomplete navigation: arrows move the highlight, + /// return/tab insert the highlighted command. Returning nil consumes the + /// event, so return completes instead of sending while the list is up; + /// the usage-reminder row stays informational and passes everything + /// through (return sends the composed command). + private func handleKeyDown(_ event: NSEvent) -> NSEvent? { + let commands = filteredCommands + guard !commands.isEmpty, + typedCommandAlias == nil, + event.modifierFlags.intersection([.command, .option, .control]).isEmpty else { + return event + } + + switch event.keyCode { + case 126: // up arrow + selectedIndex = max(0, selectedIndex - 1) + return nil + case 125: // down arrow + selectedIndex = min(commands.count - 1, selectedIndex + 1) + return nil + case 36, 48: // return, tab + accept(commands[min(selectedIndex, commands.count - 1)]) + return nil + default: + return event + } + } + #endif + + private func buttonRow(for command: CommandInfo) -> some View { HStack { Text(command.alias)