krz/orgstar

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

Commit e7870268f3

e7870268f3b6277a382f0ba2450af4c87d4ab418

parent: 10ef90cc41

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-09 04:50 UTC

Agenda rows, day groups and agenda theme keys

AgendaRow holds a row's gutter, status, icon, category, clean title,
tags, keyword and priority for Org entries and calendar events.
AgendaRows.groups hides empty days except today, puts today's
overdue entries in their own group and merges events by time.
ThemeSpec gains keyword class, priority, overdue, due-soon and event
colors, and [theme.category] with hashed hues.

Layout: unified · split

Sources/OrgPresentation/AgendaRows.swift added +319
@@ -0,0 +1,319 @@
1import Foundation
2import OrgCore
3
4/// An event from the system Calendar, read-only (OrgApp's `CalendarEvents` reads them).
5public 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`.
48public 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`.
214public 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
242public 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.
38public struct ThemeSpec: Equatable, Sendable { 38public 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
267extension 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
343extension 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 @@
1import Foundation
2import OrgCore
3import Testing
4@testable import OrgPresentation
5
6struct 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
149struct 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}