krz/orgstar

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

Commit 2415257464

241525746419a10a1816afce1593ecb62ebea46b

parent: c547244f4d

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-06 02:50 UTC

Agenda prefix format and log mode

- org-agenda-prefix-format for the agenda, TODO list and tag matches:
  %c %t %s %e %l %b %i with optional ?, widths, .N category limits and
  punctuation, compiled as org-compile-prefix-format. Config table
  [org-agenda-prefix-format]; the Emacs import reads the alist or a
  string. A non-default format draws the prefix as Emacs prints it
  instead of the agenda's columns.
- Log mode (l): closed entries and clock lines on each day, with the
  clocked time and the first line of a note, as org-agenda-get-progress.

Oracle tests against Org 9.8.7.

Layout: unified · split

Sources/OrgApp/AgendaModel.swift +2 −2
@@ -65,9 +65,9 @@ public final class AgendaModel {
6565 case .agenda:
6666 days = Agenda.list(all, start: first, days: span, today: today, now: (clock.hour ?? 0, clock.minute ?? 0), options: options)
6767 case .todo(let keywords):
68 list = Agenda.todoList(all, keywords: keywords)
68 list = Agenda.todoList(all, keywords: keywords, options: options)
6969 case .match(let match, let todoOnly):
70 list = Agenda.tagsMatch(all, match: match, todoOnly: todoOnly, now: now, calendar: calendar)
70 list = Agenda.tagsMatch(all, match: match, todoOnly: todoOnly, now: now, calendar: calendar, options: options)
7171 }
7272 return (days, list)
7373 }.value
Sources/OrgApp/EmacsImport.swift +10 −1
@@ -460,6 +460,15 @@ public struct EmacsImport: Sendable {
460460 } else {
461461 add(name, .bool(truthy))
462462 }
463 case "org-agenda-prefix-format":
464 if let s = value.string {
465 for view in ["agenda", "todo", "tags"] { add("org-agenda-prefix-format.\(view)", .string(s)) }
466 } else {
467 for entry in value.elements ?? [] {
468 guard case .dotted(let key, let format) = entry, let view = key.first?.symbol, let s = format.string else { continue }
469 if ["agenda", "todo", "tags"].contains(view) { add("org-agenda-prefix-format.\(view)", .string(s)) }
470 }
471 }
463472 case "org-log-done":
464473 switch value.symbol {
465474 case "nil": add(name, .string("nil"))
@@ -622,7 +631,7 @@ extension EmacsImport {
622631 var problems: [String] = []
623632 for item in items {
624633 guard case .setting(let key, let value) = item.change,
625 let setting = UserConfig.settings.first(where: { $0.key == key }) else { continue }
634 let setting = UserConfig.settings.first(where: { $0.path == key || $0.key == key }) else { continue }
626635 if let problem = UserConfig.store(value, setting, in: defaults) { problems.append(problem) } else { settings += 1 }
627636 }
628637 for item in items {
Sources/OrgApp/UserConfig.swift +6
@@ -90,6 +90,12 @@ public enum UserConfig {
9090 comment: "the agenda's first day: \"-3d\", \"+0d\"", legacy: "agenda.start-offset"),
9191 Setting(section: "", key: "appt-message-warning-time", defaultsKey: "reminderLead", kind: .integer, fallback: .integer(12),
9292 comment: "minutes of warning before timed entries; APPT_WARNTIME overrides", legacy: "agenda.reminder-lead"),
93 Setting(section: "org-agenda-prefix-format", key: "agenda", defaultsKey: "agendaPrefixAgenda", kind: .string, fallback: .string(PrefixFormat.defaults["agenda"]!),
94 comment: "%c category, %t time, %s scheduled/deadline, %e effort, %l level, %b outline path; %-12 pads", legacy: ""),
95 Setting(section: "org-agenda-prefix-format", key: "todo", defaultsKey: "agendaPrefixTodo", kind: .string, fallback: .string(PrefixFormat.defaults["todo"]!),
96 comment: "the TODO list", legacy: ""),
97 Setting(section: "org-agenda-prefix-format", key: "tags", defaultsKey: "agendaPrefixTags", kind: .string, fallback: .string(PrefixFormat.defaults["tags"]!),
98 comment: "tag and property matches", legacy: ""),
9399 Setting(section: "theme", key: "font", defaultsKey: "themeFont", kind: .string, fallback: .string(""),
94100 comment: "a monospaced font family; \"\" for the system's", legacy: ""),
95101 Setting(section: "theme", key: "font-size", defaultsKey: "themeFontSize", kind: .integer, fallback: .integer(13),
Sources/OrgCore/Agenda/Agenda.swift +84 −14
@@ -12,6 +12,8 @@ public struct AgendaItem: Sendable, Equatable {
1212 case currentTime
1313 case todo
1414 case tagsMatch = "tagsmatch"
15 /// Log mode: a closed entry, a clock line, a state change.
16 case closed, clock, state
1517 }
1618
1719 public let kind: Kind
@@ -41,6 +43,8 @@ public struct AgendaItem: Sendable, Equatable {
4143 public let habit: Habit?
4244 /// The line as org prints it, with one space before the tags.
4345 public let line: String
46 /// What `org-agenda-prefix-format` puts before the heading text.
47 public var prefix = ""
4448}
4549
4650public struct AgendaDay: Sendable, Equatable {
@@ -58,9 +62,19 @@ public struct AgendaOptions: Sendable, Equatable {
5862 public var gridTrailing = " ┄┄┄┄┄ "
5963 public var gridLine = "┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄"
6064 public var currentTimeLine = "← now ───────────────────────────────────────────────"
65 /// `org-agenda-prefix-format`, by view: `agenda`, `todo`, `tags`, `search`.
66 public var prefixFormat = PrefixFormat.defaults
67 /// Log mode (`l`): closed entries and clocked time on each day.
68 public var logMode = false
69 /// `org-agenda-log-mode-items`.
70 public var logItems: Set<String> = ["closed", "clock"]
6171
6272 public init() {}
6373
74 func prefix(_ view: String) -> PrefixFormat {
75 PrefixFormat(prefixFormat[view] ?? PrefixFormat.defaults[view]!)
76 }
77
6478 /// What Emacs shows without a graphical display.
6579 public static let terminal: AgendaOptions = {
6680 var options = AgendaOptions()
@@ -91,7 +105,7 @@ public enum Agenda {
91105 /// `org-agenda-get-day-entries` with the default entry types.
92106 static func entries(_ source: AgendaSource, day: Int, today: Int, options: AgendaOptions) -> [AgendaItem] {
93107 let deadlines = self.deadlines(source, day: day, today: today, options: options)
94 return deadlines + scheduled(source, day: day, today: today, options: options) + blocks(source, day: day, options: options)
108 return (options.logMode ? progress(source, day: day, options: options) : []) + deadlines + scheduled(source, day: day, today: today, options: options) + blocks(source, day: day, options: options)
95109 + timestamps(source, day: day, today: today, options: options)
96110 }
97111
@@ -126,7 +140,7 @@ public enum Agenda {
126140 let upcoming = isToday && deadline > today
127141 items.append(format(
128142 source, heading, kind: upcoming ? .upcomingDeadline : .deadline, marker: offset, extra: extra,
129 dotime: time, removing: nil, trailing: options.gridTrailing, urgency: { (isToday ? -diff : 0) + $0 }
143 dotime: time, removing: nil, trailing: options.gridTrailing, prefix: options.prefix("agenda"), urgency: { (isToday ? -diff : 0) + $0 }
130144 ))
131145 }
132146 return items
@@ -153,7 +167,7 @@ public enum Agenda {
153167 let habit = heading.isHabit ? heading.habit : nil
154168 items.append(format(
155169 source, heading, kind: past ? .pastScheduled : .scheduled, marker: offset, extra: extra,
156 dotime: time, removing: nil, trailing: options.gridTrailing, habit: habit,
170 dotime: time, removing: nil, trailing: options.gridTrailing, prefix: options.prefix("agenda"), habit: habit,
157171 urgency: { habit?.urgency(today: today) ?? 99 + diff + $0 }
158172 ))
159173 }
@@ -177,7 +191,45 @@ public enum Agenda {
177191 } else {
178192 dotime = nil
179193 }
180 items.append(format(source, heading, kind: .block, marker: block.marker, extra: extra, dotime: dotime, removing: nil, trailing: options.gridTrailing, urgency: { $0 }))
194 items.append(format(source, heading, kind: .block, marker: block.marker, extra: extra, dotime: dotime, removing: nil, trailing: options.gridTrailing, prefix: options.prefix("agenda"), urgency: { $0 }))
195 }
196 return items
197 }
198
199 /// `org-agenda-get-progress`.
200 static func progress(_ source: AgendaSource, day current: Int, options: AgendaOptions) -> [AgendaItem] {
201 let date = Days.date(current)
202 let literal = String(format: "%04d-%02d-%02d", date.year, date.month, date.day)
203 var items: [AgendaItem] = []
204 for entry in source.progress where entry.date == literal {
205 let kind: AgendaItem.Kind
206 let extra: String
207 switch entry.kind {
208 case .closed:
209 guard options.logItems.contains("closed") else { continue }
210 (kind, extra) = (.closed, "Closed: ")
211 case .clock:
212 guard options.logItems.contains("clock") else { continue }
213 (kind, extra) = (.clock, "Clocked: (\(entry.clocked))")
214 case .state:
215 guard options.logItems.contains("state") else { continue }
216 (kind, extra) = (.state, "State: (\(entry.state ?? ""))")
217 }
218 let heading = source.headings[entry.heading]
219 var head = heading.head
220 if let note = entry.note {
221 // Before the tags, as org adds it.
222 if let tags = head.range(of: "([ \\t]+)(:[^ \\n\\t]*?:)[ \\t]*$", options: .regularExpression) {
223 let tagText = head[tags].trimmingCharacters(in: .whitespaces)
224 head = head[..<tags.lowerBound] + " - " + note + " " + tagText
225 } else {
226 head += " - " + note
227 }
228 }
229 items.append(format(
230 source, heading, kind: kind, marker: entry.offset, extra: extra, dotime: .only(entry.timestamp), removing: nil,
231 trailing: options.gridTrailing, prefix: options.prefix("agenda"), head: head, urgency: { _ in 100_000 }
232 ))
181233 }
182234 return items
183235 }
@@ -210,7 +262,7 @@ public enum Agenda {
210262 let heading = source.headings[stamp.heading]
211263 items.append(format(
212264 source, heading, kind: .timestamp, marker: stamp.offset, extra: "",
213 dotime: .string(timestamp), removing: stampActive, trailing: options.gridTrailing,
265 dotime: .string(timestamp), removing: stampActive, trailing: options.gridTrailing, prefix: options.prefix("agenda"),
214266 urgency: { heading.isHabit ? heading.habit?.urgency(today: today) ?? $0 : $0 }
215267 ))
216268 }
@@ -229,6 +281,8 @@ public enum Agenda {
229281 case string(String)
230282 /// Look for a time in the heading only.
231283 case headline
284 /// In the string only (`org-agenda-search-headline-for-time` nil).
285 case only(String)
232286 }
233287
234288 static let tagGroup = try! NSRegularExpression(pattern: "[ \\t]+(:([\\p{L}\\p{N}_@#%:]+):)[ \\t]*$")
@@ -246,10 +300,10 @@ public enum Agenda {
246300 /// `org-agenda-highlight-todo`.
247301 static func format(
248302 _ source: AgendaSource, _ heading: AgendaSource.Heading, kind: AgendaItem.Kind, marker: Int,
249 extra: String, dotime: Dotime?, removing: NSRegularExpression?, trailing: String, hasTime: Bool = true,
250 habit: Habit? = nil, urgency: (Int) -> Int
303 extra: String, dotime: Dotime?, removing: NSRegularExpression?, trailing: String, prefix prefixFormat: PrefixFormat,
304 head: String? = nil, habit: Habit? = nil, urgency: (Int) -> Int
251305 ) -> AgendaItem {
252 var txt = heading.head.trimmingCharacters(in: .whitespaces)
306 var txt = (head ?? heading.head).trimmingCharacters(in: .whitespaces)
253307 // `org-agenda-fix-displayed-tags`: the heading's tags are replaced by the full list.
254308 let tags = heading.tags
255309 if let m = tagGroup.firstMatch(in: txt, range: NSRange(location: 0, length: (txt as NSString).length)) {
@@ -270,16 +324,17 @@ public enum Agenda {
270324 tagString = ":" + parts.joined(separator: ":") + (haveInherited ? "::" : ":")
271325 }
272326
273 let timed = timeFields(&txt, tagString: tagString, dotime: dotime, trailing: trailing, removeTimes: hasTime)
327 let timed = timeFields(&txt, tagString: tagString, dotime: dotime, trailing: trailing, removeTimes: prefixFormat.hasTime)
274328 if let removing {
275329 while let m = removing.firstMatch(in: txt, range: NSRange(location: 0, length: (txt as NSString).length)) {
276330 txt = (txt as NSString).replacingCharacters(in: m.range, with: "")
277331 }
278332 }
279333 let category = heading.category
280 // ` %i %-12:c%?-12t% s` in the agenda, ` %i %-12:c` in TODO and tags lists.
281 let prefix = " " + pad(category.isEmpty ? "" : category + ":", 12)
282 + (hasTime ? (timed.time.isEmpty ? "" : pad(timed.time, 12)) + (extra.isEmpty ? "" : extra + " ") : "")
334 let prefix = prefixFormat.render([
335 "c": category, "t": timed.time, "s": habit == nil ? extra : "", "l": String(repeating: " ", count: heading.level),
336 "e": heading.properties["EFFORT"] ?? "", "b": breadcrumbs(source, heading), "T": tags.last?.name ?? "",
337 ])
283338 txt = highlightTodo(txt, keywords: source.keywords)
284339 let priority = Self.priority(prefix + txt, source.priorities)
285340 return AgendaItem(
@@ -287,10 +342,24 @@ public enum Agenda {
287342 timeOfDay: timed.timeOfDay, time: timed.time, extra: extra, text: txt, tags: tags.map(\.name),
288343 todo: heading.todo, isDone: heading.isDone, urgency: urgency(priority),
289344 warntime: heading.properties["APPT_WARNTIME"].flatMap { Int($0.trimmingCharacters(in: .whitespaces)) }, habit: habit,
290 line: prefix + txt + (tagString.isEmpty ? "" : " " + tagString)
345 line: prefix + txt + (tagString.isEmpty ? "" : " " + tagString), prefix: prefix
291346 )
292347 }
293348
349 /// `%b`: the outline path above the heading, each title followed by `->`.
350 static func breadcrumbs(_ source: AgendaSource, _ heading: AgendaSource.Heading) -> String {
351 guard let index = source.headings.firstIndex(where: { $0.start == heading.start }) else { return "" }
352 var path: [String] = []
353 var level = heading.level
354 for candidate in source.headings[..<index].reversed() where candidate.level < level {
355 let title = candidate.title.replacingOccurrences(of: "\\[[0-9]+%\\]|\\[[0-9]+/[0-9]+\\]", with: "", options: .regularExpression)
356 path.insert(Links.displayFormat(title).trimmingCharacters(in: .whitespaces), at: 0)
357 level = candidate.level
358 if level == 1 { break }
359 }
360 return path.isEmpty ? "" : path.joined(separator: "->") + "->"
361 }
362
294363 /// The time-of-day part of `org-agenda-format-item`: finds the time, removes it from the
295364 /// text, and formats the `%t` field.
296365 static func timeFields(_ txt: inout String, tagString: String, dotime: Dotime?, trailing: String, removeTimes: Bool = true) -> (timeOfDay: Int?, time: String) {
@@ -301,6 +370,7 @@ public enum Agenda {
301370 switch dotime {
302371 case .string(let s): containing = s + withoutStamps
303372 case .headline: containing = withoutStamps
373 case .only(let s): containing = s
304374 }
305375 guard let timeOfDay = time(of: containing) else { return (nil, "") }
306376 let ns = containing as NSString
@@ -402,7 +472,7 @@ public enum Agenda {
402472 return AgendaItem(
403473 kind: kind, path: nil, headingOffset: nil, headingLine: nil, markerOffset: nil, category: "", timeOfDay: timed.timeOfDay,
404474 time: timed.time, extra: "", text: txt, tags: [], todo: nil, isDone: false, urgency: 0, warntime: nil, habit: nil,
405 line: " " + pad("", 12) + pad(timed.time, 12) + txt
475 line: options.prefix("agenda").render(["t": timed.time]) + txt, prefix: options.prefix("agenda").render(["t": timed.time])
406476 )
407477 }
408478 var new = options.gridTimes.reversed().map { t in
Sources/OrgCore/Agenda/AgendaSearch.swift +7 −4
@@ -3,7 +3,7 @@ import Foundation
33extension Agenda {
44 /// `org-todo-list`: unfinished entries, or with `keywords` (`WAIT|HOLD`) those with any of
55 /// the keywords.
6 public static func todoList(_ sources: [AgendaSource], keywords: String? = nil) -> [AgendaItem] {
6 public static func todoList(_ sources: [AgendaSource], keywords: String? = nil, options: AgendaOptions = AgendaOptions()) -> [AgendaItem] {
77 let selected = keywords.map { $0.split(separator: "|").map(String.init) }
88 var items: [AgendaItem] = []
99 for source in sources {
@@ -16,7 +16,7 @@ extension Agenda {
1616 }
1717 items.append(format(
1818 source, heading, kind: .todo, marker: heading.start, extra: "", dotime: .headline, removing: nil,
19 trailing: "", hasTime: false, urgency: { 1 + $0 }
19 trailing: "", prefix: options.prefix("todo"), urgency: { 1 + $0 }
2020 ))
2121 }
2222 }
@@ -25,7 +25,10 @@ extension Agenda {
2525
2626 /// `org-tags-view`: entries matching a tags/property/TODO match string. `todoOnly` keeps
2727 /// unfinished TODO entries, as `M` does.
28 public static func tagsMatch(_ sources: [AgendaSource], match: String, todoOnly: Bool = false, now: Date = Date(), calendar: Calendar = .current) -> [AgendaItem] {
28 public static func tagsMatch(
29 _ sources: [AgendaSource], match: String, todoOnly: Bool = false, now: Date = Date(), calendar: Calendar = .current,
30 options: AgendaOptions = AgendaOptions()
31 ) -> [AgendaItem] {
2932 let matcher = TagsMatcher(match, todoOnly: todoOnly)
3033 var items: [AgendaItem] = []
3134 for source in sources {
@@ -34,7 +37,7 @@ extension Agenda {
3437 guard matcher.matches(heading, in: source, now: now, calendar: calendar), !heading.skipped else { continue }
3538 items.append(format(
3639 source, heading, kind: .tagsMatch, marker: heading.start, extra: "", dotime: nil, removing: nil,
37 trailing: "", hasTime: false, urgency: { $0 }
40 trailing: "", prefix: options.prefix("tags"), urgency: { $0 }
3841 ))
3942 }
4043 }
Sources/OrgCore/Agenda/AgendaSource.swift +61
@@ -56,9 +56,28 @@ public struct AgendaSource: Sendable {
5656 let heading: Int
5757 }
5858
59 /// A `CLOSED:`, `CLOCK:` or `- State "…"` line, for log mode (`org-agenda-get-progress`).
60 struct Progress: Sendable {
61 enum Kind: Sendable { case closed, clock, state }
62 let kind: Kind
63 let offset: Int
64 let heading: Int
65 /// `YYYY-MM-DD` of the first timestamp.
66 let date: String
67 /// The timestamp, from the keyword; a clock's ends with its end time (`[… 10:00-11:30]`).
68 let timestamp: String
69 /// A clock's duration, or `-`.
70 let clocked: String
71 /// The state a note records.
72 let state: String?
73 /// The first line of the note under it.
74 let note: String?
75 }
76
5977 let headings: [Heading]
6078 let stamps: [Stamp]
6179 let blocks: [Block]
80 let progress: [Progress]
6281 /// The file's TODO keywords, active and done.
6382 let keywords: [String]
6483 let priorities: Priorities
@@ -243,6 +262,48 @@ public struct AgendaSource: Sendable {
243262 blocks.append(Block(start: ns.substring(with: m.range(at: 1)), end: ns.substring(with: m.range(at: 2)), marker: point, heading: owner))
244263 }
245264 self.blocks = blocks
265
266 // Log lines: org searches the raw text, then the heading above each match.
267 let keywordPattern = keywords.map(NSRegularExpression.escapedPattern(for:)).joined(separator: "|")
268 let logLine = try! NSRegularExpression(pattern: "(\\bCLOSED:|\\bCLOCK:|- +State \"(\(keywordPattern.isEmpty ? "(?!)" : keywordPattern))\".*?) *\\[([0-9]{4}-[0-9]{2}-[0-9]{2})")
269 let inactive = try! NSRegularExpression(pattern: "\\[([0-9]{4}-[0-9]{2}-[0-9]{2}( +[^\\]+0-9>\\r\\n -]+)?( +([0-9]{1,2}):([0-9]{2}))?)\\]")
270 let clockEnd = try! NSRegularExpression(pattern: "([0-9]{1,2}:[0-9]{2})\\].*?([0-9]{1,2}:[0-9]{2})")
271 let stateNote = try! NSRegularExpression(pattern: ".*\\\\\\n[ \\t]*([^-\\n \\t].*?)[ \\t]*$", options: .anchorsMatchLines)
272 let clockNote = try! NSRegularExpression(pattern: ".*\\n[ \\t]*-[ \\t]+([^-\\n \\t].*?)[ \\t]*$", options: .anchorsMatchLines)
273 var progress: [Progress] = []
274 for m in logLine.matches(in: text, range: NSRange(location: 0, length: ns.length)) {
275 guard let owner = heading(at: m.range.location), !skipped[owner] else { continue }
276 let keyword = ns.substring(with: m.range(at: 1))
277 let kind: Progress.Kind = keyword == "CLOSED:" ? .closed : keyword == "CLOCK:" ? .clock : .state
278 var timestamp = restOfLine(m.range.location)
279 var clocked = "-"
280 let tns = timestamp as NSString
281 if let ts = inactive.firstMatch(in: timestamp, range: NSRange(location: 0, length: tns.length)) {
282 let rest = tns.substring(from: NSMaxRange(ts.range))
283 timestamp = tns.substring(to: NSMaxRange(ts.range))
284 let rns = rest as NSString
285 if kind == .clock, let end = clockEnd.firstMatch(in: rest, range: NSRange(location: 0, length: rns.length)) {
286 timestamp = String(timestamp.dropLast()) + "-" + rns.substring(with: end.range(at: 1)) + "]"
287 clocked = rns.substring(with: end.range(at: 2))
288 }
289 }
290 // The note: after a state line ending in `\\`, or a list item under a clock line.
291 var note: String?
292 let after = NSMaxRange(m.range)
293 let notePattern: NSRegularExpression? = switch kind {
294 case .state: stateNote
295 case .clock: clockNote
296 case .closed: nil
297 }
298 if let notePattern, let n = notePattern.firstMatch(in: text, options: .anchored, range: NSRange(location: after, length: ns.length - after)) {
299 note = ns.substring(with: n.range(at: 1))
300 }
301 progress.append(Progress(
302 kind: kind, offset: m.range.location, heading: owner, date: ns.substring(with: m.range(at: 3)), timestamp: timestamp,
303 clocked: clocked, state: m.range(at: 2).location == NSNotFound ? nil : ns.substring(with: m.range(at: 2)), note: note
304 ))
305 }
306 self.progress = progress
246307 }
247308
248309 /// `org-ts--internal-regexp`.
Sources/OrgCore/Agenda/PrefixFormat.swift added +82
@@ -0,0 +1,82 @@
1import Foundation
2
3/// `org-agenda-prefix-format` for one view, compiled as `org-compile-prefix-format` does:
4/// `%c` category, `%t` time, `%l` level, `%s` extra, `%e` effort, `%b` breadcrumbs, `%i` icon
5/// (none in Orgstar), each with an optional `?` (empty when the value is), a `%-12`-style
6/// width with an optional `.N` limit on categories, and a punctuation character added after a
7/// non-empty value. `%(…)` forms run Lisp; they are empty here.
8public struct PrefixFormat: Sendable, Equatable {
9 enum Piece: Sendable, Equatable {
10 case literal(String)
11 case field(key: Character, optional: Bool, width: String, punctuation: String)
12 }
13
14 let pieces: [Piece]
15 /// `org-prefix-has-time`: times move out of the heading into the prefix.
16 let hasTime: Bool
17 /// `.N` in the category width: categories this long or longer are cut to N - 1.
18 let categoryMaxLength: Int?
19
20 public init(_ format: String) {
21 let regex = try! NSRegularExpression(pattern: "%(\\?)?([-+]?[0-9.]*)([ .;,:!?=|/<>]?)([cltseib]|\\(.+?\\))")
22 let ns = format as NSString
23 var pieces: [Piece] = []
24 var hasTime = false
25 var maxLength: Int?
26 var at = 0
27 for m in regex.matches(in: format, range: NSRange(location: 0, length: ns.length)) {
28 if m.range.location > at { pieces.append(.literal(ns.substring(with: NSRange(location: at, length: m.range.location - at)))) }
29 let key = ns.substring(with: m.range(at: 4))
30 let width = ns.substring(with: m.range(at: 2))
31 if key.hasPrefix("(") {
32 pieces.append(.field(key: "(", optional: false, width: width, punctuation: ""))
33 } else {
34 let k = key.first!
35 if k == "t" { hasTime = true }
36 if k == "c", let dot = width.firstIndex(of: ".") { maxLength = Int(width[width.index(after: dot)...].prefix { $0.isNumber }) }
37 pieces.append(.field(key: k, optional: m.range(at: 1).location != NSNotFound, width: width, punctuation: ns.substring(with: m.range(at: 3))))
38 }
39 at = NSMaxRange(m.range)
40 }
41 if at < ns.length { pieces.append(.literal(ns.substring(from: at))) }
42 self.pieces = pieces
43 self.hasTime = hasTime
44 self.categoryMaxLength = maxLength
45 }
46
47 /// The prefix for these values, by field key.
48 func render(_ values: [Character: String]) -> String {
49 var out = ""
50 for piece in pieces {
51 switch piece {
52 case .literal(let s): out += s
53 case .field(let key, let optional, let width, let punctuation):
54 var value = values[key] ?? ""
55 if key == "c", let max = categoryMaxLength, value.count >= max { value = String(value.prefix(max - 1)) }
56 if value.isEmpty {
57 out += optional ? "" : Self.format("", width)
58 } else {
59 out += Self.format(value + punctuation, width)
60 }
61 }
62 }
63 return out
64 }
65
66 /// Emacs `format` with `%WIDTHs` or `%-WIDTH.PRECISIONs`.
67 static func format(_ s: String, _ spec: String) -> String {
68 var spec = Substring(spec)
69 let left = spec.first == "-"
70 if left || spec.first == "+" { spec = spec.dropFirst() }
71 let parts = spec.split(separator: ".", omittingEmptySubsequences: false)
72 let width = Int(parts.first ?? "") ?? 0
73 var value = s
74 if parts.count > 1, let precision = Int(parts[1]) { value = String(value.prefix(precision)) }
75 let padding = String(repeating: " ", count: max(0, width - displayWidth(value)))
76 return left ? value + padding : padding + value
77 }
78
79 public static let defaults: [String: String] = [
80 "agenda": " %i %-12:c%?-12t% s", "todo": " %i %-12:c", "tags": " %i %-12:c", "search": " %i %-12:c",
81 ]
82}
Sources/Orgstar/AgendaView.swift +38 −3
@@ -5,7 +5,7 @@ import SwiftUI
55
66/// The agenda window: the day/week agenda, the TODO list, tag matches and saved views.
77/// Return or a double click jumps to the entry in the main window. Keys as in Emacs's agenda:
8/// f/b/. move the span, g refreshes, t cycles the TODO state, +/- change the priority, : or
8/// f/b/. move the span, g refreshes, l shows the log (closed and clocked), t cycles the TODO state, +/- change the priority, : or
99/// C-c C-q sets tags, C-c C-s / C-c C-d schedule and set a deadline, S-→/S-← move the date.
1010struct AgendaView: View {
1111 let workspace: WorkspaceModel
@@ -22,9 +22,13 @@ struct AgendaView: View {
2222 @State private var prompt: PendingPrompt?
2323 @State private var promptText = ""
2424 @State private var prefix = false
25 @State private var logMode = false
2526 @AppStorage("agendaSpan") private var span = 10
2627 @AppStorage("agendaStartOffset") private var startOffset = -3
2728 @AppStorage("agendaSubfolders") private var includeSubfolders = false
29 @AppStorage("agendaPrefixAgenda") private var prefixAgenda = PrefixFormat.defaults["agenda"]!
30 @AppStorage("agendaPrefixTodo") private var prefixTodo = PrefixFormat.defaults["todo"]!
31 @AppStorage("agendaPrefixTags") private var prefixTags = PrefixFormat.defaults["tags"]!
2832 @Environment(\.openWindow) private var openWindow
2933
3034 static let match = "Match…"
@@ -53,6 +57,8 @@ struct AgendaView: View {
5357 var span: Int
5458 var startOffset: Int
5559 var includeSubfolders: Bool
60 var prefix: [String]
61 var logMode: Bool
5662 var files: Int
5763 var changed: Int?
5864 var edits: Int
@@ -62,7 +68,7 @@ struct AgendaView: View {
6268
6369 private var refreshKey: RefreshKey {
6470 RefreshKey(
65 mode: agenda.mode, start: agenda.start, span: span, startOffset: startOffset, includeSubfolders: includeSubfolders,
71 mode: agenda.mode, start: agenda.start, span: span, startOffset: startOffset, includeSubfolders: includeSubfolders, prefix: [prefixAgenda, prefixTodo, prefixTags], logMode: logMode,
6672 files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion,
6773 path: session.path, tick: tick
6874 )
@@ -75,6 +81,14 @@ struct AgendaView: View {
7581
7682 private var isMatch: Bool { viewName == Self.match || viewName == Self.todoMatch }
7783
84 private var customPrefix: Bool {
85 switch agenda.mode {
86 case .agenda: prefixAgenda != PrefixFormat.defaults["agenda"]
87 case .todo: prefixTodo != PrefixFormat.defaults["todo"]
88 case .match: prefixTags != PrefixFormat.defaults["tags"]
89 }
90 }
91
7892 @Environment(\.orgTheme) private var theme
7993
8094 var body: some View {
@@ -174,6 +188,8 @@ struct AgendaView: View {
174188 agenda.span = span
175189 agenda.startOffset = startOffset
176190 agenda.includeSubfolders = includeSubfolders
191 agenda.options.prefixFormat = ["agenda": prefixAgenda, "todo": prefixTodo, "tags": prefixTags]
192 agenda.options.logMode = logMode
177193 let open = session.openTexts
178194 await agenda.refresh(open: open)
179195 }
@@ -190,7 +206,7 @@ struct AgendaView: View {
190206 @ViewBuilder
191207 private func rows(_ items: [AgendaItem], prefix: String, day: Int? = nil) -> some View {
192208 ForEach(Array(items.enumerated()), id: \.offset) { index, item in
193 AgendaRow(item: item, day: day)
209 AgendaRow(item: item, day: day, customPrefix: customPrefix)
194210 .tag("\(prefix):\(index)")
195211 .selectionDisabled(item.path == nil)
196212 }
@@ -281,6 +297,9 @@ struct AgendaView: View {
281297 case "f" where isAgenda: agenda.shift(forward: true)
282298 case "b" where isAgenda: agenda.shift(forward: false)
283299 case "." where isAgenda: agenda.showToday()
300 case "l" where isAgenda:
301 logMode.toggle()
302 message = logMode ? "Log mode on" : "Log mode off"
284303 case "g", "r":
285304 loadViews()
286305 tick += 1
@@ -334,6 +353,8 @@ struct AgendaRow: View {
334353 let item: AgendaItem
335354 /// The agenda day the row is on, for habit graphs.
336355 var day: Int?
356 /// A user `org-agenda-prefix-format`: the prefix as Emacs prints it, not the columns.
357 var customPrefix = false
337358 @Environment(\.orgTheme) private var theme
338359
339360 var body: some View {
@@ -357,6 +378,19 @@ struct AgendaRow: View {
357378 .font(.caption)
358379 .foregroundStyle(theme.color("agenda-today") ?? .accentColor)
359380 default:
381 if customPrefix {
382 HStack(spacing: 0) {
383 Text(item.prefix)
384 .font(.body.monospaced())
385 .foregroundStyle(theme.color("agenda-category") ?? .secondary)
386 title.lineLimit(1)
387 Spacer(minLength: 8)
388 if let habit = item.habit, let day { HabitGraph(cells: habit.graph(today: day), theme: theme) }
389 if !item.tags.isEmpty {
390 Text(":" + item.tags.joined(separator: ":") + ":").font(.caption).foregroundStyle(theme.color("tags") ?? .secondary)
391 }
392 }
393 } else {
360394 HStack(spacing: 8) {
361395 Text(item.category)
362396 .lineLimit(1)
@@ -385,6 +419,7 @@ struct AgendaRow: View {
385419 .foregroundStyle(theme.color("tags") ?? .secondary)
386420 }
387421 }
422 }
388423 }
389424 }
390425
Tests/OrgAppTests/EmacsImportTests.swift +4 −1
@@ -19,6 +19,7 @@ struct EmacsImportTests {
1919 org-tags-column -80))
2020 (setq org-tags-column 4)
2121 (setq org-log-done 'note org-log-into-drawer t)
22 (setq org-agenda-prefix-format '((agenda . " %i %-12:c%?-12t% s%b") (todo . " %-8:c")))
2223 (setq fill-column (+ 70 2))
2324 (setq org-capture-templates
2425 '(("t" "Todo" entry (file+headline "~/notes/todo.org" "Inbox") "* TODO %?\n%a")
@@ -71,6 +72,8 @@ struct EmacsImportTests {
7172 #expect(s["org-pretty-entities"] == .bool(true))
7273 #expect(s["org-log-done"] == .string("note"))
7374 #expect(s["org-log-into-drawer"] == .string("LOGBOOK"))
75 #expect(s["org-agenda-prefix-format.agenda"] == .string(" %i %-12:c%?-12t% s%b"))
76 #expect(s["org-agenda-prefix-format.todo"] == .string(" %-8:c"))
7477 // after! org runs after the top-level setq.
7578 #expect(s["org-tags-column"] == .integer(-80))
7679 #expect(s["org-agenda-span"] == .integer(10))
@@ -140,7 +143,7 @@ struct EmacsImportTests {
140143 let keymap = folder.appendingPathComponent("keymap.toml")
141144 #expect(KeymapLoader.load(.doom, userFile: keymap).problems.isEmpty)
142145 #expect(KeymapLoader.load(.doom, userFile: keymap).keymap.keyLabels(for: "app.agenda", modes: ["normal"]).contains("C-c a"))
143 #expect(summary.hasPrefix("Imported 14 settings, 3 capture templates, 3 key bindings."), "\(summary)")
146 #expect(summary.hasPrefix("Imported 16 settings, 3 capture templates, 3 key bindings."), "\(summary)")
144147 #expect(defaults.string(forKey: "themeFont") == "JetBrains Mono")
145148 #expect(defaults.integer(forKey: "themeFontSize") == 14)
146149 }
Tests/OrgCoreTests/AgendaTests.swift +98 −3
@@ -107,12 +107,26 @@ enum AgendaOracle {
107107 line.replacingOccurrences(of: "[ \\t]+(:[^ \\t]+:)[ \\t]*$", with: " $1", options: .regularExpression)
108108 }
109109
110 static func compare(files: [(name: String, text: String)], start: Int, span: Int, today: Int, hour: Int = 9, minute: Int = 30) throws {
110 /// `org-agenda-prefix-format` as Lisp.
111 static func prefixLisp(_ formats: [String: String]) -> String {
112 "'(" + formats.sorted { $0.key < $1.key }.map { "(\($0.key) . \"\($0.value)\")" }.joined(separator: " ") + ")"
113 }
114
115 static func compare(
116 files: [(name: String, text: String)], start: Int, span: Int, today: Int, hour: Int = 9, minute: Int = 30,
117 prefix: [String: String]? = nil, logMode: Bool = false
118 ) throws {
111119 let date = Days.date(start)
112 let emacs = try run(files: files, form: "(org-agenda-list nil \"\(String(format: "%04d-%02d-%02d", date.year, date.month, date.day))\" \(span))", today: today, hour: hour, minute: minute)
120 var form = "(org-agenda-list nil \"\(String(format: "%04d-%02d-%02d", date.year, date.month, date.day))\" \(span))"
121 if let prefix { form = "(let ((org-agenda-prefix-format \(prefixLisp(prefix)))) \(form))" }
122 if logMode { form = "(let ((org-agenda-start-with-log-mode t)) \(form))" }
123 let emacs = try run(files: files, form: form, today: today, hour: hour, minute: minute)
113124 let defaults = OrgSettings.default.withTodoKeywords(keywords)!
114125 let sources = files.map { AgendaSource(path: $0.name, text: $0.text, defaults: defaults) }
115 let ours = Agenda.list(sources, start: start, days: span, today: today, now: (hour, minute), options: .terminal)
126 var options = AgendaOptions.terminal
127 if let prefix { options.prefixFormat = prefix }
128 options.logMode = logMode
129 let ours = Agenda.list(sources, start: start, days: span, today: today, now: (hour, minute), options: options)
116130 #expect(ours.count == emacs.count)
117131 for (o, e) in zip(ours, emacs) {
118132 #expect(o.header == e.header)
@@ -378,3 +392,84 @@ struct ReminderTests {
378392 #expect(Set(reminders.map(\.id)).count == 6)
379393 }
380394}
395
396struct AgendaPrefixFormatTests {
397 static let file = """
398 #+TODO: TODO NEXT | DONE
399 * Projects [1/2] :proj:
400 ** TODO [#A] Write report :work:
401 :PROPERTIES:
402 :Effort: 1:30
403 :END:
404 SCHEDULED: <2026-10-05 Mon 10:00>
405 *** NEXT Outline [[https://x.org][the plan]]
406 DEADLINE: <2026-10-06 Tue>
407 * Errands
408 :PROPERTIES:
409 :CATEGORY: averyverylongcategory
410 :END:
411 ** TODO Groceries 14:00
412 SCHEDULED: <2026-10-05 Mon>
413 """
414
415 static let formats = [
416 "agenda": " %i %-12:c%?-12t%-6e% s%l%b", "todo": " %-8.6c %e %?-4l|", "tags": "%c %?-5:l %b", "search": " %i %-12:c",
417 ]
418
419 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
420 func agendaMatchesEmacs() throws {
421 let today = Days.absolute(year: 2026, month: 10, day: 5)
422 try AgendaOracle.compare(files: [("p.org", Self.file)], start: today, span: 2, today: today, prefix: Self.formats)
423 }
424
425 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
426 func listsMatchEmacs() throws {
427 let today = Days.absolute(year: 2026, month: 10, day: 5)
428 var options = AgendaOptions()
429 options.prefixFormat = Self.formats
430 let lisp = AgendaOracle.prefixLisp(Self.formats)
431 try AgendaOracle.compareList(files: [("p.org", Self.file)], form: "(let ((org-agenda-prefix-format \(lisp))) (org-todo-list))", today: today) {
432 Agenda.todoList($0, options: options)
433 }
434 try AgendaOracle.compareList(files: [("p.org", Self.file)], form: "(let ((org-agenda-prefix-format \(lisp))) (org-tags-view nil \"work|proj\"))", today: today) {
435 Agenda.tagsMatch($0, match: "work|proj", options: options)
436 }
437 }
438
439 @Test func compilesFields() {
440 let format = PrefixFormat(" %-8.6c|%?-4t%e")
441 #expect(format.render(["c": "category", "e": "1:00"]) == " categ |1:00")
442 #expect(format.render(["c": "cat", "t": "9:00"]) == " cat |9:00")
443 }
444}
445
446struct AgendaLogModeTests {
447 static let file = """
448 #+TODO: TODO NEXT | DONE
449 * DONE Ship release :work:
450 CLOSED: [2026-10-05 Mon 16:20] SCHEDULED: <2026-10-05 Mon>
451 :LOGBOOK:
452 CLOCK: [2026-10-05 Mon 14:00]--[2026-10-05 Mon 15:30] => 1:30
453 - Fixed the build
454 CLOCK: [2026-10-04 Sun 22:00]--[2026-10-05 Mon 01:15] => 3:15
455 :END:
456 * TODO Writing
457 :LOGBOOK:
458 CLOCK: [2026-10-06 Tue 09:00]--[2026-10-06 Tue 09:45] => 0:45
459 CLOCK: [2026-10-06 Tue 11:00]
460 :END:
461 ** DONE Draft [1/1]
462 CLOSED: [2026-10-06 Tue]
463 * Archived :ARCHIVE:
464 CLOSED: [2026-10-05 Mon 10:00]
465 * Plain
466 Mentions CLOSED: [2026-10-05 Mon 09:00] in text.
467 """
468
469 @Test(.enabled(if: ProcessInfo.processInfo.environment["ORGSTAR_SKIP_ORACLE"] == nil))
470 func logModeMatchesEmacs() throws {
471 let today = Days.absolute(year: 2026, month: 10, day: 5)
472 try AgendaOracle.compare(files: [("log.org", Self.file)], start: today - 1, span: 3, today: today, logMode: true)
473 try AgendaOracle.compare(files: [("log.org", Self.file)], start: today - 1, span: 3, today: today, prefix: ["agenda": " %-8:c%?-12t%s %l"], logMode: true)
474 }
475}