Sources/OrgCore/Commands/FastSelection.swift
246 lines · 10568 bytes
1import Foundation
2
3// Fast TODO and tag selection (`org-fast-todo-selection`, `org-fast-tag-selection`, Org 9.8.7)
4// with `org-use-fast-*-selection` `auto`, `org-fast-tag-selection-single-key` nil and no TODO
5// keywords among the tags. The editor shows the keys; the answer is the keys pressed, which
6// `FastSelection` replays as org does.
7
8/// What a fast selection prompt shows and how its keys act.
9public struct FastSelection: Sendable, Equatable {
10 public enum Item: Sendable, Equatable {
11 case option(key: Character, name: String)
12 /// `{`: the options up to `endGroup` exclude each other (tags).
13 case startGroup
14 case endGroup
15 /// `[`, `]` and `:` of a group tag; shown, without effect on selection.
16 case startTagGroup, endTagGroup, groupTags
17 case newline
18 /// A line of text above the options that follow (`org-clock-select-task`'s sections).
19 case label(String)
20 }
21
22 public let items: [Item]
23 /// Tags: keys toggle until Return. TODO keywords: one key answers.
24 public let multiple: Bool
25 public let current: [String]
26 public let inherited: [String]
27
28 public init(items: [Item], multiple: Bool, current: [String] = [], inherited: [String] = []) {
29 self.items = items
30 self.multiple = multiple
31 self.current = current
32 self.inherited = inherited
33 }
34
35 public var options: [(key: Character, name: String)] {
36 items.compactMap { if case .option(let key, let name) = $0 { (key, name) } else { nil } }
37 }
38
39 /// The `{ }` groups' members.
40 var groups: [[String]] {
41 var groups: [[String]] = []
42 var inGroup = false
43 for item in items {
44 switch item {
45 case .startGroup: groups.append([]); inGroup = true
46 case .endGroup: inGroup = false
47 case .option(_, let name) where inGroup: groups[groups.count - 1].append(name)
48 default: break
49 }
50 }
51 return groups
52 }
53
54 /// A key pressed in a tag selection, or a tag typed after Tab.
55 public enum Input: Sendable, Equatable {
56 case key(Character)
57 case typed(String)
58 }
59
60 /// The answer a prompt sends: keys as themselves, a typed tag between Tab and newline.
61 public static func encode(_ inputs: [Input]) -> String {
62 inputs.map { input in
63 switch input {
64 case .key(let c): String(c)
65 case .typed(let tag): "\t" + tag + "\n"
66 }
67 }.joined()
68 }
69
70 public static func decode(_ answer: String) -> [Input] {
71 var inputs: [Input] = []
72 var rest = Substring(answer)
73 while let c = rest.first {
74 rest = rest.dropFirst()
75 if c == "\t" {
76 let tag = rest.prefix { $0 != "\n" }
77 inputs.append(.typed(String(tag)))
78 rest = rest.dropFirst(tag.count + 1)
79 } else {
80 inputs.append(.key(c))
81 }
82 }
83 return inputs
84 }
85
86 /// The tags after `inputs`, or nil when they quit (`q` with no binding, or C-g).
87 public func tags(after inputs: [Input]) -> [String]? {
88 var current = self.current
89 var groupsOn = true
90 let order = options.map(\.name)
91 for input in inputs {
92 switch input {
93 case .key("!"): groupsOn.toggle()
94 case .key(" "): current = []
95 case .key("\u{7}"): return nil
96 case .key(let c):
97 if let tag = options.first(where: { $0.key == c })?.name {
98 current = Self.toggle(tag, current, groups: groupsOn ? groups : [])
99 } else if c == "q" {
100 return nil
101 }
102 case .typed(let tag):
103 guard tag.contains(where: { !$0.isWhitespace }) else { continue }
104 current = Self.toggle(tag, current, groups: groupsOn ? groups : [])
105 }
106 // Sorted by the table's order; tags not in it keep theirs, after.
107 current = current.enumerated().sorted { a, b in
108 let ia = order.firstIndex(of: a.element) ?? Int.max
109 let ib = order.firstIndex(of: b.element) ?? Int.max
110 return ia != ib ? ia < ib : a.offset < b.offset
111 }.map(\.element)
112 }
113 return current
114 }
115
116 /// `org--add-or-remove-tag`.
117 static func toggle(_ tag: String, _ current: [String], groups: [[String]]) -> [String] {
118 if current.contains(tag) { return current.filter { $0 != tag } }
119 var current = current
120 for group in groups where group.contains(tag) { current.removeAll { group.contains($0) } }
121 return [tag] + current
122 }
123
124 // MARK: - TODO keywords
125
126 /// `org-todo-key-alist` after `org-assign-fast-keys`, when any keyword has a key.
127 static func todo(_ settings: OrgSettings) -> FastSelection? {
128 guard settings.todoSequences.contains(where: { ($0.active + $0.done).contains { $0.fastKey != nil } }) else { return nil }
129 var items: [Item] = []
130 let all = settings.todoSequences.flatMap { $0.active + $0.done }
131 var used = Set(all.compactMap(\.fastKey))
132 var assigned: [Character] = []
133 var alternative = Character("0")
134 for keyword in all {
135 if let key = keyword.fastKey { assigned.append(key); continue }
136 var letters = Array(keyword.name.lowercased())
137 if letters.first == "@" { letters.removeFirst() }
138 let key = letters.first { !used.contains($0) } ?? {
139 while used.contains(alternative) { alternative = Character(UnicodeScalar(alternative.unicodeScalars.first!.value + 1)!) }
140 return alternative
141 }()
142 used.insert(key)
143 assigned.append(key)
144 }
145 var index = 0
146 for sequence in settings.todoSequences {
147 items.append(.startGroup)
148 for keyword in sequence.active + sequence.done {
149 items.append(.option(key: assigned[index], name: keyword.name))
150 index += 1
151 }
152 items.append(.endGroup)
153 }
154 return FastSelection(items: items, multiple: false)
155 }
156
157 /// The keyword for `key`, preferring the sequence of `current`; `.some(nil)` clears it,
158 /// nil quits.
159 func keyword(for key: Character, current: String?) -> String?? {
160 if key == " " { return .some(nil) }
161 var sequences: [[(key: Character, name: String)]] = []
162 for item in items {
163 switch item {
164 case .startGroup: sequences.append([])
165 case .option(let k, let name): sequences[sequences.count - 1].append((k, name))
166 default: break
167 }
168 }
169 if let current, let preferred = sequences.first(where: { $0.contains { $0.name == current } }),
170 let match = preferred.first(where: { $0.key == key }) {
171 return .some(match.name)
172 }
173 return options.first { $0.key == key }.map { .some($0.name) } ?? nil
174 }
175
176 // MARK: - Tags
177
178 /// `org-tag-string-to-alist` of the file's `#+TAGS` lines, or nil without any.
179 static func tagTable(_ text: String) -> [Item]? {
180 let regex = try! NSRegularExpression(pattern: "^[ \\t]*#\\+TAGS:[ \\t]*(.*?)[ \\t]*$", options: [.anchorsMatchLines, .caseInsensitive])
181 let ns = text as NSString
182 let lines = regex.matches(in: text, range: NSRange(location: 0, length: ns.length)).map { ns.substring(with: $0.range(at: 1)) }
183 guard !lines.isEmpty else { return nil }
184 var items: [Item] = []
185 var names: Set<String> = []
186 var inGroup = false
187 for line in lines.joined(separator: "\n").split(separator: "\n", omittingEmptySubsequences: true) {
188 items.append(.newline)
189 let tokens = line.split(whereSeparator: \.isWhitespace).map(String.init)
190 for (i, token) in tokens.enumerated() {
191 switch token {
192 case "{":
193 items.append(.startGroup)
194 if i + 2 < tokens.count, tokens[i + 2] == ":" { inGroup = true }
195 case "}": items.append(.endGroup); inGroup = false
196 case "[":
197 items.append(.startTagGroup)
198 if i + 2 < tokens.count, tokens[i + 2] == ":" { inGroup = true }
199 case "]": items.append(.endTagGroup); inGroup = false
200 case ":": items.append(.groupTags)
201 default:
202 guard let match = token.firstMatch(of: /^([\p{L}\p{N}_@#%]+|\{.+?\})(?:\((.)\))?$/) else { continue }
203 let name = String(match.1)
204 if inGroup || !names.contains(name) {
205 names.insert(name)
206 items.append(.option(key: match.2.flatMap(\.first) ?? "\0", name: name))
207 }
208 }
209 }
210 }
211 items.removeFirst()
212 return items
213 }
214
215 /// The selection `org-fast-tag-selection` shows for `table` (keys given as `\0` are
216 /// assigned), or nil when no tag in the table has a key.
217 static func tags(table: [Item], current: [String], inherited: [String]) -> FastSelection? {
218 let given = table.compactMap { if case .option(let key, _) = $0, key != "\0" { key } else { nil } }
219 guard !given.isEmpty else { return nil }
220 let pool = Array("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ{|}~")
221 var spare = pool[...]
222 var assigned: [Character] = []
223 var items: [Item] = []
224 for item in table {
225 guard case .option(let key, let name) = item else { items.append(item); continue }
226 if key != "\0" {
227 assigned.append(key)
228 items.append(item)
229 continue
230 }
231 var letters = Array(name.lowercased())
232 if letters.first == "@" { letters.removeFirst() }
233 let taken = { (c: Character) in given.contains(c) || assigned.contains(c) }
234 var chosen: Character = " "
235 if let first = letters.first, !taken(first) {
236 chosen = first
237 } else {
238 while let c = spare.first, taken(c) { spare = spare.dropFirst() }
239 chosen = spare.first ?? " "
240 }
241 assigned.append(chosen)
242 items.append(.option(key: chosen, name: name))
243 }
244 return FastSelection(items: items, multiple: true, current: current, inherited: inherited)
245 }
246}