Files
luma/Luma/Editor/CodeCompletionPanel.swift
Ole André Vadla Ravnås b88a9be290 Replace SwiftyMonaco with native editors
Drop the Monaco webview. Edit in a GtkSourceView on GTK and in a
native text view on Apple, both speaking to Frida.Compiler's
in-process language server for completions, signature help,
hovers and diagnostics.

Color, complete and indent code natively, matching the GitHub
theme in light and dark.
2026-09-08 19:52:31 +02:00

249 lines
11 KiB
Swift

#if canImport(AppKit)
import AppKit
import LumaCore
@MainActor
final class CodeCompletionPanel: NSObject, NSTableViewDataSource, NSTableViewDelegate {
var onAccept: ((LSP.CompletionItem) -> Void)?
var describe: ((LSP.CompletionItem) async -> LSP.CompletionItem)?
var classify: ((String) async -> [SemanticToken])?
private let panel: NSPanel
private let table = NSTableView()
private let detailField = NSTextField(wrappingLabelWithString: "")
private let documentationField = NSTextField(wrappingLabelWithString: "")
private let detailPane = NSStackView()
private let listWidth: CGFloat = 320
private let rowHeight: CGFloat = 22
private var items: [LSP.CompletionItem] = []
private var describing: Task<Void, Never>?
override init() {
panel = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 100),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: true
)
super.init()
panel.level = .floating
panel.hasShadow = true
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hidesOnDeactivate = true
let labelColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("label"))
labelColumn.width = 196
let kindColumn = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("kind"))
kindColumn.width = 90
table.addTableColumn(labelColumn)
table.addTableColumn(kindColumn)
table.headerView = nil
table.rowHeight = rowHeight
table.intercellSpacing = NSSize(width: 6, height: 0)
table.selectionHighlightStyle = .regular
table.allowsEmptySelection = false
table.refusesFirstResponder = true
table.dataSource = self
table.delegate = self
table.target = self
table.action = #selector(tableClicked)
table.doubleAction = #selector(tableDoubleClicked)
let scroll = NSScrollView()
scroll.documentView = table
scroll.hasVerticalScroller = true
scroll.autohidesScrollers = true
scroll.drawsBackground = false
scroll.translatesAutoresizingMaskIntoConstraints = false
scroll.widthAnchor.constraint(equalToConstant: listWidth).isActive = true
detailField.font = NSFont.monospacedSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular)
documentationField.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)
documentationField.textColor = .secondaryLabelColor
for field in [detailField, documentationField] {
field.preferredMaxLayoutWidth = 300
field.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
}
detailPane.orientation = .vertical
detailPane.alignment = .leading
detailPane.spacing = 6
detailPane.edgeInsets = NSEdgeInsets(top: 8, left: 10, bottom: 8, right: 10)
detailPane.addArrangedSubview(detailField)
detailPane.addArrangedSubview(documentationField)
detailPane.translatesAutoresizingMaskIntoConstraints = false
detailPane.widthAnchor.constraint(equalToConstant: listWidth).isActive = true
detailPane.isHidden = true
let panes = NSStackView(views: [scroll, detailPane])
panes.orientation = .horizontal
panes.alignment = .top
panes.spacing = 0
panes.distribution = .fill
let content = NSVisualEffectView()
content.material = .popover
content.state = .active
content.wantsLayer = true
content.layer?.cornerRadius = 8
content.layer?.masksToBounds = true
content.layer?.borderWidth = 1
content.layer?.borderColor = NSColor.separatorColor.cgColor
content.addSubview(panes)
panes.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
panes.leadingAnchor.constraint(equalTo: content.leadingAnchor),
panes.trailingAnchor.constraint(equalTo: content.trailingAnchor),
panes.topAnchor.constraint(equalTo: content.topAnchor),
panes.bottomAnchor.constraint(equalTo: content.bottomAnchor),
])
panel.contentView = content
}
var isShown: Bool {
panel.isVisible
}
var selectedItem: LSP.CompletionItem? {
let row = table.selectedRow
return row >= 0 && row < items.count ? items[row] : nil
}
func show(_ newItems: [LSP.CompletionItem], belowScreenRect anchor: NSRect, of view: NSView) {
items = newItems
table.reloadData()
table.selectRowIndexes(IndexSet(integer: 0), byExtendingSelection: false)
table.scrollRowToVisible(0)
layout(below: anchor, in: view)
if !panel.isVisible {
view.window?.addChildWindow(panel, ordered: .above)
panel.orderFront(nil)
}
describeSelection()
}
private func layout(below anchor: NSRect, in view: NSView) {
guard let window = view.window else { return }
let listHeight = CGFloat(min(items.count, 8)) * rowHeight + 4
let width = listWidth + (detailPane.isHidden ? 0 : listWidth)
var origin = NSPoint(x: anchor.minX, y: anchor.minY - listHeight - 4)
if let screen = window.screen {
origin.x = min(origin.x, screen.visibleFrame.maxX - width)
if origin.y < screen.visibleFrame.minY {
origin.y = anchor.maxY + 4
}
}
panel.setFrame(NSRect(x: origin.x, y: origin.y, width: width, height: listHeight), display: true)
}
func moveSelection(by delta: Int) {
guard !items.isEmpty else { return }
var row = table.selectedRow + delta
if row < 0 { row = items.count - 1 }
if row >= items.count { row = 0 }
table.selectRowIndexes(IndexSet(integer: row), byExtendingSelection: false)
table.scrollRowToVisible(row)
}
func dismiss() {
describing?.cancel()
guard panel.isVisible else { return }
panel.parent?.removeChildWindow(panel)
panel.orderOut(nil)
detailPane.isHidden = true
items = []
table.reloadData()
}
private func describeSelection() {
describing?.cancel()
guard let item = selectedItem, let describe else { return }
describing = Task { @MainActor in
let resolved = await describe(item)
guard !Task.isCancelled, selectedItem?.label == item.label else { return }
let detail = resolved.detail ?? ""
let documentation = resolved.documentation?.text ?? ""
let tokens = detail.isEmpty ? [] : (await classify?(detail) ?? [])
guard !Task.isCancelled, selectedItem?.label == item.label else { return }
detailField.attributedStringValue = highlighted(detail, tokens: tokens)
detailField.isHidden = detail.isEmpty
documentationField.stringValue = documentation
documentationField.isHidden = documentation.isEmpty
detailPane.isHidden = detail.isEmpty && documentation.isEmpty
var frame = panel.frame
frame.size.width = listWidth + (detailPane.isHidden ? 0 : listWidth)
panel.setFrame(frame, display: true)
}
}
private func highlighted(_ code: String, tokens: [SemanticToken]) -> NSAttributedString {
let font = detailField.font ?? NSFont.monospacedSystemFont(ofSize: NSFont.smallSystemFontSize, weight: .regular)
let dark = (panel.contentView?.effectiveAppearance ?? NSApp.effectiveAppearance).bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
let units = Array(code.utf16)
let result = NSMutableAttributedString()
for run in SourceHighlighter.runs(of: code, semanticTokens: tokens, dark: dark) {
let piece = String(utf16CodeUnits: Array(units[run.range]), count: run.range.count)
let color = run.color.map(NSColor.init(rgb:)) ?? NSColor.labelColor
result.append(NSAttributedString(string: piece, attributes: [.font: font, .foregroundColor: color]))
}
return result
}
@objc private func tableClicked() {
describeSelection()
}
@objc private func tableDoubleClicked() {
if let item = selectedItem {
onAccept?(item)
}
}
func numberOfRows(in tableView: NSTableView) -> Int {
items.count
}
func tableView(_ tableView: NSTableView, objectValueFor tableColumn: NSTableColumn?, row: Int) -> Any? {
let item = items[row]
if tableColumn?.identifier.rawValue == "kind" {
return completionKindName(item.kind) ?? ""
}
return item.label
}
func tableView(_ tableView: NSTableView, willDisplayCell cell: Any, for tableColumn: NSTableColumn?, row: Int) {
guard let cell = cell as? NSTextFieldCell else { return }
if tableColumn?.identifier.rawValue == "kind" {
cell.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)
cell.textColor = .secondaryLabelColor
cell.alignment = .right
} else {
cell.font = NSFont.monospacedSystemFont(ofSize: NSFont.systemFontSize, weight: .regular)
}
}
func tableViewSelectionDidChange(_ notification: Notification) {
describeSelection()
}
}
func completionKindName(_ kind: Int?) -> String? {
switch kind {
case 2: return "method"
case 3: return "function"
case 4: return "constructor"
case 5: return "field"
case 6: return "variable"
case 7: return "class"
case 8: return "interface"
case 9: return "module"
case 10: return "property"
case 13: return "enum"
case 14: return "keyword"
case 21: return "constant"
case 25: return "type"
default: return nil
}
}
#endif