krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit 414cb44451

414cb44451e2d300710ae9c021e5be073c2bf97f

parent: 1d52ac3e82

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-07 00:23 UTC

iOS: clocking

Clock In from an agenda entry's menu or the editor (More, the palette,
C-c C-x C-i). While a clock runs, an accessory over the tab bar shows the
entry and time, with Clock Out, Cancel Clock, Go to Clocked Entry and the
clock report, which can be shared. Capture files to the clocked entry and
honours clock-in, clock-keep, clock-resume and jump-to-captured.

Capture target resolution and the capture clock-in moved into OrgApp
(CaptureTemplate.resolved, ClockModel.captured) for both apps.

Layout: unified · split

Sources/OrgApp/CaptureModel.swift +21
@@ -147,3 +147,24 @@ struct CapturePlacement: OrgCommand {
147147 }
148148 }
149149}
150
151extension CaptureTemplate {
152 /// `id` and `clock` targets as a file and a heading.
153 @MainActor
154 public func resolved(
155 workspace: WorkspaceModel, session: DocumentSession, clock: ClockModel
156 ) -> Result<(template: CaptureTemplate, file: String?), CaptureError> {
157 switch target {
158 case .id(let id):
159 guard let location = try? workspace.index.headings(withID: id).first else {
160 return .failure(CaptureError(message: "Cannot find target ID \"\(id)\""))
161 }
162 return .success((self, location.path))
163 case .clock:
164 guard let entry = clock.entry(session) else { return .failure(CaptureError(message: "No running clock")) }
165 return .success((retargeted(.heading(file: entry.path, offset: entry.offset)), entry.path))
166 default:
167 return .success((self, nil))
168 }
169 }
170}
Sources/OrgApp/ClockModel.swift +18
@@ -83,6 +83,24 @@ public final class ClockModel {
8383 }
8484 }
8585
86 /// A template's `clock-in` once its entry is filed: clocked from when capture began and,
87 /// without `clock-keep`, clocked out now, the clock it interrupted resuming with
88 /// `clock-resume`. The messages, in order.
89 public func captured(
90 _ entry: (path: String, offset: Int), options: CaptureTemplate.Options, session: DocumentSession, opened: Date, now: Date = Date()
91 ) -> [String] {
92 guard options.clockIn else { return [] }
93 let previous = self.entry(session)
94 var messages = [clockIn(session, path: entry.path, at: entry.offset, now: opened)]
95 if !options.clockKeep {
96 messages.append(clockOut(session, now: now))
97 if options.clockResume, let previous {
98 messages.append(clockIn(session, path: previous.path, at: previous.offset, now: now))
99 }
100 }
101 return messages
102 }
103
86104 static func text(_ path: String, _ session: DocumentSession) -> String? {
87105 session.openDocuments.first { $0.path == path }?.document.text ?? (try? String(contentsOfFile: path, encoding: .utf8))
88106 }
Sources/Orgstar/CaptureIntent.swift +1 −1
@@ -41,7 +41,7 @@ struct CaptureIntent: AppIntent {
4141 guard case .success(let filled) = Capture.fill(chosen.template, context: context, answers: answers) else {
4242 throw Failure(message: "Can't fill template \"\(chosen.key)\"")
4343 }
44 let placed = CaptureView.resolved(chosen, workspace: workspace, session: session, clock: clock).flatMap { resolved in
44 let placed = chosen.resolved(workspace: workspace, session: session, clock: clock).flatMap { resolved in
4545 session.capture(filled.text, template: resolved.template, root: workspace.rootPaths.first, file: resolved.file)
4646 }
4747 switch placed {
Sources/Orgstar/CaptureView.swift +2 −30
@@ -181,29 +181,11 @@ struct CaptureView: View {
181181 }
182182 }
183183
184 /// `id` and `clock` targets as a file and a heading.
185 static func resolved(
186 _ template: CaptureTemplate, workspace: WorkspaceModel, session: DocumentSession, clock: ClockModel
187 ) -> Result<(template: CaptureTemplate, file: String?), CaptureError> {
188 switch template.target {
189 case .id(let id):
190 guard let location = try? workspace.index.headings(withID: id).first else {
191 return .failure(CaptureError(message: "Cannot find target ID \"\(id)\""))
192 }
193 return .success((template, location.path))
194 case .clock:
195 guard let entry = clock.entry(session) else { return .failure(CaptureError(message: "No running clock")) }
196 return .success((template.retargeted(.heading(file: entry.path, offset: entry.offset)), entry.path))
197 default:
198 return .success((template, nil))
199 }
200 }
201
202184 private func finish() {
203185 guard let template else { return }
204186 let opened = context.now
205187 let now = Date()
206 let placed = Self.resolved(template, workspace: workspace, session: session, clock: clock).flatMap { resolved in
188 let placed = template.resolved(workspace: workspace, session: session, clock: clock).flatMap { resolved in
207189 session.capture(text, template: resolved.template, root: workspace.rootPaths.first, file: resolved.file, now: now)
208190 }
209191 switch placed {
@@ -214,17 +196,7 @@ struct CaptureView: View {
214196 case .success(let entry):
215197 commands.show("Captured to \(Self.target(template))")
216198 let options = template.options
217 if options.clockIn {
218 // Clocked from when capture began; the clock it interrupted resumes after.
219 let previous = clock.entry(session)
220 commands.show(clock.clockIn(session, path: entry.path, at: entry.offset, now: opened))
221 if !options.clockKeep {
222 commands.show(clock.clockOut(session, now: now))
223 if options.clockResume, let previous {
224 commands.show(clock.clockIn(session, path: previous.path, at: previous.offset, now: now))
225 }
226 }
227 }
199 for message in clock.captured(entry, options: options, session: session, opened: opened, now: now) { commands.show(message) }
228200 if options.jumpToCaptured { commands.jump(path: entry.path, offset: entry.offset) }
229201 }
230202 close()
Sources/OrgstarMobile/AgendaScreen.swift +4
@@ -13,6 +13,7 @@ struct AgendaScreen: View {
1313 @Bindable var agenda: AgendaModel
1414 let scheduler: ReminderScheduler
1515 @State private var message: String?
16 @Environment(ClockModel.self) private var clock
1617 @State private var tick = 0
1718 @State private var views = AgendaViewLoader.builtIn
1819 @State private var viewName = "Agenda"
@@ -187,6 +188,9 @@ struct AgendaScreen: View {
187188 Button("Deadline…", systemImage: "calendar.badge.exclamationmark") { run("org.deadline", on: item) }
188189 Button("Tags…", systemImage: "tag") { run("org.tags.set", on: item) }
189190 Button("Priority…", systemImage: "exclamationmark.circle") { run("org.priority.set", on: item) }
191 Button("Clock In", systemImage: "stopwatch") {
192 if let (path, offset) = locate(item) { message = clock.clockIn(session, path: path, at: offset) }
193 }
190194 Divider()
191195 Button("Refile…", systemImage: "arrow.turn.down.right") { refiling = item }
192196 Button("Archive…", systemImage: "archivebox") { archiving = item }
Sources/OrgstarMobile/CaptureScreen.swift +14 −16
@@ -10,6 +10,8 @@ struct CaptureScreen: View {
1010 let session: DocumentSession
1111 let request: CaptureRequest
1212 @Environment(\.dismiss) private var dismiss
13 @Environment(ClockModel.self) private var clock
14 @Environment(MobileModel.self) private var model
1315 @State private var templates: [CaptureTemplate] = []
1416 @State private var key: String?
1517 @State private var context = CaptureContext()
@@ -108,23 +110,19 @@ struct CaptureScreen: View {
108110
109111 private func file() {
110112 guard let template else { return }
111 var file: String?
112 switch template.target {
113 case .id(let id):
114 guard let location = try? workspace.index.headings(withID: id).first else {
115 problem = "Cannot find target ID \"\(id)\""
116 return
117 }
118 file = location.path
119 case .clock:
120 problem = "There's no running clock on iOS"
121 return
122 default:
123 break
113 let now = Date()
114 let placed = template.resolved(workspace: workspace, session: session, clock: clock).flatMap { resolved in
115 session.capture(text, template: resolved.template, root: workspace.rootPaths.first, file: resolved.file, now: now)
124116 }
125 switch session.capture(text, template: template, root: workspace.rootPaths.first, file: file) {
126 case .failure(let error): problem = error.message
127 case .success: dismiss()
117 switch placed {
118 case .failure(let error):
119 problem = error.message
120 editing = true
121 case .success(let entry):
122 // Clocked from when capture began.
123 _ = clock.captured(entry, options: template.options, session: session, opened: context.now, now: now)
124 if template.options.jumpToCaptured { model.openRequest = .offset(entry.path, entry.offset) }
125 dismiss()
128126 }
129127 }
130128}
Sources/OrgstarMobile/ClockScreens.swift added +129
@@ -0,0 +1,129 @@
1#if os(iOS)
2import OrgApp
3import OrgCore
4import SwiftUI
5
6/// The running clock over the tab bar: the entry and time so far, and clocking out.
7struct ClockAccessory: View {
8 let running: ClockModel.Running
9 let perform: (String) -> Void
10
11 var body: some View {
12 Menu {
13 Button("Clock Out", systemImage: "stop.circle") { perform("app.clock.out") }
14 Button("Cancel Clock", systemImage: "xmark.circle") { perform("app.clock.cancel") }
15 Button("Go to Clocked Entry", systemImage: "arrow.right.circle") { perform("app.clock.goto") }
16 Button("Clock Report", systemImage: "list.bullet.rectangle") { perform("app.clock.report") }
17 } label: {
18 HStack {
19 Image(systemName: "stopwatch")
20 Text(running.heading).lineLimit(1)
21 Spacer()
22 TimelineView(.periodic(from: .now, by: 30)) { context in
23 Text(Self.elapsed(running, now: context.date)).monospacedDigit()
24 }
25 }
26 .padding(.horizontal)
27 }
28 .accessibilityLabel("Clocked in on \(running.heading)")
29 }
30
31 /// Elapsed time of the running clock, `H:MM`.
32 static func elapsed(_ running: ClockModel.Running, now: Date) -> String {
33 let minutes = max(0, Int(now.timeIntervalSince(running.startedAt) / 60))
34 return String(format: "%d:%02d", minutes / 60, minutes % 60)
35 }
36}
37
38extension View {
39 /// The running clock over the tab bar, while there is one.
40 @ViewBuilder
41 func clockAccessory(_ running: ClockModel.Running?, perform: @escaping (String) -> Void) -> some View {
42 if #available(iOS 26.1, *) {
43 tabViewBottomAccessory(isEnabled: running != nil) {
44 if let running { ClockAccessory(running: running, perform: perform) }
45 }
46 } else {
47 safeAreaInset(edge: .bottom) {
48 if let running { ClockAccessory(running: running, perform: perform).padding(.vertical, 8).background(.bar) }
49 }
50 }
51 }
52}
53
54/// Time per day and heading, as `tr/generate-report` writes it, for chosen files and dates.
55struct ClockReportScreen: View {
56 let workspace: WorkspaceModel
57 @State private var start = Calendar.current.date(byAdding: .day, value: -6, to: Date()) ?? Date()
58 @State private var end = Date()
59 @State private var limit = false
60 @State private var selected: Set<String> = []
61 @State private var report = ""
62 @Environment(\.dismiss) private var dismiss
63
64 private var candidates: [FileEntry] { workspace.files.filter { $0.kind == .org } }
65
66 var body: some View {
67 NavigationStack {
68 Form {
69 Section("Files") {
70 ForEach(candidates) { file in
71 Toggle(file.relativePath, isOn: Binding {
72 selected.contains(file.path)
73 } set: { on in
74 if on { selected.insert(file.path) } else { selected.remove(file.path) }
75 })
76 }
77 }
78 Section {
79 Toggle("Limit dates", isOn: $limit)
80 if limit {
81 DatePicker("From", selection: $start, displayedComponents: .date)
82 DatePicker("To", selection: $end, displayedComponents: .date)
83 }
84 }
85 Section("Report") {
86 ScrollView(.horizontal) {
87 Text(report.isEmpty ? "Choose files with clocked time." : report)
88 .font(.footnote.monospaced())
89 .textSelection(.enabled)
90 .fixedSize()
91 }
92 }
93 }
94 .navigationTitle("Clock Report")
95 .navigationBarTitleDisplayMode(.inline)
96 .toolbar {
97 ToolbarItem(placement: .cancellationAction) { Button("Close") { dismiss() } }
98 ToolbarItem(placement: .primaryAction) {
99 ShareLink(item: report, preview: SharePreview("Time report")).disabled(report.isEmpty)
100 }
101 }
102 }
103 .onAppear {
104 if selected.isEmpty {
105 selected = Set(candidates.filter { (try? String(contentsOfFile: $0.path, encoding: .utf8))?.contains("CLOCK: [") ?? false }.map(\.path))
106 }
107 compute()
108 }
109 .onChange(of: selected) { compute() }
110 .onChange(of: start) { compute() }
111 .onChange(of: end) { compute() }
112 .onChange(of: limit) { compute() }
113 }
114
115 private func day(_ date: Date) -> String {
116 let c = Calendar.current.dateComponents([.year, .month, .day], from: date)
117 return String(format: "%04d-%02d-%02d", c.year!, c.month!, c.day!)
118 }
119
120 private func compute() {
121 var entries: [ClockReport.Entry] = []
122 for path in selected.sorted() {
123 guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { continue }
124 entries += ClockReport.entries(text, settings: workspace.settings.org)
125 }
126 report = selected.isEmpty ? "" : ClockReport.timeReport(entries, start: limit ? day(start) : nil, end: limit ? day(end) : nil)
127 }
128}
129#endif
Sources/OrgstarMobile/EditorScreen.swift +15
@@ -17,6 +17,8 @@ struct EditorScreen: View {
1717 @State private var prompt: PromptRequest?
1818 @State private var message: String?
1919 @State private var sheet: Sheet?
20 @Environment(ClockModel.self) private var clock
21 @Environment(MobileModel.self) private var model
2022 @Environment(\.openURL) private var openURL
2123
2224 struct PromptRequest: Identifiable {
@@ -50,6 +52,7 @@ struct EditorScreen: View {
5052 Button("Undo", systemImage: "arrow.uturn.backward") { editor?.run("edit.undo") }
5153 Menu("More", systemImage: "ellipsis.circle") {
5254 Button("Commands", systemImage: "command") { sheet = .palette }
55 Button("Clock In", systemImage: "stopwatch") { editor?.run("app.clock.in") }
5356 Button("Sync Conflict Copies", systemImage: "doc.on.doc") { sheet = .syncCopies }
5457 Button("Recovered Versions", systemImage: "clock.arrow.circlepath") { sheet = .recovery }
5558 }
@@ -111,6 +114,16 @@ struct EditorScreen: View {
111114 case .message(let text): message = text
112115 }
113116 }
117 editor.onAppCommand = { id in
118 switch id {
119 case "app.clock.in": message = clock.clockIn(session)
120 case "app.clock.out": message = clock.clockOut(session)
121 case "app.clock.cancel": message = clock.clockOut(session, cancel: true)
122 case "app.clock.goto":
123 if let location = clock.location(session) { model.openRequest = .offset(location.path, location.offset) } else { message = "No active clock" }
124 default: message = "Not available on iOS yet"
125 }
126 }
114127 self.editor = editor
115128 }
116129 if let editor { session.attach(editor) }
@@ -306,6 +319,8 @@ struct CommandPalette: View {
306319 private static let viewCommands: [(id: String, title: String)] = [
307320 ("org.cycle", "Cycle Visibility"), ("org.cycle-global", "Cycle Global Visibility"), ("org.link.open", "Open Link"),
308321 ("edit.undo", "Undo"), ("edit.redo", "Redo"),
322 ("app.clock.in", "Clock In"), ("app.clock.out", "Clock Out"), ("app.clock.cancel", "Cancel Clock"),
323 ("app.clock.goto", "Go to Clocked Entry"),
309324 ]
310325
311326 var body: some View {
Sources/OrgstarMobile/MobileEditor.swift +5
@@ -60,6 +60,8 @@ final class MobileEditor: NSObject, DocumentBuffer {
6060 var onMessage: ((String) -> Void)?
6161 var onPrompt: ((Prompt, @escaping (String?) -> Void) -> Void)?
6262 var onLink: ((OrgLink) -> Void)?
63 /// Commands the app runs, as `app.clock.in`.
64 var onAppCommand: ((String) -> Void)?
6365 /// Tags used across the workspace, for completion.
6466 var knownTags: @Sendable () -> [String] = { [] }
6567
@@ -183,6 +185,8 @@ final class MobileEditor: NSObject, DocumentBuffer {
183185 case "org.link.open": openLinkAtCaret()
184186 case "edit.undo": textView.undoManager?.undo()
185187 case "edit.redo": textView.undoManager?.redo()
188 case _ where id.hasPrefix("app."):
189 if let onAppCommand { onAppCommand(id) } else { onMessage?("Not available on iOS yet") }
186190 default:
187191 if let command = Commands.command(id) {
188192 _ = perform(command)
@@ -198,6 +202,7 @@ final class MobileEditor: NSObject, DocumentBuffer {
198202 case "org.cycle":
199203 return Presentation.heading(containing: context.caret, in: context.tree) != nil || Wrappers.toggleable(at: context.caret, in: context.tree) != nil
200204 case "org.cycle-global", "edit.undo", "edit.redo": return true
205 case "app.clock.in", "app.clock.out", "app.clock.cancel", "app.clock.goto": return onAppCommand != nil
201206 case "org.link.open": return Links.link(at: context.caret, text: context.text, tree: context.tree) != nil
202207 default: return false
203208 }
Sources/OrgstarMobile/MobileModel.swift +1
@@ -16,6 +16,7 @@ final class MobileModel {
1616 let session = DocumentSession()
1717 let agenda: AgendaModel?
1818 let scheduler = ReminderScheduler(center: SystemReminderCenter())
19 let clock = ClockModel()
1920 private(set) var configFolder: URL?
2021 private(set) var configProblems: [String] = []
2122 /// An entry to show, from a tapped reminder.
Sources/OrgstarMobile/OrgstarMobileApp.swift +27
@@ -14,6 +14,8 @@ struct OrgstarMobileApp: App {
1414 WindowGroup {
1515 if let workspace = model.workspace, let agenda = model.agenda {
1616 RootView(model: model, workspace: workspace, agenda: agenda)
17 .environment(model)
18 .environment(model.clock)
1719 .onAppear { delegate.model = model }
1820 } else {
1921 ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(model.failure ?? ""))
@@ -71,6 +73,8 @@ struct RootView: View {
7173 @State private var tab = AppTab.agenda
7274 @State private var agendaPath = NavigationPath()
7375 @State private var hour = 0
76 @State private var showingClockReport = false
77 @State private var message: String?
7478 @Environment(\.scenePhase) private var scenePhase
7579
7680 private var session: DocumentSession { model.session }
@@ -110,6 +114,18 @@ struct RootView: View {
110114 }
111115 }
112116 }
117 .clockAccessory(model.clock.running, perform: clock)
118 .overlay(alignment: .top) {
119 if let message {
120 Text(message).font(.callout).padding(8).frame(maxWidth: .infinity).background(.bar)
121 .onTapGesture { self.message = nil }
122 .task(id: message) {
123 try? await Task.sleep(for: .seconds(4))
124 if self.message == message { self.message = nil }
125 }
126 }
127 }
128 .sheet(isPresented: $showingClockReport) { ClockReportScreen(workspace: workspace) }
113129 .onChange(of: model.openRequest) {
114130 guard let route = model.openRequest else { return }
115131 model.openRequest = nil
@@ -160,6 +176,17 @@ struct RootView: View {
160176 }
161177 }
162178
179 private func clock(_ id: String) {
180 switch id {
181 case "app.clock.out": message = model.clock.clockOut(session)
182 case "app.clock.cancel": message = model.clock.clockOut(session, cancel: true)
183 case "app.clock.goto":
184 if let location = model.clock.location(session) { model.openRequest = .offset(location.path, location.offset) }
185 case "app.clock.report": showingClockReport = true
186 default: break
187 }
188 }
189
163190 private var captureButton: some ToolbarContent {
164191 ToolbarItem(placement: .primaryAction) {
165192 Button("Capture", systemImage: "square.and.pencil") { capture = CaptureRequest() }