mirror of
https://github.com/permissionlesstech/bitchat.git
synced 2026-09-19 05:00:48 +00:00
fix: navigate mention suggestions with the same macOS key monitor as commands
Arrow keys never reach SwiftUI while the composer field editor has focus, so adopt the NSEvent local monitor from #1504. Align accept keys (Return or Tab), add Escape to dismiss, and match highlight opacity. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
816eaee949
commit
f5094c4e85
@ -115,6 +115,15 @@ final class ConversationUIModel: ObservableObject {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Dismiss the mention suggestion panel without inserting (Escape).
|
||||||
|
func dismissAutocomplete() {
|
||||||
|
guard showAutocomplete else { return }
|
||||||
|
chatViewModel.showAutocomplete = false
|
||||||
|
chatViewModel.autocompleteSuggestions = []
|
||||||
|
chatViewModel.autocompleteRange = nil
|
||||||
|
chatViewModel.selectedAutocompleteIndex = 0
|
||||||
|
}
|
||||||
|
|
||||||
func moveAutocompleteSelection(by delta: Int) {
|
func moveAutocompleteSelection(by delta: Int) {
|
||||||
guard showAutocomplete, !autocompleteSuggestions.isEmpty else { return }
|
guard showAutocomplete, !autocompleteSuggestions.isEmpty else { return }
|
||||||
let count = min(4, autocompleteSuggestions.count)
|
let count = min(4, autocompleteSuggestions.count)
|
||||||
|
|||||||
@ -2,6 +2,9 @@ import SwiftUI
|
|||||||
#if os(iOS)
|
#if os(iOS)
|
||||||
import UIKit
|
import UIKit
|
||||||
#endif
|
#endif
|
||||||
|
#if os(macOS)
|
||||||
|
import AppKit
|
||||||
|
#endif
|
||||||
|
|
||||||
struct ContentComposerView: View {
|
struct ContentComposerView: View {
|
||||||
@EnvironmentObject private var conversationUIModel: ConversationUIModel
|
@EnvironmentObject private var conversationUIModel: ConversationUIModel
|
||||||
@ -45,7 +48,7 @@ struct ContentComposerView: View {
|
|||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(
|
.background(
|
||||||
index == conversationUIModel.selectedAutocompleteIndex
|
index == conversationUIModel.selectedAutocompleteIndex
|
||||||
? palette.secondary.opacity(0.18)
|
? palette.secondary.opacity(0.15)
|
||||||
: Color.clear
|
: Color.clear
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -78,14 +81,6 @@ struct ContentComposerView: View {
|
|||||||
.textInputAutocapitalization(.sentences)
|
.textInputAutocapitalization(.sentences)
|
||||||
#endif
|
#endif
|
||||||
.submitLabel(.send)
|
.submitLabel(.send)
|
||||||
.onSubmit {
|
|
||||||
onSendMessage()
|
|
||||||
// Only the return-key path: it steals focus on iOS, so
|
|
||||||
// every message would cost a tap to reopen the keyboard.
|
|
||||||
// The send button must not reopen a deliberately
|
|
||||||
// dismissed keyboard, so it stays out of this.
|
|
||||||
isTextFieldFocused.wrappedValue = true
|
|
||||||
}
|
|
||||||
.modifier(AutocompleteKeyboardNavigationModifier(
|
.modifier(AutocompleteKeyboardNavigationModifier(
|
||||||
isActive: conversationUIModel.showAutocomplete
|
isActive: conversationUIModel.showAutocomplete
|
||||||
&& !conversationUIModel.autocompleteSuggestions.isEmpty,
|
&& !conversationUIModel.autocompleteSuggestions.isEmpty,
|
||||||
@ -94,8 +89,27 @@ struct ContentComposerView: View {
|
|||||||
},
|
},
|
||||||
onAccept: {
|
onAccept: {
|
||||||
conversationUIModel.completeSelectedSuggestion(in: &messageText)
|
conversationUIModel.completeSelectedSuggestion(in: &messageText)
|
||||||
|
},
|
||||||
|
onDismiss: {
|
||||||
|
conversationUIModel.dismissAutocomplete()
|
||||||
}
|
}
|
||||||
))
|
))
|
||||||
|
// Return while the mention panel is open completes the
|
||||||
|
// highlight instead of sending — matches command suggestions
|
||||||
|
// (#1504) and keeps Tab/Return/Escape on one convention.
|
||||||
|
.onSubmit {
|
||||||
|
if conversationUIModel.showAutocomplete,
|
||||||
|
!conversationUIModel.autocompleteSuggestions.isEmpty,
|
||||||
|
conversationUIModel.completeSelectedSuggestion(in: &messageText) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
onSendMessage()
|
||||||
|
// Only the return-key path: it steals focus on iOS, so
|
||||||
|
// every message would cost a tap to reopen the keyboard.
|
||||||
|
// The send button must not reopen a deliberately
|
||||||
|
// dismissed keyboard, so it stays out of this.
|
||||||
|
isTextFieldFocused.wrappedValue = true
|
||||||
|
}
|
||||||
.padding(.vertical, theme.usesGlassChrome ? 8 : 4)
|
.padding(.vertical, theme.usesGlassChrome ? 8 : 4)
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
.themedInputBackground()
|
.themedInputBackground()
|
||||||
@ -390,15 +404,40 @@ private extension ContentComposerView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Arrow/Tab navigation for the mention suggestion list. Inactive when the
|
/// Arrow/Tab/Return/Escape navigation for the mention suggestion list.
|
||||||
/// panel is hidden so Tab keeps its normal focus-cycle behavior.
|
///
|
||||||
|
/// Deployment targets are iOS 16 / macOS 13, so `.onKeyPress` (iOS 17 /
|
||||||
|
/// macOS 14+) is gated and unavailable on the minimum OS. Separately, on
|
||||||
|
/// macOS the single-line field editor consumes `moveUp:`/`moveDown:` itself,
|
||||||
|
/// so arrow keys never reach SwiftUI while the composer has focus — the
|
||||||
|
/// same reason command suggestions (#1504) use an `NSEvent` local monitor.
|
||||||
|
/// Mentions follow that mechanism on macOS and keep `.onKeyPress` for iOS 17+.
|
||||||
private struct AutocompleteKeyboardNavigationModifier: ViewModifier {
|
private struct AutocompleteKeyboardNavigationModifier: ViewModifier {
|
||||||
let isActive: Bool
|
let isActive: Bool
|
||||||
let onMove: (Int) -> Void
|
let onMove: (Int) -> Void
|
||||||
let onAccept: () -> Bool
|
let onAccept: () -> Bool
|
||||||
|
let onDismiss: () -> Void
|
||||||
|
|
||||||
|
#if os(macOS)
|
||||||
|
@State private var keyMonitor: Any?
|
||||||
|
#endif
|
||||||
|
|
||||||
func body(content: Content) -> some View {
|
func body(content: Content) -> some View {
|
||||||
if #available(iOS 17.0, macOS 14.0, *) {
|
#if os(macOS)
|
||||||
|
content
|
||||||
|
.onChange(of: isActive) { active in
|
||||||
|
if active {
|
||||||
|
installKeyMonitor()
|
||||||
|
} else {
|
||||||
|
removeKeyMonitor()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
if isActive { installKeyMonitor() }
|
||||||
|
}
|
||||||
|
.onDisappear { removeKeyMonitor() }
|
||||||
|
#else
|
||||||
|
if #available(iOS 17.0, *) {
|
||||||
content
|
content
|
||||||
.onKeyPress(.upArrow) {
|
.onKeyPress(.upArrow) {
|
||||||
guard isActive else { return .ignored }
|
guard isActive else { return .ignored }
|
||||||
@ -414,8 +453,57 @@ private struct AutocompleteKeyboardNavigationModifier: ViewModifier {
|
|||||||
guard isActive else { return .ignored }
|
guard isActive else { return .ignored }
|
||||||
return onAccept() ? .handled : .ignored
|
return onAccept() ? .handled : .ignored
|
||||||
}
|
}
|
||||||
|
.onKeyPress(.escape) {
|
||||||
|
guard isActive else { return .ignored }
|
||||||
|
onDismiss()
|
||||||
|
return .handled
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
content
|
content
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#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 (aligned with #1504): arrows move
|
||||||
|
/// the highlight, return/tab insert, escape dismisses. Returning nil
|
||||||
|
/// consumes the event so return completes instead of sending while the
|
||||||
|
/// list is up. Inactive monitors pass everything through.
|
||||||
|
private func handleKeyDown(_ event: NSEvent) -> NSEvent? {
|
||||||
|
guard isActive,
|
||||||
|
event.modifierFlags.intersection([.command, .option, .control]).isEmpty else {
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
|
||||||
|
switch event.keyCode {
|
||||||
|
case 126: // up arrow
|
||||||
|
onMove(-1)
|
||||||
|
return nil
|
||||||
|
case 125: // down arrow
|
||||||
|
onMove(1)
|
||||||
|
return nil
|
||||||
|
case 36, 48: // return, tab
|
||||||
|
return onAccept() ? nil : event
|
||||||
|
case 53: // escape
|
||||||
|
onDismiss()
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user