PadXcode-iPad/Editor/CompletionController.swift

87 lines
3.6 KiB
Swift
Raw Permalink Normal View History

2026-04-12 00:46:30 -07:00
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) {
2026-04-12 22:07:35 -07:00
let sel = tv.selectedRange
guard sel.location > 0 else { dismiss(); return }
2026-04-12 00:46:30 -07:00
let text = tv.text as NSString
let c = text.character(at: sel.location - 1)
2026-04-12 22:07:35 -07:00
let shouldTrigger = Unicode.Scalar(c).map { autoTriggers.contains(Character($0)) } ?? false
2026-04-12 00:46:30 -07:00
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 }
2026-04-12 22:07:35 -07:00
self?.requestCompletions(tv: tv, filePath: filePath)
2026-04-12 00:46:30 -07:00
}
}
}
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) {
2026-04-12 22:07:35 -07:00
let sel = tv.selectedRange
2026-04-12 00:46:30 -07:00
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("()") {
2026-04-12 22:07:35 -07:00
let loc = tv.selectedRange.location > 0 ? tv.selectedRange.location - 1 : 0
2026-04-12 00:46:30 -07:00
tv.selectedRange = NSRange(location: loc, length: 0)
}
dismiss()
}
private func requestCompletions(tv: Runestone.TextView, filePath: String) {
2026-04-12 22:07:35 -07:00
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)
2026-04-12 00:46:30 -07:00
lastId += 1; let id = lastId
Task { [weak self] in
guard let self else { return }
2026-04-12 22:07:35 -07:00
let results = await lspClient.requestCompletions(filePath: filePath, line: loc.lineNumber + 1, column: loc.column + 1)
2026-04-12 00:46:30 -07:00
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)
}
}