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)