Columns and clock inspector !106

merged merged by cmc on 2026-10-06 07:11 UTC · krz/orgstar:inspector into main

6 files changed, +172 −2

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 @@
1import OrgApp
2import OrgCore
3import SwiftUI
4
5/// The inspector beside the editor: column view and clocked time for the open file, kept
6/// current as it changes.
7struct 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)")