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 | every byte you didn't change as it was, so Emacs and sync tools can work on | 6 | every byte you didn't change as it was, so Emacs and sync tools can work on |
| 7 | the same folder. | 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 | ## Build and run | 14 | ## Build and run |
| 12 | 15 | ||
| @@ -18,8 +21,14 @@ open .build/app/Orgstar.app | |||
| 18 | ``` | 21 | ``` |
| 19 | 22 | ||
| 20 | Add a folder with File > Add Folder… (⇧⌘O). ⌘P opens a file by name, ⇧⌘F | 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 | 24 | searches, ⇧⌘A shows the agenda. In the editor, TAB on a heading cycles its |
| 22 | cycles the whole file. | 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 | ```sh | 33 | ```sh |
| 25 | swift test # all tests | 34 | swift 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 | ||
| 335 | Seven phases, each usable on its own. Phase 1 is read-only, so it can run next to Emacs from the first build. | 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 | 1. **Core and viewer:** `OrgCore` parser, workspace, index, read-only rendering, folding, search. | 339 | 1. **Core and viewer:** `OrgCore` parser, workspace, index, read-only rendering, folding, search. |
| 338 | 2. **Editor:** text editing, structure and heading commands, timestamps, `M-q`, table alignment, command palette, Emacs and Mac keymaps, remaining tree-sitter grammars. | 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 | 3. **Agenda:** day/week views, tag and TODO search, saved custom views, notifications from SCHEDULED/DEADLINE. | 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 | **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] |
| 4 | set -eu | 5 | set -eu |
| 5 | 6 | ||
| @@ -13,6 +14,10 @@ binary="$(swift build -c release --show-bin-path)/Orgstar" | |||
| 13 | rm -rf "$app" | 14 | rm -rf "$app" |
| 14 | mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" | 15 | mkdir -p "$app/Contents/MacOS" "$app/Contents/Resources" |
| 15 | cp "$binary" "$app/Contents/MacOS/Orgstar" | 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 | cat > "$app/Contents/Info.plist" <<EOF | 22 | cat > "$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> | ||
| 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 | } | ||