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