Speed keys and org-priority !89

merged merged by cmc on 2026-10-06 04:24 UTC · krz/orgstar:speed-keys into main

12 files changed, +166 −15

Layout: unified · split

Sources/OrgApp/EmacsImport.swift +1 −1
@@ -448,7 +448,7 @@ public struct EmacsImport: Sendable {
448448 guard let n = value.integer else { return note("not a number") }
449449 add(name, .integer(n))
450450 case "org-insert-heading-respect-content", "org-list-allow-alphabetical", "org-hide-emphasis-markers", "org-pretty-entities",
451 "org-cycle-hide-drawer-startup", "org-cycle-hide-block-startup":
451 "org-cycle-hide-drawer-startup", "org-cycle-hide-block-startup", "org-use-speed-commands":
452452 add(name, .bool(truthy))
453453 case "org-hide-drawer-startup", "org-hide-block-startup":
454454 add(name == "org-hide-drawer-startup" ? "org-cycle-hide-drawer-startup" : "org-cycle-hide-block-startup", .bool(truthy))
Sources/OrgApp/UserConfig.swift +2
@@ -82,6 +82,8 @@ public enum UserConfig {
8282 fallback: .string("nil"), comment: "nil, time or note: log changing or removing a DEADLINE", legacy: ""),
8383 Setting(section: "", key: "org-log-into-drawer", defaultsKey: "logIntoDrawer", kind: .string, fallback: .string(""),
8484 comment: "the drawer state notes go in (\"LOGBOOK\" is Emacs's t); \"\" for none", legacy: ""),
85 Setting(section: "", key: "org-use-speed-commands", defaultsKey: "useSpeedCommands", kind: .bool, fallback: .bool(false),
86 comment: "single keys at the start of a heading line run commands (n, p, t, c, …)", legacy: ""),
8587 Setting(section: "", key: "org-cycle-hide-drawer-startup", defaultsKey: "hideDrawerStartup", kind: .bool, fallback: .bool(true),
8688 comment: "fold drawers when a file opens; #+STARTUP: nohidedrawers overrides", legacy: ""),
8789 Setting(section: "", key: "org-cycle-hide-block-startup", defaultsKey: "hideBlockStartup", kind: .bool, fallback: .bool(false),
Sources/OrgCore/Commands/Command.swift +6 −2
@@ -22,6 +22,8 @@ public struct EditingOptions: Sendable, Equatable, Codable {
2222 public var logRedeadline: LogHow?
2323 /// `org-use-fast-todo-selection` `auto`: with keys in the keywords, C-c C-t asks for one.
2424 public var fastTodoSelection: Bool
25 /// `org-use-speed-commands`: single keys at the start of a heading line run commands.
26 public var useSpeedCommands: Bool
2527 /// `org-cycle-hide-drawer-startup`: drawers are folded when a file opens.
2628 public var hideDrawerStartup: Bool
2729 /// `org-cycle-hide-block-startup`: blocks are folded when a file opens.
@@ -40,8 +42,10 @@ public struct EditingOptions: Sendable, Equatable, Codable {
4042 listAllowAlphabetical: Bool = false, logRepeat: Bool = true, fillColumn: Int = 70,
4143 hideEmphasisMarkers: Bool = false, prettyEntities: Bool = false, subSuperscriptsNeedBraces: Bool = false,
4244 logDone: LogHow? = nil, logIntoDrawer: String? = nil, fastTodoSelection: Bool = true,
43 hideDrawerStartup: Bool = true, hideBlockStartup: Bool = false, logReschedule: LogHow? = nil, logRedeadline: LogHow? = nil
45 hideDrawerStartup: Bool = true, hideBlockStartup: Bool = false, logReschedule: LogHow? = nil, logRedeadline: LogHow? = nil,
46 useSpeedCommands: Bool = false
4447 ) {
48 self.useSpeedCommands = useSpeedCommands
4549 self.logReschedule = logReschedule
4650 self.logRedeadline = logRedeadline
4751 self.hideDrawerStartup = hideDrawerStartup
@@ -235,7 +239,7 @@ public enum Commands {
235239 ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false),
236240 OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(),
237241 CopySubtree(cut: false), CopySubtree(cut: true), CloneSubtree(), SortEntries(), InsertStructureTemplate(), EditSpecial(),
238 UpdateDynamicBlock(), ClockReportTable(), SetEffort(), CtrlCCtrlC(), EvaluateTimeRange(), FootnoteAction(), FootnoteAction(special: true), TableEvalFormula(named: false), TableEvalFormula(named: true), SortTableLines(), TransposeTable(), GotoHeading(), SparseTreeCommand(),
242 UpdateDynamicBlock(), ClockReportTable(), SetEffort(), CtrlCCtrlC(), EvaluateTimeRange(), FootnoteAction(), FootnoteAction(special: true), TableEvalFormula(named: false), TableEvalFormula(named: true), SortTableLines(), TransposeTable(), GotoHeading(), SparseTreeCommand(), SetPriority(), SetPriority(value: "A"), SetPriority(value: "B"), SetPriority(value: "C"), SetPriority(value: " "),
239243 ]
240244
241245 public static var everything: [any OrgCommand] { all + TableCommand.Action.allCases.map(TableCommand.init) }
Sources/OrgCore/Commands/HeadingCommands.swift +62 −2
@@ -296,8 +296,12 @@ struct PriorityChange {
296296 } else {
297297 new = Self.value(priorities.default)
298298 }
299 let cookie = new.map { "[#\(Self.label($0, numeric: numeric))]" }
300 return line.commit(context) { buffer in
299 return Self.write(new.map { "[#\(Self.label($0, numeric: numeric))]" }, line: line, context: context)
300 }
301
302 /// Puts `cookie` on the heading, or with nil removes it.
303 static func write(_ cookie: String?, line: HeadingLine, context: EditContext) -> CommandStep {
304 line.commit(context) { buffer in
301305 if let existing = line.priority {
302306 let range = line.local(existing.range)
303307 if let cookie {
@@ -331,6 +335,62 @@ struct PriorityChange {
331335 }
332336}
333337
338/// C-c ,: `org-priority` with `set`, asking for the priority (SPC removes it); with `value`,
339/// that priority, or with " " none, as the speed keys 1, 2, 3 and 0 do.
340public struct SetPriority: OrgCommand {
341 let value: Character?
342 public init(value: Character? = nil) { self.value = value }
343 public var id: String {
344 switch value {
345 case nil: "org.priority.set"
346 case " ": "org.priority.remove"
347 case let v?: "org.priority.set-\(v.lowercased())"
348 }
349 }
350 public var title: String {
351 switch value {
352 case nil: "Set Priority…"
353 case " ": "Remove Priority"
354 case let v?: "Set Priority \(v)"
355 }
356 }
357 public func applies(in context: EditContext) -> Bool { headingOnLine(at: context.caret, in: context.tree) != nil }
358
359 public func run(in context: EditContext) -> CommandStep {
360 guard let heading = headingOnLine(at: context.caret, in: context.tree) else { return .failed("Not on a heading") }
361 let priorities = context.tree.settings.priorities
362 let numeric = Int(priorities.lowest) != nil
363 let answer: String
364 if let value {
365 answer = String(value)
366 } else if let given = context.answers["priority"] {
367 answer = given
368 } else {
369 let message = "Priority \(priorities.highest)-\(priorities.lowest), SPC to remove:"
370 if numeric, (Int(priorities.lowest) ?? 0) >= 10 { return .prompt(Prompt(key: "priority", message: message)) }
371 guard let high = PriorityChange.value(priorities.highest), let low = PriorityChange.value(priorities.lowest), high <= low else {
372 return .failed("Unsupported priority range")
373 }
374 let items: [FastSelection.Item] = (high...low).map { v in
375 let label = PriorityChange.label(v, numeric: numeric)
376 return .option(key: Character(label.lowercased()), name: label)
377 } + [.option(key: " ", name: "remove")]
378 return .prompt(Prompt(key: "priority", message: message, fast: FastSelection(items: items, multiple: false)))
379 }
380 let line = HeadingLine(heading)
381 if answer == " " {
382 guard line.priority != nil else { return .failed("No priority cookie found in line") }
383 return PriorityChange.write(nil, line: line, context: context)
384 }
385 let new = numeric ? Int(answer.trimmingCharacters(in: .whitespaces)) : PriorityChange.value(answer.uppercased())
386 guard let new, let high = PriorityChange.value(priorities.highest), let low = PriorityChange.value(priorities.lowest), new >= high, new <= low else {
387 return .failed(numeric ? "Priority must be a number between `\(priorities.highest)' and `\(priorities.lowest)'"
388 : "Priority must be between `\(priorities.highest)' and `\(priorities.lowest)'")
389 }
390 return PriorityChange.write("[#\(PriorityChange.label(new, numeric: numeric))]", line: line, context: context)
391 }
392}
393
334394public struct PriorityUp: OrgCommand {
335395 public init() {}
336396 public var id: String { "org.priority.up" }
Sources/OrgCore/Keymap/Keymap.swift +10 −2
@@ -101,7 +101,8 @@ public struct Keymap: Sendable, Equatable {
101101 public func keyLabels(for command: String, modes: [String?] = [nil], limit: Int = 2) -> [String] {
102102 var found: [(keys: [KeyChord], rank: [Int])] = []
103103 for (order, mode) in modes.enumerated() {
104 for binding in bindings.reversed() where binding.mode == mode && binding.command == command && isLive(binding) {
104 // Speed keys are labels for nothing: they act only at a heading's start.
105 for binding in bindings.reversed() where binding.mode == mode && binding.command == command && isLive(binding) && binding.when != "speed" {
105106 found.append((binding.keys, [order, binding.when == nil ? 0 : 1, binding.keys.count, found.count]))
106107 }
107108 }
@@ -130,7 +131,7 @@ public struct Keymap: Sendable, Equatable {
130131
131132/// Context names a binding's `when` can use.
132133public enum KeyContext {
133 public static let names: Set<String> = ["heading", "table", "item", "region", "timestamp", "tblfm", "src", "fold-line", "dblock"]
134 public static let names: Set<String> = ["heading", "table", "item", "region", "timestamp", "tblfm", "src", "fold-line", "dblock", "speed"]
134135
135136 public static func holds(_ name: String, in context: EditContext) -> Bool {
136137 switch name {
@@ -147,6 +148,13 @@ public enum KeyContext {
147148 case "dblock":
148149 let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings)
149150 return buffer.lookingAtLine(EmacsBuffer.dblockStart)
151 // `org-use-speed-commands`: at the very start of a heading line.
152 case "speed":
153 guard context.options.useSpeedCommands, context.selection.allSatisfy(\.isEmpty) else { return false }
154 let ns = context.text as NSString
155 let caret = context.caret
156 guard caret == 0 || (caret <= ns.length && ns.character(at: caret - 1) == 10) else { return false }
157 return ns.substring(from: caret).range(of: "^\\*+ ", options: .regularExpression) != nil
150158 default: return false
151159 }
152160 }
Sources/OrgCore/Keymap/Presets.swift +30 −3
@@ -22,12 +22,32 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
2222
2323 public var toml: String {
2424 switch self {
25 case .emacs: Self.common + Self.emacsBindings
26 case .mac: Self.common + Self.macBindings
27 case .doom: Self.doomToml
25 case .emacs: Self.common + Self.speedBindings(mode: nil) + Self.emacsBindings
26 case .mac: Self.common + Self.speedBindings(mode: nil) + Self.macBindings
27 case .doom: Self.doomToml + Self.speedBindings(mode: "insert")
2828 }
2929 }
3030
31 /// `org-speed-commands`, for the start of a heading line when `org-use-speed-commands` is on.
32 static let speedCommands: [(key: String, command: String)] = [
33 ("n", "org.heading.next"), ("p", "org.heading.previous"), ("f", "org.heading.forward-same-level"),
34 ("b", "org.heading.backward-same-level"), ("u", "org.heading.up"), ("j", "org.goto"),
35 ("c", "org.cycle"), ("C", "org.cycle-global"), ("s", "org.narrow.toggle"), ("k", "org.subtree.cut"),
36 ("U", "org.subtree.up"), ("D", "org.subtree.down"), ("r", "org.heading.demote"), ("l", "org.heading.promote"),
37 ("R", "org.subtree.demote"), ("L", "org.subtree.promote"), ("i", "org.heading.insert-after-subtree"),
38 ("^", "org.sort"), ("w", "app.refile"), ("a", "app.archive"), ("@", "org.subtree.mark"),
39 ("#", "org.heading.toggle-comment"), ("I", "app.clock.in"), ("O", "app.clock.out"), ("t", "org.todo.cycle"),
40 (",", "org.priority.set"), ("0", "org.priority.remove"), ("1", "org.priority.set-a"), ("2", "org.priority.set-b"),
41 ("3", "org.priority.set-c"), (":", "org.tags.set"), ("e", "org.effort.set"), ("v", "app.agenda"),
42 ("/", "org.sparse-tree"), ("o", "org.link.open"),
43 ]
44
45 static func speedBindings(mode: String?) -> String {
46 speedCommands.map { key, command in
47 "[[bind]]\nkeys = \"\(key)\"\ncommand = \"\(command)\"\nwhen = \"speed\"\n" + (mode.map { "mode = \"\($0)\"\n" } ?? "")
48 }.joined()
49 }
50
3151 /// Doom: the Emacs preset's org bindings in every evil state (as org-mode-map's are), with
3252 /// evil-org's `M-h/j/k/l` forms, then Doom's leader, localleader and evil-org keys.
3353 static var doomToml: String {
@@ -346,6 +366,10 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
346366 command = "org.effort.set"
347367 mode = "normal"
348368 [[bind]]
369 keys = "SPC m p p"
370 command = "org.priority.set"
371 mode = "normal"
372 [[bind]]
349373 keys = "SPC m p u"
350374 command = "org.priority.up"
351375 mode = "normal"
@@ -682,6 +706,9 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
682706 keys = "C-c /"
683707 command = "org.sparse-tree"
684708 [[bind]]
709 keys = "C-c ,"
710 command = "org.priority.set"
711 [[bind]]
685712 keys = "C-c C-x C-r"
686713 command = "org.clock.report"
687714 [[bind]]
Sources/OrgEditorAppKit/EditorCommands.swift +1
@@ -40,6 +40,7 @@ public enum EditorCommands {
4040 EditorCommand(id: "org.narrow.subtree", title: "Narrow to Subtree", action: .view),
4141 EditorCommand(id: "org.narrow.block", title: "Narrow to Block", action: .view),
4242 EditorCommand(id: "org.widen", title: "Widen", action: .view),
43 EditorCommand(id: "org.narrow.toggle", title: "Narrow to Subtree or Widen", action: .view),
4344 EditorCommand(id: "org.table.toggle-column-width", title: "Shrink or Expand Table Column", action: .view),
4445 EditorCommand(id: "org.table.shrink", title: "Shrink Table Columns with Widths", action: .view),
4546 EditorCommand(id: "org.table.expand", title: "Expand Table Columns", action: .view),
Sources/OrgEditorAppKit/OrgEditor.swift +3 −1
@@ -405,7 +405,7 @@ public final class OrgEditor: NSObject {
405405 switch id {
406406 case "org.cycle": cycleAtCaret()
407407 case "org.cycle-global": cycleGlobal()
408 case "org.narrow.subtree", "org.narrow.block", "org.widen": narrow(id)
408 case "org.narrow.subtree", "org.narrow.block", "org.narrow.toggle", "org.widen": narrow(id)
409409 default: tableColumnWidth(id)
410410 }
411411 case .edit(let selector, let extending):
@@ -615,6 +615,8 @@ public final class OrgEditor: NSObject {
615615 do {
616616 switch id {
617617 case "org.narrow.subtree": narrowed = try Narrowing.subtree(at: caret, in: textView.string)
618 // `org-toggle-narrow-to-subtree`.
619 case "org.narrow.toggle": narrowed = narrowed == nil ? try Narrowing.subtree(at: caret, in: textView.string) : nil
618620 case "org.narrow.block": narrowed = try Narrowing.block(at: caret, in: textView.string)
619621 default: narrowed = nil
620622 }
Sources/Orgstar/ContentView.swift +3 −1
@@ -38,6 +38,7 @@ struct ContentView: View {
3838 @AppStorage("logIntoDrawer") private var logIntoDrawer = ""
3939 @AppStorage("logReschedule") private var logReschedule = "nil"
4040 @AppStorage("logRedeadline") private var logRedeadline = "nil"
41 @AppStorage("useSpeedCommands") private var useSpeedCommands = false
4142 @AppStorage("hideDrawerStartup") private var hideDrawerStartup = true
4243 @AppStorage("hideBlockStartup") private var hideBlockStartup = false
4344
@@ -45,7 +46,8 @@ struct ContentView: View {
4546 Preferences.options(
4647 tagsColumn: tagsColumn, headingAfterSubtree: headingAfterSubtree, splitLine: splitLine, alphabeticalLists: alphabeticalLists,
4748 fillColumn: fillColumn, hiddenMarkers: hiddenMarkers, prettyEntities: prettyEntities, logDone: logDone, logIntoDrawer: logIntoDrawer,
48 hideDrawerStartup: hideDrawerStartup, hideBlockStartup: hideBlockStartup, logReschedule: logReschedule, logRedeadline: logRedeadline
49 hideDrawerStartup: hideDrawerStartup, hideBlockStartup: hideBlockStartup, logReschedule: logReschedule, logRedeadline: logRedeadline,
50 useSpeedCommands: useSpeedCommands
4951 )
5052 }
5153 /// A block being edited apart (`org-edit-special`).
Sources/Orgstar/Preferences.swift +3 −2
@@ -18,7 +18,7 @@ enum Preferences {
1818 static func options(
1919 tagsColumn: Int, headingAfterSubtree: Bool, splitLine: Bool, alphabeticalLists: Bool, fillColumn: Int,
2020 hiddenMarkers: Bool, prettyEntities: Bool, logDone: String, logIntoDrawer: String,
21 hideDrawerStartup: Bool, hideBlockStartup: Bool, logReschedule: String, logRedeadline: String
21 hideDrawerStartup: Bool, hideBlockStartup: Bool, logReschedule: String, logRedeadline: String, useSpeedCommands: Bool
2222 ) -> EditingOptions {
2323 EditingOptions(
2424 tagsColumn: tagsColumn, insertHeadingRespectContent: headingAfterSubtree,
@@ -26,7 +26,8 @@ enum Preferences {
2626 hideEmphasisMarkers: hiddenMarkers, prettyEntities: prettyEntities, subSuperscriptsNeedBraces: true,
2727 logDone: LogHow(rawValue: logDone), logIntoDrawer: logIntoDrawer.isEmpty ? nil : logIntoDrawer,
2828 hideDrawerStartup: hideDrawerStartup, hideBlockStartup: hideBlockStartup,
29 logReschedule: LogHow(rawValue: logReschedule), logRedeadline: LogHow(rawValue: logRedeadline)
29 logReschedule: LogHow(rawValue: logReschedule), logRedeadline: LogHow(rawValue: logRedeadline),
30 useSpeedCommands: useSpeedCommands
3031 )
3132 }
3233}
Tests/OrgCoreTests/KeymapTests.swift +1 −1
@@ -135,7 +135,7 @@ struct KeymapTests {
135135 #expect(dispatcher.feed(z) == .undefined([c, z]))
136136 #expect(dispatcher.feed(c) == .pending([c]))
137137 #expect(dispatcher.feed(KeyDispatcher.quit) == .cancelled)
138 #expect(dispatcher.feed(KeyChord("a")) == .unbound)
138 #expect(dispatcher.feed(KeyChord("z")) == .unbound)
139139 }
140140
141141 @Test func continuationsListNextKeys() {
Tests/OrgCoreTests/PrioritySetTests.swift added +44
@@ -0,0 +1,44 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct PrioritySetTests {
6 static let oracle = ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil
7
8 @Test(.enabled(if: oracle))
9 func setsPrioritiesLikeOrg() throws {
10 let texts = ["* TODO Task :tag:\nbody\n", "* [#B] Plain\n", "* DONE [#A] Done one :x:y:\n", "* Heading\n"]
11 // Org reads SPC and lower case only at its prompt.
12 let values: [(Character, String)] = [("A", "(org-priority ?A)"), ("C", "(org-priority ?C)"),
13 (" ", "(cl-letf (((symbol-function 'read-char-exclusive) (lambda (&rest _) ?\\s))) (org-priority))"),
14 ("b", "(cl-letf (((symbol-function 'read-char-exclusive) (lambda (&rest _) ?b))) (org-priority))")]
15 var cases: [EmacsOracle.Case] = []
16 var ours: [(String, String?)] = []
17 for text in texts {
18 for (value, form) in values {
19 cases.append(EmacsOracle.Case(text: text, point: 3, form: form))
20 let result = runCommand(SetPriority(), text, caret: 2, answers: ["priority": String(value)])
21 ours.append((result.text, result.failure))
22 }
23 cases.append(EmacsOracle.Case(text: text, point: 3, form: "(org-priority ?D)"))
24 let result = runCommand(SetPriority(), text, caret: 2, answers: ["priority": "D"])
25 ours.append((result.text, result.failure))
26 }
27 for (i, theirs) in try EmacsOracle.run(cases).enumerated() {
28 #expect(ours[i].0 == theirs.text, "\(cases[i].form) on \(cases[i].text.debugDescription)")
29 #expect((ours[i].1 != nil) == !theirs.error.isEmpty, "\(cases[i].form): \(ours[i].1 ?? "") vs \(theirs.error)")
30 }
31 }
32
33 @Test func speedKeysHoldAtHeadingStarts() {
34 let text = "* One\nbody\n** Two\n"
35 func holds(_ caret: Int, _ on: Bool) -> Bool {
36 KeyContext.holds("speed", in: EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [caret..<caret], options: EditingOptions(useSpeedCommands: on)))
37 }
38 #expect(holds(0, true))
39 #expect(holds(11, true))
40 #expect(!holds(1, true))
41 #expect(!holds(6, true))
42 #expect(!holds(0, false))
43 }
44}