krz/orgstar

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

Sources/Orgstar/AgendaView.swift

3a3dedba062790e1580c66d38606eda4b02cb501
orgstar/Sources/Orgstar/AgendaView.swift history · blame · raw

1249 lines · 53992 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    @State 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 = Self.relativeName(group.day, 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 relativeName(_ day: Int, today: Int) -> String? {
 618        switch day - today {
 619        case 0: "Today"
 620        case 1: "Tomorrow"
 621        case -1: "Yesterday"
 622        default: nil
 623        }
 624    }
 625
 626    static func hhmm(_ date: Date) -> Int {
 627        let c = Calendar.current.dateComponents([.hour, .minute], from: date)
 628        return (c.hour ?? 0) * 100 + (c.minute ?? 0)
 629    }
 630
 631    @AppStorage("reminders") private var remindersEnabled = true
 632
 633    /// How far ahead notifications are set, as the design asks the agenda to show.
 634    private var reminderStatus: String? {
 635        guard let scheduler, remindersEnabled else { return nil }
 636        if !scheduler.isAuthorized { return "Notifications are off for Orgstar in System Settings." }
 637        guard let through = scheduler.scheduledThrough else { return nil }
 638        return "Reminders are set through " + through.formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated).hour().minute()) + "."
 639    }
 640
 641    private var listTitle: String {
 642        switch agenda.mode {
 643        case .todo(let keywords): "TODO items: \(keywords ?? "all")"
 644        case .match(let match, let todoOnly): (todoOnly ? "TODO entries" : "Entries") + " matching \(match)"
 645        case .agenda: ""
 646        }
 647    }
 648
 649    private func loadViews() {
 650        let loaded = AgendaViewLoader.load()
 651        views = loaded.views
 652        message = loaded.problems.first
 653    }
 654
 655    /// The days of a followed timestamp, in the agenda view.
 656    private func showRequestedDays() {
 657        guard let request = commands.agendaRequest else { return }
 658        commands.agendaRequest = nil
 659        selection = nil
 660        spanKind = nil
 661        if viewName != AgendaViewLoader.builtIn[0].name {
 662            keepRequestedDays = true
 663            viewName = AgendaViewLoader.builtIn[0].name
 664        }
 665        agenda.mode = .agenda(span: request.days, startOffset: nil)
 666        agenda.start = request.start
 667    }
 668
 669    private func applyView() {
 670        if keepRequestedDays { return keepRequestedDays = false }
 671        selection = nil
 672        spanKind = nil
 673        agenda.start = nil
 674        switch viewName {
 675        case Self.match, Self.todoMatch:
 676            let match = matchText.trimmingCharacters(in: .whitespaces)
 677            guard !match.isEmpty else { return }
 678            agenda.mode = .match(match, todoOnly: viewName == Self.todoMatch)
 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        .background {
1100            RoundedRectangle(cornerRadius: 6)
1101                .fill(selected ? theme.color("selection") ?? Color.accentColor.opacity(0.2) : hovered ? Color.primary.opacity(0.05) : .clear)
1102                .padding(.horizontal, 6)
1103        }
1104        .overlay(alignment: .leading) {
1105            if marked {
1106                Capsule().fill(theme.color("agenda-today") ?? .accentColor).frame(width: 3).padding(.vertical, 8).padding(.leading, 8)
1107            }
1108        }
1109        .accessibilityElement(children: .combine)
1110    }
1111
1112    private var statusColor: Color {
1113        theme.dynamic(.secondaryLabelColor) { [theme, row] in theme.statusColor(row.status, dark: $0) }
1114    }
1115
1116    private var gutter: some View {
1117        VStack(alignment: .leading, spacing: 3) {
1118            Text(row.gutter)
1119                .font(.system(size: 12, weight: row.status == .overdue || row.status == .dueSoon ? .semibold : .regular).monospacedDigit())
1120                .foregroundStyle(statusColor)
1121                .lineLimit(1)
1122                .minimumScaleFactor(0.8)
1123            if let symbol {
1124                Image(systemName: symbol)
1125                    .font(.system(size: row.icon == .event ? 8 : 10, weight: .semibold))
1126                    .foregroundStyle(iconColor)
1127                    .frame(height: 12)
1128            }
1129        }
1130        .frame(width: Self.gutterWidth, alignment: .leading)
1131    }
1132
1133    /// Overdue entries show a warning, done ones a check, as the status says first.
1134    private var symbol: String? {
1135        guard let icon = row.icon else { return nil }
1136        if row.status == .overdue { return "exclamationmark.circle.fill" }
1137        if row.status == .done { return "checkmark.circle.fill" }
1138        switch icon {
1139        case .scheduled: return "calendar"
1140        case .deadline: return "flag.fill"
1141        case .habit: return "repeat"
1142        case .event: return "circle.fill"
1143        case .timestamp: return row.start == nil ? "calendar" : "clock"
1144        }
1145    }
1146
1147    private var iconColor: Color {
1148        if let event = row.event { return theme.dynamic(.systemPurple) { [theme] in theme.eventColor(event, dark: $0) } }
1149        return row.status == .normal ? Color.secondary.opacity(0.8) : statusColor
1150    }
1151
1152    private var categoryLine: some View {
1153        HStack(spacing: 5) {
1154            Circle()
1155                .fill(row.event.map { event in theme.dynamic(.systemPurple) { [theme] in theme.eventColor(event, dark: $0) } }
1156                    ?? theme.dynamic { [theme, row] in theme.categoryColor(row.category, dark: $0) })
1157                .frame(width: 6, height: 6)
1158            Text(row.category + (row.event?.location.map { " · " + $0 } ?? ""))
1159                .font(.system(size: 11))
1160                .foregroundStyle(theme.color("agenda-category") ?? .secondary)
1161                .lineLimit(1)
1162        }
1163    }
1164
1165    private var titleLine: some View {
1166        HStack(spacing: 6) {
1167            Text(row.title.isEmpty ? " " : row.title)
1168                .font(.system(size: 13))
1169                .strikethrough(row.isDone)
1170                .foregroundStyle(row.isDone ? AnyShapeStyle(.secondary) : AnyShapeStyle(theme.color("foreground") ?? .primary))
1171                .lineLimit(1)
1172                .truncationMode(.tail)
1173            if let progress = row.progress {
1174                Text(progress)
1175                    .font(.system(size: 10, weight: .semibold).monospacedDigit())
1176                    .foregroundStyle(.secondary)
1177                    .padding(.horizontal, 6)
1178                    .padding(.vertical, 1)
1179                    .background(Capsule().fill(Color.secondary.opacity(0.14)))
1180                    .fixedSize()
1181            }
1182            ForEach(row.tags, id: \.self) { tag in
1183                Text(tag)
1184                    .font(.system(size: 10, weight: .medium))
1185                    .foregroundStyle(theme.color("tags") ?? .secondary)
1186                    .padding(.horizontal, 6)
1187                    .padding(.vertical, 1)
1188                    .background(Capsule().strokeBorder((theme.color("tags") ?? .secondary).opacity(0.35), lineWidth: 0.5))
1189                    .fixedSize()
1190            }
1191        }
1192    }
1193
1194    @ViewBuilder
1195    private var priorityBadge: some View {
1196        if let priority = row.priority {
1197            let color = theme.dynamic(.systemOrange) { [theme] in theme.priorityColor(priority, dark: $0) }
1198            Text(priority)
1199                .font(.system(size: 10, weight: .bold))
1200                .foregroundStyle(color)
1201                .frame(width: 18, height: 18)
1202                .background(Circle().fill(color.opacity(0.14)))
1203                .overlay(Circle().strokeBorder(color.opacity(0.6), lineWidth: 1))
1204                .help("Priority \(priority)")
1205        } else {
1206            Color.clear.frame(width: 18, height: 18)
1207        }
1208    }
1209}
1210
1211/// org-habit's consistency graph: a cell per day, `*` where the habit was done, `!` today.
1212struct HabitGraph: View {
1213    let cells: [Habit.Cell]
1214    var theme: ThemeSpec = .default
1215
1216    var body: some View {
1217        HStack(spacing: 1) {
1218            ForEach(cells, id: \.day) { cell in
1219                Text(String(cell.glyph))
1220                    .font(.system(size: 9, weight: .bold, design: .monospaced))
1221                    .frame(width: 8, height: 14)
1222                    .background(color(cell.face))
1223                    .help(Self.label(cell))
1224            }
1225        }
1226        .accessibilityElement(children: .ignore)
1227        .accessibilityLabel("Done \(cells.filter { $0.glyph == "*" }.count) of the last \(Habit.precedingDays) days")
1228    }
1229
1230    /// Due-state colors, lighter for days to come, as org-habit's faces.
1231    func color(_ face: Habit.Face) -> Color {
1232        func themed(_ key: String, _ fallback: Color) -> Color { theme.color(key) ?? fallback }
1233        switch face {
1234        case .clear: return themed("habit-clear", .blue).opacity(0.7)
1235        case .clearFuture: return themed("habit-clear", .blue).opacity(0.35)
1236        case .ready: return themed("habit-ready", .green).opacity(0.75)
1237        case .readyFuture: return themed("habit-ready", .green).opacity(0.35)
1238        case .alert: return themed("habit-alert", .yellow).opacity(0.85)
1239        case .alertFuture: return themed("habit-alert", .yellow).opacity(0.4)
1240        case .overdue: return themed("habit-overdue", .red).opacity(0.75)
1241        case .overdueFuture: return themed("habit-overdue", .red).opacity(0.35)
1242        }
1243    }
1244
1245    static func label(_ cell: Habit.Cell) -> String {
1246        let date = Days.date(cell.day)
1247        return String(format: "%04d-%02d-%02d", date.year, date.month, date.day) + (cell.glyph == "*" ? " DONE" : "")
1248    }
1249}