| @@ -1,16 +1,30 @@ |
| 1 | import AppKit |
| 1 | 2 | import OrgApp |
| 2 | 3 | import OrgDocument |
| 3 | 4 | import OrgCore |
| 4 | 5 | import OrgPresentation |
| 5 | 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 | 19 | /// The agenda window: the day/week agenda, the TODO list, tag matches and saved views. |
| 8 | 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 | 23 | /// (`+cat-tag<0:10-/regexp/`), \ by tag, < by the line's category, = by regexp, _ by effort, |
| 11 | 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 |
| 13 | | /// C-c C-q sets tags, C-c C-s / C-c C-d schedule and set a deadline, S-→/S-← move the date. |
| 25 | /// and unmark, B acts on the marked entries, t cycles the TODO state, +/- change the priority and |
| 26 | /// , sets it, : or C-c C-q sets tags, C-c C-s / C-c C-d schedule and set a deadline, S-→/S-← move |
| 27 | /// the date, k captures on the line's day, s saves all files, TAB and SPC show the entry. |
| 14 | 28 | struct AgendaView: View { |
| 15 | 29 | let workspace: WorkspaceModel |
| 16 | 30 | let session: DocumentSession |
| @@ -21,6 +35,7 @@ struct AgendaView: View { |
| 21 | 35 | @State private var views = AgendaViewLoader.builtIn |
| 22 | 36 | @State private var viewName = AgendaViewLoader.builtIn[0].name |
| 23 | 37 | @State private var matchText = "" |
| 38 | /// The selected line: `day:index` (`day:o` + index in the overdue group), or `list:index`. |
| 24 | 39 | @State private var selection: String? |
| 25 | 40 | @State private var tick = 0 |
| 26 | 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 | 59 | @State private var textPrompt: TextPrompt? |
| 45 | 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 | 68 | @AppStorage("agendaSpan") private var span = 10 |
| 47 | 69 | @AppStorage("agendaStartOffset") private var startOffset = -3 |
| 48 | 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 | 74 | @AppStorage("agendaPrefixAgenda") private var prefixAgenda = PrefixFormat.defaults["agenda"]! |
| 50 | 75 | @AppStorage("agendaPrefixTodo") private var prefixTodo = PrefixFormat.defaults["todo"]! |
| 51 | 76 | @AppStorage("agendaPrefixTags") private var prefixTags = PrefixFormat.defaults["tags"]! |
| @@ -94,6 +119,7 @@ struct AgendaView: View { |
| 94 | 119 | var includeSubfolders: Bool |
| 95 | 120 | var prefix: [String] |
| 96 | 121 | var logMode: Bool |
| 122 | var calendars: [String] |
| 97 | 123 | var files: Int |
| 98 | 124 | var changed: Int? |
| 99 | 125 | var edits: Int |
| @@ -104,7 +130,7 @@ struct AgendaView: View { |
| 104 | 130 | private var refreshKey: RefreshKey { |
| 105 | 131 | RefreshKey( |
| 106 | 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 | 134 | path: session.path, tick: tick |
| 109 | 135 | ) |
| 110 | 136 | } |
| @@ -116,41 +142,74 @@ struct AgendaView: View { |
| 116 | 142 | |
| 117 | 143 | private var isMatch: Bool { viewName == Self.match || viewName == Self.todoMatch } |
| 118 | 144 | |
| 119 | | private var customPrefix: Bool { |
| 120 | | switch agenda.mode { |
| 121 | | case .agenda: prefixAgenda != PrefixFormat.defaults["agenda"] |
| 122 | | case .todo: prefixTodo != PrefixFormat.defaults["todo"] |
| 123 | | case .match: prefixTags != PrefixFormat.defaults["tags"] |
| 145 | @Environment(\.orgTheme) private var theme |
| 146 | |
| 147 | // MARK: - Lines |
| 148 | |
| 149 | /// A selectable line: its selection id, the row, and the day it is on. |
| 150 | struct Line { |
| 151 | let id: String |
| 152 | let row: AgendaRow |
| 153 | let day: Int |
| 154 | } |
| 155 | |
| 156 | private var groups: [AgendaDayGroup] { |
| 157 | isAgenda ? agenda.groups(showAllDates: showAllDates, keep: filter.keeps) : [] |
| 158 | } |
| 159 | |
| 160 | private var listRows: [AgendaRow] { |
| 161 | isAgenda ? [] : agenda.list.filter(filter.keeps).compactMap { AgendaRow(item: $0, day: agenda.today, today: agenda.today) } |
| 162 | } |
| 163 | |
| 164 | /// The lines in order, as up and down move through them. |
| 165 | private func lines(_ groups: [AgendaDayGroup]) -> [Line] { |
| 166 | guard isAgenda else { return listRows.enumerated().map { Line(id: "list:\($0.offset)", row: $0.element, day: agenda.today) } } |
| 167 | return groups.flatMap { group in |
| 168 | (overdueShown ? group.overdue.enumerated().map { Line(id: "\(group.day):o\($0.offset)", row: $0.element, day: group.day) } : []) |
| 169 | + group.rows.enumerated().map { Line(id: "\(group.day):\($0.offset)", row: $0.element, day: group.day) } |
| 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 | 175 | var body: some View { |
| 130 | | List(selection: $selection) { |
| 131 | | if isAgenda { |
| 132 | | ForEach(agenda.days, id: \.day) { day in |
| 133 | | Section { |
| 134 | | rows(day.items.filter(filter.keeps), prefix: "\(day.day)", day: day.day) |
| 135 | | } header: { |
| 136 | | Text(day.header) |
| 137 | | .font(.headline) |
| 138 | | .foregroundStyle(day.isToday ? theme.color("agenda-today") ?? .accentColor : theme.color("agenda-date") ?? .primary) |
| 176 | let groups = self.groups |
| 177 | _ = tick |
| 178 | return ScrollViewReader { proxy in |
| 179 | ScrollView { |
| 180 | LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { |
| 181 | if isAgenda { |
| 182 | ForEach(groups) { group in |
| 183 | Section { |
| 184 | dayRows(group) |
| 185 | } header: { |
| 186 | dayHeader(group).id("day\(group.day)") |
| 187 | } |
| 188 | } |
| 189 | } else { |
| 190 | Section { |
| 191 | ForEach(Array(listRows.enumerated()), id: \.offset) { index, row in |
| 192 | rowView(Line(id: "list:\(index)", row: row, day: agenda.today), showsGutter: false) |
| 193 | } |
| 194 | } header: { |
| 195 | header { Text(listTitle).font(.system(size: 13, weight: .semibold)) } |
| 196 | } |
| 139 | 197 | } |
| 140 | 198 | } |
| 141 | | } else { |
| 142 | | Section { |
| 143 | | rows(agenda.list.filter(filter.keeps), prefix: "list") |
| 144 | | } header: { |
| 145 | | Text(listTitle).font(.headline) |
| 146 | | } |
| 199 | .padding(.bottom, 12) |
| 200 | } |
| 201 | .onChange(of: selection) { |
| 202 | if let selection { proxy.scrollTo(selection) } |
| 203 | } |
| 204 | .onChange(of: agenda.days.first?.day) { |
| 205 | if groups.contains(where: \.isToday) { proxy.scrollTo("day\(agenda.today)", anchor: .top) } |
| 147 | 206 | } |
| 148 | 207 | } |
| 149 | | .themedListBackground(theme.color("agenda-background")) |
| 150 | | .contextMenu(forSelectionType: String.self) { _ in |
| 151 | | } primaryAction: { ids in |
| 152 | | if let id = ids.first { jump(id) } |
| 153 | | } |
| 208 | .background(theme.color("agenda-background") ?? Color(nsColor: .textBackgroundColor)) |
| 209 | .focusable() |
| 210 | .focusEffectDisabled() |
| 211 | .focused($focused) |
| 212 | .onAppear { focused = true } |
| 154 | 213 | .onKeyPress(phases: .down) { press in handle(press) } |
| 155 | 214 | .safeAreaInset(edge: .bottom, spacing: 0) { |
| 156 | 215 | if let message = message ?? (filter.isEmpty ? reminderStatus : "Filter: " + filter.description) { |
| @@ -162,35 +221,7 @@ struct AgendaView: View { |
| 162 | 221 | .background(.bar) |
| 163 | 222 | } |
| 164 | 223 | } |
| 165 | | .toolbar { |
| 166 | | ToolbarItemGroup(placement: .navigation) { |
| 167 | | Menu { |
| 168 | | ForEach(views) { view in Button(view.name) { viewName = view.name } } |
| 169 | | Divider() |
| 170 | | Button(Self.match) { viewName = Self.match } |
| 171 | | Button(Self.todoMatch) { viewName = Self.todoMatch } |
| 172 | | } label: { |
| 173 | | Label(viewName, systemImage: "calendar").labelStyle(.titleAndIcon) |
| 174 | | } |
| 175 | | .help("What the agenda shows") |
| 176 | | if isMatch { |
| 177 | | TextField("+work-boss|LEVEL>2/!TODO", text: $matchText) |
| 178 | | .frame(minWidth: 220) |
| 179 | | .onSubmit { applyView() } |
| 180 | | } |
| 181 | | } |
| 182 | | ToolbarItemGroup { |
| 183 | | if agenda.isComputing { ProgressView().controlSize(.small) } |
| 184 | | if isAgenda { |
| 185 | | Button("Earlier", systemImage: "chevron.left") { agenda.shift(forward: false) } |
| 186 | | .help("Previous days (b)") |
| 187 | | Button("Today") { agenda.showToday() } |
| 188 | | .help("Back to today (.)") |
| 189 | | Button("Later", systemImage: "chevron.right") { agenda.shift(forward: true) } |
| 190 | | .help("Next days (f)") |
| 191 | | } |
| 192 | | } |
| 193 | | } |
| 224 | .toolbar { toolbar } |
| 194 | 225 | .overlay { |
| 195 | 226 | if agenda.files.isEmpty { |
| 196 | 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 | 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 | 277 | .onChange(of: viewName) { applyView() } |
| 246 | 278 | .onChange(of: commands.agendaRequest?.start, initial: true) { showRequestedDays() } |
| 247 | 279 | .onChange(of: commands.agendaRequest?.days) { showRequestedDays() } |
| @@ -252,6 +284,12 @@ struct AgendaView: View { |
| 252 | 284 | agenda.options.prefixFormat = ["agenda": prefixAgenda, "todo": prefixTodo, "tags": prefixTags] |
| 253 | 285 | agenda.options.logMode = logMode |
| 254 | 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 | 293 | let open = session.openTexts |
| 256 | 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 | 310 | @ViewBuilder |
| 269 | | private func rows(_ items: [AgendaItem], prefix: String, day: Int? = nil) -> some View { |
| 270 | | ForEach(Array(items.enumerated()), id: \.offset) { index, item in |
| 271 | | AgendaRow(item: item, day: day, customPrefix: customPrefix, marked: EntryRef(item).map(marked.contains) ?? false) |
| 272 | | .tag("\(prefix):\(index)") |
| 273 | | .selectionDisabled(item.path == nil) |
| 311 | private func dayRows(_ group: AgendaDayGroup) -> some View { |
| 312 | if group.isToday, !group.overdue.isEmpty { |
| 313 | overdueHeader(count: group.overdue.count) |
| 314 | if overdueShown { |
| 315 | ForEach(Array(group.overdue.enumerated()), id: \.element.id) { index, row in |
| 316 | rowView(Line(id: "\(group.day):o\(index)", row: row, day: group.day)) |
| 317 | } |
| 318 | Divider().padding(.leading, 16).padding(.vertical, 4) |
| 319 | } |
| 320 | } |
| 321 | if group.isEmpty { |
| 322 | Text("Nothing scheduled") |
| 323 | .font(.callout) |
| 324 | .foregroundStyle(.secondary) |
| 325 | .padding(.horizontal, 16) |
| 326 | .padding(.vertical, 10) |
| 327 | } |
| 328 | let now = group.nowIndex(now: Self.hhmm(Date())) |
| 329 | ForEach(Array(group.rows.enumerated()), id: \.element.id) { index, row in |
| 330 | if index == now { nowLine } |
| 331 | rowView(Line(id: "\(group.day):\(index)", row: row, day: group.day)) |
| 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 | 627 | @AppStorage("reminders") private var remindersEnabled = true |
| @@ -303,6 +653,7 @@ struct AgendaView: View { |
| 303 | 653 | guard let request = commands.agendaRequest else { return } |
| 304 | 654 | commands.agendaRequest = nil |
| 305 | 655 | selection = nil |
| 656 | spanKind = nil |
| 306 | 657 | if viewName != AgendaViewLoader.builtIn[0].name { |
| 307 | 658 | keepRequestedDays = true |
| 308 | 659 | viewName = AgendaViewLoader.builtIn[0].name |
| @@ -314,6 +665,7 @@ struct AgendaView: View { |
| 314 | 665 | private func applyView() { |
| 315 | 666 | if keepRequestedDays { return keepRequestedDays = false } |
| 316 | 667 | selection = nil |
| 668 | spanKind = nil |
| 317 | 669 | agenda.start = nil |
| 318 | 670 | switch viewName { |
| 319 | 671 | case Self.match, Self.todoMatch: |
| @@ -327,11 +679,29 @@ struct AgendaView: View { |
| 327 | 679 | |
| 328 | 680 | // MARK: - Items |
| 329 | 681 | |
| 330 | | private func item(_ id: String) -> AgendaItem? { |
| 331 | | let parts = id.split(separator: ":") |
| 332 | | guard parts.count == 2, let index = Int(parts[1]) else { return nil } |
| 333 | | let items = (parts[0] == "list" ? agenda.list : agenda.days.first { "\($0.day)" == parts[0] }?.items ?? []).filter(filter.keeps) |
| 334 | | return index < items.count ? items[index] : nil |
| 682 | private func item(_ id: String) -> AgendaItem? { line(id)?.row.item } |
| 683 | |
| 684 | /// Return and double click: the entry in the main window, or the event in Calendar. |
| 685 | private func open(_ line: Line) { |
| 686 | selection = line.id |
| 687 | if let event = line.row.event { openEvent(event) } else { jump(line.id) } |
| 688 | } |
| 689 | |
| 690 | /// Calendar shows an event from its identifier (`AgendaEvent.id` adds `@` and the start). |
| 691 | private func openEvent(_ event: AgendaEvent) { |
| 692 | let identifier = event.id.split(separator: "@").dropLast().joined(separator: "@") |
| 693 | guard !identifier.isEmpty, let encoded = identifier.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed), |
| 694 | let url = URL(string: "ical://ekevent/\(encoded)?method=show&options=more") else { return } |
| 695 | NSWorkspace.shared.open(url) |
| 696 | } |
| 697 | |
| 698 | /// Moves the selection by `step` lines. |
| 699 | private func step(_ step: Int) { |
| 700 | let all = lines(groups) |
| 701 | guard !all.isEmpty else { return } |
| 702 | let index = selection.flatMap { id in all.firstIndex { $0.id == id } } |
| 703 | let next = index.map { min(max($0 + step, 0), all.count - 1) } ?? (step > 0 ? 0 : all.count - 1) |
| 704 | selection = all[next].id |
| 335 | 705 | } |
| 336 | 706 | |
| 337 | 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 | 764 | guard let item = item(id), let path = item.path, let offset = item.markerOffset ?? item.headingOffset else { return } |
| 394 | 765 | commands.jump(path: path, offset: offset) |
| 395 | | openWindow(id: "main") |
| 766 | if focus { openWindow(id: "main") } |
| 396 | 767 | } |
| 397 | 768 | |
| 398 | 769 | // MARK: - Commands |
| @@ -403,6 +774,16 @@ struct AgendaView: View { |
| 403 | 774 | func isControl(_ letter: String, _ code: String) -> Bool { control && (press.characters == letter || press.characters == code) } |
| 404 | 775 | if let pending = prefix { |
| 405 | 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 | 787 | if pending == "C-c C-x" { |
| 407 | 788 | if isControl("s", "\u{13}") { archive() } else { message = "C-c C-x \(press.characters) is undefined" } |
| 408 | 789 | return .handled |
| @@ -432,8 +813,20 @@ struct AgendaView: View { |
| 432 | 813 | message = "C-c-" |
| 433 | 814 | return .handled |
| 434 | 815 | } |
| 435 | | if press.key == .return, let selection { |
| 436 | | jump(selection) |
| 816 | if isControl("n", "\u{0e}") || (press.key == .downArrow && press.modifiers.isEmpty) { |
| 817 | step(1) |
| 818 | return .handled |
| 819 | } |
| 820 | if isControl("p", "\u{10}") || (press.key == .upArrow && press.modifiers.isEmpty) { |
| 821 | step(-1) |
| 822 | return .handled |
| 823 | } |
| 824 | if press.key == .return || press.key == .tab, let selection, let line = line(selection) { |
| 825 | open(line) |
| 826 | return .handled |
| 827 | } |
| 828 | if press.key == .space, let selection { |
| 829 | jump(selection, focus: false) |
| 437 | 830 | return .handled |
| 438 | 831 | } |
| 439 | 832 | if press.modifiers == .shift, press.key == .rightArrow || press.key == .leftArrow { |
| @@ -442,9 +835,23 @@ struct AgendaView: View { |
| 442 | 835 | } |
| 443 | 836 | guard press.modifiers.subtracting(.shift).isEmpty else { return .ignored } |
| 444 | 837 | switch press.characters { |
| 445 | | case "f" where isAgenda: agenda.shift(forward: true) |
| 446 | | case "b" where isAgenda: agenda.shift(forward: false) |
| 447 | | case "." where isAgenda: agenda.showToday() |
| 838 | case "n": step(1) |
| 839 | case "p": step(-1) |
| 840 | case "f" where isAgenda: move(forward: true) |
| 841 | case "b" where isAgenda: move(forward: false) |
| 842 | case "." where isAgenda: goToday() |
| 843 | case "j" where isAgenda: |
| 844 | pickedDate = Self.date(anchorDay) |
| 845 | showingDatePicker = true |
| 846 | case "v" where isAgenda: |
| 847 | prefix = "v" |
| 848 | message = "v-" |
| 849 | case "d" where isAgenda: setSpan(.day) |
| 850 | case "w" where isAgenda: setSpan(.week) |
| 851 | case "k" where isAgenda: capture(on: anchorDay) |
| 852 | case "s": |
| 853 | commands.run("app.save-all") |
| 854 | message = "Saved" |
| 448 | 855 | case "/", "\\", "=", "_": askFilter(press.characters.first!) |
| 449 | 856 | case "<": toggleCategoryFilter() |
| 450 | 857 | case "|": |
| @@ -469,6 +876,7 @@ struct AgendaView: View { |
| 469 | 876 | case "t": run(TodoCycle()) |
| 470 | 877 | case "+": run(PriorityUp()) |
| 471 | 878 | case "-": run(PriorityDown()) |
| 879 | case ",": run(SetPriority()) |
| 472 | 880 | case ":": run(SetTags()) |
| 473 | 881 | default: return .ignored |
| 474 | 882 | } |
| @@ -641,111 +1049,158 @@ struct AgendaView: View { |
| 641 | 1049 | } |
| 642 | 1050 | } |
| 643 | 1051 | |
| 644 | | struct AgendaRow: View { |
| 645 | | let item: AgendaItem |
| 1052 | /// One agenda line: the time or status over an icon, the category above the title, progress |
| 1053 | /// and tags after it, and the TODO keyword and priority at the end. |
| 1054 | struct AgendaRowView: View { |
| 1055 | let row: AgendaRow |
| 646 | 1056 | /// The agenda day the row is on, for habit graphs. |
| 647 | | var day: Int? |
| 648 | | /// A user `org-agenda-prefix-format`: the prefix as Emacs prints it, not the columns. |
| 649 | | var customPrefix = false |
| 1057 | var day: Int |
| 1058 | var showsGutter = true |
| 1059 | var selected = false |
| 1060 | var hovered = false |
| 650 | 1061 | /// Marked for a bulk action. |
| 651 | 1062 | var marked = false |
| 652 | 1063 | @Environment(\.orgTheme) private var theme |
| 653 | 1064 | |
| 1065 | static let gutterWidth: CGFloat = 92 |
| 1066 | |
| 654 | 1067 | var body: some View { |
| 655 | | switch item.kind { |
| 656 | | case .timeGrid: |
| 657 | | HStack(spacing: 8) { |
| 658 | | Text(Self.trim(item.time)) |
| 659 | | .monospacedDigit() |
| 660 | | .frame(width: 140, alignment: .trailing) |
| 661 | | VStack { Divider() } |
| 662 | | } |
| 663 | | .font(.caption) |
| 664 | | .foregroundStyle(theme.color("agenda-time").map(AnyShapeStyle.init) ?? AnyShapeStyle(.tertiary)) |
| 665 | | case .currentTime: |
| 666 | | HStack(spacing: 8) { |
| 667 | | Text("now " + Self.trim(item.time)) |
| 668 | | .monospacedDigit() |
| 669 | | .frame(width: 140, alignment: .trailing) |
| 670 | | Rectangle().fill(theme.color("agenda-today") ?? .accentColor).frame(height: 1) |
| 671 | | } |
| 672 | | .font(.caption) |
| 673 | | .foregroundStyle(theme.color("agenda-today") ?? .accentColor) |
| 674 | | default: |
| 675 | | HStack(spacing: 4) { |
| 676 | | if marked { Text("›").bold().foregroundStyle(theme.color("agenda-today") ?? .accentColor) } |
| 677 | | entryRow |
| 1068 | HStack(alignment: .center, spacing: 12) { |
| 1069 | if showsGutter { gutter } |
| 1070 | VStack(alignment: .leading, spacing: 2) { |
| 1071 | if !row.category.isEmpty { categoryLine } |
| 1072 | titleLine |
| 1073 | } |
| 1074 | .layoutPriority(1) |
| 1075 | Spacer(minLength: 8) |
| 1076 | if let habit = row.item?.habit { |
| 1077 | HabitGraph(cells: habit.graph(today: day), theme: theme) |
| 1078 | } |
| 1079 | if let todo = row.todo { |
| 1080 | let color = theme.dynamic(row.isDone ? .systemGreen : .systemRed) { [theme, row] in theme.todoColor(todo, isDone: row.isDone, dark: $0) } |
| 1081 | Text(todo) |
| 1082 | .font(.system(size: 10, weight: .bold)) |
| 1083 | .foregroundStyle(color) |
| 1084 | .padding(.horizontal, 7) |
| 1085 | .padding(.vertical, 2) |
| 1086 | .background(Capsule().fill(color.opacity(0.14))) |
| 1087 | .fixedSize() |
| 1088 | } |
| 1089 | priorityBadge |
| 1090 | .frame(width: 18) |
| 1091 | } |
| 1092 | .padding(.horizontal, 16) |
| 1093 | .padding(.vertical, 6) |
| 1094 | .frame(minHeight: 44) |
| 1095 | .background { |
| 1096 | RoundedRectangle(cornerRadius: 6) |
| 1097 | .fill(selected ? theme.color("selection") ?? Color.accentColor.opacity(0.2) : hovered ? Color.primary.opacity(0.05) : .clear) |
| 1098 | .padding(.horizontal, 6) |
| 1099 | } |
| 1100 | .overlay(alignment: .leading) { |
| 1101 | if marked { |
| 1102 | Capsule().fill(theme.color("agenda-today") ?? .accentColor).frame(width: 3).padding(.vertical, 8).padding(.leading, 8) |
| 678 | 1103 | } |
| 679 | 1104 | } |
| 1105 | .accessibilityElement(children: .combine) |
| 680 | 1106 | } |
| 681 | 1107 | |
| 682 | | @ViewBuilder |
| 683 | | private var entryRow: some View { |
| 684 | | if customPrefix { |
| 685 | | HStack(spacing: 0) { |
| 686 | | Text(item.prefix) |
| 687 | | .font(.body.monospaced()) |
| 688 | | .foregroundStyle(theme.color("agenda-category") ?? .secondary) |
| 689 | | title.lineLimit(1) |
| 690 | | Spacer(minLength: 8) |
| 691 | | if let habit = item.habit, let day { HabitGraph(cells: habit.graph(today: day), theme: theme) } |
| 692 | | if !item.tags.isEmpty { |
| 693 | | Text(":" + item.tags.joined(separator: ":") + ":").font(.caption).foregroundStyle(theme.color("tags") ?? .secondary) |
| 694 | | } |
| 695 | | } |
| 696 | | } else { |
| 697 | | HStack(spacing: 8) { |
| 698 | | Text(item.category) |
| 699 | | .lineLimit(1) |
| 700 | | .foregroundStyle(theme.color("agenda-category") ?? .secondary) |
| 701 | | .frame(width: 80, alignment: .leading) |
| 702 | | if item.kind != .todo, item.kind != .tagsMatch { |
| 703 | | Text(Self.trim(item.time)) |
| 704 | | .monospacedDigit() |
| 705 | | .foregroundStyle(theme.color("agenda-time") ?? .primary) |
| 706 | | .frame(width: 80, alignment: .leading) |
| 707 | | } |
| 708 | | if !item.extra.isEmpty { |
| 709 | | Text(item.extra.trimmingCharacters(in: .whitespaces)) |
| 710 | | .monospacedDigit() |
| 711 | | .foregroundStyle(leaderColor) |
| 712 | | } |
| 713 | | title |
| 714 | | .lineLimit(1) |
| 715 | | Spacer(minLength: 8) |
| 716 | | if let habit = item.habit, let day { |
| 717 | | HabitGraph(cells: habit.graph(today: day), theme: theme) |
| 718 | | } |
| 719 | | if !item.tags.isEmpty { |
| 720 | | Text(":" + item.tags.joined(separator: ":") + ":") |
| 721 | | .font(.caption) |
| 722 | | .foregroundStyle(theme.color("tags") ?? .secondary) |
| 723 | | } |
| 724 | | } |
| 1108 | private var statusColor: Color { |
| 1109 | theme.dynamic(.secondaryLabelColor) { [theme, row] in theme.statusColor(row.status, dark: $0) } |
| 1110 | } |
| 1111 | |
| 1112 | private var gutter: some View { |
| 1113 | VStack(alignment: .leading, spacing: 3) { |
| 1114 | Text(row.gutter) |
| 1115 | .font(.system(size: 12, weight: row.status == .overdue || row.status == .dueSoon ? .semibold : .regular).monospacedDigit()) |
| 1116 | .foregroundStyle(statusColor) |
| 1117 | .lineLimit(1) |
| 1118 | .minimumScaleFactor(0.8) |
| 1119 | if let symbol { |
| 1120 | Image(systemName: symbol) |
| 1121 | .font(.system(size: row.icon == .event ? 8 : 10, weight: .semibold)) |
| 1122 | .foregroundStyle(iconColor) |
| 1123 | .frame(height: 12) |
| 725 | 1124 | } |
| 1125 | } |
| 1126 | .frame(width: Self.gutterWidth, alignment: .leading) |
| 726 | 1127 | } |
| 727 | 1128 | |
| 728 | | static func trim(_ time: String) -> String { |
| 729 | | time.trimmingCharacters(in: CharacterSet(charactersIn: " ┄.")) |
| 1129 | /// Overdue entries show a warning, done ones a check, as the status says first. |
| 1130 | private var symbol: String? { |
| 1131 | guard let icon = row.icon else { return nil } |
| 1132 | if row.status == .overdue { return "exclamationmark.circle.fill" } |
| 1133 | if row.status == .done { return "checkmark.circle.fill" } |
| 1134 | switch icon { |
| 1135 | case .scheduled: return "calendar" |
| 1136 | case .deadline: return "flag.fill" |
| 1137 | case .habit: return "repeat" |
| 1138 | case .event: return "circle.fill" |
| 1139 | case .timestamp: return row.start == nil ? "calendar" : "clock" |
| 1140 | } |
| 730 | 1141 | } |
| 731 | 1142 | |
| 732 | | private var leaderColor: Color { |
| 733 | | switch item.kind { |
| 734 | | case .deadline: theme.color("agenda-deadline") ?? .red |
| 735 | | case .upcomingDeadline: theme.color("agenda-upcoming") ?? .orange |
| 736 | | case .pastScheduled: theme.color("agenda-scheduled-past") ?? .brown |
| 737 | | default: theme.color("agenda-scheduled") ?? .secondary |
| 1143 | private var iconColor: Color { |
| 1144 | if let event = row.event { return theme.dynamic(.systemPurple) { [theme] in theme.eventColor(event, dark: $0) } } |
| 1145 | return row.status == .normal ? Color.secondary.opacity(0.8) : statusColor |
| 1146 | } |
| 1147 | |
| 1148 | private var categoryLine: some View { |
| 1149 | HStack(spacing: 5) { |
| 1150 | Circle() |
| 1151 | .fill(row.event.map { event in theme.dynamic(.systemPurple) { [theme] in theme.eventColor(event, dark: $0) } } |
| 1152 | ?? theme.dynamic { [theme, row] in theme.categoryColor(row.category, dark: $0) }) |
| 1153 | .frame(width: 6, height: 6) |
| 1154 | Text(row.category + (row.event?.location.map { " · " + $0 } ?? "")) |
| 1155 | .font(.system(size: 11)) |
| 1156 | .foregroundStyle(theme.color("agenda-category") ?? .secondary) |
| 1157 | .lineLimit(1) |
| 738 | 1158 | } |
| 739 | 1159 | } |
| 740 | 1160 | |
| 741 | | /// The TODO keyword colored as in the editor. |
| 742 | | private var title: Text { |
| 743 | | let text = item.text.trimmingCharacters(in: .whitespaces) |
| 744 | | guard let todo = item.todo, text.hasPrefix(todo + " ") else { return Text(text) } |
| 745 | | let rest = String(text.dropFirst(todo.count)) |
| 746 | | let rest_ = Text(rest).foregroundColor(item.isDone ? theme.color("comment") ?? .secondary : theme.color("foreground") ?? .primary) |
| 747 | | let todo_ = Text(todo).foregroundColor(theme.keywordColor(todo, done: item.isDone)).bold() |
| 748 | | return Text("\(todo_)\(rest_)") |
| 1161 | private var titleLine: some View { |
| 1162 | HStack(spacing: 6) { |
| 1163 | Text(row.title.isEmpty ? " " : row.title) |
| 1164 | .font(.system(size: 13)) |
| 1165 | .strikethrough(row.isDone) |
| 1166 | .foregroundStyle(row.isDone ? AnyShapeStyle(.secondary) : AnyShapeStyle(theme.color("foreground") ?? .primary)) |
| 1167 | .lineLimit(1) |
| 1168 | .truncationMode(.tail) |
| 1169 | if let progress = row.progress { |
| 1170 | Text(progress) |
| 1171 | .font(.system(size: 10, weight: .semibold).monospacedDigit()) |
| 1172 | .foregroundStyle(.secondary) |
| 1173 | .padding(.horizontal, 6) |
| 1174 | .padding(.vertical, 1) |
| 1175 | .background(Capsule().fill(Color.secondary.opacity(0.14))) |
| 1176 | .fixedSize() |
| 1177 | } |
| 1178 | ForEach(row.tags, id: \.self) { tag in |
| 1179 | Text(tag) |
| 1180 | .font(.system(size: 10, weight: .medium)) |
| 1181 | .foregroundStyle(theme.color("tags") ?? .secondary) |
| 1182 | .padding(.horizontal, 6) |
| 1183 | .padding(.vertical, 1) |
| 1184 | .background(Capsule().strokeBorder((theme.color("tags") ?? .secondary).opacity(0.35), lineWidth: 0.5)) |
| 1185 | .fixedSize() |
| 1186 | } |
| 1187 | } |
| 1188 | } |
| 1189 | |
| 1190 | @ViewBuilder |
| 1191 | private var priorityBadge: some View { |
| 1192 | if let priority = row.priority { |
| 1193 | let color = theme.dynamic(.systemOrange) { [theme] in theme.priorityColor(priority, dark: $0) } |
| 1194 | Text(priority) |
| 1195 | .font(.system(size: 10, weight: .bold)) |
| 1196 | .foregroundStyle(color) |
| 1197 | .frame(width: 18, height: 18) |
| 1198 | .background(Circle().fill(color.opacity(0.14))) |
| 1199 | .overlay(Circle().strokeBorder(color.opacity(0.6), lineWidth: 1)) |
| 1200 | .help("Priority \(priority)") |
| 1201 | } else { |
| 1202 | Color.clear.frame(width: 18, height: 18) |
| 1203 | } |
| 749 | 1204 | } |
| 750 | 1205 | } |
| 751 | 1206 | |