Commit ed6b554dc7
Verified · cmc
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 | ||
| 3 | extension 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 @@ | ||
| 1 | import 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 | ||
| 7 | public 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 | ||
| 31 | public 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 | ||
| 244 | private 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 | 489 | command = "org.cycle-global" |
| 490 | 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 | 502 | # Insert state: org-cycle and org-shifttab with Doom's +org-indent-maybe-h, which |
| 493 | 503 | # demotes or promotes a heading and indents or outdents an item tree. |
| 494 | 504 | [[bind]] |
| @@ -617,6 +627,9 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | ||
| 617 | 627 | keys = "C-SPC" |
| 618 | 628 | command = "edit.set-mark" |
| 619 | 629 | [[bind]] |
| 630 | keys = "C-M-i" | |
| 631 | command = "editor.complete" | |
| 632 | [[bind]] | |
| 620 | 633 | keys = "C-x h" |
| 621 | 634 | command = "edit.select-all" |
| 622 | 635 | [[bind]] |
Sources/OrgEditorAppKit/CompletionPopup.swift added +193
| @@ -0,0 +1,193 @@ | ||
| 1 | #if os(macOS) | |
| 2 | import AppKit | |
| 3 | import 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 | |
| 8 | final 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 | ||
| 83 | extension 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 | 42 | EditorCommand(id: "org.widen", title: "Widen", action: .view), |
| 43 | 43 | EditorCommand(id: "org.narrow.toggle", title: "Narrow to Subtree or Widen", action: .view), |
| 44 | 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 | 46 | EditorCommand(id: "editor.toggle-spell-check", title: "Check Spelling While Typing", action: .view), |
| 46 | 47 | EditorCommand(id: "editor.toggle-truncate-lines", title: "Truncate or Wrap Long Lines", action: .view), |
| 47 | 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 | 98 | public let mode: Mode |
| 99 | 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 | 103 | /// The spell checker's skipped ranges and the text they were found in. |
| 101 | 104 | var spellSkipCache: (text: String, ranges: [Range<Int>])? |
| 102 | 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 | 366 | /// Offers a key press to the keymap. False lets the text system have it. |
| 364 | 367 | func handleKey(_ event: NSEvent) -> Bool { |
| 365 | 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 | 370 | if modal { return handleModal(chord) } |
| 367 | 371 | switch dispatcher.feed(chord) { |
| 368 | 372 | case .pending(let keys): |
| @@ -437,6 +441,7 @@ public final class OrgEditor: NSObject { | ||
| 437 | 441 | case "org.narrow.subtree", "org.narrow.block", "org.narrow.toggle", "org.widen": narrow(id) |
| 438 | 442 | case "org.toggle-inline-images": toggleImages() |
| 439 | 443 | case "editor.toggle-spell-check": toggleSpellCheck() |
| 444 | case "editor.complete": completeAtPoint() | |
| 440 | 445 | case "editor.toggle-truncate-lines": toggleTruncateLines() |
| 441 | 446 | default: tableColumnWidth(id) |
| 442 | 447 | } |
Sources/OrgEditorAppKit/ViewToggles.swift +7
| @@ -89,6 +89,13 @@ extension OrgEditor { | ||
| 89 | 89 | extension OrgEditor: NSTextViewDelegate { |
| 90 | 90 | public func textViewDidChangeSelection(_ notification: Notification) { |
| 91 | 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 | 101 | /// No spelling marks in code, links, dates, tags or keywords. |
Tests/OrgCoreTests/CompletionTests.swift added +64
| @@ -0,0 +1,64 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import OrgCore | |
| 4 | ||
| 5 | struct 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 @@ | ||
| 1 | import AppKit | |
| 2 | import OrgCore | |
| 3 | import Testing | |
| 4 | @testable import OrgEditorAppKit | |
| 5 | ||
| 6 | @MainActor | |
| 7 | struct 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 | 20 | let key = String(token) |
| 21 | 21 | if key.hasPrefix("C-") { |
| 22 | 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 | 26 | } else if let code = codes[key] { |
| 25 | 27 | let characters = key == "ESC" ? "\u{1b}" : key == "RET" ? "\r" : key == "TAB" ? "\t" : " " |
| 26 | 28 | KeyTests().send(h, characters: characters, ignoring: characters, code: code) |