Open .org files from Finder, app icon, current docs !64
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 | ||
| 6 | 6 | every byte you didn't change as it was, so Emacs and sync tools can work on |
| 7 | 7 | the same folder. |
| 8 | 8 | |
| 9 | Status: phase 1 (parser, workspace, read-only viewer). | |
| 9 | Status: macOS v1, design phases 1–6: editing with Emacs, Mac and Doom keys, | |
| 10 | agenda, table formulas, Babel, export, capture, clocking, board, themes and | |
| 11 | buffers. Owed: the M1 Air gate run and the VoiceOver check. iOS (phase 7) is | |
| 12 | not started. | |
| 10 | 13 | |
| 11 | 14 | ## Build and run |
| 12 | 15 | |
| @@ -18,8 +21,14 @@ open .build/app/Orgstar.app | ||
| 18 | 21 | ``` |
| 19 | 22 | |
| 20 | 23 | Add a folder with File > Add Folder… (⇧⌘O). ⌘P opens a file by name, ⇧⌘F |
| 21 | searches. In the editor, TAB on a heading cycles its folding and Shift-TAB | |
| 22 | cycles the whole file. | |
| 24 | searches, ⇧⌘A shows the agenda. In the editor, TAB on a heading cycles its | |
| 25 | folding and Shift-TAB cycles the whole file. Orgstar opens `.org` files from | |
| 26 | Finder once the app has been launched. | |
| 27 | ||
| 28 | Settings live in `~/.config/orgstar/config.toml` (Orgstar ▸ Edit Config File), | |
| 29 | with keys in `keymap.toml` and capture templates in `capture.toml` beside it. | |
| 30 | Settings ▸ General ▸ Import from Emacs reads an existing Emacs or Doom | |
| 31 | configuration. | |
| 23 | 32 | |
| 24 | 33 | ```sh |
| 25 | 34 | swift test # all tests |
Sources/OrgApp/EmacsImport.swift +40 −2
| @@ -290,6 +290,31 @@ public struct EmacsImport: Sendable { | ||
| 290 | 290 | |
| 291 | 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 | 318 | static func unquote(_ sexp: Sexp) -> Sexp? { |
| 294 | 319 | if let items = sexp.elements, items.count == 2, items[0].symbol == "quote" || items[0].symbol == "function" { return items[1] } |
| 295 | 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 | 385 | if assignment.rank >= 2 { skipped += 1 } |
| 361 | 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 | 397 | guard let value = Self.evaluate(assignment.value, env) else { |
| 364 | 398 | if assignment.rank < 2 { |
| 365 | 399 | doomDefaultsLeftOut += 1 |
| @@ -448,8 +482,12 @@ public struct EmacsImport: Sendable { | ||
| 448 | 482 | } |
| 449 | 483 | case "org-capture-templates": |
| 450 | 484 | 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") | |
| 453 | 491 | default: |
| 454 | 492 | note("Orgstar has no equivalent") |
| 455 | 493 | } |
Sources/Orgstar/ContentView.swift +3
| @@ -288,6 +288,9 @@ struct ContentView: View { | ||
| 288 | 288 | DevelopmentHooks.scheduleSnapshot() |
| 289 | 289 | pendingCarets = BufferRestore.restore(session) |
| 290 | 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 | 294 | if let path = DevelopmentHooks.fileToOpen { selectedFile = path } |
| 292 | 295 | if let root = DevelopmentHooks.rootToAdd { try? await workspace.addRoot(URL(fileURLWithPath: root)) } |
| 293 | 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 | 128 | func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } |
| 115 | 129 | |
| 116 | 130 | func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply { |
Tests/OrgAppTests/EmacsImportTests.swift +5 −1
| @@ -7,6 +7,7 @@ struct EmacsImportTests { | ||
| 7 | 7 | static let config = #""" |
| 8 | 8 | (setq user-full-name "Someone") |
| 9 | 9 | (setq doom-theme 'doom-homage-black) |
| 10 | (setq doom-font (font-spec :family "JetBrains Mono" :size 14)) | |
| 10 | 11 | (setq display-line-numbers-type t) |
| 11 | 12 | (setq org-support-shift-select t) |
| 12 | 13 | (setq org-directory "~/notes/") |
| @@ -72,6 +73,7 @@ struct EmacsImportTests { | ||
| 72 | 73 | #expect(s["org-M-RET-may-split-line"] == .bool(false)) |
| 73 | 74 | #expect(s["org-todo-keywords"] == .string("TODO(t) WAIT(w) | DONE(d)")) |
| 74 | 75 | #expect(s["fill-column"] == nil) |
| 76 | #expect(s["font"] == .string("JetBrains Mono") && s["font-size"] == .integer(14)) | |
| 75 | 77 | let folders = result.items.compactMap { if case .folder(let f) = $0.change { f } else { nil } } |
| 76 | 78 | #expect(folders == ["~/notes/", "~/work"]) |
| 77 | 79 | #expect(result.unreadable.map(\.name) == ["fill-column"]) |
| @@ -131,6 +133,8 @@ struct EmacsImportTests { | ||
| 131 | 133 | let keymap = folder.appendingPathComponent("keymap.toml") |
| 132 | 134 | #expect(KeymapLoader.load(.doom, userFile: keymap).problems.isEmpty) |
| 133 | 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 | 52 | - `OrgCore`: parser, syntax tree, semantic layer, commands, agenda queries, table formula evaluator, Babel header parsing and results writing. Foundation only. |
| 53 | 53 | - `OrgIndex`: GRDB schema, indexing and queries. Foundation only. |
| 54 | 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 | 56 | - App target: windows, sidebar, menus, settings, keymap loading. |
| 57 | 57 | |
| 58 | 58 | ## OrgCore data model |
| @@ -334,6 +334,8 @@ Fidelity is checked against Emacs itself, using the same oracle pattern as orgo' | ||
| 334 | 334 | |
| 335 | 335 | Seven phases, each usable on its own. Phase 1 is read-only, so it can run next to Emacs from the first build. |
| 336 | 336 | |
| 337 | Status, 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 | ||
| 337 | 339 | 1. **Core and viewer:** `OrgCore` parser, workspace, index, read-only rendering, folding, search. |
| 338 | 340 | 2. **Editor:** text editing, structure and heading commands, timestamps, `M-q`, table alignment, command palette, Emacs and Mac keymaps, remaining tree-sitter grammars. |
| 339 | 341 | 3. **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 | 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 | |
| 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) | |
| 358 | 360 | |
| 359 | 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 | 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 | 4 | # Usage: scripts/build-app.sh [version] |
| 4 | 5 | set -eu |
| 5 | 6 | |
| @@ -13,6 +14,10 @@ binary="$(swift build -c release --show-bin-path)/Orgstar" | ||
| 13 | 14 | rm -rf "$app" |
| 14 | 15 | mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" |
| 15 | 16 | cp "$binary" "$app/Contents/MacOS/Orgstar" |
| 17 | iconset="$(mktemp -d)/AppIcon.iconset" | |
| 18 | swift scripts/make-icon.swift "$iconset" | |
| 19 | iconutil -c icns "$iconset" -o "$app/Contents/Resources/AppIcon.icns" | |
| 20 | rm -rf "$(dirname "$iconset")" | |
| 16 | 21 | |
| 17 | 22 | cat > "$app/Contents/Info.plist" <<EOF |
| 18 | 23 | <?xml version="1.0" encoding="UTF-8"?> |
| @@ -29,6 +34,47 @@ cat > "$app/Contents/Info.plist" <<EOF | ||
| 29 | 34 | <string>Orgstar</string> |
| 30 | 35 | <key>CFBundlePackageType</key> |
| 31 | 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 | 78 | <key>CFBundleShortVersionString</key> |
| 33 | 79 | <string>${version}</string> |
| 34 | 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> | |
| 3 | import AppKit | |
| 4 | ||
| 5 | let folder = URL(fileURLWithPath: CommandLine.arguments[1]) | |
| 6 | try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) | |
| 7 | ||
| 8 | func 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 | ||
| 35 | for 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 | } | |