Commit f663dac299
Verified · cmc
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 | ||
| 178 | public enum Commands { | 193 | public 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 @@ | |||
| 1 | import Foundation | ||
| 2 | |||
| 3 | // Fast TODO and tag selection (`org-fast-todo-selection`, `org-fast-tag-selection`, Org 9.8.7) | ||
| 4 | // with `org-use-fast-*-selection` `auto`, `org-fast-tag-selection-single-key` nil and no TODO | ||
| 5 | // keywords among the tags. The editor shows the keys; the answer is the keys pressed, which | ||
| 6 | // `FastSelection` replays as org does. | ||
| 7 | |||
| 8 | /// What a fast selection prompt shows and how its keys act. | ||
| 9 | public struct FastSelection: Sendable, Equatable { | ||
| 10 | public enum Item: Sendable, Equatable { | ||
| 11 | case option(key: Character, name: String) | ||
| 12 | /// `{`: the options up to `endGroup` exclude each other (tags). | ||
| 13 | case startGroup | ||
| 14 | case endGroup | ||
| 15 | /// `[`, `]` and `:` of a group tag; shown, without effect on selection. | ||
| 16 | case startTagGroup, endTagGroup, groupTags | ||
| 17 | case newline | ||
| 18 | } | ||
| 19 | |||
| 20 | public let items: [Item] | ||
| 21 | /// Tags: keys toggle until Return. TODO keywords: one key answers. | ||
| 22 | public let multiple: Bool | ||
| 23 | public let current: [String] | ||
| 24 | public let inherited: [String] | ||
| 25 | |||
| 26 | public init(items: [Item], multiple: Bool, current: [String] = [], inherited: [String] = []) { | ||
| 27 | self.items = items | ||
| 28 | self.multiple = multiple | ||
| 29 | self.current = current | ||
| 30 | self.inherited = inherited | ||
| 31 | } | ||
| 32 | |||
| 33 | public var options: [(key: Character, name: String)] { | ||
| 34 | items.compactMap { if case .option(let key, let name) = $0 { (key, name) } else { nil } } | ||
| 35 | } | ||
| 36 | |||
| 37 | /// The `{ }` groups' members. | ||
| 38 | var groups: [[String]] { | ||
| 39 | var groups: [[String]] = [] | ||
| 40 | var inGroup = false | ||
| 41 | for item in items { | ||
| 42 | switch item { | ||
| 43 | case .startGroup: groups.append([]); inGroup = true | ||
| 44 | case .endGroup: inGroup = false | ||
| 45 | case .option(_, let name) where inGroup: groups[groups.count - 1].append(name) | ||
| 46 | default: break | ||
| 47 | } | ||
| 48 | } | ||
| 49 | return groups | ||
| 50 | } | ||
| 51 | |||
| 52 | /// A key pressed in a tag selection, or a tag typed after Tab. | ||
| 53 | public enum Input: Sendable, Equatable { | ||
| 54 | case key(Character) | ||
| 55 | case typed(String) | ||
| 56 | } | ||
| 57 | |||
| 58 | /// The answer a prompt sends: keys as themselves, a typed tag between Tab and newline. | ||
| 59 | public static func encode(_ inputs: [Input]) -> String { | ||
| 60 | inputs.map { input in | ||
| 61 | switch input { | ||
| 62 | case .key(let c): String(c) | ||
| 63 | case .typed(let tag): "\t" + tag + "\n" | ||
| 64 | } | ||
| 65 | }.joined() | ||
| 66 | } | ||
| 67 | |||
| 68 | public static func decode(_ answer: String) -> [Input] { | ||
| 69 | var inputs: [Input] = [] | ||
| 70 | var rest = Substring(answer) | ||
| 71 | while let c = rest.first { | ||
| 72 | rest = rest.dropFirst() | ||
| 73 | if c == "\t" { | ||
| 74 | let tag = rest.prefix { $0 != "\n" } | ||
| 75 | inputs.append(.typed(String(tag))) | ||
| 76 | rest = rest.dropFirst(tag.count + 1) | ||
| 77 | } else { | ||
| 78 | inputs.append(.key(c)) | ||
| 79 | } | ||
| 80 | } | ||
| 81 | return inputs | ||
| 82 | } | ||
| 83 | |||
| 84 | /// The tags after `inputs`, or nil when they quit (`q` with no binding, or C-g). | ||
| 85 | public func tags(after inputs: [Input]) -> [String]? { | ||
| 86 | var current = self.current | ||
| 87 | var groupsOn = true | ||
| 88 | let order = options.map(\.name) | ||
| 89 | for input in inputs { | ||
| 90 | switch input { | ||
| 91 | case .key("!"): groupsOn.toggle() | ||
| 92 | case .key(" "): current = [] | ||
| 93 | case .key("\u{7}"): return nil | ||
| 94 | case .key(let c): | ||
| 95 | if let tag = options.first(where: { $0.key == c })?.name { | ||
| 96 | current = Self.toggle(tag, current, groups: groupsOn ? groups : []) | ||
| 97 | } else if c == "q" { | ||
| 98 | return nil | ||
| 99 | } | ||
| 100 | case .typed(let tag): | ||
| 101 | guard tag.contains(where: { !$0.isWhitespace }) else { continue } | ||
| 102 | current = Self.toggle(tag, current, groups: groupsOn ? groups : []) | ||
| 103 | } | ||
| 104 | // Sorted by the table's order; tags not in it keep theirs, after. | ||
| 105 | current = current.enumerated().sorted { a, b in | ||
| 106 | let ia = order.firstIndex(of: a.element) ?? Int.max | ||
| 107 | let ib = order.firstIndex(of: b.element) ?? Int.max | ||
| 108 | return ia != ib ? ia < ib : a.offset < b.offset | ||
| 109 | }.map(\.element) | ||
| 110 | } | ||
| 111 | return current | ||
| 112 | } | ||
| 113 | |||
| 114 | /// `org--add-or-remove-tag`. | ||
| 115 | static func toggle(_ tag: String, _ current: [String], groups: [[String]]) -> [String] { | ||
| 116 | if current.contains(tag) { return current.filter { $0 != tag } } | ||
| 117 | var current = current | ||
| 118 | for group in groups where group.contains(tag) { current.removeAll { group.contains($0) } } | ||
| 119 | return [tag] + current | ||
| 120 | } | ||
| 121 | |||
| 122 | // MARK: - TODO keywords | ||
| 123 | |||
| 124 | /// `org-todo-key-alist` after `org-assign-fast-keys`, when any keyword has a key. | ||
| 125 | static func todo(_ settings: OrgSettings) -> FastSelection? { | ||
| 126 | guard settings.todoSequences.contains(where: { ($0.active + $0.done).contains { $0.fastKey != nil } }) else { return nil } | ||
| 127 | var items: [Item] = [] | ||
| 128 | let all = settings.todoSequences.flatMap { $0.active + $0.done } | ||
| 129 | var used = Set(all.compactMap(\.fastKey)) | ||
| 130 | var assigned: [Character] = [] | ||
| 131 | var alternative = Character("0") | ||
| 132 | for keyword in all { | ||
| 133 | if let key = keyword.fastKey { assigned.append(key); continue } | ||
| 134 | var letters = Array(keyword.name.lowercased()) | ||
| 135 | if letters.first == "@" { letters.removeFirst() } | ||
| 136 | let key = letters.first { !used.contains($0) } ?? { | ||
| 137 | while used.contains(alternative) { alternative = Character(UnicodeScalar(alternative.unicodeScalars.first!.value + 1)!) } | ||
| 138 | return alternative | ||
| 139 | }() | ||
| 140 | used.insert(key) | ||
| 141 | assigned.append(key) | ||
| 142 | } | ||
| 143 | var index = 0 | ||
| 144 | for sequence in settings.todoSequences { | ||
| 145 | items.append(.startGroup) | ||
| 146 | for keyword in sequence.active + sequence.done { | ||
| 147 | items.append(.option(key: assigned[index], name: keyword.name)) | ||
| 148 | index += 1 | ||
| 149 | } | ||
| 150 | items.append(.endGroup) | ||
| 151 | } | ||
| 152 | return FastSelection(items: items, multiple: false) | ||
| 153 | } | ||
| 154 | |||
| 155 | /// The keyword for `key`, preferring the sequence of `current`; `.some(nil)` clears it, | ||
| 156 | /// nil quits. | ||
| 157 | func keyword(for key: Character, current: String?) -> String?? { | ||
| 158 | if key == " " { return .some(nil) } | ||
| 159 | var sequences: [[(key: Character, name: String)]] = [] | ||
| 160 | for item in items { | ||
| 161 | switch item { | ||
| 162 | case .startGroup: sequences.append([]) | ||
| 163 | case .option(let k, let name): sequences[sequences.count - 1].append((k, name)) | ||
| 164 | default: break | ||
| 165 | } | ||
| 166 | } | ||
| 167 | if let current, let preferred = sequences.first(where: { $0.contains { $0.name == current } }), | ||
| 168 | let match = preferred.first(where: { $0.key == key }) { | ||
| 169 | return .some(match.name) | ||
| 170 | } | ||
| 171 | return options.first { $0.key == key }.map { .some($0.name) } ?? nil | ||
| 172 | } | ||
| 173 | |||
| 174 | // MARK: - Tags | ||
| 175 | |||
| 176 | /// `org-tag-string-to-alist` of the file's `#+TAGS` lines, or nil without any. | ||
| 177 | static func tagTable(_ text: String) -> [Item]? { | ||
| 178 | let regex = try! NSRegularExpression(pattern: "^[ \\t]*#\\+TAGS:[ \\t]*(.*?)[ \\t]*$", options: [.anchorsMatchLines, .caseInsensitive]) | ||
| 179 | let ns = text as NSString | ||
| 180 | let lines = regex.matches(in: text, range: NSRange(location: 0, length: ns.length)).map { ns.substring(with: $0.range(at: 1)) } | ||
| 181 | guard !lines.isEmpty else { return nil } | ||
| 182 | var items: [Item] = [] | ||
| 183 | var names: Set<String> = [] | ||
| 184 | var inGroup = false | ||
| 185 | for line in lines.joined(separator: "\n").split(separator: "\n", omittingEmptySubsequences: true) { | ||
| 186 | items.append(.newline) | ||
| 187 | let tokens = line.split(whereSeparator: \.isWhitespace).map(String.init) | ||
| 188 | for (i, token) in tokens.enumerated() { | ||
| 189 | switch token { | ||
| 190 | case "{": | ||
| 191 | items.append(.startGroup) | ||
| 192 | if i + 2 < tokens.count, tokens[i + 2] == ":" { inGroup = true } | ||
| 193 | case "}": items.append(.endGroup); inGroup = false | ||
| 194 | case "[": | ||
| 195 | items.append(.startTagGroup) | ||
| 196 | if i + 2 < tokens.count, tokens[i + 2] == ":" { inGroup = true } | ||
| 197 | case "]": items.append(.endTagGroup); inGroup = false | ||
| 198 | case ":": items.append(.groupTags) | ||
| 199 | default: | ||
| 200 | guard let match = token.firstMatch(of: /^([\p{L}\p{N}_@#%]+|\{.+?\})(?:\((.)\))?$/) else { continue } | ||
| 201 | let name = String(match.1) | ||
| 202 | if inGroup || !names.contains(name) { | ||
| 203 | names.insert(name) | ||
| 204 | items.append(.option(key: match.2.flatMap(\.first) ?? "\0", name: name)) | ||
| 205 | } | ||
| 206 | } | ||
| 207 | } | ||
| 208 | } | ||
| 209 | items.removeFirst() | ||
| 210 | return items | ||
| 211 | } | ||
| 212 | |||
| 213 | /// The selection `org-fast-tag-selection` shows for `table` (keys given as `\0` are | ||
| 214 | /// assigned), or nil when no tag in the table has a key. | ||
| 215 | static func tags(table: [Item], current: [String], inherited: [String]) -> FastSelection? { | ||
| 216 | let given = table.compactMap { if case .option(let key, _) = $0, key != "\0" { key } else { nil } } | ||
| 217 | guard !given.isEmpty else { return nil } | ||
| 218 | let pool = Array("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ{|}~") | ||
| 219 | var spare = pool[...] | ||
| 220 | var assigned: [Character] = [] | ||
| 221 | var items: [Item] = [] | ||
| 222 | for item in table { | ||
| 223 | guard case .option(let key, let name) = item else { items.append(item); continue } | ||
| 224 | if key != "\0" { | ||
| 225 | assigned.append(key) | ||
| 226 | items.append(item) | ||
| 227 | continue | ||
| 228 | } | ||
| 229 | var letters = Array(name.lowercased()) | ||
| 230 | if letters.first == "@" { letters.removeFirst() } | ||
| 231 | let taken = { (c: Character) in given.contains(c) || assigned.contains(c) } | ||
| 232 | var chosen: Character = " " | ||
| 233 | if let first = letters.first, !taken(first) { | ||
| 234 | chosen = first | ||
| 235 | } else { | ||
| 236 | while let c = spare.first, taken(c) { spare = spare.dropFirst() } | ||
| 237 | chosen = spare.first ?? " " | ||
| 238 | } | ||
| 239 | assigned.append(chosen) | ||
| 240 | items.append(.option(key: chosen, name: name)) | ||
| 241 | } | ||
| 242 | return FastSelection(items: items, multiple: true, current: current, inherited: inherited) | ||
| 243 | } | ||
| 244 | } | ||
Sources/OrgCore/Commands/HeadingCommands.swift +29
| @@ -208,6 +208,14 @@ public struct TodoCycle: OrgCommand { | |||
| 208 | /// 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. | ||
| 256 | public struct TodoShift: OrgCommand { | ||
| 257 | public let right: Bool | ||
| 258 | public init(right: Bool) { self.right = right } | ||
| 259 | public var id: String { right ? "org.todo.next" : "org.todo.previous" } | ||
| 260 | public var title: String { right ? "Next TODO Keyword" : "Previous TODO Keyword" } | ||
| 261 | public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil } | ||
| 262 | public func run(in context: EditContext) -> CommandStep { | ||
| 263 | let keywords = context.tree.settings.todoSequences.flatMap { ($0.active + $0.done).map(\.name) } | ||
| 264 | let current = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings).todoKeyword | ||
| 265 | let index = current.flatMap(keywords.firstIndex(of:)) | ||
| 266 | let next: String? = if right { | ||
| 267 | index.map { $0 + 1 < keywords.count ? keywords[$0 + 1] : nil } ?? keywords.first | ||
| 268 | } else { | ||
| 269 | index.map { $0 > 0 ? keywords[$0 - 1] : nil } ?? keywords.last | ||
| 270 | } | ||
| 271 | return SetTodo(keyword: next).run(in: context) | ||
| 272 | } | ||
| 273 | } | ||
| 274 | |||
| 246 | // MARK: - Priority | 275 | // MARK: - Priority |
| 247 | 276 | ||
| 248 | struct PriorityChange { | 277 | struct PriorityChange { |
Sources/OrgCore/Commands/TagCommands.swift +36 −6
| @@ -1,7 +1,8 @@ | |||
| 1 | import Foundation | 1 | import 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. | ||
| 5 | public struct SetTags: OrgCommand { | 6 | public 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 @@ | |||
| 1 | import OrgCore | ||
| 2 | import SwiftUI | ||
| 3 | |||
| 4 | /// Fast TODO and tag selection in the echo area: each option with its key. TODO keywords take | ||
| 5 | /// one key; tags toggle until Return, Tab types a tag, Escape or C-g quits. | ||
| 6 | struct FastSelectionView: View { | ||
| 7 | let selection: FastSelection | ||
| 8 | let message: String | ||
| 9 | let answer: (String?) -> Void | ||
| 10 | @Environment(\.orgTheme) private var theme | ||
| 11 | @State private var inputs: [FastSelection.Input] = [] | ||
| 12 | @State private var typing = false | ||
| 13 | @State private var typed = "" | ||
| 14 | @FocusState private var focused: Bool | ||
| 15 | @FocusState private var typingFocused: Bool | ||
| 16 | |||
| 17 | var body: some View { | ||
| 18 | let current = selection.multiple ? selection.tags(after: inputs) ?? [] : [] | ||
| 19 | VStack(alignment: .leading, spacing: 6) { | ||
| 20 | if selection.multiple { | ||
| 21 | if !selection.inherited.isEmpty { line("Inherited:", selection.inherited) } | ||
| 22 | line("Current:", current) | ||
| 23 | } | ||
| 24 | ForEach(Array(rows.enumerated()), id: \.offset) { _, row in | ||
| 25 | HStack(spacing: 14) { | ||
| 26 | ForEach(Array(row.enumerated()), id: \.offset) { _, cell in | ||
| 27 | switch cell { | ||
| 28 | case .text(let text): | ||
| 29 | Text(text).foregroundStyle(.secondary) | ||
| 30 | case .option(let key, let name): | ||
| 31 | HStack(spacing: 4) { | ||
| 32 | Text("[\(String(key))]").foregroundStyle(.tint) | ||
| 33 | Text(name) | ||
| 34 | .fontWeight(current.contains(name) ? .bold : .regular) | ||
| 35 | .foregroundStyle(current.contains(name) ? theme.color("todo") ?? .primary : .primary) | ||
| 36 | } | ||
| 37 | } | ||
| 38 | } | ||
| 39 | } | ||
| 40 | } | ||
| 41 | if typing { | ||
| 42 | HStack(spacing: 6) { | ||
| 43 | Text("Tag:").foregroundStyle(.secondary) | ||
| 44 | TextField("", text: $typed) | ||
| 45 | .textFieldStyle(.plain) | ||
| 46 | .focused($typingFocused) | ||
| 47 | .onSubmit { | ||
| 48 | inputs.append(.typed(typed)) | ||
| 49 | typed = "" | ||
| 50 | typing = false | ||
| 51 | focused = true | ||
| 52 | } | ||
| 53 | .onExitCommand { | ||
| 54 | typing = false | ||
| 55 | focused = true | ||
| 56 | } | ||
| 57 | } | ||
| 58 | } | ||
| 59 | Text(message).foregroundStyle(.secondary) | ||
| 60 | } | ||
| 61 | .font(.system(.callout, design: .monospaced)) | ||
| 62 | .frame(maxWidth: .infinity, alignment: .leading) | ||
| 63 | .padding(10) | ||
| 64 | .focusable() | ||
| 65 | .focusEffectDisabled() | ||
| 66 | .focused($focused) | ||
| 67 | .onKeyPress(phases: .down, action: press) | ||
| 68 | .onAppear { focused = true } | ||
| 69 | .onChange(of: typing) { _, now in if now { typingFocused = true } } | ||
| 70 | } | ||
| 71 | |||
| 72 | private func line(_ label: String, _ tags: [String]) -> some View { | ||
| 73 | HStack(spacing: 6) { | ||
| 74 | Text(label).foregroundStyle(.secondary) | ||
| 75 | Text(tags.isEmpty ? "" : ":" + tags.joined(separator: ":") + ":") | ||
| 76 | } | ||
| 77 | } | ||
| 78 | |||
| 79 | private func press(_ key: KeyPress) -> KeyPress.Result { | ||
| 80 | guard !typing else { return .ignored } | ||
| 81 | if key.key == .escape || (key.modifiers.contains(.control) && key.characters == "g") { | ||
| 82 | answer(nil) | ||
| 83 | return .handled | ||
| 84 | } | ||
| 85 | if selection.multiple { | ||
| 86 | if key.key == .return { | ||
| 87 | answer(FastSelection.encode(inputs)) | ||
| 88 | } else if key.key == .tab { | ||
| 89 | typing = true | ||
| 90 | } else if let c = key.characters.first, key.characters.count == 1 { | ||
| 91 | if c == "q", !selection.options.contains(where: { $0.key == "q" }) { | ||
| 92 | answer(nil) | ||
| 93 | } else { | ||
| 94 | inputs.append(.key(c)) | ||
| 95 | } | ||
| 96 | } else { | ||
| 97 | return .ignored | ||
| 98 | } | ||
| 99 | return .handled | ||
| 100 | } | ||
| 101 | guard let c = key.characters.first, key.characters.count == 1 else { return .ignored } | ||
| 102 | answer(String(c)) | ||
| 103 | return .handled | ||
| 104 | } | ||
| 105 | |||
| 106 | private enum Cell { | ||
| 107 | case text(String) | ||
| 108 | case option(Character, String) | ||
| 109 | } | ||
| 110 | |||
| 111 | /// Options in rows, braces and brackets around groups, as org lays them out. | ||
| 112 | private var rows: [[Cell]] { | ||
| 113 | var rows: [[Cell]] = [[]] | ||
| 114 | func newRow() { if !(rows.last?.isEmpty ?? true) { rows.append([]) } } | ||
| 115 | for item in selection.items { | ||
| 116 | switch item { | ||
| 117 | case .startGroup: newRow(); rows[rows.count - 1].append(.text("{")) | ||
| 118 | case .endGroup: rows[rows.count - 1].append(.text("}")); newRow() | ||
| 119 | case .startTagGroup: newRow(); rows[rows.count - 1].append(.text("[")) | ||
| 120 | case .endTagGroup: rows[rows.count - 1].append(.text("]")); newRow() | ||
| 121 | case .groupTags: rows[rows.count - 1].append(.text(":")) | ||
| 122 | case .newline: newRow() | ||
| 123 | case .option(let key, let name): rows[rows.count - 1].append(.option(key, name)) | ||
| 124 | } | ||
| 125 | } | ||
| 126 | return rows.filter { !$0.isEmpty } | ||
| 127 | } | ||
| 128 | } | ||
Sources/Orgstar/Keys.swift +28 −5
| @@ -31,14 +31,20 @@ struct EchoArea: View { | |||
| 31 | } | 31 | } |
| 32 | .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 | ||
| 134 | extension EchoArea { | 140 | extension EchoArea { |
| 141 | /// What completion matches: the text after the last separator, or all of it. | ||
| 142 | private func completing(_ separator: Character?) -> Substring { | ||
| 143 | guard let separator, let last = answer.lastIndex(of: separator) else { return answer[...] } | ||
| 144 | return answer[answer.index(after: last)...] | ||
| 145 | } | ||
| 146 | |||
| 147 | /// Puts `choice` in place of what's being completed; with a separator, adds one after it. | ||
| 148 | func complete(_ choice: String, _ prompt: Prompt) { | ||
| 149 | guard let separator = prompt.completionSeparator else { | ||
| 150 | answer = choice | ||
| 151 | return | ||
| 152 | } | ||
| 153 | let start = answer.lastIndex(of: separator).map { answer.index(after: $0) } ?? answer.startIndex | ||
| 154 | answer = String(answer[..<start]) + (start == answer.startIndex ? String(separator) : "") + choice + String(separator) | ||
| 155 | } | ||
| 156 | |||
| 135 | /// 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 @@ | |||
| 1 | import Foundation | ||
| 2 | import Testing | ||
| 3 | @testable import OrgCore | ||
| 4 | |||
| 5 | struct FastSelectionTests { | ||
| 6 | static let oracle = ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil | ||
| 7 | |||
| 8 | static let todoTexts = [ | ||
| 9 | // Without log flags: notes are stored after the command, which these cases don't reach. | ||
| 10 | "#+TODO: TODO(t) WAIT(w) | DONE(d) CANCELED(c)\n* TODO Task\n", | ||
| 11 | "#+TODO: TODO(t) NEXT | DONE(d)\n#+TODO: BUG(b) TRIAGE(t) | FIXED(f)\n* BUG Task\n", | ||
| 12 | "#+TODO: TODO(t) NEXT | DONE(d)\n#+TODO: BUG(b) TRIAGE(t) | FIXED(f)\n* Task\n", | ||
| 13 | "#+TODO: TODO(t) DOING DONE\n* DOING Task\n", | ||
| 14 | ] | ||
| 15 | |||
| 16 | @Test(.enabled(if: oracle)) | ||
| 17 | func selectsTodoLikeOrgTodo() throws { | ||
| 18 | var cases: [EmacsOracle.Case] = [] | ||
| 19 | var ours: [String] = [] | ||
| 20 | for text in Self.todoTexts { | ||
| 21 | let heading = (text as NSString).range(of: "Task").location | ||
| 22 | for key in ["t", "w", "d", " ", "x", "q", "n", "o", "0", "f"] { | ||
| 23 | let char = key == " " ? "?\\s" : "?" + key | ||
| 24 | cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(heading, in: text), form: """ | ||
| 25 | (condition-case nil | ||
| 26 | (cl-letf (((symbol-function 'read-char-exclusive) (lambda (&rest _) \(char)))) (org-todo)) | ||
| 27 | (quit nil)) | ||
| 28 | """)) | ||
| 29 | ours.append(runCommand(TodoCycle(), text, caret: heading, answers: ["todo": key]).text) | ||
| 30 | } | ||
| 31 | } | ||
| 32 | let emacs = try EmacsOracle.run(cases) | ||
| 33 | for (i, (mine, theirs)) in zip(ours, emacs).enumerated() { | ||
| 34 | #expect(mine == theirs.text, "\(mine.debugDescription) vs \(theirs.text.debugDescription) \(cases[i].form) on \(cases[i].text.debugDescription)") | ||
| 35 | } | ||
| 36 | } | ||
| 37 | |||
| 38 | @Test(.enabled(if: oracle)) | ||
| 39 | func shiftsLikeOrgShiftRightAndLeft() throws { | ||
| 40 | let texts = Self.todoTexts + ["* Task\n", "* TODO Task\n", "* DONE Task\n"] | ||
| 41 | var cases: [EmacsOracle.Case] = [] | ||
| 42 | var ours: [String] = [] | ||
| 43 | for text in texts { | ||
| 44 | let heading = (text as NSString).range(of: "Task").location | ||
| 45 | for (right, form) in [(true, "(org-shiftright)"), (false, "(org-shiftleft)"), (true, "(progn (org-shiftright) (org-shiftright))")] { | ||
| 46 | cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(heading, in: text), form: form)) | ||
| 47 | var result = runCommand(TodoShift(right: right), text, caret: heading).text | ||
| 48 | if form.contains("progn") { result = runCommand(TodoShift(right: right), result, caret: heading).text } | ||
| 49 | ours.append(result) | ||
| 50 | } | ||
| 51 | } | ||
| 52 | let emacs = try EmacsOracle.run(cases) | ||
| 53 | for (i, (mine, theirs)) in zip(ours, emacs).enumerated() { | ||
| 54 | #expect(mine == theirs.text, "\(mine.debugDescription) vs \(theirs.text.debugDescription) \(cases[i].form) on \(cases[i].text.debugDescription) \(theirs.error)") | ||
| 55 | } | ||
| 56 | } | ||
| 57 | |||
| 58 | static let tagTexts = [ | ||
| 59 | "#+TAGS: work(w) home(h) errand\n* Task :home:\n", | ||
| 60 | "#+TAGS: { office(o) home(h) } computer(c) phone\n#+TAGS: @read reading\n* Task :phone:\n", | ||
| 61 | "#+TAGS: [ Project : alpha(a) beta(b) ] solo(s)\n#+FILETAGS: :top:\n* Parent :solo:\n** Task\n", | ||
| 62 | "#+TAGS: alpha(a) apple avocado\n* Task :avocado:alpha:\n", | ||
| 63 | ] | ||
| 64 | |||
| 65 | @Test(.enabled(if: oracle)) | ||
| 66 | func selectsTagsLikeOrgFastTagSelection() throws { | ||
| 67 | let sequences = ["\r", "w\r", "wh\r", "o\r", "oh\r", "c o h\r", "!oh\r", " \r", "a\r", "e\r", "r\r", "q", "ap\r", "s\r", "b\r", "AB\r"] | ||
| 68 | var cases: [EmacsOracle.Case] = [] | ||
| 69 | var ours: [String] = [] | ||
| 70 | for text in Self.tagTexts { | ||
| 71 | let heading = (text as NSString).range(of: "Task").location | ||
| 72 | for keys in sequences { | ||
| 73 | let chars = keys.map { $0 == "\r" ? "?\\r" : $0 == " " ? "?\\s" : "?" + String($0) }.joined(separator: " ") | ||
| 74 | cases.append(EmacsOracle.Case(text: text, point: EmacsOracle.point(heading, in: text), form: """ | ||
| 75 | (condition-case nil | ||
| 76 | (let ((keys (list \(chars)))) | ||
| 77 | (cl-letf (((symbol-function 'read-char-exclusive) (lambda (&rest _) (if keys (pop keys) (signal 'quit nil))))) | ||
| 78 | (org-set-tags-command))) | ||
| 79 | (quit nil)) | ||
| 80 | """)) | ||
| 81 | let answer = String(keys.prefix { $0 != "\r" }) | ||
| 82 | ours.append(runCommand(SetTags(), text, caret: heading, answers: ["tags": answer]).text) | ||
| 83 | } | ||
| 84 | } | ||
| 85 | let emacs = try EmacsOracle.run(cases) | ||
| 86 | for (i, (mine, theirs)) in zip(ours, emacs).enumerated() { | ||
| 87 | #expect(mine == theirs.text, "\(mine.debugDescription) vs \(theirs.text.debugDescription) \(cases[i].form.split(separator: "\n")[1]) on \(cases[i].text.debugDescription) \(theirs.error)") | ||
| 88 | } | ||
| 89 | } | ||
| 90 | |||
| 91 | @Test func assignsTagKeysAsOrgDoes() { | ||
| 92 | let table = FastSelection.tagTable("#+TAGS: alpha(a) apple avocado banana @bread\n")! | ||
| 93 | let fast = FastSelection.tags(table: table, current: [], inherited: [])! | ||
| 94 | #expect(fast.options.map { "\($0.key)\($0.name)" } == ["aalpha", "bapple", "cavocado", "dbanana", "e@bread"]) | ||
| 95 | } | ||
| 96 | |||
| 97 | @Test func encodesTypedTags() { | ||
| 98 | let inputs: [FastSelection.Input] = [.key("a"), .typed("new tag"), .key(" ")] | ||
| 99 | #expect(FastSelection.decode(FastSelection.encode(inputs)) == inputs) | ||
| 100 | } | ||
| 101 | } | ||
| 102 | |||
| 103 | struct TodoKeywordSettingsTests { | ||
| 104 | @Test func readsOneSequencePerLine() { | ||
| 105 | let settings = OrgSettings.default.withTodoKeywords("TODO(t) | DONE(d)\nBUG | FIXED\n")! | ||
| 106 | #expect(settings.todoSequences.map { ($0.active + $0.done).map(\.name) } == [["TODO", "DONE"], ["BUG", "FIXED"]]) | ||
| 107 | #expect(OrgSettings.default.withTodoKeywords("\n") == nil) | ||
| 108 | } | ||
| 109 | } | ||
Tests/OrgCoreTests/KeymapTests.swift +3 −2
| @@ -84,12 +84,13 @@ struct KeymapTests { | |||
| 84 | 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)) |