Sources/OrgEditorAppKit/CompletionPopup.swift
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