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
1import OrgApp 2import OrgApp
2import OrgDocument 3import OrgDocument
3import OrgCore 4import OrgCore
4import OrgPresentation 5import OrgPresentation
5import SwiftUI 6import SwiftUI
6 7
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
7/// The agenda window: the day/week agenda, the TODO list, tag matches and saved views. 19/// The agenda window: the day/week agenda, the TODO list, tag matches and saved views.
8/// Return or a double click jumps to the entry in the main window. Keys as in Emacs's agenda: 20/// 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
10/// (`+cat-tag<0:10-/regexp/`), \ by tag, < by the line's category, = by regexp, _ by effort, 23/// (`+cat-tag<0:10-/regexp/`), \ by tag, < by the line's category, = by regexp, _ by effort,
11/// | clears filters, I/O/X clock in, out and cancel, C-c C-w refiles, $ archives, m/u/U mark 24/// | 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 25/// and unmark, B acts on the marked entries, t cycles the TODO state, +/- change the priority and
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. 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.
14struct AgendaView: View { 28struct AgendaView: View {
15 let workspace: WorkspaceModel 29 let workspace: WorkspaceModel
16 let session: DocumentSession 30 let session: DocumentSession
@@ -21,6 +35,7 @@ struct AgendaView: View {
21 @State private var views = AgendaViewLoader.builtIn 35 @State private var views = AgendaViewLoader.builtIn
22 @State private var viewName = AgendaViewLoader.builtIn[0].name 36 @State private var viewName = AgendaViewLoader.builtIn[0].name
23 @State private var matchText = "" 37 @State private var matchText = ""
38 /// The selected line: `day:index` (`day:o` + index in the overdue group), or `list:index`.
24 @State private var selection: String? 39 @State private var selection: String?
25 @State private var tick = 0 40 @State private var tick = 0
26 /// The view picker moved to the agenda for a followed timestamp, which sets its own days. 41 /// The view picker moved to the agenda for a followed timestamp, which sets its own days.
@@ -43,9 +58,19 @@ struct AgendaView: View {
43 } 58 }
44 @State private var textPrompt: TextPrompt? 59 @State private var textPrompt: TextPrompt?
45 @State private var textAnswer = "" 60 @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
46 @AppStorage("agendaSpan") private var span = 10 68 @AppStorage("agendaSpan") private var span = 10
47 @AppStorage("agendaStartOffset") private var startOffset = -3 69 @AppStorage("agendaStartOffset") private var startOffset = -3
48 @AppStorage("agendaSubfolders") private var includeSubfolders = false 70 @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 = ""
49 @AppStorage("agendaPrefixAgenda") private var prefixAgenda = PrefixFormat.defaults["agenda"]! 74 @AppStorage("agendaPrefixAgenda") private var prefixAgenda = PrefixFormat.defaults["agenda"]!
50 @AppStorage("agendaPrefixTodo") private var prefixTodo = PrefixFormat.defaults["todo"]! 75 @AppStorage("agendaPrefixTodo") private var prefixTodo = PrefixFormat.defaults["todo"]!
51 @AppStorage("agendaPrefixTags") private var prefixTags = PrefixFormat.defaults["tags"]! 76 @AppStorage("agendaPrefixTags") private var prefixTags = PrefixFormat.defaults["tags"]!
@@ -94,6 +119,7 @@ struct AgendaView: View {
94 var includeSubfolders: Bool 119 var includeSubfolders: Bool
95 var prefix: [String] 120 var prefix: [String]
96 var logMode: Bool 121 var logMode: Bool
122 var calendars: [String]
97 var files: Int 123 var files: Int
98 var changed: Int? 124 var changed: Int?
99 var edits: Int 125 var edits: Int
@@ -104,7 +130,7 @@ struct AgendaView: View {
104 private var refreshKey: RefreshKey { 130 private var refreshKey: RefreshKey {
105 RefreshKey( 131 RefreshKey(
106 mode: agenda.mode, start: agenda.start, span: span, startOffset: startOffset, includeSubfolders: includeSubfolders, prefix: [prefixAgenda, prefixTodo, prefixTags], logMode: logMode, 132 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,
108 path: session.path, tick: tick 134 path: session.path, tick: tick
109 ) 135 )
110 } 136 }
@@ -116,41 +142,74 @@ struct AgendaView: View {
116 142
117 private var isMatch: Bool { viewName == Self.match || viewName == Self.todoMatch } 143 private var isMatch: Bool { viewName == Self.match || viewName == Self.todoMatch }
118 144
119 private var customPrefix: Bool { 145 @Environment(\.orgTheme) private var theme
120 switch agenda.mode { 146
121 case .agenda: prefixAgenda != PrefixFormat.defaults["agenda"] 147 // MARK: - Lines
122 case .todo: prefixTodo != PrefixFormat.defaults["todo"] 148
123 case .match: prefixTags != PrefixFormat.defaults["tags"] 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) }
124 } 170 }
125 } 171 }
126 172
127 @Environment(\.orgTheme) private var theme 173 private func line(_ id: String) -> Line? { lines(groups).first { $0.id == id } }
128 174
129 var body: some View { 175 var body: some View {
130 List(selection: $selection) { 176 let groups = self.groups
131 if isAgenda { 177 _ = tick
132 ForEach(agenda.days, id: \.day) { day in 178 return ScrollViewReader { proxy in
133 Section { 179 ScrollView {
134 rows(day.items.filter(filter.keeps), prefix: "\(day.day)", day: day.day) 180 LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) {
135 } header: { 181 if isAgenda {
136 Text(day.header) 182 ForEach(groups) { group in
137 .font(.headline) 183 Section {
138 .foregroundStyle(day.isToday ? theme.color("agenda-today") ?? .accentColor : theme.color("agenda-date") ?? .primary) 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 }
139 } 197 }
140 } 198 }
141 } else { 199 .padding(.bottom, 12)
142 Section { 200 }
143 rows(agenda.list.filter(filter.keeps), prefix: "list") 201 .onChange(of: selection) {
144 } header: { 202 if let selection { proxy.scrollTo(selection) }
145 Text(listTitle).font(.headline) 203 }
146 } 204 .onChange(of: agenda.days.first?.day) {
205 if groups.contains(where: \.isToday) { proxy.scrollTo("day\(agenda.today)", anchor: .top) }
147 } 206 }
148 } 207 }
149 .themedListBackground(theme.color("agenda-background")) 208 .background(theme.color("agenda-background") ?? Color(nsColor: .textBackgroundColor))
150 .contextMenu(forSelectionType: String.self) { _ in 209 .focusable()
151 } primaryAction: { ids in 210 .focusEffectDisabled()
152 if let id = ids.first { jump(id) } 211 .focused($focused)
153 } 212 .onAppear { focused = true }
154 .onKeyPress(phases: .down) { press in handle(press) } 213 .onKeyPress(phases: .down) { press in handle(press) }
155 .safeAreaInset(edge: .bottom, spacing: 0) { 214 .safeAreaInset(edge: .bottom, spacing: 0) {
156 if let message = message ?? (filter.isEmpty ? reminderStatus : "Filter: " + filter.description) { 215 if let message = message ?? (filter.isEmpty ? reminderStatus : "Filter: " + filter.description) {
@@ -162,35 +221,7 @@ struct AgendaView: View {
162 .background(.bar) 221 .background(.bar)
163 } 222 }
164 } 223 }
165 .toolbar { 224 .toolbar { 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 }
194 .overlay { 225 .overlay {
195 if agenda.files.isEmpty { 226 if agenda.files.isEmpty {
196 ContentUnavailableView("No agenda files", systemImage: "calendar", description: Text("The agenda reads the org files at the top of each folder you add.")) 227 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 {
241 textAnswer = "" 272 textAnswer = ""
242 } 273 }
243 } 274 }
244 .navigationTitle(viewName.replacingOccurrences(of: "…", with: "")) 275 .navigationTitle(title)
276 .navigationSubtitle(isAgenda && viewName != AgendaViewLoader.builtIn[0].name ? viewName : "")
245 .onChange(of: viewName) { applyView() } 277 .onChange(of: viewName) { applyView() }
246 .onChange(of: commands.agendaRequest?.start, initial: true) { showRequestedDays() } 278 .onChange(of: commands.agendaRequest?.start, initial: true) { showRequestedDays() }
247 .onChange(of: commands.agendaRequest?.days) { showRequestedDays() } 279 .onChange(of: commands.agendaRequest?.days) { showRequestedDays() }
@@ -252,6 +284,12 @@ struct AgendaView: View {
252 agenda.options.prefixFormat = ["agenda": prefixAgenda, "todo": prefixTodo, "tags": prefixTags] 284 agenda.options.prefixFormat = ["agenda": prefixAgenda, "todo": prefixTodo, "tags": prefixTags]
253 agenda.options.logMode = logMode 285 agenda.options.logMode = logMode
254 agenda.options = OrgPreferences.agendaOptions(agenda.options) 286 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 }
255 let open = session.openTexts 293 let open = session.openTexts
256 await agenda.refresh(open: open) 294 await agenda.refresh(open: open)
257 } 295 }
@@ -265,13 +303,325 @@ struct AgendaView: View {
265 } 303 }
266 } 304 }
267 305
306 // MARK: - Days
307
308 private var todayColor: Color { theme.color("agenda-today") ?? .accentColor }
309
268 @ViewBuilder 310 @ViewBuilder
269 private func rows(_ items: [AgendaItem], prefix: String, day: Int? = nil) -> some View { 311 private func dayRows(_ group: AgendaDayGroup) -> some View {
270 ForEach(Array(items.enumerated()), id: \.offset) { index, item in 312 if group.isToday, !group.overdue.isEmpty {
271 AgendaRow(item: item, day: day, customPrefix: customPrefix, marked: EntryRef(item).map(marked.contains) ?? false) 313 overdueHeader(count: group.overdue.count)
272 .tag("\(prefix):\(index)") 314 if overdueShown {
273 .selectionDisabled(item.path == nil) 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))
274 } 332 }
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)
275 } 625 }
276 626
277 @AppStorage("reminders") private var remindersEnabled = true 627 @AppStorage("reminders") private var remindersEnabled = true
@@ -303,6 +653,7 @@ struct AgendaView: View {
303 guard let request = commands.agendaRequest else { return } 653 guard let request = commands.agendaRequest else { return }
304 commands.agendaRequest = nil 654 commands.agendaRequest = nil
305 selection = nil 655 selection = nil
656 spanKind = nil
306 if viewName != AgendaViewLoader.builtIn[0].name { 657 if viewName != AgendaViewLoader.builtIn[0].name {
307 keepRequestedDays = true 658 keepRequestedDays = true
308 viewName = AgendaViewLoader.builtIn[0].name 659 viewName = AgendaViewLoader.builtIn[0].name
@@ -314,6 +665,7 @@ struct AgendaView: View {
314 private func applyView() { 665 private func applyView() {
315 if keepRequestedDays { return keepRequestedDays = false } 666 if keepRequestedDays { return keepRequestedDays = false }
316 selection = nil 667 selection = nil
668 spanKind = nil
317 agenda.start = nil 669 agenda.start = nil
318 switch viewName { 670 switch viewName {
319 case Self.match, Self.todoMatch: 671 case Self.match, Self.todoMatch:
@@ -327,11 +679,29 @@ struct AgendaView: View {
327 679
328 // MARK: - Items 680 // MARK: - Items
329 681
330 private func item(_ id: String) -> AgendaItem? { 682 private func item(_ id: String) -> AgendaItem? { line(id)?.row.item }
331 let parts = id.split(separator: ":") 683
332 guard parts.count == 2, let index = Int(parts[1]) else { return nil } 684 /// Return and double click: the entry in the main window, or the event in Calendar.
333 let items = (parts[0] == "list" ? agenda.list : agenda.days.first { "\($0.day)" == parts[0] }?.items ?? []).filter(filter.keeps) 685 private func open(_ line: Line) {
334 return index < items.count ? items[index] : nil 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
335 } 705 }
336 706
337 // MARK: - Filters 707 // MARK: - Filters
@@ -389,10 +759,11 @@ struct AgendaView: View {
389 } 759 }
390 } 760 }
391 761
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) {
393 guard let item = item(id), let path = item.path, let offset = item.markerOffset ?? item.headingOffset else { return } 764 guard let item = item(id), let path = item.path, let offset = item.markerOffset ?? item.headingOffset else { return }
394 commands.jump(path: path, offset: offset) 765 commands.jump(path: path, offset: offset)
395 openWindow(id: "main") 766 if focus { openWindow(id: "main") }
396 } 767 }
397 768
398 // MARK: - Commands 769 // MARK: - Commands
@@ -403,6 +774,16 @@ struct AgendaView: View {
403 func isControl(_ letter: String, _ code: String) -> Bool { control && (press.characters == letter || press.characters == code) } 774 func isControl(_ letter: String, _ code: String) -> Bool { control && (press.characters == letter || press.characters == code) }
404 if let pending = prefix { 775 if let pending = prefix {
405 prefix = nil 776 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 }
406 if pending == "C-c C-x" { 787 if pending == "C-c C-x" {
407 if isControl("s", "\u{13}") { archive() } else { message = "C-c C-x \(press.characters) is undefined" } 788 if isControl("s", "\u{13}") { archive() } else { message = "C-c C-x \(press.characters) is undefined" }
408 return .handled 789 return .handled
@@ -432,8 +813,20 @@ struct AgendaView: View {
432 message = "C-c-" 813 message = "C-c-"
433 return .handled 814 return .handled
434 } 815 }
435 if press.key == .return, let selection { 816 if isControl("n", "\u{0e}") || (press.key == .downArrow && press.modifiers.isEmpty) {
436 jump(selection) 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)
437 return .handled 830 return .handled
438 } 831 }
439 if press.modifiers == .shift, press.key == .rightArrow || press.key == .leftArrow { 832 if press.modifiers == .shift, press.key == .rightArrow || press.key == .leftArrow {
@@ -442,9 +835,23 @@ struct AgendaView: View {
442 } 835 }
443 guard press.modifiers.subtracting(.shift).isEmpty else { return .ignored } 836 guard press.modifiers.subtracting(.shift).isEmpty else { return .ignored }
444 switch press.characters { 837 switch press.characters {
445 case "f" where isAgenda: agenda.shift(forward: true) 838 case "n": step(1)
446 case "b" where isAgenda: agenda.shift(forward: false) 839 case "p": step(-1)
447 case "." where isAgenda: agenda.showToday() 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"
448 case "/", "\\", "=", "_": askFilter(press.characters.first!) 855 case "/", "\\", "=", "_": askFilter(press.characters.first!)
449 case "<": toggleCategoryFilter() 856 case "<": toggleCategoryFilter()
450 case "|": 857 case "|":
@@ -469,6 +876,7 @@ struct AgendaView: View {
469 case "t": run(TodoCycle()) 876 case "t": run(TodoCycle())
470 case "+": run(PriorityUp()) 877 case "+": run(PriorityUp())
471 case "-": run(PriorityDown()) 878 case "-": run(PriorityDown())
879 case ",": run(SetPriority())
472 case ":": run(SetTags()) 880 case ":": run(SetTags())
473 default: return .ignored 881 default: return .ignored
474 } 882 }
@@ -641,111 +1049,158 @@ struct AgendaView: View {
641 } 1049 }
642} 1050}
643 1051
644struct AgendaRow: View { 1052/// One agenda line: the time or status over an icon, the category above the title, progress
645 let item: AgendaItem 1053/// and tags after it, and the TODO keyword and priority at the end.
1054struct AgendaRowView: View {
1055 let row: AgendaRow
646 /// The agenda day the row is on, for habit graphs. 1056 /// The agenda day the row is on, for habit graphs.
647 var day: Int? 1057 var day: Int
648 /// A user `org-agenda-prefix-format`: the prefix as Emacs prints it, not the columns. 1058 var showsGutter = true
649 var customPrefix = false 1059 var selected = false
1060 var hovered = false
650 /// Marked for a bulk action. 1061 /// Marked for a bulk action.
651 var marked = false 1062 var marked = false
652 @Environment(\.orgTheme) private var theme 1063 @Environment(\.orgTheme) private var theme
653 1064
1065 static let gutterWidth: CGFloat = 92
1066
654 var body: some View { 1067 var body: some View {
655 switch item.kind { 1068 HStack(alignment: .center, spacing: 12) {
656 case .timeGrid: 1069 if showsGutter { gutter }
657 HStack(spacing: 8) { 1070 VStack(alignment: .leading, spacing: 2) {
658 Text(Self.trim(item.time)) 1071 if !row.category.isEmpty { categoryLine }
659 .monospacedDigit() 1072 titleLine
660 .frame(width: 140, alignment: .trailing) 1073 }
661 VStack { Divider() } 1074 .layoutPriority(1)
662 } 1075 Spacer(minLength: 8)
663 .font(.caption) 1076 if let habit = row.item?.habit {
664 .foregroundStyle(theme.color("agenda-time").map(AnyShapeStyle.init) ?? AnyShapeStyle(.tertiary)) 1077 HabitGraph(cells: habit.graph(today: day), theme: theme)
665 case .currentTime: 1078 }
666 HStack(spacing: 8) { 1079 if let todo = row.todo {
667 Text("now " + Self.trim(item.time)) 1080 let color = theme.dynamic(row.isDone ? .systemGreen : .systemRed) { [theme, row] in theme.todoColor(todo, isDone: row.isDone, dark: $0) }
668 .monospacedDigit() 1081 Text(todo)
669 .frame(width: 140, alignment: .trailing) 1082 .font(.system(size: 10, weight: .bold))
670 Rectangle().fill(theme.color("agenda-today") ?? .accentColor).frame(height: 1) 1083 .foregroundStyle(color)
671 } 1084 .padding(.horizontal, 7)
672 .font(.caption) 1085 .padding(.vertical, 2)
673 .foregroundStyle(theme.color("agenda-today") ?? .accentColor) 1086 .background(Capsule().fill(color.opacity(0.14)))
674 default: 1087 .fixedSize()
675 HStack(spacing: 4) { 1088 }
676 if marked { Text("›").bold().foregroundStyle(theme.color("agenda-today") ?? .accentColor) } 1089 priorityBadge
677 entryRow 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)
678 } 1103 }
679 } 1104 }
1105 .accessibilityElement(children: .combine)
680 } 1106 }
681 1107
682 @ViewBuilder 1108 private var statusColor: Color {
683 private var entryRow: some View { 1109 theme.dynamic(.secondaryLabelColor) { [theme, row] in theme.statusColor(row.status, dark: $0) }
684 if customPrefix { 1110 }
685 HStack(spacing: 0) { 1111
686 Text(item.prefix) 1112 private var gutter: some View {
687 .font(.body.monospaced()) 1113 VStack(alignment: .leading, spacing: 3) {
688 .foregroundStyle(theme.color("agenda-category") ?? .secondary) 1114 Text(row.gutter)
689 title.lineLimit(1) 1115 .font(.system(size: 12, weight: row.status == .overdue || row.status == .dueSoon ? .semibold : .regular).monospacedDigit())
690 Spacer(minLength: 8) 1116 .foregroundStyle(statusColor)
691 if let habit = item.habit, let day { HabitGraph(cells: habit.graph(today: day), theme: theme) } 1117 .lineLimit(1)
692 if !item.tags.isEmpty { 1118 .minimumScaleFactor(0.8)
693 Text(":" + item.tags.joined(separator: ":") + ":").font(.caption).foregroundStyle(theme.color("tags") ?? .secondary) 1119 if let symbol {
694 } 1120 Image(systemName: symbol)
695 } 1121 .font(.system(size: row.icon == .event ? 8 : 10, weight: .semibold))
696 } else { 1122 .foregroundStyle(iconColor)
697 HStack(spacing: 8) { 1123 .frame(height: 12)
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 }
725 } 1124 }
1125 }
1126 .frame(width: Self.gutterWidth, alignment: .leading)
726 } 1127 }
727 1128
728 static func trim(_ time: String) -> String { 1129 /// Overdue entries show a warning, done ones a check, as the status says first.
729 time.trimmingCharacters(in: CharacterSet(charactersIn: " ┄.")) 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 }
730 } 1141 }
731 1142
732 private var leaderColor: Color { 1143 private var iconColor: Color {
733 switch item.kind { 1144 if let event = row.event { return theme.dynamic(.systemPurple) { [theme] in theme.eventColor(event, dark: $0) } }
734 case .deadline: theme.color("agenda-deadline") ?? .red 1145 return row.status == .normal ? Color.secondary.opacity(0.8) : statusColor
735 case .upcomingDeadline: theme.color("agenda-upcoming") ?? .orange 1146 }
736 case .pastScheduled: theme.color("agenda-scheduled-past") ?? .brown 1147
737 default: theme.color("agenda-scheduled") ?? .secondary 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)
738 } 1158 }
739 } 1159 }
740 1160
741 /// The TODO keyword colored as in the editor. 1161 private var titleLine: some View {
742 private var title: Text { 1162 HStack(spacing: 6) {
743 let text = item.text.trimmingCharacters(in: .whitespaces) 1163 Text(row.title.isEmpty ? " " : row.title)
744 guard let todo = item.todo, text.hasPrefix(todo + " ") else { return Text(text) } 1164 .font(.system(size: 13))
745 let rest = String(text.dropFirst(todo.count)) 1165 .strikethrough(row.isDone)
746 let rest_ = Text(rest).foregroundColor(item.isDone ? theme.color("comment") ?? .secondary : theme.color("foreground") ?? .primary) 1166 .foregroundStyle(row.isDone ? AnyShapeStyle(.secondary) : AnyShapeStyle(theme.color("foreground") ?? .primary))
747 let todo_ = Text(todo).foregroundColor(theme.keywordColor(todo, done: item.isDone)).bold() 1167 .lineLimit(1)
748 return Text("\(todo_)\(rest_)") 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 }
749 } 1204 }
750} 1205}
751 1206
Sources/Orgstar/CaptureView.swift +19 −4
@@ -22,6 +22,8 @@ struct CaptureView: View {
22 @State private var editing = false 22 @State private var editing = false
23 /// The template keys typed so far, for multi-key templates such as "wb". 23 /// The template keys typed so far, for multi-key templates such as "wb".
24 @State private var keys = "" 24 @State private var keys = ""
25 /// The agenda day the entry is captured for, as `Days` numbers it.
26 @State private var day: Int?
25 @FocusState private var focused: Bool 27 @FocusState private var focused: Bool
26 @Environment(\.dismissWindow) private var dismissWindow 28 @Environment(\.dismissWindow) private var dismissWindow
27 29
@@ -59,7 +61,8 @@ struct CaptureView: View {
59 .keyboardShortcut(.defaultAction) 61 .keyboardShortcut(.defaultAction)
60 } 62 }
61 } else { 63 } 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)
63 ForEach(templates.filter { $0.key.hasPrefix(keys) }) { template in 66 ForEach(templates.filter { $0.key.hasPrefix(keys) }) { template in
64 Button { 67 Button {
65 choose(template) 68 choose(template)
@@ -134,6 +137,7 @@ struct CaptureView: View {
134 template = nil 137 template = nil
135 editing = false 138 editing = false
136 keys = "" 139 keys = ""
140 day = nil
137 let clocked = clock.running 141 let clocked = clock.running
138 let clockFile = clocked.map { Links.abbreviate($0.path, home: NSHomeDirectory()) } 142 let clockFile = clocked.map { Links.abbreviate($0.path, home: NSHomeDirectory()) }
139 context = CaptureContext( 143 context = CaptureContext(
@@ -154,6 +158,7 @@ struct CaptureView: View {
154 private func takeRequest() { 158 private func takeRequest() {
155 guard let request = commands.captureRequest else { return } 159 guard let request = commands.captureRequest else { return }
156 commands.captureRequest = nil 160 commands.captureRequest = nil
161 day = request.day
157 context.initial = request.properties["initial"] ?? "" 162 context.initial = request.properties["initial"] ?? ""
158 context.annotation = request.properties["annotation"] ?? "" 163 context.annotation = request.properties["annotation"] ?? ""
159 context.linkProperties = request.properties 164 context.linkProperties = request.properties
@@ -179,12 +184,13 @@ struct CaptureView: View {
179 184
180 private func expand() { 185 private func expand() {
181 guard let template else { return } 186 guard let template else { return }
182 context.now = Date() 187 context.now = captureDate(Date())
183 switch Capture.fill(template.template, context: context, answers: answers) { 188 switch Capture.fill(template.template, context: context, answers: answers) {
184 case .failure(.message(let message)): 189 case .failure(.message(let message)):
185 problem = message 190 problem = message
186 self.template = nil 191 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) }
188 text = filled.text 194 text = filled.text
189 if template.options.immediateFinish { 195 if template.options.immediateFinish {
190 finish() 196 finish()
@@ -197,12 +203,21 @@ struct CaptureView: View {
197 } 203 }
198 } 204 }
199 205
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
200 private func finish() { 215 private func finish() {
201 guard let template else { return } 216 guard let template else { return }
202 let opened = context.now 217 let opened = context.now
203 let now = Date() 218 let now = Date()
204 let placed = template.resolved(workspace: workspace, session: session, clock: clock).flatMap { resolved in 219 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))
206 } 221 }
207 switch placed { 222 switch placed {
208 case .failure(let error): 223 case .failure(let error):
Sources/Orgstar/OrgstarApp.swift +11 −4
@@ -103,17 +103,24 @@ final class AppCommands {
103 runRequest = (id, runSerial) 103 runRequest = (id, runSerial)
104 } 104 }
105 105
106 /// An org-protocol capture waiting for the capture window: the template key and the link 106 /// A capture waiting for the capture window: the template key and the link properties for
107 /// properties for its escapes. 107 /// its escapes (org-protocol), or the agenda day to schedule the entry on.
108 var captureRequest: (template: String?, properties: [String: String], serial: Int)? 108 var captureRequest: (template: String?, properties: [String: String], day: Int?, serial: Int)?
109 private var captureSerial = 0 109 private var captureSerial = 0
110 110
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
111 /// `org-protocol://capture` and `store-link`. 118 /// `org-protocol://capture` and `store-link`.
112 func handle(protocolURL url: String) { 119 func handle(protocolURL url: String) {
113 switch OrgProtocol.parse(url) { 120 switch OrgProtocol.parse(url) {
114 case .capture(let template, let properties): 121 case .capture(let template, let properties):
115 captureSerial += 1 122 captureSerial += 1
116 captureRequest = (template, properties, captureSerial) 123 captureRequest = (template, properties, nil, captureSerial)
117 run("app.capture") 124 run("app.capture")
118 case .storeLink(let link, let title): 125 case .storeLink(let link, let title):
119 storeLink(StoredLink(link: link, description: title)) 126 storeLink(StoredLink(link: link, description: title))
Sources/Orgstar/ThemeColors.swift +10
@@ -21,6 +21,16 @@ extension ThemeSpec {
21 }) 21 })
22 } 22 }
23 23
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
24 /// What the system shows for a key the theme leaves unset in one appearance. 34 /// What the system shows for a key the theme leaves unset in one appearance.
25 static func systemColor(_ key: String) -> NSColor { 35 static func systemColor(_ key: String) -> NSColor {
26 switch key { 36 switch key {