Commit e7870268f3
Verified · cmc
Layout: unified · split
Sources/OrgPresentation/AgendaRows.swift added +319
| @@ -0,0 +1,319 @@ | ||
| 1 | import Foundation | |
| 2 | import OrgCore | |
| 3 | ||
| 4 | /// An event from the system Calendar, read-only (OrgApp's `CalendarEvents` reads them). | |
| 5 | public struct AgendaEvent: Sendable, Equatable, Identifiable { | |
| 6 | public let id: String | |
| 7 | public let title: String | |
| 8 | public let calendarID: String | |
| 9 | public let calendarTitle: String | |
| 10 | /// The calendar's color; nil uses the theme's `agenda-event`. | |
| 11 | public let color: RGBA? | |
| 12 | public let start: Date | |
| 13 | public let end: Date | |
| 14 | public let isAllDay: Bool | |
| 15 | public let location: String? | |
| 16 | ||
| 17 | public init( | |
| 18 | id: String, title: String, calendarID: String, calendarTitle: String, color: RGBA?, start: Date, end: Date, | |
| 19 | isAllDay: Bool, location: String? = nil | |
| 20 | ) { | |
| 21 | self.id = id | |
| 22 | self.title = title | |
| 23 | self.calendarID = calendarID | |
| 24 | self.calendarTitle = calendarTitle | |
| 25 | self.color = color | |
| 26 | self.start = start | |
| 27 | self.end = end | |
| 28 | self.isAllDay = isAllDay | |
| 29 | self.location = location | |
| 30 | } | |
| 31 | ||
| 32 | /// The days (as `Days` numbers them) the event covers; an end at midnight ends the day before. | |
| 33 | public func days(calendar: Calendar) -> ClosedRange<Int> { | |
| 34 | let first = Days.absolute(start, calendar: calendar) | |
| 35 | var last = Days.absolute(end, calendar: calendar) | |
| 36 | if end > start, calendar.startOfDay(for: end) == end { last -= 1 } | |
| 37 | return first...max(first, last) | |
| 38 | } | |
| 39 | } | |
| 40 | ||
| 41 | /// One line of the agenda: an Org entry or a calendar event. | |
| 42 | /// | |
| 43 | /// The gutter holds the time (`08:00`, `13:50–14:30`) or, untimed, a short status: | |
| 44 | /// deadlines `Due today`, `Due in 2d`, `3d late`, `Due` (on a day other than today); | |
| 45 | /// scheduled `Scheduled`, `Sched. 4d ago`; habits `Habit`; timestamps, diary sexps and | |
| 46 | /// calendar events `All day`; ranges `Day 2/5`; log mode `Closed`, `Clocked`, `State`; TODO | |
| 47 | /// and match lists nothing. Ages use `AgendaAge.compact`. | |
| 48 | public struct AgendaRow: Sendable, Equatable, Identifiable { | |
| 49 | /// What the gutter's color says. | |
| 50 | public enum Status: String, Sendable { | |
| 51 | /// Not done, with a deadline or scheduled date before today. | |
| 52 | case overdue | |
| 53 | /// Not done, with a deadline today or within its warning period. | |
| 54 | case dueSoon = "due-soon" | |
| 55 | case normal | |
| 56 | /// A done TODO keyword. | |
| 57 | case done | |
| 58 | } | |
| 59 | ||
| 60 | public enum Icon: String, Sendable { | |
| 61 | case scheduled, deadline, habit, event, timestamp | |
| 62 | } | |
| 63 | ||
| 64 | /// Unique within a day group. | |
| 65 | public internal(set) var id: String | |
| 66 | public let gutter: String | |
| 67 | public let status: Status | |
| 68 | /// Nil for TODO and match list entries. | |
| 69 | public let icon: Icon? | |
| 70 | /// The category line above the title: the entry's category, or the event's calendar. | |
| 71 | public let category: String | |
| 72 | public let title: String | |
| 73 | public let tags: [String] | |
| 74 | public let todo: String? | |
| 75 | public let isDone: Bool | |
| 76 | public let priority: String? | |
| 77 | /// The statistics cookie: `1/3`, `50%`. | |
| 78 | public let progress: String? | |
| 79 | /// `HHMM` of the start and the end, when timed. | |
| 80 | public let start: Int? | |
| 81 | public let end: Int? | |
| 82 | /// The deadline or scheduled day, as `Days` numbers it. | |
| 83 | public let planningDay: Int? | |
| 84 | /// The Org entry, for its actions. | |
| 85 | public let item: AgendaItem? | |
| 86 | public let event: AgendaEvent? | |
| 87 | ||
| 88 | public var isEvent: Bool { event != nil } | |
| 89 | /// The calendar's color for an event. | |
| 90 | public var color: RGBA? { event?.color } | |
| 91 | ||
| 92 | /// Nil for the time grid and the current-time line. | |
| 93 | public init?(item: AgendaItem, day: Int, today: Int) { | |
| 94 | if item.kind == .timeGrid || item.kind == .currentTime { return nil } | |
| 95 | let timed = item.timeOfDay.map { start in (start, Self.endTime(item.time)) } | |
| 96 | let planning = item.planningDay | |
| 97 | let late = planning.map { $0 < today } ?? false | |
| 98 | var icon: Icon? | |
| 99 | var status: Status = .normal | |
| 100 | var untimed = "" | |
| 101 | switch item.kind { | |
| 102 | case .deadline, .upcomingDeadline: | |
| 103 | icon = .deadline | |
| 104 | let due = planning ?? day | |
| 105 | if day == today { | |
| 106 | untimed = due == today ? "Due today" : due < today ? "\(AgendaAge.compact(today - due)) late" : "Due in \(AgendaAge.compact(due - today))" | |
| 107 | } else { | |
| 108 | untimed = "Due" | |
| 109 | } | |
| 110 | status = late ? .overdue : day == today ? .dueSoon : .normal | |
| 111 | case .scheduled, .pastScheduled: | |
| 112 | if item.habit != nil { | |
| 113 | icon = .habit | |
| 114 | untimed = "Habit" | |
| 115 | } else { | |
| 116 | icon = .scheduled | |
| 117 | let on = planning ?? day | |
| 118 | untimed = on < day ? "Sched. \(AgendaAge.compact(day - on)) ago" : "Scheduled" | |
| 119 | } | |
| 120 | status = late ? .overdue : .normal | |
| 121 | case .timestamp, .sexp: | |
| 122 | icon = .timestamp | |
| 123 | untimed = "All day" | |
| 124 | case .block: | |
| 125 | icon = .timestamp | |
| 126 | let ns = item.extra as NSString | |
| 127 | if let m = Self.rangeDay.firstMatch(in: item.extra, range: NSRange(location: 0, length: ns.length)) { | |
| 128 | untimed = "Day " + ns.substring(with: m.range(at: 1)) | |
| 129 | } else { | |
| 130 | untimed = "All day" | |
| 131 | } | |
| 132 | case .closed: (icon, untimed) = (.timestamp, "Closed") | |
| 133 | case .clock: (icon, untimed) = (.timestamp, "Clocked") | |
| 134 | case .state: (icon, untimed) = (.timestamp, "State") | |
| 135 | case .todo, .tagsMatch, .timeGrid, .currentTime: break | |
| 136 | } | |
| 137 | if item.isDone { status = .done } | |
| 138 | self.id = [item.path ?? "", "\(item.headingOffset ?? -1)", "\(item.markerOffset ?? -1)", item.kind.rawValue, item.text].joined(separator: "\u{1f}") | |
| 139 | self.gutter = timed.map { Self.timeText($0.0, $0.1) } ?? untimed | |
| 140 | self.status = status | |
| 141 | self.icon = icon | |
| 142 | self.category = item.category | |
| 143 | self.title = item.title | |
| 144 | self.tags = item.tags | |
| 145 | self.todo = item.todo | |
| 146 | self.isDone = item.isDone | |
| 147 | self.priority = item.priorityLetter | |
| 148 | self.progress = item.progress | |
| 149 | self.start = timed?.0 | |
| 150 | self.end = timed?.1 | |
| 151 | self.planningDay = planning | |
| 152 | self.item = item | |
| 153 | self.event = nil | |
| 154 | } | |
| 155 | ||
| 156 | /// The event's row on `day`: its times on the day it starts (the end only when it ends that | |
| 157 | /// day too), `Until 10:00` on the day a longer event ends, `All day` otherwise. | |
| 158 | public init(event: AgendaEvent, day: Int, calendar: Calendar) { | |
| 159 | let days = event.days(calendar: calendar) | |
| 160 | func hhmm(_ date: Date) -> Int { | |
| 161 | let c = calendar.dateComponents([.hour, .minute], from: date) | |
| 162 | return (c.hour ?? 0) * 100 + (c.minute ?? 0) | |
| 163 | } | |
| 164 | var start: Int? | |
| 165 | var end: Int? | |
| 166 | let gutter: String | |
| 167 | if event.isAllDay || (day != days.lowerBound && day != days.upperBound) { | |
| 168 | gutter = "All day" | |
| 169 | } else if day == days.lowerBound { | |
| 170 | start = hhmm(event.start) | |
| 171 | if days.lowerBound == days.upperBound { end = hhmm(event.end) } | |
| 172 | gutter = Self.timeText(start!, end) | |
| 173 | } else { | |
| 174 | gutter = "Until " + Self.timeText(hhmm(event.end), nil) | |
| 175 | } | |
| 176 | self.id = "event\u{1f}" + event.id | |
| 177 | self.gutter = gutter | |
| 178 | self.status = .normal | |
| 179 | self.icon = .event | |
| 180 | self.category = event.calendarTitle | |
| 181 | self.title = event.title | |
| 182 | self.tags = [] | |
| 183 | self.todo = nil | |
| 184 | self.isDone = false | |
| 185 | self.priority = nil | |
| 186 | self.progress = nil | |
| 187 | self.start = start | |
| 188 | self.end = end | |
| 189 | self.planningDay = nil | |
| 190 | self.item = nil | |
| 191 | self.event = event | |
| 192 | } | |
| 193 | ||
| 194 | static let rangeDay = try! NSRegularExpression(pattern: "^\\(([0-9]+/[0-9]+)\\)") | |
| 195 | static let rangeEnd = try! NSRegularExpression(pattern: "-\\+?([0-9]{1,2}):([0-9]{2})") | |
| 196 | ||
| 197 | /// The end of a `%t` time range (` 9:00-10:30`), as `HHMM`; past midnight (`+1:00`) adds 24 hours. | |
| 198 | static func endTime(_ time: String) -> Int? { | |
| 199 | let ns = time as NSString | |
| 200 | guard let m = rangeEnd.firstMatch(in: time, range: NSRange(location: 0, length: ns.length)) else { return nil } | |
| 201 | let overtime = ns.substring(with: m.range).contains("+") ? 24 : 0 | |
| 202 | return (Int(ns.substring(with: m.range(at: 1)))! + overtime) * 100 + Int(ns.substring(with: m.range(at: 2)))! | |
| 203 | } | |
| 204 | ||
| 205 | /// `08:00`, `13:50–14:30`; hours past midnight wrap. | |
| 206 | static func timeText(_ start: Int, _ end: Int?) -> String { | |
| 207 | func text(_ t: Int) -> String { String(format: "%02d:%02d", (t / 100) % 24, t % 100) } | |
| 208 | return end.map { text(start) + "–" + text($0) } ?? text(start) | |
| 209 | } | |
| 210 | } | |
| 211 | ||
| 212 | /// One day of the agenda: today's entries carried from earlier days in `overdue`, the rest | |
| 213 | /// in `rows`. | |
| 214 | public struct AgendaDayGroup: Sendable, Equatable, Identifiable { | |
| 215 | /// As `Days` numbers it. | |
| 216 | public let day: Int | |
| 217 | public let isToday: Bool | |
| 218 | /// Org's day header: `Thursday 8 October 2026`. | |
| 219 | public let header: String | |
| 220 | /// Today only: deadlines and scheduled entries from before today, oldest first. | |
| 221 | public let overdue: [AgendaRow] | |
| 222 | public let rows: [AgendaRow] | |
| 223 | ||
| 224 | public var id: Int { day } | |
| 225 | public var isEmpty: Bool { overdue.isEmpty && rows.isEmpty } | |
| 226 | ||
| 227 | /// Midnight starting the day. | |
| 228 | public func date(calendar: Calendar) -> Date { | |
| 229 | let d = Days.date(day) | |
| 230 | return calendar.date(from: DateComponents(year: d.year, month: d.month, day: d.day))! | |
| 231 | } | |
| 232 | ||
| 233 | /// Where the current-time line goes in `rows`: before the first timed row starting after | |
| 234 | /// `now` (`HHMM`), or after the last timed row when all have started. Nil on other days | |
| 235 | /// and when no row is timed, as Org adds the line only among timed entries. | |
| 236 | public func nowIndex(now: Int) -> Int? { | |
| 237 | guard isToday else { return nil } | |
| 238 | return AgendaRows.nowIndex(rows, now: now) | |
| 239 | } | |
| 240 | } | |
| 241 | ||
| 242 | public enum AgendaRows { | |
| 243 | /// The days of `Agenda.list` as rows, with `events` merged in. | |
| 244 | /// | |
| 245 | /// - Days with no rows are left out unless `showAllDates` (`org-agenda-show-all-dates`); | |
| 246 | /// today always stays. | |
| 247 | /// - Today's past-scheduled entries and past deadlines (not habits) go in `overdue`, | |
| 248 | /// oldest date first, Org's order among the same date. | |
| 249 | /// - Other rows keep Org's order (timed by time, then untimed by urgency, habits last). | |
| 250 | /// All-day events come first, timed events go among the timed entries by start time, | |
| 251 | /// after entries at the same time. | |
| 252 | /// - `keep` filters the Org entries (an `AgendaFilter`); events are not filtered. | |
| 253 | public static func groups( | |
| 254 | _ days: [AgendaDay], events: [AgendaEvent] = [], today: Int, showAllDates: Bool = false, calendar: Calendar = .current, | |
| 255 | keep: (AgendaItem) -> Bool = { _ in true } | |
| 256 | ) -> [AgendaDayGroup] { | |
| 257 | let eventDays = events.map { ($0, $0.days(calendar: calendar)) } | |
| 258 | var groups: [AgendaDayGroup] = [] | |
| 259 | for day in days { | |
| 260 | var overdue: [AgendaRow] = [] | |
| 261 | var rows: [AgendaRow] = [] | |
| 262 | for item in day.items where keep(item) { | |
| 263 | guard let row = AgendaRow(item: item, day: day.day, today: today) else { continue } | |
| 264 | if day.day == today, item.habit == nil, let planning = row.planningDay, planning < today { | |
| 265 | overdue.append(row) | |
| 266 | } else { | |
| 267 | rows.append(row) | |
| 268 | } | |
| 269 | } | |
| 270 | overdue = overdue.enumerated().sorted { a, b in | |
| 271 | a.element.planningDay! != b.element.planningDay! ? a.element.planningDay! < b.element.planningDay! : a.offset < b.offset | |
| 272 | }.map(\.element) | |
| 273 | let dayEvents = eventDays.filter { $0.1.contains(day.day) }.map { AgendaRow(event: $0.0, day: day.day, calendar: calendar) } | |
| 274 | rows = merge(rows, events: dayEvents) | |
| 275 | let group = AgendaDayGroup(day: day.day, isToday: day.day == today, header: day.header, overdue: unique(overdue), rows: unique(rows)) | |
| 276 | if group.isEmpty, !group.isToday, !showAllDates { continue } | |
| 277 | groups.append(group) | |
| 278 | } | |
| 279 | return groups | |
| 280 | } | |
| 281 | ||
| 282 | /// `org` (in Org's order) with event rows added: untimed events first, timed ones before | |
| 283 | /// the first untimed or habit entry, or the first timed entry starting later. | |
| 284 | static func merge(_ org: [AgendaRow], events: [AgendaRow]) -> [AgendaRow] { | |
| 285 | let allDay = events.filter { $0.start == nil } | |
| 286 | var result = org | |
| 287 | let timed = events.filter { $0.start != nil }.enumerated().sorted { a, b in | |
| 288 | a.element.start! != b.element.start! ? a.element.start! < b.element.start! : a.offset < b.offset | |
| 289 | }.map(\.element) | |
| 290 | for event in timed { | |
| 291 | let at = result.firstIndex { row in | |
| 292 | if row.isEvent { return row.start.map { $0 > event.start! } ?? false } | |
| 293 | guard let start = row.start, row.item?.habit == nil else { return true } | |
| 294 | return start > event.start! | |
| 295 | } ?? result.count | |
| 296 | result.insert(event, at: at) | |
| 297 | } | |
| 298 | return allDay + result | |
| 299 | } | |
| 300 | ||
| 301 | /// Ids made unique: a repeat gets `#2`, `#3`. | |
| 302 | static func unique(_ rows: [AgendaRow]) -> [AgendaRow] { | |
| 303 | var seen: [String: Int] = [:] | |
| 304 | return rows.map { row in | |
| 305 | var row = row | |
| 306 | let n = seen[row.id, default: 0] + 1 | |
| 307 | seen[row.id] = n | |
| 308 | if n > 1 { row.id += "#\(n)" } | |
| 309 | return row | |
| 310 | } | |
| 311 | } | |
| 312 | ||
| 313 | /// See `AgendaDayGroup.nowIndex(now:)`. | |
| 314 | public static func nowIndex(_ rows: [AgendaRow], now: Int) -> Int? { | |
| 315 | let timed = rows.indices.filter { rows[$0].start != nil && rows[$0].item?.habit == nil } | |
| 316 | guard let last = timed.last else { return nil } | |
| 317 | return timed.first { rows[$0].start! > now } ?? last + 1 | |
| 318 | } | |
| 319 | } | |
Sources/OrgPresentation/ThemeSpec.swift +122 −2
| @@ -33,8 +33,8 @@ public struct RGBA: Hashable, Sendable { | ||
| 33 | 33 | } |
| 34 | 34 | |
| 35 | 35 | /// The editor's look: colors for each kind of text in light and dark appearance, colors for |
| 36 | /// TODO keywords, and type. Read from `[theme]`, `[theme.light]`, `[theme.dark]` and | |
| 37 | /// `[theme.todo]` in config.toml, on top of the default theme. | |
| 36 | /// TODO keywords and categories, and type. Read from `[theme]`, `[theme.light]`, `[theme.dark]`, | |
| 37 | /// `[theme.todo]` and `[theme.category]` in config.toml, on top of the default theme. | |
| 38 | 38 | public struct ThemeSpec: Equatable, Sendable { |
| 39 | 39 | /// A font family; empty for the system's monospaced font. |
| 40 | 40 | public var font = "" |
| @@ -47,6 +47,8 @@ public struct ThemeSpec: Equatable, Sendable { | ||
| 47 | 47 | public var dark: [String: RGBA] |
| 48 | 48 | /// `org-todo-keyword-faces`: a color per keyword, the same in both appearances. |
| 49 | 49 | public var todo: [String: RGBA] = [:] |
| 50 | /// A color per agenda category, the same in both appearances, over `categoryColor`'s hue. | |
| 51 | public var category: [String: RGBA] = [:] | |
| 50 | 52 | |
| 51 | 53 | public init(light: [String: RGBA], dark: [String: RGBA]) { |
| 52 | 54 | self.light = light |
| @@ -69,8 +71,14 @@ public struct ThemeSpec: Equatable, Sendable { | ||
| 69 | 71 | ("heading-4", "level 4 headings"), ("heading-5", "level 5 headings"), ("heading-6", "level 6 headings"), |
| 70 | 72 | ("heading-7", "level 7 headings"), ("heading-8", "level 8 and deeper headings"), |
| 71 | 73 | ("todo", "TODO keywords not yet done"), |
| 74 | ("todo-next", "keywords of work under way: NEXT, STRT, STARTED, DOING, ACTIVE"), | |
| 75 | ("todo-waiting", "keywords of work on hold: WAIT, WAITING, HOLD, BLOCKED, DEFERRED, SOMEDAY, MAYBE"), | |
| 72 | 76 | ("done", "DONE keywords"), |
| 77 | ("todo-cancelled", "done keywords that cancel: KILL, CANCELLED, CANCELED, CANCEL, SKIPPED, SKIP, ABORTED, NO, WONTFIX"), | |
| 73 | 78 | ("priority", "[#A] cookies"), |
| 79 | ("priority-a", "agenda: priority A"), | |
| 80 | ("priority-b", "agenda: priority B"), | |
| 81 | ("priority-c", "agenda: priority C"), | |
| 74 | 82 | ("tags", ":tags:"), |
| 75 | 83 | ("link", "links"), |
| 76 | 84 | ("timestamp", "timestamps"), |
| @@ -113,6 +121,9 @@ public struct ThemeSpec: Equatable, Sendable { | ||
| 113 | 121 | ("agenda-upcoming", "deadlines coming up"), |
| 114 | 122 | ("agenda-scheduled", "scheduled items"), |
| 115 | 123 | ("agenda-scheduled-past", "items scheduled on an earlier day"), |
| 124 | ("agenda-overdue", "agenda: the status of entries past their deadline or scheduled date"), | |
| 125 | ("agenda-due-soon", "agenda: the status of deadlines due today or coming up"), | |
| 126 | ("agenda-event", "agenda: calendar events whose calendar has no color"), | |
| 116 | 127 | ("habit-clear", "habit graph: not due yet"), |
| 117 | 128 | ("habit-ready", "habit graph: due"), |
| 118 | 129 | ("habit-alert", "habit graph: due today, last chance"), |
| @@ -138,6 +149,8 @@ public struct ThemeSpec: Equatable, Sendable { | ||
| 138 | 149 | "heading-1": "#0550ae", "heading-2": "#8250df", "heading-3": "#116329", "heading-4": "#953800", |
| 139 | 150 | "heading-5": "#0550ae", "heading-6": "#8250df", "heading-7": "#116329", "heading-8": "#953800", |
| 140 | 151 | "todo": "#cf222e", "done": "#1a7f37", "priority": "#bc4c00", "tags": "#6e7781", |
| 152 | "todo-next": "#0969da", "todo-waiting": "#9a6700", "todo-cancelled": "#6e7781", | |
| 153 | "priority-a": "#cf222e", "priority-b": "#bc4c00", "priority-c": "#57606a", | |
| 141 | 154 | "link": "#0969da", "timestamp": "#8250df", "code": "#953800", "verbatim": "#0a3069", "inline-background": "#afb8c133", |
| 142 | 155 | "markup": "#8c959f", "comment": "#6e7781", "keyword": "#6e7781", "metadata": "#6e7781", "special": "#1b7c83", |
| 143 | 156 | "block-background": "#f6f8fa", "block-delimiter": "#8c959f", "table": "#1f2328", |
| @@ -147,6 +160,7 @@ public struct ThemeSpec: Equatable, Sendable { | ||
| 147 | 160 | "modeline-highlight": "#1f2328", "state-normal": "#0969da", "state-insert": "#1a7f37", "state-visual": "#bc4c00", |
| 148 | 161 | "agenda-date": "#1f2328", "agenda-today": "#0969da", "agenda-time": "#6e7781", "agenda-category": "#6e7781", |
| 149 | 162 | "agenda-deadline": "#cf222e", "agenda-upcoming": "#bc4c00", "agenda-scheduled": "#6e7781", "agenda-scheduled-past": "#953800", |
| 163 | "agenda-overdue": "#cf222e", "agenda-due-soon": "#bc4c00", "agenda-event": "#8250df", | |
| 150 | 164 | "habit-clear": "#54aeff", "habit-ready": "#4ac26b", "habit-alert": "#d4a72c", "habit-overdue": "#ff8182", |
| 151 | 165 | ]), |
| 152 | 166 | dark: palette([ |
| @@ -154,6 +168,8 @@ public struct ThemeSpec: Equatable, Sendable { | ||
| 154 | 168 | "heading-1": "#79c0ff", "heading-2": "#d2a8ff", "heading-3": "#7ee787", "heading-4": "#ffa657", |
| 155 | 169 | "heading-5": "#79c0ff", "heading-6": "#d2a8ff", "heading-7": "#7ee787", "heading-8": "#ffa657", |
| 156 | 170 | "todo": "#ff7b72", "done": "#3fb950", "priority": "#ffa657", "tags": "#8b949e", |
| 171 | "todo-next": "#58a6ff", "todo-waiting": "#d29922", "todo-cancelled": "#8b949e", | |
| 172 | "priority-a": "#ff7b72", "priority-b": "#ffa657", "priority-c": "#8b949e", | |
| 157 | 173 | "link": "#58a6ff", "timestamp": "#d2a8ff", "code": "#ffa657", "verbatim": "#a5d6ff", "inline-background": "#6e768166", |
| 158 | 174 | "markup": "#6e7681", "comment": "#8b949e", "keyword": "#8b949e", "metadata": "#8b949e", "special": "#56d4dd", |
| 159 | 175 | "block-background": "#161b22", "block-delimiter": "#6e7681", "table": "#e6edf3", |
| @@ -163,6 +179,7 @@ public struct ThemeSpec: Equatable, Sendable { | ||
| 163 | 179 | "modeline-highlight": "#e6edf3", "state-normal": "#1f6feb", "state-insert": "#238636", "state-visual": "#9e6a03", |
| 164 | 180 | "agenda-date": "#e6edf3", "agenda-today": "#58a6ff", "agenda-time": "#8b949e", "agenda-category": "#8b949e", |
| 165 | 181 | "agenda-deadline": "#ff7b72", "agenda-upcoming": "#ffa657", "agenda-scheduled": "#8b949e", "agenda-scheduled-past": "#d29922", |
| 182 | "agenda-overdue": "#ff7b72", "agenda-due-soon": "#ffa657", "agenda-event": "#d2a8ff", | |
| 166 | 183 | "habit-clear": "#1f6feb", "habit-ready": "#238636", "habit-alert": "#9e6a03", "habit-overdue": "#da3633", |
| 167 | 184 | ]) |
| 168 | 185 | ) |
| @@ -193,6 +210,8 @@ public struct ThemeSpec: Equatable, Sendable { | ||
| 193 | 210 | switch table.name { |
| 194 | 211 | case "theme.todo": |
| 195 | 212 | spec.todo[key] = color |
| 213 | case "theme.category": | |
| 214 | spec.category[key] = color | |
| 196 | 215 | case "theme", "theme.light", "theme.dark": |
| 197 | 216 | guard known.contains(key) else { |
| 198 | 217 | problems.append("\(file): unknown theme color \(key)") |
| @@ -219,6 +238,10 @@ public struct ThemeSpec: Equatable, Sendable { | ||
| 219 | 238 | # [theme.todo] |
| 220 | 239 | # WAIT = "#bf8700" |
| 221 | 240 | # |
| 241 | # Otherwise a keyword takes its class's color: todo, todo-next, todo-waiting, done or | |
| 242 | # todo-cancelled. [theme.category] colors agenda categories by name the same way; | |
| 243 | # categories without one get a hue of their own from their name. | |
| 244 | # | |
| 222 | 245 | # A theme kept in its own file (with the same tables) is used with |
| 223 | 246 | # `theme-file = "name.toml"` under [theme] in config.toml. |
| 224 | 247 | |
| @@ -238,3 +261,100 @@ public struct ThemeSpec: Equatable, Sendable { | ||
| 238 | 261 | return out |
| 239 | 262 | } |
| 240 | 263 | } |
| 264 | ||
| 265 | // MARK: - Agenda colors | |
| 266 | ||
| 267 | extension ThemeSpec { | |
| 268 | /// The color classes of TODO keywords, by name: keywords of work under way and on hold, | |
| 269 | /// and done keywords that cancel; the others are `todo` or `done`. | |
| 270 | public enum TodoClass: String, Sendable { | |
| 271 | case todo, next, waiting, done, cancelled | |
| 272 | ||
| 273 | /// The theme key. | |
| 274 | public var key: String { | |
| 275 | switch self { | |
| 276 | case .todo: "todo" | |
| 277 | case .next: "todo-next" | |
| 278 | case .waiting: "todo-waiting" | |
| 279 | case .done: "done" | |
| 280 | case .cancelled: "todo-cancelled" | |
| 281 | } | |
| 282 | } | |
| 283 | ||
| 284 | static let nextWords: Set<String> = ["NEXT", "STRT", "STARTED", "START", "DOING", "ACTIVE", "INPROGRESS", "IN-PROGRESS"] | |
| 285 | static let waitingWords: Set<String> = ["WAIT", "WAITING", "HOLD", "ONHOLD", "BLOCKED", "DEFERRED", "SOMEDAY", "MAYBE"] | |
| 286 | static let cancelledWords: Set<String> = ["KILL", "KILLED", "CANCELLED", "CANCELED", "CANCEL", "SKIPPED", "SKIP", "ABORTED", "NO", "WONTFIX"] | |
| 287 | ||
| 288 | public static func of(_ keyword: String, isDone: Bool) -> TodoClass { | |
| 289 | let word = keyword.uppercased() | |
| 290 | if isDone { return cancelledWords.contains(word) ? .cancelled : .done } | |
| 291 | if nextWords.contains(word) { return .next } | |
| 292 | if waitingWords.contains(word) { return .waiting } | |
| 293 | return .todo | |
| 294 | } | |
| 295 | } | |
| 296 | ||
| 297 | /// `[theme.todo]`'s color for the keyword, else its class's. | |
| 298 | public func todoColor(_ keyword: String, isDone: Bool, dark isDark: Bool) -> RGBA? { | |
| 299 | todo[keyword] ?? color(TodoClass.of(keyword, isDone: isDone).key, dark: isDark) | |
| 300 | } | |
| 301 | ||
| 302 | /// `priority-a`, `-b`, `-c`; `priority` for the others. | |
| 303 | public func priorityColor(_ priority: String, dark isDark: Bool) -> RGBA? { | |
| 304 | let key = ["A": "priority-a", "B": "priority-b", "C": "priority-c"][priority] ?? "priority" | |
| 305 | return color(key, dark: isDark) ?? color("priority", dark: isDark) | |
| 306 | } | |
| 307 | ||
| 308 | /// The gutter's color: `agenda-overdue`, `agenda-due-soon`, `done`, else `agenda-time`. | |
| 309 | public func statusColor(_ status: AgendaRow.Status, dark isDark: Bool) -> RGBA? { | |
| 310 | switch status { | |
| 311 | case .overdue: color("agenda-overdue", dark: isDark) | |
| 312 | case .dueSoon: color("agenda-due-soon", dark: isDark) | |
| 313 | case .done: color("done", dark: isDark) | |
| 314 | case .normal: color("agenda-time", dark: isDark) | |
| 315 | } | |
| 316 | } | |
| 317 | ||
| 318 | /// An event's calendar color, else `agenda-event`. | |
| 319 | public func eventColor(_ event: AgendaEvent, dark isDark: Bool) -> RGBA? { | |
| 320 | event.color ?? color("agenda-event", dark: isDark) | |
| 321 | } | |
| 322 | ||
| 323 | /// `[theme.category]`'s color for the category, else its hue (`categoryHue`) at a | |
| 324 | /// saturation and lightness that read on the appearance's background. | |
| 325 | public func categoryColor(_ name: String, dark isDark: Bool) -> RGBA { | |
| 326 | if let set = category[name] { return set } | |
| 327 | let hue = Self.categoryHue(name) | |
| 328 | return isDark ? RGBA(hue: hue, saturation: 0.65, lightness: 0.70) : RGBA(hue: hue, saturation: 0.60, lightness: 0.40) | |
| 329 | } | |
| 330 | ||
| 331 | /// A hue in degrees from the category's name (FNV-1a of its UTF-8), the same on every run | |
| 332 | /// and device. | |
| 333 | public static func categoryHue(_ name: String) -> Double { | |
| 334 | var hash: UInt32 = 2166136261 | |
| 335 | for byte in name.utf8 { | |
| 336 | hash ^= UInt32(byte) | |
| 337 | hash = hash &* 16777619 | |
| 338 | } | |
| 339 | return Double(hash % 360) | |
| 340 | } | |
| 341 | } | |
| 342 | ||
| 343 | extension RGBA { | |
| 344 | /// From HSL: hue in degrees, saturation and lightness 0 to 1. | |
| 345 | public init(hue: Double, saturation s: Double, lightness l: Double, alpha: Double = 1) { | |
| 346 | let c = (1 - abs(2 * l - 1)) * s | |
| 347 | let h = (hue.truncatingRemainder(dividingBy: 360) + 360).truncatingRemainder(dividingBy: 360) / 60 | |
| 348 | let x = c * (1 - abs(h.truncatingRemainder(dividingBy: 2) - 1)) | |
| 349 | let (r, g, b): (Double, Double, Double) = switch Int(h) { | |
| 350 | case 0: (c, x, 0) | |
| 351 | case 1: (x, c, 0) | |
| 352 | case 2: (0, c, x) | |
| 353 | case 3: (0, x, c) | |
| 354 | case 4: (x, 0, c) | |
| 355 | default: (c, 0, x) | |
| 356 | } | |
| 357 | let m = l - c / 2 | |
| 358 | self.init(red: r + m, green: g + m, blue: b + m, alpha: alpha) | |
| 359 | } | |
| 360 | } | |
Tests/OrgPresentationTests/AgendaRowsTests.swift added +196
| @@ -0,0 +1,196 @@ | ||
| 1 | import Foundation | |
| 2 | import OrgCore | |
| 3 | import Testing | |
| 4 | @testable import OrgPresentation | |
| 5 | ||
| 6 | struct AgendaRowsTests { | |
| 7 | static let file = """ | |
| 8 | #+CATEGORY: life | |
| 9 | * TODO [#B] Pay rent [0/2] :home: | |
| 10 | SCHEDULED: <2026-10-01 Thu> | |
| 11 | * TODO Ancient chore | |
| 12 | SCHEDULED: <2025-05-24 Sat> | |
| 13 | * TODO Send forms | |
| 14 | DEADLINE: <2026-10-02 Fri> | |
| 15 | * TODO File taxes | |
| 16 | DEADLINE: <2026-10-05 Mon> | |
| 17 | * TODO Renew passport | |
| 18 | DEADLINE: <2026-10-09 Fri> | |
| 19 | * TODO Water plants | |
| 20 | SCHEDULED: <2026-10-05 Mon> | |
| 21 | * DONE Took out bins | |
| 22 | SCHEDULED: <2026-10-05 Mon> | |
| 23 | * Standup | |
| 24 | SCHEDULED: <2026-10-05 Mon 08:00> | |
| 25 | * Dentist <2026-10-05 Mon 13:50-14:30> | |
| 26 | * Birthday <2026-10-05 Mon> | |
| 27 | * Conference | |
| 28 | <2026-10-04 Sun>--<2026-10-06 Tue> | |
| 29 | * TODO Stretch | |
| 30 | SCHEDULED: <2026-10-04 Sun .+1d> | |
| 31 | :PROPERTIES: | |
| 32 | :STYLE: habit | |
| 33 | :END: | |
| 34 | * Far off | |
| 35 | <2026-10-11 Sun> | |
| 36 | """ | |
| 37 | ||
| 38 | static let today = Days.absolute(year: 2026, month: 10, day: 5) | |
| 39 | static let utc: Calendar = { | |
| 40 | var c = Calendar(identifier: .gregorian) | |
| 41 | c.timeZone = TimeZone(identifier: "UTC")! | |
| 42 | return c | |
| 43 | }() | |
| 44 | ||
| 45 | static func days(start: Int = today, span: Int = 1) -> [AgendaDay] { | |
| 46 | let source = AgendaSource(path: "life.org", text: file, defaults: OrgSettings.default.withTodoKeywords("TODO | DONE")!) | |
| 47 | return Agenda.list([source], start: start, days: span, today: today, now: (10, 0)) | |
| 48 | } | |
| 49 | ||
| 50 | static func rows(day: Int = today) -> [String: AgendaRow] { | |
| 51 | var rows: [String: AgendaRow] = [:] | |
| 52 | for item in days(start: day)[0].items { | |
| 53 | guard let row = AgendaRow(item: item, day: day, today: today) else { continue } | |
| 54 | rows[row.title] = rows[row.title] ?? row | |
| 55 | } | |
| 56 | return rows | |
| 57 | } | |
| 58 | ||
| 59 | static func date(_ day: Int, _ hour: Int, _ minute: Int = 0) -> Date { | |
| 60 | Date(timeIntervalSince1970: Double((day - 719163) * 86400 + hour * 3600 + minute * 60)) | |
| 61 | } | |
| 62 | ||
| 63 | @Test func guttersAndStatusesOnToday() { | |
| 64 | let rows = Self.rows() | |
| 65 | func check(_ title: String, _ gutter: String, _ status: AgendaRow.Status, _ icon: AgendaRow.Icon?, sourceLocation: SourceLocation = #_sourceLocation) { | |
| 66 | let row = rows[title] | |
| 67 | #expect(row?.gutter == gutter, "\(title)", sourceLocation: sourceLocation) | |
| 68 | #expect(row?.status == status, "\(title)", sourceLocation: sourceLocation) | |
| 69 | #expect(row?.icon == icon, "\(title)", sourceLocation: sourceLocation) | |
| 70 | } | |
| 71 | check("Pay rent", "Sched. 4d ago", .overdue, .scheduled) | |
| 72 | check("Ancient chore", "Sched. 1y 4mo ago", .overdue, .scheduled) | |
| 73 | check("Send forms", "3d late", .overdue, .deadline) | |
| 74 | check("File taxes", "Due today", .dueSoon, .deadline) | |
| 75 | check("Renew passport", "Due in 4d", .dueSoon, .deadline) | |
| 76 | check("Water plants", "Scheduled", .normal, .scheduled) | |
| 77 | check("Took out bins", "Scheduled", .done, .scheduled) | |
| 78 | check("Standup", "08:00", .normal, .scheduled) | |
| 79 | check("Dentist", "13:50–14:30", .normal, .timestamp) | |
| 80 | check("Birthday", "All day", .normal, .timestamp) | |
| 81 | check("Conference", "Day 2/3", .normal, .timestamp) | |
| 82 | check("Stretch", "Habit", .overdue, .habit) | |
| 83 | #expect(rows["Dentist"]?.start == 1350 && rows["Dentist"]?.end == 1430) | |
| 84 | let rent = rows["Pay rent"]! | |
| 85 | #expect(rent.category == "life" && rent.tags == ["home"] && rent.todo == "TODO" && rent.priority == "B" && rent.progress == "0/2") | |
| 86 | #expect(!rent.isEvent && rent.item != nil) | |
| 87 | } | |
| 88 | ||
| 89 | @Test func guttersOnOtherDays() { | |
| 90 | let later = Self.rows(day: Self.today + 4) | |
| 91 | #expect(later["Renew passport"]?.gutter == "Due" && later["Renew passport"]?.status == .normal) | |
| 92 | let earlier = Self.rows(day: Self.today - 3) | |
| 93 | #expect(earlier["Send forms"]?.gutter == "Due" && earlier["Send forms"]?.status == .overdue) | |
| 94 | } | |
| 95 | ||
| 96 | @Test func emptyDaysAreHiddenButTodayStays() { | |
| 97 | let days = Self.days(start: Self.today - 1, span: 8) | |
| 98 | let shown = AgendaRows.groups(days, today: Self.today).map(\.day) | |
| 99 | // Sunday has the conference and the habit's first day; Wednesday to Saturday only Friday's deadline. | |
| 100 | #expect(shown == [Self.today - 1, Self.today, Self.today + 1, Self.today + 4, Self.today + 6]) | |
| 101 | #expect(AgendaRows.groups(days, today: Self.today, showAllDates: true).count == 8) | |
| 102 | let empty = AgendaRows.groups(days, today: Self.today, keep: { _ in false }) | |
| 103 | #expect(empty.map(\.day) == [Self.today] && empty[0].isEmpty && empty[0].isToday) | |
| 104 | } | |
| 105 | ||
| 106 | @Test func overdueEntriesGroupAboveTodayOldestFirst() { | |
| 107 | let today = AgendaRows.groups(Self.days(), today: Self.today)[0] | |
| 108 | #expect(today.overdue.map(\.title) == ["Ancient chore", "Pay rent", "Send forms"]) | |
| 109 | #expect(!today.rows.contains { $0.status == .overdue && $0.icon != .habit }) | |
| 110 | #expect(today.rows.contains { $0.title == "Stretch" }) | |
| 111 | #expect(Set(today.rows.map(\.id)).count == today.rows.count) | |
| 112 | } | |
| 113 | ||
| 114 | @Test func eventsMergeByTime() { | |
| 115 | let day = Self.today | |
| 116 | let events = [ | |
| 117 | AgendaEvent(id: "a", title: "Coffee", calendarID: "c1", calendarTitle: "Work", color: RGBA(hex: "#ff0000"), start: Self.date(day, 9), end: Self.date(day, 9, 30), isAllDay: false), | |
| 118 | AgendaEvent(id: "b", title: "Holiday", calendarID: "c2", calendarTitle: "Home", color: nil, start: Self.date(day, 0), end: Self.date(day + 1, 0), isAllDay: true), | |
| 119 | AgendaEvent(id: "c", title: "Flight", calendarID: "c1", calendarTitle: "Work", color: nil, start: Self.date(day - 1, 22), end: Self.date(day + 1, 2), isAllDay: false), | |
| 120 | AgendaEvent(id: "d", title: "Lunch", calendarID: "c1", calendarTitle: "Work", color: nil, start: Self.date(day, 13, 50), end: Self.date(day, 14, 30), isAllDay: false), | |
| 121 | ] | |
| 122 | let groups = AgendaRows.groups(Self.days(start: day - 1, span: 3), events: events, today: day, calendar: Self.utc) | |
| 123 | let today = groups.first { $0.isToday }! | |
| 124 | #expect(today.rows.prefix(2).map(\.title) == ["Holiday", "Flight"]) | |
| 125 | #expect(today.rows.map(\.gutter).prefix(6) == ["All day", "All day", "08:00", "09:00–09:30", "13:50–14:30", "13:50–14:30"]) | |
| 126 | #expect(today.rows[4].title == "Dentist" && today.rows[5].title == "Lunch") | |
| 127 | #expect(today.rows[3].isEvent && today.rows[3].category == "Work" && today.rows[3].color == RGBA(hex: "#ff0000") && today.rows[3].icon == .event) | |
| 128 | let before = groups.first { $0.day == day - 1 }! | |
| 129 | #expect(before.rows.first { $0.title == "Flight" }?.gutter == "22:00") | |
| 130 | let after = groups.first { $0.day == day + 1 }! | |
| 131 | #expect(after.rows.first { $0.title == "Flight" }?.gutter == "Until 02:00") | |
| 132 | // The all-day event ends at midnight: one day only. | |
| 133 | #expect(!after.rows.contains { $0.title == "Holiday" }) | |
| 134 | } | |
| 135 | ||
| 136 | @Test func nowLineGoesAmongTimedRows() { | |
| 137 | let today = AgendaRows.groups(Self.days(), today: Self.today)[0] | |
| 138 | let timed = today.rows.indices.filter { today.rows[$0].start != nil } | |
| 139 | #expect(today.nowIndex(now: 700) == timed[0]) | |
| 140 | #expect(today.nowIndex(now: 800) == timed[1]) | |
| 141 | #expect(today.nowIndex(now: 1000) == timed[1]) | |
| 142 | #expect(today.nowIndex(now: 1500) == timed.last! + 1) | |
| 143 | #expect(AgendaRows.nowIndex([], now: 900) == nil) | |
| 144 | let later = AgendaRows.groups(Self.days(start: Self.today + 4), today: Self.today)[0] | |
| 145 | #expect(later.nowIndex(now: 900) == nil) | |
| 146 | } | |
| 147 | } | |
| 148 | ||
| 149 | struct AgendaThemeTests { | |
| 150 | @Test func newKeysHaveDefaultsInBothAppearances() { | |
| 151 | for key in ["todo-next", "todo-waiting", "todo-cancelled", "priority-a", "priority-b", "priority-c", "agenda-overdue", "agenda-due-soon", "agenda-event", "agenda-today"] { | |
| 152 | #expect(ThemeSpec.default.light[key] != nil && ThemeSpec.default.dark[key] != nil, "\(key)") | |
| 153 | } | |
| 154 | } | |
| 155 | ||
| 156 | @Test func keywordClasses() { | |
| 157 | #expect(ThemeSpec.TodoClass.of("TODO", isDone: false) == .todo) | |
| 158 | #expect(ThemeSpec.TodoClass.of("STRT", isDone: false) == .next) | |
| 159 | #expect(ThemeSpec.TodoClass.of("NEXT", isDone: false) == .next) | |
| 160 | #expect(ThemeSpec.TodoClass.of("WAIT", isDone: false) == .waiting) | |
| 161 | #expect(ThemeSpec.TodoClass.of("HOLD", isDone: false) == .waiting) | |
| 162 | #expect(ThemeSpec.TodoClass.of("DONE", isDone: true) == .done) | |
| 163 | #expect(ThemeSpec.TodoClass.of("KILL", isDone: true) == .cancelled) | |
| 164 | #expect(ThemeSpec.TodoClass.of("SKIPPED", isDone: true) == .cancelled) | |
| 165 | // Only done keywords cancel. | |
| 166 | #expect(ThemeSpec.TodoClass.of("SKIPPED", isDone: false) == .todo) | |
| 167 | } | |
| 168 | ||
| 169 | @Test func colorsResolveOverridesFirst() throws { | |
| 170 | let (spec, problems) = ThemeSpec.reading(try TOML.parse(""" | |
| 171 | [theme.todo] | |
| 172 | WAIT = "#010203" | |
| 173 | [theme.category] | |
| 174 | work = "#0a0b0c" | |
| 175 | """)) | |
| 176 | #expect(problems.isEmpty) | |
| 177 | #expect(spec.todoColor("WAIT", isDone: false, dark: true)?.hex == "#010203") | |
| 178 | #expect(spec.todoColor("HOLD", isDone: false, dark: true) == ThemeSpec.default.dark["todo-waiting"]) | |
| 179 | #expect(spec.todoColor("KILL", isDone: true, dark: false) == ThemeSpec.default.light["todo-cancelled"]) | |
| 180 | #expect(spec.categoryColor("work", dark: false).hex == "#0a0b0c" && spec.categoryColor("work", dark: true).hex == "#0a0b0c") | |
| 181 | #expect(spec.priorityColor("A", dark: false) == ThemeSpec.default.light["priority-a"]) | |
| 182 | #expect(spec.priorityColor("D", dark: false) == ThemeSpec.default.light["priority"]) | |
| 183 | #expect(spec.statusColor(.overdue, dark: true) == ThemeSpec.default.dark["agenda-overdue"]) | |
| 184 | let event = AgendaEvent(id: "x", title: "", calendarID: "", calendarTitle: "", color: nil, start: Date(), end: Date(), isAllDay: true) | |
| 185 | #expect(spec.eventColor(event, dark: false) == ThemeSpec.default.light["agenda-event"]) | |
| 186 | } | |
| 187 | ||
| 188 | @Test func categoryHuesAreStable() { | |
| 189 | #expect(ThemeSpec.categoryHue("work") == 264) | |
| 190 | #expect(ThemeSpec.categoryHue("home") == 206) | |
| 191 | #expect(ThemeSpec.default.categoryColor("home", dark: false) == ThemeSpec.default.categoryColor("home", dark: false)) | |
| 192 | #expect(ThemeSpec.default.categoryColor("home", dark: false) != ThemeSpec.default.categoryColor("home", dark: true)) | |
| 193 | #expect(RGBA(hue: 0, saturation: 1, lightness: 0.5).hex == "#ff0000") | |
| 194 | #expect(RGBA(hue: 240, saturation: 1, lightness: 0.5).hex == "#0000ff") | |
| 195 | } | |
| 196 | } | |