Tab and arrow keys for mention autocomplete

Highlight the selected suggestion and let Tab accept it. Up/down move
the selection when the mention panel is open; Tab otherwise still
cycles focus.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Taksh 2026-07-29 19:16:01 +03:00
parent 2c22b117b2
commit c7a5f3e022
2 changed files with 68 additions and 1 deletions

View File

@ -9,6 +9,7 @@ import UIKit
final class ConversationUIModel: ObservableObject {
@Published private(set) var showAutocomplete = false
@Published private(set) var autocompleteSuggestions: [String] = []
@Published private(set) var selectedAutocompleteIndex = 0
@Published private(set) var currentNickname: String
@Published private(set) var isBatchingPublic = false
@Published private(set) var canSendMediaInCurrentContext = true
@ -36,6 +37,7 @@ final class ConversationUIModel: ObservableObject {
self.isBatchingPublic = chatViewModel.isBatchingPublic
self.showAutocomplete = chatViewModel.showAutocomplete
self.autocompleteSuggestions = chatViewModel.autocompleteSuggestions
self.selectedAutocompleteIndex = chatViewModel.selectedAutocompleteIndex
self.canSendMediaInCurrentContext = chatViewModel.canSendMediaInCurrentContext
bind()
@ -104,6 +106,22 @@ final class ConversationUIModel: ObservableObject {
chatViewModel.completeNickname(nickname, in: &text)
}
/// Accept the currently highlighted mention suggestion, if any.
func completeSelectedSuggestion(in text: inout String) -> Bool {
guard showAutocomplete,
autocompleteSuggestions.indices.contains(selectedAutocompleteIndex)
else { return false }
_ = completeNickname(autocompleteSuggestions[selectedAutocompleteIndex], in: &text)
return true
}
func moveAutocompleteSelection(by delta: Int) {
guard showAutocomplete, !autocompleteSuggestions.isEmpty else { return }
let count = min(4, autocompleteSuggestions.count)
let next = (selectedAutocompleteIndex + delta + count) % count
chatViewModel.selectedAutocompleteIndex = next
}
func formatMessage(_ message: BitchatMessage, colorScheme: ColorScheme, theme: AppTheme? = nil) -> AttributedString {
chatViewModel.formatMessageAsText(message, colorScheme: colorScheme, theme: theme)
}
@ -193,6 +211,10 @@ final class ConversationUIModel: ObservableObject {
.receive(on: DispatchQueue.main)
.assign(to: &$autocompleteSuggestions)
chatViewModel.$selectedAutocompleteIndex
.receive(on: DispatchQueue.main)
.assign(to: &$selectedAutocompleteIndex)
chatViewModel.$isBatchingPublic
.receive(on: DispatchQueue.main)
.assign(to: &$isBatchingPublic)

View File

@ -29,7 +29,7 @@ struct ContentComposerView: View {
VStack(alignment: .leading, spacing: 6) {
if conversationUIModel.showAutocomplete && !conversationUIModel.autocompleteSuggestions.isEmpty {
VStack(alignment: .leading, spacing: 0) {
ForEach(Array(conversationUIModel.autocompleteSuggestions.prefix(4)), id: \.self) { suggestion in
ForEach(Array(conversationUIModel.autocompleteSuggestions.prefix(4).enumerated()), id: \.element) { index, suggestion in
Button(action: {
_ = conversationUIModel.completeNickname(suggestion, in: &messageText)
}) {
@ -43,6 +43,11 @@ struct ContentComposerView: View {
.padding(.horizontal, 12)
.padding(.vertical, 3)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
index == conversationUIModel.selectedAutocompleteIndex
? palette.secondary.opacity(0.18)
: Color.clear
)
}
.buttonStyle(.plain)
}
@ -81,6 +86,16 @@ struct ContentComposerView: View {
// dismissed keyboard, so it stays out of this.
isTextFieldFocused.wrappedValue = true
}
.modifier(AutocompleteKeyboardNavigationModifier(
isActive: conversationUIModel.showAutocomplete
&& !conversationUIModel.autocompleteSuggestions.isEmpty,
onMove: { delta in
conversationUIModel.moveAutocompleteSelection(by: delta)
},
onAccept: {
conversationUIModel.completeSelectedSuggestion(in: &messageText)
}
))
.padding(.vertical, theme.usesGlassChrome ? 8 : 4)
.padding(.horizontal, 6)
.themedInputBackground()
@ -374,3 +389,33 @@ private extension ContentComposerView {
)
}
}
/// Arrow/Tab navigation for the mention suggestion list. Inactive when the
/// panel is hidden so Tab keeps its normal focus-cycle behavior.
private struct AutocompleteKeyboardNavigationModifier: ViewModifier {
let isActive: Bool
let onMove: (Int) -> Void
let onAccept: () -> Bool
func body(content: Content) -> some View {
if #available(iOS 17.0, macOS 14.0, *) {
content
.onKeyPress(.upArrow) {
guard isActive else { return .ignored }
onMove(-1)
return .handled
}
.onKeyPress(.downArrow) {
guard isActive else { return .ignored }
onMove(1)
return .handled
}
.onKeyPress(.tab) {
guard isActive else { return .ignored }
return onAccept() ? .handled : .ignored
}
} else {
content
}
}
}