krz/orgstar

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

Commit 3a3dedba06

3a3dedba062790e1580c66d38606eda4b02cb501

parent: 29fd24ba30

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-09 05:35 UTC

Agenda: move the now line each minute

Both apps redraw on each minute boundary and refresh when the day changes, so the now line and today's statuses stay current. The Mac no longer rereads the agenda files every minute.

Layout: unified · split

Sources/Orgstar/AgendaView.swift +11 −7
@@ -38,6 +38,8 @@ struct AgendaView: View {
38 /// The selected line: `day:index` (`day:o` + index in the overdue group), or `list:index`. 38 /// The selected line: `day:index` (`day:o` + index in the overdue group), or `list:index`.
39 @State private var selection: String? 39 @State private var selection: String?
40 @State private var tick = 0 40 @State private var tick = 0
41 /// The time the now line shows, kept to the minute while the agenda is open.
42 @State private var now = Date()
41 /// The view picker moved to the agenda for a followed timestamp, which sets its own days. 43 /// The view picker moved to the agenda for a followed timestamp, which sets its own days.
42 @State private var keepRequestedDays = false 44 @State private var keepRequestedDays = false
43 @State private var message: String? 45 @State private var message: String?
@@ -295,10 +297,12 @@ struct AgendaView: View {
295 } 297 }
296 .task { 298 .task {
297 loadViews() 299 loadViews()
298 // The time grid's "now" line and the day itself move on. 300 // The now line moves each minute; a new day refreshes the statuses.
299 while !Task.isCancelled { 301 while !Task.isCancelled {
300 try? await Task.sleep(for: .seconds(60)) 302 let next = Calendar.current.nextDate(after: Date(), matching: DateComponents(second: 0), matchingPolicy: .nextTime) ?? Date().addingTimeInterval(60)
301 tick += 1 303 try? await Task.sleep(for: .seconds(next.timeIntervalSinceNow))
304 now = Date()
305 if Days.absolute(now, calendar: .current) != agenda.today { tick += 1 }
302 } 306 }
303 } 307 }
304 } 308 }
@@ -325,12 +329,12 @@ struct AgendaView: View {
325 .padding(.horizontal, 16) 329 .padding(.horizontal, 16)
326 .padding(.vertical, 10) 330 .padding(.vertical, 10)
327 } 331 }
328 let now = group.nowIndex(now: Self.hhmm(Date())) 332 let nowAt = group.nowIndex(now: Self.hhmm(now))
329 ForEach(Array(group.rows.enumerated()), id: \.element.id) { index, row in 333 ForEach(Array(group.rows.enumerated()), id: \.element.id) { index, row in
330 if index == now { nowLine } 334 if index == nowAt { nowLine }
331 rowView(Line(id: "\(group.day):\(index)", row: row, day: group.day)) 335 rowView(Line(id: "\(group.day):\(index)", row: row, day: group.day))
332 } 336 }
333 if let now, now == group.rows.count { nowLine } 337 if let nowAt, nowAt == group.rows.count { nowLine }
334 } 338 }
335 339
336 private func header(@ViewBuilder _ content: () -> some View) -> some View { 340 private func header(@ViewBuilder _ content: () -> some View) -> some View {
@@ -400,7 +404,7 @@ struct AgendaView: View {
400 404
401 private var nowLine: some View { 405 private var nowLine: some View {
402 HStack(spacing: 6) { 406 HStack(spacing: 6) {
403 Text(Date().formatted(.dateTime.hour(.twoDigits(amPM: .omitted)).minute())) 407 Text(now.formatted(.dateTime.hour(.twoDigits(amPM: .omitted)).minute()))
404 .font(.system(size: 10, weight: .semibold).monospacedDigit()) 408 .font(.system(size: 10, weight: .semibold).monospacedDigit())
405 .frame(width: AgendaRowView.gutterWidth, alignment: .leading) 409 .frame(width: AgendaRowView.gutterWidth, alignment: .leading)
406 Circle().frame(width: 6, height: 6) 410 Circle().frame(width: 6, height: 6)
Sources/OrgstarMobile/AgendaScreen.swift +17 −5
@@ -18,6 +18,8 @@ struct AgendaScreen: View {
18 @Environment(MobileModel.self) private var model 18 @Environment(MobileModel.self) private var model
19 @Environment(\.colorScheme) private var colorScheme 19 @Environment(\.colorScheme) private var colorScheme
20 @State private var tick = 0 20 @State private var tick = 0
21 /// The time the now line shows, kept to the minute while the agenda is open.
22 @State private var now = Date()
21 @State private var views = AgendaViewLoader.builtIn 23 @State private var views = AgendaViewLoader.builtIn
22 @State private var viewName = "Agenda" 24 @State private var viewName = "Agenda"
23 @State private var filter = AgendaFilter() 25 @State private var filter = AgendaFilter()
@@ -173,6 +175,15 @@ struct AgendaScreen: View {
173 views = loaded.views 175 views = loaded.views
174 if let problem = loaded.problems.first { message = problem } 176 if let problem = loaded.problems.first { message = problem }
175 } 177 }
178 // The now line moves each minute; a new day refreshes the statuses.
179 .task {
180 while !Task.isCancelled {
181 let next = Calendar.current.nextDate(after: Date(), matching: DateComponents(second: 0), matchingPolicy: .nextTime) ?? Date().addingTimeInterval(60)
182 try? await Task.sleep(for: .seconds(next.timeIntervalSinceNow))
183 now = Date()
184 if Days.absolute(now, calendar: .current) != agenda.today { tick += 1 }
185 }
186 }
176 // Calendar access is asked for when calendar-events is turned on. 187 // Calendar access is asked for when calendar-events is turned on.
177 .task(id: model.settingsVersion) { 188 .task(id: model.settingsVersion) {
178 guard OrgPreferences.calendarEvents(), !CalendarEvents.isAuthorized else { return } 189 guard OrgPreferences.calendarEvents(), !CalendarEvents.isAuthorized else { return }
@@ -232,17 +243,17 @@ struct AgendaScreen: View {
232 if group.isEmpty { 243 if group.isEmpty {
233 Text("Nothing scheduled").font(.subheadline).foregroundStyle(.secondary) 244 Text("Nothing scheduled").font(.subheadline).foregroundStyle(.secondary)
234 } 245 }
235 let now = nowIndex(group) 246 let nowAt = nowIndex(group)
236 ForEach(Array(group.rows.enumerated()), id: \.element.id) { index, item in 247 ForEach(Array(group.rows.enumerated()), id: \.element.id) { index, item in
237 if index == now { NowLine(color: todayColor) } 248 if index == nowAt { NowLine(time: now, color: todayColor) }
238 row(item, day: group.day) 249 row(item, day: group.day)
239 } 250 }
240 if let now, now == group.rows.count { NowLine(color: todayColor) } 251 if let nowAt, nowAt == group.rows.count { NowLine(time: now, color: todayColor) }
241 } 252 }
242 } 253 }
243 254
244 private func nowIndex(_ group: AgendaDayGroup) -> Int? { 255 private func nowIndex(_ group: AgendaDayGroup) -> Int? {
245 let c = Calendar.current.dateComponents([.hour, .minute], from: Date()) 256 let c = Calendar.current.dateComponents([.hour, .minute], from: now)
246 return group.nowIndex(now: (c.hour ?? 0) * 100 + (c.minute ?? 0)) 257 return group.nowIndex(now: (c.hour ?? 0) * 100 + (c.minute ?? 0))
247 } 258 }
248 259
@@ -672,11 +683,12 @@ struct AgendaRowView: View {
672 683
673/// The current time between timed rows, as Org's `now - - -` line. 684/// The current time between timed rows, as Org's `now - - -` line.
674struct NowLine: View { 685struct NowLine: View {
686 let time: Date
675 let color: Color 687 let color: Color
676 688
677 var body: some View { 689 var body: some View {
678 HStack(spacing: 6) { 690 HStack(spacing: 6) {
679 Text(Date.now.formatted(date: .omitted, time: .shortened)) 691 Text(time.formatted(date: .omitted, time: .shortened))
680 .font(.caption2.weight(.semibold).monospacedDigit()) 692 .font(.caption2.weight(.semibold).monospacedDigit())
681 .foregroundStyle(color) 693 .foregroundStyle(color)
682 Circle().fill(color).frame(width: 6, height: 6) 694 Circle().fill(color).frame(width: 6, height: 6)