Sources/Orgstar/InspectorPane.swift
145 lines · 5928 bytes
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 .accessibilityAddTraits(.isButton)
77 .accessibilityAction { session.reveal(row.start) }
78 }
79 }
80 .font(.system(.callout, design: .monospaced))
81 .padding(8)
82 }
83 .defaultScrollAnchor(.topLeading)
84 } else {
85 ContentUnavailableView("No entries", systemImage: "tablecells")
86 }
87 }
88
89 @ViewBuilder
90 private var clockView: some View {
91 Picker("Span", selection: $span) {
92 ForEach(Span.allCases, id: \.self) { Text($0.rawValue) }
93 }
94 .pickerStyle(.segmented)
95 .padding(.horizontal, 8)
96 .padding(.bottom, 6)
97 if clocked.isEmpty {
98 ContentUnavailableView("No clocked time", systemImage: "clock")
99 } else {
100 List {
101 ForEach(clocked, id: \.start) { entry in
102 HStack {
103 Text(entry.heading.isEmpty ? "(untitled)" : entry.heading).lineLimit(1)
104 Spacer()
105 Text(ClockReport.hours(entry.minutes)).monospacedDigit()
106 }
107 .fontWeight(entry.start == heading ? .semibold : .regular)
108 .contentShape(Rectangle())
109 .onTapGesture { session.reveal(entry.start) }
110 .accessibilityElement(children: .combine)
111 .accessibilityAddTraits(.isButton)
112 .accessibilityAction { session.reveal(entry.start) }
113 }
114 HStack {
115 Text("Total").bold()
116 Spacer()
117 Text(ClockReport.hours(clocked.reduce(0) { $0 + $1.minutes })).bold().monospacedDigit()
118 }
119 }
120 }
121 }
122
123 private func refresh() {
124 guard session.isOrg, let document = session.document else {
125 columns = nil
126 clocked = []
127 return
128 }
129 let text = document.text
130 columns = ColumnView.capture(text: text, tree: document.tree, at: heading ?? 0, global: !subtree || heading == nil).view
131 let formatter = DateFormatter()
132 formatter.locale = Locale(identifier: "en_US_POSIX")
133 formatter.dateFormat = "yyyy-MM-dd"
134 // Weeks start on Monday, as org's do.
135 var calendar = Calendar.current
136 calendar.firstWeekday = 2
137 let today = Date()
138 let from: Date? = switch span {
139 case .today: today
140 case .week: calendar.dateInterval(of: .weekOfYear, for: today)?.start
141 case .all: nil
142 }
143 clocked = ClockReport.byHeading(ClockReport.entries(text), from: from.map(formatter.string), to: span == .all ? nil : formatter.string(from: today))
144 }
145}