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

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

8 files changed, +176 −19

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
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
7the same folder. 7the same folder.
8 8
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.
10 13
11## Build and run 14## Build and run
12 15
@@ -18,8 +21,14 @@ open .build/app/Orgstar.app
18``` 21```
19 22
20Add a folder with File > Add Folder… (⇧⌘O). ⌘P opens a file by name, ⇧⌘F 23Add 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 24searches, ⇧⌘A shows the agenda. In the editor, TAB on a heading cycles its
22cycles the whole file. 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.
23 32
24```sh 33```sh
25swift test # all tests 34swift test # all tests
Sources/OrgApp/EmacsImport.swift +40 −2
@@ -290,6 +290,31 @@ public struct EmacsImport: Sendable {
290 290
291 // MARK: - Values 291 // MARK: - Values
292 292
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
293 static func unquote(_ sexp: Sexp) -> Sexp? { 318 static func unquote(_ sexp: Sexp) -> Sexp? {
294 if let items = sexp.elements, items.count == 2, items[0].symbol == "quote" || items[0].symbol == "function" { return items[1] } 319 if let items = sexp.elements, items.count == 2, items[0].symbol == "quote" || items[0].symbol == "function" { return items[1] }
295 if case .list(let items) = sexp, items.count == 2, items[0].symbol == "\\`" { return items[1] } 320 if case .list(let items) = sexp, items.count == 2, items[0].symbol == "\\`" { return items[1] }
@@ -360,6 +385,15 @@ public struct EmacsImport: Sendable {
360 if assignment.rank >= 2 { skipped += 1 } 385 if assignment.rank >= 2 { skipped += 1 }
361 continue 386 continue
362 } 387 }
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 }
363 guard let value = Self.evaluate(assignment.value, env) else { 397 guard let value = Self.evaluate(assignment.value, env) else {
364 if assignment.rank < 2 { 398 if assignment.rank < 2 {
365 doomDefaultsLeftOut += 1 399 doomDefaultsLeftOut += 1
@@ -448,8 +482,12 @@ public struct EmacsImport: Sendable {
448 } 482 }
449 case "org-capture-templates": 483 case "org-capture-templates":
450 for entry in value.elements ?? [] { captureTemplate(entry, source: source, id: id) } 484 for entry in value.elements ?? [] { captureTemplate(entry, source: source, id: id) }
451 case "doom-font", "doom-variable-pitch-font", "doom-theme": 485 case "doom-font":
452 note("Orgstar has no fonts or themes to set yet") 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")
453 default: 491 default:
454 note("Orgstar has no equivalent") 492 note("Orgstar has no equivalent")
455 } 493 }
Sources/Orgstar/ContentView.swift +3
@@ -288,6 +288,9 @@ struct ContentView: View {
288 DevelopmentHooks.scheduleSnapshot() 288 DevelopmentHooks.scheduleSnapshot()
289 pendingCarets = BufferRestore.restore(session) 289 pendingCarets = BufferRestore.restore(session)
290 if let path = session.path, let caret = pendingCarets.removeValue(forKey: path) { session.reveal(caret) } 290 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 = []
291 if let path = DevelopmentHooks.fileToOpen { selectedFile = path } 294 if let path = DevelopmentHooks.fileToOpen { selectedFile = path }
292 if let root = DevelopmentHooks.rootToAdd { try? await workspace.addRoot(URL(fileURLWithPath: root)) } 295 if let root = DevelopmentHooks.rootToAdd { try? await workspace.addRoot(URL(fileURLWithPath: root)) }
293 if let command = DevelopmentHooks.commandToRun { perform(command) } 296 if let command = DevelopmentHooks.commandToRun { perform(command) }
Sources/Orgstar/OrgstarApp.swift +14
@@ -111,6 +111,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent
111 } 111 }
112 } 112 }
113 113
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
114 func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } 128 func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true }
115 129
116 func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { 130 func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
Tests/OrgAppTests/EmacsImportTests.swift +5 −1
@@ -7,6 +7,7 @@ struct EmacsImportTests {
7 static let config = #""" 7 static let config = #"""
8 (setq user-full-name "Someone") 8 (setq user-full-name "Someone")
9 (setq doom-theme 'doom-homage-black) 9 (setq doom-theme 'doom-homage-black)
10 (setq doom-font (font-spec :family "JetBrains Mono" :size 14))
10 (setq display-line-numbers-type t) 11 (setq display-line-numbers-type t)
11 (setq org-support-shift-select t) 12 (setq org-support-shift-select t)
12 (setq org-directory "~/notes/") 13 (setq org-directory "~/notes/")
@@ -72,6 +73,7 @@ struct EmacsImportTests {
72 #expect(s["org-M-RET-may-split-line"] == .bool(false)) 73 #expect(s["org-M-RET-may-split-line"] == .bool(false))
73 #expect(s["org-todo-keywords"] == .string("TODO(t) WAIT(w) | DONE(d)")) 74 #expect(s["org-todo-keywords"] == .string("TODO(t) WAIT(w) | DONE(d)"))
74 #expect(s["fill-column"] == nil) 75 #expect(s["fill-column"] == nil)
76 #expect(s["font"] == .string("JetBrains Mono") && s["font-size"] == .integer(14))
75 let folders = result.items.compactMap { if case .folder(let f) = $0.change { f } else { nil } } 77 let folders = result.items.compactMap { if case .folder(let f) = $0.change { f } else { nil } }
76 #expect(folders == ["~/notes/", "~/work"]) 78 #expect(folders == ["~/notes/", "~/work"])
77 #expect(result.unreadable.map(\.name) == ["fill-column"]) 79 #expect(result.unreadable.map(\.name) == ["fill-column"])
@@ -131,6 +133,8 @@ struct EmacsImportTests {
131 let keymap = folder.appendingPathComponent("keymap.toml") 133 let keymap = folder.appendingPathComponent("keymap.toml")
132 #expect(KeymapLoader.load(.doom, userFile: keymap).problems.isEmpty) 134 #expect(KeymapLoader.load(.doom, userFile: keymap).problems.isEmpty)
133 #expect(KeymapLoader.load(.doom, userFile: keymap).keymap.keyLabels(for: "app.agenda", modes: ["normal"]).contains("C-c a")) 135 #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)
135 } 139 }
136} 140}
docs/design.md +14 −12
@@ -52,7 +52,7 @@ Diagram (described in text):
52- `OrgCore`: parser, syntax tree, semantic layer, commands, agenda queries, table formula evaluator, Babel header parsing and results writing. Foundation only. 52- `OrgCore`: parser, syntax tree, semantic layer, commands, agenda queries, table formula evaluator, Babel header parsing and results writing. Foundation only.
53- `OrgIndex`: GRDB schema, indexing and queries. Foundation only. 53- `OrgIndex`: GRDB schema, indexing and queries. Foundation only.
54- `OrgPlatform`: TextKit 2 editor view, file access and watching, process runner, notifications. Separate macOS and iOS implementations behind shared protocols. 54- `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.
56- App target: windows, sidebar, menus, settings, keymap loading. 56- App target: windows, sidebar, menus, settings, keymap loading.
57 57
58## OrgCore data model 58## OrgCore data model
@@ -334,6 +334,8 @@ 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.
338
3371. **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.
3382. **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.
3393. **Agenda:** day/week views, tag and TODO search, saved custom views, notifications from SCHEDULED/DEADLINE. 3413. **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
344 346
345**Phase 1 scope** 347**Phase 1 scope**
346 348
347- [ ] `OrgCore` package: lossless parser with UTF-16 ranges and the bytes-and-encoding contract; element-aware settings pass; inline rules ported from OrgSwift 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
348- [ ] Incremental reparse with old/new classification and synchronization points; differential tests 350- [x] Incremental reparse with old/new classification and synchronization points; differential tests
349- [ ] Document session: original bytes, revisions, merge base, per-view state 351- [x] 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) 352- [x] 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 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)
352- [ ] Workspace: folders, bookmarks, discovery/agenda/link scopes, FSEvents with reconciliation scans, Syncthing temp/conflict handling, iCloud placeholders 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)
353- [ ] Index: schema, settings version, dirty-buffer overlay, FTS5 search 355- [x] 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 356- [x] 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) 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)
356- [ ] Folding: local and global cycling, `#+STARTUP` visibility 358- [x] Folding: local and global cycling, `#+STARTUP` visibility
357- [ ] Benchmarks recorded for the files listed in the exit gates 359- [ ] Benchmarks recorded for the files listed in the exit gates (recorded on an M-series Mac; the M1 Air run is owed)
358 360
359**Phase 1 exit gates** (measured on an M1 MacBook Air, 8 GB, the slowest supported reference machine): 361**Phase 1 exit gates** (measured on an M1 MacBook Air, 8 GB, the slowest supported reference machine):
360 362
scripts/build-app.sh +47 −1
@@ -1,5 +1,6 @@
1#!/bin/sh 1#!/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.
3# Usage: scripts/build-app.sh [version] 4# Usage: scripts/build-app.sh [version]
4set -eu 5set -eu
5 6
@@ -13,6 +14,10 @@ binary="$(swift build -c release --show-bin-path)/Orgstar"
13rm -rf "$app" 14rm -rf "$app"
14mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" 15mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources"
15cp "$binary" "$app/Contents/MacOS/Orgstar" 16cp "$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")"
16 21
17cat > "$app/Contents/Info.plist" <<EOF 22cat > "$app/Contents/Info.plist" <<EOF
18<?xml version="1.0" encoding="UTF-8"?> 23<?xml version="1.0" encoding="UTF-8"?>
@@ -29,6 +34,47 @@ cat > "$app/Contents/Info.plist" <<EOF
29 <string>Orgstar</string> 34 <string>Orgstar</string>
30 <key>CFBundlePackageType</key> 35 <key>CFBundlePackageType</key>
31 <string>APPL</string> 36 <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>
32 <key>CFBundleShortVersionString</key> 78 <key>CFBundleShortVersionString</key>
33 <string>${version}</string> 79 <string>${version}</string>
34 <key>CFBundleVersion</key> 80 <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}