krz/orgstar

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

Sources/OrgCore/Commands/FastSelection.swift

15f6b0709d88971fb62ed432c3e5b8032643670a
orgstar/Sources/OrgCore/Commands/FastSelection.swift history · blame · raw

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}