krz/orgstar

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

Sources/Orgstar/ClockViews.swift

778058aafc7ddacee4a613b1da87c60aa0c8e113
orgstar/Sources/Orgstar/ClockViews.swift history · blame · raw

205 lines · 7769 bytes

15 symbols in this file
  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}