Fast TODO and tag selection, tag completion, TODO sequences !68
21 files changed, +649 −38
Layout: unified · split
Sources/OrgApp/EmacsImport.swift +8 −4
| @@ -475,10 +475,14 @@ public struct EmacsImport: Sendable { | ||
| 475 | 475 | case "org-agenda-start-day": |
| 476 | 476 | if value.symbol == "nil" { add(name, .string("+0d")) } else if let s = value.string, UserConfig.dayOffset(s) != nil { add(name, .string(s)) } else { note("only day offsets such as \"-3d\" are supported") } |
| 477 | 477 | case "org-todo-keywords": |
| 478 | guard let sequences = value.elements, let first = sequences.first?.elements, first.count > 1 else { return note("unexpected value") } | |
| 479 | let words = first.dropFirst().compactMap(\.string) | |
| 480 | add(name, .string(words.joined(separator: " "))) | |
| 481 | if sequences.count > 1 { note("only the first sequence is used; the other \(sequences.count - 1) are left out") } | |
| 478 | let sequences = (value.elements ?? []).compactMap { $0.elements }.filter { $0.count > 1 }.map { $0.dropFirst().compactMap(\.string) } | |
| 479 | let lines = sequences.filter { !$0.contains { $0.contains(where: \.isWhitespace) } }.map { $0.joined(separator: " ") } | |
| 480 | guard !lines.isEmpty else { return note("unexpected value") } | |
| 481 | if lines.count < sequences.count { | |
| 482 | note("\(sequences.count - lines.count) sequence(s) with spaces in a keyword left out; #+TODO syntax can't write them") | |
| 483 | } | |
| 484 | if (value.elements ?? []).contains(where: { $0.elements?.first?.symbol == "type" }) { note("type sequences are read as sequences") } | |
| 485 | add(name, .string(lines.joined(separator: "\n"))) | |
| 482 | 486 | case "org-directory": |
| 483 | 487 | guard let path = value.string else { return note("not a folder") } |
| 484 | 488 | items.append(Item(id: id(), name: name, summary: "add folder \(path)", source: source, change: .folder(path))) |
Sources/OrgApp/UserConfig.swift +1 −1
| @@ -73,7 +73,7 @@ public enum UserConfig { | ||
| 73 | 73 | Setting(section: "", key: "org-pretty-entities", defaultsKey: "prettyEntities", kind: .bool, fallback: .bool(true), |
| 74 | 74 | comment: "\\alpha shows as α and x_{1} is lowered when markup is hidden", legacy: "editor.pretty-entities"), |
| 75 | 75 | Setting(section: "", key: "org-todo-keywords", defaultsKey: "todoKeywords", kind: .string, fallback: .string("TODO PROJ LOOP STRT WAIT HOLD IDEA | DONE KILL"), |
| 76 | comment: "one sequence, in #+TODO syntax, for files without #+TODO; read at launch", legacy: "editor.todo-keywords"), | |
| 76 | comment: "for files without #+TODO, in #+TODO syntax, one sequence per line (\\n); read at launch", legacy: "editor.todo-keywords"), | |
| 77 | 77 | Setting(section: "", key: "org-log-done", defaultsKey: "logDone", kind: .choice([("nil", "nil"), ("time", "time"), ("note", "note")]), |
| 78 | 78 | fallback: .string("nil"), comment: "nil, time (CLOSED when done) or note (CLOSED and a note)", legacy: ""), |
| 79 | 79 | Setting(section: "", key: "org-log-into-drawer", defaultsKey: "logIntoDrawer", kind: .string, fallback: .string(""), |
Sources/OrgCore/Commands/Command.swift +19 −4
| @@ -17,6 +17,8 @@ public struct EditingOptions: Sendable, Equatable, Codable { | ||
| 17 | 17 | public var logDone: LogHow? |
| 18 | 18 | /// `org-log-into-drawer`: the drawer state notes go in, or nil for none. |
| 19 | 19 | public var logIntoDrawer: String? |
| 20 | /// `org-use-fast-todo-selection` `auto`: with keys in the keywords, C-c C-t asks for one. | |
| 21 | public var fastTodoSelection: Bool | |
| 20 | 22 | /// `fill-column` for M-q. |
| 21 | 23 | public var fillColumn: Int |
| 22 | 24 | /// `org-hide-emphasis-markers`: table alignment measures emphasis without its markers. |
| @@ -30,8 +32,9 @@ public struct EditingOptions: Sendable, Equatable, Codable { | ||
| 30 | 32 | tagsColumn: Int = -77, insertHeadingRespectContent: Bool = false, metaReturnMaySplitLine: Bool = true, |
| 31 | 33 | listAllowAlphabetical: Bool = false, logRepeat: Bool = true, fillColumn: Int = 70, |
| 32 | 34 | hideEmphasisMarkers: Bool = false, prettyEntities: Bool = false, subSuperscriptsNeedBraces: Bool = false, |
| 33 | logDone: LogHow? = nil, logIntoDrawer: String? = nil | |
| 35 | logDone: LogHow? = nil, logIntoDrawer: String? = nil, fastTodoSelection: Bool = true | |
| 34 | 36 | ) { |
| 37 | self.fastTodoSelection = fastTodoSelection | |
| 35 | 38 | self.logDone = logDone |
| 36 | 39 | self.logIntoDrawer = logIntoDrawer |
| 37 | 40 | self.tagsColumn = tagsColumn |
| @@ -66,12 +69,15 @@ public struct EditContext: Sendable { | ||
| 66 | 69 | /// Ranges the view hides (folded text); motion commands skip headings inside them. |
| 67 | 70 | public let hidden: [Range<Int>] |
| 68 | 71 | public let options: EditingOptions |
| 72 | /// Tags used across the workspace, for completion; read when a command needs them. | |
| 73 | public let knownTags: @Sendable () -> [String] | |
| 69 | 74 | |
| 70 | 75 | public init( |
| 71 | 76 | revision: Int, text: String, tree: OrgTree, selection: [Range<Int>], |
| 72 | 77 | now: Date = Date(), calendar: Calendar = .current, answers: [String: String] = [:], hidden: [Range<Int>] = [], |
| 73 | options: EditingOptions = .org | |
| 78 | options: EditingOptions = .org, knownTags: @escaping @Sendable () -> [String] = { [] } | |
| 74 | 79 | ) { |
| 80 | self.knownTags = knownTags | |
| 75 | 81 | self.revision = revision |
| 76 | 82 | self.text = text |
| 77 | 83 | self.tree = tree |
| @@ -95,13 +101,22 @@ public struct Prompt: Sendable, Equatable { | ||
| 95 | 101 | /// When set, the answer is one of these, or with `requireMatch` false, these are offered. |
| 96 | 102 | public let choices: [String] |
| 97 | 103 | public let requireMatch: Bool |
| 104 | /// Choices complete the text after the last of these characters (`:` between tags). | |
| 105 | public let completionSeparator: Character? | |
| 106 | /// Single keys answer instead of text. | |
| 107 | public let fast: FastSelection? | |
| 98 | 108 | |
| 99 | public init(key: String, message: String, initial: String = "", choices: [String] = [], requireMatch: Bool = true) { | |
| 109 | public init( | |
| 110 | key: String, message: String, initial: String = "", choices: [String] = [], requireMatch: Bool = true, | |
| 111 | completionSeparator: Character? = nil, fast: FastSelection? = nil | |
| 112 | ) { | |
| 100 | 113 | self.key = key |
| 101 | 114 | self.message = message |
| 102 | 115 | self.initial = initial |
| 103 | 116 | self.choices = choices |
| 104 | 117 | self.requireMatch = requireMatch |
| 118 | self.completionSeparator = completionSeparator | |
| 119 | self.fast = fast | |
| 105 | 120 | } |
| 106 | 121 | } |
| 107 | 122 | |
| @@ -177,7 +192,7 @@ public protocol OrgCommand: Sendable { | ||
| 177 | 192 | |
| 178 | 193 | public enum Commands { |
| 179 | 194 | public static let all: [any OrgCommand] = [ |
| 180 | TodoCycle(), PriorityUp(), PriorityDown(), PromoteHeading(), DemoteHeading(), | |
| 195 | TodoCycle(), TodoShift(right: true), TodoShift(right: false), PriorityUp(), PriorityDown(), PromoteHeading(), DemoteHeading(), | |
| 181 | 196 | InsertHeading(), InsertHeadingAfterSubtree(), InsertTodoHeading(), |
| 182 | 197 | MoveSubtreeUp(), MoveSubtreeDown(), PromoteSubtree(), DemoteSubtree(), |
| 183 | 198 | NextHeading(), PreviousHeading(), ForwardSameLevel(), BackwardSameLevel(), UpHeading(), |
Sources/OrgCore/Commands/FastSelection.swift added +244
| @@ -0,0 +1,244 @@ | ||
| 1 | import 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. | |
| 9 | public 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 | } | |
| 19 | ||
| 20 | public let items: [Item] | |
| 21 | /// Tags: keys toggle until Return. TODO keywords: one key answers. | |
| 22 | public let multiple: Bool | |
| 23 | public let current: [String] | |
| 24 | public let inherited: [String] | |
| 25 | ||
| 26 | public init(items: [Item], multiple: Bool, current: [String] = [], inherited: [String] = []) { | |
| 27 | self.items = items | |
| 28 | self.multiple = multiple | |
| 29 | self.current = current | |
| 30 | self.inherited = inherited | |
| 31 | } | |
| 32 | ||
| 33 | public var options: [(key: Character, name: String)] { | |
| 34 | items.compactMap { if case .option(let key, let name) = $0 { (key, name) } else { nil } } | |
| 35 | } | |
| 36 | ||
| 37 | /// The `{ }` groups' members. | |
| 38 | var groups: [[String]] { | |
| 39 | var groups: [[String]] = [] | |
| 40 | var inGroup = false | |
| 41 | for item in items { | |
| 42 | switch item { | |
| 43 | case .startGroup: groups.append([]); inGroup = true | |
| 44 | case .endGroup: inGroup = false | |
| 45 | case .option(_, let name) where inGroup: groups[groups.count - 1].append(name) | |
| 46 | default: break | |
| 47 | } | |
| 48 | } | |
| 49 | return groups | |
| 50 | } | |
| 51 | ||
| 52 | /// A key pressed in a tag selection, or a tag typed after Tab. | |
| 53 | public enum Input: Sendable, Equatable { | |
| 54 | case key(Character) | |
| 55 | case typed(String) | |
| 56 | } | |
| 57 | ||
| 58 | /// The answer a prompt sends: keys as themselves, a typed tag between Tab and newline. | |
| 59 | public static func encode(_ inputs: [Input]) -> String { | |
| 60 | inputs.map { input in | |
| 61 | switch input { | |
| 62 | case .key(let c): String(c) | |
| 63 | case .typed(let tag): "\t" + tag + "\n" | |
| 64 | } | |
| 65 | }.joined() | |
| 66 | } | |
| 67 | ||
| 68 | public static func decode(_ answer: String) -> [Input] { | |
| 69 | var inputs: [Input] = [] | |
| 70 | var rest = Substring(answer) | |
| 71 | while let c = rest.first { | |
| 72 | rest = rest.dropFirst() | |
| 73 | if c == "\t" { | |
| 74 | let tag = rest.prefix { $0 != "\n" } | |
| 75 | inputs.append(.typed(String(tag))) | |
| 76 | rest = rest.dropFirst(tag.count + 1) | |
| 77 | } else { | |
| 78 | inputs.append(.key(c)) | |
| 79 | } | |
| 80 | } | |
| 81 | return inputs | |
| 82 | } | |
| 83 | ||
| 84 | /// The tags after `inputs`, or nil when they quit (`q` with no binding, or C-g). | |
| 85 | public func tags(after inputs: [Input]) -> [String]? { | |
| 86 | var current = self.current | |
| 87 | var groupsOn = true | |
| 88 | let order = options.map(\.name) | |
| 89 | for input in inputs { | |
| 90 | switch input { | |
| 91 | case .key("!"): groupsOn.toggle() | |
| 92 | case .key(" "): current = [] | |
| 93 | case .key("\u{7}"): return nil | |
| 94 | case .key(let c): | |
| 95 | if let tag = options.first(where: { $0.key == c })?.name { | |
| 96 | current = Self.toggle(tag, current, groups: groupsOn ? groups : []) | |
| 97 | } else if c == "q" { | |
| 98 | return nil | |
| 99 | } | |
| 100 | case .typed(let tag): | |
| 101 | guard tag.contains(where: { !$0.isWhitespace }) else { continue } | |
| 102 | current = Self.toggle(tag, current, groups: groupsOn ? groups : []) | |
| 103 | } | |
| 104 | // Sorted by the table's order; tags not in it keep theirs, after. | |
| 105 | current = current.enumerated().sorted { a, b in | |
| 106 | let ia = order.firstIndex(of: a.element) ?? Int.max | |
| 107 | let ib = order.firstIndex(of: b.element) ?? Int.max | |
| 108 | return ia != ib ? ia < ib : a.offset < b.offset | |
| 109 | }.map(\.element) | |
| 110 | } | |
| 111 | return current | |
| 112 | } | |
| 113 | ||
| 114 | /// `org--add-or-remove-tag`. | |
| 115 | static func toggle(_ tag: String, _ current: [String], groups: [[String]]) -> [String] { | |
| 116 | if current.contains(tag) { return current.filter { $0 != tag } } | |
| 117 | var current = current | |
| 118 | for group in groups where group.contains(tag) { current.removeAll { group.contains($0) } } | |
| 119 | return [tag] + current | |
| 120 | } | |
| 121 | ||
| 122 | // MARK: - TODO keywords | |
| 123 | ||
| 124 | /// `org-todo-key-alist` after `org-assign-fast-keys`, when any keyword has a key. | |
| 125 | static func todo(_ settings: OrgSettings) -> FastSelection? { | |
| 126 | guard settings.todoSequences.contains(where: { ($0.active + $0.done).contains { $0.fastKey != nil } }) else { return nil } | |
| 127 | var items: [Item] = [] | |
| 128 | let all = settings.todoSequences.flatMap { $0.active + $0.done } | |
| 129 | var used = Set(all.compactMap(\.fastKey)) | |
| 130 | var assigned: [Character] = [] | |
| 131 | var alternative = Character("0") | |
| 132 | for keyword in all { | |
| 133 | if let key = keyword.fastKey { assigned.append(key); continue } | |
| 134 | var letters = Array(keyword.name.lowercased()) | |
| 135 | if letters.first == "@" { letters.removeFirst() } | |
| 136 | let key = letters.first { !used.contains($0) } ?? { | |
| 137 | while used.contains(alternative) { alternative = Character(UnicodeScalar(alternative.unicodeScalars.first!.value + 1)!) } | |
| 138 | return alternative | |
| 139 | }() | |
| 140 | used.insert(key) | |
| 141 | assigned.append(key) | |
| 142 | } | |
| 143 | var index = 0 | |
| 144 | for sequence in settings.todoSequences { | |
| 145 | items.append(.startGroup) | |
| 146 | for keyword in sequence.active + sequence.done { | |
| 147 | items.append(.option(key: assigned[index], name: keyword.name)) | |
| 148 | index += 1 | |
| 149 | } | |
| 150 | items.append(.endGroup) | |
| 151 | } | |
| 152 | return FastSelection(items: items, multiple: false) | |
| 153 | } | |
| 154 | ||
| 155 | /// The keyword for `key`, preferring the sequence of `current`; `.some(nil)` clears it, | |
| 156 | /// nil quits. | |
| 157 | func keyword(for key: Character, current: String?) -> String?? { | |
| 158 | if key == " " { return .some(nil) } | |
| 159 | var sequences: [[(key: Character, name: String)]] = [] | |
| 160 | for item in items { | |
| 161 | switch item { | |
| 162 | case .startGroup: sequences.append([]) | |
| 163 | case .option(let k, let name): sequences[sequences.count - 1].append((k, name)) | |
| 164 | default: break | |
| 165 | } | |
| 166 | } | |
| 167 | if let current, let preferred = sequences.first(where: { $0.contains { $0.name == current } }), | |
| 168 | let match = preferred.first(where: { $0.key == key }) { | |
| 169 | return .some(match.name) | |
| 170 | } | |
| 171 | return options.first { $0.key == key }.map { .some($0.name) } ?? nil | |
| 172 | } | |
| 173 | ||
| 174 | // MARK: - Tags | |
| 175 | ||
| 176 | /// `org-tag-string-to-alist` of the file's `#+TAGS` lines, or nil without any. | |
| 177 | static func tagTable(_ text: String) -> [Item]? { | |
| 178 | let regex = try! NSRegularExpression(pattern: "^[ \\t]*#\\+TAGS:[ \\t]*(.*?)[ \\t]*$", options: [.anchorsMatchLines, .caseInsensitive]) | |
| 179 | let ns = text as NSString | |
| 180 | let lines = regex.matches(in: text, range: NSRange(location: 0, length: ns.length)).map { ns.substring(with: $0.range(at: 1)) } | |
| 181 | guard !lines.isEmpty else { return nil } | |
| 182 | var items: [Item] = [] | |
| 183 | var names: Set<String> = [] | |
| 184 | var inGroup = false | |
| 185 | for line in lines.joined(separator: "\n").split(separator: "\n", omittingEmptySubsequences: true) { | |
| 186 | items.append(.newline) | |
| 187 | let tokens = line.split(whereSeparator: \.isWhitespace).map(String.init) | |
| 188 | for (i, token) in tokens.enumerated() { | |
| 189 | switch token { | |
| 190 | case "{": | |
| 191 | items.append(.startGroup) | |
| 192 | if i + 2 < tokens.count, tokens[i + 2] == ":" { inGroup = true } | |
| 193 | case "}": items.append(.endGroup); inGroup = false | |
| 194 | case "[": | |
| 195 | items.append(.startTagGroup) | |
| 196 | if i + 2 < tokens.count, tokens[i + 2] == ":" { inGroup = true } | |
| 197 | case "]": items.append(.endTagGroup); inGroup = false | |
| 198 | case ":": items.append(.groupTags) | |
| 199 | default: | |
| 200 | guard let match = token.firstMatch(of: /^([\p{L}\p{N}_@#%]+|\{.+?\})(?:\((.)\))?$/) else { continue } | |
| 201 | let name = String(match.1) | |
| 202 | if inGroup || !names.contains(name) { | |
| 203 | names.insert(name) | |
| 204 | items.append(.option(key: match.2.flatMap(\.first) ?? "\0", name: name)) | |
| 205 | } | |
| 206 | } | |
| 207 | } | |
| 208 | } | |
| 209 | items.removeFirst() | |
| 210 | return items | |
| 211 | } | |
| 212 | ||
| 213 | /// The selection `org-fast-tag-selection` shows for `table` (keys given as `\0` are | |
| 214 | /// assigned), or nil when no tag in the table has a key. | |
| 215 | static func tags(table: [Item], current: [String], inherited: [String]) -> FastSelection? { | |
| 216 | let given = table.compactMap { if case .option(let key, _) = $0, key != "\0" { key } else { nil } } | |
| 217 | guard !given.isEmpty else { return nil } | |
| 218 | let pool = Array("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ{|}~") | |
| 219 | var spare = pool[...] | |
| 220 | var assigned: [Character] = [] | |
| 221 | var items: [Item] = [] | |
| 222 | for item in table { | |
| 223 | guard case .option(let key, let name) = item else { items.append(item); continue } | |
| 224 | if key != "\0" { | |
| 225 | assigned.append(key) | |
| 226 | items.append(item) | |
| 227 | continue | |
| 228 | } | |
| 229 | var letters = Array(name.lowercased()) | |
| 230 | if letters.first == "@" { letters.removeFirst() } | |
| 231 | let taken = { (c: Character) in given.contains(c) || assigned.contains(c) } | |
| 232 | var chosen: Character = " " | |
| 233 | if let first = letters.first, !taken(first) { | |
| 234 | chosen = first | |
| 235 | } else { | |
| 236 | while let c = spare.first, taken(c) { spare = spare.dropFirst() } | |
| 237 | chosen = spare.first ?? " " | |
| 238 | } | |
| 239 | assigned.append(chosen) | |
| 240 | items.append(.option(key: chosen, name: name)) | |
| 241 | } | |
| 242 | return FastSelection(items: items, multiple: true, current: current, inherited: inherited) | |
| 243 | } | |
| 244 | } | |
Sources/OrgCore/Commands/HeadingCommands.swift +29
| @@ -208,6 +208,14 @@ public struct TodoCycle: OrgCommand { | ||
| 208 | 208 | /// that became done. |
| 209 | 209 | public func run(in context: EditContext) -> CommandStep { |
| 210 | 210 | let previous = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings).todoKeyword |
| 211 | if context.options.fastTodoSelection, entryHeading(at: context.caret, in: context.tree) != nil, | |
| 212 | let fast = FastSelection.todo(context.tree.settings) { | |
| 213 | guard let answer = context.answers["todo"] else { | |
| 214 | return .prompt(Prompt(key: "todo", message: "TODO state: [a-z..]:Set [SPC]:clear", fast: fast)) | |
| 215 | } | |
| 216 | guard let key = answer.first, let keyword = fast.keyword(for: key, current: previous) else { return .failed("Quit") } | |
| 217 | return SetTodo(keyword: keyword).run(in: context) | |
| 218 | } | |
| 211 | 219 | return followUp(context, cycle(in: context)) { buffer in |
| 212 | 220 | try buffer.saveExcursion { |
| 213 | 221 | buffer.updateParentTodoStatistics() |
| @@ -243,6 +251,27 @@ public struct TodoCycle: OrgCommand { | ||
| 243 | 251 | } |
| 244 | 252 | } |
| 245 | 253 | |
| 254 | /// S-right and S-left on a heading: `org-todo` with `right` or `left`, through every keyword | |
| 255 | /// of every sequence. | |
| 256 | public struct TodoShift: OrgCommand { | |
| 257 | public let right: Bool | |
| 258 | public init(right: Bool) { self.right = right } | |
| 259 | public var id: String { right ? "org.todo.next" : "org.todo.previous" } | |
| 260 | public var title: String { right ? "Next TODO Keyword" : "Previous TODO Keyword" } | |
| 261 | public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil } | |
| 262 | public func run(in context: EditContext) -> CommandStep { | |
| 263 | let keywords = context.tree.settings.todoSequences.flatMap { ($0.active + $0.done).map(\.name) } | |
| 264 | let current = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings).todoKeyword | |
| 265 | let index = current.flatMap(keywords.firstIndex(of:)) | |
| 266 | let next: String? = if right { | |
| 267 | index.map { $0 + 1 < keywords.count ? keywords[$0 + 1] : nil } ?? keywords.first | |
| 268 | } else { | |
| 269 | index.map { $0 > 0 ? keywords[$0 - 1] : nil } ?? keywords.last | |
| 270 | } | |
| 271 | return SetTodo(keyword: next).run(in: context) | |
| 272 | } | |
| 273 | } | |
| 274 | ||
| 246 | 275 | // MARK: - Priority |
| 247 | 276 | |
| 248 | 277 | struct PriorityChange { |
Sources/OrgCore/Commands/TagCommands.swift +36 −6
| @@ -1,7 +1,8 @@ | ||
| 1 | 1 | import Foundation |
| 2 | 2 | |
| 3 | /// C-c C-q: `org-set-tags-command` without fast tag selection. Asks for the tags, then sets | |
| 4 | /// them as `org-set-tags` does and realigns. | |
| 3 | /// C-c C-q: `org-set-tags-command`. With keys in `#+TAGS`, fast tag selection; otherwise asks | |
| 4 | /// for the tags, completing from the file's tags and the workspace's. Sets them as | |
| 5 | /// `org-set-tags` does and realigns. | |
| 5 | 6 | public struct SetTags: OrgCommand { |
| 6 | 7 | public init() {} |
| 7 | 8 | public var id: String { "org.tags.set" } |
| @@ -13,17 +14,46 @@ public struct SetTags: OrgCommand { | ||
| 13 | 14 | answer.split { !($0.isLetter || $0.isNumber || "_@#%".contains($0)) }.map(String.init) |
| 14 | 15 | } |
| 15 | 16 | |
| 17 | /// `org-get-buffer-tags`: every heading's tags, then `#+FILETAGS`. | |
| 18 | static func bufferTags(_ context: EditContext) -> [String] { | |
| 19 | var names: [String] = [] | |
| 20 | let model = DocumentModel(tree: context.tree) | |
| 21 | for heading in model.headings { for tag in heading.localTags where !names.contains(tag) { names.append(tag) } } | |
| 22 | let buffer = EmacsBuffer(context.text, point: 0, settings: context.tree.settings) | |
| 23 | let fileTags = (buffer.keyword("FILETAGS") ?? "").split(whereSeparator: { $0 == ":" || $0 == " " || $0 == "\t" }).map(String.init) | |
| 24 | for tag in fileTags where !names.contains(tag) { names.append(tag) } | |
| 25 | return names | |
| 26 | } | |
| 27 | ||
| 28 | func inheritedTags(_ context: EditContext) -> [String] { | |
| 29 | EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings).tags().inherited | |
| 30 | } | |
| 31 | ||
| 16 | 32 | static let tagLine = "^\\*+ (?:.*[ \\t])?(:([[:alnum:]_@#%:]+):)[ \\t]*$" |
| 17 | 33 | |
| 18 | 34 | public func run(in context: EditContext) -> CommandStep { |
| 19 | 35 | guard let heading = entryHeading(at: context.caret, in: context.tree) else { |
| 20 | 36 | return .failed("Setting file tags is not supported yet") |
| 21 | 37 | } |
| 22 | guard let answer = context.answers["tags"] else { | |
| 23 | let current = HeadingLine(heading).tags.map { (context.text as NSString).substring(with: NSRange($0.range)) } ?? "" | |
| 24 | return .prompt(Prompt(key: "tags", message: "Tags:", initial: current)) | |
| 38 | let currentText = HeadingLine(heading).tags.map { (context.text as NSString).substring(with: NSRange($0.range)) } ?? "" | |
| 39 | let current = currentText.split(separator: ":").map(String.init) | |
| 40 | let table = FastSelection.tagTable(context.text) ?? Self.bufferTags(context).map { .option(key: "\0", name: $0) } | |
| 41 | let tags: [String] | |
| 42 | if let fast = FastSelection.tags(table: table, current: current, inherited: inheritedTags(context)) { | |
| 43 | guard let answer = context.answers["tags"] else { | |
| 44 | return .prompt(Prompt(key: "tags", message: "Tags: [a-z..]:toggle [SPC]:clear [RET]:accept [TAB]:edit [!] groups", fast: fast)) | |
| 45 | } | |
| 46 | guard let selected = fast.tags(after: FastSelection.decode(answer)) else { return .failed("Quit") } | |
| 47 | tags = Self.tags(from: selected.joined(separator: ":")) | |
| 48 | } else { | |
| 49 | guard let answer = context.answers["tags"] else { | |
| 50 | var names: [String] = [] | |
| 51 | for case .option(_, let name) in table where !names.contains(name) { names.append(name) } | |
| 52 | for name in context.knownTags() where !names.contains(name) { names.append(name) } | |
| 53 | return .prompt(Prompt(key: "tags", message: "Tags:", initial: currentText, choices: names, requireMatch: false, completionSeparator: ":")) | |
| 54 | } | |
| 55 | tags = Self.tags(from: answer) | |
| 25 | 56 | } |
| 26 | let tags = Self.tags(from: answer) | |
| 27 | 57 | return commitBuffer(context) { buffer in |
| 28 | 58 | try buffer.saveExcursion { |
| 29 | 59 | try buffer.backToHeading() |
Sources/OrgCore/Keymap/Presets.swift +5 −1
| @@ -645,7 +645,11 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | ||
| 645 | 645 | command = "org.todo.cycle" |
| 646 | 646 | [[bind]] |
| 647 | 647 | keys = "S-<right>" |
| 648 | command = "org.todo.cycle" | |
| 648 | command = "org.todo.next" | |
| 649 | when = "heading" | |
| 650 | [[bind]] | |
| 651 | keys = "S-<left>" | |
| 652 | command = "org.todo.previous" | |
| 649 | 653 | when = "heading" |
| 650 | 654 | [[bind]] |
| 651 | 655 | keys = "S-<up>" |
Sources/OrgCore/Parser/Settings.swift +6 −5
| @@ -58,12 +58,13 @@ public struct OrgSettings: Sendable, Equatable { | ||
| 58 | 58 | Set(todoSequences.flatMap { ($0.active + $0.done).map(\.name) }) |
| 59 | 59 | } |
| 60 | 60 | |
| 61 | /// These settings with TODO keywords from `#+TODO:` syntax (`TODO NEXT | DONE`); nil when | |
| 62 | /// the line has no keywords. | |
| 63 | public func withTodoKeywords(_ line: String) -> OrgSettings? { | |
| 64 | guard let sequence = SettingsScanner.todoSequence(Substring(line), kind: .sequence) else { return nil } | |
| 61 | /// These settings with TODO keywords from `#+TODO:` syntax (`TODO NEXT | DONE`), one | |
| 62 | /// sequence per line; nil when there are no keywords. | |
| 63 | public func withTodoKeywords(_ lines: String) -> OrgSettings? { | |
| 64 | let sequences = lines.split(separator: "\n").compactMap { SettingsScanner.todoSequence($0, kind: .sequence) } | |
| 65 | guard !sequences.isEmpty else { return nil } | |
| 65 | 66 | var copy = self |
| 66 | copy.todoSequences = [sequence] | |
| 67 | copy.todoSequences = sequences | |
| 67 | 68 | return copy |
| 68 | 69 | } |
| 69 | 70 | |
Sources/OrgEditorAppKit/OrgEditor.swift +3 −1
| @@ -260,6 +260,8 @@ public final class OrgEditor: NSObject { | ||
| 260 | 260 | public var onPrompt: ((Prompt, _ reply: @escaping (String?) -> Void) -> Void)? |
| 261 | 261 | /// Runs work outside the text, such as a table recalculation in Emacs. |
| 262 | 262 | public var onExternal: ((ExternalRequest) -> Void)? |
| 263 | /// Tags used across the workspace, for tag completion. | |
| 264 | public var knownTags: @Sendable () -> [String] = { [] } | |
| 263 | 265 | /// Effects other than messages, for the app to carry out. |
| 264 | 266 | public var onEffect: ((Effect) -> Void)? |
| 265 | 267 | |
| @@ -315,7 +317,7 @@ public final class OrgEditor: NSObject { | ||
| 315 | 317 | return EditContext( |
| 316 | 318 | revision: document.revision, text: document.text, tree: document.tree, |
| 317 | 319 | selection: [selected.location..<NSMaxRange(selected)], now: now, calendar: .current, answers: answers, |
| 318 | hidden: hidden.all, options: options | |
| 320 | hidden: hidden.all, options: options, knownTags: knownTags | |
| 319 | 321 | ) |
| 320 | 322 | } |
| 321 | 323 | |
Sources/OrgIndex/IndexStore.swift +7
| @@ -291,6 +291,13 @@ public final class IndexStore: Sendable { | ||
| 291 | 291 | } |
| 292 | 292 | |
| 293 | 293 | /// Headings with `:ID: id`. More than one means the ID is duplicated. |
| 294 | /// Every tag a heading has, most used first. | |
| 295 | public func tags() throws -> [String] { | |
| 296 | try database.read { db in | |
| 297 | try String.fetchAll(db, sql: "SELECT tag FROM tags WHERE inherited = 0 GROUP BY tag ORDER BY count(*) DESC, tag") | |
| 298 | } | |
| 299 | } | |
| 300 | ||
| 294 | 301 | public func headings(withID id: String, overlay: [String: FileRecord] = [:]) throws -> [HeadingLocation] { |
| 295 | 302 | let indexed = try database.read { db in |
| 296 | 303 | try Row.fetchAll( |
Sources/Orgstar/AgendaView.swift +13 −1
| @@ -147,7 +147,19 @@ struct AgendaView: View { | ||
| 147 | 147 | ContentUnavailableView("No agenda files", systemImage: "calendar", description: Text("The agenda reads the org files at the top of each folder you add.")) |
| 148 | 148 | } |
| 149 | 149 | } |
| 150 | .alert(prompt?.prompt.message ?? "", isPresented: Binding(get: { prompt != nil }, set: { if !$0 { prompt = nil } })) { | |
| 150 | .sheet(item: Binding(get: { prompt?.prompt.fast == nil ? nil : prompt }, set: { if $0 == nil { prompt = nil } })) { pending in | |
| 151 | if let fast = pending.prompt.fast { | |
| 152 | FastSelectionView(selection: fast, message: pending.prompt.message) { answer in | |
| 153 | prompt = nil | |
| 154 | guard let answer else { return } | |
| 155 | promptText = answer | |
| 156 | prompt = pending | |
| 157 | answerPrompt() | |
| 158 | } | |
| 159 | .frame(minWidth: 420) | |
| 160 | } | |
| 161 | } | |
| 162 | .alert(prompt?.prompt.message ?? "", isPresented: Binding(get: { prompt != nil && prompt?.prompt.fast == nil }, set: { if !$0 { prompt = nil } })) { | |
| 151 | 163 | TextField("", text: $promptText) |
| 152 | 164 | Button("OK") { answerPrompt() } |
| 153 | 165 | Button("Cancel", role: .cancel) { prompt = nil } |
Sources/Orgstar/ContentView.swift +1 −1
| @@ -94,7 +94,7 @@ struct ContentView: View { | ||
| 94 | 94 | } else if session.path != nil { |
| 95 | 95 | VStack(spacing: 0) { |
| 96 | 96 | if showTabBar { BufferTabs(session: session) } |
| 97 | EditorView(session: session, commands: commands, keymap: keymap, modal: preset.isModal, optionAsMeta: OptionAsMeta(rawValue: optionAsMeta), options: options, showMarkup: showMarkup, showLineNumbers: showLineNumbers, theme: theme, onAppCommand: perform, onExternal: runExternal) | |
| 97 | EditorView(session: session, commands: commands, keymap: keymap, modal: preset.isModal, optionAsMeta: OptionAsMeta(rawValue: optionAsMeta), options: options, showMarkup: showMarkup, showLineNumbers: showLineNumbers, theme: theme, onAppCommand: perform, onExternal: runExternal, knownTags: { [index = workspace.index] in (try? index.tags()) ?? [] }) | |
| 98 | 98 | } |
| 99 | 99 | .safeAreaInset(edge: .bottom, spacing: 0) { |
| 100 | 100 | VStack(spacing: 0) { |
Sources/Orgstar/EditorView.swift +2
| @@ -24,6 +24,7 @@ struct EditorView: NSViewRepresentable { | ||
| 24 | 24 | let theme: ThemeSpec |
| 25 | 25 | let onAppCommand: (String) -> Void |
| 26 | 26 | let onExternal: (ExternalRequest) -> Void |
| 27 | let knownTags: @Sendable () -> [String] | |
| 27 | 28 | |
| 28 | 29 | final class Coordinator { |
| 29 | 30 | var editor: OrgEditor? |
| @@ -94,6 +95,7 @@ struct EditorView: NSViewRepresentable { | ||
| 94 | 95 | editor.onSelectionChange = { [weak commands] in commands?.selection = $0 } |
| 95 | 96 | editor.onAppCommand = onAppCommand |
| 96 | 97 | editor.onExternal = onExternal |
| 98 | editor.knownTags = knownTags | |
| 97 | 99 | editor.onPrompt = { [weak commands] prompt, reply in commands?.ask(prompt, reply: reply) } |
| 98 | 100 | editor.onEffect = { [weak commands] effect in |
| 99 | 101 | if case .storedLinkUsed(let link) = effect { commands?.storedLinks.removeAll { $0.link == link } } |
Sources/Orgstar/FastSelectionView.swift added +128
| @@ -0,0 +1,128 @@ | ||
| 1 | import OrgCore | |
| 2 | import SwiftUI | |
| 3 | ||
| 4 | /// Fast TODO and tag selection in the echo area: each option with its key. TODO keywords take | |
| 5 | /// one key; tags toggle until Return, Tab types a tag, Escape or C-g quits. | |
| 6 | struct FastSelectionView: View { | |
| 7 | let selection: FastSelection | |
| 8 | let message: String | |
| 9 | let answer: (String?) -> Void | |
| 10 | @Environment(\.orgTheme) private var theme | |
| 11 | @State private var inputs: [FastSelection.Input] = [] | |
| 12 | @State private var typing = false | |
| 13 | @State private var typed = "" | |
| 14 | @FocusState private var focused: Bool | |
| 15 | @FocusState private var typingFocused: Bool | |
| 16 | ||
| 17 | var body: some View { | |
| 18 | let current = selection.multiple ? selection.tags(after: inputs) ?? [] : [] | |
| 19 | VStack(alignment: .leading, spacing: 6) { | |
| 20 | if selection.multiple { | |
| 21 | if !selection.inherited.isEmpty { line("Inherited:", selection.inherited) } | |
| 22 | line("Current:", current) | |
| 23 | } | |
| 24 | ForEach(Array(rows.enumerated()), id: \.offset) { _, row in | |
| 25 | HStack(spacing: 14) { | |
| 26 | ForEach(Array(row.enumerated()), id: \.offset) { _, cell in | |
| 27 | switch cell { | |
| 28 | case .text(let text): | |
| 29 | Text(text).foregroundStyle(.secondary) | |
| 30 | case .option(let key, let name): | |
| 31 | HStack(spacing: 4) { | |
| 32 | Text("[\(String(key))]").foregroundStyle(.tint) | |
| 33 | Text(name) | |
| 34 | .fontWeight(current.contains(name) ? .bold : .regular) | |
| 35 | .foregroundStyle(current.contains(name) ? theme.color("todo") ?? .primary : .primary) | |
| 36 | } | |
| 37 | } | |
| 38 | } | |
| 39 | } | |
| 40 | } | |
| 41 | if typing { | |
| 42 | HStack(spacing: 6) { | |
| 43 | Text("Tag:").foregroundStyle(.secondary) | |
| 44 | TextField("", text: $typed) | |
| 45 | .textFieldStyle(.plain) | |
| 46 | .focused($typingFocused) | |
| 47 | .onSubmit { | |
| 48 | inputs.append(.typed(typed)) | |
| 49 | typed = "" | |
| 50 | typing = false | |
| 51 | focused = true | |
| 52 | } | |
| 53 | .onExitCommand { | |
| 54 | typing = false | |
| 55 | focused = true | |
| 56 | } | |
| 57 | } | |
| 58 | } | |
| 59 | Text(message).foregroundStyle(.secondary) | |
| 60 | } | |
| 61 | .font(.system(.callout, design: .monospaced)) | |
| 62 | .frame(maxWidth: .infinity, alignment: .leading) | |
| 63 | .padding(10) | |
| 64 | .focusable() | |
| 65 | .focusEffectDisabled() | |
| 66 | .focused($focused) | |
| 67 | .onKeyPress(phases: .down, action: press) | |
| 68 | .onAppear { focused = true } | |
| 69 | .onChange(of: typing) { _, now in if now { typingFocused = true } } | |
| 70 | } | |
| 71 | ||
| 72 | private func line(_ label: String, _ tags: [String]) -> some View { | |
| 73 | HStack(spacing: 6) { | |
| 74 | Text(label).foregroundStyle(.secondary) | |
| 75 | Text(tags.isEmpty ? "" : ":" + tags.joined(separator: ":") + ":") | |
| 76 | } | |
| 77 | } | |
| 78 | ||
| 79 | private func press(_ key: KeyPress) -> KeyPress.Result { | |
| 80 | guard !typing else { return .ignored } | |
| 81 | if key.key == .escape || (key.modifiers.contains(.control) && key.characters == "g") { | |
| 82 | answer(nil) | |
| 83 | return .handled | |
| 84 | } | |
| 85 | if selection.multiple { | |
| 86 | if key.key == .return { | |
| 87 | answer(FastSelection.encode(inputs)) | |
| 88 | } else if key.key == .tab { | |
| 89 | typing = true | |
| 90 | } else if let c = key.characters.first, key.characters.count == 1 { | |
| 91 | if c == "q", !selection.options.contains(where: { $0.key == "q" }) { | |
| 92 | answer(nil) | |
| 93 | } else { | |
| 94 | inputs.append(.key(c)) | |
| 95 | } | |
| 96 | } else { | |
| 97 | return .ignored | |
| 98 | } | |
| 99 | return .handled | |
| 100 | } | |
| 101 | guard let c = key.characters.first, key.characters.count == 1 else { return .ignored } | |
| 102 | answer(String(c)) | |
| 103 | return .handled | |
| 104 | } | |
| 105 | ||
| 106 | private enum Cell { | |
| 107 | case text(String) | |
| 108 | case option(Character, String) | |
| 109 | } | |
| 110 | ||
| 111 | /// Options in rows, braces and brackets around groups, as org lays them out. | |
| 112 | private var rows: [[Cell]] { | |
| 113 | var rows: [[Cell]] = [[]] | |
| 114 | func newRow() { if !(rows.last?.isEmpty ?? true) { rows.append([]) } } | |
| 115 | for item in selection.items { | |
| 116 | switch item { | |
| 117 | case .startGroup: newRow(); rows[rows.count - 1].append(.text("{")) | |
| 118 | case .endGroup: rows[rows.count - 1].append(.text("}")); newRow() | |
| 119 | case .startTagGroup: newRow(); rows[rows.count - 1].append(.text("[")) | |
| 120 | case .endTagGroup: rows[rows.count - 1].append(.text("]")); newRow() | |
| 121 | case .groupTags: rows[rows.count - 1].append(.text(":")) | |
| 122 | case .newline: newRow() | |
| 123 | case .option(let key, let name): rows[rows.count - 1].append(.option(key, name)) | |
| 124 | } | |
| 125 | } | |
| 126 | return rows.filter { !$0.isEmpty } | |
| 127 | } | |
| 128 | } | |
Sources/Orgstar/Keys.swift +28 −5
| @@ -31,14 +31,20 @@ struct EchoArea: View { | ||
| 31 | 31 | } |
| 32 | 32 | .padding(10) |
| 33 | 33 | } |
| 34 | if let request = commands.prompt { | |
| 34 | if let request = commands.prompt, let fast = request.prompt.fast { | |
| 35 | Divider() | |
| 36 | FastSelectionView(selection: fast, message: request.prompt.message) { commands.answer($0) } | |
| 37 | .id(request.id) | |
| 38 | } else if let request = commands.prompt { | |
| 35 | 39 | if !request.prompt.choices.isEmpty { |
| 36 | 40 | Divider() |
| 37 | 41 | let matches = filteredChoices(request.prompt.choices) |
| 38 | 42 | ScrollView { |
| 39 | 43 | VStack(alignment: .leading, spacing: 2) { |
| 40 | 44 | ForEach(matches.prefix(12), id: \.self) { choice in |
| 41 | Button(choice) { commands.answer(choice) } | |
| 45 | Button(choice) { | |
| 46 | if request.prompt.completionSeparator != nil { complete(choice, request.prompt) } else { commands.answer(choice) } | |
| 47 | } | |
| 42 | 48 | .buttonStyle(.plain) |
| 43 | 49 | .font(.system(.callout, design: .monospaced)) |
| 44 | 50 | .foregroundStyle(choice == matches.first ? .primary : .secondary) |
| @@ -68,7 +74,7 @@ struct EchoArea: View { | ||
| 68 | 74 | } |
| 69 | 75 | .onKeyPress(.tab) { |
| 70 | 76 | guard let first = filteredChoices(request.prompt.choices).first else { return .ignored } |
| 71 | answer = first | |
| 77 | complete(first, request.prompt) | |
| 72 | 78 | return .handled |
| 73 | 79 | } |
| 74 | 80 | .onExitCommand { commands.answer(nil) } |
| @@ -132,10 +138,27 @@ struct StateTag: View { | ||
| 132 | 138 | } |
| 133 | 139 | |
| 134 | 140 | extension EchoArea { |
| 141 | /// What completion matches: the text after the last separator, or all of it. | |
| 142 | private func completing(_ separator: Character?) -> Substring { | |
| 143 | guard let separator, let last = answer.lastIndex(of: separator) else { return answer[...] } | |
| 144 | return answer[answer.index(after: last)...] | |
| 145 | } | |
| 146 | ||
| 147 | /// Puts `choice` in place of what's being completed; with a separator, adds one after it. | |
| 148 | func complete(_ choice: String, _ prompt: Prompt) { | |
| 149 | guard let separator = prompt.completionSeparator else { | |
| 150 | answer = choice | |
| 151 | return | |
| 152 | } | |
| 153 | let start = answer.lastIndex(of: separator).map { answer.index(after: $0) } ?? answer.startIndex | |
| 154 | answer = String(answer[..<start]) + (start == answer.startIndex ? String(separator) : "") + choice + String(separator) | |
| 155 | } | |
| 156 | ||
| 135 | 157 | /// Choices matching what's typed, best first. |
| 136 | 158 | func filteredChoices(_ choices: [String]) -> [String] { |
| 137 | guard !answer.isEmpty else { return choices } | |
| 138 | return choices.compactMap { choice in FuzzyMatch.score(answer, choice).map { (choice, $0) } } | |
| 159 | let typed = String(completing(commands.prompt?.prompt.completionSeparator)) | |
| 160 | guard !typed.isEmpty else { return choices } | |
| 161 | return choices.compactMap { choice in FuzzyMatch.score(typed, choice).map { (choice, $0) } } | |
| 139 | 162 | .sorted { $0.1 > $1.1 } |
| 140 | 163 | .map(\.0) |
| 141 | 164 | } |
Sources/Orgstar/OrgstarApp.swift +2 −2
| @@ -543,9 +543,9 @@ struct SettingsView: View { | ||
| 543 | 543 | .settingsFooter() |
| 544 | 544 | } |
| 545 | 545 | Section { |
| 546 | TextField("Default TODO keywords", text: $todoKeywords) | |
| 546 | TextField("Default TODO keywords", text: $todoKeywords, axis: .vertical) | |
| 547 | 547 | } footer: { |
| 548 | Text("For files without #+TODO, in #+TODO syntax. Takes effect when Orgstar restarts.") | |
| 548 | Text("For files without #+TODO, in #+TODO syntax, one sequence per line. Takes effect when Orgstar restarts.") | |
| 549 | 549 | .settingsFooter() |
| 550 | 550 | } |
| 551 | 551 | } |
Tests/OrgAppTests/EmacsImportTests.swift +3 −3
| @@ -37,7 +37,7 @@ struct EmacsImportTests { | ||
| 37 | 37 | (setq org-tags-column 0 |
| 38 | 38 | org-agenda-span 10 |
| 39 | 39 | org-agenda-start-day "-3d" |
| 40 | org-todo-keywords '((sequence "TODO(t)" "WAIT(w)" "|" "DONE(d)") (sequence "[ ](T)" "|" "[X](D)")))) | |
| 40 | org-todo-keywords '((sequence "TODO(t)" "WAIT(w)" "|" "DONE(d)") (sequence "[ ](T)" "|" "[X](D)") (sequence "BUG" "|" "FIXED")))) | |
| 41 | 41 | (after! org (setq org-M-RET-may-split-line nil org-list-allow-alphabetical t)) |
| 42 | 42 | (map! :map org-mode-map "C-c q" #'org-set-tags-command) |
| 43 | 43 | """# |
| @@ -74,7 +74,7 @@ struct EmacsImportTests { | ||
| 74 | 74 | #expect(s["org-agenda-span"] == .integer(10)) |
| 75 | 75 | #expect(s["org-agenda-start-day"] == .string("-3d")) |
| 76 | 76 | #expect(s["org-M-RET-may-split-line"] == .bool(false)) |
| 77 | #expect(s["org-todo-keywords"] == .string("TODO(t) WAIT(w) | DONE(d)")) | |
| 77 | #expect(s["org-todo-keywords"] == .string("TODO(t) WAIT(w) | DONE(d)\nBUG | FIXED")) | |
| 78 | 78 | #expect(s["fill-column"] == nil) |
| 79 | 79 | #expect(s["font"] == .string("JetBrains Mono") && s["font-size"] == .integer(14)) |
| 80 | 80 | let folders = result.items.compactMap { if case .folder(let f) = $0.change { f } else { nil } } |
| @@ -129,7 +129,7 @@ struct EmacsImportTests { | ||
| 129 | 129 | #expect(defaults.string(forKey: "keymap") == "doom") |
| 130 | 130 | #expect(defaults.integer(forKey: "tagsColumn") == -80) |
| 131 | 131 | #expect(defaults.integer(forKey: "agendaStartOffset") == -3) |
| 132 | #expect(defaults.string(forKey: "todoKeywords") == "TODO(t) WAIT(w) | DONE(d)") | |
| 132 | #expect(defaults.string(forKey: "todoKeywords") == "TODO(t) WAIT(w) | DONE(d)\nBUG | FIXED") | |
| 133 | 133 | let capture = try String(contentsOf: folder.appendingPathComponent("capture.toml"), encoding: .utf8) |
| 134 | 134 | #expect(capture.components(separatedBy: "[[template]]").count == 3) |
| 135 | 135 | #expect(CaptureTemplates.load(userFile: folder.appendingPathComponent("capture.toml")).problems.isEmpty) |
Tests/OrgCoreTests/FastSelectionTests.swift added +109
| @@ -0,0 +1,109 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import OrgCore | |
| 4 | ||
| 5 | struct FastSelectionTests { | |
| 6 | static let oracle = ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil | |
| 7 | ||
| 8 | static let todoTexts = [ | |
| 9 | // Without log flags: notes are stored after the command, which these cases don't reach. | |
| 10 | "#+TODO: TODO(t) WAIT(w) | DONE(d) CANCELED(c)\n* TODO Task\n", | |
| 11 | "#+TODO: TODO(t) NEXT | DONE(d)\n#+TODO: BUG(b) TRIAGE(t) | FIXED(f)\n* BUG Task\n", | |
| 12 | "#+TODO: TODO(t) NEXT | DONE(d)\n#+TODO: BUG(b) TRIAGE(t) | FIXED(f)\n* Task\n", | |
| 13 | "#+TODO: TODO(t) DOING DONE\n* DOING Task\n", | |
| 14 | ] | |
| 15 | ||
| 16 | @Test(.enabled(if: oracle)) | |
| 17 | func selectsTodoLikeOrgTodo() throws { | |
| 18 | var cases: [EmacsOracle.Case] = [] | |
| 19 | var ours: [String] = [] | |
| 20 | for text in Self.todoTexts { | |
| 21 | let heading = (text as NSString).range(of: "Task").location | |
| 22 | for key in ["t", "w", "d", " ", "x", "q", "n", "o", "0", "f"] { | |
| 23 | let char = key == " " ? "?\\s" : "?" + key | |
| 24 | cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(heading, in: text), form: """ | |
| 25 | (condition-case nil | |
| 26 | (cl-letf (((symbol-function 'read-char-exclusive) (lambda (&rest _) \(char)))) (org-todo)) | |
| 27 | (quit nil)) | |
| 28 | """)) | |
| 29 | ours.append(runCommand(TodoCycle(), text, caret: heading, answers: ["todo": key]).text) | |
| 30 | } | |
| 31 | } | |
| 32 | let emacs = try EmacsOracle.run(cases) | |
| 33 | for (i, (mine, theirs)) in zip(ours, emacs).enumerated() { | |
| 34 | #expect(mine == theirs.text, "\(mine.debugDescription) vs \(theirs.text.debugDescription) \(cases[i].form) on \(cases[i].text.debugDescription)") | |
| 35 | } | |
| 36 | } | |
| 37 | ||
| 38 | @Test(.enabled(if: oracle)) | |
| 39 | func shiftsLikeOrgShiftRightAndLeft() throws { | |
| 40 | let texts = Self.todoTexts + ["* Task\n", "* TODO Task\n", "* DONE Task\n"] | |
| 41 | var cases: [EmacsOracle.Case] = [] | |
| 42 | var ours: [String] = [] | |
| 43 | for text in texts { | |
| 44 | let heading = (text as NSString).range(of: "Task").location | |
| 45 | for (right, form) in [(true, "(org-shiftright)"), (false, "(org-shiftleft)"), (true, "(progn (org-shiftright) (org-shiftright))")] { | |
| 46 | cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(heading, in: text), form: form)) | |
| 47 | var result = runCommand(TodoShift(right: right), text, caret: heading).text | |
| 48 | if form.contains("progn") { result = runCommand(TodoShift(right: right), result, caret: heading).text } | |
| 49 | ours.append(result) | |
| 50 | } | |
| 51 | } | |
| 52 | let emacs = try EmacsOracle.run(cases) | |
| 53 | for (i, (mine, theirs)) in zip(ours, emacs).enumerated() { | |
| 54 | #expect(mine == theirs.text, "\(mine.debugDescription) vs \(theirs.text.debugDescription) \(cases[i].form) on \(cases[i].text.debugDescription) \(theirs.error)") | |
| 55 | } | |
| 56 | } | |
| 57 | ||
| 58 | static let tagTexts = [ | |
| 59 | "#+TAGS: work(w) home(h) errand\n* Task :home:\n", | |
| 60 | "#+TAGS: { office(o) home(h) } computer(c) phone\n#+TAGS: @read reading\n* Task :phone:\n", | |
| 61 | "#+TAGS: [ Project : alpha(a) beta(b) ] solo(s)\n#+FILETAGS: :top:\n* Parent :solo:\n** Task\n", | |
| 62 | "#+TAGS: alpha(a) apple avocado\n* Task :avocado:alpha:\n", | |
| 63 | ] | |
| 64 | ||
| 65 | @Test(.enabled(if: oracle)) | |
| 66 | func selectsTagsLikeOrgFastTagSelection() throws { | |
| 67 | let sequences = ["\r", "w\r", "wh\r", "o\r", "oh\r", "c o h\r", "!oh\r", " \r", "a\r", "e\r", "r\r", "q", "ap\r", "s\r", "b\r", "AB\r"] | |
| 68 | var cases: [EmacsOracle.Case] = [] | |
| 69 | var ours: [String] = [] | |
| 70 | for text in Self.tagTexts { | |
| 71 | let heading = (text as NSString).range(of: "Task").location | |
| 72 | for keys in sequences { | |
| 73 | let chars = keys.map { $0 == "\r" ? "?\\r" : $0 == " " ? "?\\s" : "?" + String($0) }.joined(separator: " ") | |
| 74 | cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(heading, in: text), form: """ | |
| 75 | (condition-case nil | |
| 76 | (let ((keys (list \(chars)))) | |
| 77 | (cl-letf (((symbol-function 'read-char-exclusive) (lambda (&rest _) (if keys (pop keys) (signal 'quit nil))))) | |
| 78 | (org-set-tags-command))) | |
| 79 | (quit nil)) | |
| 80 | """)) | |
| 81 | let answer = String(keys.prefix { $0 != "\r" }) | |
| 82 | ours.append(runCommand(SetTags(), text, caret: heading, answers: ["tags": answer]).text) | |
| 83 | } | |
| 84 | } | |
| 85 | let emacs = try EmacsOracle.run(cases) | |
| 86 | for (i, (mine, theirs)) in zip(ours, emacs).enumerated() { | |
| 87 | #expect(mine == theirs.text, "\(mine.debugDescription) vs \(theirs.text.debugDescription) \(cases[i].form.split(separator: "\n")[1]) on \(cases[i].text.debugDescription) \(theirs.error)") | |
| 88 | } | |
| 89 | } | |
| 90 | ||
| 91 | @Test func assignsTagKeysAsOrgDoes() { | |
| 92 | let table = FastSelection.tagTable("#+TAGS: alpha(a) apple avocado banana @bread\n")! | |
| 93 | let fast = FastSelection.tags(table: table, current: [], inherited: [])! | |
| 94 | #expect(fast.options.map { "\($0.key)\($0.name)" } == ["aalpha", "bapple", "cavocado", "dbanana", "e@bread"]) | |
| 95 | } | |
| 96 | ||
| 97 | @Test func encodesTypedTags() { | |
| 98 | let inputs: [FastSelection.Input] = [.key("a"), .typed("new tag"), .key(" ")] | |
| 99 | #expect(FastSelection.decode(FastSelection.encode(inputs)) == inputs) | |
| 100 | } | |
| 101 | } | |
| 102 | ||
| 103 | struct TodoKeywordSettingsTests { | |
| 104 | @Test func readsOneSequencePerLine() { | |
| 105 | let settings = OrgSettings.default.withTodoKeywords("TODO(t) | DONE(d)\nBUG | FIXED\n")! | |
| 106 | #expect(settings.todoSequences.map { ($0.active + $0.done).map(\.name) } == [["TODO", "DONE"], ["BUG", "FIXED"]]) | |
| 107 | #expect(OrgSettings.default.withTodoKeywords("\n") == nil) | |
| 108 | } | |
| 109 | } | |
Tests/OrgCoreTests/KeymapTests.swift +3 −2
| @@ -84,12 +84,13 @@ struct KeymapTests { | ||
| 84 | 84 | let keymap = Keymap.layered([preset, user]) |
| 85 | 85 | #expect(keymap.candidates(for: KeySequence.parse("C-c C-t")!).first?.command == "org.priority.up") |
| 86 | 86 | #expect(keymap.candidates(for: KeySequence.parse("C-k")!).isEmpty) |
| 87 | #expect(keymap.keys(for: "org.todo.cycle").map(KeySequence.format) == "S-<right>") | |
| 87 | #expect(keymap.keys(for: "org.todo.next").map(KeySequence.format) == "S-<right>") | |
| 88 | 88 | } |
| 89 | 89 | |
| 90 | 90 | @Test func keyLabelsForMenus() { |
| 91 | 91 | let emacs = KeymapPreset.emacs.keymap |
| 92 | #expect(emacs.keyLabels(for: "org.todo.cycle") == ["C-c C-t", "S-<right>"]) | |
| 92 | #expect(emacs.keyLabels(for: "org.todo.cycle") == ["C-c C-t"]) | |
| 93 | #expect(emacs.keyLabels(for: "org.todo.next") == ["S-<right>"]) | |
| 93 | 94 | #expect(emacs.keyLabels(for: "org.schedule") == ["C-c C-s"]) |
| 94 | 95 | let doom = KeymapPreset.doom.keymap |
| 95 | 96 | let labels = doom.keyLabels(for: "org.todo.cycle", modes: ["normal", "insert"]) |
Tests/OrgCoreTests/TagCommandTests.swift +1 −1
| @@ -6,7 +6,7 @@ struct TagCommandTests { | ||
| 6 | 6 | @Test func promptsWithCurrentTags() { |
| 7 | 7 | let text = "* a :x:y:\n" |
| 8 | 8 | let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [0..<0]) |
| 9 | #expect(SetTags().run(in: context) == .prompt(Prompt(key: "tags", message: "Tags:", initial: ":x:y:"))) | |
| 9 | #expect(SetTags().run(in: context) == .prompt(Prompt(key: "tags", message: "Tags:", initial: ":x:y:", choices: ["x", "y"], requireMatch: false, completionSeparator: ":"))) | |
| 10 | 10 | } |
| 11 | 11 | |
| 12 | 12 | @Test func setsAndClears() { |
Tests/OrgCoreTests/TodoLoggingTests.swift +1 −1
| @@ -38,7 +38,7 @@ struct TodoLoggingTests { | ||
| 38 | 38 | for text in Self.texts { |
| 39 | 39 | let heading = (text as NSString).range(of: "Task").location |
| 40 | 40 | for (done, drawer) in Self.options { |
| 41 | let options = EditingOptions(logDone: done, logIntoDrawer: drawer) | |
| 41 | let options = EditingOptions(logDone: done, logIntoDrawer: drawer, fastTodoSelection: false) | |
| 42 | 42 | for (command, form) in changes { |
| 43 | 43 | let lisp = """ |
| 44 | 44 | (let ((b (current-buffer)) (org-use-fast-todo-selection nil)) |