krz/orgstar

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

Sources/Orgstar/BoardView.swift

191ddb506f3a91b93c755ba6e8123a4e8ac4f90d
orgstar/Sources/Orgstar/BoardView.swift history · blame · raw

248 lines · 10922 bytes

  1import OrgApp
  2import OrgCore
  3import OrgPresentation
  4import SwiftUI
  5import UniformTypeIdentifiers
  6
  7/// Entries of the agenda files as a sortable table with property columns, or as a kanban
  8/// board by TODO keyword. Dragging a card to a column sets its keyword; edits go through the
  9/// open buffer or the file.
 10struct BoardView: View {
 11    let workspace: WorkspaceModel
 12    let session: DocumentSession
 13    let commands: AppCommands
 14    @State private var agenda: AgendaModel
 15    @State private var entries: [BoardEntry] = []
 16    @State private var keywords: [String] = []
 17    @State private var selection: Set<String> = []
 18    @State private var sortOrder = [KeyPathComparator(\BoardRow.todoOrder), KeyPathComparator(\BoardRow.title)]
 19    @State private var message: String?
 20    @State private var tick = 0
 21    @AppStorage("boardKanban") private var kanban = false
 22    @AppStorage("boardMatch") private var match = ""
 23    @AppStorage("boardColumns") private var columnText = "EFFORT"
 24    @AppStorage("agendaSubfolders") private var includeSubfolders = false
 25    @Environment(\.openWindow) private var openWindow
 26    @Environment(\.orgTheme) private var theme
 27
 28    init(workspace: WorkspaceModel, session: DocumentSession, commands: AppCommands) {
 29        self.workspace = workspace
 30        self.session = session
 31        self.commands = commands
 32        _agenda = State(initialValue: AgendaModel(workspace: workspace))
 33    }
 34
 35    private var propertyColumns: [String] {
 36        columnText.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces).uppercased() }.filter { !$0.isEmpty }
 37    }
 38
 39    private var rows: [BoardRow] {
 40        entries.map { BoardRow(entry: $0, todoOrder: $0.todo.flatMap { keywords.firstIndex(of: $0) } ?? Int.max) }.sorted(using: sortOrder)
 41    }
 42
 43    private struct RefreshKey: Hashable {
 44        var match: String
 45        var subfolders: Bool
 46        var files: Int
 47        var changed: Int?
 48        var edits: Int
 49        var path: String?
 50        var tick: Int
 51    }
 52
 53    var body: some View {
 54        Group {
 55            if kanban { board } else { table }
 56        }
 57        .safeAreaInset(edge: .bottom, spacing: 0) {
 58            if let message {
 59                Text(message).font(.callout).frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal, 12).padding(.vertical, 6).background(.bar)
 60            }
 61        }
 62        .toolbar {
 63            ToolbarItemGroup(placement: .navigation) {
 64                Picker("View", selection: $kanban) {
 65                    Label("Table", systemImage: "tablecells").tag(false)
 66                    Label("Kanban", systemImage: "rectangle.split.3x1").tag(true)
 67                }
 68                .pickerStyle(.segmented)
 69                TextField("Match: +work/!TODO", text: $match)
 70                    .frame(minWidth: 200)
 71                if !kanban {
 72                    TextField("Property columns", text: $columnText)
 73                        .frame(minWidth: 140)
 74                        .help("Property names to show as columns, separated by commas")
 75                }
 76            }
 77        }
 78        .navigationTitle(kanban ? "Kanban" : "Entries")
 79        .task(id: RefreshKey(match: match, subfolders: includeSubfolders, files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion, path: session.path, tick: tick)) {
 80            agenda.includeSubfolders = includeSubfolders
 81            let open = session.openTexts
 82            let result = await agenda.board(open: open, match: match)
 83            entries = result.entries
 84            keywords = result.keywords
 85        }
 86    }
 87
 88    // MARK: - Table
 89
 90    private var table: some View {
 91        Table(rows, selection: $selection, sortOrder: $sortOrder) {
 92            TableColumn("TODO", value: \.todoOrder) { row in
 93                Text(row.entry.todo ?? "").foregroundStyle(theme.keywordColor(row.entry.todo ?? "", done: row.entry.isDone)).bold()
 94            }
 95            .width(min: 50, ideal: 60, max: 90)
 96            TableColumn("P", value: \.priority) { row in Text(row.priority) }
 97                .width(min: 20, ideal: 24, max: 40)
 98            TableColumn("Title", value: \.title) { row in Text(row.title).lineLimit(1) }
 99                .width(min: 160, ideal: 320)
100            TableColumn("Tags", value: \.tags) { row in Text(row.tags).foregroundStyle(.secondary).lineLimit(1) }
101                .width(min: 60, ideal: 120)
102            TableColumn("Scheduled", value: \.scheduled) { row in Text(row.scheduled).monospacedDigit() }
103                .width(min: 80, ideal: 110)
104            TableColumn("Deadline", value: \.deadline) { row in Text(row.deadline).monospacedDigit() }
105                .width(min: 80, ideal: 110)
106            TableColumnForEach(propertyColumns, id: \.self) { key in
107                TableColumn(key.capitalized) { (row: BoardRow) in Text(row.entry.properties[key] ?? "") }
108                    .width(min: 50, ideal: 80)
109            }
110            TableColumn("File", value: \.file) { row in Text(row.file).foregroundStyle(.secondary).lineLimit(1) }
111                .width(min: 60, ideal: 120)
112        }
113        .contextMenu(forSelectionType: String.self) { ids in
114            if let entry = entries.first(where: { ids.contains($0.id) }) {
115                Menu("Set TODO") {
116                    ForEach(keywords, id: \.self) { keyword in Button(keyword) { setTodo(entry, keyword) } }
117                    Divider()
118                    Button("None") { setTodo(entry, nil) }
119                }
120                Button("Set Property…") { askProperty(entry) }
121                Button("Open") { jump(entry) }
122            }
123        } primaryAction: { ids in
124            if let entry = entries.first(where: { ids.contains($0.id) }) { jump(entry) }
125        }
126    }
127
128    // MARK: - Kanban
129
130    private var board: some View {
131        ScrollView(.horizontal) {
132            HStack(alignment: .top, spacing: 12) {
133                ForEach(keywords, id: \.self) { keyword in
134                    column(keyword)
135                }
136            }
137            .padding(12)
138        }
139        .background(theme.color("agenda-background") ?? .clear)
140    }
141
142    private func column(_ keyword: String) -> some View {
143        let cards = entries.filter { $0.todo == keyword }
144        return VStack(alignment: .leading, spacing: 8) {
145            HStack {
146                Text(keyword).font(.headline).foregroundStyle(theme.keywordColor(keyword, done: cards.first?.isDone ?? false))
147                Text("\(cards.count)").foregroundStyle(.secondary)
148            }
149            ScrollView {
150                VStack(alignment: .leading, spacing: 6) {
151                    ForEach(cards) { entry in
152                        card(entry)
153                            .draggable(entry.id)
154                            .onTapGesture(count: 2) { jump(entry) }
155                            .accessibilityElement(children: .combine)
156                            .accessibilityAddTraits(.isButton)
157                            .accessibilityAction { jump(entry) }
158                            .accessibilityActions {
159                                // Dragging between columns, for VoiceOver.
160                                ForEach(keywords.filter { $0 != keyword }, id: \.self) { other in
161                                    Button("Move to \(other)") { setTodo(entry, other) }
162                                }
163                            }
164                    }
165                }
166            }
167        }
168        .frame(width: 240, alignment: .topLeading)
169        .frame(maxHeight: .infinity, alignment: .top)
170        .padding(8)
171        .background(RoundedRectangle(cornerRadius: 8).fill(Color.secondary.opacity(0.08)))
172        .dropDestination(for: String.self) { ids, _ in
173            for id in ids {
174                if let entry = entries.first(where: { $0.id == id }), entry.todo != keyword { setTodo(entry, keyword) }
175            }
176            return true
177        }
178    }
179
180    private func card(_ entry: BoardEntry) -> some View {
181        VStack(alignment: .leading, spacing: 4) {
182            Text((entry.priority.map { "[#\($0)] " } ?? "") + entry.title).lineLimit(3)
183            HStack(spacing: 6) {
184                Text(entry.category).font(.caption).foregroundStyle(.secondary)
185                if !entry.tags.isEmpty {
186                    Text(entry.tags.joined(separator: " ")).font(.caption).foregroundStyle(.secondary).lineLimit(1)
187                }
188            }
189            if let deadline = entry.deadline {
190                Text("Deadline \(deadline)").font(.caption).foregroundStyle(.red)
191            } else if let scheduled = entry.scheduled {
192                Text("Scheduled \(scheduled)").font(.caption).foregroundStyle(.secondary)
193            }
194        }
195        .padding(8)
196        .frame(maxWidth: .infinity, alignment: .leading)
197        .background(RoundedRectangle(cornerRadius: 6).fill(Color(nsColor: .controlBackgroundColor)))
198    }
199
200    // MARK: - Editing
201
202    private func perform(_ command: any OrgCommand, on entry: BoardEntry, answers: [String: String] = [:]) {
203        switch session.perform(command, path: entry.path, at: entry.headingOffset, expect: (entry.headingOffset, entry.headingLine), answers: answers) {
204        case .failed(let text): message = text
205        case .prompt(let prompt):
206            // A state note: asked in the main window's echo area.
207            commands.ask(prompt) { answer in
208                guard let answer else { return }
209                perform(command, on: entry, answers: answers.merging([prompt.key: answer]) { $1 })
210            }
211            openWindow(id: "main")
212        default: message = nil
213        }
214        tick += 1
215    }
216
217    private func setTodo(_ entry: BoardEntry, _ keyword: String?) {
218        perform(SetTodo(keyword: keyword), on: entry)
219    }
220
221    private func askProperty(_ entry: BoardEntry) {
222        commands.ask(Prompt(key: "property", message: "Property (NAME value):")) { answer in
223            guard let answer, let space = answer.firstIndex(of: " ") else { return }
224            let key = String(answer[..<space])
225            let value = String(answer[answer.index(after: space)...]).trimmingCharacters(in: .whitespaces)
226            perform(SetProperty(key: key, value: value), on: entry)
227        }
228        openWindow(id: "main")
229    }
230
231    private func jump(_ entry: BoardEntry) {
232        commands.jump(path: entry.path, offset: entry.headingOffset)
233        openWindow(id: "main")
234    }
235}
236
237/// A table row with sortable values.
238struct BoardRow: Identifiable {
239    let entry: BoardEntry
240    let todoOrder: Int
241    var id: String { entry.id }
242    var title: String { entry.title }
243    var priority: String { entry.priority ?? "" }
244    var tags: String { entry.tags.joined(separator: " ") }
245    var scheduled: String { entry.scheduled ?? "" }
246    var deadline: String { entry.deadline ?? "" }
247    var file: String { (entry.path as NSString).lastPathComponent }
248}