krz/orgstar

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

Sources/Orgstar/ClockViews.swift

b8d091bdcbf2889c220b12b2d0640d50cea17c24
orgstar/Sources/Orgstar/ClockViews.swift history · blame · raw

141 lines · 5576 bytes

  1import AppKit
  2import OrgApp
  3import OrgCore
  4import SwiftUI
  5
  6/// Elapsed time of the running clock, `H:MM`.
  7func clockElapsed(_ running: ClockModel.Running, now: Date) -> String {
  8    let minutes = max(0, Int(now.timeIntervalSince(running.startedAt) / 60))
  9    return String(format: "%d:%02d", minutes / 60, minutes % 60)
 10}
 11
 12/// The menu bar item while a clock runs: elapsed time, and clock out, cancel and go to.
 13@MainActor
 14final class ClockStatusItem: NSObject {
 15    private var item: NSStatusItem?
 16    private var timer: Timer?
 17    private var running: ClockModel.Running?
 18    private let run: (String) -> Void
 19
 20    init(run: @escaping (String) -> Void) {
 21        self.run = run
 22    }
 23
 24    func update(_ running: ClockModel.Running?) {
 25        self.running = running
 26        guard let running else {
 27            timer?.invalidate()
 28            timer = nil
 29            if let item { NSStatusBar.system.removeStatusItem(item) }
 30            item = nil
 31            return
 32        }
 33        if item == nil {
 34            item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
 35            timer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
 36                MainActor.assumeIsolated { self?.refresh() }
 37            }
 38        }
 39        let menu = NSMenu()
 40        menu.addItem(withTitle: running.heading, action: nil, keyEquivalent: "")
 41        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)
 47        }
 48        item?.menu = menu
 49        refresh()
 50    }
 51
 52    private func refresh() {
 53        guard let running else { return }
 54        item?.button?.title = "⏱ " + clockElapsed(running, now: Date())
 55    }
 56
 57    @objc private func choose(_ sender: NSMenuItem) {
 58        if let id = sender.representedObject as? String {
 59            NSApp.activate()
 60            run(id)
 61        }
 62    }
 63}
 64
 65/// Time per day and heading, as your `tr/generate-report` writes it, for chosen files and
 66/// dates.
 67struct ClockReportView: View {
 68    let workspace: WorkspaceModel
 69    @State private var start = Calendar.current.date(byAdding: .day, value: -6, to: Date()) ?? Date()
 70    @State private var end = Date()
 71    @State private var limit = false
 72    @State private var selected: Set<String> = []
 73    @State private var report = ""
 74
 75    private var candidates: [FileEntry] { workspace.files.filter { $0.kind == .org } }
 76
 77    var body: some View {
 78        HSplitView {
 79            List(candidates, selection: $selected) { file in
 80                Text(file.relativePath).tag(file.path)
 81            }
 82            .frame(minWidth: 180, idealWidth: 220, maxWidth: 320)
 83            VStack(alignment: .leading, spacing: 8) {
 84                HStack {
 85                    Toggle("From", isOn: $limit)
 86                    DatePicker("", selection: $start, displayedComponents: .date).labelsHidden().disabled(!limit)
 87                    Text("to")
 88                    DatePicker("", selection: $end, displayedComponents: .date).labelsHidden().disabled(!limit)
 89                    Spacer()
 90                    Button("Copy") {
 91                        NSPasteboard.general.clearContents()
 92                        NSPasteboard.general.setString(report, forType: .string)
 93                    }
 94                    Button("Save…", action: saveReport)
 95                }
 96                ScrollView([.horizontal, .vertical]) {
 97                    Text(report.isEmpty ? "Choose files with clocked time." : report)
 98                        .font(.body.monospaced())
 99                        .textSelection(.enabled)
100                        .fixedSize()
101                        .padding(4)
102                }
103                .defaultScrollAnchor(.topLeading)
104                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
105            }
106            .padding(12)
107        }
108        .navigationTitle("Clock Report")
109        .onAppear {
110            if selected.isEmpty {
111                selected = Set(candidates.filter { (try? String(contentsOfFile: $0.path, encoding: .utf8))?.contains("CLOCK: [") ?? false }.map(\.path))
112            }
113            compute()
114        }
115        .onChange(of: selected) { compute() }
116        .onChange(of: start) { compute() }
117        .onChange(of: end) { compute() }
118        .onChange(of: limit) { compute() }
119    }
120
121    private func day(_ date: Date) -> String {
122        let c = Calendar.current.dateComponents([.year, .month, .day], from: date)
123        return String(format: "%04d-%02d-%02d", c.year!, c.month!, c.day!)
124    }
125
126    private func compute() {
127        var entries: [ClockReport.Entry] = []
128        for path in selected.sorted() {
129            guard let text = try? String(contentsOfFile: path, encoding: .utf8) else { continue }
130            entries += ClockReport.entries(text, settings: workspace.settings.org)
131        }
132        report = selected.isEmpty ? "" : ClockReport.timeReport(entries, start: limit ? day(start) : nil, end: limit ? day(end) : nil)
133    }
134
135    private func saveReport() {
136        let panel = NSSavePanel()
137        panel.nameFieldStringValue = "time-report.org"
138        guard panel.runModal() == .OK, let url = panel.url else { return }
139        try? report.write(to: url, atomically: true, encoding: .utf8)
140    }
141}