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
33import OrgCore
44import SwiftUI
55
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
6169/// The running clock over the tab bar: the entry and time so far, and clocking out.
7170struct ClockAccessory: View {
8171 let running: ClockModel.Running
172 let clock: ClockModel
9173 let perform: (String) -> Void
10174
11175 var body: some View {
@@ -13,6 +177,7 @@ struct ClockAccessory: View {
13177 Button("Clock Out", systemImage: "stop.circle") { perform("app.clock.out") }
14178 Button("Cancel Clock", systemImage: "xmark.circle") { perform("app.clock.cancel") }
15179 Button("Go to Clocked Entry", systemImage: "arrow.right.circle") { perform("app.clock.goto") }
180 RecentClockMenu(clock: clock, perform: perform)
16181 Button("Clock Report", systemImage: "list.bullet.rectangle") { perform("app.clock.report") }
17182 } label: {
18183 HStack {
@@ -38,14 +203,14 @@ struct ClockAccessory: View {
38203extension View {
39204 /// The running clock over the tab bar, while there is one.
40205 @ViewBuilder
41 func clockAccessory(_ running: ClockModel.Running?, perform: @escaping (String) -> Void) -> some View {
206 func clockAccessory(_ clock: ClockModel, perform: @escaping (String) -> Void) -> some View {
42207 if #available(iOS 26.1, *) {
43 tabViewBottomAccessory(isEnabled: running != nil) {
44 if let running { ClockAccessory(running: running, perform: perform) }
208 tabViewBottomAccessory(isEnabled: clock.running != nil) {
209 if let running = clock.running { ClockAccessory(running: running, clock: clock, perform: perform) }
45210 }
46211 } else {
47212 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) }
49214 }
50215 }
51216 }
Sources/OrgstarMobile/EditorScreen.swift +9 −9
@@ -70,6 +70,7 @@ struct EditorScreen: View {
7070 Toggle(isOn: Binding(get: { model.showMarkup }, set: { model.showMarkup = $0 })) {
7171 Label("Show Markup", systemImage: "chevron.left.forwardslash.chevron.right")
7272 }
73 RecentClockMenu(clock: clock) { editor?.run($0) }
7374 if let text = session.document?.text, session.path == path { ExportMenu(path: path, text: text) }
7475 Button("Sync Conflict Copies", systemImage: "doc.on.doc") { sheet = .syncCopies }
7576 Button("Recovered Versions", systemImage: "clock.arrow.circlepath") { sheet = .recovery }
@@ -175,14 +176,10 @@ struct EditorScreen: View {
175176 editor.onRefreshSetup = { session.refreshSetup() }
176177 editor.onAgenda = { start, count in model.agendaRequest = .init(start: start, count: count) }
177178 editor.onAppCommand = { id in
178 switch id {
179 case "app.clock.in": message = clock.clockIn(session)
180 case "app.clock.out": message = clock.clockOut(session)
181 case "app.clock.cancel": message = clock.clockOut(session, cancel: true)
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"
179 if id == "app.toggle-markup" {
180 model.showMarkup.toggle()
181 } else if !MobileClock.run(id, model: model, workspace: workspace, show: { message = $0 }) {
182 message = "Not available on iOS yet"
186183 }
187184 }
188185 self.editor = editor
@@ -412,7 +409,10 @@ struct CommandPalette: View {
412409 ("org.cycle", "Cycle Visibility"), ("org.cycle-global", "Cycle Global Visibility"),
413410 ("edit.undo", "Undo"), ("edit.redo", "Redo"),
414411 ("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"),
416416 ("org.toggle-inline-images", "Show or Hide Inline Images"), ("editor.toggle-truncate-lines", "Truncate or Wrap Long Lines"),
417417 ]
418418
Sources/OrgstarMobile/MobileEditor.swift +2 −1
@@ -333,7 +333,8 @@ final class MobileEditor: NSObject, DocumentBuffer {
333333 case "org.cycle":
334334 return Presentation.heading(containing: context.caret, in: context.tree) != nil || Wrappers.toggleable(at: context.caret, in: context.tree) != nil
335335 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
337338 case "org.link.open": return OpenLinkAtPoint().applies(in: context)
338339 default: return false
339340 }
Sources/OrgstarMobile/MobileModel.swift +1
@@ -22,6 +22,7 @@ final class MobileModel {
2222 let agenda: AgendaModel?
2323 let scheduler = ReminderScheduler(center: SystemReminderCenter())
2424 let clock = ClockModel()
25 let clockPrompts = ClockPrompter()
2526 let spotlight = SpotlightIndexer()
2627 /// Captures from the share extension; nil without the app group.
2728 let inbox = CaptureInbox()
Sources/OrgstarMobile/OrgstarMobileApp.swift +8 −8
@@ -131,7 +131,10 @@ struct RootView: View {
131131 }
132132 }
133133 }
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 }
135138 // Above the tab bar, clear of every screen's toolbar.
136139 .overlay(alignment: .bottom) {
137140 if let message {
@@ -222,13 +225,10 @@ struct RootView: View {
222225 }
223226
224227 private func clock(_ id: String) {
225 switch id {
226 case "app.clock.out": message = model.clock.clockOut(session)
227 case "app.clock.cancel": message = model.clock.clockOut(session, cancel: true)
228 case "app.clock.goto":
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
228 if id == "app.clock.report" {
229 showingClockReport = true
230 } else {
231 _ = MobileClock.run(id, model: model, workspace: workspace) { message = $0 }
232232 }
233233 }
234234