mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-08-29 07:27:16 +00:00
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
This commit is contained in:
parent
0152196ac2
commit
34e64cbe4d
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user