krz/orgstar

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

Commit ab6ccec56e

ab6ccec56eca03d0dadf2c9332aab10c4490eb62

parent: 469ca131b7

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-09 06:15 UTC

Fix agenda log details and make the iOS and Mac agendas match

- Log mode shows the time clocked again (AgendaRow.detail).
- iOS keeps the last calendar checked instead of saving an empty list.
- iOS weeks start on Monday; day headers name Tomorrow and Yesterday
  and grey past days.
- The Mac remembers whether Overdue is collapsed.
- iOS calendar events open Calendar on their day.
- iOS Next Week moves to next Monday.
- iOS Settings has Show days without entries.
- The Mac editor colors TODO keywords by class.

Layout: unified · split

Sources/OrgEditorAppKit/Theme.swift +1 −1
@@ -89,7 +89,7 @@ struct Theme {
8989 if let custom = spec.todo[keyword] {
9090 storage.addAttribute(.foregroundColor, value: Self.dynamic(custom, custom), range: range)
9191 } else {
92 fg(role == .todo ? "todo" : "done")
92 fg(ThemeSpec.TodoClass.of(keyword, isDone: role == .done).key)
9393 }
9494 case .priority: fg("priority")
9595 case .tags: fg("tags")
Sources/OrgPresentation/AgendaRows.swift +23
@@ -76,6 +76,8 @@ public struct AgendaRow: Sendable, Equatable, Identifiable {
7676 public let priority: String?
7777 /// The statistics cookie: `1/3`, `50%`.
7878 public let progress: String?
79 /// Log mode: the time clocked (`1:30`) or the state set (`DONE`).
80 public let detail: String?
7981 /// `HHMM` of the start and the end, when timed.
8082 public let start: Int?
8183 public let end: Int?
@@ -146,6 +148,7 @@ public struct AgendaRow: Sendable, Equatable, Identifiable {
146148 self.isDone = item.isDone
147149 self.priority = item.priorityLetter
148150 self.progress = item.progress
151 self.detail = item.kind == .clock || item.kind == .state ? Self.logDetail(item.extra) : nil
149152 self.start = timed?.0
150153 self.end = timed?.1
151154 self.planningDay = planning
@@ -184,6 +187,7 @@ public struct AgendaRow: Sendable, Equatable, Identifiable {
184187 self.isDone = false
185188 self.priority = nil
186189 self.progress = nil
190 self.detail = nil
187191 self.start = start
188192 self.end = end
189193 self.planningDay = nil
@@ -192,8 +196,17 @@ public struct AgendaRow: Sendable, Equatable, Identifiable {
192196 }
193197
194198 static let rangeDay = try! NSRegularExpression(pattern: "^\\(([0-9]+/[0-9]+)\\)")
199 static let logParen = try! NSRegularExpression(pattern: "\\(([^)]+)\\)")
195200 static let rangeEnd = try! NSRegularExpression(pattern: "-\\+?([0-9]{1,2}):([0-9]{2})")
196201
202 /// The text in parentheses of a log row's `extra`: `Clocked: (1:30)`.
203 static func logDetail(_ extra: String) -> String? {
204 let ns = extra as NSString
205 guard let m = logParen.firstMatch(in: extra, range: NSRange(location: 0, length: ns.length)) else { return nil }
206 let text = ns.substring(with: m.range(at: 1)).trimmingCharacters(in: .whitespaces)
207 return text.isEmpty ? nil : text
208 }
209
197210 /// The end of a `%t` time range (` 9:00-10:30`), as `HHMM`; past midnight (`+1:00`) adds 24 hours.
198211 static func endTime(_ time: String) -> Int? {
199212 let ns = time as NSString
@@ -224,6 +237,16 @@ public struct AgendaDayGroup: Sendable, Equatable, Identifiable {
224237 public var id: Int { day }
225238 public var isEmpty: Bool { overdue.isEmpty && rows.isEmpty }
226239
240 /// `Today`, `Tomorrow`, `Yesterday`, or nil.
241 public func relativeName(today: Int) -> String? {
242 switch day - today {
243 case 0: "Today"
244 case 1: "Tomorrow"
245 case -1: "Yesterday"
246 default: nil
247 }
248 }
249
227250 /// Midnight starting the day.
228251 public func date(calendar: Calendar) -> Date {
229252 let d = Days.date(day)
Sources/Orgstar/AgendaView.swift +3 −12
@@ -64,7 +64,7 @@ struct AgendaView: View {
6464 @State private var spanKind: AgendaSpan?
6565 @State private var showingDatePicker = false
6666 @State private var pickedDate = Date()
67 @State private var overdueShown = true
67 @AppStorage("agendaOverdueExpanded") private var overdueShown = true
6868 @State private var hovered: String?
6969 @FocusState private var focused: Bool
7070 @AppStorage("agendaSpan") private var span = 10
@@ -350,7 +350,7 @@ struct AgendaView: View {
350350 let past = group.day < agenda.today
351351 let color = group.isToday ? todayColor : past ? Color.secondary : theme.color("agenda-date") ?? .primary
352352 return header {
353 if let relative = Self.relativeName(group.day, today: agenda.today) {
353 if let relative = group.relativeName(today: agenda.today) {
354354 Text(relative)
355355 .font(.system(size: 13, weight: .bold))
356356 .foregroundStyle(color)
@@ -614,15 +614,6 @@ struct AgendaView: View {
614614 return formatter.string(from: date(day))
615615 }
616616
617 static func relativeName(_ day: Int, today: Int) -> String? {
618 switch day - today {
619 case 0: "Today"
620 case 1: "Tomorrow"
621 case -1: "Yesterday"
622 default: nil
623 }
624 }
625
626617 static func hhmm(_ date: Date) -> Int {
627618 let c = Calendar.current.dateComponents([.hour, .minute], from: date)
628619 return (c.hour ?? 0) * 100 + (c.minute ?? 0)
@@ -1170,7 +1161,7 @@ struct AgendaRowView: View {
11701161 .foregroundStyle(row.isDone ? AnyShapeStyle(.secondary) : AnyShapeStyle(theme.color("foreground") ?? .primary))
11711162 .lineLimit(1)
11721163 .truncationMode(.tail)
1173 if let progress = row.progress {
1164 ForEach([row.progress, row.detail].compactMap { $0 }, id: \.self) { progress in
11741165 Text(progress)
11751166 .font(.system(size: 10, weight: .semibold).monospacedDigit())
11761167 .foregroundStyle(.secondary)
Sources/OrgstarMobile/AgendaScreen.swift +16 −6
@@ -17,6 +17,7 @@ struct AgendaScreen: View {
1717 @Environment(ClockModel.self) private var clock
1818 @Environment(MobileModel.self) private var model
1919 @Environment(\.colorScheme) private var colorScheme
20 @Environment(\.openURL) private var openURL
2021 @State private var tick = 0
2122 /// The time the now line shows, kept to the minute while the agenda is open.
2223 @State private var now = Date()
@@ -261,10 +262,11 @@ struct AgendaScreen: View {
261262
262263 private func header(_ group: AgendaDayGroup) -> some View {
263264 let day = date(group.day).formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated))
265 let color = group.isToday ? todayColor : group.day < agenda.today ? Color.secondary : themed(theme.color("agenda-date", dark: isDark), .primary)
264266 return HStack(alignment: .firstTextBaseline) {
265 Text(group.isToday ? "Today · " + day : day)
267 Text(group.relativeName(today: agenda.today).map { $0 + " · " + day } ?? day)
266268 .font(.subheadline.weight(group.isToday ? .bold : .semibold))
267 .foregroundStyle(group.isToday ? todayColor : .primary)
269 .foregroundStyle(color)
268270 Spacer()
269271 Button("Add to \(day)", systemImage: "plus.circle") { model.captureRequest = CaptureRequest(day: group.day) }
270272 .labelStyle(.iconOnly)
@@ -305,7 +307,7 @@ struct AgendaScreen: View {
305307 }
306308
307309 private func weekStart(_ day: Int) -> Int {
308 day - (Days.weekday(day) - (Calendar.current.firstWeekday - 1) + 7) % 7
310 day - (Days.weekday(day) + 6) % 7
309311 }
310312
311313 /// Shows `range` around `day`; nil returns to the configured span.
@@ -403,6 +405,14 @@ struct AgendaScreen: View {
403405 Group {
404406 if let item = row.item, let path = item.path, let offset = item.headingOffset ?? item.markerOffset {
405407 NavigationLink(value: ReaderRoute.offset(path, offset)) { AgendaRowView(row: row, day: day, theme: theme, dark: isDark) }
408 } else if let event = row.event {
409 // Calendar can't be asked for one event by URL; it opens on the event's day.
410 Button {
411 if let url = URL(string: "calshow:\(event.start.timeIntervalSinceReferenceDate)") { openURL(url) }
412 } label: {
413 AgendaRowView(row: row, day: day, theme: theme, dark: isDark)
414 }
415 .tint(.primary)
406416 } else {
407417 AgendaRowView(row: row, day: day, theme: theme, dark: isDark)
408418 }
@@ -416,7 +426,7 @@ struct AgendaScreen: View {
416426 .swipeActions(edge: .leading) {
417427 if canReschedule(row), let item = row.item {
418428 Button("Tomorrow", systemImage: "sunrise") { reschedule(row, item, to: agenda.today + 1) }.tint(.orange)
419 Button("Next Week", systemImage: "calendar.badge.plus") { reschedule(row, item, to: agenda.today + 7) }.tint(.indigo)
429 Button("Next Week", systemImage: "calendar.badge.plus") { reschedule(row, item, to: weekStart(agenda.today) + 7) }.tint(.indigo)
420430 Button("Pick Date", systemImage: "calendar") { rescheduling = row }.tint(.gray)
421431 }
422432 }
@@ -426,7 +436,7 @@ struct AgendaScreen: View {
426436 if canReschedule(row) {
427437 Menu("Reschedule", systemImage: "calendar.badge.clock") {
428438 Button("Tomorrow") { reschedule(row, item, to: agenda.today + 1) }
429 Button("Next Week") { reschedule(row, item, to: agenda.today + 7) }
439 Button("Next Week") { reschedule(row, item, to: weekStart(agenda.today) + 7) }
430440 Button("Pick Date…") { rescheduling = row }
431441 }
432442 }
@@ -572,7 +582,7 @@ struct AgendaRowView: View {
572582 Circle().fill(categoryColor).frame(width: 7, height: 7)
573583 Text(row.category).lineLimit(1).layoutPriority(1)
574584 }
575 if let progress = row.progress {
585 ForEach([row.progress, row.detail].compactMap { $0 }, id: \.self) { progress in
576586 Text(progress).monospacedDigit()
577587 .padding(.horizontal, 5)
578588 .background(.quaternary.opacity(0.7), in: Capsule())
Sources/OrgstarMobile/SettingsScreen.swift +11 −2
@@ -32,6 +32,12 @@ struct SettingsScreen: View {
3232 } footer: {
3333 Text("A folder with config.toml and capture.toml, such as a synced copy of ~/.config/orgstar from your Mac. Changes to them apply as they sync.")
3434 }
35 Section("Agenda") {
36 let _ = model.settingsVersion
37 Toggle("Show days without entries", isOn: Binding {
38 OrgPreferences.agendaShowAllDates()
39 } set: { model.set("agendaShowAllDates", to: .bool($0)) })
40 }
3541 calendarSection
3642 Section("In use") {
3743 let _ = model.settingsVersion
@@ -101,9 +107,12 @@ struct SettingsScreen: View {
101107 }
102108
103109 /// Shows or hides a calendar's events; with every calendar checked the setting is empty.
110 /// The last checked calendar stays checked, as an empty list means every calendar.
104111 private func toggle(_ title: String, shown: Bool, chosen: [String]) {
105 var titles = chosen.isEmpty ? calendars.map(\.title) : chosen
106 if shown { titles.removeAll { $0 == title } } else { titles.append(title) }
112 var titles = calendars.filter { CalendarEvents.chooses(id: $0.id, title: $0.title, chosen: chosen) }.map(\.title)
113 titles.removeAll { $0 == title }
114 if !shown { titles.append(title) }
115 guard !titles.isEmpty else { return }
107116 let value = Set(titles) == Set(calendars.map(\.title)) ? "" : titles.joined(separator: ", ")
108117 model.set("calendarEventCalendars", to: .string(value))
109118 }
Tests/OrgPresentationTests/AgendaRowsTests.swift +20
@@ -86,6 +86,26 @@ struct AgendaRowsTests {
8686 #expect(!rent.isEvent && rent.item != nil)
8787 }
8888
89 @Test func logRowsKeepTheDurationAndState() {
90 let text = """
91 * DONE Write report
92 CLOSED: [2026-10-05 Mon 11:00]
93 :LOGBOOK:
94 - State "DONE" from "TODO" [2026-10-05 Mon 11:00]
95 CLOCK: [2026-10-05 Mon 09:00]--[2026-10-05 Mon 10:30] => 1:30
96 :END:
97 """
98 var options = AgendaOptions()
99 options.logMode = true
100 options.logItems = ["closed", "clock", "state"]
101 let source = AgendaSource(path: "w.org", text: text, defaults: OrgSettings.default.withTodoKeywords("TODO | DONE")!)
102 let items = Agenda.list([source], start: Self.today, days: 1, today: Self.today, now: (12, 0), options: options)[0].items
103 let rows = items.compactMap { AgendaRow(item: $0, day: Self.today, today: Self.today) }
104 #expect(rows.first { $0.item?.kind == .clock }?.detail == "1:30")
105 #expect(rows.first { $0.item?.kind == .state }?.detail == "DONE")
106 #expect(rows.first { $0.item?.kind == .closed }?.detail == nil)
107 }
108
89109 @Test func guttersOnOtherDays() {
90110 let later = Self.rows(day: Self.today + 4)
91111 #expect(later["Renew passport"]?.gutter == "Due" && later["Renew passport"]?.status == .normal)
docs/manual/guide/07-agenda.org +7 −7
@@ -106,13 +106,13 @@ A time can come from the timestamp (=<2026-10-05 Mon 14:00-15:30>=) or from the
106106
107107** Today, overdue entries and empty days
108108
109Days without entries are left out. Today always shows, with =Nothing scheduled= when it has no entries. To show every day of the span, as Org does by default, turn on Settings ▸ Agenda ▸ Show days without entries, or set this in =config.toml=:
109Days without entries are left out. Today always shows, with =Nothing scheduled= when it has no entries. To show every day of the span, as Org does by default, turn on Settings ▸ Agenda ▸ Show days without entries (on iOS, Settings ▸ Agenda), or set this in =config.toml=:
110110
111111#+BEGIN_SRC toml
112112org-agenda-show-all-dates = true
113113#+END_SRC
114114
115Today's past deadlines and entries scheduled on earlier days, habits excepted, go in a group headed =Overdue= with their count, above today's other entries. The oldest date comes first; entries with the same date keep Org's order. Click or tap the header to collapse or expand the group.
115Today's past deadlines and entries scheduled on earlier days, habits excepted, go in a group headed =Overdue= with their count, above today's other entries. The oldest date comes first; entries with the same date keep Org's order. Click or tap the header to collapse or expand the group; it stays as you left it.
116116
117117On today, a line marked with the current time sits between the timed entries: before the first entry that starts later, or after the last one when all have started. It shows only when today has timed entries, as Org's =now= line does. Org's time grid (the lines at 8:00, 10:00 and so on) isn't shown.
118118
@@ -125,7 +125,7 @@ Days are sorted as =org-agenda-sorting-strategy= sorts them by default for the a
125125Press =l= to turn log mode (=org-agenda-log-mode=) on or off. Each day then also shows:
126126
127127- =Closed= entries whose =CLOSED:= timestamp is on that day;
128- =Clocked= entries for each =CLOCK:= line that starts on that day, with the clock's start and end times in the left column.
128- =Clocked= entries for each =CLOCK:= line that starts on that day, with the clock's start and end times in the left column and the time clocked, such as =1:30=, after the title.
129129
130130A note under a clock line (a list item directly below it) is added to the title after a =-=. These are the default =org-agenda-log-mode-items=, =(closed clock)=; state changes are not shown and there is no setting for that.
131131
@@ -530,14 +530,14 @@ The board is not available on iOS.
530530The Agenda tab shows the same views from the same files, using the settings in the synced =config.toml= and the views in =views.toml= (see [[file:14-ios.org][iOS]]). Differences from the Mac:
531531
532532- The Views menu, at the top left, lists the built-in views, your saved views, and Tags and Properties…, which asks for a match string. There is no TODO-only match from the menu; use =/!= in the match or a =todo-match= view.
533- A bar floats at the bottom of the screen with Earlier, Filter, Today, Go to Date, the span and Later. The span menu switches between Day, Week, Month and the configured span (=10 Days= by default). Weeks start on the first day of the week in the device's region settings. In the TODO list and match views the bar has Filter and Agenda, which returns to the day view. Messages show above the bar; tap one to dismiss it.
533- A bar floats at the bottom of the screen with Earlier, Filter, Today, Go to Date, the span and Later. The span menu switches between Day, Week, Month and the configured span (=10 Days= by default). Weeks start on Monday, as on the Mac. In the TODO list and match views the bar has Filter and Agenda, which returns to the day view. Messages show above the bar; tap one to dismiss it.
534534- The title names the days shown: =Thu 8 Oct=, =Week of 5 Oct=, =October 2026=, or =5 Oct – 14 Oct=.
535- Day headers read =Today · Thu 8 Oct= or =Fri 9 Oct=, with a + that captures onto that day. The Overdue group stays collapsed or expanded as you left it.
535- Day headers read =Today · Thu 8 Oct=, =Tomorrow · Fri 9 Oct= or =Mon 12 Oct=, with a + that captures onto that day. Days before today are grey.
536536- Pull down to refresh.
537537- The Filter button opens a sheet: type a filter as for =/=, or tap a tag or category to cycle between Keep, Leave out and no filter.
538- Tap an entry to open it. Calendar events don't open.
538- Tap an entry to open it. Tap a calendar event to open Calendar on the event's day; iOS can't open Calendar at one event.
539539- Swipe left on an open TODO entry for Done, which sets the first done keyword of its sequence, and Next State, which moves to the next keyword. The sequence is the one the file defines: the default keywords from =config.toml=, the file's own =#+TODO= lines, and those of its setup files, with unsaved edits in an open buffer included.
540- Swipe right on a scheduled entry, a deadline, a habit or a TODO entry, when it isn't done, for Tomorrow, Next Week and Pick Date. Tomorrow and Next Week are one day and seven days after today. On a deadline row these move the deadline; on other rows they set the scheduled date. A timed row keeps its times.
540- Swipe right on a scheduled entry, a deadline, a habit or a TODO entry, when it isn't done, for Tomorrow, Next Week and Pick Date. Tomorrow is the day after today and Next Week is next Monday. On a deadline row these move the deadline; on other rows they set the scheduled date. A timed row keeps its times.
541541- Touch and hold an entry for TODO State…, Reschedule (Tomorrow, Next Week, Pick Date…), Schedule…, Deadline…, Tags…, Priority…, Clock In, Refile… and Archive….
542542- Marking an entry done and rescheduling one give haptic feedback.
543543- There are no keyboard commands, log mode or bulk marks.
docs/manual/guide/13-configuration.org +1 −1
@@ -493,7 +493,7 @@ A keyword without its own color takes its class's color. Case doesn't matter in
493493| Other open | =todo= | Any other keyword that isn't done |
494494| Other done | =done= | Any other done keyword |
495495
496The classes color keywords in the agenda and its widgets on both platforms, and in the iOS editor and reader. The Mac editor colors keywords with =todo= and =done= only.
496The classes color keywords in the editors, the iOS reader, the agenda and its widgets.
497497
498498A category without its own color gets a hue worked out from its name, the same on every run and device, at a lightness that reads on the appearance's background.
499499
docs/manual/guide/14-ios.org +3 −2
@@ -160,7 +160,7 @@ The Agenda tab shows the agenda over all your folders, with the span and start d
160160- + on a day's header :: captures an entry scheduled on that day (see [[file:08-capture.org][Capture]]).
161161- Tap :: an entry to read it, at its heading.
162162- Swipe left :: on a TODO entry for Done, which sets the first done keyword of its sequence, or Next State.
163- Swipe right :: for Tomorrow, Next Week or Pick Date, which move the entry's scheduled date, or its deadline on a deadline row.
163- Swipe right :: for Tomorrow, Next Week (next Monday) or Pick Date, which move the entry's scheduled date, or its deadline on a deadline row.
164164- Touch and hold :: an entry for TODO State…, Reschedule, Schedule…, Deadline…, Tags…, Priority…, Clock In, Refile…, and Archive….
165165
166166Commands from the agenda change the file directly when it isn't open, and through the editor when it is. If the heading changed since the agenda was built, nothing runs.
@@ -311,8 +311,9 @@ Changes to the files apply as they sync, and again each time you return to the a
311311
312312Stop Using This Folder returns every setting to its default.
313313
314The Calendar section is the one place the app changes a setting itself:
314The Agenda and Calendar sections are the only places the app changes a setting itself:
315315
316- Show days without entries :: turns =org-agenda-show-all-dates= on or off.
316317- Show Calendar Events :: turns on =calendar-events=, and asks for access to your calendars. iOS asks for full access, as Calendar has no read-only level; Orgstar only reads. While access is missing the section shows Allow Access to Calendars…; if you declined, allow it in the Settings app under Privacy & Security ▸ Calendars.
317318- The calendars :: tap one to show or hide its events (=calendar-event-calendars=). With every calendar checked the setting is empty, so calendars added later show too.
318319