Commit af140c9251
Verified · cmc
Layout: unified · split
Sources/OrgApp/AgendaModel.swift added +93
| @@ -0,0 +1,93 @@ | |||
| 1 | import Foundation | ||
| 2 | import Observation | ||
| 3 | import OrgCore | ||
| 4 | import OrgIndex | ||
| 5 | |||
| 6 | /// The day/week agenda over the workspace's agenda files. Sources are read from disk and | ||
| 7 | /// cached by modification date and size; the open file's come from its buffer, so the agenda | ||
| 8 | /// shows unsaved edits. | ||
| 9 | @MainActor | ||
| 10 | @Observable | ||
| 11 | public final class AgendaModel { | ||
| 12 | public private(set) var days: [AgendaDay] = [] | ||
| 13 | /// The first day shown, as `Days` numbers it; nil follows today. | ||
| 14 | public var start: Int? | ||
| 15 | public var span = 10 | ||
| 16 | /// Where the view starts relative to today, as Doom's `org-agenda-start-day "-3d"`. | ||
| 17 | public var startOffset = -3 | ||
| 18 | /// Files below the top level of each folder are agenda files too. | ||
| 19 | public var includeSubfolders = false | ||
| 20 | public var options = AgendaOptions() | ||
| 21 | public private(set) var isComputing = false | ||
| 22 | |||
| 23 | private let workspace: WorkspaceModel | ||
| 24 | @ObservationIgnored private var cache: [String: (mtime: Date, size: Int, source: AgendaSource)] = [:] | ||
| 25 | @ObservationIgnored private var generation = 0 | ||
| 26 | |||
| 27 | public init(workspace: WorkspaceModel) { | ||
| 28 | self.workspace = workspace | ||
| 29 | } | ||
| 30 | |||
| 31 | /// Org files directly in each folder (or below it, with `includeSubfolders`), by path, as | ||
| 32 | /// Emacs lists a directory in `org-agenda-files`. | ||
| 33 | public var files: [String] { | ||
| 34 | workspace.files | ||
| 35 | .filter { $0.kind == .org && (includeSubfolders || !$0.relativePath.contains("/")) } | ||
| 36 | .map(\.path) | ||
| 37 | .sorted() | ||
| 38 | } | ||
| 39 | |||
| 40 | /// Recomputes the days. `open` is the open file's path and text. | ||
| 41 | public func refresh(open: (path: String, text: String)?, now: Date = Date(), calendar: Calendar = .current) async { | ||
| 42 | generation += 1 | ||
| 43 | let id = generation | ||
| 44 | isComputing = true | ||
| 45 | let today = Days.absolute(now, calendar: calendar) | ||
| 46 | let clock = calendar.dateComponents([.hour, .minute], from: now) | ||
| 47 | let first = start ?? today + startOffset | ||
| 48 | let defaults = workspace.settings.org | ||
| 49 | var reads: [(path: String, cached: AgendaSource?, mtime: Date, size: Int, text: String?)] = [] | ||
| 50 | for path in files { | ||
| 51 | if let open, open.path == path { | ||
| 52 | reads.append((path, nil, .distantPast, -1, open.text)) | ||
| 53 | continue | ||
| 54 | } | ||
| 55 | let attributes = try? FileManager.default.attributesOfItem(atPath: path) | ||
| 56 | let mtime = attributes?[.modificationDate] as? Date ?? .distantPast | ||
| 57 | let size = (attributes?[.size] as? NSNumber)?.intValue ?? -1 | ||
| 58 | let cached = cache[path].flatMap { $0.mtime == mtime && $0.size == size ? $0.source : nil } | ||
| 59 | reads.append((path, cached, mtime, size, nil)) | ||
| 60 | } | ||
| 61 | let span = self.span | ||
| 62 | let options = self.options | ||
| 63 | let result = await Task.detached { | ||
| 64 | var sources: [(path: String, mtime: Date, size: Int, source: AgendaSource, fromDisk: Bool)] = [] | ||
| 65 | for read in reads { | ||
| 66 | if let cached = read.cached { | ||
| 67 | sources.append((read.path, read.mtime, read.size, cached, true)) | ||
| 68 | } else if let text = read.text { | ||
| 69 | sources.append((read.path, read.mtime, read.size, AgendaSource(path: read.path, text: text, defaults: defaults), false)) | ||
| 70 | } else if let data = FileManager.default.contents(atPath: read.path) { | ||
| 71 | let text = SourceText(bytes: [UInt8](data)).text | ||
| 72 | sources.append((read.path, read.mtime, read.size, AgendaSource(path: read.path, text: text, defaults: defaults), true)) | ||
| 73 | } | ||
| 74 | } | ||
| 75 | let days = Agenda.list(sources.map(\.source), start: first, days: span, today: today, now: (clock.hour ?? 0, clock.minute ?? 0), options: options) | ||
| 76 | return (sources, days) | ||
| 77 | }.value | ||
| 78 | guard id == generation else { return } | ||
| 79 | for source in result.0 where source.fromDisk { | ||
| 80 | cache[source.path] = (source.mtime, source.size, source.source) | ||
| 81 | } | ||
| 82 | days = result.1 | ||
| 83 | isComputing = false | ||
| 84 | } | ||
| 85 | |||
| 86 | public func showToday() { start = nil } | ||
| 87 | |||
| 88 | /// Moves the view by a whole span, forward or back. | ||
| 89 | public func shift(forward: Bool, now: Date = Date(), calendar: Calendar = .current) { | ||
| 90 | let current = start ?? Days.absolute(now, calendar: calendar) + startOffset | ||
| 91 | start = current + (forward ? span : -span) | ||
| 92 | } | ||
| 93 | } | ||
Sources/OrgApp/DocumentSession.swift +8 −1
| @@ -53,6 +53,9 @@ public final class DocumentSession { | |||
| 53 | /// keystroke; views follow `path`, `isDirty` and `outline`. | 53 | /// keystroke; views follow `path`, `isDirty` and `outline`. |
| 54 | @ObservationIgnored public private(set) var document: DocumentState? | 54 | @ObservationIgnored public private(set) var document: DocumentState? |
| 55 | public private(set) var outline: [OutlineEntry] = [] | 55 | public private(set) var outline: [OutlineEntry] = [] |
| 56 | /// Bumped when the buffer settles after edits, and when a file opens: views that read | ||
| 57 | /// `document` follow this. | ||
| 58 | public private(set) var editVersion = 0 | ||
| 56 | public private(set) var isDirty = false | 59 | public private(set) var isDirty = false |
| 57 | /// The file isn't UTF-8, so it can't be edited or saved. | 60 | /// The file isn't UTF-8, so it can't be edited or saved. |
| 58 | public private(set) var isReadOnly = false | 61 | public private(set) var isReadOnly = false |
| @@ -117,6 +120,7 @@ public final class DocumentSession { | |||
| 117 | try? await Task.sleep(for: .milliseconds(300)) | 120 | try? await Task.sleep(for: .milliseconds(300)) |
| 118 | guard !Task.isCancelled, let self, let document = self.document else { return } | 121 | guard !Task.isCancelled, let self, let document = self.document else { return } |
| 119 | self.outline = Presentation.outline(document.tree) | 122 | self.outline = Presentation.outline(document.tree) |
| 123 | self.editVersion += 1 | ||
| 120 | } | 124 | } |
| 121 | scheduleAutosave() | 125 | scheduleAutosave() |
| 122 | } | 126 | } |
| @@ -127,7 +131,10 @@ public final class DocumentSession { | |||
| 127 | document = state | 131 | document = state |
| 128 | if isDirty != state.isDirty { isDirty = state.isDirty } | 132 | if isDirty != state.isDirty { isDirty = state.isDirty } |
| 129 | if isReadOnly == state.isEditable { isReadOnly = !state.isEditable } | 133 | if isReadOnly == state.isEditable { isReadOnly = !state.isEditable } |
| 130 | if updateOutline { outline = Presentation.outline(state.tree) } | 134 | if updateOutline { |
| 135 | outline = Presentation.outline(state.tree) | ||
| 136 | editVersion += 1 | ||
| 137 | } | ||
| 131 | } | 138 | } |
| 132 | 139 | ||
| 133 | // MARK: - Saving | 140 | // MARK: - Saving |
Sources/OrgCore/Keymap/Presets.swift +3
| @@ -150,6 +150,9 @@ public enum KeymapPreset: String, Sendable, CaseIterable { | |||
| 150 | [[bind]] | 150 | [[bind]] |
| 151 | keys = "M-x" | 151 | keys = "M-x" |
| 152 | command = "app.palette" | 152 | command = "app.palette" |
| 153 | [[bind]] | ||
| 154 | keys = "C-c a" | ||
| 155 | command = "app.agenda" | ||
| 153 | 156 | ||
| 154 | # Org | 157 | # Org |
| 155 | [[bind]] | 158 | [[bind]] |
Sources/OrgEditorAppKit/EditorCommands.swift +1
| @@ -42,6 +42,7 @@ public enum EditorCommands { | |||
| 42 | EditorCommand(id: "app.reload-keymap", title: "Reload Keymap", action: .app), | 42 | EditorCommand(id: "app.reload-keymap", title: "Reload Keymap", action: .app), |
| 43 | EditorCommand(id: "app.refile", title: "Refile…", action: .app), | 43 | EditorCommand(id: "app.refile", title: "Refile…", action: .app), |
| 44 | EditorCommand(id: "app.archive", title: "Archive Subtree", action: .app), | 44 | EditorCommand(id: "app.archive", title: "Archive Subtree", action: .app), |
| 45 | EditorCommand(id: "app.agenda", title: "Agenda", action: .app), | ||
| 45 | 46 | ||
| 46 | movement("forward-char", "Forward Character", "moveForward:"), | 47 | movement("forward-char", "Forward Character", "moveForward:"), |
| 47 | movement("backward-char", "Backward Character", "moveBackward:"), | 48 | movement("backward-char", "Backward Character", "moveBackward:"), |
Sources/Orgstar/AgendaView.swift added +190
| @@ -0,0 +1,190 @@ | |||
| 1 | import OrgApp | ||
| 2 | import OrgCore | ||
| 3 | import SwiftUI | ||
| 4 | |||
| 5 | /// The agenda window: `org-agenda-list` over the agenda files. Return or a double click jumps | ||
| 6 | /// to the entry in the main window; f/b move a span, `.` returns to today, g refreshes. | ||
| 7 | struct AgendaView: View { | ||
| 8 | let workspace: WorkspaceModel | ||
| 9 | let session: DocumentSession | ||
| 10 | let commands: AppCommands | ||
| 11 | @State private var agenda: AgendaModel | ||
| 12 | @State private var selection: String? | ||
| 13 | @State private var tick = 0 | ||
| 14 | @AppStorage("agendaSpan") private var span = 10 | ||
| 15 | @AppStorage("agendaStartOffset") private var startOffset = -3 | ||
| 16 | @AppStorage("agendaSubfolders") private var includeSubfolders = false | ||
| 17 | @Environment(\.openWindow) private var openWindow | ||
| 18 | |||
| 19 | init(workspace: WorkspaceModel, session: DocumentSession, commands: AppCommands) { | ||
| 20 | self.workspace = workspace | ||
| 21 | self.session = session | ||
| 22 | self.commands = commands | ||
| 23 | _agenda = State(initialValue: AgendaModel(workspace: workspace)) | ||
| 24 | } | ||
| 25 | |||
| 26 | private struct RefreshKey: Hashable { | ||
| 27 | var start: Int? | ||
| 28 | var span: Int | ||
| 29 | var startOffset: Int | ||
| 30 | var includeSubfolders: Bool | ||
| 31 | var files: Int | ||
| 32 | var changed: Int? | ||
| 33 | var edits: Int | ||
| 34 | var path: String? | ||
| 35 | var tick: Int | ||
| 36 | } | ||
| 37 | |||
| 38 | private var refreshKey: RefreshKey { | ||
| 39 | RefreshKey( | ||
| 40 | start: agenda.start, span: span, startOffset: startOffset, includeSubfolders: includeSubfolders, | ||
| 41 | files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion, | ||
| 42 | path: session.path, tick: tick | ||
| 43 | ) | ||
| 44 | } | ||
| 45 | |||
| 46 | var body: some View { | ||
| 47 | List(selection: $selection) { | ||
| 48 | ForEach(agenda.days, id: \.day) { day in | ||
| 49 | Section { | ||
| 50 | ForEach(Array(day.items.enumerated()), id: \.offset) { index, item in | ||
| 51 | AgendaRow(item: item) | ||
| 52 | .tag(Self.id(day, index)) | ||
| 53 | .selectionDisabled(item.path == nil) | ||
| 54 | } | ||
| 55 | } header: { | ||
| 56 | Text(day.header) | ||
| 57 | .font(.headline) | ||
| 58 | .foregroundStyle(day.isToday ? Color.accentColor : .primary) | ||
| 59 | } | ||
| 60 | } | ||
| 61 | } | ||
| 62 | .contextMenu(forSelectionType: String.self) { _ in | ||
| 63 | } primaryAction: { ids in | ||
| 64 | if let id = ids.first { jump(id) } | ||
| 65 | } | ||
| 66 | .onKeyPress(.return) { | ||
| 67 | guard let selection else { return .ignored } | ||
| 68 | jump(selection) | ||
| 69 | return .handled | ||
| 70 | } | ||
| 71 | .onKeyPress(characters: CharacterSet(charactersIn: "fb.gr")) { press in | ||
| 72 | switch press.characters { | ||
| 73 | case "f": agenda.shift(forward: true) | ||
| 74 | case "b": agenda.shift(forward: false) | ||
| 75 | case ".": agenda.showToday() | ||
| 76 | default: tick += 1 | ||
| 77 | } | ||
| 78 | return .handled | ||
| 79 | } | ||
| 80 | .toolbar { | ||
| 81 | ToolbarItemGroup { | ||
| 82 | if agenda.isComputing { ProgressView().controlSize(.small) } | ||
| 83 | Button("Earlier", systemImage: "chevron.left") { agenda.shift(forward: false) } | ||
| 84 | .help("Previous \(span) days (b)") | ||
| 85 | Button("Today") { agenda.showToday() } | ||
| 86 | .help("Back to today (.)") | ||
| 87 | Button("Later", systemImage: "chevron.right") { agenda.shift(forward: true) } | ||
| 88 | .help("Next \(span) days (f)") | ||
| 89 | } | ||
| 90 | } | ||
| 91 | .overlay { | ||
| 92 | if agenda.files.isEmpty { | ||
| 93 | ContentUnavailableView("No agenda files", systemImage: "calendar", description: Text("The agenda reads the org files at the top of each folder you add.")) | ||
| 94 | } | ||
| 95 | } | ||
| 96 | .navigationTitle("Agenda") | ||
| 97 | .task(id: refreshKey) { | ||
| 98 | agenda.span = span | ||
| 99 | agenda.startOffset = startOffset | ||
| 100 | agenda.includeSubfolders = includeSubfolders | ||
| 101 | let open = session.path.flatMap { path in session.document.map { (path, $0.text) } } | ||
| 102 | await agenda.refresh(open: open) | ||
| 103 | } | ||
| 104 | .task { | ||
| 105 | // The time grid's "now" line and the day itself move on. | ||
| 106 | while !Task.isCancelled { | ||
| 107 | try? await Task.sleep(for: .seconds(60)) | ||
| 108 | tick += 1 | ||
| 109 | } | ||
| 110 | } | ||
| 111 | } | ||
| 112 | |||
| 113 | private static func id(_ day: AgendaDay, _ index: Int) -> String { "\(day.day):\(index)" } | ||
| 114 | |||
| 115 | private func jump(_ id: String) { | ||
| 116 | let parts = id.split(separator: ":").compactMap { Int($0) } | ||
| 117 | guard parts.count == 2, let day = agenda.days.first(where: { $0.day == parts[0] }), parts[1] < day.items.count else { return } | ||
| 118 | let item = day.items[parts[1]] | ||
| 119 | guard let path = item.path, let offset = item.markerOffset ?? item.headingOffset else { return } | ||
| 120 | commands.jump(path: path, offset: offset) | ||
| 121 | openWindow(id: "main") | ||
| 122 | } | ||
| 123 | } | ||
| 124 | |||
| 125 | struct AgendaRow: View { | ||
| 126 | let item: AgendaItem | ||
| 127 | |||
| 128 | var body: some View { | ||
| 129 | switch item.kind { | ||
| 130 | case .timeGrid: | ||
| 131 | HStack(spacing: 8) { | ||
| 132 | Text(item.time.trimmingCharacters(in: CharacterSet(charactersIn: " ┄."))) | ||
| 133 | .monospacedDigit() | ||
| 134 | .frame(width: 140, alignment: .trailing) | ||
| 135 | VStack { Divider() } | ||
| 136 | } | ||
| 137 | .font(.caption) | ||
| 138 | .foregroundStyle(.tertiary) | ||
| 139 | case .currentTime: | ||
| 140 | HStack(spacing: 8) { | ||
| 141 | Text("now " + item.time.trimmingCharacters(in: CharacterSet(charactersIn: " ┄."))) | ||
| 142 | .monospacedDigit() | ||
| 143 | .frame(width: 140, alignment: .trailing) | ||
| 144 | Rectangle().fill(Color.accentColor).frame(height: 1) | ||
| 145 | } | ||
| 146 | .font(.caption) | ||
| 147 | .foregroundStyle(Color.accentColor) | ||
| 148 | default: | ||
| 149 | HStack(spacing: 8) { | ||
| 150 | Text(item.category) | ||
| 151 | .lineLimit(1) | ||
| 152 | .foregroundStyle(.secondary) | ||
| 153 | .frame(width: 80, alignment: .leading) | ||
| 154 | Text(item.time.trimmingCharacters(in: CharacterSet(charactersIn: " ┄."))) | ||
| 155 | .monospacedDigit() | ||
| 156 | .frame(width: 80, alignment: .leading) | ||
| 157 | if !item.extra.isEmpty { | ||
| 158 | Text(item.extra.trimmingCharacters(in: .whitespaces)) | ||
| 159 | .monospacedDigit() | ||
| 160 | .foregroundStyle(leaderColor) | ||
| 161 | } | ||
| 162 | title | ||
| 163 | .lineLimit(1) | ||
| 164 | Spacer(minLength: 8) | ||
| 165 | if !item.tags.isEmpty { | ||
| 166 | Text(":" + item.tags.joined(separator: ":") + ":") | ||
| 167 | .font(.caption) | ||
| 168 | .foregroundStyle(.secondary) | ||
| 169 | } | ||
| 170 | } | ||
| 171 | } | ||
| 172 | } | ||
| 173 | |||
| 174 | private var leaderColor: Color { | ||
| 175 | switch item.kind { | ||
| 176 | case .deadline: .red | ||
| 177 | case .upcomingDeadline: .orange | ||
| 178 | case .pastScheduled: .brown | ||
| 179 | default: .secondary | ||
| 180 | } | ||
| 181 | } | ||
| 182 | |||
| 183 | /// The TODO keyword colored as in the editor. | ||
| 184 | private var title: Text { | ||
| 185 | guard let todo = item.todo, item.text.hasPrefix(todo + " ") else { return Text(item.text.trimmingCharacters(in: .whitespaces)) } | ||
| 186 | let text = item.text.trimmingCharacters(in: .whitespaces) | ||
| 187 | let rest = String(text.dropFirst(todo.count)) | ||
| 188 | return Text(todo).foregroundColor(item.isDone ? .green : .red).bold() + Text(rest).foregroundColor(item.isDone ? .secondary : .primary) | ||
| 189 | } | ||
| 190 | } | ||
Sources/Orgstar/ContentView.swift +14
| @@ -29,6 +29,7 @@ struct ContentView: View { | |||
| 29 | @State private var selectedFile: String? | 29 | @State private var selectedFile: String? |
| 30 | @State private var query = "" | 30 | @State private var query = "" |
| 31 | @FocusState private var searchFocused: Bool | 31 | @FocusState private var searchFocused: Bool |
| 32 | @Environment(\.openWindow) private var openWindow | ||
| 32 | 33 | ||
| 33 | var body: some View { | 34 | var body: some View { |
| 34 | NavigationSplitView { | 35 | NavigationSplitView { |
| @@ -101,6 +102,15 @@ struct ContentView: View { | |||
| 101 | .onChange(of: workspace.changedFile?.version) { | 102 | .onChange(of: workspace.changedFile?.version) { |
| 102 | if let path = workspace.changedFile?.path { session.fileChanged(path) } | 103 | if let path = workspace.changedFile?.path { session.fileChanged(path) } |
| 103 | } | 104 | } |
| 105 | .onChange(of: commands.jumpRequest?.id) { | ||
| 106 | guard let request = commands.jumpRequest else { return } | ||
| 107 | if request.path != session.path { | ||
| 108 | guard UnsavedChanges.resolve(session) else { return } | ||
| 109 | session.open(request.path) | ||
| 110 | selectedFile = request.path | ||
| 111 | } | ||
| 112 | session.reveal(request.offset) | ||
| 113 | } | ||
| 104 | .onChange(of: commands.focusSearch) { _, focus in | 114 | .onChange(of: commands.focusSearch) { _, focus in |
| 105 | if focus { | 115 | if focus { |
| 106 | searchFocused = true | 116 | searchFocused = true |
| @@ -130,6 +140,8 @@ struct ContentView: View { | |||
| 130 | .task { | 140 | .task { |
| 131 | DevelopmentHooks.scheduleSnapshot() | 141 | DevelopmentHooks.scheduleSnapshot() |
| 132 | if let path = DevelopmentHooks.fileToOpen { selectedFile = path } | 142 | if let path = DevelopmentHooks.fileToOpen { selectedFile = path } |
| 143 | if let root = DevelopmentHooks.rootToAdd { try? await workspace.addRoot(URL(fileURLWithPath: root)) } | ||
| 144 | if let id = DevelopmentHooks.windowToOpen { openWindow(id: id) } | ||
| 133 | await workspace.refresh() | 145 | await workspace.refresh() |
| 134 | workspace.startWatching() | 146 | workspace.startWatching() |
| 135 | } | 147 | } |
| @@ -163,6 +175,8 @@ struct ContentView: View { | |||
| 163 | } | 175 | } |
| 164 | case "app.archive": | 176 | case "app.archive": |
| 165 | if let message = session.archive() { commands.show(message) } | 177 | if let message = session.archive() { commands.show(message) } |
| 178 | case "app.agenda": | ||
| 179 | openWindow(id: "agenda") | ||
| 166 | default: | 180 | default: |
| 167 | if let editor = commands.editor { editor.run(id) } else { commands.show("No file is open") } | 181 | if let editor = commands.editor { editor.run(id) } else { commands.show("No file is open") } |
| 168 | } | 182 | } |
Sources/Orgstar/DevelopmentHooks.swift +7 −1
| @@ -3,11 +3,15 @@ import AppKit | |||
| 3 | /// Switches for checking the app from scripts, read from the environment: | 3 | /// Switches for checking the app from scripts, read from the environment: |
| 4 | /// - `ORGSTAR_DATA_DIR`: where the index and roots live (see `WorkspaceModel.defaultDirectory`). | 4 | /// - `ORGSTAR_DATA_DIR`: where the index and roots live (see `WorkspaceModel.defaultDirectory`). |
| 5 | /// - `ORGSTAR_OPEN`: a file to select at launch. | 5 | /// - `ORGSTAR_OPEN`: a file to select at launch. |
| 6 | /// - `ORGSTAR_ROOT`: a folder to add at launch. | ||
| 7 | /// - `ORGSTAR_WINDOW`: a window to open at launch (`agenda`); the snapshot renders it. | ||
| 6 | /// - `ORGSTAR_SNAPSHOT`: after `ORGSTAR_SNAPSHOT_DELAY` seconds (default 3), render the main | 8 | /// - `ORGSTAR_SNAPSHOT`: after `ORGSTAR_SNAPSHOT_DELAY` seconds (default 3), render the main |
| 7 | /// window to this PNG path and quit. Rendering from inside the app needs no screen-recording | 9 | /// window to this PNG path and quit. Rendering from inside the app needs no screen-recording |
| 8 | /// permission. | 10 | /// permission. |
| 9 | enum DevelopmentHooks { | 11 | enum DevelopmentHooks { |
| 10 | static var fileToOpen: String? { ProcessInfo.processInfo.environment["ORGSTAR_OPEN"] } | 12 | static var fileToOpen: String? { ProcessInfo.processInfo.environment["ORGSTAR_OPEN"] } |
| 13 | static var rootToAdd: String? { ProcessInfo.processInfo.environment["ORGSTAR_ROOT"] } | ||
| 14 | static var windowToOpen: String? { ProcessInfo.processInfo.environment["ORGSTAR_WINDOW"] } | ||
| 11 | 15 | ||
| 12 | @MainActor | 16 | @MainActor |
| 13 | static func scheduleSnapshot() { | 17 | static func scheduleSnapshot() { |
| @@ -15,7 +19,9 @@ enum DevelopmentHooks { | |||
| 15 | guard let path = environment["ORGSTAR_SNAPSHOT"] else { return } | 19 | guard let path = environment["ORGSTAR_SNAPSHOT"] else { return } |
| 16 | let delay = Double(environment["ORGSTAR_SNAPSHOT_DELAY"] ?? "") ?? 3 | 20 | let delay = Double(environment["ORGSTAR_SNAPSHOT_DELAY"] ?? "") ?? 3 |
| 17 | DispatchQueue.main.asyncAfter(deadline: .now() + delay) { | 21 | DispatchQueue.main.asyncAfter(deadline: .now() + delay) { |
| 18 | if let view = NSApp.windows.first(where: \.isVisible)?.contentView?.superview, | 22 | let visible = NSApp.windows.filter(\.isVisible) |
| 23 | let window = windowToOpen.flatMap { id in visible.first { $0.identifier?.rawValue.hasPrefix(id) == true } } ?? visible.first | ||
| 24 | if let view = window?.contentView?.superview, | ||
| 19 | let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) { | 25 | let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) { |
| 20 | view.cacheDisplay(in: view.bounds, to: bitmap) | 26 | view.cacheDisplay(in: view.bounds, to: bitmap) |
| 21 | try? bitmap.representation(using: .png, properties: [:])?.write(to: URL(fileURLWithPath: path)) | 27 | try? bitmap.representation(using: .png, properties: [:])?.write(to: URL(fileURLWithPath: path)) |
Sources/Orgstar/OrgstarApp.swift +34
| @@ -13,6 +13,9 @@ final class AppCommands { | |||
| 13 | var focusSearch = false | 13 | var focusSearch = false |
| 14 | var showingConflict = false | 14 | var showingConflict = false |
| 15 | var showingPalette = false | 15 | var showingPalette = false |
| 16 | /// A location another window asked the main window to show. | ||
| 17 | private(set) var jumpRequest: (path: String, offset: Int, id: Int)? | ||
| 18 | private var jumpID = 0 | ||
| 16 | /// The editor showing the open file, for menus and the palette. | 19 | /// The editor showing the open file, for menus and the palette. |
| 17 | var editor: OrgEditor? | 20 | var editor: OrgEditor? |
| 18 | /// The pending key prefix, and what can follow it. | 21 | /// The pending key prefix, and what can follow it. |
| @@ -36,6 +39,11 @@ final class AppCommands { | |||
| 36 | request.reply(text) | 39 | request.reply(text) |
| 37 | } | 40 | } |
| 38 | 41 | ||
| 42 | func jump(path: String, offset: Int) { | ||
| 43 | jumpID += 1 | ||
| 44 | jumpRequest = (path, offset, jumpID) | ||
| 45 | } | ||
| 46 | |||
| 39 | func show(_ text: String) { | 47 | func show(_ text: String) { |
| 40 | messageID += 1 | 48 | messageID += 1 |
| 41 | let id = messageID | 49 | let id = messageID |
| @@ -111,6 +119,12 @@ struct OrgstarApp: App { | |||
| 111 | } | 119 | } |
| 112 | } | 120 | } |
| 113 | .defaultSize(width: 1280, height: 860) | 121 | .defaultSize(width: 1280, height: 860) |
| 122 | Window("Agenda", id: "agenda") { | ||
| 123 | if case .success(let model) = workspace { | ||
| 124 | AgendaView(workspace: model, session: session, commands: commands) | ||
| 125 | } | ||
| 126 | } | ||
| 127 | .defaultSize(width: 760, height: 720) | ||
| 114 | .commands { | 128 | .commands { |
| 115 | CommandGroup(after: .newItem) { | 129 | CommandGroup(after: .newItem) { |
| 116 | Button("Add Folder…") { commands.showingFolderPicker = true } | 130 | Button("Add Folder…") { commands.showingFolderPicker = true } |
| @@ -131,6 +145,9 @@ struct OrgstarApp: App { | |||
| 131 | Button("Search Notes") { commands.focusSearch = true } | 145 | Button("Search Notes") { commands.focusSearch = true } |
| 132 | .keyboardShortcut("f", modifiers: [.command, .shift]) | 146 | .keyboardShortcut("f", modifiers: [.command, .shift]) |
| 133 | } | 147 | } |
| 148 | CommandGroup(before: .windowList) { | ||
| 149 | AgendaMenuItem() | ||
| 150 | } | ||
| 134 | } | 151 | } |
| 135 | .commands { | 152 | .commands { |
| 136 | CommandMenu("Org") { | 153 | CommandMenu("Org") { |
| @@ -146,6 +163,16 @@ struct OrgstarApp: App { | |||
| 146 | } | 163 | } |
| 147 | } | 164 | } |
| 148 | 165 | ||
| 166 | /// Window ▸ Agenda. | ||
| 167 | struct AgendaMenuItem: View { | ||
| 168 | @Environment(\.openWindow) private var openWindow | ||
| 169 | |||
| 170 | var body: some View { | ||
| 171 | Button("Agenda") { openWindow(id: "agenda") } | ||
| 172 | .keyboardShortcut("a", modifiers: [.command, .shift]) | ||
| 173 | } | ||
| 174 | } | ||
| 175 | |||
| 149 | struct SettingsView: View { | 176 | struct SettingsView: View { |
| 150 | @AppStorage("saveMode") private var saveMode = SaveMode.automatic | 177 | @AppStorage("saveMode") private var saveMode = SaveMode.automatic |
| 151 | @AppStorage("keymap") private var preset = KeymapPreset.emacs | 178 | @AppStorage("keymap") private var preset = KeymapPreset.emacs |
| @@ -158,6 +185,9 @@ struct SettingsView: View { | |||
| 158 | @AppStorage("hiddenMarkers") private var hiddenMarkers = true | 185 | @AppStorage("hiddenMarkers") private var hiddenMarkers = true |
| 159 | @AppStorage("prettyEntities") private var prettyEntities = true | 186 | @AppStorage("prettyEntities") private var prettyEntities = true |
| 160 | @AppStorage("todoKeywords") private var todoKeywords = Preferences.defaultTodoKeywords | 187 | @AppStorage("todoKeywords") private var todoKeywords = Preferences.defaultTodoKeywords |
| 188 | @AppStorage("agendaSpan") private var agendaSpan = 10 | ||
| 189 | @AppStorage("agendaStartOffset") private var agendaStartOffset = -3 | ||
| 190 | @AppStorage("agendaSubfolders") private var agendaSubfolders = false | ||
| 161 | 191 | ||
| 162 | var body: some View { | 192 | var body: some View { |
| 163 | Form { | 193 | Form { |
| @@ -197,6 +227,10 @@ struct SettingsView: View { | |||
| 197 | Text("For files without #+TODO, in #+TODO syntax. Takes effect when Orgstar restarts.") | 227 | Text("For files without #+TODO, in #+TODO syntax. Takes effect when Orgstar restarts.") |
| 198 | .font(.caption) | 228 | .font(.caption) |
| 199 | .foregroundStyle(.secondary) | 229 | .foregroundStyle(.secondary) |
| 230 | Divider() | ||
| 231 | Stepper("Agenda shows \(agendaSpan) days", value: $agendaSpan, in: 1...31) | ||
| 232 | Stepper(agendaStartOffset == 0 ? "Agenda starts today" : "Agenda starts \(-agendaStartOffset) days before today", value: $agendaStartOffset, in: -14...0) | ||
| 233 | Toggle("Agenda includes files in subfolders", isOn: $agendaSubfolders) | ||
| 200 | } | 234 | } |
| 201 | .padding(20) | 235 | .padding(20) |
| 202 | .frame(width: 480) | 236 | .frame(width: 480) |
Tests/OrgAppTests/AppTests.swift +33
| @@ -273,3 +273,36 @@ enum StateBufferCaret { | |||
| 273 | session.attach(buffer) | 273 | session.attach(buffer) |
| 274 | } | 274 | } |
| 275 | } | 275 | } |
| 276 | |||
| 277 | @MainActor | ||
| 278 | struct AgendaModelTests { | ||
| 279 | @Test func topLevelFilesAndTheOpenBuffer() async throws { | ||
| 280 | let notes = try Folder() | ||
| 281 | try notes.write("a.org", "* TODO Pay rent\nSCHEDULED: <2026-10-05 Mon>\n") | ||
| 282 | try notes.write("sub/b.org", "* Hidden <2026-10-05 Mon>\n") | ||
| 283 | try notes.write("old.org_archive", "* Archived <2026-10-05 Mon>\n") | ||
| 284 | let model = try WorkspaceModel(directory: nil) | ||
| 285 | try await model.addRoot(notes.url) | ||
| 286 | let agenda = AgendaModel(workspace: model) | ||
| 287 | var utc = Calendar(identifier: .gregorian) | ||
| 288 | utc.timeZone = TimeZone(identifier: "UTC")! | ||
| 289 | let now = try #require(utc.date(from: DateComponents(year: 2026, month: 10, day: 5, hour: 9))) | ||
| 290 | |||
| 291 | await agenda.refresh(open: nil, now: now, calendar: utc) | ||
| 292 | #expect(agenda.days.count == 10) | ||
| 293 | #expect(agenda.days[3].isToday) | ||
| 294 | #expect(agenda.days[3].items.map(\.text) == ["TODO Pay rent"]) | ||
| 295 | |||
| 296 | agenda.includeSubfolders = true | ||
| 297 | await agenda.refresh(open: nil, now: now, calendar: utc) | ||
| 298 | #expect(agenda.days[3].items.map(\.text) == ["TODO Pay rent", "Hidden "]) | ||
| 299 | |||
| 300 | let path = try #require(agenda.files.first { $0.hasSuffix("/a.org") }) | ||
| 301 | await agenda.refresh(open: (path, "* DONE Pay rent\nSCHEDULED: <2026-10-05 Mon>\n"), now: now, calendar: utc) | ||
| 302 | #expect(agenda.days[3].items.first?.text == "DONE Pay rent") | ||
| 303 | |||
| 304 | agenda.shift(forward: true, now: now, calendar: utc) | ||
| 305 | await agenda.refresh(open: nil, now: now, calendar: utc) | ||
| 306 | #expect(agenda.days.first?.day == Days.absolute(year: 2026, month: 10, day: 12)) | ||
| 307 | } | ||
| 308 | } | ||
docs/plans/2026-10-05-agenda.md +7 −7
| @@ -24,13 +24,13 @@ | |||
| 24 | 24 | ||
| 25 | ### Task 1: Engine | 25 | ### Task 1: Engine |
| 26 | 26 | ||
| 27 | - [ ] Absolute days, ISO weeks; `org-closest-date`; `org-get-wdays`. | 27 | - [x] Absolute days, ISO weeks; `org-closest-date`; `org-get-wdays`. |
| 28 | - [ ] `AgendaSource` from text; collectors; formatting; sorting; time grid; day headers. | 28 | - [x] `AgendaSource` from text; collectors; formatting; sorting; time grid; day headers. |
| 29 | - [ ] Oracle over synthetic files (repeaters of every unit, warnings, delays, ranges, times in headings, priorities, categories, inherited tags, archived and commented trees, src blocks, done items) for several "today"s, and over the three corpora for a 10-day span. | 29 | - [x] Oracle over synthetic files (repeaters of every unit, warnings, delays, ranges, times in headings, priorities, categories, inherited tags, archived and commented trees, src blocks, done items) for several "today"s, and over the three corpora for a 10-day span. |
| 30 | - [ ] Commit "Agenda engine". | 30 | - [x] Commit "Agenda engine". |
| 31 | 31 | ||
| 32 | ### Task 2: App | 32 | ### Task 2: App |
| 33 | 33 | ||
| 34 | - [ ] Agenda window (⌘⇧A, palette): day headers, items with category, time, leader, TODO, title, tags; previous/next span, today; click or RET jumps to the entry; refreshes on saves and open-document edits. | 34 | - [x] Agenda window (⌘⇧A, palette): day headers, items with category, time, leader, TODO, title, tags; previous/next span, today; click or RET jumps to the entry; refreshes on saves and open-document edits. |
| 35 | - [ ] Settings: span, start offset, agenda folders. | 35 | - [x] Settings: span, start offset, agenda folders. |
| 36 | - [ ] Commit "Agenda view". | 36 | - [x] Commit "Agenda view". |