Sources/Orgstar/AgendaView.swift
1251 lines · 54630 bytes
1import AppKit
2import OrgApp
3import OrgDocument
4import OrgCore
5import OrgPresentation
6import SwiftUI
7
8/// The days an agenda shows, as Org's `v d`, `v w` and `v m`.
9enum AgendaSpan: String, CaseIterable {
10 case day, week, month
11}
12
13/// The app's one reader of Calendar events, for the agenda and Settings.
14@MainActor
15enum CalendarAccess {
16 static let events = CalendarEvents()
17}
18
19/// The agenda window: the day/week agenda, the TODO list, tag matches, text search and saved views.
20/// Return or a double click jumps to the entry in the main window. Keys as in Emacs's agenda:
21/// n/p (or the arrows) move between lines, f/b/. move the span, j picks a date, v d/v w/v m (or
22/// d/w) show a day, week or month, g refreshes, l shows the log (closed and clocked), / filters
23/// (`+cat-tag<0:10-/regexp/`), \ by tag, < by the line's category, = by regexp, _ by effort,
24/// | clears filters, I/O/X clock in, out and cancel, C-c C-w refiles, $ archives, m/u/U mark
25/// and unmark, B acts on the marked entries, t cycles the TODO state, +/- change the priority and
26/// , sets it, : or C-c C-q sets tags, C-c C-s / C-c C-d schedule and set a deadline, S-→/S-← move
27/// the date, k captures on the line's day, s saves all files, TAB and SPC show the entry.
28struct AgendaView: View {
29 let workspace: WorkspaceModel
30 let session: DocumentSession
31 let commands: AppCommands
32 let scheduler: ReminderScheduler?
33 let clock: ClockModel
34 @State private var agenda: AgendaModel
35 @State private var views = AgendaViewLoader.builtIn
36 @State private var viewName = AgendaViewLoader.builtIn[0].name
37 @State private var matchText = ""
38 /// The selected line: `day:index` (`day:o` + index in the overdue group), or `list:index`.
39 @State private var selection: String?
40 @State private var tick = 0
41 /// The time the now line shows, kept to the minute while the agenda is open.
42 @State private var now = Date()
43 /// The view picker moved to the agenda for a followed timestamp, which sets its own days.
44 @State private var keepRequestedDays = false
45 @State private var message: String?
46 @State private var prompt: PendingPrompt?
47 @State private var promptText = ""
48 @State private var prefix: String?
49 /// Entries marked for a bulk action.
50 @State private var marked: [EntryRef] = []
51 @State private var logMode = false
52 @State private var filter = AgendaFilter()
53 /// A filter question: its title and which filter it sets.
54 @State private var filterPrompt: (title: String, kind: Character)?
55 @State private var filterText = ""
56 /// A one-line question for an agenda action, and what to do with the answer.
57 struct TextPrompt {
58 let title: String
59 let apply: (String) -> Void
60 }
61 @State private var textPrompt: TextPrompt?
62 @State private var textAnswer = ""
63 /// Day, week or month chosen with `v`; nil shows the span the settings or the view give.
64 @State private var spanKind: AgendaSpan?
65 @State private var showingDatePicker = false
66 @State private var pickedDate = Date()
67 @AppStorage("agendaOverdueExpanded") private var overdueShown = true
68 @State private var hovered: String?
69 @FocusState private var focused: Bool
70 @AppStorage("agendaSpan") private var span = 10
71 @AppStorage("agendaStartOffset") private var startOffset = -3
72 @AppStorage("agendaSubfolders") private var includeSubfolders = false
73 @AppStorage("agendaShowAllDates") private var showAllDates = false
74 @AppStorage("calendarEvents") private var calendarEventsOn = false
75 @AppStorage("calendarEventCalendars") private var calendarNames = ""
76 @AppStorage("agendaPrefixAgenda") private var prefixAgenda = PrefixFormat.defaults["agenda"]!
77 @AppStorage("agendaPrefixTodo") private var prefixTodo = PrefixFormat.defaults["todo"]!
78 @AppStorage("agendaPrefixTags") private var prefixTags = PrefixFormat.defaults["tags"]!
79 @Environment(\.openWindow) private var openWindow
80
81 static let match = "Match…"
82 static let todoMatch = "TODO Match…"
83 static let search = "Search…"
84 static let todoSearch = "TODO Search…"
85
86 init(workspace: WorkspaceModel, session: DocumentSession, commands: AppCommands, scheduler: ReminderScheduler?, clock: ClockModel) {
87 self.workspace = workspace
88 self.session = session
89 self.commands = commands
90 self.scheduler = scheduler
91 self.clock = clock
92 _agenda = State(initialValue: AgendaModel(workspace: workspace))
93 }
94
95 /// An agenda entry by its file, heading line and where that line was.
96 struct EntryRef: Hashable {
97 let path: String
98 let line: String
99 let offset: Int
100
101 init?(_ item: AgendaItem) {
102 guard let path = item.path, let line = item.headingLine, let offset = item.headingOffset else { return nil }
103 self.path = path
104 self.line = line
105 self.offset = offset
106 }
107 }
108
109 struct PendingPrompt: Identifiable {
110 let id = UUID()
111 let prompt: Prompt
112 let command: any OrgCommand
113 let atMarker: Bool
114 let answers: [String: String]
115 let itemID: String
116 }
117
118 private struct RefreshKey: Hashable {
119 var mode: AgendaMode
120 var start: Int?
121 var span: Int
122 var startOffset: Int
123 var includeSubfolders: Bool
124 var prefix: [String]
125 var logMode: Bool
126 var calendars: [String]
127 var files: Int
128 var changed: Int?
129 var edits: Int
130 var path: String?
131 var tick: Int
132 }
133
134 private var refreshKey: RefreshKey {
135 RefreshKey(
136 mode: agenda.mode, start: agenda.start, span: span, startOffset: startOffset, includeSubfolders: includeSubfolders, prefix: [prefixAgenda, prefixTodo, prefixTags], logMode: logMode,
137 calendars: calendarEventsOn ? [calendarNames] : [], files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion,
138 path: session.path, tick: tick
139 )
140 }
141
142 private var isAgenda: Bool {
143 if case .agenda = agenda.mode { return true }
144 return false
145 }
146
147 private var isMatch: Bool { viewName == Self.match || viewName == Self.todoMatch }
148 private var isSearch: Bool { viewName == Self.search || viewName == Self.todoSearch }
149
150 @Environment(\.orgTheme) private var theme
151
152 // MARK: - Lines
153
154 /// A selectable line: its selection id, the row, and the day it is on.
155 struct Line {
156 let id: String
157 let row: AgendaRow
158 let day: Int
159 }
160
161 private var groups: [AgendaDayGroup] {
162 isAgenda ? agenda.groups(showAllDates: showAllDates, keep: filter.keeps) : []
163 }
164
165 private var listRows: [AgendaRow] {
166 isAgenda ? [] : agenda.list.filter(filter.keeps).compactMap { AgendaRow(item: $0, day: agenda.today, today: agenda.today) }
167 }
168
169 /// The lines in order, as up and down move through them.
170 private func lines(_ groups: [AgendaDayGroup]) -> [Line] {
171 guard isAgenda else { return listRows.enumerated().map { Line(id: "list:\($0.offset)", row: $0.element, day: agenda.today) } }
172 return groups.flatMap { group in
173 (overdueShown ? group.overdue.enumerated().map { Line(id: "\(group.day):o\($0.offset)", row: $0.element, day: group.day) } : [])
174 + group.rows.enumerated().map { Line(id: "\(group.day):\($0.offset)", row: $0.element, day: group.day) }
175 }
176 }
177
178 private func line(_ id: String) -> Line? { lines(groups).first { $0.id == id } }
179
180 var body: some View {
181 let groups = self.groups
182 _ = tick
183 return ScrollViewReader { proxy in
184 ScrollView {
185 LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
186 if isAgenda {
187 ForEach(groups) { group in
188 Section {
189 dayRows(group)
190 } header: {
191 dayHeader(group).id("day\(group.day)")
192 }
193 }
194 } else {
195 Section {
196 ForEach(Array(listRows.enumerated()), id: \.offset) { index, row in
197 rowView(Line(id: "list:\(index)", row: row, day: agenda.today), showsGutter: false)
198 }
199 } header: {
200 header { Text(listTitle).font(.system(size: 13, weight: .semibold)) }
201 }
202 }
203 }
204 .padding(.bottom, 12)
205 }
206 .onChange(of: selection) {
207 if let selection { proxy.scrollTo(selection) }
208 }
209 .onChange(of: agenda.days.first?.day) {
210 if groups.contains(where: \.isToday) { proxy.scrollTo("day\(agenda.today)", anchor: .top) }
211 }
212 }
213 .background(theme.color("agenda-background") ?? Color(nsColor: .textBackgroundColor))
214 .focusable()
215 .focusEffectDisabled()
216 .focused($focused)
217 .onAppear { focused = true }
218 .onKeyPress(phases: .down) { press in handle(press) }
219 .safeAreaInset(edge: .bottom, spacing: 0) {
220 if let message = message ?? (filter.isEmpty ? reminderStatus : "Filter: " + filter.description) {
221 Text(message)
222 .font(.callout)
223 .frame(maxWidth: .infinity, alignment: .leading)
224 .padding(.horizontal, 12)
225 .padding(.vertical, 6)
226 .background(.bar)
227 }
228 }
229 .toolbar { toolbar }
230 .overlay {
231 if agenda.files.isEmpty {
232 ContentUnavailableView("No agenda files", systemImage: "calendar", description: Text("The agenda reads the org files at the top of each folder you add."))
233 }
234 }
235 .sheet(item: Binding(get: { prompt?.prompt.fast == nil ? nil : prompt }, set: { if $0 == nil { prompt = nil } })) { pending in
236 if let fast = pending.prompt.fast {
237 FastSelectionView(selection: fast, message: pending.prompt.message) { answer in
238 prompt = nil
239 guard let answer else { return }
240 promptText = answer
241 prompt = pending
242 answerPrompt()
243 }
244 .frame(minWidth: 420)
245 }
246 }
247 .alert(prompt?.prompt.message ?? "", isPresented: Binding(get: { prompt != nil && prompt?.prompt.fast == nil }, set: { if !$0 { prompt = nil } })) {
248 TextField("", text: $promptText)
249 Button("OK") { answerPrompt() }
250 Button("Cancel", role: .cancel) { prompt = nil }
251 } message: {
252 if let choices = prompt?.prompt.choices, !choices.isEmpty {
253 Text(choices.prefix(12).joined(separator: " "))
254 }
255 }
256 .alert(filterPrompt?.title ?? "", isPresented: Binding(get: { filterPrompt != nil }, set: { if !$0 { filterPrompt = nil } })) {
257 TextField("", text: $filterText)
258 Button("Filter") { applyFilter() }
259 Button("Cancel", role: .cancel) { filterPrompt = nil }
260 } message: {
261 if filterPrompt?.kind == "/" || filterPrompt?.kind == "\\" {
262 Text(shown.tags.sorted().prefix(20).joined(separator: " "))
263 }
264 }
265 .alert(textPrompt?.title ?? "", isPresented: Binding(get: { textPrompt != nil }, set: { if !$0 { textPrompt = nil } })) {
266 TextField("", text: $textAnswer)
267 Button("OK") {
268 let pending = textPrompt
269 let answer = textAnswer
270 textPrompt = nil
271 textAnswer = ""
272 // A follow-up question opens after this alert closes.
273 DispatchQueue.main.async { pending?.apply(answer) }
274 }
275 Button("Cancel", role: .cancel) {
276 textPrompt = nil
277 textAnswer = ""
278 }
279 }
280 .navigationTitle(title)
281 .navigationSubtitle(isAgenda && viewName != AgendaViewLoader.builtIn[0].name ? viewName : "")
282 .onChange(of: viewName) { applyView() }
283 .onChange(of: commands.agendaRequest?.start, initial: true) { showRequestedDays() }
284 .onChange(of: commands.agendaRequest?.days) { showRequestedDays() }
285 .task(id: refreshKey) {
286 agenda.span = span
287 agenda.startOffset = startOffset
288 agenda.includeSubfolders = includeSubfolders
289 agenda.options.prefixFormat = ["agenda": prefixAgenda, "todo": prefixTodo, "tags": prefixTags]
290 agenda.options.logMode = logMode
291 agenda.options = OrgPreferences.agendaOptions(agenda.options)
292 agenda.calendarNames = OrgPreferences.calendarEventCalendars()
293 if calendarEventsOn, isAgenda {
294 agenda.calendarEvents = await CalendarAccess.events.requestAccess() ? CalendarAccess.events : nil
295 } else {
296 agenda.calendarEvents = nil
297 }
298 let open = session.openTexts
299 await agenda.refresh(open: open)
300 }
301 .task {
302 loadViews()
303 // The now line moves each minute; a new day refreshes the statuses.
304 while !Task.isCancelled {
305 let next = Calendar.current.nextDate(after: Date(), matching: DateComponents(second: 0), matchingPolicy: .nextTime) ?? Date().addingTimeInterval(60)
306 try? await Task.sleep(for: .seconds(next.timeIntervalSinceNow))
307 now = Date()
308 if Days.absolute(now, calendar: .current) != agenda.today { tick += 1 }
309 }
310 }
311 }
312
313 // MARK: - Days
314
315 private var todayColor: Color { theme.color("agenda-today") ?? .accentColor }
316
317 @ViewBuilder
318 private func dayRows(_ group: AgendaDayGroup) -> some View {
319 if group.isToday, !group.overdue.isEmpty {
320 overdueHeader(count: group.overdue.count)
321 if overdueShown {
322 ForEach(Array(group.overdue.enumerated()), id: \.element.id) { index, row in
323 rowView(Line(id: "\(group.day):o\(index)", row: row, day: group.day))
324 }
325 Divider().padding(.leading, 16).padding(.vertical, 4)
326 }
327 }
328 if group.isEmpty {
329 Text("Nothing scheduled")
330 .font(.callout)
331 .foregroundStyle(.secondary)
332 .padding(.horizontal, 16)
333 .padding(.vertical, 10)
334 }
335 let nowAt = group.nowIndex(now: Self.hhmm(now))
336 ForEach(Array(group.rows.enumerated()), id: \.element.id) { index, row in
337 if index == nowAt { nowLine }
338 rowView(Line(id: "\(group.day):\(index)", row: row, day: group.day))
339 }
340 if let nowAt, nowAt == group.rows.count { nowLine }
341 }
342
343 private func header(@ViewBuilder _ content: () -> some View) -> some View {
344 HStack(spacing: 6) { content() }
345 .padding(.horizontal, 16)
346 .frame(height: 30)
347 .frame(maxWidth: .infinity, alignment: .leading)
348 .background(theme.color("agenda-background") ?? Color(nsColor: .textBackgroundColor))
349 .overlay(alignment: .bottom) { Divider() }
350 }
351
352 private func dayHeader(_ group: AgendaDayGroup) -> some View {
353 let past = group.day < agenda.today
354 let color = group.isToday ? todayColor : past ? Color.secondary : theme.color("agenda-date") ?? .primary
355 return header {
356 if let relative = group.relativeName(today: agenda.today) {
357 Text(relative)
358 .font(.system(size: 13, weight: .bold))
359 .foregroundStyle(color)
360 Text("·").foregroundStyle(.tertiary)
361 }
362 Text(Self.format(group.day, Days.date(group.day).year == Days.date(agenda.today).year ? "EEEE d MMMM" : "EEEE d MMMM yyyy"))
363 .font(.system(size: 13, weight: group.isToday ? .bold : .semibold))
364 .foregroundStyle(color)
365 Spacer()
366 Button {
367 capture(on: group.day)
368 } label: {
369 Image(systemName: "plus")
370 .font(.system(size: 12, weight: .semibold))
371 .frame(width: 22, height: 22)
372 .contentShape(Rectangle())
373 }
374 .buttonStyle(.borderless)
375 .foregroundStyle(group.isToday ? todayColor : .secondary)
376 .help("Capture an entry scheduled on this day (k)")
377 }
378 }
379
380 private func overdueHeader(count: Int) -> some View {
381 let color = theme.dynamic(.systemRed) { [theme] in theme.statusColor(.overdue, dark: $0) }
382 return Button {
383 withAnimation(.snappy(duration: 0.2)) { overdueShown.toggle() }
384 if !overdueShown, selection?.contains(":o") == true { selection = nil }
385 } label: {
386 HStack(spacing: 6) {
387 Image(systemName: "chevron.right")
388 .font(.system(size: 10, weight: .bold))
389 .rotationEffect(.degrees(overdueShown ? 90 : 0))
390 Text("Overdue").font(.system(size: 12, weight: .semibold))
391 Text("\(count)")
392 .font(.system(size: 10, weight: .bold).monospacedDigit())
393 .padding(.horizontal, 6)
394 .padding(.vertical, 1)
395 .background(Capsule().fill(color.opacity(0.15)))
396 Spacer()
397 }
398 .foregroundStyle(color)
399 .padding(.horizontal, 16)
400 .padding(.top, 8)
401 .padding(.bottom, 4)
402 .contentShape(Rectangle())
403 }
404 .buttonStyle(.plain)
405 .help(overdueShown ? "Hide entries from earlier days" : "Show entries from earlier days")
406 }
407
408 private var nowLine: some View {
409 HStack(spacing: 6) {
410 Text(now.formatted(.dateTime.hour(.twoDigits(amPM: .omitted)).minute()))
411 .font(.system(size: 10, weight: .semibold).monospacedDigit())
412 .frame(width: AgendaRowView.gutterWidth, alignment: .leading)
413 Circle().frame(width: 6, height: 6)
414 Rectangle().frame(height: 1)
415 }
416 .foregroundStyle(todayColor)
417 .padding(.horizontal, 16)
418 .frame(height: 10)
419 .accessibilityLabel("Now")
420 }
421
422 private func rowView(_ line: Line, showsGutter: Bool = true) -> some View {
423 AgendaRowView(
424 row: line.row, day: line.day, showsGutter: showsGutter, selected: selection == line.id, hovered: hovered == line.id,
425 marked: line.row.item.flatMap(EntryRef.init).map(marked.contains) ?? false
426 )
427 .id(line.id)
428 .contentShape(Rectangle())
429 .onHover { inside in
430 if inside { hovered = line.id } else if hovered == line.id { hovered = nil }
431 }
432 .onTapGesture(count: 2) { open(line) }
433 .simultaneousGesture(TapGesture().onEnded {
434 selection = line.id
435 focused = true
436 })
437 .contextMenu { menu(line) }
438 }
439
440 @ViewBuilder
441 private func menu(_ line: Line) -> some View {
442 if let event = line.row.event {
443 Button("Show in Calendar") { openEvent(event) }
444 } else if line.row.item != nil {
445 Button("Open") { selection = line.id; jump(line.id) }
446 Divider()
447 Button("Cycle TODO State") { selection = line.id; run(TodoCycle()) }
448 Button("Set Priority…") { selection = line.id; run(SetPriority()) }
449 Button("Set Tags…") { selection = line.id; run(SetTags()) }
450 Button("Schedule…") { selection = line.id; run(SetPlanning(deadline: false)) }
451 Button("Set Deadline…") { selection = line.id; run(SetPlanning(deadline: true)) }
452 Divider()
453 Button("Clock In") { selection = line.id; clockIn() }
454 Button(EntryRef(line.row.item!).map(marked.contains) == true ? "Unmark" : "Mark") {
455 selection = line.id
456 mark(!(EntryRef(line.row.item!).map(marked.contains) ?? false))
457 }
458 Button("Archive") { selection = line.id; archive() }
459 }
460 }
461
462 // MARK: - Toolbar
463
464 @ToolbarContentBuilder
465 private var toolbar: some ToolbarContent {
466 ToolbarItemGroup(placement: .navigation) {
467 Menu {
468 ForEach(views) { view in Button(view.name) { viewName = view.name } }
469 Divider()
470 Button(Self.match) { viewName = Self.match }
471 Button(Self.todoMatch) { viewName = Self.todoMatch }
472 Button(Self.search) { viewName = Self.search }
473 Button(Self.todoSearch) { viewName = Self.todoSearch }
474 } label: {
475 Label(viewName, systemImage: "calendar").labelStyle(.titleAndIcon)
476 }
477 .help("What the agenda shows")
478 if isMatch || isSearch {
479 TextField(isSearch ? "Phrase or +word -word {regexp}" : "+work-boss|LEVEL>2/!TODO", text: $matchText)
480 .frame(minWidth: 220)
481 .onSubmit { applyView() }
482 }
483 }
484 ToolbarItemGroup {
485 if agenda.isComputing { ProgressView().controlSize(.small) }
486 if isAgenda {
487 Picker("Span", selection: Binding(get: { spanKind }, set: { if let kind = $0 { setSpan(kind) } })) {
488 Text("Day").tag(AgendaSpan?.some(.day))
489 Text("Week").tag(AgendaSpan?.some(.week))
490 Text("Month").tag(AgendaSpan?.some(.month))
491 }
492 .pickerStyle(.segmented)
493 .labelsHidden()
494 .help("Show a day, week or month (v d, v w, v m)")
495 ControlGroup {
496 Button("Earlier", systemImage: "chevron.left") { move(forward: false) }
497 .help("Previous days (b)")
498 Button("Today") { goToday() }
499 .help("Back to today (.)")
500 Button("Later", systemImage: "chevron.right") { move(forward: true) }
501 .help("Next days (f)")
502 }
503 Button("Go to Date", systemImage: "calendar") {
504 pickedDate = Self.date(anchorDay)
505 showingDatePicker = true
506 }
507 .help("Go to a date (j)")
508 .popover(isPresented: $showingDatePicker, arrowEdge: .bottom) {
509 DatePicker("Date", selection: $pickedDate, displayedComponents: .date)
510 .datePickerStyle(.graphical)
511 .labelsHidden()
512 .padding()
513 .onChange(of: pickedDate) {
514 showingDatePicker = false
515 goTo(Days.absolute(pickedDate, calendar: .current))
516 }
517 }
518 }
519 Menu {
520 Button("Filter…") { askFilter("/") }
521 Button("By Tag…") { askFilter("\\") }
522 Button("By Category of Selected Line") { toggleCategoryFilter() }
523 Button("By Regexp…") { askFilter("=") }
524 Button("By Effort…") { askFilter("_") }
525 Divider()
526 Button("Remove Filters") { filter = AgendaFilter() }
527 .disabled(filter.isEmpty)
528 } label: {
529 Label("Filter", systemImage: filter.isEmpty ? "line.3.horizontal.decrease.circle" : "line.3.horizontal.decrease.circle.fill")
530 } primaryAction: {
531 askFilter("/")
532 }
533 .help("Filter the entries (/, \\, <, =, _; | removes)")
534 }
535 }
536
537 /// The span shown, as the window's title: `Week of 5 Oct 2026`.
538 private var title: String {
539 guard isAgenda else { return viewName.replacingOccurrences(of: "…", with: "") }
540 guard let first = agenda.days.first?.day, let last = agenda.days.last?.day else { return "Agenda" }
541 switch spanKind {
542 case .day: return Self.format(first, "EEEE d MMMM yyyy")
543 case .week: return "Week of " + Self.format(first, "d MMM yyyy")
544 case .month: return Self.format(first, "MMMM yyyy")
545 case nil:
546 if first == last { return Self.format(first, "EEEE d MMMM yyyy") }
547 let sameYear = Days.date(first).year == Days.date(last).year
548 return Self.format(first, sameYear ? "d MMM" : "d MMM yyyy") + " – " + Self.format(last, "d MMM yyyy")
549 }
550 }
551
552 // MARK: - Span
553
554 /// The day `v`, `j` and `k` start from: the selected line's, else today when shown, else the first.
555 private var anchorDay: Int {
556 if let selection, let day = Int(selection.prefix { $0 != ":" }) { return day }
557 let days = agenda.days.map(\.day)
558 if days.contains(agenda.today) || days.isEmpty { return agenda.today }
559 return days[0]
560 }
561
562 /// `org-agenda-day-view`, `-week-view`, `-month-view`: the day, the week from Monday
563 /// (`org-agenda-start-on-weekday`), or the month around `anchor`.
564 private func setSpan(_ kind: AgendaSpan, around anchor: Int? = nil) {
565 let anchor = anchor ?? anchorDay
566 spanKind = kind
567 selection = nil
568 switch kind {
569 case .day:
570 agenda.mode = .agenda(span: 1, startOffset: nil)
571 agenda.start = anchor
572 case .week:
573 agenda.mode = .agenda(span: 7, startOffset: nil)
574 agenda.start = anchor - (Days.weekday(anchor) + 6) % 7
575 case .month:
576 let d = Days.date(anchor)
577 let first = Days.absolute(year: d.year, month: d.month, day: 1)
578 let next = d.month == 12 ? Days.absolute(year: d.year + 1, month: 1, day: 1) : Days.absolute(year: d.year, month: d.month + 1, day: 1)
579 agenda.mode = .agenda(span: next - first, startOffset: nil)
580 agenda.start = first
581 }
582 }
583
584 private func move(forward: Bool) {
585 selection = nil
586 if spanKind == .month, let first = agenda.days.first?.day {
587 setSpan(.month, around: forward ? first + 31 : first - 1)
588 } else {
589 agenda.shift(forward: forward)
590 }
591 }
592
593 private func goToday() {
594 if let spanKind { setSpan(spanKind, around: agenda.today) } else { agenda.showToday() }
595 selection = nil
596 }
597
598 /// `org-agenda-goto-date`.
599 private func goTo(_ day: Int) {
600 if let spanKind { setSpan(spanKind, around: day) } else { agenda.start = day }
601 selection = nil
602 }
603
604 /// Capture on `day`, as `org-agenda-capture`.
605 private func capture(on day: Int) {
606 commands.capture(on: day)
607 NSApp.activate()
608 openWindow(id: "capture")
609 }
610
611 static func date(_ day: Int) -> Date {
612 let d = Days.date(day)
613 return Calendar.current.date(from: DateComponents(year: d.year, month: d.month, day: d.day)) ?? Date()
614 }
615
616 static func format(_ day: Int, _ pattern: String) -> String {
617 let formatter = DateFormatter()
618 formatter.dateFormat = pattern
619 return formatter.string(from: date(day))
620 }
621
622 static func hhmm(_ date: Date) -> Int {
623 let c = Calendar.current.dateComponents([.hour, .minute], from: date)
624 return (c.hour ?? 0) * 100 + (c.minute ?? 0)
625 }
626
627 @AppStorage("reminders") private var remindersEnabled = true
628
629 /// How far ahead notifications are set, as the design asks the agenda to show.
630 private var reminderStatus: String? {
631 guard let scheduler, remindersEnabled else { return nil }
632 if !scheduler.isAuthorized { return "Notifications are off for Orgstar in System Settings." }
633 guard let through = scheduler.scheduledThrough else { return nil }
634 return "Reminders are set through " + through.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute()) + "."
635 }
636
637 private var listTitle: String {
638 switch agenda.mode {
639 case .todo(let keywords): "TODO items: \(keywords ?? "all")"
640 case .match(let match, let todoOnly): (todoOnly ? "TODO entries" : "Entries") + " matching \(match)"
641 case .search(let string, let todoOnly): (todoOnly ? "TODO entries" : "Entries") + " containing \(string)"
642 case .agenda: ""
643 }
644 }
645
646 private func loadViews() {
647 let loaded = AgendaViewLoader.load()
648 views = loaded.views
649 message = loaded.problems.first
650 }
651
652 /// The days of a followed timestamp, in the agenda view.
653 private func showRequestedDays() {
654 guard let request = commands.agendaRequest else { return }
655 commands.agendaRequest = nil
656 selection = nil
657 spanKind = nil
658 if viewName != AgendaViewLoader.builtIn[0].name {
659 keepRequestedDays = true
660 viewName = AgendaViewLoader.builtIn[0].name
661 }
662 agenda.mode = .agenda(span: request.days, startOffset: nil)
663 agenda.start = request.start
664 }
665
666 private func applyView() {
667 if keepRequestedDays { return keepRequestedDays = false }
668 selection = nil
669 spanKind = nil
670 agenda.start = nil
671 switch viewName {
672 case Self.match, Self.todoMatch:
673 let match = matchText.trimmingCharacters(in: .whitespaces)
674 guard !match.isEmpty else { return }
675 agenda.mode = .match(match, todoOnly: viewName == Self.todoMatch)
676 case Self.search, Self.todoSearch:
677 guard matchText.contains(where: { !$0.isWhitespace }) else { return }
678 agenda.mode = .search(matchText, todoOnly: viewName == Self.todoSearch)
679 default:
680 if let view = views.first(where: { $0.name == viewName }) { agenda.mode = view.mode }
681 }
682 }
683
684 // MARK: - Items
685
686 private func item(_ id: String) -> AgendaItem? { line(id)?.row.item }
687
688 /// Return and double click: the entry in the main window, or the event in Calendar.
689 private func open(_ line: Line) {
690 selection = line.id
691 if let event = line.row.event { openEvent(event) } else { jump(line.id) }
692 }
693
694 /// Calendar shows an event from its identifier (`AgendaEvent.id` adds `@` and the start).
695 private func openEvent(_ event: AgendaEvent) {
696 let identifier = event.id.split(separator: "@").dropLast().joined(separator: "@")
697 guard !identifier.isEmpty, let encoded = identifier.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed),
698 let url = URL(string: "ical://ekevent/\(encoded)?method=show&options=more") else { return }
699 NSWorkspace.shared.open(url)
700 }
701
702 /// Moves the selection by `step` lines.
703 private func step(_ step: Int) {
704 let all = lines(groups)
705 guard !all.isEmpty else { return }
706 let index = selection.flatMap { id in all.firstIndex { $0.id == id } }
707 let next = index.map { min(max($0 + step, 0), all.count - 1) } ?? (step > 0 ? 0 : all.count - 1)
708 selection = all[next].id
709 }
710
711 // MARK: - Filters
712
713 /// The tags and categories on the view's lines, which filter words are read against.
714 private var shown: (tags: Set<String>, categories: Set<String>) {
715 let items = (isAgenda ? agenda.days.flatMap(\.items) : agenda.list).filter { $0.path != nil }
716 return (Set(items.flatMap(\.tags)), Set(items.map(\.category)))
717 }
718
719 private func askFilter(_ kind: Character) {
720 switch kind {
721 case "/": filterText = filter.description
722 default: filterText = ""
723 }
724 let title = switch kind {
725 case "/": "Filter [+cat-tag<0:10-/regexp/]:"
726 case "\\": "Tag filter (+tag-tag):"
727 case "=": "Regexp filter (-re to drop):"
728 default: "Effort filter (<0:30, >1:00, =1:00):"
729 }
730 filterPrompt = (title, kind)
731 }
732
733 private func applyFilter() {
734 guard let kind = filterPrompt?.kind else { return }
735 filterPrompt = nil
736 let text = filterText.trimmingCharacters(in: .whitespaces)
737 var current = filter
738 switch kind {
739 case "/":
740 let (parsed, ignored) = AgendaFilter().parse(text, tags: shown.tags, categories: shown.categories)
741 current = parsed
742 if !ignored.isEmpty { message = "\(ignored.joined(separator: " ")) ignored: no line has that tag or category" }
743 case "\\":
744 // Every word as a tag.
745 current.tags = AgendaFilter().parse(text, tags: Set(text.split(whereSeparator: { "+- ".contains($0) }).map(String.init)), categories: []).filter.tags
746 case "=":
747 current.regexps = text.isEmpty ? [] : [text.hasPrefix("-") || text.hasPrefix("+") ? text : "+" + text]
748 default:
749 current.efforts = text.isEmpty ? [] : ["+" + text]
750 }
751 filter = current
752 selection = nil
753 }
754
755 /// `<`: keep only the selected line's category, or drop that filter.
756 private func toggleCategoryFilter() {
757 if !filter.categories.isEmpty {
758 filter.categories = []
759 } else if let selection, let item = item(selection) {
760 filter.categories = ["+" + item.category]
761 } else {
762 message = "No entry selected"
763 }
764 }
765
766 /// The entry in the main window; with `focus` false the agenda keeps the keys (SPC).
767 private func jump(_ id: String, focus: Bool = true) {
768 guard let item = item(id), let path = item.path, let offset = item.markerOffset ?? item.headingOffset else { return }
769 commands.jump(path: path, offset: offset)
770 if focus { openWindow(id: "main") }
771 }
772
773 // MARK: - Commands
774
775 private func handle(_ press: KeyPress) -> KeyPress.Result {
776 let control = press.modifiers.contains(.control)
777 // `C-x` and friends arrive as control characters or as letters with control.
778 func isControl(_ letter: String, _ code: String) -> Bool { control && (press.characters == letter || press.characters == code) }
779 if let pending = prefix {
780 prefix = nil
781 if pending == "v" {
782 switch press.characters {
783 case "d" where isAgenda: setSpan(.day)
784 case "w" where isAgenda: setSpan(.week)
785 case "m" where isAgenda: setSpan(.month)
786 default: message = "v \(press.characters) is undefined"
787 }
788 if message == "v-" { message = nil }
789 return .handled
790 }
791 if pending == "C-c C-x" {
792 if isControl("s", "\u{13}") { archive() } else { message = "C-c C-x \(press.characters) is undefined" }
793 return .handled
794 }
795 if isControl("s", "\u{13}") {
796 run(SetPlanning(deadline: false))
797 } else if isControl("d", "\u{04}") {
798 run(SetPlanning(deadline: true))
799 } else if isControl("q", "\u{11}") {
800 run(SetTags())
801 } else if isControl("t", "\u{14}") {
802 run(TodoCycle())
803 } else if isControl("w", "\u{17}") {
804 refile(marked: false)
805 } else if isControl("x", "\u{18}") {
806 prefix = "C-c C-x"
807 message = "C-c C-x-"
808 } else if press.characters == "$" {
809 archive()
810 } else {
811 message = "C-c \(press.characters) is undefined"
812 }
813 return .handled
814 }
815 if isControl("c", "\u{03}") {
816 prefix = "C-c"
817 message = "C-c-"
818 return .handled
819 }
820 if isControl("n", "\u{0e}") || (press.key == .downArrow && press.modifiers.isEmpty) {
821 step(1)
822 return .handled
823 }
824 if isControl("p", "\u{10}") || (press.key == .upArrow && press.modifiers.isEmpty) {
825 step(-1)
826 return .handled
827 }
828 if press.key == .return || press.key == .tab, let selection, let line = line(selection) {
829 open(line)
830 return .handled
831 }
832 if press.key == .space, let selection {
833 jump(selection, focus: false)
834 return .handled
835 }
836 if press.modifiers == .shift, press.key == .rightArrow || press.key == .leftArrow {
837 run(ShiftTimestampDay(later: press.key == .rightArrow), atMarker: true)
838 return .handled
839 }
840 guard press.modifiers.subtracting(.shift).isEmpty else { return .ignored }
841 switch press.characters {
842 case "n": step(1)
843 case "p": step(-1)
844 case "f" where isAgenda: move(forward: true)
845 case "b" where isAgenda: move(forward: false)
846 case "." where isAgenda: goToday()
847 case "j" where isAgenda:
848 pickedDate = Self.date(anchorDay)
849 showingDatePicker = true
850 case "v" where isAgenda:
851 prefix = "v"
852 message = "v-"
853 case "d" where isAgenda: setSpan(.day)
854 case "w" where isAgenda: setSpan(.week)
855 case "k" where isAgenda: capture(on: anchorDay)
856 case "s":
857 commands.run("app.save-all")
858 message = "Saved"
859 case "/", "\\", "=", "_": askFilter(press.characters.first!)
860 case "<": toggleCategoryFilter()
861 case "|":
862 filter = AgendaFilter()
863 message = "Filters removed"
864 case "l" where isAgenda:
865 logMode.toggle()
866 message = logMode ? "Log mode on" : "Log mode off"
867 case "g", "r":
868 loadViews()
869 tick += 1
870 case "I": clockIn()
871 case "O": message = clock.clockOut(session)
872 case "X": message = clock.clockOut(session, cancel: true)
873 case "$": archive()
874 case "m": mark(true)
875 case "u": mark(false)
876 case "U":
877 marked = []
878 message = "No entries marked"
879 case "B": askBulk()
880 case "t": run(TodoCycle())
881 case "+": run(PriorityUp())
882 case "-": run(PriorityDown())
883 case ",": run(SetPriority())
884 case ":": run(SetTags())
885 default: return .ignored
886 }
887 return .handled
888 }
889
890 // MARK: - Clock, refile, archive, bulk
891
892 private var selectedEntry: EntryRef? { selection.flatMap(item).flatMap(EntryRef.init) }
893
894 private func clockIn() {
895 guard let entry = selectedEntry, let offset = session.locateHeading(path: entry.path, line: entry.line, near: entry.offset) else {
896 message = "No entry selected"
897 return
898 }
899 let paths = workspace.files.filter { $0.kind == .org }.map(\.path)
900 Task {
901 let outcome = await clock.clockIn(session, target: .entry(path: entry.path, offset: offset), paths: paths, ask: commands.askClock)
902 if let jump = outcome.jump { commands.jump(path: jump.path, offset: jump.offset) }
903 message = outcome.messages.last { !$0.isEmpty }
904 tick += 1
905 }
906 }
907
908 private func archive() {
909 guard let entry = selectedEntry, let offset = session.locateHeading(path: entry.path, line: entry.line, near: entry.offset) else {
910 message = "No entry selected"
911 return
912 }
913 message = session.archive(from: (entry.path, offset))
914 tick += 1
915 }
916
917 /// Refiles the selected entry, or with `marked` the marked ones, to a target asked for in
918 /// the main window, where the list of targets is searchable.
919 private func refile(marked bulk: Bool) {
920 let entries = bulk ? marked : selectedEntry.map { [$0] } ?? []
921 guard !entries.isEmpty else {
922 message = "No entry selected"
923 return
924 }
925 let targets = workspace.refileTargets(openPath: session.path, document: session.document)
926 commands.ask(Prompt(key: "refile", message: "Refile to:", choices: targets.map(\.label))) { answer in
927 guard let answer, targets.contains(where: { $0.label == answer }) else { return }
928 message = eachEntry(entries) { entry, offset in
929 // Targets in the entry's own file from its text now, as earlier moves shift them.
930 let document = session.openDocuments.first { $0.path == entry.path }?.document
931 ?? FileManager.default.contents(atPath: entry.path).map { DocumentState(bytes: [UInt8]($0)) }
932 guard let target = workspace.refileTargets(openPath: entry.path, document: document).first(where: { $0.label == answer }) else {
933 return "Can't find \(answer) any more."
934 }
935 return session.refile(to: target, from: (entry.path, offset))
936 }
937 if bulk { marked = [] }
938 tick += 1
939 }
940 openWindow(id: "main")
941 }
942
943 /// Runs `action` on each entry, finding it again first: later entries in a file first, so
944 /// earlier ones keep their place. Returns the last message.
945 private func eachEntry(_ entries: [EntryRef], _ action: (EntryRef, Int) -> String?) -> String? {
946 var last: String?
947 var failed = 0
948 for entry in entries.sorted(by: { ($0.path, $1.offset) < ($1.path, $0.offset) }) {
949 guard let offset = session.locateHeading(path: entry.path, line: entry.line, near: entry.offset) else {
950 failed += 1
951 continue
952 }
953 last = action(entry, offset)
954 }
955 return failed > 0 ? "\(failed) marked \(failed == 1 ? "entry" : "entries") changed and \(failed == 1 ? "was" : "were") skipped" : last
956 }
957
958 private func mark(_ on: Bool) {
959 guard let entry = selectedEntry else {
960 message = "No entry selected"
961 return
962 }
963 if on {
964 if !marked.contains(entry) { marked.append(entry) }
965 } else {
966 marked.removeAll { $0 == entry }
967 }
968 message = "\(marked.count) marked"
969 }
970
971 /// `B`: an action for every marked entry.
972 private func askBulk() {
973 guard !marked.isEmpty else {
974 message = "No entries are marked"
975 return
976 }
977 textPrompt = TextPrompt(title: "Bulk action on \(marked.count): [$] archive [r] refile [t] TODO state [+] tag [-] untag [s] schedule [d] deadline") { answer in
978 switch answer.trimmingCharacters(in: .whitespaces) {
979 case "$": bulk { session.archive(from: ($0.path, $1)) }
980 case "r": refile(marked: true)
981 case "t":
982 textPrompt = TextPrompt(title: "TODO state (empty for none):") { keyword in
983 let word = keyword.trimmingCharacters(in: .whitespaces)
984 bulk { performAt($0, $1, SetTodo(keyword: word.isEmpty ? nil : word)) }
985 }
986 case "+", "-":
987 let on = answer.trimmingCharacters(in: .whitespaces) == "+"
988 textPrompt = TextPrompt(title: on ? "Tag to add:" : "Tag to remove:") { tag in
989 let tag = tag.trimmingCharacters(in: .whitespaces)
990 guard !tag.isEmpty else { return }
991 bulk { performAt($0, $1, ToggleTag(tag: tag, on: on)) }
992 }
993 case "s", "d":
994 let deadline = answer.trimmingCharacters(in: .whitespaces) == "d"
995 textPrompt = TextPrompt(title: deadline ? "Deadline (+2d, fri, 2026-10-12):" : "Schedule (+2d, fri, 2026-10-12):") { date in
996 bulk { performAt($0, $1, SetPlanning(deadline: deadline), answers: ["date": date]) }
997 }
998 default:
999 message = "No bulk action \(answer)"
1000 }
1001 }
1002 }
1003
1004 private func bulk(_ action: (EntryRef, Int) -> String?) {
1005 message = eachEntry(marked, action) ?? "Done with \(marked.count) entries"
1006 marked = []
1007 tick += 1
1008 }
1009
1010 /// Runs `command` at the heading at `offset`, as `run` does for the selection.
1011 private func performAt(_ entry: EntryRef, _ offset: Int, _ command: any OrgCommand, answers: [String: String] = [:]) -> String? {
1012 switch session.perform(command, path: entry.path, at: offset, expect: (offset, entry.line), answers: answers) {
1013 case .failed(let text): text
1014 case .prompt: "\(command.title) needs an answer; run it on the entry itself"
1015 default: nil
1016 }
1017 }
1018
1019 /// Runs `command` on the selected entry, at its heading or, with `atMarker`, at the
1020 /// timestamp the entry is listed for.
1021 private func run(_ command: any OrgCommand, atMarker: Bool = false, answers: [String: String] = [:], itemID: String? = nil) {
1022 guard let id = itemID ?? selection, let item = item(id), let path = item.path, let start = item.headingOffset else {
1023 message = "No entry selected"
1024 return
1025 }
1026 if atMarker, ![.deadline, .upcomingDeadline, .scheduled, .pastScheduled, .timestamp].contains(item.kind) {
1027 message = "Only dated entries can move"
1028 return
1029 }
1030 let offset = atMarker ? (item.markerOffset ?? start) : start
1031 let step = session.perform(command, path: path, at: offset, expect: item.headingLine.map { (start, $0) }, answers: answers)
1032 switch step {
1033 case .prompt(let request):
1034 promptText = request.initial
1035 prompt = PendingPrompt(prompt: request, command: command, atMarker: atMarker, answers: answers, itemID: id)
1036 case .failed(let text):
1037 message = text
1038 tick += 1
1039 case .external:
1040 message = "Not available from the agenda"
1041 case .commit:
1042 message = nil
1043 tick += 1
1044 }
1045 }
1046
1047 private func answerPrompt() {
1048 guard let pending = prompt else { return }
1049 prompt = nil
1050 var answers = pending.answers
1051 answers[pending.prompt.key] = promptText
1052 run(pending.command, atMarker: pending.atMarker, answers: answers, itemID: pending.itemID)
1053 }
1054}
1055
1056/// One agenda line: the time or status over an icon, the category above the title, progress
1057/// and tags after it, and the TODO keyword and priority at the end.
1058struct AgendaRowView: View {
1059 let row: AgendaRow
1060 /// The agenda day the row is on, for habit graphs.
1061 var day: Int
1062 var showsGutter = true
1063 var selected = false
1064 var hovered = false
1065 /// Marked for a bulk action.
1066 var marked = false
1067 @Environment(\.orgTheme) private var theme
1068
1069 static let gutterWidth: CGFloat = 92
1070
1071 var body: some View {
1072 HStack(alignment: .center, spacing: 12) {
1073 if showsGutter { gutter }
1074 VStack(alignment: .leading, spacing: 2) {
1075 if !row.category.isEmpty { categoryLine }
1076 titleLine
1077 }
1078 .layoutPriority(1)
1079 Spacer(minLength: 8)
1080 if let habit = row.item?.habit {
1081 HabitGraph(cells: habit.graph(today: day), theme: theme)
1082 }
1083 if let todo = row.todo {
1084 let color = theme.dynamic(row.isDone ? .systemGreen : .systemRed) { [theme, row] in theme.todoColor(todo, isDone: row.isDone, dark: $0) }
1085 Text(todo)
1086 .font(.system(size: 10, weight: .bold))
1087 .foregroundStyle(color)
1088 .padding(.horizontal, 7)
1089 .padding(.vertical, 2)
1090 .background(Capsule().fill(color.opacity(0.14)))
1091 .fixedSize()
1092 }
1093 priorityBadge
1094 .frame(width: 18)
1095 }
1096 .padding(.horizontal, 16)
1097 .padding(.vertical, 6)
1098 .frame(minHeight: 44)
1099 // `org-agenda-dim-blocked-tasks`.
1100 .opacity(row.isBlocked ? 0.5 : 1)
1101 .background {
1102 RoundedRectangle(cornerRadius: 6)
1103 .fill(selected ? theme.color("selection") ?? Color.accentColor.opacity(0.2) : hovered ? Color.primary.opacity(0.05) : .clear)
1104 .padding(.horizontal, 6)
1105 }
1106 .overlay(alignment: .leading) {
1107 if marked {
1108 Capsule().fill(theme.color("agenda-today") ?? .accentColor).frame(width: 3).padding(.vertical, 8).padding(.leading, 8)
1109 }
1110 }
1111 .accessibilityElement(children: .combine)
1112 }
1113
1114 private var statusColor: Color {
1115 theme.dynamic(.secondaryLabelColor) { [theme, row] in theme.statusColor(row.status, dark: $0) }
1116 }
1117
1118 private var gutter: some View {
1119 VStack(alignment: .leading, spacing: 3) {
1120 Text(row.gutter)
1121 .font(.system(size: 12, weight: row.status == .overdue || row.status == .dueSoon ? .semibold : .regular).monospacedDigit())
1122 .foregroundStyle(statusColor)
1123 .lineLimit(1)
1124 .minimumScaleFactor(0.8)
1125 if let symbol {
1126 Image(systemName: symbol)
1127 .font(.system(size: row.icon == .event ? 8 : 10, weight: .semibold))
1128 .foregroundStyle(iconColor)
1129 .frame(height: 12)
1130 }
1131 }
1132 .frame(width: Self.gutterWidth, alignment: .leading)
1133 }
1134
1135 /// Overdue entries show a warning, done ones a check, as the status says first.
1136 private var symbol: String? {
1137 guard let icon = row.icon else { return nil }
1138 if row.status == .overdue { return "exclamationmark.circle.fill" }
1139 if row.status == .done { return "checkmark.circle.fill" }
1140 switch icon {
1141 case .scheduled: return "calendar"
1142 case .deadline: return "flag.fill"
1143 case .habit: return "repeat"
1144 case .event: return "circle.fill"
1145 case .timestamp: return row.start == nil ? "calendar" : "clock"
1146 }
1147 }
1148
1149 private var iconColor: Color {
1150 if let event = row.event { return theme.dynamic(.systemPurple) { [theme] in theme.eventColor(event, dark: $0) } }
1151 return row.status == .normal ? Color.secondary.opacity(0.8) : statusColor
1152 }
1153
1154 private var categoryLine: some View {
1155 HStack(spacing: 5) {
1156 Circle()
1157 .fill(row.event.map { event in theme.dynamic(.systemPurple) { [theme] in theme.eventColor(event, dark: $0) } }
1158 ?? theme.dynamic { [theme, row] in theme.categoryColor(row.category, dark: $0) })
1159 .frame(width: 6, height: 6)
1160 Text(row.category + (row.event?.location.map { " · " + $0 } ?? ""))
1161 .font(.system(size: 11))
1162 .foregroundStyle(theme.color("agenda-category") ?? .secondary)
1163 .lineLimit(1)
1164 }
1165 }
1166
1167 private var titleLine: some View {
1168 HStack(spacing: 6) {
1169 Text(row.title.isEmpty ? " " : row.title)
1170 .font(.system(size: 13))
1171 .strikethrough(row.isDone)
1172 .foregroundStyle(row.isDone ? AnyShapeStyle(.secondary) : AnyShapeStyle(theme.color("foreground") ?? .primary))
1173 .lineLimit(1)
1174 .truncationMode(.tail)
1175 ForEach([row.progress, row.detail].compactMap { $0 }, id: \.self) { progress in
1176 Text(progress)
1177 .font(.system(size: 10, weight: .semibold).monospacedDigit())
1178 .foregroundStyle(.secondary)
1179 .padding(.horizontal, 6)
1180 .padding(.vertical, 1)
1181 .background(Capsule().fill(Color.secondary.opacity(0.14)))
1182 .fixedSize()
1183 }
1184 ForEach(row.tags, id: \.self) { tag in
1185 Text(tag)
1186 .font(.system(size: 10, weight: .medium))
1187 .foregroundStyle(theme.color("tags") ?? .secondary)
1188 .padding(.horizontal, 6)
1189 .padding(.vertical, 1)
1190 .background(Capsule().strokeBorder((theme.color("tags") ?? .secondary).opacity(0.35), lineWidth: 0.5))
1191 .fixedSize()
1192 }
1193 }
1194 }
1195
1196 @ViewBuilder
1197 private var priorityBadge: some View {
1198 if let priority = row.priority {
1199 let color = theme.dynamic(.systemOrange) { [theme] in theme.priorityColor(priority, dark: $0) }
1200 Text(priority)
1201 .font(.system(size: 10, weight: .bold))
1202 .foregroundStyle(color)
1203 .frame(width: 18, height: 18)
1204 .background(Circle().fill(color.opacity(0.14)))
1205 .overlay(Circle().strokeBorder(color.opacity(0.6), lineWidth: 1))
1206 .help("Priority \(priority)")
1207 } else {
1208 Color.clear.frame(width: 18, height: 18)
1209 }
1210 }
1211}
1212
1213/// org-habit's consistency graph: a cell per day, `*` where the habit was done, `!` today.
1214struct HabitGraph: View {
1215 let cells: [Habit.Cell]
1216 var theme: ThemeSpec = .default
1217
1218 var body: some View {
1219 HStack(spacing: 1) {
1220 ForEach(cells, id: \.day) { cell in
1221 Text(String(cell.glyph))
1222 .font(.system(size: 9, weight: .bold, design: .monospaced))
1223 .frame(width: 8, height: 14)
1224 .background(color(cell.face))
1225 .help(Self.label(cell))
1226 }
1227 }
1228 .accessibilityElement(children: .ignore)
1229 .accessibilityLabel("Done \(cells.filter { $0.glyph == "*" }.count) of the last \(Habit.precedingDays) days")
1230 }
1231
1232 /// Due-state colors, lighter for days to come, as org-habit's faces.
1233 func color(_ face: Habit.Face) -> Color {
1234 func themed(_ key: String, _ fallback: Color) -> Color { theme.color(key) ?? fallback }
1235 switch face {
1236 case .clear: return themed("habit-clear", .blue).opacity(0.7)
1237 case .clearFuture: return themed("habit-clear", .blue).opacity(0.35)
1238 case .ready: return themed("habit-ready", .green).opacity(0.75)
1239 case .readyFuture: return themed("habit-ready", .green).opacity(0.35)
1240 case .alert: return themed("habit-alert", .yellow).opacity(0.85)
1241 case .alertFuture: return themed("habit-alert", .yellow).opacity(0.4)
1242 case .overdue: return themed("habit-overdue", .red).opacity(0.75)
1243 case .overdueFuture: return themed("habit-overdue", .red).opacity(0.35)
1244 }
1245 }
1246
1247 static func label(_ cell: Habit.Cell) -> String {
1248 let date = Days.date(cell.day)
1249 return String(format: "%04d-%02d-%02d", date.year, date.month, date.day) + (cell.glyph == "*" ? " DONE" : "")
1250 }
1251}