krz/orgstar

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

Commit f02651ad8e

f02651ad8efeefa535e05100042f64a39a5b405c

parent: 6a95a830c0

Verified · cmc

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

Mac agenda: rows, day headers and toolbar

The agenda is drawn from AgendaModel.groups: empty days hidden, sticky day
headers with today highlighted, today's overdue entries in a collapsible
group, and the now line. Rows show the time or status over an icon, the
category, the title with progress and tags, and the TODO keyword and
priority as badges. Lists use the same rows.

The toolbar has day/week/month, previous/today/next, a date picker and a
filter menu. New keys: n/p, j, v d/w/m, d, w, k, s, ",", TAB and SPC.
The + on a day header (and k) opens capture with the entry scheduled on
that day. Calendar events show when calendar-events is set.

Layout: unified · split

Sources/Orgstar/AgendaView.swift +619 −164
@@ -1,16 +1,30 @@
1import AppKit
12import OrgApp
23import OrgDocument
34import OrgCore
45import OrgPresentation
56import SwiftUI
67
8/// The days an agenda shows, as Org's `v d`, `v w` and `v m`.
9enum AgendaSpan: String, CaseIterable {
10 case day, week, month
11}
12
13/// The app's one reader of Calendar events, for the agenda and Settings.
14@MainActor
15enum CalendarAccess {
16 static let events = CalendarEvents()
17}
18
719/// The agenda window: the day/week agenda, the TODO list, tag matches and saved views.
820/// Return or a double click jumps to the entry in the main window. Keys as in Emacs's agenda:
9/// f/b/. move the span, g refreshes, l shows the log (closed and clocked), / filters
21/// n/p (or the arrows) move between lines, f/b/. move the span, j picks a date, v d/v w/v m (or
22/// d/w) show a day, week or month, g refreshes, l shows the log (closed and clocked), / filters
1023/// (`+cat-tag<0:10-/regexp/`), \ by tag, < by the line's category, = by regexp, _ by effort,
1124/// | clears filters, I/O/X clock in, out and cancel, C-c C-w refiles, $ archives, m/u/U mark
12/// and unmark, B acts on the marked entries, t cycles the TODO state, +/- change the priority, : or
13/// C-c C-q sets tags, C-c C-s / C-c C-d schedule and set a deadline, S-→/S-← move the date.
25/// and unmark, B acts on the marked entries, t cycles the TODO state, +/- change the priority and
26/// , sets it, : or C-c C-q sets tags, C-c C-s / C-c C-d schedule and set a deadline, S-→/S-← move
27/// the date, k captures on the line's day, s saves all files, TAB and SPC show the entry.
1428struct AgendaView: View {
1529 let workspace: WorkspaceModel
1630 let session: DocumentSession
@@ -21,6 +35,7 @@ struct AgendaView: View {
2135 @State private var views = AgendaViewLoader.builtIn
2236 @State private var viewName = AgendaViewLoader.builtIn[0].name
2337 @State private var matchText = ""
38 /// The selected line: `day:index` (`day:o` + index in the overdue group), or `list:index`.
2439 @State private var selection: String?
2540 @State private var tick = 0
2641 /// The view picker moved to the agenda for a followed timestamp, which sets its own days.
@@ -43,9 +58,19 @@ struct AgendaView: View {
4358 }
4459 @State private var textPrompt: TextPrompt?
4560 @State private var textAnswer = ""
61 /// Day, week or month chosen with `v`; nil shows the span the settings or the view give.
62 @State private var spanKind: AgendaSpan?
63 @State private var showingDatePicker = false
64 @State private var pickedDate = Date()
65 @State private var overdueShown = true
66 @State private var hovered: String?
67 @FocusState private var focused: Bool
4668 @AppStorage("agendaSpan") private var span = 10
4769 @AppStorage("agendaStartOffset") private var startOffset = -3
4870 @AppStorage("agendaSubfolders") private var includeSubfolders = false
71 @AppStorage("agendaShowAllDates") private var showAllDates = false
72 @AppStorage("calendarEvents") private var calendarEventsOn = false
73 @AppStorage("calendarEventCalendars") private var calendarNames = ""
4974 @AppStorage("agendaPrefixAgenda") private var prefixAgenda = PrefixFormat.defaults["agenda"]!
5075 @AppStorage("agendaPrefixTodo") private var prefixTodo = PrefixFormat.defaults["todo"]!
5176 @AppStorage("agendaPrefixTags") private var prefixTags = PrefixFormat.defaults["tags"]!
@@ -94,6 +119,7 @@ struct AgendaView: View {
94119 var includeSubfolders: Bool
95120 var prefix: [String]
96121 var logMode: Bool
122 var calendars: [String]
97123 var files: Int
98124 var changed: Int?
99125 var edits: Int
@@ -104,7 +130,7 @@ struct AgendaView: View {
104130 private var refreshKey: RefreshKey {
105131 RefreshKey(
106132 mode: agenda.mode, start: agenda.start, span: span, startOffset: startOffset, includeSubfolders: includeSubfolders, prefix: [prefixAgenda, prefixTodo, prefixTags], logMode: logMode,
107 files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion,
133 calendars: calendarEventsOn ? [calendarNames] : [], files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion,
108134 path: session.path, tick: tick
109135 )
110136 }
@@ -116,41 +142,74 @@ struct AgendaView: View {
116142
117143 private var isMatch: Bool { viewName == Self.match || viewName == Self.todoMatch }
118144
119 private var customPrefix: Bool {
120 switch agenda.mode {
121 case .agenda: prefixAgenda != PrefixFormat.defaults["agenda"]
122 case .todo: prefixTodo != PrefixFormat.defaults["todo"]
123 case .match: prefixTags != PrefixFormat.defaults["tags"]
145 @Environment(\.orgTheme) private var theme
146
147 // MARK: - Lines
148
149 /// A selectable line: its selection id, the row, and the day it is on.
150 struct Line {
151 let id: String
152 let row: AgendaRow
153 let day: Int
154 }
155
156 private var groups: [AgendaDayGroup] {
157 isAgenda ? agenda.groups(showAllDates: showAllDates, keep: filter.keeps) : []
158 }
159
160 private var listRows: [AgendaRow] {
161 isAgenda ? [] : agenda.list.filter(filter.keeps).compactMap { AgendaRow(item: $0, day: agenda.today, today: agenda.today) }
162 }
163
164 /// The lines in order, as up and down move through them.
165 private func lines(_ groups: [AgendaDayGroup]) -> [Line] {
166 guard isAgenda else { return listRows.enumerated().map { Line(id: "list:\($0.offset)", row: $0.element, day: agenda.today) } }
167 return groups.flatMap { group in
168 (overdueShown ? group.overdue.enumerated().map { Line(id: "\(group.day):o\($0.offset)", row: $0.element, day: group.day) } : [])
169 + group.rows.enumerated().map { Line(id: "\(group.day):\($0.offset)", row: $0.element, day: group.day) }
124170 }
125171 }
126172
127 @Environment(\.orgTheme) private var theme
173 private func line(_ id: String) -> Line? { lines(groups).first { $0.id == id } }
128174
129175 var body: some View {
130 List(selection: $selection) {
131 if isAgenda {
132 ForEach(agenda.days, id: \.day) { day in
133 Section {
134 rows(day.items.filter(filter.keeps), prefix: "\(day.day)", day: day.day)
135 } header: {
136 Text(day.header)
137 .font(.headline)
138 .foregroundStyle(day.isToday ? theme.color("agenda-today") ?? .accentColor : theme.color("agenda-date") ?? .primary)
176 let groups = self.groups
177 _ = tick
178 return ScrollViewReader { proxy in
179 ScrollView {
180 LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
181 if isAgenda {
182 ForEach(groups) { group in
183 Section {
184 dayRows(group)
185 } header: {
186 dayHeader(group).id("day\(group.day)")
187 }
188 }
189 } else {
190 Section {
191 ForEach(Array(listRows.enumerated()), id: \.offset) { index, row in
192 rowView(Line(id: "list:\(index)", row: row, day: agenda.today), showsGutter: false)
193 }
194 } header: {
195 header { Text(listTitle).font(.system(size: 13, weight: .semibold)) }
196 }
139197 }
140198 }
141 } else {
142 Section {
143 rows(agenda.list.filter(filter.keeps), prefix: "list")
144 } header: {
145 Text(listTitle).font(.headline)
146 }
199 .padding(.bottom, 12)
200 }
201 .onChange(of: selection) {
202 if let selection { proxy.scrollTo(selection) }
203 }
204 .onChange(of: agenda.days.first?.day) {
205 if groups.contains(where: \.isToday) { proxy.scrollTo("day\(agenda.today)", anchor: .top) }
147206 }
148207 }
149 .themedListBackground(theme.color("agenda-background"))
150 .contextMenu(forSelectionType: String.self) { _ in
151 } primaryAction: { ids in
152 if let id = ids.first { jump(id) }
153 }
208 .background(theme.color("agenda-background") ?? Color(nsColor: .textBackgroundColor))
209 .focusable()
210 .focusEffectDisabled()
211 .focused($focused)
212 .onAppear { focused = true }
154213 .onKeyPress(phases: .down) { press in handle(press) }
155214 .safeAreaInset(edge: .bottom, spacing: 0) {
156215 if let message = message ?? (filter.isEmpty ? reminderStatus : "Filter: " + filter.description) {
@@ -162,35 +221,7 @@ struct AgendaView: View {
162221 .background(.bar)
163222 }
164223 }
165 .toolbar {
166 ToolbarItemGroup(placement: .navigation) {
167 Menu {
168 ForEach(views) { view in Button(view.name) { viewName = view.name } }
169 Divider()
170 Button(Self.match) { viewName = Self.match }
171 Button(Self.todoMatch) { viewName = Self.todoMatch }
172 } label: {
173 Label(viewName, systemImage: "calendar").labelStyle(.titleAndIcon)
174 }
175 .help("What the agenda shows")
176 if isMatch {
177 TextField("+work-boss|LEVEL>2/!TODO", text: $matchText)
178 .frame(minWidth: 220)
179 .onSubmit { applyView() }
180 }
181 }
182 ToolbarItemGroup {
183 if agenda.isComputing { ProgressView().controlSize(.small) }
184 if isAgenda {
185 Button("Earlier", systemImage: "chevron.left") { agenda.shift(forward: false) }
186 .help("Previous days (b)")
187 Button("Today") { agenda.showToday() }
188 .help("Back to today (.)")
189 Button("Later", systemImage: "chevron.right") { agenda.shift(forward: true) }
190 .help("Next days (f)")
191 }
192 }
193 }
224 .toolbar { toolbar }
194225 .overlay {
195226 if agenda.files.isEmpty {
196227 ContentUnavailableView("No agenda files", systemImage: "calendar", description: Text("The agenda reads the org files at the top of each folder you add."))
@@ -241,7 +272,8 @@ struct AgendaView: View {
241272 textAnswer = ""
242273 }
243274 }
244 .navigationTitle(viewName.replacingOccurrences(of: "…", with: ""))
275 .navigationTitle(title)
276 .navigationSubtitle(isAgenda && viewName != AgendaViewLoader.builtIn[0].name ? viewName : "")
245277 .onChange(of: viewName) { applyView() }
246278 .onChange(of: commands.agendaRequest?.start, initial: true) { showRequestedDays() }
247279 .onChange(of: commands.agendaRequest?.days) { showRequestedDays() }
@@ -252,6 +284,12 @@ struct AgendaView: View {
252284 agenda.options.prefixFormat = ["agenda": prefixAgenda, "todo": prefixTodo, "tags": prefixTags]
253285 agenda.options.logMode = logMode
254286 agenda.options = OrgPreferences.agendaOptions(agenda.options)
287 agenda.calendarNames = OrgPreferences.calendarEventCalendars()
288 if calendarEventsOn, isAgenda {
289 agenda.calendarEvents = await CalendarAccess.events.requestAccess() ? CalendarAccess.events : nil
290 } else {
291 agenda.calendarEvents = nil
292 }
255293 let open = session.openTexts
256294 await agenda.refresh(open: open)
257295 }
@@ -265,13 +303,325 @@ struct AgendaView: View {
265303 }
266304 }
267305
306 // MARK: - Days
307
308 private var todayColor: Color { theme.color("agenda-today") ?? .accentColor }
309
268310 @ViewBuilder
269 private func rows(_ items: [AgendaItem], prefix: String, day: Int? = nil) -> some View {
270 ForEach(Array(items.enumerated()), id: \.offset) { index, item in
271 AgendaRow(item: item, day: day, customPrefix: customPrefix, marked: EntryRef(item).map(marked.contains) ?? false)
272 .tag("\(prefix):\(index)")
273 .selectionDisabled(item.path == nil)
311 private func dayRows(_ group: AgendaDayGroup) -> some View {
312 if group.isToday, !group.overdue.isEmpty {
313 overdueHeader(count: group.overdue.count)
314 if overdueShown {
315 ForEach(Array(group.overdue.enumerated()), id: \.element.id) { index, row in
316 rowView(Line(id: "\(group.day):o\(index)", row: row, day: group.day))
317 }
318 Divider().padding(.leading, 16).padding(.vertical, 4)
319 }
320 }
321 if group.isEmpty {
322 Text("Nothing scheduled")
323 .font(.callout)
324 .foregroundStyle(.secondary)
325 .padding(.horizontal, 16)
326 .padding(.vertical, 10)
327 }
328 let now = group.nowIndex(now: Self.hhmm(Date()))
329 ForEach(Array(group.rows.enumerated()), id: \.element.id) { index, row in
330 if index == now { nowLine }
331 rowView(Line(id: "\(group.day):\(index)", row: row, day: group.day))
274332 }
333 if let now, now == group.rows.count { nowLine }
334 }
335
336 private func header(@ViewBuilder _ content: () -> some View) -> some View {
337 HStack(spacing: 6) { content() }
338 .padding(.horizontal, 16)
339 .frame(height: 30)
340 .frame(maxWidth: .infinity, alignment: .leading)
341 .background(theme.color("agenda-background") ?? Color(nsColor: .textBackgroundColor))
342 .overlay(alignment: .bottom) { Divider() }
343 }
344
345 private func dayHeader(_ group: AgendaDayGroup) -> some View {
346 let past = group.day < agenda.today
347 let color = group.isToday ? todayColor : past ? Color.secondary : theme.color("agenda-date") ?? .primary
348 return header {
349 if let relative = Self.relativeName(group.day, today: agenda.today) {
350 Text(relative)
351 .font(.system(size: 13, weight: .bold))
352 .foregroundStyle(color)
353 Text("·").foregroundStyle(.tertiary)
354 }
355 Text(Self.format(group.day, Days.date(group.day).year == Days.date(agenda.today).year ? "EEEE d MMMM" : "EEEE d MMMM yyyy"))
356 .font(.system(size: 13, weight: group.isToday ? .bold : .semibold))
357 .foregroundStyle(color)
358 Spacer()
359 Button {
360 capture(on: group.day)
361 } label: {
362 Image(systemName: "plus")
363 .font(.system(size: 12, weight: .semibold))
364 .frame(width: 22, height: 22)
365 .contentShape(Rectangle())
366 }
367 .buttonStyle(.borderless)
368 .foregroundStyle(group.isToday ? todayColor : .secondary)
369 .help("Capture an entry scheduled on this day (k)")
370 }
371 }
372
373 private func overdueHeader(count: Int) -> some View {
374 let color = theme.dynamic(.systemRed) { [theme] in theme.statusColor(.overdue, dark: $0) }
375 return Button {
376 withAnimation(.snappy(duration: 0.2)) { overdueShown.toggle() }
377 if !overdueShown, selection?.contains(":o") == true { selection = nil }
378 } label: {
379 HStack(spacing: 6) {
380 Image(systemName: "chevron.right")
381 .font(.system(size: 10, weight: .bold))
382 .rotationEffect(.degrees(overdueShown ? 90 : 0))
383 Text("Overdue").font(.system(size: 12, weight: .semibold))
384 Text("\(count)")
385 .font(.system(size: 10, weight: .bold).monospacedDigit())
386 .padding(.horizontal, 6)
387 .padding(.vertical, 1)
388 .background(Capsule().fill(color.opacity(0.15)))
389 Spacer()
390 }
391 .foregroundStyle(color)
392 .padding(.horizontal, 16)
393 .padding(.top, 8)
394 .padding(.bottom, 4)
395 .contentShape(Rectangle())
396 }
397 .buttonStyle(.plain)
398 .help(overdueShown ? "Hide entries from earlier days" : "Show entries from earlier days")
399 }
400
401 private var nowLine: some View {
402 HStack(spacing: 6) {
403 Text(Date().formatted(.dateTime.hour(.twoDigits(amPM: .omitted)).minute()))
404 .font(.system(size: 10, weight: .semibold).monospacedDigit())
405 .frame(width: AgendaRowView.gutterWidth, alignment: .leading)
406 Circle().frame(width: 6, height: 6)
407 Rectangle().frame(height: 1)
408 }
409 .foregroundStyle(todayColor)
410 .padding(.horizontal, 16)
411 .frame(height: 10)
412 .accessibilityLabel("Now")
413 }
414
415 private func rowView(_ line: Line, showsGutter: Bool = true) -> some View {
416 AgendaRowView(
417 row: line.row, day: line.day, showsGutter: showsGutter, selected: selection == line.id, hovered: hovered == line.id,
418 marked: line.row.item.flatMap(EntryRef.init).map(marked.contains) ?? false
419 )
420 .id(line.id)
421 .contentShape(Rectangle())
422 .onHover { inside in
423 if inside { hovered = line.id } else if hovered == line.id { hovered = nil }
424 }
425 .onTapGesture(count: 2) { open(line) }
426 .simultaneousGesture(TapGesture().onEnded {
427 selection = line.id
428 focused = true
429 })
430 .contextMenu { menu(line) }
431 }
432
433 @ViewBuilder
434 private func menu(_ line: Line) -> some View {
435 if let event = line.row.event {
436 Button("Show in Calendar") { openEvent(event) }
437 } else if line.row.item != nil {
438 Button("Open") { selection = line.id; jump(line.id) }
439 Divider()
440 Button("Cycle TODO State") { selection = line.id; run(TodoCycle()) }
441 Button("Set Priority…") { selection = line.id; run(SetPriority()) }
442 Button("Set Tags…") { selection = line.id; run(SetTags()) }
443 Button("Schedule…") { selection = line.id; run(SetPlanning(deadline: false)) }
444 Button("Set Deadline…") { selection = line.id; run(SetPlanning(deadline: true)) }
445 Divider()
446 Button("Clock In") { selection = line.id; clockIn() }
447 Button(EntryRef(line.row.item!).map(marked.contains) == true ? "Unmark" : "Mark") {
448 selection = line.id
449 mark(!(EntryRef(line.row.item!).map(marked.contains) ?? false))
450 }
451 Button("Archive") { selection = line.id; archive() }
452 }
453 }
454
455 // MARK: - Toolbar
456
457 @ToolbarContentBuilder
458 private var toolbar: some ToolbarContent {
459 ToolbarItemGroup(placement: .navigation) {
460 Menu {
461 ForEach(views) { view in Button(view.name) { viewName = view.name } }
462 Divider()
463 Button(Self.match) { viewName = Self.match }
464 Button(Self.todoMatch) { viewName = Self.todoMatch }
465 } label: {
466 Label(viewName, systemImage: "calendar").labelStyle(.titleAndIcon)
467 }
468 .help("What the agenda shows")
469 if isMatch {
470 TextField("+work-boss|LEVEL>2/!TODO", text: $matchText)
471 .frame(minWidth: 220)
472 .onSubmit { applyView() }
473 }
474 }
475 ToolbarItemGroup {
476 if agenda.isComputing { ProgressView().controlSize(.small) }
477 if isAgenda {
478 Picker("Span", selection: Binding(get: { spanKind }, set: { if let kind = $0 { setSpan(kind) } })) {
479 Text("Day").tag(AgendaSpan?.some(.day))
480 Text("Week").tag(AgendaSpan?.some(.week))
481 Text("Month").tag(AgendaSpan?.some(.month))
482 }
483 .pickerStyle(.segmented)
484 .labelsHidden()
485 .help("Show a day, week or month (v d, v w, v m)")
486 ControlGroup {
487 Button("Earlier", systemImage: "chevron.left") { move(forward: false) }
488 .help("Previous days (b)")
489 Button("Today") { goToday() }
490 .help("Back to today (.)")
491 Button("Later", systemImage: "chevron.right") { move(forward: true) }
492 .help("Next days (f)")
493 }
494 Button("Go to Date", systemImage: "calendar") {
495 pickedDate = Self.date(anchorDay)
496 showingDatePicker = true
497 }
498 .help("Go to a date (j)")
499 .popover(isPresented: $showingDatePicker, arrowEdge: .bottom) {
500 DatePicker("Date", selection: $pickedDate, displayedComponents: .date)
501 .datePickerStyle(.graphical)
502 .labelsHidden()
503 .padding()
504 .onChange(of: pickedDate) {
505 showingDatePicker = false
506 goTo(Days.absolute(pickedDate, calendar: .current))
507 }
508 }
509 }
510 Menu {
511 Button("Filter…") { askFilter("/") }
512 Button("By Tag…") { askFilter("\\") }
513 Button("By Category of Selected Line") { toggleCategoryFilter() }
514 Button("By Regexp…") { askFilter("=") }
515 Button("By Effort…") { askFilter("_") }
516 Divider()
517 Button("Remove Filters") { filter = AgendaFilter() }
518 .disabled(filter.isEmpty)
519 } label: {
520 Label("Filter", systemImage: filter.isEmpty ? "line.3.horizontal.decrease.circle" : "line.3.horizontal.decrease.circle.fill")
521 } primaryAction: {
522 askFilter("/")
523 }
524 .help("Filter the entries (/, \\, <, =, _; | removes)")
525 }
526 }
527
528 /// The span shown, as the window's title: `Week of 5 Oct 2026`.
529 private var title: String {
530 guard isAgenda else { return viewName.replacingOccurrences(of: "…", with: "") }
531 guard let first = agenda.days.first?.day, let last = agenda.days.last?.day else { return "Agenda" }
532 switch spanKind {
533 case .day: return Self.format(first, "EEEE d MMMM yyyy")
534 case .week: return "Week of " + Self.format(first, "d MMM yyyy")
535 case .month: return Self.format(first, "MMMM yyyy")
536 case nil:
537 if first == last { return Self.format(first, "EEEE d MMMM yyyy") }
538 let sameYear = Days.date(first).year == Days.date(last).year
539 return Self.format(first, sameYear ? "d MMM" : "d MMM yyyy") + " – " + Self.format(last, "d MMM yyyy")
540 }
541 }
542
543 // MARK: - Span
544
545 /// The day `v`, `j` and `k` start from: the selected line's, else today when shown, else the first.
546 private var anchorDay: Int {
547 if let selection, let day = Int(selection.prefix { $0 != ":" }) { return day }
548 let days = agenda.days.map(\.day)
549 if days.contains(agenda.today) || days.isEmpty { return agenda.today }
550 return days[0]
551 }
552
553 /// `org-agenda-day-view`, `-week-view`, `-month-view`: the day, the week from Monday
554 /// (`org-agenda-start-on-weekday`), or the month around `anchor`.
555 private func setSpan(_ kind: AgendaSpan, around anchor: Int? = nil) {
556 let anchor = anchor ?? anchorDay
557 spanKind = kind
558 selection = nil
559 switch kind {
560 case .day:
561 agenda.mode = .agenda(span: 1, startOffset: nil)
562 agenda.start = anchor
563 case .week:
564 agenda.mode = .agenda(span: 7, startOffset: nil)
565 agenda.start = anchor - (Days.weekday(anchor) + 6) % 7
566 case .month:
567 let d = Days.date(anchor)
568 let first = Days.absolute(year: d.year, month: d.month, day: 1)
569 let next = d.month == 12 ? Days.absolute(year: d.year + 1, month: 1, day: 1) : Days.absolute(year: d.year, month: d.month + 1, day: 1)
570 agenda.mode = .agenda(span: next - first, startOffset: nil)
571 agenda.start = first
572 }
573 }
574
575 private func move(forward: Bool) {
576 selection = nil
577 if spanKind == .month, let first = agenda.days.first?.day {
578 setSpan(.month, around: forward ? first + 31 : first - 1)
579 } else {
580 agenda.shift(forward: forward)
581 }
582 }
583
584 private func goToday() {
585 if let spanKind { setSpan(spanKind, around: agenda.today) } else { agenda.showToday() }
586 selection = nil
587 }
588
589 /// `org-agenda-goto-date`.
590 private func goTo(_ day: Int) {
591 if let spanKind { setSpan(spanKind, around: day) } else { agenda.start = day }
592 selection = nil
593 }
594
595 /// Capture on `day`, as `org-agenda-capture`.
596 private func capture(on day: Int) {
597 commands.capture(on: day)
598 NSApp.activate()
599 openWindow(id: "capture")
600 }
601
602 static func date(_ day: Int) -> Date {
603 let d = Days.date(day)
604 return Calendar.current.date(from: DateComponents(year: d.year, month: d.month, day: d.day)) ?? Date()
605 }
606
607 static func format(_ day: Int, _ pattern: String) -> String {
608 let formatter = DateFormatter()
609 formatter.dateFormat = pattern
610 return formatter.string(from: date(day))
611 }
612
613 static func relativeName(_ day: Int, today: Int) -> String? {
614 switch day - today {
615 case 0: "Today"
616 case 1: "Tomorrow"
617 case -1: "Yesterday"
618 default: nil
619 }
620 }
621
622 static func hhmm(_ date: Date) -> Int {
623 let c = Calendar.current.dateComponents([.hour, .minute], from: date)
624 return (c.hour ?? 0) * 100 + (c.minute ?? 0)
275625 }
276626
277627 @AppStorage("reminders") private var remindersEnabled = true
@@ -303,6 +653,7 @@ struct AgendaView: View {
303653 guard let request = commands.agendaRequest else { return }
304654 commands.agendaRequest = nil
305655 selection = nil
656 spanKind = nil
306657 if viewName != AgendaViewLoader.builtIn[0].name {
307658 keepRequestedDays = true
308659 viewName = AgendaViewLoader.builtIn[0].name
@@ -314,6 +665,7 @@ struct AgendaView: View {
314665 private func applyView() {
315666 if keepRequestedDays { return keepRequestedDays = false }
316667 selection = nil
668 spanKind = nil
317669 agenda.start = nil
318670 switch viewName {
319671 case Self.match, Self.todoMatch:
@@ -327,11 +679,29 @@ struct AgendaView: View {
327679
328680 // MARK: - Items
329681
330 private func item(_ id: String) -> AgendaItem? {
331 let parts = id.split(separator: ":")
332 guard parts.count == 2, let index = Int(parts[1]) else { return nil }
333 let items = (parts[0] == "list" ? agenda.list : agenda.days.first { "\($0.day)" == parts[0] }?.items ?? []).filter(filter.keeps)
334 return index < items.count ? items[index] : nil
682 private func item(_ id: String) -> AgendaItem? { line(id)?.row.item }
683
684 /// Return and double click: the entry in the main window, or the event in Calendar.
685 private func open(_ line: Line) {
686 selection = line.id
687 if let event = line.row.event { openEvent(event) } else { jump(line.id) }
688 }
689
690 /// Calendar shows an event from its identifier (`AgendaEvent.id` adds `@` and the start).
691 private func openEvent(_ event: AgendaEvent) {
692 let identifier = event.id.split(separator: "@").dropLast().joined(separator: "@")
693 guard !identifier.isEmpty, let encoded = identifier.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed),
694 let url = URL(string: "ical://ekevent/\(encoded)?method=show&options=more") else { return }
695 NSWorkspace.shared.open(url)
696 }
697
698 /// Moves the selection by `step` lines.
699 private func step(_ step: Int) {
700 let all = lines(groups)
701 guard !all.isEmpty else { return }
702 let index = selection.flatMap { id in all.firstIndex { $0.id == id } }
703 let next = index.map { min(max($0 + step, 0), all.count - 1) } ?? (step > 0 ? 0 : all.count - 1)
704 selection = all[next].id
335705 }
336706
337707 // MARK: - Filters
@@ -389,10 +759,11 @@ struct AgendaView: View {
389759 }
390760 }
391761
392 private func jump(_ id: String) {
762 /// The entry in the main window; with `focus` false the agenda keeps the keys (SPC).
763 private func jump(_ id: String, focus: Bool = true) {
393764 guard let item = item(id), let path = item.path, let offset = item.markerOffset ?? item.headingOffset else { return }
394765 commands.jump(path: path, offset: offset)
395 openWindow(id: "main")
766 if focus { openWindow(id: "main") }
396767 }
397768
398769 // MARK: - Commands
@@ -403,6 +774,16 @@ struct AgendaView: View {
403774 func isControl(_ letter: String, _ code: String) -> Bool { control && (press.characters == letter || press.characters == code) }
404775 if let pending = prefix {
405776 prefix = nil
777 if pending == "v" {
778 switch press.characters {
779 case "d" where isAgenda: setSpan(.day)
780 case "w" where isAgenda: setSpan(.week)
781 case "m" where isAgenda: setSpan(.month)
782 default: message = "v \(press.characters) is undefined"
783 }
784 if message == "v-" { message = nil }
785 return .handled
786 }
406787 if pending == "C-c C-x" {
407788 if isControl("s", "\u{13}") { archive() } else { message = "C-c C-x \(press.characters) is undefined" }
408789 return .handled
@@ -432,8 +813,20 @@ struct AgendaView: View {
432813 message = "C-c-"
433814 return .handled
434815 }
435 if press.key == .return, let selection {
436 jump(selection)
816 if isControl("n", "\u{0e}") || (press.key == .downArrow && press.modifiers.isEmpty) {
817 step(1)
818 return .handled
819 }
820 if isControl("p", "\u{10}") || (press.key == .upArrow && press.modifiers.isEmpty) {
821 step(-1)
822 return .handled
823 }
824 if press.key == .return || press.key == .tab, let selection, let line = line(selection) {
825 open(line)
826 return .handled
827 }
828 if press.key == .space, let selection {
829 jump(selection, focus: false)
437830 return .handled
438831 }
439832 if press.modifiers == .shift, press.key == .rightArrow || press.key == .leftArrow {
@@ -442,9 +835,23 @@ struct AgendaView: View {
442835 }
443836 guard press.modifiers.subtracting(.shift).isEmpty else { return .ignored }
444837 switch press.characters {
445 case "f" where isAgenda: agenda.shift(forward: true)
446 case "b" where isAgenda: agenda.shift(forward: false)
447 case "." where isAgenda: agenda.showToday()
838 case "n": step(1)
839 case "p": step(-1)
840 case "f" where isAgenda: move(forward: true)
841 case "b" where isAgenda: move(forward: false)
842 case "." where isAgenda: goToday()
843 case "j" where isAgenda:
844 pickedDate = Self.date(anchorDay)
845 showingDatePicker = true
846 case "v" where isAgenda:
847 prefix = "v"
848 message = "v-"
849 case "d" where isAgenda: setSpan(.day)
850 case "w" where isAgenda: setSpan(.week)
851 case "k" where isAgenda: capture(on: anchorDay)
852 case "s":
853 commands.run("app.save-all")
854 message = "Saved"
448855 case "/", "\\", "=", "_": askFilter(press.characters.first!)
449856 case "<": toggleCategoryFilter()
450857 case "|":
@@ -469,6 +876,7 @@ struct AgendaView: View {
469876 case "t": run(TodoCycle())
470877 case "+": run(PriorityUp())
471878 case "-": run(PriorityDown())
879 case ",": run(SetPriority())
472880 case ":": run(SetTags())
473881 default: return .ignored
474882 }
@@ -641,111 +1049,158 @@ struct AgendaView: View {
6411049 }
6421050}
6431051
644struct AgendaRow: View {
645 let item: AgendaItem
1052/// One agenda line: the time or status over an icon, the category above the title, progress
1053/// and tags after it, and the TODO keyword and priority at the end.
1054struct AgendaRowView: View {
1055 let row: AgendaRow
6461056 /// The agenda day the row is on, for habit graphs.
647 var day: Int?
648 /// A user `org-agenda-prefix-format`: the prefix as Emacs prints it, not the columns.
649 var customPrefix = false
1057 var day: Int
1058 var showsGutter = true
1059 var selected = false
1060 var hovered = false
6501061 /// Marked for a bulk action.
6511062 var marked = false
6521063 @Environment(\.orgTheme) private var theme
6531064
1065 static let gutterWidth: CGFloat = 92
1066
6541067 var body: some View {
655 switch item.kind {
656 case .timeGrid:
657 HStack(spacing: 8) {
658 Text(Self.trim(item.time))
659 .monospacedDigit()
660 .frame(width: 140, alignment: .trailing)
661 VStack { Divider() }
662 }
663 .font(.caption)
664 .foregroundStyle(theme.color("agenda-time").map(AnyShapeStyle.init) ?? AnyShapeStyle(.tertiary))
665 case .currentTime:
666 HStack(spacing: 8) {
667 Text("now " + Self.trim(item.time))
668 .monospacedDigit()
669 .frame(width: 140, alignment: .trailing)
670 Rectangle().fill(theme.color("agenda-today") ?? .accentColor).frame(height: 1)
671 }
672 .font(.caption)
673 .foregroundStyle(theme.color("agenda-today") ?? .accentColor)
674 default:
675 HStack(spacing: 4) {
676 if marked { Text("›").bold().foregroundStyle(theme.color("agenda-today") ?? .accentColor) }
677 entryRow
1068 HStack(alignment: .center, spacing: 12) {
1069 if showsGutter { gutter }
1070 VStack(alignment: .leading, spacing: 2) {
1071 if !row.category.isEmpty { categoryLine }
1072 titleLine
1073 }
1074 .layoutPriority(1)
1075 Spacer(minLength: 8)
1076 if let habit = row.item?.habit {
1077 HabitGraph(cells: habit.graph(today: day), theme: theme)
1078 }
1079 if let todo = row.todo {
1080 let color = theme.dynamic(row.isDone ? .systemGreen : .systemRed) { [theme, row] in theme.todoColor(todo, isDone: row.isDone, dark: $0) }
1081 Text(todo)
1082 .font(.system(size: 10, weight: .bold))
1083 .foregroundStyle(color)
1084 .padding(.horizontal, 7)
1085 .padding(.vertical, 2)
1086 .background(Capsule().fill(color.opacity(0.14)))
1087 .fixedSize()
1088 }
1089 priorityBadge
1090 .frame(width: 18)
1091 }
1092 .padding(.horizontal, 16)
1093 .padding(.vertical, 6)
1094 .frame(minHeight: 44)
1095 .background {
1096 RoundedRectangle(cornerRadius: 6)
1097 .fill(selected ? theme.color("selection") ?? Color.accentColor.opacity(0.2) : hovered ? Color.primary.opacity(0.05) : .clear)
1098 .padding(.horizontal, 6)
1099 }
1100 .overlay(alignment: .leading) {
1101 if marked {
1102 Capsule().fill(theme.color("agenda-today") ?? .accentColor).frame(width: 3).padding(.vertical, 8).padding(.leading, 8)
6781103 }
6791104 }
1105 .accessibilityElement(children: .combine)
6801106 }
6811107
682 @ViewBuilder
683 private var entryRow: some View {
684 if customPrefix {
685 HStack(spacing: 0) {
686 Text(item.prefix)
687 .font(.body.monospaced())
688 .foregroundStyle(theme.color("agenda-category") ?? .secondary)
689 title.lineLimit(1)
690 Spacer(minLength: 8)
691 if let habit = item.habit, let day { HabitGraph(cells: habit.graph(today: day), theme: theme) }
692 if !item.tags.isEmpty {
693 Text(":" + item.tags.joined(separator: ":") + ":").font(.caption).foregroundStyle(theme.color("tags") ?? .secondary)
694 }
695 }
696 } else {
697 HStack(spacing: 8) {
698 Text(item.category)
699 .lineLimit(1)
700 .foregroundStyle(theme.color("agenda-category") ?? .secondary)
701 .frame(width: 80, alignment: .leading)
702 if item.kind != .todo, item.kind != .tagsMatch {
703 Text(Self.trim(item.time))
704 .monospacedDigit()
705 .foregroundStyle(theme.color("agenda-time") ?? .primary)
706 .frame(width: 80, alignment: .leading)
707 }
708 if !item.extra.isEmpty {
709 Text(item.extra.trimmingCharacters(in: .whitespaces))
710 .monospacedDigit()
711 .foregroundStyle(leaderColor)
712 }
713 title
714 .lineLimit(1)
715 Spacer(minLength: 8)
716 if let habit = item.habit, let day {
717 HabitGraph(cells: habit.graph(today: day), theme: theme)
718 }
719 if !item.tags.isEmpty {
720 Text(":" + item.tags.joined(separator: ":") + ":")
721 .font(.caption)
722 .foregroundStyle(theme.color("tags") ?? .secondary)
723 }
724 }
1108 private var statusColor: Color {
1109 theme.dynamic(.secondaryLabelColor) { [theme, row] in theme.statusColor(row.status, dark: $0) }
1110 }
1111
1112 private var gutter: some View {
1113 VStack(alignment: .leading, spacing: 3) {
1114 Text(row.gutter)
1115 .font(.system(size: 12, weight: row.status == .overdue || row.status == .dueSoon ? .semibold : .regular).monospacedDigit())
1116 .foregroundStyle(statusColor)
1117 .lineLimit(1)
1118 .minimumScaleFactor(0.8)
1119 if let symbol {
1120 Image(systemName: symbol)
1121 .font(.system(size: row.icon == .event ? 8 : 10, weight: .semibold))
1122 .foregroundStyle(iconColor)
1123 .frame(height: 12)
7251124 }
1125 }
1126 .frame(width: Self.gutterWidth, alignment: .leading)
7261127 }
7271128
728 static func trim(_ time: String) -> String {
729 time.trimmingCharacters(in: CharacterSet(charactersIn: " ┄."))
1129 /// Overdue entries show a warning, done ones a check, as the status says first.
1130 private var symbol: String? {
1131 guard let icon = row.icon else { return nil }
1132 if row.status == .overdue { return "exclamationmark.circle.fill" }
1133 if row.status == .done { return "checkmark.circle.fill" }
1134 switch icon {
1135 case .scheduled: return "calendar"
1136 case .deadline: return "flag.fill"
1137 case .habit: return "repeat"
1138 case .event: return "circle.fill"
1139 case .timestamp: return row.start == nil ? "calendar" : "clock"
1140 }
7301141 }
7311142
732 private var leaderColor: Color {
733 switch item.kind {
734 case .deadline: theme.color("agenda-deadline") ?? .red
735 case .upcomingDeadline: theme.color("agenda-upcoming") ?? .orange
736 case .pastScheduled: theme.color("agenda-scheduled-past") ?? .brown
737 default: theme.color("agenda-scheduled") ?? .secondary
1143 private var iconColor: Color {
1144 if let event = row.event { return theme.dynamic(.systemPurple) { [theme] in theme.eventColor(event, dark: $0) } }
1145 return row.status == .normal ? Color.secondary.opacity(0.8) : statusColor
1146 }
1147
1148 private var categoryLine: some View {
1149 HStack(spacing: 5) {
1150 Circle()
1151 .fill(row.event.map { event in theme.dynamic(.systemPurple) { [theme] in theme.eventColor(event, dark: $0) } }
1152 ?? theme.dynamic { [theme, row] in theme.categoryColor(row.category, dark: $0) })
1153 .frame(width: 6, height: 6)
1154 Text(row.category + (row.event?.location.map { " · " + $0 } ?? ""))
1155 .font(.system(size: 11))
1156 .foregroundStyle(theme.color("agenda-category") ?? .secondary)
1157 .lineLimit(1)
7381158 }
7391159 }
7401160
741 /// The TODO keyword colored as in the editor.
742 private var title: Text {
743 let text = item.text.trimmingCharacters(in: .whitespaces)
744 guard let todo = item.todo, text.hasPrefix(todo + " ") else { return Text(text) }
745 let rest = String(text.dropFirst(todo.count))
746 let rest_ = Text(rest).foregroundColor(item.isDone ? theme.color("comment") ?? .secondary : theme.color("foreground") ?? .primary)
747 let todo_ = Text(todo).foregroundColor(theme.keywordColor(todo, done: item.isDone)).bold()
748 return Text("\(todo_)\(rest_)")
1161 private var titleLine: some View {
1162 HStack(spacing: 6) {
1163 Text(row.title.isEmpty ? " " : row.title)
1164 .font(.system(size: 13))
1165 .strikethrough(row.isDone)
1166 .foregroundStyle(row.isDone ? AnyShapeStyle(.secondary) : AnyShapeStyle(theme.color("foreground") ?? .primary))
1167 .lineLimit(1)
1168 .truncationMode(.tail)
1169 if let progress = row.progress {
1170 Text(progress)
1171 .font(.system(size: 10, weight: .semibold).monospacedDigit())
1172 .foregroundStyle(.secondary)
1173 .padding(.horizontal, 6)
1174 .padding(.vertical, 1)
1175 .background(Capsule().fill(Color.secondary.opacity(0.14)))
1176 .fixedSize()
1177 }
1178 ForEach(row.tags, id: \.self) { tag in
1179 Text(tag)
1180 .font(.system(size: 10, weight: .medium))
1181 .foregroundStyle(theme.color("tags") ?? .secondary)
1182 .padding(.horizontal, 6)
1183 .padding(.vertical, 1)
1184 .background(Capsule().strokeBorder((theme.color("tags") ?? .secondary).opacity(0.35), lineWidth: 0.5))
1185 .fixedSize()
1186 }
1187 }
1188 }
1189
1190 @ViewBuilder
1191 private var priorityBadge: some View {
1192 if let priority = row.priority {
1193 let color = theme.dynamic(.systemOrange) { [theme] in theme.priorityColor(priority, dark: $0) }
1194 Text(priority)
1195 .font(.system(size: 10, weight: .bold))
1196 .foregroundStyle(color)
1197 .frame(width: 18, height: 18)
1198 .background(Circle().fill(color.opacity(0.14)))
1199 .overlay(Circle().strokeBorder(color.opacity(0.6), lineWidth: 1))
1200 .help("Priority \(priority)")
1201 } else {
1202 Color.clear.frame(width: 18, height: 18)
1203 }
7491204 }
7501205}
7511206
Sources/Orgstar/CaptureView.swift +19 −4
@@ -22,6 +22,8 @@ struct CaptureView: View {
2222 @State private var editing = false
2323 /// The template keys typed so far, for multi-key templates such as "wb".
2424 @State private var keys = ""
25 /// The agenda day the entry is captured for, as `Days` numbers it.
26 @State private var day: Int?
2527 @FocusState private var focused: Bool
2628 @Environment(\.dismissWindow) private var dismissWindow
2729
@@ -59,7 +61,8 @@ struct CaptureView: View {
5961 .keyboardShortcut(.defaultAction)
6062 }
6163 } else {
62 Text(keys.isEmpty ? "Capture" : "Capture: \(keys)").font(.headline)
64 Text((keys.isEmpty ? "Capture" : "Capture: \(keys)") + (day == nil ? "" : ", scheduled " + captureDate(Date()).formatted(.dateTime.weekday(.abbreviated).day().month(.abbreviated))))
65 .font(.headline)
6366 ForEach(templates.filter { $0.key.hasPrefix(keys) }) { template in
6467 Button {
6568 choose(template)
@@ -134,6 +137,7 @@ struct CaptureView: View {
134137 template = nil
135138 editing = false
136139 keys = ""
140 day = nil
137141 let clocked = clock.running
138142 let clockFile = clocked.map { Links.abbreviate($0.path, home: NSHomeDirectory()) }
139143 context = CaptureContext(
@@ -154,6 +158,7 @@ struct CaptureView: View {
154158 private func takeRequest() {
155159 guard let request = commands.captureRequest else { return }
156160 commands.captureRequest = nil
161 day = request.day
157162 context.initial = request.properties["initial"] ?? ""
158163 context.annotation = request.properties["annotation"] ?? ""
159164 context.linkProperties = request.properties
@@ -179,12 +184,13 @@ struct CaptureView: View {
179184
180185 private func expand() {
181186 guard let template else { return }
182 context.now = Date()
187 context.now = captureDate(Date())
183188 switch Capture.fill(template.template, context: context, answers: answers) {
184189 case .failure(.message(let message)):
185190 problem = message
186191 self.template = nil
187 case .success(let filled):
192 case .success(var filled):
193 if let day { filled = Capture.scheduling(filled.text, cursor: filled.cursor, on: day) }
188194 text = filled.text
189195 if template.options.immediateFinish {
190196 finish()
@@ -197,12 +203,21 @@ struct CaptureView: View {
197203 }
198204 }
199205
206 /// `now` on the agenda day captured for, as `org-overriding-default-time` sets `%t`, `%U`
207 /// and the date tree's day.
208 private func captureDate(_ now: Date) -> Date {
209 guard let day else { return now }
210 let d = Days.date(day)
211 let time = Calendar.current.dateComponents([.hour, .minute, .second], from: now)
212 return Calendar.current.date(from: DateComponents(year: d.year, month: d.month, day: d.day, hour: time.hour, minute: time.minute, second: time.second)) ?? now
213 }
214
200215 private func finish() {
201216 guard let template else { return }
202217 let opened = context.now
203218 let now = Date()
204219 let placed = template.resolved(workspace: workspace, session: session, clock: clock).flatMap { resolved in
205 session.capture(text, template: resolved.template, root: workspace.rootPaths.first, file: resolved.file, now: now)
220 session.capture(text, template: resolved.template, root: workspace.rootPaths.first, file: resolved.file, now: captureDate(now))
206221 }
207222 switch placed {
208223 case .failure(let error):
Sources/Orgstar/OrgstarApp.swift +11 −4
@@ -103,17 +103,24 @@ final class AppCommands {
103103 runRequest = (id, runSerial)
104104 }
105105
106 /// An org-protocol capture waiting for the capture window: the template key and the link
107 /// properties for its escapes.
108 var captureRequest: (template: String?, properties: [String: String], serial: Int)?
106 /// A capture waiting for the capture window: the template key and the link properties for
107 /// its escapes (org-protocol), or the agenda day to schedule the entry on.
108 var captureRequest: (template: String?, properties: [String: String], day: Int?, serial: Int)?
109109 private var captureSerial = 0
110110
111 /// Capture from an agenda day, as `org-agenda-capture`: that day is the capture's date, and
112 /// the entry is scheduled on it. The caller opens the capture window.
113 func capture(on day: Int) {
114 captureSerial += 1
115 captureRequest = (nil, [:], day, captureSerial)
116 }
117
111118 /// `org-protocol://capture` and `store-link`.
112119 func handle(protocolURL url: String) {
113120 switch OrgProtocol.parse(url) {
114121 case .capture(let template, let properties):
115122 captureSerial += 1
116 captureRequest = (template, properties, captureSerial)
123 captureRequest = (template, properties, nil, captureSerial)
117124 run("app.capture")
118125 case .storeLink(let link, let title):
119126 storeLink(StoredLink(link: link, description: title))
Sources/Orgstar/ThemeColors.swift +10
@@ -21,6 +21,16 @@ extension ThemeSpec {
2121 })
2222 }
2323
24 /// A color that follows the appearance from a helper taking `dark:` (`todoColor`,
25 /// `statusColor`, …), or `fallback` where it gives none.
26 func dynamic(_ fallback: NSColor = .labelColor, _ pick: @escaping @Sendable (_ dark: Bool) -> RGBA?) -> Color {
27 Color(nsColor: NSColor(name: nil) { appearance in
28 let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua
29 guard let c = pick(isDark) else { return fallback }
30 return NSColor(srgbRed: c.red, green: c.green, blue: c.blue, alpha: c.alpha)
31 })
32 }
33
2434 /// What the system shows for a key the theme leaves unset in one appearance.
2535 static func systemColor(_ key: String) -> NSColor {
2636 switch key {