import UIKit import Runestone @MainActor final class CompletionController: ObservableObject { @Published var items: [CompletionItem] = [] @Published var isVisible: Bool = false @Published var anchorRect: CGRect = .zero private weak var textView: Runestone.TextView? private let lspClient: LSPClient private var debounceTask: Task? private var lastId = 0 private let autoTriggers: Set = [".", "(", ","] init(lspClient: LSPClient) { self.lspClient = lspClient } func attach(to tv: Runestone.TextView) { self.textView = tv } func textDidChange(in tv: Runestone.TextView, filePath: String) { let sel = tv.selectedRange guard sel.location > 0 else { dismiss(); return } let text = tv.text as NSString let c = text.character(at: sel.location - 1) let shouldTrigger = Unicode.Scalar(c).map { autoTriggers.contains(Character($0)) } ?? false if shouldTrigger { requestCompletions(tv: tv, filePath: filePath) } else if isVisible { debounceTask?.cancel() debounceTask = Task { [weak self] in try? await Task.sleep(for: .milliseconds(200)) guard !Task.isCancelled else { return } self?.requestCompletions(tv: tv, filePath: filePath) } } } func requestManual(tv: Runestone.TextView, filePath: String) { requestCompletions(tv: tv, filePath: filePath) } func dismiss() { debounceTask?.cancel(); items = []; isVisible = false } func insert(_ item: CompletionItem, into tv: Runestone.TextView) { let sel = tv.selectedRange let text = tv.text as NSString let wordRange = text.rangeOfCurrentWord(at: sel.location) let insertText = item.insertText ?? item.label tv.replace(wordRange, withText: insertText) if insertText.hasSuffix("()") { let loc = tv.selectedRange.location > 0 ? tv.selectedRange.location - 1 : 0 tv.selectedRange = NSRange(location: loc, length: 0) } dismiss() } private func requestCompletions(tv: Runestone.TextView, filePath: String) { let sel = tv.selectedRange guard let loc = tv.textLocation(at: sel.location) else { return } // caretRect(for:) on Runestone.TextView takes UITextPosition, not Int. // Approximate anchor position from font metrics for the completion popover. let font = tv.theme.font let lineHeight = ceil(font.lineHeight * tv.lineHeightMultiplier) let approxY = CGFloat(loc.lineNumber) * lineHeight + tv.textContainerInset.top - tv.contentOffset.y anchorRect = CGRect(x: 0, y: approxY, width: 0, height: lineHeight) lastId += 1; let id = lastId Task { [weak self] in guard let self else { return } let results = await lspClient.requestCompletions(filePath: filePath, line: loc.lineNumber + 1, column: loc.column + 1) guard self.lastId == id else { return } if results.isEmpty { self.dismiss() } else { self.items = results; self.isVisible = true } } } } private extension NSString { func rangeOfCurrentWord(at location: Int) -> NSRange { let wordChars = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "_")) var start = location while start > 0, let scalar = Unicode.Scalar(character(at: start - 1)), wordChars.contains(scalar) { start -= 1 } return NSRange(location: start, length: location - start) } }