krz/orgstar

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

Commit 4a9617c0dd

4a9617c0ddd96c5ca9f89875fdf56f447bbf56c6

parent: 789201d6da

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-08 07:45 UTC

iOS clock history and open clock resolution

Recent entries in the clock accessory and the editor's More menu, the
new commands in the palette, and a sheet for clock questions.

Layout: unified · split

Sources/OrgstarMobile/ClockScreens.swift +169 −4
@@ -3,9 +3,173 @@ import OrgApp
3import OrgCore 3import OrgCore
4import SwiftUI 4import SwiftUI
5 5
6/// Clock commands that ask questions, and their answers in `ClockPromptSheet`.
7@MainActor
8@Observable
9final class ClockPrompter {
10 struct Pending: Identifiable {
11 let id = UUID()
12 let prompt: Prompt
13 let reply: (String?) -> Void
14 }
15
16 var pending: Pending?
17
18 var ask: ClockModel.Ask {
19 { [self] prompt in
20 await withCheckedContinuation { continuation in
21 pending = Pending(prompt: prompt) { continuation.resume(returning: $0) }
22 }
23 }
24 }
25
26 func answer(_ text: String?) {
27 guard let request = pending else { return }
28 pending = nil
29 request.reply(text)
30 }
31}
32
33/// The clock commands both the editor and the clock accessory run.
34@MainActor
35enum MobileClock {
36 /// Runs a clock command; false for other ids. Messages go to `show`; entries to show open.
37 static func run(_ id: String, model: MobileModel, workspace: WorkspaceModel, show: @escaping (String) -> Void) -> Bool {
38 let clock = model.clock
39 let session = model.session
40 let ask = model.clockPrompts.ask
41 let paths = workspace.files.filter { $0.kind == .org }.map(\.path)
42 func open(_ location: (path: String, offset: Int)?) {
43 if let location { model.openRequest = .offset(location.path, location.offset) }
44 }
45 func report(_ outcome: ClockModel.Outcome) {
46 open(outcome.jump)
47 if let message = outcome.messages.last(where: { !$0.isEmpty }) { show(message) }
48 }
49 func clockIn(_ target: ClockModel.Target) {
50 Task { report(await clock.clockIn(session, target: target, paths: paths, ask: ask)) }
51 }
52 switch id {
53 case "app.clock.in": clockIn(.caret)
54 case "app.clock.in-recent": clockIn(.select)
55 case "app.clock.in-last": clockIn(.last)
56 case let id where id.hasPrefix("app.clock.history."):
57 guard let index = Int(id.dropFirst("app.clock.history.".count)), index < clock.history.count else { return true }
58 clockIn(.task(clock.history[index]))
59 case "app.clock.out": show(clock.clockOut(session))
60 case "app.clock.cancel": show(clock.clockOut(session, cancel: true))
61 case "app.clock.goto":
62 let found = clock.goto(session)
63 open(found.location)
64 if let message = found.message { show(message) }
65 case "app.clock.goto-recent":
66 Task {
67 let found = await clock.gotoRecent(session, ask: ask)
68 open(found.location)
69 if let message = found.message { show(message) }
70 }
71 case "app.clock.default": show(clock.markDefault(session))
72 case "app.clock.resolve":
73 Task { report(await clock.resolveOpenClocks(session, paths: paths, ask: ask)) }
74 default:
75 return false
76 }
77 return true
78 }
79}
80
81/// Recent entries to clock in to, and the commands that choose among them.
82struct RecentClockMenu: View {
83 let clock: ClockModel
84 let perform: (String) -> Void
85
86 var body: some View {
87 Menu("Recent Entries", systemImage: "clock.arrow.circlepath") {
88 ForEach(Array(clock.history.enumerated()), id: \.offset) { index, task in
89 Button(task.title) { perform("app.clock.history.\(index)") }
90 }
91 Divider()
92 Button("Clock In to Recent Entry…") { perform("app.clock.in-recent") }
93 Button("Go to Recent Clocked Entry…") { perform("app.clock.goto-recent") }
94 }
95 .disabled(clock.history.isEmpty)
96 }
97}
98
99/// A clock question: keys to choose from under their sections, or a number or date to type.
100struct ClockPromptSheet: View {
101 let prompt: Prompt
102 let answer: (String?) -> Void
103 @State private var text = ""
104
105 var body: some View {
106 NavigationStack {
107 Form {
108 if let fast = prompt.fast {
109 ForEach(Array(sections(fast).enumerated()), id: \.offset) { _, section in
110 Section(section.title) {
111 ForEach(Array(section.options.enumerated()), id: \.offset) { _, option in
112 Button {
113 answer(String(option.key))
114 } label: {
115 HStack(alignment: .firstTextBaseline) {
116 Text(String(option.key)).font(.body.monospaced()).foregroundStyle(.tint)
117 Text(option.name).foregroundStyle(.primary)
118 }
119 }
120 }
121 }
122 }
123 } else {
124 Section {
125 TextField(prompt.message, text: $text)
126 .autocorrectionDisabled()
127 .textInputAutocapitalization(.never)
128 .keyboardType(prompt.date == nil ? .numbersAndPunctuation : .default)
129 .onSubmit { answer(text) }
130 } footer: {
131 Text(prompt.date == nil ? "Empty takes the default." : "Org's date syntax: 14:00, -1h, today 9:30")
132 }
133 }
134 }
135 .navigationTitle(prompt.message.trimmingCharacters(in: CharacterSet(charactersIn: ":?[] ")))
136 .navigationBarTitleDisplayMode(.inline)
137 .toolbar {
138 ToolbarItem(placement: .cancellationAction) { Button("Cancel") { answer(nil) } }
139 if prompt.fast == nil {
140 ToolbarItem(placement: .confirmationAction) { Button("Done") { answer(text) } }
141 }
142 }
143 }
144 .onAppear { text = prompt.initial }
145 .interactiveDismissDisabled()
146 }
147
148 private struct Group {
149 var title: String
150 var options: [(key: Character, name: String)]
151 }
152
153 /// Options under the labels before them.
154 private func sections(_ fast: FastSelection) -> [Group] {
155 var groups: [Group] = []
156 for item in fast.items {
157 switch item {
158 case .label(let title): groups.append(Group(title: title, options: []))
159 case .option(let key, let name):
160 if groups.isEmpty { groups.append(Group(title: "", options: [])) }
161 groups[groups.count - 1].options.append((key, name))
162 default: break
163 }
164 }
165 return groups
166 }
167}
168
6/// The running clock over the tab bar: the entry and time so far, and clocking out. 169/// The running clock over the tab bar: the entry and time so far, and clocking out.
7struct ClockAccessory: View { 170struct ClockAccessory: View {
8 let running: ClockModel.Running 171 let running: ClockModel.Running
172 let clock: ClockModel
9 let perform: (String) -> Void 173 let perform: (String) -> Void
10 174
11 var body: some View { 175 var body: some View {
@@ -13,6 +177,7 @@ struct ClockAccessory: View {
13 Button("Clock Out", systemImage: "stop.circle") { perform("app.clock.out") } 177 Button("Clock Out", systemImage: "stop.circle") { perform("app.clock.out") }
14 Button("Cancel Clock", systemImage: "xmark.circle") { perform("app.clock.cancel") } 178 Button("Cancel Clock", systemImage: "xmark.circle") { perform("app.clock.cancel") }
15 Button("Go to Clocked Entry", systemImage: "arrow.right.circle") { perform("app.clock.goto") } 179 Button("Go to Clocked Entry", systemImage: "arrow.right.circle") { perform("app.clock.goto") }
180 RecentClockMenu(clock: clock, perform: perform)
16 Button("Clock Report", systemImage: "list.bullet.rectangle") { perform("app.clock.report") } 181 Button("Clock Report", systemImage: "list.bullet.rectangle") { perform("app.clock.report") }
17 } label: { 182 } label: {
18 HStack { 183 HStack {
@@ -38,14 +203,14 @@ struct ClockAccessory: View {
38extension View { 203extension View {
39 /// The running clock over the tab bar, while there is one. 204 /// The running clock over the tab bar, while there is one.
40 @ViewBuilder 205 @ViewBuilder
41 func clockAccessory(_ running: ClockModel.Running?, perform: @escaping (String) -> Void) -> some View { 206 func clockAccessory(_ clock: ClockModel, perform: @escaping (String) -> Void) -> some View {
42 if #available(iOS 26.1, *) { 207 if #available(iOS 26.1, *) {
43 tabViewBottomAccessory(isEnabled: running != nil) { 208 tabViewBottomAccessory(isEnabled: clock.running != nil) {
44 if let running { ClockAccessory(running: running, perform: perform) } 209 if let running = clock.running { ClockAccessory(running: running, clock: clock, perform: perform) }
45 } 210 }
46 } else { 211 } else {
47 safeAreaInset(edge: .bottom) { 212 safeAreaInset(edge: .bottom) {
48 if let running { ClockAccessory(running: running, perform: perform).padding(.vertical, 8).background(.bar) } 213 if let running = clock.running { ClockAccessory(running: running, clock: clock, perform: perform).padding(.vertical, 8).background(.bar) }
49 } 214 }
50 } 215 }
51 } 216 }
Sources/OrgstarMobile/EditorScreen.swift +9 −9
@@ -70,6 +70,7 @@ struct EditorScreen: View {
70 Toggle(isOn: Binding(get: { model.showMarkup }, set: { model.showMarkup = $0 })) { 70 Toggle(isOn: Binding(get: { model.showMarkup }, set: { model.showMarkup = $0 })) {
71 Label("Show Markup", systemImage: "chevron.left.forwardslash.chevron.right") 71 Label("Show Markup", systemImage: "chevron.left.forwardslash.chevron.right")
72 } 72 }
73 RecentClockMenu(clock: clock) { editor?.run($0) }
73 if let text = session.document?.text, session.path == path { ExportMenu(path: path, text: text) } 74 if let text = session.document?.text, session.path == path { ExportMenu(path: path, text: text) }
74 Button("Sync Conflict Copies", systemImage: "doc.on.doc") { sheet = .syncCopies } 75 Button("Sync Conflict Copies", systemImage: "doc.on.doc") { sheet = .syncCopies }
75 Button("Recovered Versions", systemImage: "clock.arrow.circlepath") { sheet = .recovery } 76 Button("Recovered Versions", systemImage: "clock.arrow.circlepath") { sheet = .recovery }
@@ -175,14 +176,10 @@ struct EditorScreen: View {
175 editor.onRefreshSetup = { session.refreshSetup() } 176 editor.onRefreshSetup = { session.refreshSetup() }
176 editor.onAgenda = { start, count in model.agendaRequest = .init(start: start, count: count) } 177 editor.onAgenda = { start, count in model.agendaRequest = .init(start: start, count: count) }
177 editor.onAppCommand = { id in 178 editor.onAppCommand = { id in
178 switch id { 179 if id == "app.toggle-markup" {
179 case "app.clock.in": message = clock.clockIn(session) 180 model.showMarkup.toggle()
180 case "app.clock.out": message = clock.clockOut(session) 181 } else if !MobileClock.run(id, model: model, workspace: workspace, show: { message = $0 }) {
181 case "app.clock.cancel": message = clock.clockOut(session, cancel: true) 182 message = "Not available on iOS yet"
182 case "app.clock.goto":
183 if let location = clock.location(session) { model.openRequest = .offset(location.path, location.offset) } else { message = "No active clock" }
184 case "app.toggle-markup": model.showMarkup.toggle()
185 default: message = "Not available on iOS yet"
186 } 183 }
187 } 184 }
188 self.editor = editor 185 self.editor = editor
@@ -412,7 +409,10 @@ struct CommandPalette: View {
412 ("org.cycle", "Cycle Visibility"), ("org.cycle-global", "Cycle Global Visibility"), 409 ("org.cycle", "Cycle Visibility"), ("org.cycle-global", "Cycle Global Visibility"),
413 ("edit.undo", "Undo"), ("edit.redo", "Redo"), 410 ("edit.undo", "Undo"), ("edit.redo", "Redo"),
414 ("app.clock.in", "Clock In"), ("app.clock.out", "Clock Out"), ("app.clock.cancel", "Cancel Clock"), 411 ("app.clock.in", "Clock In"), ("app.clock.out", "Clock Out"), ("app.clock.cancel", "Cancel Clock"),
415 ("app.clock.goto", "Go to Clocked Entry"), ("app.toggle-markup", "Show or Hide Markup"), 412 ("app.clock.goto", "Go to Clocked Entry"), ("app.clock.in-recent", "Clock In to Recent Entry…"),
413 ("app.clock.in-last", "Clock In to Last Entry"), ("app.clock.goto-recent", "Go to Recent Clocked Entry…"),
414 ("app.clock.default", "Mark as Default Clock Task"), ("app.clock.resolve", "Resolve Open Clocks…"),
415 ("app.toggle-markup", "Show or Hide Markup"),
416 ("org.toggle-inline-images", "Show or Hide Inline Images"), ("editor.toggle-truncate-lines", "Truncate or Wrap Long Lines"), 416 ("org.toggle-inline-images", "Show or Hide Inline Images"), ("editor.toggle-truncate-lines", "Truncate or Wrap Long Lines"),
417 ] 417 ]
418 418
Sources/OrgstarMobile/MobileEditor.swift +2 −1
@@ -333,7 +333,8 @@ final class MobileEditor: NSObject, DocumentBuffer {
333 case "org.cycle": 333 case "org.cycle":
334 return Presentation.heading(containing: context.caret, in: context.tree) != nil || Wrappers.toggleable(at: context.caret, in: context.tree) != nil 334 return Presentation.heading(containing: context.caret, in: context.tree) != nil || Wrappers.toggleable(at: context.caret, in: context.tree) != nil
335 case "org.cycle-global", "edit.undo", "edit.redo", "org.toggle-inline-images", "editor.toggle-truncate-lines": return true 335 case "org.cycle-global", "edit.undo", "edit.redo", "org.toggle-inline-images", "editor.toggle-truncate-lines": return true
336 case "app.clock.in", "app.clock.out", "app.clock.cancel", "app.clock.goto", "app.toggle-markup": return onAppCommand != nil 336 case "app.clock.in", "app.clock.out", "app.clock.cancel", "app.clock.goto", "app.clock.in-recent", "app.clock.in-last",
337 "app.clock.goto-recent", "app.clock.default", "app.clock.resolve", "app.toggle-markup": return onAppCommand != nil
337 case "org.link.open": return OpenLinkAtPoint().applies(in: context) 338 case "org.link.open": return OpenLinkAtPoint().applies(in: context)
338 default: return false 339 default: return false
339 } 340 }
Sources/OrgstarMobile/MobileModel.swift +1
@@ -22,6 +22,7 @@ final class MobileModel {
22 let agenda: AgendaModel? 22 let agenda: AgendaModel?
23 let scheduler = ReminderScheduler(center: SystemReminderCenter()) 23 let scheduler = ReminderScheduler(center: SystemReminderCenter())
24 let clock = ClockModel() 24 let clock = ClockModel()
25 let clockPrompts = ClockPrompter()
25 let spotlight = SpotlightIndexer() 26 let spotlight = SpotlightIndexer()
26 /// Captures from the share extension; nil without the app group. 27 /// Captures from the share extension; nil without the app group.
27 let inbox = CaptureInbox() 28 let inbox = CaptureInbox()
Sources/OrgstarMobile/OrgstarMobileApp.swift +8 −8
@@ -131,7 +131,10 @@ struct RootView: View {
131 } 131 }
132 } 132 }
133 } 133 }
134 .clockAccessory(model.clock.running, perform: clock) 134 .clockAccessory(model.clock, perform: clock)
135 .sheet(item: Binding(get: { model.clockPrompts.pending }, set: { if $0 == nil { model.clockPrompts.answer(nil) } })) { pending in
136 ClockPromptSheet(prompt: pending.prompt) { model.clockPrompts.answer($0) }
137 }
135 // Above the tab bar, clear of every screen's toolbar. 138 // Above the tab bar, clear of every screen's toolbar.
136 .overlay(alignment: .bottom) { 139 .overlay(alignment: .bottom) {
137 if let message { 140 if let message {
@@ -222,13 +225,10 @@ struct RootView: View {
222 } 225 }
223 226
224 private func clock(_ id: String) { 227 private func clock(_ id: String) {
225 switch id { 228 if id == "app.clock.report" {
226 case "app.clock.out": message = model.clock.clockOut(session) 229 showingClockReport = true
227 case "app.clock.cancel": message = model.clock.clockOut(session, cancel: true) 230 } else {
228 case "app.clock.goto": 231 _ = MobileClock.run(id, model: model, workspace: workspace) { message = $0 }
229 if let location = model.clock.location(session) { model.openRequest = .offset(location.path, location.offset) }
230 case "app.clock.report": showingClockReport = true
231 default: break
232 } 232 }
233 } 233 }
234 234