krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Sources/OrgEditorAppKit/CompletionPopup.swift

2fa201330f61c801f777baa3f2943d49d8758cda
orgstar/Sources/OrgEditorAppKit/CompletionPopup.swift history · blame · raw

193 lines · 8186 bytes

  1#if os(macOS)
  2import AppKit
  3import OrgCore
  4
  5/// The candidate list under the caret, as corfu shows it: at most 16 rows, nothing selected
  6/// until C-n or an arrow picks one.
  7@MainActor
  8final class CompletionPopup {
  9    static let rows = 16
 10    let window: NSPanel
 11    private let stack = NSStackView()
 12    private(set) var completion: Completion
 13    /// The chosen row; nil while the typed text stands (`corfu-preselect` prompt).
 14    private(set) var selection: Int?
 15
 16    init(_ completion: Completion) {
 17        self.completion = completion
 18        window = NSPanel(contentRect: NSRect(x: 0, y: 0, width: 200, height: 20), styleMask: [.borderless, .nonactivatingPanel], backing: .buffered, defer: true)
 19        window.identifier = NSUserInterfaceItemIdentifier("completion")
 20        window.hasShadow = true
 21        window.backgroundColor = .controlBackgroundColor
 22        window.isReleasedWhenClosed = false
 23        stack.orientation = .vertical
 24        stack.alignment = .leading
 25        stack.spacing = 0
 26        stack.edgeInsets = NSEdgeInsets(top: 2, left: 0, bottom: 2, right: 0)
 27        window.contentView = stack
 28        window.contentView?.wantsLayer = true
 29        window.contentView?.layer?.borderColor = NSColor.separatorColor.cgColor
 30        window.contentView?.layer?.borderWidth = 1
 31    }
 32
 33    func update(_ completion: Completion) {
 34        if completion.candidates != self.completion.candidates { selection = nil }
 35        self.completion = completion
 36    }
 37
 38    func move(_ delta: Int) {
 39        let count = completion.candidates.count
 40        guard count > 0 else { return }
 41        // corfu-cycle: past either end wraps through the typed text.
 42        if let s = selection {
 43            let next = s + delta
 44            selection = next < 0 || next >= count ? nil : next
 45        } else {
 46            selection = delta > 0 ? 0 : count - 1
 47        }
 48    }
 49
 50    var selected: String? { selection.map { completion.candidates[$0] } }
 51
 52    /// Lays the rows out and puts the list under `caret`, a rectangle in screen coordinates.
 53    func show(under caret: NSRect, in parent: NSWindow?, font: NSFont) {
 54        stack.arrangedSubviews.forEach { $0.removeFromSuperview() }
 55        let candidates = completion.candidates
 56        let first = max(0, min((selection ?? 0) - Self.rows + 1, candidates.count - Self.rows))
 57        var width: CGFloat = 80
 58        for (index, candidate) in candidates.enumerated().dropFirst(first).prefix(Self.rows) {
 59            let label = NSTextField(labelWithString: " " + completion.label(candidate) + " ")
 60            label.font = font
 61            label.drawsBackground = index == selection
 62            label.backgroundColor = .selectedContentBackgroundColor
 63            label.textColor = index == selection ? .alternateSelectedControlTextColor : .labelColor
 64            label.lineBreakMode = .byTruncatingTail
 65            stack.addArrangedSubview(label)
 66            width = max(width, label.intrinsicContentSize.width)
 67        }
 68        width = min(width, 120 * font.maximumAdvancement.width)
 69        for label in stack.arrangedSubviews { label.widthAnchor.constraint(equalToConstant: width).isActive = true }
 70        let height = stack.fittingSize.height
 71        window.setContentSize(NSSize(width: width, height: height))
 72        window.setFrameTopLeftPoint(NSPoint(x: caret.minX, y: caret.minY - 2))
 73        if window.parent == nil { parent?.addChildWindow(window, ordered: .above) }
 74        window.orderFront(nil)
 75    }
 76
 77    func close() {
 78        window.parent?.removeChildWindow(window)
 79        window.orderOut(nil)
 80    }
 81}
 82
 83extension OrgEditor {
 84    /// `completion-at-point`: one candidate is inserted, several are listed.
 85    func completeAtPoint() {
 86        guard let completion = OrgCompletion.at(context()), !completion.candidates.isEmpty else {
 87            closeCompletion()
 88            onMessage?("No match")
 89            return
 90        }
 91        if completion.candidates.count == 1 {
 92            closeCompletion()
 93            insertCompletion(completion, completion.candidates[0])
 94            return
 95        }
 96        showCompletion(completion)
 97    }
 98
 99    func showCompletion(_ completion: Completion) {
100        if let popup = completionPopup { popup.update(completion) } else { completionPopup = CompletionPopup(completion) }
101        let screen = textView.firstRect(forCharacterRange: NSRange(location: completion.range.lowerBound, length: 0), actualRange: nil)
102        completionPopup?.show(under: screen, in: textView.window, font: theme.font)
103    }
104
105    func closeCompletion() {
106        completionTimer?.cancel()
107        completionPopup?.close()
108        completionPopup = nil
109    }
110
111    /// After typing: the open list follows the text; otherwise, with `modal` (corfu-auto), a
112    /// list opens after a pause once two characters are typed.
113    func textTyped() {
114        completionTimer?.cancel()
115        if completionPopup != nil {
116            refreshCompletion()
117            return
118        }
119        guard modal, vim.mode == .insert, isOrg else { return }
120        let work = DispatchWorkItem { [weak self] in self?.autoComplete() }
121        completionTimer = work
122        DispatchQueue.main.asyncAfter(deadline: .now() + 0.4, execute: work)
123    }
124
125    func autoComplete() {
126        guard completionPopup == nil, textView.selectedRange().length == 0,
127              let completion = OrgCompletion.at(context()), completion.range.count >= 2, !completion.candidates.isEmpty else { return }
128        showCompletion(completion)
129    }
130
131    func refreshCompletion() {
132        guard let popup = completionPopup else { return }
133        guard textView.selectedRange().length == 0, let completion = OrgCompletion.at(context()),
134              completion.range.lowerBound == popup.completion.range.lowerBound, !completion.candidates.isEmpty else {
135            closeCompletion()
136            return
137        }
138        showCompletion(completion)
139    }
140
141    func insertCompletion(_ completion: Completion, _ candidate: String) {
142        let (text, caret) = completion.insertion(candidate)
143        let range = NSRange(location: completion.range.lowerBound, length: completion.range.count)
144        guard textView.shouldChangeText(in: range, replacementString: text) else { return }
145        textView.textStorage?.replaceCharacters(in: range, with: text)
146        textView.didChangeText()
147        setCaret(completion.range.lowerBound + caret)
148    }
149
150    /// Keys while the list is open. Nil lets the key through.
151    func completionKey(_ chord: KeyChord) -> Bool? {
152        guard let popup = completionPopup else { return nil }
153        let completion = popup.completion
154        switch chord {
155        case KeyChord("n", .control), KeyChord("<down>"), KeyChord("j", .control):
156            popup.move(1)
157        case KeyChord("p", .control), KeyChord("<up>"), KeyChord("k", .control):
158            popup.move(-1)
159        case KeyChord("TAB"):
160            // corfu-complete: the selection, else the candidates' common start.
161            if let selected = popup.selected {
162                closeCompletion()
163                insertCompletion(completion, selected)
164            } else if completion.candidates.count == 1 {
165                closeCompletion()
166                insertCompletion(completion, completion.candidates[0])
167            } else {
168                let common = completion.candidates.dropFirst().reduce(completion.candidates[0]) { $0.commonPrefix(with: $1) }
169                if common.utf16.count > completion.range.count, completion.kind != .template {
170                    insertCompletion(completion, common)
171                    refreshCompletion()
172                }
173            }
174            return true
175        case KeyChord("RET"):
176            guard let selected = popup.selected else {
177                closeCompletion()
178                return nil
179            }
180            closeCompletion()
181            insertCompletion(completion, selected)
182            return true
183        case KeyChord("ESC"), KeyChord("g", .control):
184            closeCompletion()
185            return chord == KeyChord("ESC") ? nil : true
186        default:
187            return nil
188        }
189        showCompletion(popup.completion)
190        return true
191    }
192}
193#endif