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 | /// The editor's look: colors for each kind of text in light and dark appearance, colors for | 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 | 36 | /// TODO keywords and categories, and type. Read from `[theme]`, `[theme.light]`, `[theme.dark]`, |
| 37 | /// `[theme.todo]` in config.toml, on top of the default theme. | 37 | /// `[theme.todo]` and `[theme.category]` in config.toml, on top of the default theme. |
| 38 | public struct ThemeSpec: Equatable, Sendable { | 38 | public struct ThemeSpec: Equatable, Sendable { |
| 39 | /// A font family; empty for the system's monospaced font. | 39 | /// A font family; empty for the system's monospaced font. |
| 40 | public var font = "" | 40 | public var font = "" |
| @@ -47,6 +47,8 @@ public struct ThemeSpec: Equatable, Sendable { | |||
| 47 | public var dark: [String: RGBA] | 47 | public var dark: [String: RGBA] |
| 48 | /// `org-todo-keyword-faces`: a color per keyword, the same in both appearances. | 48 | /// `org-todo-keyword-faces`: a color per keyword, the same in both appearances. |
| 49 | public var todo: [String: RGBA] = [:] | 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 | public init(light: [String: RGBA], dark: [String: RGBA]) { | 53 | public init(light: [String: RGBA], dark: [String: RGBA]) { |
| 52 | self.light = light | 54 | self.light = light |
| @@ -69,8 +71,14 @@ public struct ThemeSpec: Equatable, Sendable { | |||
| 69 | ("heading-4", "level 4 headings"), ("heading-5", "level 5 headings"), ("heading-6", "level 6 headings"), | 71 | ("heading-4", "level 4 headings"), ("heading-5", "level 5 headings"), ("heading-6", "level 6 headings"), |
| 70 | ("heading-7", "level 7 headings"), ("heading-8", "level 8 and deeper headings"), | 72 | ("heading-7", "level 7 headings"), ("heading-8", "level 8 and deeper headings"), |
| 71 | ("todo", "TODO keywords not yet done"), | 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 | ("done", "DONE keywords"), | 76 | ("done", "DONE keywords"), |
| 77 | ("todo-cancelled", "done keywords that cancel: KILL, CANCELLED, CANCELED, CANCEL, SKIPPED, SKIP, ABORTED, NO, WONTFIX"), | ||
| 73 | ("priority", "[#A] cookies"), | 78 | ("priority", "[#A] cookies"), |
| 79 | ("priority-a", "agenda: priority A"), | ||
| 80 | ("priority-b", "agenda: priority B"), | ||
| 81 | ("priority-c", "agenda: priority C"), | ||
| 74 | ("tags", ":tags:"), | 82 | ("tags", ":tags:"), |
| 75 | ("link", "links"), | 83 | ("link", "links"), |
| 76 | ("timestamp", "timestamps"), | 84 | ("timestamp", "timestamps"), |
| @@ -113,6 +121,9 @@ public struct ThemeSpec: Equatable, Sendable { | |||
| 113 | ("agenda-upcoming", "deadlines coming up"), | 121 | ("agenda-upcoming", "deadlines coming up"), |
| 114 | ("agenda-scheduled", "scheduled items"), | 122 | ("agenda-scheduled", "scheduled items"), |
| 115 | ("agenda-scheduled-past", "items scheduled on an earlier day"), | 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 | ("habit-clear", "habit graph: not due yet"), | 127 | ("habit-clear", "habit graph: not due yet"), |
| 117 | ("habit-ready", "habit graph: due"), | 128 | ("habit-ready", "habit graph: due"), |
| 118 | ("habit-alert", "habit graph: due today, last chance"), | 129 | ("habit-alert", "habit graph: due today, last chance"), |
| @@ -138,6 +149,8 @@ public struct ThemeSpec: Equatable, Sendable { | |||
| 138 | "heading-1": "#0550ae", "heading-2": "#8250df", "heading-3": "#116329", "heading-4": "#953800", | 149 | "heading-1": "#0550ae", "heading-2": "#8250df", "heading-3": "#116329", "heading-4": "#953800", |
| 139 | "heading-5": "#0550ae", "heading-6": "#8250df", "heading-7": "#116329", "heading-8": "#953800", | 150 | "heading-5": "#0550ae", "heading-6": "#8250df", "heading-7": "#116329", "heading-8": "#953800", |
| 140 | "todo": "#cf222e", "done": "#1a7f37", "priority": "#bc4c00", "tags": "#6e7781", | 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 | "link": "#0969da", "timestamp": "#8250df", "code": "#953800", "verbatim": "#0a3069", "inline-background": "#afb8c133", | 154 | "link": "#0969da", "timestamp": "#8250df", "code": "#953800", "verbatim": "#0a3069", "inline-background": "#afb8c133", |
| 142 | "markup": "#8c959f", "comment": "#6e7781", "keyword": "#6e7781", "metadata": "#6e7781", "special": "#1b7c83", | 155 | "markup": "#8c959f", "comment": "#6e7781", "keyword": "#6e7781", "metadata": "#6e7781", "special": "#1b7c83", |
| 143 | "block-background": "#f6f8fa", "block-delimiter": "#8c959f", "table": "#1f2328", | 156 | "block-background": "#f6f8fa", "block-delimiter": "#8c959f", "table": "#1f2328", |
| @@ -147,6 +160,7 @@ public struct ThemeSpec: Equatable, Sendable { | |||
| 147 | "modeline-highlight": "#1f2328", "state-normal": "#0969da", "state-insert": "#1a7f37", "state-visual": "#bc4c00", | 160 | "modeline-highlight": "#1f2328", "state-normal": "#0969da", "state-insert": "#1a7f37", "state-visual": "#bc4c00", |
| 148 | "agenda-date": "#1f2328", "agenda-today": "#0969da", "agenda-time": "#6e7781", "agenda-category": "#6e7781", | 161 | "agenda-date": "#1f2328", "agenda-today": "#0969da", "agenda-time": "#6e7781", "agenda-category": "#6e7781", |
| 149 | "agenda-deadline": "#cf222e", "agenda-upcoming": "#bc4c00", "agenda-scheduled": "#6e7781", "agenda-scheduled-past": "#953800", | 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 | "habit-clear": "#54aeff", "habit-ready": "#4ac26b", "habit-alert": "#d4a72c", "habit-overdue": "#ff8182", | 164 | "habit-clear": "#54aeff", "habit-ready": "#4ac26b", "habit-alert": "#d4a72c", "habit-overdue": "#ff8182", |
| 151 | ]), | 165 | ]), |
| 152 | dark: palette([ | 166 | dark: palette([ |
| @@ -154,6 +168,8 @@ public struct ThemeSpec: Equatable, Sendable { | |||
| 154 | "heading-1": "#79c0ff", "heading-2": "#d2a8ff", "heading-3": "#7ee787", "heading-4": "#ffa657", | 168 | "heading-1": "#79c0ff", "heading-2": "#d2a8ff", "heading-3": "#7ee787", "heading-4": "#ffa657", |
| 155 | "heading-5": "#79c0ff", "heading-6": "#d2a8ff", "heading-7": "#7ee787", "heading-8": "#ffa657", | 169 | "heading-5": "#79c0ff", "heading-6": "#d2a8ff", "heading-7": "#7ee787", "heading-8": "#ffa657", |
| 156 | "todo": "#ff7b72", "done": "#3fb950", "priority": "#ffa657", "tags": "#8b949e", | 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 | "link": "#58a6ff", "timestamp": "#d2a8ff", "code": "#ffa657", "verbatim": "#a5d6ff", "inline-background": "#6e768166", | 173 | "link": "#58a6ff", "timestamp": "#d2a8ff", "code": "#ffa657", "verbatim": "#a5d6ff", "inline-background": "#6e768166", |
| 158 | "markup": "#6e7681", "comment": "#8b949e", "keyword": "#8b949e", "metadata": "#8b949e", "special": "#56d4dd", | 174 | "markup": "#6e7681", "comment": "#8b949e", "keyword": "#8b949e", "metadata": "#8b949e", "special": "#56d4dd", |
| 159 | "block-background": "#161b22", "block-delimiter": "#6e7681", "table": "#e6edf3", | 175 | "block-background": "#161b22", "block-delimiter": "#6e7681", "table": "#e6edf3", |
| @@ -163,6 +179,7 @@ public struct ThemeSpec: Equatable, Sendable { | |||
| 163 | "modeline-highlight": "#e6edf3", "state-normal": "#1f6feb", "state-insert": "#238636", "state-visual": "#9e6a03", | 179 | "modeline-highlight": "#e6edf3", "state-normal": "#1f6feb", "state-insert": "#238636", "state-visual": "#9e6a03", |
| 164 | "agenda-date": "#e6edf3", "agenda-today": "#58a6ff", "agenda-time": "#8b949e", "agenda-category": "#8b949e", | 180 | "agenda-date": "#e6edf3", "agenda-today": "#58a6ff", "agenda-time": "#8b949e", "agenda-category": "#8b949e", |
| 165 | "agenda-deadline": "#ff7b72", "agenda-upcoming": "#ffa657", "agenda-scheduled": "#8b949e", "agenda-scheduled-past": "#d29922", | 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 | "habit-clear": "#1f6feb", "habit-ready": "#238636", "habit-alert": "#9e6a03", "habit-overdue": "#da3633", | 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 | switch table.name { | 210 | switch table.name { |
| 194 | case "theme.todo": | 211 | case "theme.todo": |
| 195 | spec.todo[key] = color | 212 | spec.todo[key] = color |
| 213 | case "theme.category": | ||
| 214 | spec.category[key] = color | ||
| 196 | case "theme", "theme.light", "theme.dark": | 215 | case "theme", "theme.light", "theme.dark": |
| 197 | guard known.contains(key) else { | 216 | guard known.contains(key) else { |
| 198 | problems.append("\(file): unknown theme color \(key)") | 217 | problems.append("\(file): unknown theme color \(key)") |
| @@ -219,6 +238,10 @@ public struct ThemeSpec: Equatable, Sendable { | |||
| 219 | # [theme.todo] | 238 | # [theme.todo] |
| 220 | # WAIT = "#bf8700" | 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 | # A theme kept in its own file (with the same tables) is used with | 245 | # A theme kept in its own file (with the same tables) is used with |
| 223 | # `theme-file = "name.toml"` under [theme] in config.toml. | 246 | # `theme-file = "name.toml"` under [theme] in config.toml. |
| 224 | 247 | ||
| @@ -238,3 +261,100 @@ public struct ThemeSpec: Equatable, Sendable { | |||
| 238 | return out | 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 | } | ||