krz/orgstar

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

Sources/Orgstar/InspectorPane.swift

b98a6509c5ae75e5172dd333d1b6105fd6ceee0e
orgstar/Sources/Orgstar/InspectorPane.swift history · blame · raw

145 lines · 5928 bytes

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