PadXcode-iPad/Editor/CompletionController.swift

86 lines
3.5 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 = autoTriggers.contains(Character(Unicode.Scalar(c)!))
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 }
await 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, column: loc.column)
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)
}
}