krz/orgstar

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

Commit 1ee4104d21

1ee4104d217911b4b02b04a288f8cd7128ead4dd

parent: c48fbeb058

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 16:07 UTC

Settings in tabs

General, Editing, Agenda and Capture tabs with grouped forms, so
labels and captions fit. ORGSTAR_WINDOW=settings opens the window.

Layout: unified · split

Sources/Orgstar/ContentView.swift +4 −1
@@ -42,6 +42,7 @@ struct ContentView: View {
4242 @State private var query = ""
4343 @FocusState private var searchFocused: Bool
4444 @Environment(\.openWindow) private var openWindow
45 @Environment(\.openSettings) private var openSettings
4546
4647 private var main: some View {
4748 NavigationSplitView {
@@ -192,7 +193,9 @@ struct ContentView: View {
192193 DevelopmentHooks.scheduleSnapshot()
193194 if let path = DevelopmentHooks.fileToOpen { selectedFile = path }
194195 if let root = DevelopmentHooks.rootToAdd { try? await workspace.addRoot(URL(fileURLWithPath: root)) }
195 if let id = DevelopmentHooks.windowToOpen { openWindow(id: id) }
196 if let id = DevelopmentHooks.windowToOpen {
197 if id == "settings" { openSettings() } else { openWindow(id: id) }
198 }
196199 await workspace.refresh()
197200 workspace.startWatching()
198201 }
Sources/Orgstar/DevelopmentHooks.swift +2 −2
@@ -4,7 +4,7 @@ import AppKit
44/// - `ORGSTAR_DATA_DIR`: where the index and roots live (see `WorkspaceModel.defaultDirectory`).
55/// - `ORGSTAR_OPEN`: a file to select at launch.
66/// - `ORGSTAR_ROOT`: a folder to add at launch.
7/// - `ORGSTAR_WINDOW`: a window to open at launch (`agenda`); the snapshot renders it.
7/// - `ORGSTAR_WINDOW`: a window to open at launch (`agenda`, `settings`); the snapshot renders it.
88/// - `ORGSTAR_SNAPSHOT`: after `ORGSTAR_SNAPSHOT_DELAY` seconds (default 3), render the main
99/// window to this PNG path and quit. Rendering from inside the app needs no screen-recording
1010/// permission.
@@ -20,7 +20,7 @@ enum DevelopmentHooks {
2020 let delay = Double(environment["ORGSTAR_SNAPSHOT_DELAY"] ?? "") ?? 3
2121 DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
2222 let visible = NSApp.windows.filter(\.isVisible)
23 let window = windowToOpen.flatMap { id in visible.first { $0.identifier?.rawValue.hasPrefix(id) == true } } ?? visible.first
23 let window = windowToOpen.flatMap { id in visible.first { $0.identifier?.rawValue.lowercased().contains(id) == true } } ?? visible.first
2424 if let view = window?.contentView?.superview,
2525 let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) {
2626 view.cacheDisplay(in: view.bounds, to: bitmap)
Sources/Orgstar/OrgstarApp.swift +102 −46
@@ -261,59 +261,115 @@ struct SettingsView: View {
261261 @AppStorage("reminders") private var reminders = true
262262 @AppStorage("reminderLead") private var reminderLead = 12
263263 @AppStorage("globalCapture") private var globalCapture = true
264 @AppStorage("settingsTab") private var tab = "general"
264265
265266 var body: some View {
267 TabView(selection: $tab) {
268 Tab("General", systemImage: "gearshape", value: "general") { general }
269 Tab("Editing", systemImage: "text.alignleft", value: "editing") { editing }
270 Tab("Agenda", systemImage: "calendar", value: "agenda") { agenda }
271 Tab("Capture", systemImage: "tray.and.arrow.down", value: "capture") { capture }
272 }
273 .frame(width: 600)
274 }
275
276 private var general: some View {
266277 Form {
267 Picker("Save files:", selection: $saveMode) {
268 Text("Automatically, when typing stops").tag(SaveMode.automatic)
269 Text("Only with File ▸ Save (⌘S)").tag(SaveMode.explicit)
278 Section {
279 Picker("Save files", selection: $saveMode) {
280 Text("Automatically, when typing stops").tag(SaveMode.automatic)
281 Text("Only with File ▸ Save (⌘S)").tag(SaveMode.explicit)
282 }
283 .pickerStyle(.radioGroup)
284 }
285 Section {
286 Picker("Keys", selection: $preset) {
287 ForEach(KeymapPreset.allCases, id: \.self) { Text($0.title).tag($0) }
288 }
289 Picker("Option as Meta", selection: $optionAsMeta) {
290 Text("Left Option").tag(OptionAsMeta.left.rawValue)
291 Text("Right Option").tag(OptionAsMeta.right.rawValue)
292 Text("Both").tag(OptionAsMeta([.left, .right]).rawValue)
293 Text("Neither").tag(0)
294 }
295 } footer: {
296 Text("Your own bindings go in \(KeymapLoader.userFile.path), on top of the preset.")
297 .settingsFooter()
298 }
299 }
300 .formStyle(.grouped)
301 .frame(height: 250)
302 }
303
304 private var editing: some View {
305 Form {
306 Section {
307 Picker("Tags", selection: $tagsColumn) {
308 Text("Aligned to end at column 77").tag(-77)
309 Text("One space after the title").tag(0)
310 }
311 Toggle("M-RET adds the new heading after the subtree", isOn: $headingAfterSubtree)
312 Toggle("M-RET splits the line at the caret", isOn: $splitLine)
313 Toggle("Lists can use letters (a. b. c.)", isOn: $alphabeticalLists)
314 Stepper("M-q fills to column \(fillColumn)", value: $fillColumn, in: 40...200)
270315 }
271 .pickerStyle(.radioGroup)
272 Picker("Keys:", selection: $preset) {
273 ForEach(KeymapPreset.allCases, id: \.self) { Text($0.title).tag($0) }
316 Section {
317 Toggle("Emacs hides emphasis markers (org-hide-emphasis-markers)", isOn: $hiddenMarkers)
318 Toggle("Emacs shows entities as characters (org-pretty-entities)", isOn: $prettyEntities)
319 } footer: {
320 Text("Tags, tables and M-q measure text as your Emacs displays it, so both write the same file.")
321 .settingsFooter()
274322 }
275 Picker("Option as Meta:", selection: $optionAsMeta) {
276 Text("Left Option").tag(OptionAsMeta.left.rawValue)
277 Text("Right Option").tag(OptionAsMeta.right.rawValue)
278 Text("Both").tag(OptionAsMeta([.left, .right]).rawValue)
279 Text("Neither").tag(0)
323 Section {
324 TextField("Default TODO keywords", text: $todoKeywords)
325 } footer: {
326 Text("For files without #+TODO, in #+TODO syntax. Takes effect when Orgstar restarts.")
327 .settingsFooter()
280328 }
281 Text("Your own bindings go in \(KeymapLoader.userFile.path), on top of the preset.")
282 .font(.caption)
283 .foregroundStyle(.secondary)
284 .textSelection(.enabled)
285 Divider()
286 Picker("Tags:", selection: $tagsColumn) {
287 Text("Aligned to end at column 77").tag(-77)
288 Text("One space after the title").tag(0)
329 }
330 .formStyle(.grouped)
331 .frame(height: 450)
332 }
333
334 private var agenda: some View {
335 Form {
336 Section {
337 Stepper("Agenda shows \(agendaSpan) days", value: $agendaSpan, in: 1...31)
338 Stepper(agendaStartOffset == 0 ? "Agenda starts today" : "Agenda starts \(-agendaStartOffset) days before today", value: $agendaStartOffset, in: -14...0)
339 Toggle("Include files in subfolders", isOn: $agendaSubfolders)
340 }
341 Section {
342 Toggle("Notify before timed agenda entries", isOn: $reminders)
343 Stepper("\(reminderLead) minutes before", value: $reminderLead, in: 0...120)
344 .disabled(!reminders)
345 } footer: {
346 Text("An entry's APPT_WARNTIME property overrides the lead time.")
347 .settingsFooter()
289348 }
290 Toggle("M-RET adds the new heading after the subtree", isOn: $headingAfterSubtree)
291 Toggle("M-RET splits the line at the caret", isOn: $splitLine)
292 Toggle("Lists can use letters (a. b. c.)", isOn: $alphabeticalLists)
293 Stepper("M-q fills to column \(fillColumn)", value: $fillColumn, in: 40...200)
294 Toggle("Emacs hides emphasis markers (org-hide-emphasis-markers)", isOn: $hiddenMarkers)
295 Toggle("Emacs shows entities as characters (org-pretty-entities)", isOn: $prettyEntities)
296 Text("Tags, tables and M-q measure text as your Emacs displays it, so both write the same file.")
297 .font(.caption)
298 .foregroundStyle(.secondary)
299 TextField("Default TODO keywords:", text: $todoKeywords)
300 Text("For files without #+TODO, in #+TODO syntax. Takes effect when Orgstar restarts.")
301 .font(.caption)
302 .foregroundStyle(.secondary)
303 Divider()
304 Stepper("Agenda shows \(agendaSpan) days", value: $agendaSpan, in: 1...31)
305 Stepper(agendaStartOffset == 0 ? "Agenda starts today" : "Agenda starts \(-agendaStartOffset) days before today", value: $agendaStartOffset, in: -14...0)
306 Toggle("Agenda includes files in subfolders", isOn: $agendaSubfolders)
307 Toggle("Notify before timed agenda entries", isOn: $reminders)
308 Stepper("\(reminderLead) minutes before (APPT_WARNTIME overrides)", value: $reminderLead, in: 0...120)
309 .disabled(!reminders)
310 Toggle("⌃⌥Space opens Capture from any app", isOn: $globalCapture)
311 Text("Templates go in \(CaptureTemplates.userFile.path).")
312 .font(.caption)
313 .foregroundStyle(.secondary)
314 .textSelection(.enabled)
315349 }
316 .padding(20)
317 .frame(width: 480)
350 .formStyle(.grouped)
351 .frame(height: 290)
352 }
353
354 private var capture: some View {
355 Form {
356 Section {
357 Toggle("⌃⌥Space opens Capture from any app", isOn: $globalCapture)
358 } footer: {
359 Text("Templates go in \(CaptureTemplates.userFile.path).")
360 .settingsFooter()
361 }
362 }
363 .formStyle(.grouped)
364 .frame(height: 160)
365 }
366}
367
368private extension Text {
369 func settingsFooter() -> some View {
370 font(.caption)
371 .foregroundStyle(.secondary)
372 .textSelection(.enabled)
373 .frame(maxWidth: .infinity, alignment: .leading)
318374 }
319375}