Sources/Orgstar/ClockViews.swift
205 lines · 7769 bytes
1import AppKit
2import CoreGraphics
3import OrgApp
4import OrgCore
5import OrgEditorAppKit
6import SwiftUI
7
8/// Elapsed time of the running clock, `H:MM`.
9func clockElapsed(_ running: ClockModel.Running, now: Date) -> String {
10 let minutes = max(0, Int(now.timeIntervalSince(running.startedAt) / 60))
11 return String(format: "%d:%02d", minutes / 60, minutes % 60)
12}
13
14/// What the menu bar item shows.
15struct 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.
21enum 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`.
29struct 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.
46struct 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
65/// The menu bar item while a clock runs: elapsed time, and clock out, cancel and go to.
66@MainActor
67final class ClockStatusItem: NSObject {
68 private var item: NSStatusItem?
69 private var timer: Timer?
70 private var running: ClockModel.Running?
71 private let run: (String) -> Void
72
73 init(run: @escaping (String) -> Void) {
74 self.run = run
75 }
76
77 func update(_ running: ClockModel.Running?, recent: [String] = []) {
78 self.running = running
79 guard let running else {
80 timer?.invalidate()
81 timer = nil
82 if let item { NSStatusBar.system.removeStatusItem(item) }
83 item = nil
84 return
85 }
86 if item == nil {
87 item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
88 timer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
89 MainActor.assumeIsolated { self?.refresh() }
90 }
91 }
92 let menu = NSMenu()
93 menu.addItem(withTitle: running.heading, action: nil, keyEquivalent: "")
94 menu.addItem(.separator())
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) }
105 }
106 item?.menu = menu
107 refresh()
108 }
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
117 private func refresh() {
118 guard let running else { return }
119 item?.button?.title = "⏱ " + clockElapsed(running, now: Date())
120 }
121
122 @objc private func choose(_ sender: NSMenuItem) {
123 if let id = sender.representedObject as? String {
124 NSApp.activate()
125 run(id)
126 }
127 }
128}
129
130/// Clocked time per day and heading (`ClockReport.timeReport`) for chosen files and dates.
131struct ClockReportView: View {
132 let workspace: WorkspaceModel
133 @State private var start = Calendar.current.date(byAdding: .day, value: -6, to: Date()) ?? Date()
134 @State private var end = Date()
135 @State private var limit = false
136 @State private var selected: Set<String> = []
137 @State private var report = ""
138
139 private var candidates: [FileEntry] { workspace.files.filter { $0.kind == .org } }
140
141 var body: some View {
142 HSplitView {
143 List(candidates, selection: $selected) { file in
144 Text(file.relativePath).tag(file.path)
145 }
146 .frame(minWidth: 180, idealWidth: 220, maxWidth: 320)
147 VStack(alignment: .leading, spacing: 8) {
148 HStack {
149 Toggle("From", isOn: $limit)
150 DatePicker("", selection: $start, displayedComponents: .date).labelsHidden().disabled(!limit)
151 Text("to")
152 DatePicker("", selection: $end, displayedComponents: .date).labelsHidden().disabled(!limit)
153 Spacer()
154 Button("Copy") {
155 NSPasteboard.general.clearContents()
156 NSPasteboard.general.setString(report, forType: .string)
157 }
158 Button("Save…", action: saveReport)
159 }
160 ScrollView([.horizontal, .vertical]) {
161 Text(report.isEmpty ? "Choose files with clocked time." : report)
162 .font(.body.monospaced())
163 .textSelection(.enabled)
164 .fixedSize()
165 .padding(4)
166 }
167 .defaultScrollAnchor(.topLeading)
168 .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
169 }
170 .padding(12)
171 }
172 .navigationTitle("Clock Report")
173 .onAppear {
174 if selected.isEmpty {
175 selected = Set(candidates.filter { (try? String(contentsOfFile: $0.path, encoding: .utf8))?.contains("CLOCK: [") ?? false }.map(\.path))
176 }
177 compute()
178 }
179 .onChange(of: selected) { compute() }
180 .onChange(of: start) { compute() }
181 .onChange(of: end) { compute() }
182 .onChange(of: limit) { compute() }
183 }
184
185 private func day(_ date: Date) -> String {
186 let c = Calendar.current.dateComponents([.year, .month, .day], from: date)
187 return String(format: "%04d-%02d-%02d", c.year!, c.month!, c.day!)
188 }
189
190 private func compute() {
191 var entries: [ClockReport.Entry] = []
192 for path in selected.sorted() {
193 guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { continue }
194 entries += ClockReport.entries(text, settings: workspace.settings.org)
195 }
196 report = selected.isEmpty ? "" : ClockReport.timeReport(entries, start: limit ? day(start) : nil, end: limit ? day(end) : nil)
197 }
198
199 private func saveReport() {
200 let panel = NSSavePanel()
201 panel.nameFieldStringValue = "time-report.org"
202 guard panel.runModal() == .OK, let url = panel.url else { return }
203 try? report.write(to: url, atomically: true, encoding: .utf8)
204 }
205}