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 {
475475 case "org-agenda-start-day":
476476 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") }
477477 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")))
482486 case "org-directory":
483487 guard let path = value.string else { return note("not a folder") }
484488 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 {
7373 Setting(section: "", key: "org-pretty-entities", defaultsKey: "prettyEntities", kind: .bool, fallback: .bool(true),
7474 comment: "\\alpha shows as α and x_{1} is lowered when markup is hidden", legacy: "editor.pretty-entities"),
7575 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"),
7777 Setting(section: "", key: "org-log-done", defaultsKey: "logDone", kind: .choice([("nil", "nil"), ("time", "time"), ("note", "note")]),
7878 fallback: .string("nil"), comment: "nil, time (CLOSED when done) or note (CLOSED and a note)", legacy: ""),
7979 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 {
1717 public var logDone: LogHow?
1818 /// `org-log-into-drawer`: the drawer state notes go in, or nil for none.
1919 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
2022 /// `fill-column` for M-q.
2123 public var fillColumn: Int
2224 /// `org-hide-emphasis-markers`: table alignment measures emphasis without its markers.
@@ -30,8 +32,9 @@ public struct EditingOptions: Sendable, Equatable, Codable {
3032 tagsColumn: Int = -77, insertHeadingRespectContent: Bool = false, metaReturnMaySplitLine: Bool = true,
3133 listAllowAlphabetical: Bool = false, logRepeat: Bool = true, fillColumn: Int = 70,
3234 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
3436 ) {
37 self.fastTodoSelection = fastTodoSelection
3538 self.logDone = logDone
3639 self.logIntoDrawer = logIntoDrawer
3740 self.tagsColumn = tagsColumn
@@ -66,12 +69,15 @@ public struct EditContext: Sendable {
6669 /// Ranges the view hides (folded text); motion commands skip headings inside them.
6770 public let hidden: [Range<Int>]
6871 public let options: EditingOptions
72 /// Tags used across the workspace, for completion; read when a command needs them.
73 public let knownTags: @Sendable () -> [String]
6974
7075 public init(
7176 revision: Int, text: String, tree: OrgTree, selection: [Range<Int>],
7277 now: Date = Date(), calendar: Calendar = .current, answers: [String: String] = [:], hidden: [Range<Int>] = [],
73 options: EditingOptions = .org
78 options: EditingOptions = .org, knownTags: @escaping @Sendable () -> [String] = { [] }
7479 ) {
80 self.knownTags = knownTags
7581 self.revision = revision
7682 self.text = text
7783 self.tree = tree
@@ -95,13 +101,22 @@ public struct Prompt: Sendable, Equatable {
95101 /// When set, the answer is one of these, or with `requireMatch` false, these are offered.
96102 public let choices: [String]
97103 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?
98108
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 ) {
100113 self.key = key
101114 self.message = message
102115 self.initial = initial
103116 self.choices = choices
104117 self.requireMatch = requireMatch
118 self.completionSeparator = completionSeparator
119 self.fast = fast
105120 }
106121}
107122
@@ -177,7 +192,7 @@ public protocol OrgCommand: Sendable {
177192
178193public enum Commands {
179194 public static let all: [any OrgCommand] = [
180 TodoCycle(), PriorityUp(), PriorityDown(), PromoteHeading(), DemoteHeading(),
195 TodoCycle(), TodoShift(right: true), TodoShift(right: false), PriorityUp(), PriorityDown(), PromoteHeading(), DemoteHeading(),
181196 InsertHeading(), InsertHeadingAfterSubtree(), InsertTodoHeading(),
182197 MoveSubtreeUp(), MoveSubtreeDown(), PromoteSubtree(), DemoteSubtree(),
183198 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 {
208208 /// that became done.
209209 public func run(in context: EditContext) -> CommandStep {
210210 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 }
211219 return followUp(context, cycle(in: context)) { buffer in
212220 try buffer.saveExcursion {
213221 buffer.updateParentTodoStatistics()
@@ -243,6 +251,27 @@ public struct TodoCycle: OrgCommand {
243251 }
244252}
245253
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
246275// MARK: - Priority
247276
248277struct PriorityChange {
Sources/OrgCore/Commands/TagCommands.swift +36 −6
@@ -1,7 +1,8 @@
11import Foundation
22
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.
56public struct SetTags: OrgCommand {
67 public init() {}
78 public var id: String { "org.tags.set" }
@@ -13,17 +14,46 @@ public struct SetTags: OrgCommand {
1314 answer.split { !($0.isLetter || $0.isNumber || "_@#%".contains($0)) }.map(String.init)
1415 }
1516
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
1632 static let tagLine = "^\\*+ (?:.*[ \\t])?(:([[:alnum:]_@#%:]+):)[ \\t]*$"
1733
1834 public func run(in context: EditContext) -> CommandStep {
1935 guard let heading = entryHeading(at: context.caret, in: context.tree) else {
2036 return .failed("Setting file tags is not supported yet")
2137 }
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)
2556 }
26 let tags = Self.tags(from: answer)
2757 return commitBuffer(context) { buffer in
2858 try buffer.saveExcursion {
2959 try buffer.backToHeading()
Sources/OrgCore/Keymap/Presets.swift +5 −1
@@ -645,7 +645,11 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
645645 command = "org.todo.cycle"
646646 [[bind]]
647647 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"
649653 when = "heading"
650654 [[bind]]
651655 keys = "S-<up>"
Sources/OrgCore/Parser/Settings.swift +6 −5
@@ -58,12 +58,13 @@ public struct OrgSettings: Sendable, Equatable {
5858 Set(todoSequences.flatMap { ($0.active + $0.done).map(\.name) })
5959 }
6060
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 }
6566 var copy = self
66 copy.todoSequences = [sequence]
67 copy.todoSequences = sequences
6768 return copy
6869 }
6970
Sources/OrgEditorAppKit/OrgEditor.swift +3 −1
@@ -260,6 +260,8 @@ public final class OrgEditor: NSObject {
260260 public var onPrompt: ((Prompt, _ reply: @escaping (String?) -> Void) -> Void)?
261261 /// Runs work outside the text, such as a table recalculation in Emacs.
262262 public var onExternal: ((ExternalRequest) -> Void)?
263 /// Tags used across the workspace, for tag completion.
264 public var knownTags: @Sendable () -> [String] = { [] }
263265 /// Effects other than messages, for the app to carry out.
264266 public var onEffect: ((Effect) -> Void)?
265267
@@ -315,7 +317,7 @@ public final class OrgEditor: NSObject {
315317 return EditContext(
316318 revision: document.revision, text: document.text, tree: document.tree,
317319 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
319321 )
320322 }
321323
Sources/OrgIndex/IndexStore.swift +7
@@ -291,6 +291,13 @@ public final class IndexStore: Sendable {
291291 }
292292
293293 /// 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
294301 public func headings(withID id: String, overlay: [String: FileRecord] = [:]) throws -> [HeadingLocation] {
295302 let indexed = try database.read { db in
296303 try Row.fetchAll(
Sources/Orgstar/AgendaView.swift +13 −1
@@ -147,7 +147,19 @@ struct AgendaView: View {
147147 ContentUnavailableView("No agenda files", systemImage: "calendar", description: Text("The agenda reads the org files at the top of each folder you add."))
148148 }
149149 }
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 } })) {
151163 TextField("", text: $promptText)
152164 Button("OK") { answerPrompt() }
153165 Button("Cancel", role: .cancel) { prompt = nil }
Sources/Orgstar/ContentView.swift +1 −1
@@ -94,7 +94,7 @@ struct ContentView: View {
9494 } else if session.path != nil {
9595 VStack(spacing: 0) {
9696 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()) ?? [] })
9898 }
9999 .safeAreaInset(edge: .bottom, spacing: 0) {
100100 VStack(spacing: 0) {
Sources/Orgstar/EditorView.swift +2
@@ -24,6 +24,7 @@ struct EditorView: NSViewRepresentable {
2424 let theme: ThemeSpec
2525 let onAppCommand: (String) -> Void
2626 let onExternal: (ExternalRequest) -> Void
27 let knownTags: @Sendable () -> [String]
2728
2829 final class Coordinator {
2930 var editor: OrgEditor?
@@ -94,6 +95,7 @@ struct EditorView: NSViewRepresentable {
9495 editor.onSelectionChange = { [weak commands] in commands?.selection = $0 }
9596 editor.onAppCommand = onAppCommand
9697 editor.onExternal = onExternal
98 editor.knownTags = knownTags
9799 editor.onPrompt = { [weak commands] prompt, reply in commands?.ask(prompt, reply: reply) }
98100 editor.onEffect = { [weak commands] effect in
99101 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 {
3131 }
3232 .padding(10)
3333 }
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 {
3539 if !request.prompt.choices.isEmpty {
3640 Divider()
3741 let matches = filteredChoices(request.prompt.choices)
3842 ScrollView {
3943 VStack(alignment: .leading, spacing: 2) {
4044 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 }
4248 .buttonStyle(.plain)
4349 .font(.system(.callout, design: .monospaced))
4450 .foregroundStyle(choice == matches.first ? .primary : .secondary)
@@ -68,7 +74,7 @@ struct EchoArea: View {
6874 }
6975 .onKeyPress(.tab) {
7076 guard let first = filteredChoices(request.prompt.choices).first else { return .ignored }
71 answer = first
77 complete(first, request.prompt)
7278 return .handled
7379 }
7480 .onExitCommand { commands.answer(nil) }
@@ -132,10 +138,27 @@ struct StateTag: View {
132138}
133139
134140extension 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
135157 /// Choices matching what's typed, best first.
136158 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) } }
139162 .sorted { $0.1 > $1.1 }
140163 .map(\.0)
141164 }
Sources/Orgstar/OrgstarApp.swift +2 −2
@@ -543,9 +543,9 @@ struct SettingsView: View {
543543 .settingsFooter()
544544 }
545545 Section {
546 TextField("Default TODO keywords", text: $todoKeywords)
546 TextField("Default TODO keywords", text: $todoKeywords, axis: .vertical)
547547 } 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.")
549549 .settingsFooter()
550550 }
551551 }
Tests/OrgAppTests/EmacsImportTests.swift +3 −3
@@ -37,7 +37,7 @@ struct EmacsImportTests {
3737 (setq org-tags-column 0
3838 org-agenda-span 10
3939 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"))))
4141 (after! org (setq org-M-RET-may-split-line nil org-list-allow-alphabetical t))
4242 (map! :map org-mode-map "C-c q" #'org-set-tags-command)
4343 """#
@@ -74,7 +74,7 @@ struct EmacsImportTests {
7474 #expect(s["org-agenda-span"] == .integer(10))
7575 #expect(s["org-agenda-start-day"] == .string("-3d"))
7676 #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"))
7878 #expect(s["fill-column"] == nil)
7979 #expect(s["font"] == .string("JetBrains Mono") && s["font-size"] == .integer(14))
8080 let folders = result.items.compactMap { if case .folder(let f) = $0.change { f } else { nil } }
@@ -129,7 +129,7 @@ struct EmacsImportTests {
129129 #expect(defaults.string(forKey: "keymap") == "doom")
130130 #expect(defaults.integer(forKey: "tagsColumn") == -80)
131131 #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")
133133 let capture = try String(contentsOf: folder.appendingPathComponent("capture.toml"), encoding: .utf8)
134134 #expect(capture.components(separatedBy: "[[template]]").count == 3)
135135 #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 {
8484 let keymap = Keymap.layered([preset, user])
8585 #expect(keymap.candidates(for: KeySequence.parse("C-c C-t")!).first?.command == "org.priority.up")
8686 #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>")
8888 }
8989
9090 @Test func keyLabelsForMenus() {
9191 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>"])
9394 #expect(emacs.keyLabels(for: "org.schedule") == ["C-c C-s"])
9495 let doom = KeymapPreset.doom.keymap
9596 let labels = doom.keyLabels(for: "org.todo.cycle", modes: ["normal", "insert"])
Tests/OrgCoreTests/TagCommandTests.swift +1 −1
@@ -6,7 +6,7 @@ struct TagCommandTests {
66 @Test func promptsWithCurrentTags() {
77 let text = "* a :x:y:\n"
88 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: ":")))
1010 }
1111
1212 @Test func setsAndClears() {
Tests/OrgCoreTests/TodoLoggingTests.swift +1 −1
@@ -38,7 +38,7 @@ struct TodoLoggingTests {
3838 for text in Self.texts {
3939 let heading = (text as NSString).range(of: "Task").location
4040 for (done, drawer) in Self.options {
41 let options = EditingOptions(logDone: done, logIntoDrawer: drawer)
41 let options = EditingOptions(logDone: done, logIntoDrawer: drawer, fastTodoSelection: false)
4242 for (command, form) in changes {
4343 let lisp = """
4444 (let ((b (current-buffer)) (org-use-fast-todo-selection nil))