Commit 789201d6da
Verified · cmc
Layout: unified · split
Sources/OrgApp/EmacsImport.swift +2 −1
| @@ -638,7 +638,8 @@ public struct EmacsImport: Sendable { | ||
| 638 | 638 | "org-table-insert-row": "org.table.insert-row", "org-table-insert-column": "org.table.insert-column", |
| 639 | 639 | "org-table-delete-column": "org.table.delete-column", "org-table-kill-row": "org.table.kill-row", "org-table-insert-hline": "org.table.insert-hline", |
| 640 | 640 | "org-babel-execute-src-block": "org.babel.execute", "org-clock-in": "app.clock.in", "org-clock-out": "app.clock.out", |
| 641 | "org-clock-cancel": "app.clock.cancel", "org-clock-goto": "app.clock.goto", | |
| 641 | "org-clock-cancel": "app.clock.cancel", "org-clock-goto": "app.clock.goto", "org-clock-in-last": "app.clock.in-last", | |
| 642 | "org-resolve-clocks": "app.clock.resolve", "org-clock-mark-default-task": "app.clock.default", | |
| 642 | 643 | "org-refile": "app.refile", "org-archive-subtree": "app.archive", "org-archive-subtree-default": "app.archive", |
| 643 | 644 | "org-capture": "app.capture", "org-agenda": "app.agenda", "org-agenda-list": "app.agenda", |
| 644 | 645 | "save-buffer": "app.save", "find-file": "app.quick-open", "projectile-find-file": "app.quick-open", "+default/find-file-under-here": "app.quick-open", |
Sources/OrgCore/Keymap/Presets.swift +28
| @@ -370,6 +370,22 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | ||
| 370 | 370 | command = "app.clock.goto" |
| 371 | 371 | mode = "normal" |
| 372 | 372 | [[bind]] |
| 373 | keys = "SPC m c G" | |
| 374 | command = "app.clock.goto-recent" | |
| 375 | mode = "normal" | |
| 376 | [[bind]] | |
| 377 | keys = "SPC m c I" | |
| 378 | command = "app.clock.in-last" | |
| 379 | mode = "normal" | |
| 380 | [[bind]] | |
| 381 | keys = "SPC m c d" | |
| 382 | command = "app.clock.default" | |
| 383 | mode = "normal" | |
| 384 | [[bind]] | |
| 385 | keys = "SPC m c r" | |
| 386 | command = "app.clock.resolve" | |
| 387 | mode = "normal" | |
| 388 | [[bind]] | |
| 373 | 389 | keys = "SPC m c R" |
| 374 | 390 | command = "app.clock.report" |
| 375 | 391 | mode = "normal" |
| @@ -830,6 +846,12 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | ||
| 830 | 846 | keys = "C-c C-x C-j" |
| 831 | 847 | command = "app.clock.goto" |
| 832 | 848 | [[bind]] |
| 849 | keys = "C-c C-x C-x" | |
| 850 | command = "app.clock.in-last" | |
| 851 | [[bind]] | |
| 852 | keys = "C-c C-x C-z" | |
| 853 | command = "app.clock.resolve" | |
| 854 | [[bind]] | |
| 833 | 855 | keys = "C-c C-e h h" |
| 834 | 856 | command = "app.export.html" |
| 835 | 857 | [[bind]] |
| @@ -1260,6 +1282,12 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | ||
| 1260 | 1282 | keys = "C-s-j" |
| 1261 | 1283 | command = "app.clock.goto" |
| 1262 | 1284 | [[bind]] |
| 1285 | keys = "C-M-s-i" | |
| 1286 | command = "app.clock.in-recent" | |
| 1287 | [[bind]] | |
| 1288 | keys = "C-M-s-j" | |
| 1289 | command = "app.clock.goto-recent" | |
| 1290 | [[bind]] | |
| 1263 | 1291 | keys = "s-S-e" |
| 1264 | 1292 | command = "app.export-dialog" |
| 1265 | 1293 | [[bind]] |
Sources/OrgEditorAppKit/EditorCommands.swift +5
| @@ -87,9 +87,14 @@ public enum EditorCommands { | ||
| 87 | 87 | EditorCommand(id: "app.capture", title: "Capture…", action: .app), |
| 88 | 88 | EditorCommand(id: "app.board", title: "Board", action: .app), |
| 89 | 89 | EditorCommand(id: "app.clock.in", title: "Clock In", action: .app), |
| 90 | EditorCommand(id: "app.clock.in-recent", title: "Clock In to Recent Entry…", action: .app), | |
| 91 | EditorCommand(id: "app.clock.in-last", title: "Clock In to Last Entry", action: .app), | |
| 90 | 92 | EditorCommand(id: "app.clock.out", title: "Clock Out", action: .app), |
| 91 | 93 | EditorCommand(id: "app.clock.cancel", title: "Cancel Clock", action: .app), |
| 92 | 94 | EditorCommand(id: "app.clock.goto", title: "Go to Clocked Entry", action: .app), |
| 95 | EditorCommand(id: "app.clock.goto-recent", title: "Go to Recent Clocked Entry…", action: .app), | |
| 96 | EditorCommand(id: "app.clock.default", title: "Mark as Default Clock Task", action: .app), | |
| 97 | EditorCommand(id: "app.clock.resolve", title: "Resolve Open Clocks…", action: .app), | |
| 93 | 98 | EditorCommand(id: "app.clock.report", title: "Clock Report", action: .app), |
| 94 | 99 | EditorCommand(id: "app.table-import", title: "Import Table from File…", action: .app), |
| 95 | 100 | EditorCommand(id: "app.table-export", title: "Export Table to File…", action: .app), |
Sources/Orgstar/ClockViews.swift +71 −6
| @@ -1,6 +1,8 @@ | ||
| 1 | 1 | import AppKit |
| 2 | import CoreGraphics | |
| 2 | 3 | import OrgApp |
| 3 | 4 | import OrgCore |
| 5 | import OrgEditorAppKit | |
| 4 | 6 | import SwiftUI |
| 5 | 7 | |
| 6 | 8 | /// Elapsed time of the running clock, `H:MM`. |
| @@ -9,6 +11,57 @@ func clockElapsed(_ running: ClockModel.Running, now: Date) -> String { | ||
| 9 | 11 | return String(format: "%d:%02d", minutes / 60, minutes % 60) |
| 10 | 12 | } |
| 11 | 13 | |
| 14 | /// What the menu bar item shows. | |
| 15 | struct ClockMenuState: Equatable { | |
| 16 | let running: ClockModel.Running? | |
| 17 | let history: [ClockTask] | |
| 18 | } | |
| 19 | ||
| 20 | /// `org-user-idle-seconds` on macOS: time since the last keyboard or mouse input anywhere. | |
| 21 | enum ClockIdle { | |
| 22 | static func seconds() -> Double { | |
| 23 | // `kCGAnyInputEventType`. | |
| 24 | CGEventSource.secondsSinceLastEventType(.combinedSessionState, eventType: CGEventType(rawValue: ~0)!) | |
| 25 | } | |
| 26 | } | |
| 27 | ||
| 28 | /// The clock history in a menu: clocking in to an entry runs `app.clock.history.N`. | |
| 29 | struct RecentClocks: View { | |
| 30 | let clock: ClockModel | |
| 31 | let run: (String) -> Void | |
| 32 | ||
| 33 | var body: some View { | |
| 34 | if !clock.history.isEmpty { | |
| 35 | Section("Recent Entries") { | |
| 36 | ForEach(Array(clock.history.enumerated()), id: \.offset) { index, task in | |
| 37 | Button(task.title) { run("app.clock.history.\(index)") } | |
| 38 | .help("Clock in to \(task.title) in \((task.path as NSString).lastPathComponent)") | |
| 39 | } | |
| 40 | } | |
| 41 | } | |
| 42 | } | |
| 43 | } | |
| 44 | ||
| 45 | /// Org ▸ Clock: the clock commands, with their keys, and the recent entries. | |
| 46 | struct ClockMenu: View { | |
| 47 | let commands: AppCommands | |
| 48 | let clock: ClockModel | |
| 49 | ||
| 50 | var body: some View { | |
| 51 | Menu("Clock") { | |
| 52 | ForEach(EditorCommands.all.filter { $0.id.hasPrefix("app.clock.") }, id: \.id) { command in | |
| 53 | Button { | |
| 54 | commands.run(command.id) | |
| 55 | } label: { | |
| 56 | Text(command.title) | |
| 57 | if let keys = commands.keyLabel(for: command.id) { Text(keys) } | |
| 58 | } | |
| 59 | } | |
| 60 | RecentClocks(clock: clock) { commands.run($0) } | |
| 61 | } | |
| 62 | } | |
| 63 | } | |
| 64 | ||
| 12 | 65 | /// The menu bar item while a clock runs: elapsed time, and clock out, cancel and go to. |
| 13 | 66 | @MainActor |
| 14 | 67 | final class ClockStatusItem: NSObject { |
| @@ -21,7 +74,7 @@ final class ClockStatusItem: NSObject { | ||
| 21 | 74 | self.run = run |
| 22 | 75 | } |
| 23 | 76 | |
| 24 | func update(_ running: ClockModel.Running?) { | |
| 77 | func update(_ running: ClockModel.Running?, recent: [String] = []) { | |
| 25 | 78 | self.running = running |
| 26 | 79 | guard let running else { |
| 27 | 80 | timer?.invalidate() |
| @@ -39,16 +92,28 @@ final class ClockStatusItem: NSObject { | ||
| 39 | 92 | let menu = NSMenu() |
| 40 | 93 | menu.addItem(withTitle: running.heading, action: nil, keyEquivalent: "") |
| 41 | 94 | menu.addItem(.separator()) |
| 42 | for (title, id) in [("Clock Out", "app.clock.out"), ("Cancel Clock", "app.clock.cancel"), ("Go to Clocked Entry", "app.clock.goto"), ("Clock Report", "app.clock.report")] { | |
| 43 | let entry = NSMenuItem(title: title, action: #selector(choose(_:)), keyEquivalent: "") | |
| 44 | entry.target = self | |
| 45 | entry.representedObject = id | |
| 46 | menu.addItem(entry) | |
| 95 | for (title, id) in [ | |
| 96 | ("Clock Out", "app.clock.out"), ("Cancel Clock", "app.clock.cancel"), ("Go to Clocked Entry", "app.clock.goto"), | |
| 97 | ("Clock In to Recent Entry…", "app.clock.in-recent"), ("Clock Report", "app.clock.report"), | |
| 98 | ] { | |
| 99 | add(title, id, to: menu) | |
| 100 | } | |
| 101 | if !recent.isEmpty { | |
| 102 | menu.addItem(.separator()) | |
| 103 | menu.addItem(NSMenuItem.sectionHeader(title: "Recent Entries")) | |
| 104 | for (index, title) in recent.enumerated() { add(title, "app.clock.history.\(index)", to: menu) } | |
| 47 | 105 | } |
| 48 | 106 | item?.menu = menu |
| 49 | 107 | refresh() |
| 50 | 108 | } |
| 51 | 109 | |
| 110 | private func add(_ title: String, _ id: String, to menu: NSMenu) { | |
| 111 | let entry = NSMenuItem(title: title, action: #selector(choose(_:)), keyEquivalent: "") | |
| 112 | entry.target = self | |
| 113 | entry.representedObject = id | |
| 114 | menu.addItem(entry) | |
| 115 | } | |
| 116 | ||
| 52 | 117 | private func refresh() { |
| 53 | 118 | guard let running else { return } |
| 54 | 119 | item?.button?.title = "⏱ " + clockElapsed(running, now: Date()) |
Sources/Orgstar/ContentView.swift +62 −5
| @@ -16,6 +16,7 @@ struct ContentView: View { | ||
| 16 | 16 | let clock: ClockModel |
| 17 | 17 | @AppStorage("reminders") private var remindersEnabled = true |
| 18 | 18 | @AppStorage("reminderLead") private var reminderLead = 12 |
| 19 | @AppStorage("clockIdleTime") private var clockIdleTime = 0 | |
| 19 | 20 | @AppStorage("agendaSubfolders") private var agendaSubfolders = false |
| 20 | 21 | @State private var reminderAgenda: AgendaModel? |
| 21 | 22 | @State private var trust = TrustStore() |
| @@ -150,6 +151,10 @@ struct ContentView: View { | ||
| 150 | 151 | Button("Clock Out") { perform("app.clock.out") } |
| 151 | 152 | Button("Cancel Clock") { perform("app.clock.cancel") } |
| 152 | 153 | Button("Go to Clocked Entry") { perform("app.clock.goto") } |
| 154 | Divider() | |
| 155 | Button("Clock In to Recent Entry…") { perform("app.clock.in-recent") } | |
| 156 | Button("Go to Recent Clocked Entry…") { perform("app.clock.goto-recent") } | |
| 157 | RecentClocks(clock: clock) { perform($0) } | |
| 153 | 158 | } label: { |
| 154 | 159 | TimelineView(.periodic(from: .now, by: 30)) { context in |
| 155 | 160 | Text("⏱ \(running.heading) \(clockElapsed(running, now: context.date))").lineLimit(1) |
| @@ -280,9 +285,9 @@ struct ContentView: View { | ||
| 280 | 285 | |
| 281 | 286 | var body: some View { |
| 282 | 287 | main |
| 283 | .onChange(of: clock.running, initial: true) { _, running in | |
| 288 | .onChange(of: ClockMenuState(running: clock.running, history: clock.history), initial: true) { _, state in | |
| 284 | 289 | if statusItem == nil { statusItem = ClockStatusItem { commands.run($0) } } |
| 285 | statusItem?.update(running) | |
| 290 | statusItem?.update(state.running, recent: state.history.map(\.title)) | |
| 286 | 291 | } |
| 287 | 292 | .onChange(of: globalCapture, initial: true) { _, enabled in |
| 288 | 293 | hotKey?.unregister() |
| @@ -345,6 +350,18 @@ struct ContentView: View { | ||
| 345 | 350 | hour += 1 |
| 346 | 351 | } |
| 347 | 352 | } |
| 353 | // `org-resolve-clocks-if-idle`, checked every minute while a clock runs. | |
| 354 | .task(id: "\(clock.running?.start ?? "") \(clockIdleTime)") { | |
| 355 | guard clock.running != nil, clockIdleTime > 0 else { return } | |
| 356 | while !Task.isCancelled { | |
| 357 | try? await Task.sleep(for: .seconds(60)) | |
| 358 | guard !Task.isCancelled, clock.running != nil, !clock.isResolving else { continue } | |
| 359 | let idle = ClockIdle.seconds() | |
| 360 | guard idle > Double(clockIdleTime * 60) else { continue } | |
| 361 | NSApp.requestUserAttention(.informationalRequest) | |
| 362 | report(await clock.resolveIdle(session, idleSince: Date().addingTimeInterval(-idle), ask: askClock)) | |
| 363 | } | |
| 364 | } | |
| 348 | 365 | .task { |
| 349 | 366 | DevelopmentHooks.scheduleSnapshot() |
| 350 | 367 | pendingCarets = BufferRestore.restore(session) |
| @@ -523,7 +540,7 @@ struct ContentView: View { | ||
| 523 | 540 | |
| 524 | 541 | /// Runs a command from a key, the palette or the Org menu. |
| 525 | 542 | private func perform(_ id: String) { |
| 526 | let orgOnly = ["app.refile", "app.archive", "app.clock.in"].contains(id) || id.hasPrefix("app.export.") || id.hasPrefix("org.") | |
| 543 | let orgOnly = ["app.refile", "app.archive", "app.clock.in", "app.clock.default"].contains(id) || id.hasPrefix("app.export.") || id.hasPrefix("org.") | |
| 527 | 544 | if orgOnly, !session.isOrg { |
| 528 | 545 | commands.show("Not an org file") |
| 529 | 546 | return |
| @@ -620,13 +637,31 @@ struct ContentView: View { | ||
| 620 | 637 | case "app.agenda": |
| 621 | 638 | openWindow(id: "agenda") |
| 622 | 639 | case "app.clock.in": |
| 623 | commands.show(clock.clockIn(session)) | |
| 640 | clockIn(.caret) | |
| 641 | case "app.clock.in-recent": | |
| 642 | clockIn(.select) | |
| 643 | case "app.clock.in-last": | |
| 644 | clockIn(.last) | |
| 645 | case let id where id.hasPrefix("app.clock.history."): | |
| 646 | if let index = Int(id.dropFirst("app.clock.history.".count)), index < clock.history.count { clockIn(.task(clock.history[index])) } | |
| 624 | 647 | case "app.clock.out": |
| 625 | 648 | commands.show(clock.clockOut(session)) |
| 626 | 649 | case "app.clock.cancel": |
| 627 | 650 | commands.show(clock.clockOut(session, cancel: true)) |
| 628 | 651 | case "app.clock.goto": |
| 629 | if let location = clock.location(session) { commands.jump(path: location.path, offset: location.offset) } else { commands.show("No active clock") } | |
| 652 | let found = clock.goto(session) | |
| 653 | if let location = found.location { commands.jump(path: location.path, offset: location.offset) } | |
| 654 | if let message = found.message { commands.show(message) } | |
| 655 | case "app.clock.goto-recent": | |
| 656 | Task { | |
| 657 | let found = await clock.gotoRecent(session, ask: askClock) | |
| 658 | if let location = found.location { commands.jump(path: location.path, offset: location.offset) } | |
| 659 | if let message = found.message { commands.show(message) } | |
| 660 | } | |
| 661 | case "app.clock.default": | |
| 662 | commands.show(clock.markDefault(session)) | |
| 663 | case "app.clock.resolve": | |
| 664 | Task { report(await clock.resolveOpenClocks(session, paths: orgFiles, ask: askClock)) } | |
| 630 | 665 | case "app.clock.report": |
| 631 | 666 | openWindow(id: "clock-report") |
| 632 | 667 | case "app.board": |
| @@ -655,6 +690,28 @@ struct ContentView: View { | ||
| 655 | 690 | } |
| 656 | 691 | } |
| 657 | 692 | |
| 693 | /// Clock questions in the echo area. | |
| 694 | private var askClock: ClockModel.Ask { | |
| 695 | { [commands] prompt in | |
| 696 | await withCheckedContinuation { continuation in | |
| 697 | commands.ask(prompt) { continuation.resume(returning: $0) } | |
| 698 | } | |
| 699 | } | |
| 700 | } | |
| 701 | ||
| 702 | /// The files `org-resolve-clocks` looks through: the workspace's org files. | |
| 703 | private var orgFiles: [String] { workspace.files.filter { $0.kind == .org }.map(\.path) } | |
| 704 | ||
| 705 | private func clockIn(_ target: ClockModel.Target) { | |
| 706 | Task { report(await clock.clockIn(session, target: target, paths: orgFiles, ask: askClock)) } | |
| 707 | } | |
| 708 | ||
| 709 | /// The last message of a clock command, and the clock to show for `j`. | |
| 710 | private func report(_ outcome: ClockModel.Outcome) { | |
| 711 | if let jump = outcome.jump { commands.jump(path: jump.path, offset: jump.offset) } | |
| 712 | if let message = outcome.messages.last(where: { !$0.isEmpty }) { commands.show(message) } | |
| 713 | } | |
| 714 | ||
| 658 | 715 | private var errorShown: Binding<Bool> { |
| 659 | 716 | Binding(get: { workspace.lastError != nil || session.lastError != nil }, set: { _ in }) |
| 660 | 717 | } |
Sources/Orgstar/OrgstarApp.swift +18 −1
| @@ -401,6 +401,8 @@ struct OrgstarApp: App { | ||
| 401 | 401 | } |
| 402 | 402 | .commands { |
| 403 | 403 | CommandMenu("Org") { |
| 404 | ClockMenu(commands: commands, clock: clock) | |
| 405 | Divider() | |
| 404 | 406 | OrgMenuItems(commands: commands) |
| 405 | 407 | } |
| 406 | 408 | } |
| @@ -503,6 +505,8 @@ struct SettingsView: View { | ||
| 503 | 505 | @AppStorage("agendaSubfolders") private var agendaSubfolders = false |
| 504 | 506 | @AppStorage("reminders") private var reminders = true |
| 505 | 507 | @AppStorage("reminderLead") private var reminderLead = 12 |
| 508 | @AppStorage("clockIdleTime") private var clockIdleTime = 0 | |
| 509 | @AppStorage("clockHistoryLength") private var clockHistoryLength = 5 | |
| 506 | 510 | @AppStorage("globalCapture") private var globalCapture = true |
| 507 | 511 | @AppStorage("settingsTab") private var tab = "general" |
| 508 | 512 | @AppStorage("showHiddenFiles") private var showHiddenFiles = true |
| @@ -664,9 +668,22 @@ struct SettingsView: View { | ||
| 664 | 668 | Text("An entry's APPT_WARNTIME property overrides the lead time.") |
| 665 | 669 | .settingsFooter() |
| 666 | 670 | } |
| 671 | Section { | |
| 672 | Toggle("Ask what to do with idle time while clocked in", isOn: Binding { | |
| 673 | clockIdleTime > 0 | |
| 674 | } set: { on in | |
| 675 | clockIdleTime = on ? 15 : 0 | |
| 676 | }) | |
| 677 | Stepper("After \(max(clockIdleTime, 1)) minutes without keyboard or mouse input", value: $clockIdleTime, in: 1...240) | |
| 678 | .disabled(clockIdleTime == 0) | |
| 679 | Stepper("Remember \(clockHistoryLength) recently clocked entries", value: $clockHistoryLength, in: 1...35) | |
| 680 | } footer: { | |
| 681 | Text("org-clock-idle-time and org-clock-history-length. Org ▸ Clock lists the recent entries.") | |
| 682 | .settingsFooter() | |
| 683 | } | |
| 667 | 684 | } |
| 668 | 685 | .formStyle(.grouped) |
| 669 | .frame(height: 290) | |
| 686 | .frame(height: 430) | |
| 670 | 687 | } |
| 671 | 688 | |
| 672 | 689 | private var capture: some View { |