krz/orgstar

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

Sources/Orgstar/BoardView.swift

abca8dc581329472f7fb8ccd3af147e5fb882f29
orgstar/Sources/Orgstar/BoardView.swift history · blame · raw

229 lines · 9760 bytes

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