Fast TODO and tag selection, tag completion, TODO sequences !68

merged merged by cmc on 2026-10-06 01:36 UTC · krz/orgstar:fast-selection into main

21 files changed, +649 −38

Layout: unified · split

Sources/OrgApp/EmacsImport.swift +8 −4
@@ -475,10 +475,14 @@ public struct EmacsImport: Sendable {
475 case "org-agenda-start-day": 475 case "org-agenda-start-day":
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") } 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 case "org-todo-keywords": 477 case "org-todo-keywords":
478 guard let sequences = value.elements, let first = sequences.first?.elements, first.count > 1 else { return note("unexpected value") } 478 let sequences = (value.elements ?? []).compactMap { $0.elements }.filter { $0.count > 1 }.map { $0.dropFirst().compactMap(\.string) }
479 let words = first.dropFirst().compactMap(\.string) 479 let lines = sequences.filter { !$0.contains { $0.contains(where: \.isWhitespace) } }.map { $0.joined(separator: " ") }
480 add(name, .string(words.joined(separator: " "))) 480 guard !lines.isEmpty else { return note("unexpected value") }
481 if sequences.count > 1 { note("only the first sequence is used; the other \(sequences.count - 1) are left out") } 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 case "org-directory": 486 case "org-directory":
483 guard let path = value.string else { return note("not a folder") } 487 guard let path = value.string else { return note("not a folder") }
484 items.append(Item(id: id(), name: name, summary: "add folder \(path)", source: source, change: .folder(path))) 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 Setting(section: "", key: "org-pretty-entities", defaultsKey: "prettyEntities", kind: .bool, fallback: .bool(true), 73 Setting(section: "", key: "org-pretty-entities", defaultsKey: "prettyEntities", kind: .bool, fallback: .bool(true),
74 comment: "\\alpha shows as α and x_{1} is lowered when markup is hidden", legacy: "editor.pretty-entities"), 74 comment: "\\alpha shows as α and x_{1} is lowered when markup is hidden", legacy: "editor.pretty-entities"),
75 Setting(section: "", key: "org-todo-keywords", defaultsKey: "todoKeywords", kind: .string, fallback: .string("TODO PROJ LOOP STRT WAIT HOLD IDEA | DONE KILL"), 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 Setting(section: "", key: "org-log-done", defaultsKey: "logDone", kind: .choice([("nil", "nil"), ("time", "time"), ("note", "note")]), 77 Setting(section: "", key: "org-log-done", defaultsKey: "logDone", kind: .choice([("nil", "nil"), ("time", "time"), ("note", "note")]),
78 fallback: .string("nil"), comment: "nil, time (CLOSED when done) or note (CLOSED and a note)", legacy: ""), 78 fallback: .string("nil"), comment: "nil, time (CLOSED when done) or note (CLOSED and a note)", legacy: ""),
79 Setting(section: "", key: "org-log-into-drawer", defaultsKey: "logIntoDrawer", kind: .string, fallback: .string(""), 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 public var logDone: LogHow? 17 public var logDone: LogHow?
18 /// `org-log-into-drawer`: the drawer state notes go in, or nil for none. 18 /// `org-log-into-drawer`: the drawer state notes go in, or nil for none.
19 public var logIntoDrawer: String? 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 /// `fill-column` for M-q. 22 /// `fill-column` for M-q.
21 public var fillColumn: Int 23 public var fillColumn: Int
22 /// `org-hide-emphasis-markers`: table alignment measures emphasis without its markers. 24 /// `org-hide-emphasis-markers`: table alignment measures emphasis without its markers.
@@ -30,8 +32,9 @@ public struct EditingOptions: Sendable, Equatable, Codable {
30 tagsColumn: Int = -77, insertHeadingRespectContent: Bool = false, metaReturnMaySplitLine: Bool = true, 32 tagsColumn: Int = -77, insertHeadingRespectContent: Bool = false, metaReturnMaySplitLine: Bool = true,
31 listAllowAlphabetical: Bool = false, logRepeat: Bool = true, fillColumn: Int = 70, 33 listAllowAlphabetical: Bool = false, logRepeat: Bool = true, fillColumn: Int = 70,
32 hideEmphasisMarkers: Bool = false, prettyEntities: Bool = false, subSuperscriptsNeedBraces: Bool = false, 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 self.logDone = logDone 38 self.logDone = logDone
36 self.logIntoDrawer = logIntoDrawer 39 self.logIntoDrawer = logIntoDrawer
37 self.tagsColumn = tagsColumn 40 self.tagsColumn = tagsColumn
@@ -66,12 +69,15 @@ public struct EditContext: Sendable {
66 /// Ranges the view hides (folded text); motion commands skip headings inside them. 69 /// Ranges the view hides (folded text); motion commands skip headings inside them.
67 public let hidden: [Range<Int>] 70 public let hidden: [Range<Int>]
68 public let options: EditingOptions 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 public init( 75 public init(
71 revision: Int, text: String, tree: OrgTree, selection: [Range<Int>], 76 revision: Int, text: String, tree: OrgTree, selection: [Range<Int>],
72 now: Date = Date(), calendar: Calendar = .current, answers: [String: String] = [:], hidden: [Range<Int>] = [], 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 self.revision = revision 81 self.revision = revision
76 self.text = text 82 self.text = text
77 self.tree = tree 83 self.tree = tree
@@ -95,13 +101,22 @@ public struct Prompt: Sendable, Equatable {
95 /// When set, the answer is one of these, or with `requireMatch` false, these are offered. 101 /// When set, the answer is one of these, or with `requireMatch` false, these are offered.
96 public let choices: [String] 102 public let choices: [String]
97 public let requireMatch: Bool 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 self.key = key 113 self.key = key
101 self.message = message 114 self.message = message
102 self.initial = initial 115 self.initial = initial
103 self.choices = choices 116 self.choices = choices
104 self.requireMatch = requireMatch 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
178public enum Commands { 193public enum Commands {
179 public static let all: [any OrgCommand] = [ 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 InsertHeading(), InsertHeadingAfterSubtree(), InsertTodoHeading(), 196 InsertHeading(), InsertHeadingAfterSubtree(), InsertTodoHeading(),
182 MoveSubtreeUp(), MoveSubtreeDown(), PromoteSubtree(), DemoteSubtree(), 197 MoveSubtreeUp(), MoveSubtreeDown(), PromoteSubtree(), DemoteSubtree(),
183 NextHeading(), PreviousHeading(), ForwardSameLevel(), BackwardSameLevel(), UpHeading(), 198 NextHeading(), PreviousHeading(), ForwardSameLevel(), BackwardSameLevel(), UpHeading(),
Sources/OrgCore/Commands/FastSelection.swift added +244
@@ -0,0 +1,244 @@
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 }
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 /// that became done. 208 /// that became done.
209 public func run(in context: EditContext) -> CommandStep { 209 public func run(in context: EditContext) -> CommandStep {
210 let previous = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings).todoKeyword 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 return followUp(context, cycle(in: context)) { buffer in 219 return followUp(context, cycle(in: context)) { buffer in
212 try buffer.saveExcursion { 220 try buffer.saveExcursion {
213 buffer.updateParentTodoStatistics() 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.
256public 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// MARK: - Priority 275// MARK: - Priority
247 276
248struct PriorityChange { 277struct PriorityChange {
Sources/OrgCore/Commands/TagCommands.swift +36 −6
@@ -1,7 +1,8 @@
1import Foundation 1import Foundation
2 2
3/// C-c C-q: `org-set-tags-command` without fast tag selection. Asks for the tags, then sets 3/// C-c C-q: `org-set-tags-command`. With keys in `#+TAGS`, fast tag selection; otherwise asks
4/// them as `org-set-tags` does and realigns. 4/// for the tags, completing from the file's tags and the workspace's. Sets them as
5/// `org-set-tags` does and realigns.
5public struct SetTags: OrgCommand { 6public struct SetTags: OrgCommand {
6 public init() {} 7 public init() {}
7 public var id: String { "org.tags.set" } 8 public var id: String { "org.tags.set" }
@@ -13,17 +14,46 @@ public struct SetTags: OrgCommand {
13 answer.split { !($0.isLetter || $0.isNumber || "_@#%".contains($0)) }.map(String.init) 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 static let tagLine = "^\\*+ (?:.*[ \\t])?(:([[:alnum:]_@#%:]+):)[ \\t]*$" 32 static let tagLine = "^\\*+ (?:.*[ \\t])?(:([[:alnum:]_@#%:]+):)[ \\t]*$"
17 33
18 public func run(in context: EditContext) -> CommandStep { 34 public func run(in context: EditContext) -> CommandStep {
19 guard let heading = entryHeading(at: context.caret, in: context.tree) else { 35 guard let heading = entryHeading(at: context.caret, in: context.tree) else {
20 return .failed("Setting file tags is not supported yet") 36 return .failed("Setting file tags is not supported yet")
21 } 37 }
22 guard let answer = context.answers["tags"] else { 38 let currentText = HeadingLine(heading).tags.map { (context.text as NSString).substring(with: NSRange($0.range)) } ?? ""
23 let current = HeadingLine(heading).tags.map { (context.text as NSString).substring(with: NSRange($0.range)) } ?? "" 39 let current = currentText.split(separator: ":").map(String.init)
24 return .prompt(Prompt(key: "tags", message: "Tags:", initial: current)) 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 return commitBuffer(context) { buffer in 57 return commitBuffer(context) { buffer in
28 try buffer.saveExcursion { 58 try buffer.saveExcursion {
29 try buffer.backToHeading() 59 try buffer.backToHeading()
Sources/OrgCore/Keymap/Presets.swift +5 −1
@@ -645,7 +645,11 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
645 command = "org.todo.cycle" 645 command = "org.todo.cycle"
646 [[bind]] 646 [[bind]]
647 keys = "S-<right>" 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 when = "heading" 653 when = "heading"
650 [[bind]] 654 [[bind]]
651 keys = "S-<up>" 655 keys = "S-<up>"
Sources/OrgCore/Parser/Settings.swift +6 −5
@@ -58,12 +58,13 @@ public struct OrgSettings: Sendable, Equatable {
58 Set(todoSequences.flatMap { ($0.active + $0.done).map(\.name) }) 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 61 /// These settings with TODO keywords from `#+TODO:` syntax (`TODO NEXT | DONE`), one
62 /// the line has no keywords. 62 /// sequence per line; nil when there are no keywords.
63 public func withTodoKeywords(_ line: String) -> OrgSettings? { 63 public func withTodoKeywords(_ lines: String) -> OrgSettings? {
64 guard let sequence = SettingsScanner.todoSequence(Substring(line), kind: .sequence) else { return nil } 64 let sequences = lines.split(separator: "\n").compactMap { SettingsScanner.todoSequence($0, kind: .sequence) }
65 guard !sequences.isEmpty else { return nil }
65 var copy = self 66 var copy = self
66 copy.todoSequences = [sequence] 67 copy.todoSequences = sequences
67 return copy 68 return copy
68 } 69 }
69 70
Sources/OrgEditorAppKit/OrgEditor.swift +3 −1
@@ -260,6 +260,8 @@ public final class OrgEditor: NSObject {
260 public var onPrompt: ((Prompt, _ reply: @escaping (String?) -> Void) -> Void)? 260 public var onPrompt: ((Prompt, _ reply: @escaping (String?) -> Void) -> Void)?
261 /// Runs work outside the text, such as a table recalculation in Emacs. 261 /// Runs work outside the text, such as a table recalculation in Emacs.
262 public var onExternal: ((ExternalRequest) -> Void)? 262 public var onExternal: ((ExternalRequest) -> Void)?
263 /// Tags used across the workspace, for tag completion.
264 public var knownTags: @Sendable () -> [String] = { [] }
263 /// Effects other than messages, for the app to carry out. 265 /// Effects other than messages, for the app to carry out.
264 public var onEffect: ((Effect) -> Void)? 266 public var onEffect: ((Effect) -> Void)?
265 267
@@ -315,7 +317,7 @@ public final class OrgEditor: NSObject {
315 return EditContext( 317 return EditContext(
316 revision: document.revision, text: document.text, tree: document.tree, 318 revision: document.revision, text: document.text, tree: document.tree,
317 selection: [selected.location..<NSMaxRange(selected)], now: now, calendar: .current, answers: answers, 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 /// Headings with `:ID: id`. More than one means the ID is duplicated. 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 public func headings(withID id: String, overlay: [String: FileRecord] = [:]) throws -> [HeadingLocation] { 301 public func headings(withID id: String, overlay: [String: FileRecord] = [:]) throws -> [HeadingLocation] {
295 let indexed = try database.read { db in 302 let indexed = try database.read { db in
296 try Row.fetchAll( 303 try Row.fetchAll(
Sources/Orgstar/AgendaView.swift +13 −1
@@ -147,7 +147,19 @@ struct AgendaView: View {
147 ContentUnavailableView("No agenda files", systemImage: "calendar", description: Text("The agenda reads the org files at the top of each folder you add.")) 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 TextField("", text: $promptText) 163 TextField("", text: $promptText)
152 Button("OK") { answerPrompt() } 164 Button("OK") { answerPrompt() }
153 Button("Cancel", role: .cancel) { prompt = nil } 165 Button("Cancel", role: .cancel) { prompt = nil }
Sources/Orgstar/ContentView.swift +1 −1
@@ -94,7 +94,7 @@ struct ContentView: View {
94 } else if session.path != nil { 94 } else if session.path != nil {
95 VStack(spacing: 0) { 95 VStack(spacing: 0) {
96 if showTabBar { BufferTabs(session: session) } 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 .safeAreaInset(edge: .bottom, spacing: 0) { 99 .safeAreaInset(edge: .bottom, spacing: 0) {
100 VStack(spacing: 0) { 100 VStack(spacing: 0) {
Sources/Orgstar/EditorView.swift +2
@@ -24,6 +24,7 @@ struct EditorView: NSViewRepresentable {
24 let theme: ThemeSpec 24 let theme: ThemeSpec
25 let onAppCommand: (String) -> Void 25 let onAppCommand: (String) -> Void
26 let onExternal: (ExternalRequest) -> Void 26 let onExternal: (ExternalRequest) -> Void
27 let knownTags: @Sendable () -> [String]
27 28
28 final class Coordinator { 29 final class Coordinator {
29 var editor: OrgEditor? 30 var editor: OrgEditor?
@@ -94,6 +95,7 @@ struct EditorView: NSViewRepresentable {
94 editor.onSelectionChange = { [weak commands] in commands?.selection = $0 } 95 editor.onSelectionChange = { [weak commands] in commands?.selection = $0 }
95 editor.onAppCommand = onAppCommand 96 editor.onAppCommand = onAppCommand
96 editor.onExternal = onExternal 97 editor.onExternal = onExternal
98 editor.knownTags = knownTags
97 editor.onPrompt = { [weak commands] prompt, reply in commands?.ask(prompt, reply: reply) } 99 editor.onPrompt = { [weak commands] prompt, reply in commands?.ask(prompt, reply: reply) }
98 editor.onEffect = { [weak commands] effect in 100 editor.onEffect = { [weak commands] effect in
99 if case .storedLinkUsed(let link) = effect { commands?.storedLinks.removeAll { $0.link == link } } 101 if case .storedLinkUsed(let link) = effect { commands?.storedLinks.removeAll { $0.link == link } }
Sources/Orgstar/FastSelectionView.swift added +128
@@ -0,0 +1,128 @@
1import OrgCore
2import 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.
6struct 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 .padding(10) 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 if !request.prompt.choices.isEmpty { 39 if !request.prompt.choices.isEmpty {
36 Divider() 40 Divider()
37 let matches = filteredChoices(request.prompt.choices) 41 let matches = filteredChoices(request.prompt.choices)
38 ScrollView { 42 ScrollView {
39 VStack(alignment: .leading, spacing: 2) { 43 VStack(alignment: .leading, spacing: 2) {
40 ForEach(matches.prefix(12), id: \.self) { choice in 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 .buttonStyle(.plain) 48 .buttonStyle(.plain)
43 .font(.system(.callout, design: .monospaced)) 49 .font(.system(.callout, design: .monospaced))
44 .foregroundStyle(choice == matches.first ? .primary : .secondary) 50 .foregroundStyle(choice == matches.first ? .primary : .secondary)
@@ -68,7 +74,7 @@ struct EchoArea: View {
68 } 74 }
69 .onKeyPress(.tab) { 75 .onKeyPress(.tab) {
70 guard let first = filteredChoices(request.prompt.choices).first else { return .ignored } 76 guard let first = filteredChoices(request.prompt.choices).first else { return .ignored }
71 answer = first 77 complete(first, request.prompt)
72 return .handled 78 return .handled
73 } 79 }
74 .onExitCommand { commands.answer(nil) } 80 .onExitCommand { commands.answer(nil) }
@@ -132,10 +138,27 @@ struct StateTag: View {
132} 138}
133 139
134extension EchoArea { 140extension 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 /// Choices matching what's typed, best first. 157 /// Choices matching what's typed, best first.
136 func filteredChoices(_ choices: [String]) -> [String] { 158 func filteredChoices(_ choices: [String]) -> [String] {
137 guard !answer.isEmpty else { return choices } 159 let typed = String(completing(commands.prompt?.prompt.completionSeparator))
138 return choices.compactMap { choice in FuzzyMatch.score(answer, choice).map { (choice, $0) } } 160 guard !typed.isEmpty else { return choices }
161 return choices.compactMap { choice in FuzzyMatch.score(typed, choice).map { (choice, $0) } }
139 .sorted { $0.1 > $1.1 } 162 .sorted { $0.1 > $1.1 }
140 .map(\.0) 163 .map(\.0)
141 } 164 }
Sources/Orgstar/OrgstarApp.swift +2 −2
@@ -543,9 +543,9 @@ struct SettingsView: View {
543 .settingsFooter() 543 .settingsFooter()
544 } 544 }
545 Section { 545 Section {
546 TextField("Default TODO keywords", text: $todoKeywords) 546 TextField("Default TODO keywords", text: $todoKeywords, axis: .vertical)
547 } footer: { 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 .settingsFooter() 549 .settingsFooter()
550 } 550 }
551 } 551 }
Tests/OrgAppTests/EmacsImportTests.swift +3 −3
@@ -37,7 +37,7 @@ struct EmacsImportTests {
37 (setq org-tags-column 0 37 (setq org-tags-column 0
38 org-agenda-span 10 38 org-agenda-span 10
39 org-agenda-start-day "-3d" 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 (after! org (setq org-M-RET-may-split-line nil org-list-allow-alphabetical t)) 41 (after! org (setq org-M-RET-may-split-line nil org-list-allow-alphabetical t))
42 (map! :map org-mode-map "C-c q" #'org-set-tags-command) 42 (map! :map org-mode-map "C-c q" #'org-set-tags-command)
43 """# 43 """#
@@ -74,7 +74,7 @@ struct EmacsImportTests {
74 #expect(s["org-agenda-span"] == .integer(10)) 74 #expect(s["org-agenda-span"] == .integer(10))
75 #expect(s["org-agenda-start-day"] == .string("-3d")) 75 #expect(s["org-agenda-start-day"] == .string("-3d"))
76 #expect(s["org-M-RET-may-split-line"] == .bool(false)) 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 #expect(s["fill-column"] == nil) 78 #expect(s["fill-column"] == nil)
79 #expect(s["font"] == .string("JetBrains Mono") && s["font-size"] == .integer(14)) 79 #expect(s["font"] == .string("JetBrains Mono") && s["font-size"] == .integer(14))
80 let folders = result.items.compactMap { if case .folder(let f) = $0.change { f } else { nil } } 80 let folders = result.items.compactMap { if case .folder(let f) = $0.change { f } else { nil } }
@@ -129,7 +129,7 @@ struct EmacsImportTests {
129 #expect(defaults.string(forKey: "keymap") == "doom") 129 #expect(defaults.string(forKey: "keymap") == "doom")
130 #expect(defaults.integer(forKey: "tagsColumn") == -80) 130 #expect(defaults.integer(forKey: "tagsColumn") == -80)
131 #expect(defaults.integer(forKey: "agendaStartOffset") == -3) 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 let capture = try String(contentsOf: folder.appendingPathComponent("capture.toml"), encoding: .utf8) 133 let capture = try String(contentsOf: folder.appendingPathComponent("capture.toml"), encoding: .utf8)
134 #expect(capture.components(separatedBy: "[[template]]").count == 3) 134 #expect(capture.components(separatedBy: "[[template]]").count == 3)
135 #expect(CaptureTemplates.load(userFile: folder.appendingPathComponent("capture.toml")).problems.isEmpty) 135 #expect(CaptureTemplates.load(userFile: folder.appendingPathComponent("capture.toml")).problems.isEmpty)
Tests/OrgCoreTests/FastSelectionTests.swift added +109
@@ -0,0 +1,109 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct 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
103struct 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 let keymap = Keymap.layered([preset, user]) 84 let keymap = Keymap.layered([preset, user])
85 #expect(keymap.candidates(for: KeySequence.parse("C-c C-t")!).first?.command == "org.priority.up") 85 #expect(keymap.candidates(for: KeySequence.parse("C-c C-t")!).first?.command == "org.priority.up")
86 #expect(keymap.candidates(for: KeySequence.parse("C-k")!).isEmpty) 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 @Test func keyLabelsForMenus() { 90 @Test func keyLabelsForMenus() {
91 let emacs = KeymapPreset.emacs.keymap 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 #expect(emacs.keyLabels(for: "org.schedule") == ["C-c C-s"]) 94 #expect(emacs.keyLabels(for: "org.schedule") == ["C-c C-s"])
94 let doom = KeymapPreset.doom.keymap 95 let doom = KeymapPreset.doom.keymap
95 let labels = doom.keyLabels(for: "org.todo.cycle", modes: ["normal", "insert"]) 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 @Test func promptsWithCurrentTags() { 6 @Test func promptsWithCurrentTags() {
7 let text = "* a :x:y:\n" 7 let text = "* a :x:y:\n"
8 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [0..<0]) 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 @Test func setsAndClears() { 12 @Test func setsAndClears() {
Tests/OrgCoreTests/TodoLoggingTests.swift +1 −1
@@ -38,7 +38,7 @@ struct TodoLoggingTests {
38 for text in Self.texts { 38 for text in Self.texts {
39 let heading = (text as NSString).range(of: "Task").location 39 let heading = (text as NSString).range(of: "Task").location
40 for (done, drawer) in Self.options { 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 for (command, form) in changes { 42 for (command, form) in changes {
43 let lisp = """ 43 let lisp = """
44 (let ((b (current-buffer)) (org-use-fast-todo-selection nil)) 44 (let ((b (current-buffer)) (org-use-fast-todo-selection nil))