86 lines
3.6 KiB
Swift
86 lines
3.6 KiB
Swift
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<Void, Never>?
|
|
private var lastId = 0
|
|
private let autoTriggers: Set<Character> = [".", "(", ","]
|
|
|
|
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)
|
|
}
|
|
}
|