Commit daac3706d5
Verified · cmc
Layout: unified · split
Sources/OrgCore/Commands/ClockReport.swift +15 −2
| @@ -8,6 +8,8 @@ public enum ClockReport { | |||
| 8 | /// The heading's title, without keyword, priority, tags or COMMENT. | 8 | /// The heading's title, without keyword, priority, tags or COMMENT. |
| 9 | public let heading: String | 9 | public let heading: String |
| 10 | public let minutes: Int | 10 | public let minutes: Int |
| 11 | /// Where the heading starts. | ||
| 12 | public let start: Int | ||
| 11 | } | 13 | } |
| 12 | 14 | ||
| 13 | static let clockLine = try! NSRegularExpression(pattern: | 15 | static let clockLine = try! NSRegularExpression(pattern: |
| @@ -32,12 +34,23 @@ public enum ClockReport { | |||
| 32 | var title = model.headings[low - 1].title.trimmingCharacters(in: .whitespaces) | 34 | var title = model.headings[low - 1].title.trimmingCharacters(in: .whitespaces) |
| 33 | if title == "COMMENT" { title = "" } else if title.hasPrefix("COMMENT ") { title = String(title.dropFirst(8)) } | 35 | if title == "COMMENT" { title = "" } else if title.hasPrefix("COMMENT ") { title = String(title.dropFirst(8)) } |
| 34 | let minutes = Int(ns.substring(with: m.range(at: 4)))! * 60 + Int(ns.substring(with: m.range(at: 5)))! | 36 | let minutes = Int(ns.substring(with: m.range(at: 4)))! * 60 + Int(ns.substring(with: m.range(at: 5)))! |
| 35 | result.append(Entry(date: ns.substring(with: m.range(at: 1)), heading: title, minutes: minutes)) | 37 | result.append(Entry(date: ns.substring(with: m.range(at: 1)), heading: title, minutes: minutes, start: starts[low - 1])) |
| 36 | } | 38 | } |
| 37 | return result | 39 | return result |
| 38 | } | 40 | } |
| 39 | 41 | ||
| 40 | static func hours(_ minutes: Int) -> String { String(format: "%d:%02d", minutes / 60, minutes % 60) } | 42 | /// Minutes per heading between `from` and `to` (inclusive, `YYYY-MM-DD`), in file order. |
| 43 | public static func byHeading(_ entries: [Entry], from: String? = nil, to: String? = nil) -> [(start: Int, heading: String, minutes: Int)] { | ||
| 44 | var totals: [Int: (heading: String, minutes: Int)] = [:] | ||
| 45 | for entry in entries { | ||
| 46 | if let from, entry.date < from { continue } | ||
| 47 | if let to, entry.date > to { continue } | ||
| 48 | totals[entry.start, default: (entry.heading, 0)].minutes += entry.minutes | ||
| 49 | } | ||
| 50 | return totals.keys.sorted().map { (start: $0, heading: totals[$0]!.heading, minutes: totals[$0]!.minutes) } | ||
| 51 | } | ||
| 52 | |||
| 53 | public static func hours(_ minutes: Int) -> String { String(format: "%d:%02d", minutes / 60, minutes % 60) } | ||
| 41 | 54 | ||
| 42 | /// Emacs `format "%-Ns"` pads by characters. | 55 | /// Emacs `format "%-Ns"` pads by characters. |
| 43 | static func pad(_ s: String, _ width: Int) -> String { | 56 | static func pad(_ s: String, _ width: Int) -> String { |
Sources/OrgEditorAppKit/EditorCommands.swift +1
| @@ -69,6 +69,7 @@ public enum EditorCommands { | |||
| 69 | EditorCommand(id: "app.toggle-tab-bar", title: "Show or Hide Tab Bar", action: .app), | 69 | EditorCommand(id: "app.toggle-tab-bar", title: "Show or Hide Tab Bar", action: .app), |
| 70 | EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app), | 70 | EditorCommand(id: "app.toggle-outline", title: "Show or Hide Outline", action: .app), |
| 71 | EditorCommand(id: "app.toggle-backlinks", title: "Show or Hide Backlinks", action: .app), | 71 | EditorCommand(id: "app.toggle-backlinks", title: "Show or Hide Backlinks", action: .app), |
| 72 | EditorCommand(id: "app.toggle-inspector", title: "Show or Hide Columns and Clock", action: .app), | ||
| 72 | EditorCommand(id: "app.toggle-markup", title: "Show or Hide Markup", action: .app), | 73 | EditorCommand(id: "app.toggle-markup", title: "Show or Hide Markup", action: .app), |
| 73 | EditorCommand(id: "app.edit-config", title: "Edit Config File", action: .app), | 74 | EditorCommand(id: "app.edit-config", title: "Edit Config File", action: .app), |
| 74 | EditorCommand(id: "app.import-emacs", title: "Import from Emacs…", action: .app), | 75 | EditorCommand(id: "app.import-emacs", title: "Import from Emacs…", action: .app), |
Sources/Orgstar/ContentView.swift +6
| @@ -69,6 +69,7 @@ struct ContentView: View { | |||
| 69 | @AppStorage("outlineShown") private var orgOutlineShown = true | 69 | @AppStorage("outlineShown") private var orgOutlineShown = true |
| 70 | @AppStorage("outlineWidth") private var outlineWidth = 220.0 | 70 | @AppStorage("outlineWidth") private var outlineWidth = 220.0 |
| 71 | @AppStorage("backlinksShown") private var backlinksShown = true | 71 | @AppStorage("backlinksShown") private var backlinksShown = true |
| 72 | @AppStorage("inspectorShown") private var inspectorShown = false | ||
| 72 | @AppStorage("showMarkup") private var showMarkup = false | 73 | @AppStorage("showMarkup") private var showMarkup = false |
| 73 | @AppStorage("showLineNumbers") private var showLineNumbers = true | 74 | @AppStorage("showLineNumbers") private var showLineNumbers = true |
| 74 | @AppStorage("showHiddenFiles") private var showHiddenFiles = true | 75 | @AppStorage("showHiddenFiles") private var showHiddenFiles = true |
| @@ -196,6 +197,10 @@ struct ContentView: View { | |||
| 196 | } | 197 | } |
| 197 | detail | 198 | detail |
| 198 | .frame(maxWidth: .infinity) | 199 | .frame(maxWidth: .infinity) |
| 200 | .inspector(isPresented: $inspectorShown) { | ||
| 201 | InspectorPane(session: session, commands: commands) | ||
| 202 | .inspectorColumnWidth(min: 240, ideal: 340, max: 600) | ||
| 203 | } | ||
| 199 | } | 204 | } |
| 200 | } | 205 | } |
| 201 | .navigationTitle((previewPath ?? session.path).map { ($0 as NSString).lastPathComponent } ?? "Orgstar") | 206 | .navigationTitle((previewPath ?? session.path).map { ($0 as NSString).lastPathComponent } ?? "Orgstar") |
| @@ -547,6 +552,7 @@ struct ContentView: View { | |||
| 547 | if session.isRunningBabel { session.cancelBabel() } else { commands.show("No block is running") } | 552 | if session.isRunningBabel { session.cancelBabel() } else { commands.show("No block is running") } |
| 548 | case "app.toggle-outline": toggleOutline() | 553 | case "app.toggle-outline": toggleOutline() |
| 549 | case "app.toggle-backlinks": backlinksShown.toggle() | 554 | case "app.toggle-backlinks": backlinksShown.toggle() |
| 555 | case "app.toggle-inspector": inspectorShown.toggle() | ||
| 550 | case "app.toggle-markup": showMarkup.toggle() | 556 | case "app.toggle-markup": showMarkup.toggle() |
| 551 | case "app.toggle-line-numbers": showLineNumbers.toggle() | 557 | case "app.toggle-line-numbers": showLineNumbers.toggle() |
| 552 | case "app.import-emacs": showingImport = true | 558 | case "app.import-emacs": showingImport = true |
Sources/Orgstar/InspectorPane.swift added +140
| @@ -0,0 +1,140 @@ | |||
| 1 | import OrgApp | ||
| 2 | import OrgCore | ||
| 3 | import SwiftUI | ||
| 4 | |||
| 5 | /// The inspector beside the editor: column view and clocked time for the open file, kept | ||
| 6 | /// current as it changes. | ||
| 7 | struct InspectorPane: View { | ||
| 8 | let session: DocumentSession | ||
| 9 | let commands: AppCommands | ||
| 10 | @AppStorage("inspectorTab") private var tab = Tab.columns | ||
| 11 | @AppStorage("inspectorColumnsScope") private var subtree = false | ||
| 12 | @AppStorage("inspectorClockSpan") private var span = Span.week | ||
| 13 | @State private var columns: ColumnView.View? | ||
| 14 | @State private var clocked: [(start: Int, heading: String, minutes: Int)] = [] | ||
| 15 | |||
| 16 | enum Tab: String, CaseIterable { case columns = "Columns", clock = "Clock" } | ||
| 17 | enum Span: String, CaseIterable { case today = "Today", week = "This Week", all = "All" } | ||
| 18 | |||
| 19 | private struct Key: Equatable { | ||
| 20 | var path: String? | ||
| 21 | var version: Int | ||
| 22 | var caret: Int? | ||
| 23 | var subtree: Bool | ||
| 24 | var span: Span | ||
| 25 | } | ||
| 26 | |||
| 27 | /// The heading at the caret, for a subtree's columns and the current row. | ||
| 28 | private var heading: Int? { session.outline.last { $0.start <= commands.selection.location }?.start } | ||
| 29 | |||
| 30 | var body: some View { | ||
| 31 | VStack(spacing: 0) { | ||
| 32 | Picker("", selection: $tab) { | ||
| 33 | ForEach(Tab.allCases, id: \.self) { Text($0.rawValue) } | ||
| 34 | } | ||
| 35 | .pickerStyle(.segmented) | ||
| 36 | .labelsHidden() | ||
| 37 | .padding(8) | ||
| 38 | Divider() | ||
| 39 | switch tab { | ||
| 40 | case .columns: columnsView | ||
| 41 | case .clock: clockView | ||
| 42 | } | ||
| 43 | } | ||
| 44 | .task(id: Key(path: session.path, version: session.editVersion, caret: subtree ? heading : nil, subtree: subtree, span: span)) { refresh() } | ||
| 45 | } | ||
| 46 | |||
| 47 | @ViewBuilder | ||
| 48 | private var columnsView: some View { | ||
| 49 | Picker("Scope", selection: $subtree) { | ||
| 50 | Text("File").tag(false) | ||
| 51 | Text("Subtree").tag(true) | ||
| 52 | } | ||
| 53 | .pickerStyle(.segmented) | ||
| 54 | .padding(.horizontal, 8) | ||
| 55 | .padding(.bottom, 6) | ||
| 56 | if let columns, !columns.rows.isEmpty { | ||
| 57 | ScrollView([.horizontal, .vertical]) { | ||
| 58 | Grid(alignment: .leading, horizontalSpacing: 10, verticalSpacing: 3) { | ||
| 59 | GridRow { | ||
| 60 | ForEach(columns.specs.indices, id: \.self) { i in | ||
| 61 | Text(columns.specs[i].title).bold().foregroundStyle(.secondary) | ||
| 62 | } | ||
| 63 | } | ||
| 64 | Divider() | ||
| 65 | ForEach(columns.rows.indices, id: \.self) { r in | ||
| 66 | let row = columns.rows[r] | ||
| 67 | GridRow { | ||
| 68 | ForEach(columns.specs.indices, id: \.self) { i in | ||
| 69 | Text(columns.specs[i].property == "ITEM" ? String(repeating: "*", count: row.level) + " " + row.values[i] : row.values[i]) | ||
| 70 | .lineLimit(1) | ||
| 71 | .fontWeight(row.start == heading ? .semibold : .regular) | ||
| 72 | } | ||
| 73 | } | ||
| 74 | .contentShape(Rectangle()) | ||
| 75 | .onTapGesture { session.reveal(row.start) } | ||
| 76 | } | ||
| 77 | } | ||
| 78 | .font(.system(.callout, design: .monospaced)) | ||
| 79 | .padding(8) | ||
| 80 | } | ||
| 81 | .defaultScrollAnchor(.topLeading) | ||
| 82 | } else { | ||
| 83 | ContentUnavailableView("No entries", systemImage: "tablecells") | ||
| 84 | } | ||
| 85 | } | ||
| 86 | |||
| 87 | @ViewBuilder | ||
| 88 | private var clockView: some View { | ||
| 89 | Picker("Span", selection: $span) { | ||
| 90 | ForEach(Span.allCases, id: \.self) { Text($0.rawValue) } | ||
| 91 | } | ||
| 92 | .pickerStyle(.segmented) | ||
| 93 | .padding(.horizontal, 8) | ||
| 94 | .padding(.bottom, 6) | ||
| 95 | if clocked.isEmpty { | ||
| 96 | ContentUnavailableView("No clocked time", systemImage: "clock") | ||
| 97 | } else { | ||
| 98 | List { | ||
| 99 | ForEach(clocked, id: \.start) { entry in | ||
| 100 | HStack { | ||
| 101 | Text(entry.heading.isEmpty ? "(untitled)" : entry.heading).lineLimit(1) | ||
| 102 | Spacer() | ||
| 103 | Text(ClockReport.hours(entry.minutes)).monospacedDigit() | ||
| 104 | } | ||
| 105 | .fontWeight(entry.start == heading ? .semibold : .regular) | ||
| 106 | .contentShape(Rectangle()) | ||
| 107 | .onTapGesture { session.reveal(entry.start) } | ||
| 108 | } | ||
| 109 | HStack { | ||
| 110 | Text("Total").bold() | ||
| 111 | Spacer() | ||
| 112 | Text(ClockReport.hours(clocked.reduce(0) { $0 + $1.minutes })).bold().monospacedDigit() | ||
| 113 | } | ||
| 114 | } | ||
| 115 | } | ||
| 116 | } | ||
| 117 | |||
| 118 | private func refresh() { | ||
| 119 | guard session.isOrg, let document = session.document else { | ||
| 120 | columns = nil | ||
| 121 | clocked = [] | ||
| 122 | return | ||
| 123 | } | ||
| 124 | let text = document.text | ||
| 125 | columns = ColumnView.capture(text: text, tree: document.tree, at: heading ?? 0, global: !subtree || heading == nil).view | ||
| 126 | let formatter = DateFormatter() | ||
| 127 | formatter.locale = Locale(identifier: "en_US_POSIX") | ||
| 128 | formatter.dateFormat = "yyyy-MM-dd" | ||
| 129 | // Weeks start on Monday, as org's do. | ||
| 130 | var calendar = Calendar.current | ||
| 131 | calendar.firstWeekday = 2 | ||
| 132 | let today = Date() | ||
| 133 | let from: Date? = switch span { | ||
| 134 | case .today: today | ||
| 135 | case .week: calendar.dateInterval(of: .weekOfYear, for: today)?.start | ||
| 136 | case .all: nil | ||
| 137 | } | ||
| 138 | clocked = ClockReport.byHeading(ClockReport.entries(text), from: from.map(formatter.string), to: span == .all ? nil : formatter.string(from: today)) | ||
| 139 | } | ||
| 140 | } | ||
Sources/Orgstar/OrgstarApp.swift +2
| @@ -350,6 +350,8 @@ struct OrgstarApp: App { | |||
| 350 | CommandGroup(after: .sidebar) { | 350 | CommandGroup(after: .sidebar) { |
| 351 | Button("Show or Hide Outline") { commands.run("app.toggle-outline") } | 351 | Button("Show or Hide Outline") { commands.run("app.toggle-outline") } |
| 352 | Button("Show or Hide Backlinks") { commands.run("app.toggle-backlinks") } | 352 | Button("Show or Hide Backlinks") { commands.run("app.toggle-backlinks") } |
| 353 | Button("Show or Hide Columns and Clock") { commands.run("app.toggle-inspector") } | ||
| 354 | .keyboardShortcut("i", modifiers: [.command, .option]) | ||
| 353 | .keyboardShortcut("o", modifiers: [.command, .option]) | 355 | .keyboardShortcut("o", modifiers: [.command, .option]) |
| 354 | MarkupToggle() | 356 | MarkupToggle() |
| 355 | } | 357 | } |
Tests/OrgCoreTests/ClockTests.swift +8
| @@ -88,6 +88,14 @@ struct ClockReportTests { | |||
| 88 | :END: | 88 | :END: |
| 89 | """ | 89 | """ |
| 90 | 90 | ||
| 91 | @Test func totalsPerHeading() { | ||
| 92 | let entries = ClockReport.entries(Self.sample) | ||
| 93 | let all = ClockReport.byHeading(entries) | ||
| 94 | #expect(all.map(\.heading) == ["Audit work", "Meetings"] && all.map(\.minutes) == [418, 57]) | ||
| 95 | #expect(all[1].start == (Self.sample as NSString).range(of: "* Meetings").location) | ||
| 96 | #expect(ClockReport.byHeading(entries, from: "2026-02-18", to: "2026-02-24").map(\.minutes) == [360, 30]) | ||
| 97 | } | ||
| 98 | |||
| 91 | /// Your own `tr/generate-report`, loaded from config.el in batch Emacs. | 99 | /// Your own `tr/generate-report`, loaded from config.el in batch Emacs. |
| 92 | static func emacsReport(_ text: String, start: String?, end: String?) throws -> String { | 100 | static func emacsReport(_ text: String, start: String?, end: String?) throws -> String { |
| 93 | let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-tr-\(UUID().uuidString)") | 101 | let folder = FileManager.default.temporaryDirectory.appendingPathComponent("orgstar-tr-\(UUID().uuidString)") |