iOS app: agenda and capture !109

merged merged by cmc on 2026-10-06 22:36 UTC · krz/orgstar:ios-app into main

8 files changed, +471 −4

Layout: unified · split

Package.swift +3
@@ -18,6 +18,7 @@ let package = Package(
1818 .library(name: "OrgApp", targets: ["OrgApp"]),
1919 .library(name: "OrgHighlight", targets: ["OrgHighlight"]),
2020 .executable(name: "Orgstar", targets: ["Orgstar"]),
21 .executable(name: "OrgstarMobile", targets: ["OrgstarMobile"]),
2122 .executable(name: "OrgstarQuickLook", targets: ["OrgstarQuickLook"]),
2223 .executable(name: "OrgstarSpotlight", targets: ["OrgstarSpotlight"])
2324 ],
@@ -80,6 +81,8 @@ let package = Package(
8081 .executableTarget(name: "OrgstarSpike", dependencies: ["OrgEditorAppKit"]),
8182 .target(name: "OrgApp", dependencies: ["OrgWorkspace", "OrgDocument", "OrgPresentation"]),
8283 .executableTarget(name: "Orgstar", dependencies: ["OrgApp", "OrgEditorAppKit", "OrgHighlight"]),
84 // The iOS app, bundled by scripts/build-ios-app.sh.
85 .executableTarget(name: "OrgstarMobile", dependencies: ["OrgApp"]),
8386 // App extensions, bundled into Orgstar.app by scripts/build-app.sh.
8487 .executableTarget(name: "OrgstarQuickLook", dependencies: ["OrgCore"], linkerSettings: extensionEntry),
8588 .executableTarget(name: "OrgstarSpotlight", dependencies: ["OrgCore"], linkerSettings: extensionEntry),
README.md +10 −3
@@ -1,6 +1,6 @@
11# Orgstar
22
3A native macOS editor for org-mode files. iOS later.
3A native macOS editor for org-mode files, with an iOS app for the agenda and capture.
44
55The files are the source of truth: Orgstar edits them in place and leaves
66every byte you didn't change as it was, so Emacs and sync tools can work on
@@ -8,8 +8,8 @@ the same folder.
88
99Status: macOS v1, design phases 1–6: editing with Emacs, Mac and Doom keys,
1010agenda, table formulas, Babel, export, capture, clocking, board, themes and
11buffers. Owed: the M1 Air gate run and the VoiceOver check. iOS (phase 7) is
12not started.
11buffers. Owed: the M1 Air gate run and the VoiceOver check. iOS (phase 7):
12agenda and capture, Simulator builds only so far.
1313
1414## Build and run
1515
@@ -30,6 +30,13 @@ Finder once the app has been launched.
3030bookmarklets written for org-protocol work unchanged. Shortcuts has a
3131"Capture to Orgstar" action that files text with a capture template.
3232
33The iOS app (agenda and capture, iOS 26) builds for the Simulator:
34
35```sh
36scripts/build-ios-app.sh # builds .build/ios-app/Orgstar.app
37xcrun simctl install booted .build/ios-app/Orgstar.app
38```
39
3340Settings live in `~/.config/orgstar/config.toml` (Orgstar ▸ Edit Config File),
3441with keys in `keymap.toml` and capture templates in `capture.toml` beside it.
3542Settings ▸ General ▸ Import from Emacs reads an existing Emacs or Doom
Sources/OrgstarMobile/AgendaScreen.swift added +136
@@ -0,0 +1,136 @@
1#if os(iOS)
2import OrgApp
3import OrgCore
4import SwiftUI
5
6/// The day/week agenda and the TODO list over the workspace's agenda files.
7struct AgendaScreen: View {
8 let workspace: WorkspaceModel
9 let session: DocumentSession
10 @Bindable var agenda: AgendaModel
11 @State private var message: String?
12 @State private var tick = 0
13
14 private enum View: String, CaseIterable { case agenda = "Agenda", todo = "TODO" }
15
16 private var view: Binding<View> {
17 Binding {
18 if case .todo = agenda.mode { .todo } else { .agenda }
19 } set: { value in
20 agenda.mode = value == .todo ? .todo(keywords: nil) : .agenda(span: nil, startOffset: nil)
21 }
22 }
23
24 private struct Key: Equatable {
25 var files: Int
26 var changed: Int?
27 var edits: Int
28 var tick: Int
29 var view: String
30 var start: Int?
31 }
32
33 var body: some SwiftUI.View {
34 List {
35 if case .agenda = agenda.mode {
36 ForEach(agenda.days, id: \.day) { day in
37 Section {
38 let items = day.items.filter(\.isEntry)
39 ForEach(items.indices, id: \.self) { i in row(items[i]) }
40 } header: {
41 Text(day.header.split(separator: " ", omittingEmptySubsequences: true).prefix(4).joined(separator: " "))
42 .foregroundStyle(day.isToday ? Color.accentColor : .secondary)
43 }
44 }
45 } else {
46 ForEach(agenda.list.indices, id: \.self) { i in row(agenda.list[i]) }
47 }
48 }
49 .listStyle(.insetGrouped)
50 .navigationTitle(agenda.isComputing && agenda.days.isEmpty ? "Agenda…" : "Agenda")
51 .overlay {
52 if workspace.rootPaths.isEmpty {
53 ContentUnavailableView("No folders", systemImage: "folder", description: Text("Add a folder of org files under Folders."))
54 }
55 }
56 .safeAreaInset(edge: .bottom) {
57 if let message {
58 Text(message).font(.callout).padding(8).frame(maxWidth: .infinity).background(.bar)
59 }
60 }
61 .toolbar {
62 ToolbarItem(placement: .principal) {
63 Picker("View", selection: view) {
64 ForEach(View.allCases, id: \.self) { Text($0.rawValue) }
65 }
66 .pickerStyle(.segmented)
67 .fixedSize()
68 }
69 if case .agenda = agenda.mode {
70 ToolbarItem(placement: .topBarLeading) {
71 Menu("Days", systemImage: "calendar") {
72 Button("Today", systemImage: "calendar.circle") { agenda.showToday() }
73 Button("Earlier", systemImage: "chevron.left") { agenda.shift(forward: false) }
74 Button("Later", systemImage: "chevron.right") { agenda.shift(forward: true) }
75 }
76 }
77 }
78 }
79 .refreshable {
80 await workspace.refresh()
81 await agenda.refresh(open: session.openTexts)
82 }
83 .task(id: Key(files: workspace.files.count, changed: workspace.changedFile?.version, edits: session.editVersion, tick: tick,
84 view: view.wrappedValue.rawValue, start: agenda.start)) {
85 await agenda.refresh(open: session.openTexts)
86 }
87 }
88
89 private func row(_ item: AgendaItem) -> some SwiftUI.View {
90 VStack(alignment: .leading, spacing: 2) {
91 HStack(alignment: .firstTextBaseline, spacing: 6) {
92 if let time = item.time.firstMatch(of: /[0-9]{1,2}:[0-9]{2}(-[0-9]{1,2}:[0-9]{2})?/) {
93 Text(time.output.0).font(.callout.monospacedDigit()).foregroundStyle(.secondary)
94 }
95 Text(item.text).strikethrough(item.isDone)
96 }
97 HStack(spacing: 6) {
98 Text(item.category)
99 let extra = item.extra.split(whereSeparator: \.isWhitespace).joined(separator: " ")
100 if !extra.isEmpty { Text(extra) }
101 if !item.tags.isEmpty { Text(item.tags.map { ":" + $0 }.joined() + ":") }
102 }
103 .font(.caption)
104 .foregroundStyle(.secondary)
105 }
106 .swipeActions(edge: .trailing) {
107 if item.todo != nil, !item.isDone, doneKeyword(for: item) != nil {
108 Button("Done", systemImage: "checkmark") { markDone(item) }.tint(.green)
109 }
110 }
111 }
112
113 /// The first done keyword of the item's TODO sequence.
114 private func doneKeyword(for item: AgendaItem) -> String? {
115 guard let todo = item.todo, let path = item.path else { return nil }
116 let text = session.openTexts[path] ?? (try? String(contentsOfFile: path, encoding: .utf8)) ?? ""
117 let settings = OrgParser.parse(text).settings
118 return settings.todoSequences.first { ($0.active + $0.done).contains { $0.name == todo } }?.done.first?.name
119 }
120
121 private func markDone(_ item: AgendaItem) {
122 guard let path = item.path, let start = item.headingOffset, let keyword = doneKeyword(for: item) else { return }
123 switch session.perform(SetTodo(keyword: keyword), path: path, at: start, expect: item.headingLine.map { (start, $0) }) {
124 case .failed(let text): message = text
125 case .prompt: message = "This state change asks for a note; make it on the Mac"
126 default: message = nil
127 }
128 tick += 1
129 }
130}
131
132extension AgendaItem {
133 /// An entry, not a time-grid line.
134 var isEntry: Bool { kind != .timeGrid && kind != .currentTime }
135}
136#endif
Sources/OrgstarMobile/CaptureScreen.swift added +93
@@ -0,0 +1,93 @@
1#if os(iOS)
2import OrgApp
3import OrgCore
4import SwiftUI
5
6/// Capture with a template: its text to edit, filed where the template says.
7struct CaptureScreen: View {
8 let workspace: WorkspaceModel
9 let session: DocumentSession
10 let request: CaptureRequest
11 @Environment(\.dismiss) private var dismiss
12 @State private var templates: [CaptureTemplate] = []
13 @State private var key: String?
14 @State private var text = ""
15 @State private var selection: TextSelection?
16 @State private var problem: String?
17 @FocusState private var focused: Bool
18
19 private var template: CaptureTemplate? { templates.first { $0.key == key } }
20
21 var body: some View {
22 NavigationStack {
23 Form {
24 Picker("Template", selection: $key) {
25 ForEach(templates) { Text($0.name).tag(Optional($0.key)) }
26 }
27 Section {
28 TextEditor(text: $text, selection: $selection)
29 .font(.body.monospaced())
30 .frame(minHeight: 200)
31 .focused($focused)
32 } footer: {
33 if let problem { Text(problem).foregroundStyle(.red) }
34 }
35 }
36 .navigationTitle("Capture")
37 .navigationBarTitleDisplayMode(.inline)
38 .toolbar {
39 ToolbarItem(placement: .cancellationAction) { Button("Cancel") { dismiss() } }
40 ToolbarItem(placement: .confirmationAction) { Button("File", action: file).disabled(template == nil) }
41 }
42 }
43 .onAppear {
44 let loaded = CaptureTemplates.load()
45 templates = loaded.templates
46 problem = loaded.problems.first
47 key = request.template.flatMap { wanted in templates.first { $0.key == wanted }?.key } ?? templates.first?.key
48 focused = true
49 }
50 .onChange(of: key, initial: true) { fill() }
51 }
52
53 /// The template expanded, its prompts taking their defaults.
54 private func fill() {
55 guard let template else { return }
56 var context = CaptureContext(
57 initial: request.properties["initial"] ?? "", annotation: request.properties["annotation"] ?? "",
58 allTags: (try? workspace.index.tags()) ?? []
59 )
60 context.linkProperties = request.properties
61 let answers = Capture.questions(template.template, context: context).map { _ in "" }
62 switch Capture.fill(template.template, context: context, answers: answers) {
63 case .success(let filled):
64 text = filled.text
65 // Where the template's `%?` was.
66 selection = TextSelection(insertionPoint: String.Index(utf16Offset: filled.cursor, in: filled.text))
67 case .failure(.message(let message)): problem = message
68 }
69 }
70
71 private func file() {
72 guard let template else { return }
73 var file: String?
74 switch template.target {
75 case .id(let id):
76 guard let location = try? workspace.index.headings(withID: id).first else {
77 problem = "Cannot find target ID \"\(id)\""
78 return
79 }
80 file = location.path
81 case .clock:
82 problem = "There's no running clock on iOS"
83 return
84 default:
85 break
86 }
87 switch session.capture(text, template: template, root: workspace.rootPaths.first, file: file) {
88 case .failure(let error): problem = error.message
89 case .success: dismiss()
90 }
91 }
92}
93#endif
Sources/OrgstarMobile/FoldersScreen.swift added +62
@@ -0,0 +1,62 @@
1#if os(iOS)
2import OrgApp
3import SwiftUI
4import UniformTypeIdentifiers
5
6/// The folders of org files the agenda reads, and their files.
7struct FoldersScreen: View {
8 let workspace: WorkspaceModel
9 @State private var importing = false
10
11 var body: some View {
12 List {
13 ForEach(workspace.rootPaths, id: \.self) { root in
14 Section((root as NSString).lastPathComponent) {
15 let files = workspace.files.filter { $0.root == root && $0.kind == .org }
16 ForEach(files) { file in
17 Label(file.relativePath, systemImage: "doc.text")
18 }
19 let pending = workspace.downloading.filter { $0.hasPrefix(root + "/") }.sorted()
20 ForEach(pending, id: \.self) { path in
21 Label(String(path.dropFirst(root.count + 1)), systemImage: "icloud.and.arrow.down")
22 .foregroundStyle(.secondary)
23 }
24 Button("Remove Folder", role: .destructive) { try? workspace.removeRoot(path: root) }
25 }
26 }
27 if let error = workspace.lastError {
28 Text(error).foregroundStyle(.red)
29 }
30 }
31 .navigationTitle("Folders")
32 .overlay {
33 if workspace.rootPaths.isEmpty {
34 ContentUnavailableView {
35 Label("No folders", systemImage: "folder")
36 } description: {
37 Text("Add the folder your org files are in, from iCloud Drive or another app's storage.")
38 } actions: {
39 Button("Add Folder") { importing = true }
40 }
41 }
42 }
43 .toolbar {
44 ToolbarItem(placement: .topBarLeading) {
45 Button("Add Folder", systemImage: "folder.badge.plus") { importing = true }
46 }
47 }
48 .fileImporter(isPresented: $importing, allowedContentTypes: [.folder]) { result in
49 guard case .success(let url) = result else { return }
50 // Kept for the bookmark; WorkspaceRoot resolves it with access again at launch.
51 _ = url.startAccessingSecurityScopedResource()
52 Task {
53 do {
54 try await workspace.addRoot(url)
55 } catch {
56 workspace.lastError = "Can't add \(url.lastPathComponent): \(error.localizedDescription)"
57 }
58 }
59 }
60 }
61}
62#endif
Sources/OrgstarMobile/OrgstarMobileApp.swift added +86
@@ -0,0 +1,86 @@
1#if os(iOS)
2import OrgApp
3import OrgCore
4import SwiftUI
5
6/// Orgstar on iPhone and iPad: the agenda, capture, and the folders they read.
7@main
8struct OrgstarMobileApp: App {
9 @State private var workspace = Result { try WorkspaceModel(directory: WorkspaceModel.defaultDirectory) }
10 @State private var session = DocumentSession()
11
12 var body: some Scene {
13 WindowGroup {
14 switch workspace {
15 case .success(let model):
16 RootView(workspace: model, session: session)
17 case .failure(let error):
18 ContentUnavailableView("Can't open the index", systemImage: "exclamationmark.triangle", description: Text(error.localizedDescription))
19 }
20 }
21 }
22}
23
24/// An org-protocol capture, or a plain one.
25struct CaptureRequest: Identifiable {
26 let id = UUID()
27 var template: String?
28 var properties: [String: String] = [:]
29}
30
31struct RootView: View {
32 let workspace: WorkspaceModel
33 let session: DocumentSession
34 @State private var agenda: AgendaModel
35 @State private var capture: CaptureRequest?
36
37 init(workspace: WorkspaceModel, session: DocumentSession) {
38 self.workspace = workspace
39 self.session = session
40 _agenda = State(initialValue: AgendaModel(workspace: workspace))
41 }
42
43 var body: some View {
44 TabView {
45 Tab("Agenda", systemImage: "calendar") {
46 NavigationStack {
47 AgendaScreen(workspace: workspace, session: session, agenda: agenda)
48 .toolbar { captureButton }
49 }
50 }
51 Tab("Folders", systemImage: "folder") {
52 NavigationStack {
53 FoldersScreen(workspace: workspace)
54 .toolbar { captureButton }
55 }
56 }
57 }
58 .sheet(item: $capture) { request in
59 CaptureScreen(workspace: workspace, session: session, request: request)
60 }
61 .task {
62 // Development: `ORGSTAR_ROOT` adds a folder at launch, as on the Mac.
63 if let root = ProcessInfo.processInfo.environment["ORGSTAR_ROOT"] { try? await workspace.addRoot(URL(fileURLWithPath: root)) }
64 workspace.startWatching()
65 await workspace.refresh()
66 }
67 .onOpenURL { url in
68 if case .capture(let template, let properties)? = OrgProtocol.parse(url.absoluteString) {
69 capture = CaptureRequest(template: template, properties: properties)
70 }
71 }
72 }
73
74 private var captureButton: some ToolbarContent {
75 ToolbarItem(placement: .primaryAction) {
76 Button("Capture", systemImage: "square.and.pencil") { capture = CaptureRequest() }
77 }
78 }
79}
80#else
81/// The iOS app has nothing to run elsewhere.
82@main
83enum OrgstarMobileUnavailable {
84 static func main() { print("Orgstar for iOS runs on iPhone and iPad.") }
85}
86#endif
docs/design.md +1 −1
@@ -334,7 +334,7 @@ Fidelity is checked against Emacs itself, using the same oracle pattern as orgo'
334334
335335Seven phases, each usable on its own. Phase 1 is read-only, so it can run next to Emacs from the first build.
336336
337Status, 2026-10-05: phases 1–6 are built for macOS. Owed from phase 1: the items left open below and the gate run on the M1 Air. The gates pass on an M-series Mac except peak memory for small real files (about 20x for a 161 kB file, from fixed overhead). Phase 7 has not started.
337Status, 2026-10-06: phases 1–6 are built for macOS. Owed from phase 1: the items left open below and the gate run on the M1 Air. The gates pass on an M-series Mac except peak memory for small real files (about 20x for a 161 kB file, from fixed overhead). Phase 7 has started: the iOS app has the agenda and capture, and runs in the Simulator; device builds need signing.
338338
3393391. **Core and viewer:** `OrgCore` parser, workspace, index, read-only rendering, folding, search.
3403402. **Editor:** text editing, structure and heading commands, timestamps, `M-q`, table alignment, command palette, Emacs and Mac keymaps, remaining tree-sitter grammars.
scripts/build-ios-app.sh added +80
@@ -0,0 +1,80 @@
1#!/bin/sh
2# Builds Orgstar.app for the iOS Simulator in .build/ios-app from the OrgstarMobile product,
3# signed ad hoc. Install with `xcrun simctl install booted .build/ios-app/Orgstar.app`.
4# Usage: scripts/build-ios-app.sh [version]
5set -eu
6
7cd "$(dirname "$0")/.."
8version="${1:-0.1.0}"
9app=".build/ios-app/Orgstar.app"
10products=".build/ios/Build/Products/Debug-iphonesimulator"
11
12xcodebuild -scheme OrgstarMobile -destination 'generic/platform=iOS Simulator' -derivedDataPath .build/ios build -quiet
13
14rm -rf "$app"
15mkdir -p "$app"
16cp "$products/OrgstarMobile" "$app/OrgstarMobile"
17for bundle in "$products"/*.bundle; do
18 [ -e "$bundle" ] && cp -R "$bundle" "$app/"
19done
20
21cat > "$app/Info.plist" <<PLIST
22<?xml version="1.0" encoding="UTF-8"?>
23<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
24<plist version="1.0">
25<dict>
26 <key>CFBundleIdentifier</key>
27 <string>sh.krz.orgstar</string>
28 <key>CFBundleName</key>
29 <string>Orgstar</string>
30 <key>CFBundleDisplayName</key>
31 <string>Orgstar</string>
32 <key>CFBundleExecutable</key>
33 <string>OrgstarMobile</string>
34 <key>CFBundlePackageType</key>
35 <string>APPL</string>
36 <key>CFBundleShortVersionString</key>
37 <string>${version}</string>
38 <key>CFBundleVersion</key>
39 <string>${version}</string>
40 <key>CFBundleSupportedPlatforms</key>
41 <array><string>iPhoneSimulator</string></array>
42 <key>MinimumOSVersion</key>
43 <string>26.0</string>
44 <key>UIDeviceFamily</key>
45 <array><integer>1</integer><integer>2</integer></array>
46 <key>UILaunchScreen</key>
47 <dict/>
48 <key>UIApplicationSceneManifest</key>
49 <dict>
50 <key>UIApplicationSupportsMultipleScenes</key>
51 <true/>
52 </dict>
53 <key>UISupportedInterfaceOrientations</key>
54 <array>
55 <string>UIInterfaceOrientationPortrait</string>
56 <string>UIInterfaceOrientationLandscapeLeft</string>
57 <string>UIInterfaceOrientationLandscapeRight</string>
58 </array>
59 <key>UISupportedInterfaceOrientations~ipad</key>
60 <array>
61 <string>UIInterfaceOrientationPortrait</string>
62 <string>UIInterfaceOrientationPortraitUpsideDown</string>
63 <string>UIInterfaceOrientationLandscapeLeft</string>
64 <string>UIInterfaceOrientationLandscapeRight</string>
65 </array>
66 <key>CFBundleURLTypes</key>
67 <array>
68 <dict>
69 <key>CFBundleURLName</key>
70 <string>org-protocol</string>
71 <key>CFBundleURLSchemes</key>
72 <array><string>org-protocol</string></array>
73 </dict>
74 </array>
75</dict>
76</plist>
77PLIST
78
79codesign --force --sign - "$app"
80echo "$app"