krz/orgstar

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

Commit b5b0571216

b5b0571216b98cb8eeee4c61f6c0576ec13ba6a0

parent: c857dec17c

Verified · cmc

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

Open .org files from Finder, app icon, current docs

The app bundle declares the Org document type (org.orgmode.org, .org
and .org_archive) with Orgstar as its editor, and plain text as an
alternate; files handed to the app open as buffers, after the restored
ones at launch. scripts/make-icon.swift draws the icon at build time.
The importer reads doom-font into [theme] font and size, and no longer
says Orgstar has no themes. README and the design doc state what is
built and what is owed.

Layout: unified · split

README.md +12 −3
@@ -6,7 +6,10 @@ The 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
77the same folder.
88
9Status: phase 1 (parser, workspace, read-only viewer).
9Status: macOS v1, design phases 1–6: editing with Emacs, Mac and Doom keys,
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
12not started.
1013
1114## Build and run
1215
@@ -18,8 +21,14 @@ open .build/app/Orgstar.app
1821```
1922
2023Add a folder with File > Add Folder… (⇧⌘O). ⌘P opens a file by name, ⇧⌘F
21searches. In the editor, TAB on a heading cycles its folding and Shift-TAB
22cycles the whole file.
24searches, ⇧⌘A shows the agenda. In the editor, TAB on a heading cycles its
25folding and Shift-TAB cycles the whole file. Orgstar opens `.org` files from
26Finder once the app has been launched.
27
28Settings live in `~/.config/orgstar/config.toml` (Orgstar ▸ Edit Config File),
29with keys in `keymap.toml` and capture templates in `capture.toml` beside it.
30Settings ▸ General ▸ Import from Emacs reads an existing Emacs or Doom
31configuration.
2332
2433```sh
2534swift test # all tests
Sources/OrgApp/EmacsImport.swift +40 −2
@@ -290,6 +290,31 @@ public struct EmacsImport: Sendable {
290290
291291 // MARK: - Values
292292
293 /// `(font-spec :family "JetBrains Mono" :size 14)`, or `"JetBrains Mono-14"`.
294 static func fontSpec(_ sexp: Sexp) -> (family: String?, size: Int?)? {
295 if let name = sexp.string {
296 let parts = name.split(separator: "-")
297 if parts.count > 1, let size = Int(parts.last!) { return (parts.dropLast().joined(separator: "-"), size) }
298 return (name, nil)
299 }
300 guard sexp.head == "font-spec", let args = sexp.elements?.dropFirst() else { return nil }
301 let list = Array(args)
302 var family: String?
303 var size: Int?
304 var i = 0
305 while i + 1 < list.count {
306 switch list[i].symbol {
307 case ":family": family = list[i + 1].string
308 case ":size":
309 if let n = list[i + 1].integer { size = n }
310 if case .float(let d) = list[i + 1] { size = Int(d.rounded()) }
311 default: break
312 }
313 i += 2
314 }
315 return family == nil && size == nil ? nil : (family, size)
316 }
317
293318 static func unquote(_ sexp: Sexp) -> Sexp? {
294319 if let items = sexp.elements, items.count == 2, items[0].symbol == "quote" || items[0].symbol == "function" { return items[1] }
295320 if case .list(let items) = sexp, items.count == 2, items[0].symbol == "\\`" { return items[1] }
@@ -360,6 +385,15 @@ public struct EmacsImport: Sendable {
360385 if assignment.rank >= 2 { skipped += 1 }
361386 continue
362387 }
388 if name == "doom-font", let spec = Self.fontSpec(assignment.value) {
389 if let family = spec.family {
390 items.append(Item(id: id(), name: name, summary: "font = \"\(family)\"", source: assignment.source, change: .setting(key: "font", value: .string(family))))
391 }
392 if let size = spec.size {
393 items.append(Item(id: id(), name: name, summary: "font-size = \(size)", source: assignment.source, change: .setting(key: "font-size", value: .integer(size))))
394 }
395 continue
396 }
363397 guard let value = Self.evaluate(assignment.value, env) else {
364398 if assignment.rank < 2 {
365399 doomDefaultsLeftOut += 1
@@ -448,8 +482,12 @@ public struct EmacsImport: Sendable {
448482 }
449483 case "org-capture-templates":
450484 for entry in value.elements ?? [] { captureTemplate(entry, source: source, id: id) }
451 case "doom-font", "doom-variable-pitch-font", "doom-theme":
452 note("Orgstar has no fonts or themes to set yet")
485 case "doom-font":
486 note("not a font-spec Orgstar can read")
487 case "doom-variable-pitch-font":
488 note("Orgstar uses one font; set it with doom-font or [theme] font")
489 case "doom-theme":
490 note("Emacs themes aren't read; set colors under [theme] in config.toml")
453491 default:
454492 note("Orgstar has no equivalent")
455493 }
Sources/Orgstar/ContentView.swift +3
@@ -288,6 +288,9 @@ struct ContentView: View {
288288 DevelopmentHooks.scheduleSnapshot()
289289 pendingCarets = BufferRestore.restore(session)
290290 if let path = session.path, let caret = pendingCarets.removeValue(forKey: path) { session.reveal(caret) }
291 // Files handed over at launch open after the restored buffers, and show.
292 for path in AppDelegate.pendingOpens { session.open(path) }
293 AppDelegate.pendingOpens = []
291294 if let path = DevelopmentHooks.fileToOpen { selectedFile = path }
292295 if let root = DevelopmentHooks.rootToAdd { try? await workspace.addRoot(URL(fileURLWithPath: root)) }
293296 if let command = DevelopmentHooks.commandToRun { perform(command) }
Sources/Orgstar/OrgstarApp.swift +14
@@ -111,6 +111,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
111111 }
112112 }
113113
114 /// Files opened from Finder, `open`, or a drop on the Dock icon: each opens as a buffer.
115 /// Ones that arrive before the window is ready wait for it (`pendingOpens`).
116 static var pendingOpens: [String] = []
117
118 func application(_ application: NSApplication, open urls: [URL]) {
119 let paths = urls.filter(\.isFileURL).map(\.path)
120 guard commands != nil, let session else {
121 Self.pendingOpens += paths
122 return
123 }
124 NSApp.activate()
125 for path in paths { session.open(path) }
126 }
127
114128 func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }
115129
116130 func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
Tests/OrgAppTests/EmacsImportTests.swift +5 −1
@@ -7,6 +7,7 @@ struct EmacsImportTests {
77 static let config = #"""
88 (setq user-full-name "Someone")
99 (setq doom-theme 'doom-homage-black)
10 (setq doom-font (font-spec :family "JetBrains Mono" :size 14))
1011 (setq display-line-numbers-type t)
1112 (setq org-support-shift-select t)
1213 (setq org-directory "~/notes/")
@@ -72,6 +73,7 @@ struct EmacsImportTests {
7273 #expect(s["org-M-RET-may-split-line"] == .bool(false))
7374 #expect(s["org-todo-keywords"] == .string("TODO(t) WAIT(w) | DONE(d)"))
7475 #expect(s["fill-column"] == nil)
76 #expect(s["font"] == .string("JetBrains Mono") && s["font-size"] == .integer(14))
7577 let folders = result.items.compactMap { if case .folder(let f) = $0.change { f } else { nil } }
7678 #expect(folders == ["~/notes/", "~/work"])
7779 #expect(result.unreadable.map(\.name) == ["fill-column"])
@@ -131,6 +133,8 @@ struct EmacsImportTests {
131133 let keymap = folder.appendingPathComponent("keymap.toml")
132134 #expect(KeymapLoader.load(.doom, userFile: keymap).problems.isEmpty)
133135 #expect(KeymapLoader.load(.doom, userFile: keymap).keymap.keyLabels(for: "app.agenda", modes: ["normal"]).contains("C-c a"))
134 #expect(summary.hasPrefix("Imported 10 settings, 1 capture template, 3 key bindings."), "\(summary)")
136 #expect(summary.hasPrefix("Imported 12 settings, 1 capture template, 3 key bindings."), "\(summary)")
137 #expect(defaults.string(forKey: "themeFont") == "JetBrains Mono")
138 #expect(defaults.integer(forKey: "themeFontSize") == 14)
135139 }
136140}
docs/design.md +14 −12
@@ -52,7 +52,7 @@ Diagram (described in text):
5252- `OrgCore`: parser, syntax tree, semantic layer, commands, agenda queries, table formula evaluator, Babel header parsing and results writing. Foundation only.
5353- `OrgIndex`: GRDB schema, indexing and queries. Foundation only.
5454- `OrgPlatform`: TextKit 2 editor view, file access and watching, process runner, notifications. Separate macOS and iOS implementations behind shared protocols.
55- `OrgSwift` (existing): HTML and SwiftUI rendering, used for export and preview.
55- `OrgSwift` (existing): HTML and SwiftUI rendering. Orgstar doesn't use it: HTML export is `OrgCore`'s own (`HTMLExport`), checked against orgo's conformance cases.
5656- App target: windows, sidebar, menus, settings, keymap loading.
5757
5858## OrgCore data model
@@ -334,6 +334,8 @@ 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.
338
3373391. **Core and viewer:** `OrgCore` parser, workspace, index, read-only rendering, folding, search.
3383402. **Editor:** text editing, structure and heading commands, timestamps, `M-q`, table alignment, command palette, Emacs and Mac keymaps, remaining tree-sitter grammars.
3393413. **Agenda:** day/week views, tag and TODO search, saved custom views, notifications from SCHEDULED/DEADLINE.
@@ -344,17 +346,17 @@ Seven phases, each usable on its own. Phase 1 is read-only, so it can run next t
344346
345347**Phase 1 scope**
346348
347- [ ] `OrgCore` package: lossless parser with UTF-16 ranges and the bytes-and-encoding contract; element-aware settings pass; inline rules ported from OrgSwift
348- [ ] Incremental reparse with old/new classification and synchronization points; differential tests
349- [ ] Document session: original bytes, revisions, merge base, per-view state
350- [ ] Save path with merge, recovery copies and fault-injection tests (used by the editing spike, not exposed in the viewer)
351- [ ] Editable TextKit 2 spike: folding and org-indent display with correct caret movement, selection, IME composition, VoiceOver and copy/paste across folded text
352- [ ] Workspace: folders, bookmarks, discovery/agenda/link scopes, FSEvents with reconciliation scans, Syncthing temp/conflict handling, iCloud placeholders
353- [ ] Index: schema, settings version, dirty-buffer overlay, FTS5 search
354- [ ] Mac app (read-only): sidebar of folders and files, outline of the current file, quick open (`⌘P`), full-text search
355- [ ] Rendering over the text: heading styles, TODO/priority/tag styling, links, emphasis, tables, src blocks highlighted for org, emacs-lisp, sh and python only (the rest move to phase 2)
356- [ ] Folding: local and global cycling, `#+STARTUP` visibility
357- [ ] Benchmarks recorded for the files listed in the exit gates
349- [x] `OrgCore` package: lossless parser with UTF-16 ranges and the bytes-and-encoding contract; element-aware settings pass; inline rules ported from OrgSwift
350- [x] Incremental reparse with old/new classification and synchronization points; differential tests
351- [x] Document session: original bytes, revisions, merge base, per-view state
352- [x] Save path with merge, recovery copies and fault-injection tests (used by the editing spike, not exposed in the viewer)
353- [ ] Editable TextKit 2 spike: folding and org-indent display with correct caret movement, selection, IME composition, VoiceOver and copy/paste across folded text (done but the VoiceOver check)
354- [ ] Workspace: folders, bookmarks, discovery/agenda/link scopes, FSEvents with reconciliation scans, Syncthing temp/conflict handling, iCloud placeholders (done but placeholders, which are only asked to download)
355- [x] Index: schema, settings version, dirty-buffer overlay, FTS5 search
356- [x] Mac app (read-only): sidebar of folders and files, outline of the current file, quick open (`⌘P`), full-text search
357- [x] Rendering over the text: heading styles, TODO/priority/tag styling, links, emphasis, tables, src blocks highlighted for org, emacs-lisp, sh and python only (the rest move to phase 2)
358- [x] Folding: local and global cycling, `#+STARTUP` visibility
359- [ ] Benchmarks recorded for the files listed in the exit gates (recorded on an M-series Mac; the M1 Air run is owed)
358360
359361**Phase 1 exit gates** (measured on an M1 MacBook Air, 8 GB, the slowest supported reference machine):
360362
scripts/build-app.sh +47 −1
@@ -1,5 +1,6 @@
11#!/bin/sh
2# Builds Orgstar.app in .build/app from the release binary, signed ad hoc for local use.
2# Builds Orgstar.app in .build/app from the release binary, signed ad hoc for local use. The
3# icon is drawn by scripts/make-icon.swift.
34# Usage: scripts/build-app.sh [version]
45set -eu
56
@@ -13,6 +14,10 @@ binary="$(swift build -c release --show-bin-path)/Orgstar"
1314rm -rf "$app"
1415mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
1516cp "$binary" "$app/Contents/MacOS/Orgstar"
17iconset="$(mktemp -d)/AppIcon.iconset"
18swift scripts/make-icon.swift "$iconset"
19iconutil -c icns "$iconset" -o "$app/Contents/Resources/AppIcon.icns"
20rm -rf "$(dirname "$iconset")"
1621
1722cat > "$app/Contents/Info.plist" <<EOF
1823<?xml version="1.0" encoding="UTF-8"?>
@@ -29,6 +34,47 @@ cat > "$app/Contents/Info.plist" <<EOF
2934 <string>Orgstar</string>
3035 <key>CFBundlePackageType</key>
3136 <string>APPL</string>
37 <key>CFBundleIconFile</key>
38 <string>AppIcon</string>
39 <key>UTImportedTypeDeclarations</key>
40 <array>
41 <dict>
42 <key>UTTypeIdentifier</key>
43 <string>org.orgmode.org</string>
44 <key>UTTypeDescription</key>
45 <string>Org document</string>
46 <key>UTTypeConformsTo</key>
47 <array><string>public.plain-text</string></array>
48 <key>UTTypeTagSpecification</key>
49 <dict>
50 <key>public.filename-extension</key>
51 <array><string>org</string><string>org_archive</string></array>
52 </dict>
53 </dict>
54 </array>
55 <key>CFBundleDocumentTypes</key>
56 <array>
57 <dict>
58 <key>CFBundleTypeName</key>
59 <string>Org document</string>
60 <key>CFBundleTypeRole</key>
61 <string>Editor</string>
62 <key>LSHandlerRank</key>
63 <string>Default</string>
64 <key>LSItemContentTypes</key>
65 <array><string>org.orgmode.org</string></array>
66 </dict>
67 <dict>
68 <key>CFBundleTypeName</key>
69 <string>Text</string>
70 <key>CFBundleTypeRole</key>
71 <string>Editor</string>
72 <key>LSHandlerRank</key>
73 <string>Alternate</string>
74 <key>LSItemContentTypes</key>
75 <array><string>public.plain-text</string></array>
76 </dict>
77 </array>
3278 <key>CFBundleShortVersionString</key>
3379 <string>${version}</string>
3480 <key>CFBundleVersion</key>
scripts/make-icon.swift added +41
@@ -0,0 +1,41 @@
1// Draws Orgstar's app icon and writes an iconset: an outline's heading star over three
2// indented lines, on a deep blue-to-violet square. Usage: swift scripts/make-icon.swift <dir.iconset>
3import AppKit
4
5let folder = URL(fileURLWithPath: CommandLine.arguments[1])
6try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true)
7
8func draw(_ size: CGFloat) -> NSBitmapImageRep {
9 let rep = NSBitmapImageRep(bitmapDataPlanes: nil, pixelsWide: Int(size), pixelsHigh: Int(size), bitsPerSample: 8,
10 samplesPerPixel: 4, hasAlpha: true, isPlanar: false, colorSpaceName: .deviceRGB, bytesPerRow: 0, bitsPerPixel: 0)!
11 NSGraphicsContext.saveGraphicsState()
12 NSGraphicsContext.current = NSGraphicsContext(bitmapImageRep: rep)
13 let s = size / 1024
14 // The macOS icon grid: an 824-point rounded square in a 1024 canvas.
15 let square = NSRect(x: 100 * s, y: 100 * s, width: 824 * s, height: 824 * s)
16 let shape = NSBezierPath(roundedRect: square, xRadius: 185 * s, yRadius: 185 * s)
17 NSGradient(colors: [NSColor(srgbRed: 0.13, green: 0.20, blue: 0.55, alpha: 1), NSColor(srgbRed: 0.40, green: 0.22, blue: 0.70, alpha: 1)])!
18 .draw(in: shape, angle: -60)
19 // The heading star.
20 let star = NSAttributedString(string: "✱", attributes: [
21 .font: NSFont.systemFont(ofSize: 330 * s, weight: .heavy), .foregroundColor: NSColor(srgbRed: 1, green: 0.78, blue: 0.30, alpha: 1),
22 ])
23 star.draw(at: NSPoint(x: 190 * s, y: 520 * s))
24 // Three outline lines, indented like a heading's children.
25 NSColor(white: 1, alpha: 0.92).setFill()
26 for (i, (x, width)) in [(470.0, 330.0), (300.0, 500.0), (380.0, 420.0)].enumerated() {
27 let y = (650 - Double(i) * 150) * s
28 let line = i == 0 ? NSRect(x: x * s, y: y, width: width * s, height: 70 * s) : NSRect(x: x * s, y: y, width: width * s, height: 56 * s)
29 NSBezierPath(roundedRect: line, xRadius: 28 * s, yRadius: 28 * s).fill()
30 }
31 NSGraphicsContext.restoreGraphicsState()
32 return rep
33}
34
35for base in [16, 32, 128, 256, 512] {
36 for scale in [1, 2] {
37 let name = scale == 1 ? "icon_\(base)x\(base).png" : "icon_\(base)x\(base)@2x.png"
38 let data = draw(CGFloat(base * scale)).representation(using: .png, properties: [:])!
39 try data.write(to: folder.appendingPathComponent(name))
40 }
41}