Completion at point with a candidate popup !102

merged merged by cmc on 2026-10-06 06:31 UTC · krz/orgstar:completion into main

10 files changed, +682 −1

Layout: unified · split

Sources/OrgCore/Completion/CompletionData.swift added +74
@@ -0,0 +1,74 @@
1// From Org 9.8.7 with the default export back-ends (ascii, html, icalendar, latex, odt).
2
3extension OrgCompletion {
4 /// `org-options-keywords`, affiliated keywords, `org-block-keywords` and the back-ends'
5 /// keywords, as `pcomplete/org-mode/file-option` offers them.
6 static let fileOptions: [String] = {
7 let options = ["ARCHIVE:", "AUTHOR:", "BIBLIOGRAPHY:", "BIND:", "CATEGORY:", "CITE_EXPORT:", "COLUMNS:", "CREATOR:", "DATE:", "DESCRIPTION:",
8 "DRAWERS:", "EMAIL:", "EXCLUDE_TAGS:", "FILETAGS:", "INCLUDE:", "INDEX:", "KEYWORDS:", "LANGUAGE:", "MACRO:", "OPTIONS:",
9 "PROPERTY:", "PRINT_BIBLIOGRAPHY:", "PRIORITIES:", "SELECT_TAGS:", "SEQ_TODO:", "SETUPFILE:", "STARTUP:", "TAGS:", "TITLE:",
10 "TODO:", "TYP_TODO:", "EXPORT_FILE_NAME:"]
11 let affiliated = ["CAPTION", "DATA", "HEADER", "HEADERS", "LABEL", "NAME", "PLOT", "RESNAME", "RESULT", "RESULTS", "SOURCE", "SRCNAME", "TBLNAME"]
12 let blocks = ["CENTER", "COMMENT", "EXAMPLE", "EXPORT", "QUOTE", "SRC", "VERSE"].flatMap { name in
13 ["ATTR_\(name): ", "BEGIN_\(name)" + (name == "EXPORT" || name == "SRC" ? " " : ""), "END_\(name)"]
14 }
15 let export = ["SUBTITLE", "ATTR_ASCII", "ASCII", "LATEX_HEADER", "CREATOR", "INFOJS_OPT", "HTML_HEAD_EXTRA", "HTML_HEAD",
16 "HTML_EQUATION_REFERENCE_FORMAT", "HTML_MATHJAX", "HTML_LINK_UP", "HTML_LINK_HOME", "KEYWORDS", "DESCRIPTION",
17 "HTML_CONTENT_CLASS", "HTML_CONTAINER", "HTML_DOCTYPE", "ATTR_HTML", "HTML", "ICAL-TTL", "ICALENDAR_EXCLUDE_TAGS",
18 "ATTR_ICALENDAR", "ICALENDAR", "DATE", "LATEX_COMPILER", "LATEX_ENGRAVED_THEME", "LATEX_FOOTNOTE_COMMAND",
19 "LATEX_CLASS_PRE", "LATEX_HEADER_EXTRA", "LATEX_CLASS_OPTIONS", "LATEX_CLASS", "ATTR_LATEX", "LATEX", "ODT_STYLES_FILE",
20 "ATTR_ODT", "ODT"]
21 return options.map { $0 + " " } + affiliated.map { $0 + ": " } + blocks + export.map { $0 + ": " }
22 }()
23
24 /// `org-startup-options`.
25 static let startupOptions = [
26 "align", "beamer", "constSI", "constcgs", "content", "customtime", "descriptivelinks", "entitiesplain", "entitiespretty", "fnadjust",
27 "fnanon", "fnauto", "fnconfirm", "fninline", "fnlocal", "fnplain", "fnprompt", "fold", "hideblocks", "hidedrawers", "hidestars", "indent",
28 "inlineimages", "latexpreview", "linkpreviews", "literallinks", "logdone", "logdrawer", "lognoteclock-out", "lognotedone",
29 "lognoteredeadline", "lognoterefile", "lognoterepeat", "lognotereschedule", "logredeadline", "logrefile", "logrepeat", "logreschedule",
30 "logstatesreversed", "noalign", "nofnadjust", "nofninline", "nofold", "nohideblocks", "nohidedrawers", "noindent", "noinlineimages",
31 "nolatexpreview", "nolinkpreviews", "nologdone", "nologdrawer", "nolognoteclock-out", "nologredeadline", "nologrefile", "nologrepeat",
32 "nologreschedule", "nologstatesreversed", "nonum", "noptag", "num", "odd", "oddeven", "overview", "show2levels", "show3levels",
33 "show4levels", "show5levels", "showall", "showeverything", "showstars", "shrink",
34 ]
35
36 /// `#+OPTIONS` items.
37 static let exportOptions = [
38 "H:", "\\n:", "num:", "timestamp:", "arch:", "author:", "c:", "creator:", "date:", "d:", "email:", "*:", "e:", "::", "f:", "inline:",
39 "tex:", "p:", "pri:", "':", "-:", "stat:", "^:", "toc:", "|:", "tags:", "tasks:", "<:", "todo:",
40 "html-self-link-headlines:", "html-scripts:", "html-style:", "html-preamble:", "html-postamble:", "html-link-use-abs-url:",
41 "html5-fancy:", "latex-use-sans:",
42 ]
43
44 /// The languages of `org-babel-load-languages`'s customization type.
45 static let languages = [
46 "awk", "C", "R", "calc", "clojure", "css", "ditaa", "dot", "emacs-lisp", "eshell", "forth", "fortran", "gnuplot", "groovy", "haskell",
47 "java", "js", "julia", "latex", "lilypond", "lisp", "lua", "makefile", "maxima", "ocaml", "octave", "org", "perl", "processing",
48 "plantuml", "python", "ruby", "sass", "scheme", "screen", "sed", "shell", "sql", "sqlite",
49 ]
50
51 /// `org-babel-common-header-args-w-values` and switches.
52 static let srcHeaders = [
53 ":cache", ":cmdline", ":colnames", ":comments", ":dir", ":eval", ":exports", ":epilogue", ":file", ":file-desc", ":file-ext",
54 ":file-mode", ":hlines", ":mkdirp", ":no-expand", ":noeval", ":noweb", ":noweb-ref", ":noweb-sep", ":noweb-prefix", ":output-dir",
55 ":padline", ":post", ":prologue", ":results", ":rownames", ":sep", ":session", ":shebang", ":tangle", ":tangle-mode", ":var", ":wrap",
56 "-n", "-r", "-l",
57 ]
58
59 static let clocktableOptions = [
60 ":maxlevel", ":scope", ":lang", ":tstart", ":tend", ":block", ":step", ":stepskip0", ":fileskip0", ":emphasize", ":link", ":narrow",
61 ":indent", ":hidefiles", ":tcolumns", ":level", ":compact", ":timestamp", ":formula", ":formatter", ":wstart", ":mstart",
62 ]
63
64 /// `org-default-properties`.
65 static let defaultProperties = [
66 "ARCHIVE", "CATEGORY", "SUMMARY", "DESCRIPTION", "CUSTOM_ID", "LOCATION", "LOGGING", "COLUMNS", "VISIBILITY", "TABLE_EXPORT_FORMAT",
67 "TABLE_EXPORT_FILE", "EXPORT_OPTIONS", "EXPORT_TEXT", "EXPORT_FILE_NAME", "EXPORT_TITLE", "EXPORT_AUTHOR", "EXPORT_DATE", "UNNUMBERED",
68 "ORDERED", "NOBLOCKING", "COOKIE_DATA", "LOG_INTO_DRAWER", "REPEAT_TO_STATE", "CLOCK_MODELINE_TOTAL", "STYLE", "HTML_CONTAINER_CLASS",
69 "ORG-IMAGE-ACTUAL-WIDTH",
70 ]
71
72 /// Link types offered after `[[` besides `#+LINK` abbreviations.
73 static let linkTypes = ["attachment", "doi", "file", "http", "https", "id", "mailto"]
74}
Sources/OrgCore/Completion/OrgCompletion.swift added +246
@@ -0,0 +1,246 @@
1import Foundation
2
3// Completion at point (Org 9.8.7's org-pcomplete): `org-thing-at-point` picks what the caret
4// is on, and each context offers what its `pcomplete/org-mode/…` function does. Link types
5// after `[[`, workspace tags and org-tempo's `<` templates are added.
6
7public struct Completion: Sendable, Equatable {
8 public enum Kind: String, Sendable {
9 case keyword, keywordValue, link, entity, tag, todo, heading, drawer, property, srcLanguage, srcHeader, clocktable, template
10 }
11
12 public var kind: Kind
13 /// The text the chosen candidate replaces: from the start of what's typed to the caret.
14 public var range: Range<Int>
15 /// Candidates starting with what's typed, sorted.
16 public var candidates: [String]
17
18 /// How the list shows `candidate`: a template with the block it makes.
19 public func label(_ candidate: String) -> String {
20 guard kind == .template, let value = OrgCompletion.templateKeys.first(where: { "<" + $0.key == candidate })?.value else { return candidate }
21 return candidate + " " + value
22 }
23
24 /// Text to insert for `candidate`, and where the caret goes in it.
25 public func insertion(_ candidate: String) -> (text: String, caret: Int) {
26 guard kind == .template, let expansion = OrgCompletion.templateExpansion(candidate) else { return (candidate, candidate.utf16.count) }
27 return expansion
28 }
29}
30
31public enum OrgCompletion {
32 public static func at(_ context: EditContext) -> Completion? {
33 let ns = context.text as NSString
34 let caret = min(context.caret, ns.length)
35 let lineRange = ns.lineRange(for: NSRange(location: caret, length: 0))
36 var contentsEnd = 0
37 ns.getLineStart(nil, end: nil, contentsEnd: &contentsEnd, for: lineRange)
38 let line = ns.substring(with: NSRange(location: lineRange.location, length: contentsEnd - lineRange.location))
39 let start = lineRange.location
40 let toHere = ns.substring(with: NSRange(location: start, length: caret - start))
41 let afterBlank = ns.substring(with: NSRange(location: caret, length: contentsEnd - caret)).allSatisfy { $0 == " " || $0 == "\t" }
42 /// `pcomplete`'s current argument: from the last blank or `[` on the line to the caret.
43 let argStart: Int = {
44 var p = caret
45 while p > start, ![32, 9, 91].contains(ns.character(at: p - 1)) { p -= 1 }
46 return p
47 }()
48 func make(_ kind: Completion.Kind, from: Int, _ candidates: [String]) -> Completion {
49 let typed = ns.substring(with: NSRange(location: from, length: caret - from))
50 var seen = Set<String>()
51 let matching = candidates.filter { $0.hasPrefix(typed) && seen.insert($0).inserted }
52 return Completion(kind: kind, range: from..<caret, candidates: matching.sorted { $0.localizedStandardCompare($1) == .orderedAscending })
53 }
54 func matches(_ pattern: String, _ s: String, caseInsensitive: Bool = true) -> NSTextCheckingResult? {
55 let regex = try! NSRegularExpression(pattern: pattern, options: caseInsensitive ? [.caseInsensitive] : [])
56 return regex.firstMatch(in: s, range: NSRange(location: 0, length: (s as NSString).length))
57 }
58 /// Arguments before the current one on the line.
59 func arguments() -> [String] {
60 ns.substring(with: NSRange(location: start, length: argStart - start)).split { " \t[".contains($0) }.map(String.init)
61 }
62
63 // Templates (org-tempo): `<` and a key after only blanks.
64 if let m = matches("^[ \\t]*<[A-Za-z]*$", toHere, caseInsensitive: false) {
65 // The expansion replaces the indentation too.
66 var completion = make(.template, from: start + (toHere as NSString).range(of: "<").location, templateKeys.map { "<" + $0.key })
67 completion.range = start..<(start + m.range.length)
68 return completion
69 }
70 if matches("^[ \\t]*#\\+BEGIN: clocktable[ \\t]", line) != nil {
71 return make(.clocktable, from: argStart, clocktableOptions)
72 }
73 if matches("^[ \\t]*#\\+BEGIN_SRC[ \\t]", line) != nil {
74 return arguments().count == 1 ? make(.srcLanguage, from: argStart, languages) : make(.srcHeader, from: argStart, srcHeaders)
75 }
76 if let m = matches("^[ \\t]*#\\+(\\S+):", line) {
77 let keyword = (line as NSString).substring(with: m.range(at: 1)).lowercased()
78 let arguments = arguments()
79 // The keyword's own text, or (as pcomplete would) nothing past the value.
80 guard argStart >= start + m.range.upperBound else { return nil }
81 let values: [String]
82 switch keyword {
83 case "startup":
84 values = startupOptions.filter { !(arguments.contains("hidestars") && $0 == "showstars") }
85 case "options":
86 values = exportOptions
87 case _ where arguments.count > 1:
88 return nil
89 case "date":
90 let formatter = DateFormatter()
91 formatter.locale = Locale(identifier: "en_US_POSIX")
92 formatter.calendar = context.calendar
93 formatter.timeZone = context.calendar.timeZone
94 formatter.dateFormat = "yyyy-MM-dd EEE"
95 values = ["<" + formatter.string(from: context.now) + ">"]
96 case "exclude_tags": values = ["noexport"]
97 case "select_tags": values = ["export"]
98 case "language": values = ["en"]
99 case "priorities":
100 let p = context.tree.settings.priorities
101 values = ["\(p.highest) \(p.lowest) \(p.default)"]
102 default:
103 return nil
104 }
105 return make(.keywordValue, from: argStart, values)
106 }
107 if matches("^[ \\t]*#\\+[a-zA-Z_]*$", line) != nil, afterBlank {
108 let from = start + NSMaxRange((line as NSString).range(of: "#+"))
109 return make(.keyword, from: from, caseDouble(fileOptions))
110 }
111 // Link abbreviations and types after `[[`.
112 do {
113 var p = caret
114 while p > start, let ch = UnicodeScalar(ns.character(at: p - 1)), CharacterSet(charactersIn: "-_").union(.alphanumerics).contains(ch), ch.isASCII { p -= 1 }
115 if p - 2 >= start, ns.character(at: p - 1) == 91, ns.character(at: p - 2) == 91 {
116 let abbreviations = Links.abbreviations(in: context.text).keys.map { $0 + ":" }
117 return make(.link, from: p, abbreviations + linkTypes.map { $0 + ":" })
118 }
119 }
120 // Entities after `\`.
121 do {
122 var p = caret
123 while p > start, (48...57).contains(ns.character(at: p - 1)) { p -= 1 }
124 while p > start, let ch = UnicodeScalar(ns.character(at: p - 1)), ch.isASCII, CharacterSet.letters.contains(ch) { p -= 1 }
125 if p > start, ns.character(at: p - 1) == 92 {
126 return make(.entity, from: p, Array(OrgEntities.display.keys))
127 }
128 }
129 // Tags at the end of a heading.
130 if let m = matches("^\\*+ (?:.+? )?(:)((?::|[\\p{L}\\p{N}_@#%]+)+)?[ \\t]*$", line, caseInsensitive: false) {
131 let colon = start + m.range(at: 1).location + 1
132 let group = m.range(at: 2)
133 let inGroup = group.location != NSNotFound && caret >= start + group.location && caret <= start + group.location + group.length
134 if inGroup || caret == colon {
135 let typed = ns.substring(with: NSRange(location: colon, length: caret - colon))
136 let from = colon + ((typed as NSString).range(of: ":", options: .backwards).toOptional.map { NSMaxRange($0) } ?? 0)
137 let heading = DocumentModel(tree: context.tree).headings.last { $0.headingRange.lowerBound <= caret }
138 let own = Set(heading?.localTags ?? [])
139 let alist = FastSelection.tagTable(context.text)?.compactMap { item -> String? in
140 if case .option(_, let name) = item { return name }
141 return nil
142 } ?? []
143 let pool = alist.isEmpty ? SetTags.bufferTags(context) + context.knownTags() : alist
144 return make(.tag, from: from, pool.filter { !own.contains($0) }.map { $0 + ":" })
145 }
146 }
147 // TODO keywords on an empty heading.
148 if matches("^\\*+ +\\S*$", toHere, caseInsensitive: false) != nil, afterBlank {
149 let keywords = context.tree.settings.todoSequences.flatMap { $0.active + $0.done }.map(\.name)
150 return make(.todo, from: argStart, keywords)
151 }
152 // Headings after `[[*`.
153 if let star = (toHere as NSString).range(of: "*", options: .backwards).toOptional, star.location >= 2,
154 (toHere as NSString).substring(with: NSRange(location: star.location - 2, length: 2)) == "[[" {
155 let titles = DocumentModel(tree: context.tree).headings.map { Links.normalizedTitle($0.title) }
156 return make(.heading, from: start + star.location + 1, titles)
157 }
158 // Property or drawer names.
159 if matches("^[ \\t]*:\\S*$", toHere, caseInsensitive: false) != nil, afterBlank {
160 let from = start + NSMaxRange((toHere as NSString).range(of: ":"))
161 if let entry = propertyEntry(context, lineStart: start) {
162 return make(.property, from: from, caseDouble(propertyKeys(context).filter { !entry.contains($0) }.map { $0 + ": " }))
163 }
164 return make(.drawer, from: from, caseDouble((["PROPERTIES"] + drawerNames(context.tree)).map { $0 + ":" }))
165 }
166 return nil
167 }
168
169 // MARK: - Sources
170
171 /// Upper and lower case of each, as `org-pcomplete-case-double`.
172 static func caseDouble(_ list: [String]) -> [String] { list.flatMap { [$0.uppercased(), $0.lowercased()] } }
173
174 /// When the line is in a heading's property drawer, the properties the entry already has.
175 static func propertyEntry(_ context: EditContext, lineStart: Int) -> Set<String>? {
176 guard let heading = DocumentModel(tree: context.tree).headings.last(where: { $0.headingRange.lowerBound < lineStart }),
177 let drawer = context.tree.root.descendants().first(where: {
178 $0.kind == .propertyDrawer && $0.range.lowerBound > heading.headingRange.lowerBound && $0.range.contains(lineStart)
179 }) else { return nil }
180 let ns = context.text as NSString
181 // Only on the line after the last property, as `org-pcomplete` checks.
182 var p = drawer.range.lowerBound
183 p = NSMaxRange(ns.lineRange(for: NSRange(location: p, length: 0)))
184 while p < drawer.range.upperBound {
185 let line = ns.substring(with: ns.lineRange(for: NSRange(location: p, length: 0)))
186 guard line.range(of: "^[ \\t]*:\\S+:", options: .regularExpression) != nil, line.range(of: "^[ \\t]*:END:", options: [.regularExpression, .caseInsensitive]) == nil else { break }
187 p = NSMaxRange(ns.lineRange(for: NSRange(location: p, length: 0)))
188 }
189 guard p == lineStart else { return nil }
190 return Set(heading.properties.map { $0.key.uppercased() } + ["CATEGORY"])
191 }
192
193 /// `org-buffer-property-keys` with defaults and column keys.
194 static func propertyKeys(_ context: EditContext) -> [String] {
195 var keys: [String] = []
196 for heading in DocumentModel(tree: context.tree).headings {
197 for property in heading.properties {
198 keys.append(property.key)
199 if property.key.uppercased().hasSuffix("_ALL") { keys.append(String(property.key.dropLast(4))) }
200 }
201 }
202 let ns = context.text as NSString
203 let columnLines = try! NSRegularExpression(pattern: "^[ \\t]*#\\+COLUMNS:(.*)$|^[ \\t]*:COLUMNS:(.*)$", options: [.anchorsMatchLines, .caseInsensitive])
204 let columns = columnLines.matches(in: context.text, range: NSRange(location: 0, length: ns.length)).map {
205 ns.substring(with: $0.range(at: 1).location != NSNotFound ? $0.range(at: 1) : $0.range(at: 2))
206 }
207 for spec in columns {
208 for m in try! NSRegularExpression(pattern: "%[0-9]*([-\\w]+)").matches(in: spec, range: NSRange(location: 0, length: (spec as NSString).length)) {
209 keys.append((spec as NSString).substring(with: m.range(at: 1)))
210 }
211 }
212 return keys + defaultProperties + ["EFFORT"]
213 }
214
215 static func drawerNames(_ tree: OrgTree) -> [String] {
216 let ns = tree.text as NSString
217 return tree.root.descendants().filter { $0.kind == .drawer }.compactMap { node in
218 let first = ns.substring(with: ns.lineRange(for: NSRange(location: node.range.lowerBound, length: 0)))
219 return first.trimmingCharacters(in: .whitespacesAndNewlines).trimmingCharacters(in: CharacterSet(charactersIn: ":"))
220 }
221 }
222
223 static let templateKeys: [(key: String, value: String)] = [
224 ("a", "export ascii"), ("c", "center"), ("C", "comment"), ("e", "example"), ("E", "export"), ("h", "export html"),
225 ("l", "export latex"), ("q", "quote"), ("s", "src"), ("v", "verse"),
226 ("L", "latex"), ("H", "html"), ("A", "ascii"), ("i", "index"),
227 ]
228
229 /// org-tempo's expansion of `<key`: a block, or for keywords a `#+key: ` line.
230 static func templateExpansion(_ candidate: String) -> (text: String, caret: Int)? {
231 let key = String(candidate.dropFirst())
232 guard let index = templateKeys.firstIndex(where: { $0.key == key }) else { return nil }
233 let value = templateKeys[index].value
234 if index >= 10 {
235 let text = "#+\(value): "
236 return (text, text.utf16.count)
237 }
238 let name = value.split(separator: " ").first.map(String.init) ?? value
239 let begin = "#+begin_\(value)" + (["src", "export"].contains(value) ? " " : "")
240 return (begin + "\n#+end_\(name)", begin.utf16.count)
241 }
242}
243
244private extension NSRange {
245 var toOptional: NSRange? { location == NSNotFound ? nil : self }
246}
Sources/OrgCore/Keymap/Presets.swift +13
@@ -489,6 +489,16 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
489 command = "org.cycle-global" 489 command = "org.cycle-global"
490 mode = "visual" 490 mode = "visual"
491 491
492 # Doom's corfu: completion-at-point in insert state.
493 [[bind]]
494 keys = "C-SPC"
495 command = "editor.complete"
496 mode = "insert"
497 [[bind]]
498 keys = "C-@"
499 command = "editor.complete"
500 mode = "insert"
501
492 # Insert state: org-cycle and org-shifttab with Doom's +org-indent-maybe-h, which 502 # Insert state: org-cycle and org-shifttab with Doom's +org-indent-maybe-h, which
493 # demotes or promotes a heading and indents or outdents an item tree. 503 # demotes or promotes a heading and indents or outdents an item tree.
494 [[bind]] 504 [[bind]]
@@ -617,6 +627,9 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
617 keys = "C-SPC" 627 keys = "C-SPC"
618 command = "edit.set-mark" 628 command = "edit.set-mark"
619 [[bind]] 629 [[bind]]
630 keys = "C-M-i"
631 command = "editor.complete"
632 [[bind]]
620 keys = "C-x h" 633 keys = "C-x h"
621 command = "edit.select-all" 634 command = "edit.select-all"
622 [[bind]] 635 [[bind]]
Sources/OrgEditorAppKit/CompletionPopup.swift added +193
@@ -0,0 +1,193 @@
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
Sources/OrgEditorAppKit/EditorCommands.swift +1
@@ -42,6 +42,7 @@ public enum EditorCommands {
42 EditorCommand(id: "org.widen", title: "Widen", action: .view), 42 EditorCommand(id: "org.widen", title: "Widen", action: .view),
43 EditorCommand(id: "org.narrow.toggle", title: "Narrow to Subtree or Widen", action: .view), 43 EditorCommand(id: "org.narrow.toggle", title: "Narrow to Subtree or Widen", action: .view),
44 EditorCommand(id: "org.toggle-inline-images", title: "Show or Hide Inline Images", action: .view), 44 EditorCommand(id: "org.toggle-inline-images", title: "Show or Hide Inline Images", action: .view),
45 EditorCommand(id: "editor.complete", title: "Complete at Point", action: .view),
45 EditorCommand(id: "editor.toggle-spell-check", title: "Check Spelling While Typing", action: .view), 46 EditorCommand(id: "editor.toggle-spell-check", title: "Check Spelling While Typing", action: .view),
46 EditorCommand(id: "editor.toggle-truncate-lines", title: "Truncate or Wrap Long Lines", action: .view), 47 EditorCommand(id: "editor.toggle-truncate-lines", title: "Truncate or Wrap Long Lines", action: .view),
47 EditorCommand(id: "org.table.toggle-column-width", title: "Shrink or Expand Table Column", action: .view), 48 EditorCommand(id: "org.table.toggle-column-width", title: "Shrink or Expand Table Column", action: .view),
Sources/OrgEditorAppKit/OrgEditor.swift +5
@@ -97,6 +97,9 @@ public final class OrgEditor: NSObject {
97 97
98 public let mode: Mode 98 public let mode: Mode
99 public var isOrg: Bool { mode == .org } 99 public var isOrg: Bool { mode == .org }
100 /// The open completion list, and the pause before one opens by itself.
101 var completionPopup: CompletionPopup?
102 var completionTimer: DispatchWorkItem?
100 /// The spell checker's skipped ranges and the text they were found in. 103 /// The spell checker's skipped ranges and the text they were found in.
101 var spellSkipCache: (text: String, ranges: [Range<Int>])? 104 var spellSkipCache: (text: String, ranges: [Range<Int>])?
102 /// Highlights of the whole file in plain mode, and the revision they were made for. 105 /// Highlights of the whole file in plain mode, and the revision they were made for.
@@ -363,6 +366,7 @@ public final class OrgEditor: NSObject {
363 /// Offers a key press to the keymap. False lets the text system have it. 366 /// Offers a key press to the keymap. False lets the text system have it.
364 func handleKey(_ event: NSEvent) -> Bool { 367 func handleKey(_ event: NSEvent) -> Bool {
365 guard let chord = KeyEvents.chord(for: event, optionAsMeta: optionAsMeta) else { return modal && vim.mode != .insert } 368 guard let chord = KeyEvents.chord(for: event, optionAsMeta: optionAsMeta) else { return modal && vim.mode != .insert }
369 if let handled = completionKey(chord) { return handled }
366 if modal { return handleModal(chord) } 370 if modal { return handleModal(chord) }
367 switch dispatcher.feed(chord) { 371 switch dispatcher.feed(chord) {
368 case .pending(let keys): 372 case .pending(let keys):
@@ -437,6 +441,7 @@ public final class OrgEditor: NSObject {
437 case "org.narrow.subtree", "org.narrow.block", "org.narrow.toggle", "org.widen": narrow(id) 441 case "org.narrow.subtree", "org.narrow.block", "org.narrow.toggle", "org.widen": narrow(id)
438 case "org.toggle-inline-images": toggleImages() 442 case "org.toggle-inline-images": toggleImages()
439 case "editor.toggle-spell-check": toggleSpellCheck() 443 case "editor.toggle-spell-check": toggleSpellCheck()
444 case "editor.complete": completeAtPoint()
440 case "editor.toggle-truncate-lines": toggleTruncateLines() 445 case "editor.toggle-truncate-lines": toggleTruncateLines()
441 default: tableColumnWidth(id) 446 default: tableColumnWidth(id)
442 } 447 }
Sources/OrgEditorAppKit/ViewToggles.swift +7
@@ -89,6 +89,13 @@ extension OrgEditor {
89extension OrgEditor: NSTextViewDelegate { 89extension OrgEditor: NSTextViewDelegate {
90 public func textViewDidChangeSelection(_ notification: Notification) { 90 public func textViewDidChangeSelection(_ notification: Notification) {
91 selectionChanged() 91 selectionChanged()
92 if completionPopup != nil, let popup = completionPopup, textView.selectedRange().location != popup.completion.range.upperBound {
93 refreshCompletion()
94 }
95 }
96
97 public func textDidChange(_ notification: Notification) {
98 textTyped()
92 } 99 }
93 100
94 /// No spelling marks in code, links, dates, tags or keywords. 101 /// No spelling marks in code, links, dates, tags or keywords.
Tests/OrgCoreTests/CompletionTests.swift added +64
@@ -0,0 +1,64 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct CompletionTests {
6 static let oracle = ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil
7
8 func complete(_ text: String, _ caret: Int? = nil, knownTags: [String] = []) -> Completion? {
9 let caret = caret ?? text.utf16.count
10 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret], knownTags: { knownTags })
11 return OrgCompletion.at(context)
12 }
13
14 static let cases: [(String, Int?)] = [
15 ("#+TI", nil), ("#+", nil), ("#+ti", nil), ("#+BEGIN_S", nil),
16 ("* T", nil), ("* ", nil), ("* D x", 3),
17 ("* H :work:\n* I :", nil), ("* H :work:\n* I :w", nil), ("* H :a:b:\n* I :x:", nil), ("* H :a:b:\n* I :a:", nil),
18 ("#+TAGS: work home(h)\n* H :", nil),
19 ("* H\n:PROPERTIES:\n:A: 1\n:\n:END:\n", 24), ("* H\n:PROPERTIES:\n:A: 1\n:CU\n:END:\n", 26),
20 ("* H\n:PROPERTIES:\n:A: 1\n:END:\n* I\n:PROPERTIES:\n:\n:END:\n", 46),
21 ("x \\alp", nil), ("x \\Alp", nil),
22 ("* Foo bar\n* TODO [#A] Baz [1/2] :t:\n[[*", nil), ("* Foo bar\n[[*Fo", nil),
23 ("#+STARTUP: in", nil), ("#+STARTUP: hidestars s", nil), ("#+OPTIONS: to", nil), ("#+PRIORITIES: ", nil),
24 ("#+begin_src py", nil), ("#+begin_src python :ses", nil),
25 ("#+BEGIN: clocktable :max", nil),
26 ("#+LINK: gh https://github.com/%s\n[[g", nil),
27 ("text\n:LO", nil), ("text\n:LOGBOOK:\n- x\n:END:\n:L", nil), ("* H\ntext\n:", nil),
28 ("plain text", nil),
29 ]
30
31 @Test(.enabled(if: oracle))
32 func matchesPcomplete() throws {
33 let form = """
34 (let ((r (progn (require 'ox) (org-load-export-backends) (pcomplete-completions-at-point))) out)
35 (setq out (if (null r) "none"
36 (let* ((beg (nth 0 r))
37 (all (delete-dups (all-completions (buffer-substring-no-properties beg (point)) (nth 2 r)))))
38 (mapconcat #'identity (cons (number-to-string (1- beg)) (sort all #'string<)) "\\n"))))
39 (erase-buffer)
40 (insert out))
41 """
42 let results = try EmacsOracle.run(Self.cases.map { EmacsOracle.Case(text: $0.0, point: ($0.1 ?? $0.0.count) + 1, form: form) })
43 for ((text, caret), result) in zip(Self.cases, results) {
44 let completion = complete(text, caret)
45 let ours = completion.map { c in ([String(c.range.lowerBound)] + c.candidates.sorted()).joined(separator: "\n") } ?? "none"
46 let emacs = result.text
47 let a = Set(ours.split(separator: "\n")), b = Set(emacs.split(separator: "\n"))
48 #expect(ours == emacs, "\(text.debugDescription) @\(caret ?? text.count)\nonly ours: \(a.subtracting(b).sorted())\nonly emacs: \(b.subtracting(a).sorted()) \(result.error)")
49 }
50 }
51
52 @Test func addsLinkTypesWorkspaceTagsAndTemplates() {
53 #expect(complete("[[ht")?.candidates == ["http:", "https:"])
54 #expect(complete("* H :", knownTags: ["home", "work"])?.candidates == ["home:", "work:"])
55 #expect(complete("#+TAGS: a\n* H :", knownTags: ["home"])?.candidates == ["a:"])
56 let template = complete(" <s")!
57 #expect(template.kind == .template && template.range == 0..<4 && template.candidates == ["<s"])
58 #expect(template.insertion("<s") == ("#+begin_src \n#+end_src", 12))
59 #expect(complete("<q")!.insertion("<q") == ("#+begin_quote\n#+end_quote", 13))
60 #expect(complete("<h")!.insertion("<h") == ("#+begin_export html\n#+end_export", 19))
61 #expect(complete("<L")!.insertion("<L") == ("#+latex: ", 9))
62 #expect(complete("text <s") == nil)
63 }
64}
Tests/OrgEditorAppKitTests/CompletionPopupTests.swift added +76
@@ -0,0 +1,76 @@
1import AppKit
2import OrgCore
3import Testing
4@testable import OrgEditorAppKit
5
6@MainActor
7struct CompletionPopupTests {
8 func at(_ text: String) -> Harness {
9 let h = Harness(text)
10 h.caret(at: text.utf16.count)
11 return h
12 }
13
14 @Test func oneCandidateIsInserted() {
15 let h = at("#+TI")
16 h.editor.run("editor.complete")
17 #expect(h.editor.document.text == "#+TITLE: " && h.caret == 9)
18 let tags = at("* H :work:\n* I :")
19 tags.editor.run("editor.complete")
20 #expect(tags.editor.document.text == "* H :work:\n* I :work:")
21 let template = at(" <s")
22 template.editor.run("editor.complete")
23 #expect(template.editor.document.text == "#+begin_src \n#+end_src" && template.caret == 12)
24 let none = at("plain")
25 none.editor.run("editor.complete")
26 #expect(none.editor.completionPopup == nil && none.editor.document.text == "plain")
27 }
28
29 @Test func listPicksWithKeys() {
30 let h = at("#+STARTUP: in")
31 h.editor.run("editor.complete")
32 #expect(h.editor.completionPopup?.completion.candidates == ["indent", "inlineimages"])
33 #expect(h.editor.completionPopup?.window.isVisible == true)
34 ModalTests().type(h, "C-n C-n")
35 #expect(h.editor.completionPopup?.selected == "inlineimages")
36 ModalTests().type(h, "C-p RET")
37 #expect(h.editor.document.text == "#+STARTUP: indent" && h.editor.completionPopup == nil)
38
39 // TAB completes the common start, then the list follows the text.
40 let common = at("#+STARTUP: entities")
41 common.editor.run("editor.complete")
42 ModalTests().type(common, "TAB")
43 #expect(common.editor.document.text == "#+STARTUP: entitiesp")
44 #expect(common.editor.completionPopup?.completion.candidates == ["entitiesplain", "entitiespretty"])
45 common.textView.insertText("r", replacementRange: common.textView.selectedRange())
46 #expect(common.editor.completionPopup?.completion.candidates == ["entitiespretty"])
47 common.textView.insertText(" ", replacementRange: common.textView.selectedRange())
48 #expect(common.editor.completionPopup?.completion.candidates == nil || common.editor.completionPopup?.completion.range.lowerBound != 11)
49
50 // RET with nothing chosen closes the list and is a newline.
51 let plain = at("#+STARTUP: in")
52 plain.editor.run("editor.complete")
53 ModalTests().type(plain, "C-g")
54 #expect(plain.editor.completionPopup == nil && plain.editor.document.text == "#+STARTUP: in")
55 }
56
57 @Test func doomOpensTheListAsYouType() {
58 let h = Harness("* H :work:home:\n* I \n")
59 h.editor.keymap = KeymapPreset.doom.keymap
60 h.editor.modal = true
61 h.caret(at: 20)
62 ModalTests().type(h, "a")
63 h.textView.insertText(":w", replacementRange: h.textView.selectedRange())
64 h.editor.autoComplete()
65 #expect(h.editor.completionPopup == nil)
66 h.textView.insertText("o", replacementRange: h.textView.selectedRange())
67 h.editor.autoComplete()
68 #expect(h.editor.completionPopup?.completion.candidates == ["work:"])
69 ModalTests().type(h, "ESC")
70 #expect(h.editor.completionPopup == nil && h.editor.vim.mode == .normal)
71 ModalTests().type(h, "a")
72 h.textView.insertText("\\alp", replacementRange: h.textView.selectedRange())
73 ModalTests().type(h, "C-SPC")
74 #expect(h.editor.document.text.contains("\\alpha"))
75 }
76}
Tests/OrgEditorAppKitTests/ModalTests.swift +3 −1
@@ -20,7 +20,9 @@ struct ModalTests {
20 let key = String(token) 20 let key = String(token)
21 if key.hasPrefix("C-") { 21 if key.hasPrefix("C-") {
22 let letter = String(key.dropFirst(2)) 22 let letter = String(key.dropFirst(2))
23 KeyTests().send(h, characters: letter, ignoring: letter, code: 0, flags: .control) 23 let named = codes[letter]
24 let characters = named == nil ? letter : " "
25 KeyTests().send(h, characters: characters, ignoring: characters, code: named ?? 0, flags: .control)
24 } else if let code = codes[key] { 26 } else if let code = codes[key] {
25 let characters = key == "ESC" ? "\u{1b}" : key == "RET" ? "\r" : key == "TAB" ? "\t" : " " 27 let characters = key == "ESC" ? "\u{1b}" : key == "RET" ? "\r" : key == "TAB" ? "\t" : " "
26 KeyTests().send(h, characters: characters, ignoring: characters, code: code) 28 KeyTests().send(h, characters: characters, ignoring: characters, code: code)