krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit fcb63f5b7b

fcb63f5b7ba2983a61307124d831d8093a15a186

parent: f9597060ef

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-06 22:36 UTC

Add the iOS app: agenda and capture

OrgstarMobile (iOS 26, iPhone and iPad) adds folders through the document
picker and keeps them as bookmarks, shows the day/week agenda and the TODO
list with a swipe to mark entries done, and captures with the capture
templates, from the app or an org-protocol link. It uses the same OrgApp
models as the Mac app. scripts/build-ios-app.sh builds it for the Simulator.

Ref docs/review-2026-10-05.md tier 3 item 6

Layout: unified · split

Package.swift +3
@@ -18,6 +18,7 @@ let package = Package(
18 .library(name: "OrgApp", targets: ["OrgApp"]), 18 .library(name: "OrgApp", targets: ["OrgApp"]),
19 .library(name: "OrgHighlight", targets: ["OrgHighlight"]), 19 .library(name: "OrgHighlight", targets: ["OrgHighlight"]),
20 .executable(name: "Orgstar", targets: ["Orgstar"]), 20 .executable(name: "Orgstar", targets: ["Orgstar"]),
21 .executable(name: "OrgstarMobile", targets: ["OrgstarMobile"]),
21 .executable(name: "OrgstarQuickLook", targets: ["OrgstarQuickLook"]), 22 .executable(name: "OrgstarQuickLook", targets: ["OrgstarQuickLook"]),
22 .executable(name: "OrgstarSpotlight", targets: ["OrgstarSpotlight"]) 23 .executable(name: "OrgstarSpotlight", targets: ["OrgstarSpotlight"])
23 ], 24 ],
@@ -80,6 +81,8 @@ let package = Package(
80 .executableTarget(name: "OrgstarSpike", dependencies: ["OrgEditorAppKit"]), 81 .executableTarget(name: "OrgstarSpike", dependencies: ["OrgEditorAppKit"]),
81 .target(name: "OrgApp", dependencies: ["OrgWorkspace", "OrgDocument", "OrgPresentation"]), 82 .target(name: "OrgApp", dependencies: ["OrgWorkspace", "OrgDocument", "OrgPresentation"]),
82 .executableTarget(name: "Orgstar", dependencies: ["OrgApp", "OrgEditorAppKit", "OrgHighlight"]), 83 .executableTarget(name: "Orgstar", dependencies: ["OrgApp", "OrgEditorAppKit", "OrgHighlight"]),
84 // The iOS app, bundled by scripts/build-ios-app.sh.
85 .executableTarget(name: "OrgstarMobile", dependencies: ["OrgApp"]),
83 // App extensions, bundled into Orgstar.app by scripts/build-app.sh. 86 // App extensions, bundled into Orgstar.app by scripts/build-app.sh.
84 .executableTarget(name: "OrgstarQuickLook", dependencies: ["OrgCore"], linkerSettings: extensionEntry), 87 .executableTarget(name: "OrgstarQuickLook", dependencies: ["OrgCore"], linkerSettings: extensionEntry),
85 .executableTarget(name: "OrgstarSpotlight", dependencies: ["OrgCore"], linkerSettings: extensionEntry), 88 .executableTarget(name: "OrgstarSpotlight", dependencies: ["OrgCore"], linkerSettings: extensionEntry),
README.md +10 −3
@@ -1,6 +1,6 @@
1# Orgstar 1# Orgstar
2 2
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.
4 4
5The files are the source of truth: Orgstar edits them in place and leaves 5The files are the source of truth: Orgstar edits them in place and leaves
6every byte you didn't change as it was, so Emacs and sync tools can work on 6every byte you didn't change as it was, so Emacs and sync tools can work on
@@ -8,8 +8,8 @@ the same folder.
8 8
9Status: macOS v1, design phases 1–6: editing with Emacs, Mac and Doom keys, 9Status: macOS v1, design phases 1–6: editing with Emacs, Mac and Doom keys,
10agenda, table formulas, Babel, export, capture, clocking, board, themes and 10agenda, table formulas, Babel, export, capture, clocking, board, themes and
11buffers. Owed: the M1 Air gate run and the VoiceOver check. iOS (phase 7) is 11buffers. Owed: the M1 Air gate run and the VoiceOver check. iOS (phase 7):
12not started. 12agenda and capture, Simulator builds only so far.
13 13
14## Build and run 14## Build and run
15 15
@@ -30,6 +30,13 @@ Finder once the app has been launched.
30bookmarklets written for org-protocol work unchanged. Shortcuts has a 30bookmarklets written for org-protocol work unchanged. Shortcuts has a
31"Capture to Orgstar" action that files text with a capture template. 31"Capture to Orgstar" action that files text with a capture template.
32 32
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
33Settings live in `~/.config/orgstar/config.toml` (Orgstar ▸ Edit Config File), 40Settings live in `~/.config/orgstar/config.toml` (Orgstar ▸ Edit Config File),
34with keys in `keymap.toml` and capture templates in `capture.toml` beside it. 41with keys in `keymap.toml` and capture templates in `capture.toml` beside it.
35Settings ▸ General ▸ Import from Emacs reads an existing Emacs or Doom 42Settings ▸ 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'
334 334
335Seven phases, each usable on its own. Phase 1 is read-only, so it can run next to Emacs from the first build. 335Seven phases, each usable on its own. Phase 1 is read-only, so it can run next to Emacs from the first build.
336 336
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.
338 338
3391. **Core and viewer:** `OrgCore` parser, workspace, index, read-only rendering, folding, search. 3391. **Core and viewer:** `OrgCore` parser, workspace, index, read-only rendering, folding, search.
3402. **Editor:** text editing, structure and heading commands, timestamps, `M-q`, table alignment, command palette, Emacs and Mac keymaps, remaining tree-sitter grammars. 3402. **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"