C-c C-c everywhere !81

merged merged by cmc on 2026-10-06 03:32 UTC · krz/orgstar:ctrl-c-ctrl-c into main

5 files changed, +178 −2

Layout: unified · split

Sources/OrgCore/Commands/Command.swift +1 −1
@@ -216,7 +216,7 @@ public enum Commands {
216216 ShiftTimestamp(up: true), ShiftTimestamp(up: false), ShiftTimestampDay(later: true), ShiftTimestampDay(later: false),
217217 OpenLinkAtPoint(), CreateID(), ToggleComment(), ToggleHeading(), ToggleItem(), MarkSubtree(),
218218 CopySubtree(cut: false), CopySubtree(cut: true), CloneSubtree(), SortEntries(), InsertStructureTemplate(), EditSpecial(),
219 UpdateDynamicBlock(), ClockReportTable(), SetEffort(),
219 UpdateDynamicBlock(), ClockReportTable(), SetEffort(), CtrlCCtrlC(),
220220 ]
221221
222222 public static var everything: [any OrgCommand] { all + TableCommand.Action.allCases.map(TableCommand.init) }
Sources/OrgCore/Commands/CtrlCCtrlC.swift added +104
@@ -0,0 +1,104 @@
1import Foundation
2
3// C-c C-c (`org-ctrl-c-ctrl-c`, Org 9.8.7) for the contexts no more specific binding takes:
4// headings set tags, clock lines get their duration again, timestamps their weekday, cookies
5// update, items without a checkbox have their list repaired, setup keywords refresh, and
6// elsewhere there's nothing to do.
7
8/// `org-ctrl-c-ctrl-c`.
9public struct CtrlCCtrlC: OrgCommand {
10 public init() {}
11 public var id: String { "org.ctrl-c-ctrl-c" }
12 public var title: String { "Act at Point (C-c C-c)" }
13 public func applies(in context: EditContext) -> Bool { true }
14
15 static let nothing = CommandStep.failed("`C-c C-c' can do nothing useful here")
16
17 public func run(in context: EditContext) -> CommandStep {
18 let buffer = EmacsBuffer(context.text, point: context.caret, settings: context.tree.settings, options: context.options)
19 if KeyContext.holds("src", in: context) { return ExecuteSrcBlock().run(in: context) }
20 if KeyContext.holds("table", in: context) || TableRecalculate.tblfmLine(in: context) != nil {
21 // At the table's very start the context is the table itself, which org recalculates.
22 let atStart = buffer.saveExcursion { buffer.tableBegin() } == context.caret
23 return TableRecalculate.tblfmLine(in: context) != nil || atStart ? TableRecalculate(all: true).run(in: context) : TableCommand(.align).run(in: context)
24 }
25 if buffer.lookingAtLine("^[ \\t]*$") { return Self.nothing }
26 // On a clock line, changing a timestamp updates the clock too.
27 if buffer.lookingAtLine("^[ \\t]*CLOCK:") { return commitBuffer(context) { $0.clockUpdateTime() } }
28 if buffer.lookingAtLine(EmacsBuffer.dblockStart) { return UpdateDynamicBlock().run(in: context) }
29 if Self.cookieAtCaret(buffer) {
30 return commitBuffer(context) { buffer in
31 buffer.saveExcursion {
32 buffer.point = buffer.lineStart(buffer.point)
33 buffer.updateStatisticsCookies()
34 }
35 }
36 }
37 if buffer.atTimestamp() != nil { return commitBuffer(context) { try $0.changeTimestamp(0, what: .day) } }
38 if buffer.atHeading { return SetTags().run(in: context) }
39 if buffer.atItem {
40 // The item's checkbox toggles, and the whole list is repaired, its cookie updated.
41 return commitBuffer(context) { buffer in
42 buffer.point = buffer.lineStart(buffer.point)
43 let item = buffer.point
44 var structure = buffer.listStruct()
45 let old = structure
46 let box = structure.checkbox(item)
47 if let box { structure.set(item) { $0.checkbox = box == "[X]" ? "[ ]" : "[X]" } }
48 var repaired = structure
49 buffer.writeStruct(&repaired, repaired.parents, old: old)
50 if box != nil, repaired == old {
51 throw EmacsBuffer.UserError(message: "Cannot toggle this checkbox: \(box == "[X]" ? "all subitems checked" : "unchecked subitems")")
52 }
53 buffer.point = item
54 buffer.updateCheckboxCount()
55 }
56 }
57 if buffer.lookingAtLine("^[ \\t]*#\\+[A-Za-z_]+:") {
58 return .commit(EditResult(baseRevision: context.revision, edits: [], effects: [.message("Local setup has been refreshed")]))
59 }
60 if buffer.lookingAtLine("^[ \\t]*:[^\\s:]+:") { return .failed("Property actions aren't supported yet") }
61 return Self.nothing
62 }
63
64 /// A `[n/m]` or `[n%]` cookie holds the caret.
65 static func cookieAtCaret(_ buffer: EmacsBuffer) -> Bool {
66 let line = buffer.lineStart(buffer.point)..<buffer.lineEnd(buffer.point)
67 let text = buffer.substring(line.lowerBound, line.upperBound) as NSString
68 let regex = try! NSRegularExpression(pattern: "\\[[0-9]*(?:%|/[0-9]*)\\]")
69 return regex.matches(in: text as String, range: NSRange(location: 0, length: text.length)).contains {
70 let start = line.lowerBound + $0.range.location
71 return start <= buffer.point && buffer.point < start + $0.range.length
72 }
73 }
74}
75
76extension EmacsBuffer {
77 /// `org-clock-update-time-maybe` on a finished clock line: both dates' weekdays fixed and
78 /// the duration written again.
79 func clockUpdateTime() {
80 saveExcursion {
81 point = lineStart(point)
82 skipForward([32, 9])
83 guard let match = lookingAt("[ \\t]*CLOCK: *[\\[<]([^\\]>]+)[\\]>](-+[\\[<]([^\\]>]+)[\\]>]([ \\t]*=>.*)?)?"),
84 match[2] != nil, let first = match[1] else { return }
85 saveExcursion {
86 point = first.lowerBound
87 try? changeTimestamp(0, what: .day)
88 }
89 guard let again = lookingAt("[ \\t]*CLOCK: *[\\[<]([^\\]>]+)[\\]>](-+[\\[<]([^\\]>]+)[\\]>]([ \\t]*=>.*)?)?"), let second = again[3] else { return }
90 saveExcursion {
91 point = second.lowerBound
92 try? changeTimestamp(0, what: .day)
93 }
94 guard let final = lookingAt("[ \\t]*CLOCK: *[\\[<]([^\\]>]+)[\\]>](-+[\\[<]([^\\]>]+)[\\]>]([ \\t]*=>.*)?)?"),
95 let start = final[1], let end = final[3] else { return }
96 if let arrow = final[4] { deleteRegion(arrow.lowerBound, arrow.upperBound) }
97 point = lineEnd(point)
98 let s = TagsMatcher.seconds(substring(end.lowerBound, end.upperBound), now: now, calendar: calendar)
99 - TagsMatcher.seconds(substring(start.lowerBound, start.upperBound), now: now, calendar: calendar)
100 let total = abs(Int(s))
101 insert(" => " + String(format: s < 0 ? "-%d:%02d" : "%2d:%02d", total / 3600, total % 3600 / 60))
102 }
103 }
104}
Sources/OrgCore/Keymap/Presets.swift +4 −1
@@ -635,6 +635,9 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
635635 keys = "C-c ^"
636636 command = "org.sort"
637637 [[bind]]
638 keys = "C-c C-c"
639 command = "org.ctrl-c-ctrl-c"
640 [[bind]]
638641 keys = "C-c C-x C-r"
639642 command = "org.clock.report"
640643 [[bind]]
@@ -822,7 +825,7 @@ public enum KeymapPreset: String, Sendable, CaseIterable {
822825 when = "item"
823826 [[bind]]
824827 keys = "C-c C-c"
825 command = "org.checkbox.toggle"
828 command = "org.ctrl-c-ctrl-c"
826829 when = "item"
827830 [[bind]]
828831 keys = "C-c C-x C-b"
Tests/OrgCoreTests/CtrlCCtrlCTests.swift added +55
@@ -0,0 +1,55 @@
1import Foundation
2import Testing
3@testable import OrgCore
4
5struct CtrlCCtrlCTests {
6 static let text = """
7 #+TITLE: Test
8 * TODO Task [1/3] :old:
9 SCHEDULED: <2026-10-05 Tue>
10 :LOGBOOK:
11 CLOCK: [2026-10-01 Fri 09:00]--[2026-10-01 Fri 11:30] => 1:00
12 CLOCK: [2026-10-01 Thu 12:00]--[2026-10-01 Thu 12:45]
13 :END:
14 - [X] one
15 - [ ] two
16 - three
17 1. first
18 3. second
19 Text with <2026-10-07 Mon> stamp.
20
21 ** DONE Child [50%]
22 - [ ] a
23 - [X] b
24 | a | b |
25 |-+-|
26
27 """
28
29 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
30 func actsLikeOrgCtrlCCtrlC() throws {
31 let ns = Self.text as NSString
32 var offsets: [Int] = []
33 var at = 0
34 while at < ns.length {
35 let line = ns.lineRange(for: NSRange(location: at, length: 0))
36 offsets += [line.location, min(line.location + 8, NSMaxRange(line) - 1)]
37 at = NSMaxRange(line)
38 }
39 offsets = Array(Set(offsets)).sorted()
40 // Cookies and timestamps under the caret.
41 offsets += [ns.range(of: "[1/3]").location + 1, ns.range(of: "[50%]").location + 2, ns.range(of: "<2026-10-07").location + 3, ns.range(of: "Fri 09").location]
42 var cases: [EmacsOracle.Case] = []
43 var ours: [String] = []
44 for offset in offsets {
45 cases.append(EmacsOracle.Case(text: Self.text, point: EmacsOracle.point(offset, in: Self.text), form: frozenClock(1_789_000_000, """
46 (cl-letf (((symbol-function 'completing-read-multiple) (lambda (&rest _) '("x"))))
47 (org-ctrl-c-ctrl-c))
48 """)))
49 ours.append(runCommand(CtrlCCtrlC(), Self.text, caret: offset, answers: ["tags": "x"]).text)
50 }
51 for (i, (mine, theirs)) in zip(ours, try EmacsOracle.run(cases)).enumerated() {
52 #expect(mine == theirs.text, "at \(offsets[i]) (\(ns.substring(with: ns.lineRange(for: NSRange(location: offsets[i], length: 0)))).debugDescription))\nours: \(mine.debugDescription)\nemacs: \(theirs.text.debugDescription) \(theirs.error)")
53 }
54 }
55}
Tests/OrgCoreTests/KeymapTests.swift +14
@@ -190,3 +190,17 @@ struct DynamicBlockKeyTests {
190190 #expect(found?.command == "org.dblock.update")
191191 }
192192}
193
194struct CtrlCCtrlCKeyTests {
195 @Test func headingsAndItemsGoThroughOrgCtrlCCtrlC() {
196 let keymap = KeymapPreset.emacs.keymap
197 let text = "* a\n- [ ] b\n| x |\n"
198 func command(at offset: Int) -> String? {
199 let context = EditContext(revision: 0, text: text, tree: OrgParser.parse(text), selection: [offset..<offset])
200 return keymap.candidates(for: KeySequence.parse("C-c C-c")!).first { $0.when.map { KeyContext.holds($0, in: context) } ?? true }?.command
201 }
202 #expect(command(at: 1) == "org.ctrl-c-ctrl-c")
203 #expect(command(at: 5) == "org.ctrl-c-ctrl-c")
204 #expect(command(at: 13) == "org.table.align")
205 }
206}