Optimize autocomplete with debouncing

- Add 150ms debounce to text field onChange handler
- Prevents excessive autocomplete calculations during rapid typing
- Add timer cleanup in onDisappear
- Works for both main chat and private chat inputs
This commit is contained in:
jack
2025-07-24 13:40:34 +02:00
parent bb6552f4a6
commit 7120b11692
2 changed files with 70 additions and 26 deletions
+54 -22
View File
@@ -36,6 +36,11 @@ class ChatViewModel: ObservableObject {
@Published var autocompleteRange: NSRange? = nil @Published var autocompleteRange: NSRange? = nil
@Published var selectedAutocompleteIndex: Int = 0 @Published var selectedAutocompleteIndex: Int = 0
// Autocomplete optimization
private let mentionRegex = try? NSRegularExpression(pattern: "@([a-zA-Z0-9_]*)$", options: [])
private var cachedNicknames: [String] = []
private var lastNicknameUpdate: Date = .distantPast
// Temporary property to fix compilation // Temporary property to fix compilation
@Published var showPasswordPrompt = false @Published var showPasswordPrompt = false
@@ -837,49 +842,76 @@ class ChatViewModel: ObservableObject {
} }
func updateAutocomplete(for text: String, cursorPosition: Int) { func updateAutocomplete(for text: String, cursorPosition: Int) {
// Quick early exit for empty text
guard cursorPosition > 0 else {
if showAutocomplete {
showAutocomplete = false
autocompleteSuggestions = []
autocompleteRange = nil
}
return
}
// Find @ symbol before cursor // Find @ symbol before cursor
let beforeCursor = String(text.prefix(cursorPosition)) let beforeCursor = String(text.prefix(cursorPosition))
// Look for @ pattern // Use cached regex
let pattern = "@([a-zA-Z0-9_]*)$" guard let regex = mentionRegex,
guard let regex = try? NSRegularExpression(pattern: pattern, options: []),
let match = regex.firstMatch(in: beforeCursor, options: [], range: NSRange(location: 0, length: beforeCursor.count)) else { let match = regex.firstMatch(in: beforeCursor, options: [], range: NSRange(location: 0, length: beforeCursor.count)) else {
showAutocomplete = false if showAutocomplete {
autocompleteSuggestions = [] showAutocomplete = false
autocompleteRange = nil autocompleteSuggestions = []
autocompleteRange = nil
}
return return
} }
// Extract the partial nickname // Extract the partial nickname
let partialRange = match.range(at: 1) let partialRange = match.range(at: 1)
guard let range = Range(partialRange, in: beforeCursor) else { guard let range = Range(partialRange, in: beforeCursor) else {
showAutocomplete = false if showAutocomplete {
autocompleteSuggestions = [] showAutocomplete = false
autocompleteRange = nil autocompleteSuggestions = []
autocompleteRange = nil
}
return return
} }
let partial = String(beforeCursor[range]).lowercased() let partial = String(beforeCursor[range]).lowercased()
// Get all available nicknames (excluding self) // Update cached nicknames only if peer list changed (check every 1 second max)
let peerNicknames = meshService.getPeerNicknames() let now = Date()
let allNicknames = Array(peerNicknames.values) if now.timeIntervalSince(lastNicknameUpdate) > 1.0 || cachedNicknames.isEmpty {
let peerNicknames = meshService.getPeerNicknames()
cachedNicknames = Array(peerNicknames.values).sorted()
lastNicknameUpdate = now
}
// Filter suggestions // Filter suggestions using cached nicknames
let suggestions = allNicknames.filter { nick in let suggestions = cachedNicknames.filter { nick in
nick.lowercased().hasPrefix(partial) nick.lowercased().hasPrefix(partial)
}.sorted() }
// Batch UI updates
if !suggestions.isEmpty { if !suggestions.isEmpty {
autocompleteSuggestions = suggestions // Only update if suggestions changed
showAutocomplete = true if autocompleteSuggestions != suggestions {
autocompleteRange = match.range(at: 0) // Store full @mention range autocompleteSuggestions = suggestions
}
if !showAutocomplete {
showAutocomplete = true
}
if autocompleteRange != match.range(at: 0) {
autocompleteRange = match.range(at: 0)
}
selectedAutocompleteIndex = 0 selectedAutocompleteIndex = 0
} else { } else {
showAutocomplete = false if showAutocomplete {
autocompleteSuggestions = [] showAutocomplete = false
autocompleteRange = nil autocompleteSuggestions = []
selectedAutocompleteIndex = 0 autocompleteRange = nil
selectedAutocompleteIndex = 0
}
} }
} }
+16 -4
View File
@@ -28,6 +28,7 @@ struct ContentView: View {
@FocusState private var isNicknameFieldFocused: Bool @FocusState private var isNicknameFieldFocused: Bool
@State private var lastScrollTime: Date = .distantPast @State private var lastScrollTime: Date = .distantPast
@State private var scrollThrottleTimer: Timer? @State private var scrollThrottleTimer: Timer?
@State private var autocompleteDebounceTimer: Timer?
private var backgroundColor: Color { private var backgroundColor: Color {
colorScheme == .dark ? Color.black : Color.white colorScheme == .dark ? Color.black : Color.white
@@ -159,6 +160,11 @@ struct ContentView: View {
Button("cancel", role: .cancel) {} Button("cancel", role: .cancel) {}
} }
.onDisappear {
// Clean up timers
scrollThrottleTimer?.invalidate()
autocompleteDebounceTimer?.invalidate()
}
} }
private func messagesView(privatePeer: String?) -> some View { private func messagesView(privatePeer: String?) -> some View {
@@ -399,11 +405,17 @@ struct ContentView: View {
.focused($isTextFieldFocused) .focused($isTextFieldFocused)
.padding(.leading, 12) .padding(.leading, 12)
.onChange(of: messageText) { newValue in .onChange(of: messageText) { newValue in
// Get cursor position (approximate - end of text for now) // Cancel previous debounce timer
let cursorPosition = newValue.count autocompleteDebounceTimer?.invalidate()
viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
// Check for command autocomplete // Debounce autocomplete updates to reduce calls during rapid typing
autocompleteDebounceTimer = Timer.scheduledTimer(withTimeInterval: 0.15, repeats: false) { _ in
// Get cursor position (approximate - end of text for now)
let cursorPosition = newValue.count
viewModel.updateAutocomplete(for: newValue, cursorPosition: cursorPosition)
}
// Check for command autocomplete (instant, no debounce needed)
if newValue.hasPrefix("/") && newValue.count >= 1 { if newValue.hasPrefix("/") && newValue.count >= 1 {
// Build context-aware command list // Build context-aware command list
let commandDescriptions = [ let commandDescriptions = [