krz/orgstar

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

Sources/Orgstar/AgendaView.swift

322a87d47526d505d903e05d14e65a5464e1468f
orgstar/Sources/Orgstar/AgendaView.swift history · blame · raw

1251 lines · 54630 bytes

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