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 {
147 } 147 }
148 } 148 }
149} 149}
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 {
83 } 83 }
84 } 84 }
85 85
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
86 static func text(_ path: String, _ session: DocumentSession) -> String? { 104 static func text(_ path: String, _ session: DocumentSession) -> String? {
87 session.openDocuments.first { $0.path == path }?.document.text ?? (try? String(contentsOfFile: path, encoding: .utf8)) 105 session.openDocuments.first { $0.path == path }?.document.text ?? (try? String(contentsOfFile: path, encoding: .utf8))
88 } 106 }
Sources/Orgstar/CaptureIntent.swift +1 −1
@@ -41,7 +41,7 @@ struct CaptureIntent: AppIntent {
41 guard case .success(let filled) = Capture.fill(chosen.template, context: context, answers: answers) else { 41 guard case .success(let filled) = Capture.fill(chosen.template, context: context, answers: answers) else {
42 throw Failure(message: "Can't fill template \"\(chosen.key)\"") 42 throw Failure(message: "Can't fill template \"\(chosen.key)\"")
43 } 43 }
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
45 session.capture(filled.text, template: resolved.template, root: workspace.rootPaths.first, file: resolved.file) 45 session.capture(filled.text, template: resolved.template, root: workspace.rootPaths.first, file: resolved.file)
46 } 46 }
47 switch placed { 47 switch placed {
Sources/Orgstar/CaptureView.swift +2 −30
@@ -181,29 +181,11 @@ struct CaptureView: View {
181 } 181 }
182 } 182 }
183 183
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
202 private func finish() { 184 private func finish() {
203 guard let template else { return } 185 guard let template else { return }
204 let opened = context.now 186 let opened = context.now
205 let now = Date() 187 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
207 session.capture(text, template: resolved.template, root: workspace.rootPaths.first, file: resolved.file, now: now) 189 session.capture(text, template: resolved.template, root: workspace.rootPaths.first, file: resolved.file, now: now)
208 } 190 }
209 switch placed { 191 switch placed {
@@ -214,17 +196,7 @@ struct CaptureView: View {
214 case .success(let entry): 196 case .success(let entry):
215 commands.show("Captured to \(Self.target(template))") 197 commands.show("Captured to \(Self.target(template))")
216 let options = template.options 198 let options = template.options
217 if options.clockIn { 199 for message in clock.captured(entry, options: options, session: session, opened: opened, now: now) { commands.show(message) }
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 }
228 if options.jumpToCaptured { commands.jump(path: entry.path, offset: entry.offset) } 200 if options.jumpToCaptured { commands.jump(path: entry.path, offset: entry.offset) }
229 } 201 }
230 close() 202 close()
Sources/OrgstarMobile/AgendaScreen.swift +4
@@ -13,6 +13,7 @@ struct AgendaScreen: View {
13 @Bindable var agenda: AgendaModel 13 @Bindable var agenda: AgendaModel
14 let scheduler: ReminderScheduler 14 let scheduler: ReminderScheduler
15 @State private var message: String? 15 @State private var message: String?
16 @Environment(ClockModel.self) private var clock
16 @State private var tick = 0 17 @State private var tick = 0
17 @State private var views = AgendaViewLoader.builtIn 18 @State private var views = AgendaViewLoader.builtIn
18 @State private var viewName = "Agenda" 19 @State private var viewName = "Agenda"
@@ -187,6 +188,9 @@ struct AgendaScreen: View {
187 Button("Deadline…", systemImage: "calendar.badge.exclamationmark") { run("org.deadline", on: item) } 188 Button("Deadline…", systemImage: "calendar.badge.exclamationmark") { run("org.deadline", on: item) }
188 Button("Tags…", systemImage: "tag") { run("org.tags.set", on: item) } 189 Button("Tags…", systemImage: "tag") { run("org.tags.set", on: item) }
189 Button("Priority…", systemImage: "exclamationmark.circle") { run("org.priority.set", on: item) } 190 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 }
190 Divider() 194 Divider()
191 Button("Refile…", systemImage: "arrow.turn.down.right") { refiling = item } 195 Button("Refile…", systemImage: "arrow.turn.down.right") { refiling = item }
192 Button("Archive…", systemImage: "archivebox") { archiving = item } 196 Button("Archive…", systemImage: "archivebox") { archiving = item }
Sources/OrgstarMobile/CaptureScreen.swift +14 −16
@@ -10,6 +10,8 @@ struct CaptureScreen: View {
10 let session: DocumentSession 10 let session: DocumentSession
11 let request: CaptureRequest 11 let request: CaptureRequest
12 @Environment(\.dismiss) private var dismiss 12 @Environment(\.dismiss) private var dismiss
13 @Environment(ClockModel.self) private var clock
14 @Environment(MobileModel.self) private var model
13 @State private var templates: [CaptureTemplate] = [] 15 @State private var templates: [CaptureTemplate] = []
14 @State private var key: String? 16 @State private var key: String?
15 @State private var context = CaptureContext() 17 @State private var context = CaptureContext()
@@ -108,23 +110,19 @@ struct CaptureScreen: View {
108 110
109 private func file() { 111 private func file() {
110 guard let template else { return } 112 guard let template else { return }
111 var file: String? 113 let now = Date()
112 switch template.target { 114 let placed = template.resolved(workspace: workspace, session: session, clock: clock).flatMap { resolved in
113 case .id(let id): 115 session.capture(text, template: resolved.template, root: workspace.rootPaths.first, file: resolved.file, now: now)
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
124 } 116 }
125 switch session.capture(text, template: template, root: workspace.rootPaths.first, file: file) { 117 switch placed {
126 case .failure(let error): problem = error.message 118 case .failure(let error):
127 case .success: dismiss() 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()
128 } 126 }
129 } 127 }
130} 128}
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 {
17 @State private var prompt: PromptRequest? 17 @State private var prompt: PromptRequest?
18 @State private var message: String? 18 @State private var message: String?
19 @State private var sheet: Sheet? 19 @State private var sheet: Sheet?
20 @Environment(ClockModel.self) private var clock
21 @Environment(MobileModel.self) private var model
20 @Environment(\.openURL) private var openURL 22 @Environment(\.openURL) private var openURL
21 23
22 struct PromptRequest: Identifiable { 24 struct PromptRequest: Identifiable {
@@ -50,6 +52,7 @@ struct EditorScreen: View {
50 Button("Undo", systemImage: "arrow.uturn.backward") { editor?.run("edit.undo") } 52 Button("Undo", systemImage: "arrow.uturn.backward") { editor?.run("edit.undo") }
51 Menu("More", systemImage: "ellipsis.circle") { 53 Menu("More", systemImage: "ellipsis.circle") {
52 Button("Commands", systemImage: "command") { sheet = .palette } 54 Button("Commands", systemImage: "command") { sheet = .palette }
55 Button("Clock In", systemImage: "stopwatch") { editor?.run("app.clock.in") }
53 Button("Sync Conflict Copies", systemImage: "doc.on.doc") { sheet = .syncCopies } 56 Button("Sync Conflict Copies", systemImage: "doc.on.doc") { sheet = .syncCopies }
54 Button("Recovered Versions", systemImage: "clock.arrow.circlepath") { sheet = .recovery } 57 Button("Recovered Versions", systemImage: "clock.arrow.circlepath") { sheet = .recovery }
55 } 58 }
@@ -111,6 +114,16 @@ struct EditorScreen: View {
111 case .message(let text): message = text 114 case .message(let text): message = text
112 } 115 }
113 } 116 }
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 }
114 self.editor = editor 127 self.editor = editor
115 } 128 }
116 if let editor { session.attach(editor) } 129 if let editor { session.attach(editor) }
@@ -306,6 +319,8 @@ struct CommandPalette: View {
306 private static let viewCommands: [(id: String, title: String)] = [ 319 private static let viewCommands: [(id: String, title: String)] = [
307 ("org.cycle", "Cycle Visibility"), ("org.cycle-global", "Cycle Global Visibility"), ("org.link.open", "Open Link"), 320 ("org.cycle", "Cycle Visibility"), ("org.cycle-global", "Cycle Global Visibility"), ("org.link.open", "Open Link"),
308 ("edit.undo", "Undo"), ("edit.redo", "Redo"), 321 ("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"),
309 ] 324 ]
310 325
311 var body: some View { 326 var body: some View {
Sources/OrgstarMobile/MobileEditor.swift +5
@@ -60,6 +60,8 @@ final class MobileEditor: NSObject, DocumentBuffer {
60 var onMessage: ((String) -> Void)? 60 var onMessage: ((String) -> Void)?
61 var onPrompt: ((Prompt, @escaping (String?) -> Void) -> Void)? 61 var onPrompt: ((Prompt, @escaping (String?) -> Void) -> Void)?
62 var onLink: ((OrgLink) -> Void)? 62 var onLink: ((OrgLink) -> Void)?
63 /// Commands the app runs, as `app.clock.in`.
64 var onAppCommand: ((String) -> Void)?
63 /// Tags used across the workspace, for completion. 65 /// Tags used across the workspace, for completion.
64 var knownTags: @Sendable () -> [String] = { [] } 66 var knownTags: @Sendable () -> [String] = { [] }
65 67
@@ -183,6 +185,8 @@ final class MobileEditor: NSObject, DocumentBuffer {
183 case "org.link.open": openLinkAtCaret() 185 case "org.link.open": openLinkAtCaret()
184 case "edit.undo": textView.undoManager?.undo() 186 case "edit.undo": textView.undoManager?.undo()
185 case "edit.redo": textView.undoManager?.redo() 187 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") }
186 default: 190 default:
187 if let command = Commands.command(id) { 191 if let command = Commands.command(id) {
188 _ = perform(command) 192 _ = perform(command)
@@ -198,6 +202,7 @@ final class MobileEditor: NSObject, DocumentBuffer {
198 case "org.cycle": 202 case "org.cycle":
199 return Presentation.heading(containing: context.caret, in: context.tree) != nil || Wrappers.toggleable(at: context.caret, in: context.tree) != nil 203 return Presentation.heading(containing: context.caret, in: context.tree) != nil || Wrappers.toggleable(at: context.caret, in: context.tree) != nil
200 case "org.cycle-global", "edit.undo", "edit.redo": return true 204 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
201 case "org.link.open": return Links.link(at: context.caret, text: context.text, tree: context.tree) != nil 206 case "org.link.open": return Links.link(at: context.caret, text: context.text, tree: context.tree) != nil
202 default: return false 207 default: return false
203 } 208 }
Sources/OrgstarMobile/MobileModel.swift +1
@@ -16,6 +16,7 @@ final class MobileModel {
16 let session = DocumentSession() 16 let session = DocumentSession()
17 let agenda: AgendaModel? 17 let agenda: AgendaModel?
18 let scheduler = ReminderScheduler(center: SystemReminderCenter()) 18 let scheduler = ReminderScheduler(center: SystemReminderCenter())
19 let clock = ClockModel()
19 private(set) var configFolder: URL? 20 private(set) var configFolder: URL?
20 private(set) var configProblems: [String] = [] 21 private(set) var configProblems: [String] = []
21 /// An entry to show, from a tapped reminder. 22 /// An entry to show, from a tapped reminder.
Sources/OrgstarMobile/OrgstarMobileApp.swift +27
@@ -14,6 +14,8 @@ struct OrgstarMobileApp: App {
14 WindowGroup { 14 WindowGroup {
15 if let workspace = model.workspace, let agenda = model.agenda { 15 if let workspace = model.workspace, let agenda = model.agenda {
16 RootView(model: model, workspace: workspace, agenda: agenda) 16 RootView(model: model, workspace: workspace, agenda: agenda)
17 .environment(model)
18 .environment(model.clock)
17 .onAppear { delegate.model = model } 19 .onAppear { delegate.model = model }
18 } else { 20 } else {
19 ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(model.failure ?? "")) 21 ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(model.failure ?? ""))
@@ -71,6 +73,8 @@ struct RootView: View {
71 @State private var tab = AppTab.agenda 73 @State private var tab = AppTab.agenda
72 @State private var agendaPath = NavigationPath() 74 @State private var agendaPath = NavigationPath()
73 @State private var hour = 0 75 @State private var hour = 0
76 @State private var showingClockReport = false
77 @State private var message: String?
74 @Environment(\.scenePhase) private var scenePhase 78 @Environment(\.scenePhase) private var scenePhase
75 79
76 private var session: DocumentSession { model.session } 80 private var session: DocumentSession { model.session }
@@ -110,6 +114,18 @@ struct RootView: View {
110 } 114 }
111 } 115 }
112 } 116 }
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) }
113 .onChange(of: model.openRequest) { 129 .onChange(of: model.openRequest) {
114 guard let route = model.openRequest else { return } 130 guard let route = model.openRequest else { return }
115 model.openRequest = nil 131 model.openRequest = nil
@@ -160,6 +176,17 @@ struct RootView: View {
160 } 176 }
161 } 177 }
162 178
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
163 private var captureButton: some ToolbarContent { 190 private var captureButton: some ToolbarContent {
164 ToolbarItem(placement: .primaryAction) { 191 ToolbarItem(placement: .primaryAction) {
165 Button("Capture", systemImage: "square.and.pencil") { capture = CaptureRequest() } 192 Button("Capture", systemImage: "square.and.pencil") { capture = CaptureRequest() }