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 {
42 @State private var query = "" 42 @State private var query = ""
43 @FocusState private var searchFocused: Bool 43 @FocusState private var searchFocused: Bool
44 @Environment(\.openWindow) private var openWindow 44 @Environment(\.openWindow) private var openWindow
45 @Environment(\.openSettings) private var openSettings
45 46
46 private var main: some View { 47 private var main: some View {
47 NavigationSplitView { 48 NavigationSplitView {
@@ -192,7 +193,9 @@ struct ContentView: View {
192 DevelopmentHooks.scheduleSnapshot() 193 DevelopmentHooks.scheduleSnapshot()
193 if let path = DevelopmentHooks.fileToOpen { selectedFile = path } 194 if let path = DevelopmentHooks.fileToOpen { selectedFile = path }
194 if let root = DevelopmentHooks.rootToAdd { try? await workspace.addRoot(URL(fileURLWithPath: root)) } 195 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 }
196 await workspace.refresh() 199 await workspace.refresh()
197 workspace.startWatching() 200 workspace.startWatching()
198 } 201 }
Sources/Orgstar/DevelopmentHooks.swift +2 −2
@@ -4,7 +4,7 @@ import AppKit
4/// - `ORGSTAR_DATA_DIR`: where the index and roots live (see `WorkspaceModel.defaultDirectory`). 4/// - `ORGSTAR_DATA_DIR`: where the index and roots live (see `WorkspaceModel.defaultDirectory`).
5/// - `ORGSTAR_OPEN`: a file to select at launch. 5/// - `ORGSTAR_OPEN`: a file to select at launch.
6/// - `ORGSTAR_ROOT`: a folder to add at launch. 6/// - `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.
8/// - `ORGSTAR_SNAPSHOT`: after `ORGSTAR_SNAPSHOT_DELAY` seconds (default 3), render the main 8/// - `ORGSTAR_SNAPSHOT`: after `ORGSTAR_SNAPSHOT_DELAY` seconds (default 3), render the main
9/// window to this PNG path and quit. Rendering from inside the app needs no screen-recording 9/// window to this PNG path and quit. Rendering from inside the app needs no screen-recording
10/// permission. 10/// permission.
@@ -20,7 +20,7 @@ enum DevelopmentHooks {
20 let delay = Double(environment["ORGSTAR_SNAPSHOT_DELAY"] ?? "") ?? 3 20 let delay = Double(environment["ORGSTAR_SNAPSHOT_DELAY"] ?? "") ?? 3
21 DispatchQueue.main.asyncAfter(deadline: .now() + delay) { 21 DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
22 let visible = NSApp.windows.filter(\.isVisible) 22 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
24 if let view = window?.contentView?.superview, 24 if let view = window?.contentView?.superview,
25 let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) { 25 let bitmap = view.bitmapImageRepForCachingDisplay(in: view.bounds) {
26 view.cacheDisplay(in: view.bounds, to: bitmap) 26 view.cacheDisplay(in: view.bounds, to: bitmap)
Sources/Orgstar/OrgstarApp.swift +102 −46
@@ -261,59 +261,115 @@ struct SettingsView: View {
261 @AppStorage("reminders") private var reminders = true 261 @AppStorage("reminders") private var reminders = true
262 @AppStorage("reminderLead") private var reminderLead = 12 262 @AppStorage("reminderLead") private var reminderLead = 12
263 @AppStorage("globalCapture") private var globalCapture = true 263 @AppStorage("globalCapture") private var globalCapture = true
264 @AppStorage("settingsTab") private var tab = "general"
264 265
265 var body: some View { 266 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 {
266 Form { 277 Form {
267 Picker("Save files:", selection: $saveMode) { 278 Section {
268 Text("Automatically, when typing stops").tag(SaveMode.automatic) 279 Picker("Save files", selection: $saveMode) {
269 Text("Only with File ▸ Save (⌘S)").tag(SaveMode.explicit) 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)
270 } 315 }
271 .pickerStyle(.radioGroup) 316 Section {
272 Picker("Keys:", selection: $preset) { 317 Toggle("Emacs hides emphasis markers (org-hide-emphasis-markers)", isOn: $hiddenMarkers)
273 ForEach(KeymapPreset.allCases, id: \.self) { Text($0.title).tag($0) } 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()
274 } 322 }
275 Picker("Option as Meta:", selection: $optionAsMeta) { 323 Section {
276 Text("Left Option").tag(OptionAsMeta.left.rawValue) 324 TextField("Default TODO keywords", text: $todoKeywords)
277 Text("Right Option").tag(OptionAsMeta.right.rawValue) 325 } footer: {
278 Text("Both").tag(OptionAsMeta([.left, .right]).rawValue) 326 Text("For files without #+TODO, in #+TODO syntax. Takes effect when Orgstar restarts.")
279 Text("Neither").tag(0) 327 .settingsFooter()
280 } 328 }
281 Text("Your own bindings go in \(KeymapLoader.userFile.path), on top of the preset.") 329 }
282 .font(.caption) 330 .formStyle(.grouped)
283 .foregroundStyle(.secondary) 331 .frame(height: 450)
284 .textSelection(.enabled) 332 }
285 Divider() 333
286 Picker("Tags:", selection: $tagsColumn) { 334 private var agenda: some View {
287 Text("Aligned to end at column 77").tag(-77) 335 Form {
288 Text("One space after the title").tag(0) 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()
289 } 348 }
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)
315 } 349 }
316 .padding(20) 350 .formStyle(.grouped)
317 .frame(width: 480) 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)
318 } 374 }
319} 375}