| @@ -1,16 +1,30 @@ |
| |
1 | import AppKit |
| 1 | import OrgApp |
2 | import OrgApp |
| 2 | import OrgDocument |
3 | import OrgDocument |
| 3 | import OrgCore |
4 | import OrgCore |
| 4 | import OrgPresentation |
5 | import OrgPresentation |
| 5 | import SwiftUI |
6 | import SwiftUI |
| 6 | |
7 | |
| |
8 | /// The days an agenda shows, as Org's `v d`, `v w` and `v m`. |
| |
9 | enum 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 |
| |
15 | enum 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. |
| 14 | struct AgendaView: View { |
28 | struct 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 | |
| 644 | struct 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. |
| |
1054 | struct 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 | |