krz/orgstar

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

Commit 427abbbee0

427abbbee0d20a70754de8e0e0846dae001f9c92

parent: bd76bdbef0

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-05 20:43 UTC

Theme the sidebar, outline, modeline, agenda and board

New theme colors: sidebar-background/-foreground/-header,
modeline-background/-foreground/-highlight, state-normal/-insert/
-visual, agenda-background/-date/-today/-time/-category/-deadline/
-upcoming/-scheduled/-scheduled-past and habit-clear/-ready/-alert/
-overdue. TODO keywords in the agenda and board take the theme's TODO,
DONE and [theme.todo] colors, tags its tags color. Backgrounds and
sidebar text are left to the system unless set, so the default theme
keeps the macOS look there.

Layout: unified · split

Sources/OrgPresentation/ThemeSpec.swift +40 −1
@@ -95,6 +95,33 @@ public struct ThemeSpec: Equatable, Sendable {
9595 ("syntax-number", "code: numbers, constants and escapes"),
9696 ("syntax-property", "code: properties, attributes and tags"),
9797 ("syntax-label", "code: labels"),
98 ("sidebar-background", "the folder sidebar and the outline; unset: the system's"),
99 ("sidebar-foreground", "file and heading names there; unset: the system's"),
100 ("sidebar-header", "folder names there; unset: the system's"),
101 ("modeline-background", "the modeline and message line; unset: the system's"),
102 ("modeline-foreground", "modeline text; unset: the system's"),
103 ("modeline-highlight", "the outline path and the clock in the modeline"),
104 ("state-normal", "the NORMAL tag"),
105 ("state-insert", "the INSERT tag"),
106 ("state-visual", "the VISUAL and V-LINE tags"),
107 ("agenda-background", "the agenda and board; unset: the system's"),
108 ("agenda-date", "agenda day headers"),
109 ("agenda-today", "today's header and the current time"),
110 ("agenda-time", "times and the time grid"),
111 ("agenda-category", "categories"),
112 ("agenda-deadline", "deadlines due"),
113 ("agenda-upcoming", "deadlines coming up"),
114 ("agenda-scheduled", "scheduled items"),
115 ("agenda-scheduled-past", "items scheduled on an earlier day"),
116 ("habit-clear", "habit graph: not due yet"),
117 ("habit-ready", "habit graph: due"),
118 ("habit-alert", "habit graph: due today, last chance"),
119 ("habit-overdue", "habit graph: overdue"),
120 ]
121
122 /// Keys the default theme leaves to the system, so windows keep the macOS look.
123 public static let systemDefaults: Set<String> = [
124 "sidebar-background", "sidebar-foreground", "sidebar-header", "modeline-background", "modeline-foreground", "agenda-background",
98125 ]
99126
100127 // MARK: - The default theme
@@ -117,6 +144,10 @@ public struct ThemeSpec: Equatable, Sendable {
117144 "line-number": "#8c959f", "line-number-current": "#1f2328",
118145 "syntax-keyword": "#cf222e", "syntax-string": "#0a3069", "syntax-comment": "#6e7781", "syntax-function": "#8250df",
119146 "syntax-type": "#953800", "syntax-number": "#0550ae", "syntax-property": "#0550ae", "syntax-label": "#8250df",
147 "modeline-highlight": "#1f2328", "state-normal": "#0969da", "state-insert": "#1a7f37", "state-visual": "#bc4c00",
148 "agenda-date": "#1f2328", "agenda-today": "#0969da", "agenda-time": "#6e7781", "agenda-category": "#6e7781",
149 "agenda-deadline": "#cf222e", "agenda-upcoming": "#bc4c00", "agenda-scheduled": "#6e7781", "agenda-scheduled-past": "#953800",
150 "habit-clear": "#54aeff", "habit-ready": "#4ac26b", "habit-alert": "#d4a72c", "habit-overdue": "#ff8182",
120151 ]),
121152 dark: palette([
122153 "background": "#0d1117", "foreground": "#e6edf3", "cursor": "#58a6ff", "selection": "#388bfd66",
@@ -129,6 +160,10 @@ public struct ThemeSpec: Equatable, Sendable {
129160 "line-number": "#6e7681", "line-number-current": "#e6edf3",
130161 "syntax-keyword": "#ff7b72", "syntax-string": "#a5d6ff", "syntax-comment": "#8b949e", "syntax-function": "#d2a8ff",
131162 "syntax-type": "#ffa657", "syntax-number": "#79c0ff", "syntax-property": "#79c0ff", "syntax-label": "#d2a8ff",
163 "modeline-highlight": "#e6edf3", "state-normal": "#1f6feb", "state-insert": "#238636", "state-visual": "#9e6a03",
164 "agenda-date": "#e6edf3", "agenda-today": "#58a6ff", "agenda-time": "#8b949e", "agenda-category": "#8b949e",
165 "agenda-deadline": "#ff7b72", "agenda-upcoming": "#ffa657", "agenda-scheduled": "#8b949e", "agenda-scheduled-past": "#d29922",
166 "habit-clear": "#1f6feb", "habit-ready": "#238636", "habit-alert": "#9e6a03", "habit-overdue": "#da3633",
132167 ])
133168 )
134169
@@ -191,7 +226,11 @@ public struct ThemeSpec: Equatable, Sendable {
191226 for (name, colors) in [("theme.light", ThemeSpec.default.light), ("theme.dark", ThemeSpec.default.dark)] {
192227 out += "\n[\(name)]\n"
193228 for slot in slots {
194 guard let color = colors[slot.key] else { continue }
229 guard let color = colors[slot.key] else {
230 let line = "# \(slot.key) = \"#rrggbb\""
231 out += line + String(repeating: " ", count: max(2, 36 - line.count)) + "# \(slot.description)\n"
232 continue
233 }
195234 let line = "\(slot.key) = \"\(color.hex)\""
196235 out += line + String(repeating: " ", count: max(2, 36 - line.count)) + "# \(slot.description)\n"
197236 }
Sources/Orgstar/AgendaView.swift +32 −22
@@ -1,5 +1,6 @@
11import OrgApp
22import OrgCore
3import OrgPresentation
34import SwiftUI
45
56/// The agenda window: the day/week agenda, the TODO list, tag matches and saved views.
@@ -74,6 +75,8 @@ struct AgendaView: View {
7475
7576 private var isMatch: Bool { viewName == Self.match || viewName == Self.todoMatch }
7677
78 @Environment(\.orgTheme) private var theme
79
7780 var body: some View {
7881 List(selection: $selection) {
7982 if isAgenda {
@@ -83,7 +86,7 @@ struct AgendaView: View {
8386 } header: {
8487 Text(day.header)
8588 .font(.headline)
86 .foregroundStyle(day.isToday ? Color.accentColor : .primary)
89 .foregroundStyle(day.isToday ? theme.color("agenda-today") ?? .accentColor : theme.color("agenda-date") ?? .primary)
8790 }
8891 }
8992 } else {
@@ -94,6 +97,7 @@ struct AgendaView: View {
9497 }
9598 }
9699 }
100 .themedListBackground(theme.color("agenda-background"))
97101 .contextMenu(forSelectionType: String.self) { _ in
98102 } primaryAction: { ids in
99103 if let id = ids.first { jump(id) }
@@ -318,6 +322,7 @@ struct AgendaRow: View {
318322 let item: AgendaItem
319323 /// The agenda day the row is on, for habit graphs.
320324 var day: Int?
325 @Environment(\.orgTheme) private var theme
321326
322327 var body: some View {
323328 switch item.kind {
@@ -329,25 +334,26 @@ struct AgendaRow: View {
329334 VStack { Divider() }
330335 }
331336 .font(.caption)
332 .foregroundStyle(.tertiary)
337 .foregroundStyle(theme.color("agenda-time").map(AnyShapeStyle.init) ?? AnyShapeStyle(.tertiary))
333338 case .currentTime:
334339 HStack(spacing: 8) {
335340 Text("now " + Self.trim(item.time))
336341 .monospacedDigit()
337342 .frame(width: 140, alignment: .trailing)
338 Rectangle().fill(Color.accentColor).frame(height: 1)
343 Rectangle().fill(theme.color("agenda-today") ?? .accentColor).frame(height: 1)
339344 }
340345 .font(.caption)
341 .foregroundStyle(Color.accentColor)
346 .foregroundStyle(theme.color("agenda-today") ?? .accentColor)
342347 default:
343348 HStack(spacing: 8) {
344349 Text(item.category)
345350 .lineLimit(1)
346 .foregroundStyle(.secondary)
351 .foregroundStyle(theme.color("agenda-category") ?? .secondary)
347352 .frame(width: 80, alignment: .leading)
348353 if item.kind != .todo, item.kind != .tagsMatch {
349354 Text(Self.trim(item.time))
350355 .monospacedDigit()
356 .foregroundStyle(theme.color("agenda-time") ?? .primary)
351357 .frame(width: 80, alignment: .leading)
352358 }
353359 if !item.extra.isEmpty {
@@ -359,12 +365,12 @@ struct AgendaRow: View {
359365 .lineLimit(1)
360366 Spacer(minLength: 8)
361367 if let habit = item.habit, let day {
362 HabitGraph(cells: habit.graph(today: day))
368 HabitGraph(cells: habit.graph(today: day), theme: theme)
363369 }
364370 if !item.tags.isEmpty {
365371 Text(":" + item.tags.joined(separator: ":") + ":")
366372 .font(.caption)
367 .foregroundStyle(.secondary)
373 .foregroundStyle(theme.color("tags") ?? .secondary)
368374 }
369375 }
370376 }
@@ -376,10 +382,10 @@ struct AgendaRow: View {
376382
377383 private var leaderColor: Color {
378384 switch item.kind {
379 case .deadline: .red
380 case .upcomingDeadline: .orange
381 case .pastScheduled: .brown
382 default: .secondary
385 case .deadline: theme.color("agenda-deadline") ?? .red
386 case .upcomingDeadline: theme.color("agenda-upcoming") ?? .orange
387 case .pastScheduled: theme.color("agenda-scheduled-past") ?? .brown
388 default: theme.color("agenda-scheduled") ?? .secondary
383389 }
384390 }
385391
@@ -388,13 +394,15 @@ struct AgendaRow: View {
388394 let text = item.text.trimmingCharacters(in: .whitespaces)
389395 guard let todo = item.todo, text.hasPrefix(todo + " ") else { return Text(text) }
390396 let rest = String(text.dropFirst(todo.count))
391 return Text(todo).foregroundColor(item.isDone ? .green : .red).bold() + Text(rest).foregroundColor(item.isDone ? .secondary : .primary)
397 let rest_ = Text(rest).foregroundColor(item.isDone ? theme.color("comment") ?? .secondary : theme.color("foreground") ?? .primary)
398 return Text(todo).foregroundColor(theme.keywordColor(todo, done: item.isDone)).bold() + rest_
392399 }
393400}
394401
395402/// org-habit's consistency graph: a cell per day, `*` where the habit was done, `!` today.
396403struct HabitGraph: View {
397404 let cells: [Habit.Cell]
405 var theme: ThemeSpec = .default
398406
399407 var body: some View {
400408 HStack(spacing: 1) {
@@ -402,7 +410,7 @@ struct HabitGraph: View {
402410 Text(String(cell.glyph))
403411 .font(.system(size: 9, weight: .bold, design: .monospaced))
404412 .frame(width: 8, height: 14)
405 .background(Self.color(cell.face))
413 .background(color(cell.face))
406414 .help(Self.label(cell))
407415 }
408416 }
@@ -410,16 +418,18 @@ struct HabitGraph: View {
410418 .accessibilityLabel("Done \(cells.filter { $0.glyph == "*" }.count) of the last \(Habit.precedingDays) days")
411419 }
412420
413 static func color(_ face: Habit.Face) -> Color {
421 /// Due-state colors, lighter for days to come, as org-habit's faces.
422 func color(_ face: Habit.Face) -> Color {
423 func themed(_ key: String, _ fallback: Color) -> Color { theme.color(key) ?? fallback }
414424 switch face {
415 case .clear: .blue.opacity(0.6)
416 case .clearFuture: .blue.opacity(0.3)
417 case .ready: .green.opacity(0.7)
418 case .readyFuture: .green.opacity(0.35)
419 case .alert: .yellow.opacity(0.8)
420 case .alertFuture: .yellow.opacity(0.4)
421 case .overdue: .red.opacity(0.7)
422 case .overdueFuture: .red.opacity(0.35)
425 case .clear: return themed("habit-clear", .blue).opacity(0.7)
426 case .clearFuture: return themed("habit-clear", .blue).opacity(0.35)
427 case .ready: return themed("habit-ready", .green).opacity(0.75)
428 case .readyFuture: return themed("habit-ready", .green).opacity(0.35)
429 case .alert: return themed("habit-alert", .yellow).opacity(0.85)
430 case .alertFuture: return themed("habit-alert", .yellow).opacity(0.4)
431 case .overdue: return themed("habit-overdue", .red).opacity(0.75)
432 case .overdueFuture: return themed("habit-overdue", .red).opacity(0.35)
423433 }
424434 }
425435
Sources/Orgstar/BoardView.swift +5 −2
@@ -1,5 +1,6 @@
11import OrgApp
22import OrgCore
3import OrgPresentation
34import SwiftUI
45import UniformTypeIdentifiers
56
@@ -22,6 +23,7 @@ struct BoardView: View {
2223 @AppStorage("boardColumns") private var columnText = "EFFORT"
2324 @AppStorage("agendaSubfolders") private var includeSubfolders = false
2425 @Environment(\.openWindow) private var openWindow
26 @Environment(\.orgTheme) private var theme
2527
2628 init(workspace: WorkspaceModel, session: DocumentSession, commands: AppCommands) {
2729 self.workspace = workspace
@@ -88,7 +90,7 @@ struct BoardView: View {
8890 private var table: some View {
8991 Table(rows, selection: $selection, sortOrder: $sortOrder) {
9092 TableColumn("TODO", value: \.todoOrder) { row in
91 Text(row.entry.todo ?? "").foregroundStyle(row.entry.isDone ? .green : .red).bold()
93 Text(row.entry.todo ?? "").foregroundStyle(theme.keywordColor(row.entry.todo ?? "", done: row.entry.isDone)).bold()
9294 }
9395 .width(min: 50, ideal: 60, max: 90)
9496 TableColumn("P", value: \.priority) { row in Text(row.priority) }
@@ -134,13 +136,14 @@ struct BoardView: View {
134136 }
135137 .padding(12)
136138 }
139 .background(theme.color("agenda-background") ?? .clear)
137140 }
138141
139142 private func column(_ keyword: String) -> some View {
140143 let cards = entries.filter { $0.todo == keyword }
141144 return VStack(alignment: .leading, spacing: 8) {
142145 HStack {
143 Text(keyword).font(.headline)
146 Text(keyword).font(.headline).foregroundStyle(theme.keywordColor(keyword, done: cards.first?.isDone ?? false))
144147 Text("\(cards.count)").foregroundStyle(.secondary)
145148 }
146149 ScrollView {
Sources/Orgstar/ContentView.swift +7 −1
@@ -468,6 +468,7 @@ struct Sidebar: View {
468468 @AppStorage("collapsedFolders") private var collapsedText = ""
469469 @State private var removing: String?
470470 /// A file operation waiting for a name or a confirmation.
471 @Environment(\.orgTheme) private var theme
471472 @State var pending: FileOperation?
472473 @State var name = ""
473474 @State var failure: String?
@@ -490,18 +491,20 @@ struct Sidebar: View {
490491 Section(isExpanded: expanded(root)) {
491492 OutlineGroup(workspace.tree(forRoot: root).children ?? [], children: \.children) { node in
492493 Label(node.name, systemImage: icon(node))
493 .foregroundStyle(node.file?.kind == .conflict ? .orange : .primary)
494 .foregroundStyle(node.file?.kind == .conflict ? .orange : theme.color("sidebar-foreground") ?? .primary)
494495 .tag(node.file?.path)
495496 .contextMenu { fileMenu(node) }
496497 }
497498 } header: {
498499 Text((root as NSString).lastPathComponent)
500 .foregroundStyle(theme.color("sidebar-header") ?? .secondary)
499501 .help(root)
500502 .contextMenu { folderMenu(root) }
501503 }
502504 }
503505 }
504506 .listStyle(.sidebar)
507 .themedListBackground(theme.color("sidebar-background"))
505508 .safeAreaInset(edge: .bottom, spacing: 0) {
506509 HStack(spacing: 4) {
507510 Button("Add Folder…", systemImage: "plus", action: addFolder)
@@ -569,6 +572,7 @@ struct Sidebar: View {
569572
570573struct OutlineColumn: View {
571574 let session: DocumentSession
575 @Environment(\.orgTheme) private var theme
572576
573577 var body: some View {
574578 List(Array(session.outline.enumerated()), id: \.element.start) { _, entry in
@@ -576,11 +580,13 @@ struct OutlineColumn: View {
576580 session.reveal(entry.start)
577581 } label: {
578582 Text(entry.title.isEmpty ? "(untitled)" : entry.title)
583 .foregroundStyle(theme.color("sidebar-foreground") ?? .primary)
579584 .lineLimit(1)
580585 .padding(.leading, CGFloat(entry.level - 1) * 12)
581586 }
582587 .buttonStyle(.plain)
583588 }
589 .themedListBackground(theme.color("sidebar-background"))
584590 .overlay {
585591 if session.path != nil, !session.isOrg {
586592 ContentUnavailableView("No outline", systemImage: "doc.plaintext", description: Text("Only org files have an outline."))
Sources/Orgstar/Keys.swift +7 −4
@@ -12,6 +12,7 @@ struct KeyHint: Hashable {
1212/// short pause on a prefix, the keys that can follow it.
1313struct EchoArea: View {
1414 let commands: AppCommands
15 @Environment(\.orgTheme) private var theme
1516 @State private var showingHints = false
1617 @State private var answer = ""
1718 @FocusState private var answerFocused: Bool
@@ -80,7 +81,8 @@ struct EchoArea: View {
8081 .frame(maxWidth: .infinity, alignment: .leading)
8182 }
8283 }
83 .background(.bar)
84 .foregroundStyle(theme.color("modeline-foreground") ?? .primary)
85 .background { if let bg = theme.color("modeline-background") { bg } else { Rectangle().fill(.bar) } }
8486 .task(id: commands.pendingKeys) {
8587 showingHints = false
8688 guard commands.pendingKeys != nil else { return }
@@ -93,6 +95,7 @@ struct EchoArea: View {
9395/// The evil state, as Doom's modeline shows it.
9496struct StateTag: View {
9597 let state: String
98 @Environment(\.orgTheme) private var theme
9699
97100 var body: some View {
98101 Text(label)
@@ -113,9 +116,9 @@ struct StateTag: View {
113116
114117 private var color: Color {
115118 switch state {
116 case "insert": .green
117 case "visual", "visual-line": .orange
118 default: .blue
119 case "insert": theme.color("state-insert") ?? .green
120 case "visual", "visual-line": theme.color("state-visual") ?? .orange
121 default: theme.color("state-normal") ?? .blue
119122 }
120123 }
121124}
Sources/Orgstar/ModeLine.swift +5 −4
@@ -12,6 +12,7 @@ struct ModeLine: View {
1212 let clock: ClockModel
1313 /// Runs an app command (clock out, go to the clocked entry).
1414 let perform: (String) -> Void
15 @Environment(\.orgTheme) private var theme
1516 @State private var words: Int?
1617 @State private var crlf = false
1718 @State private var branch: String?
@@ -44,7 +45,7 @@ struct ModeLine: View {
4445 .menuStyle(.borderlessButton)
4546 .menuIndicator(.hidden)
4647 .fixedSize()
47 .foregroundStyle(.primary)
48 .foregroundStyle(theme.color("modeline-highlight") ?? .primary)
4849 .help("Clocked in since \(running.startedAt.formatted(date: .omitted, time: .shortened))")
4950 }
5051 if selection.length > 0, let region = region(text, selection) {
@@ -70,10 +71,10 @@ struct ModeLine: View {
7071 .font(.system(.caption, design: .monospaced))
7172 .monospacedDigit()
7273 .lineLimit(1)
73 .foregroundStyle(.secondary)
74 .foregroundStyle(theme.color("modeline-foreground") ?? .secondary)
7475 .padding(.horizontal, 10)
7576 .frame(height: 22)
76 .background(.bar)
77 .background { if let bg = theme.color("modeline-background") { bg } else { Rectangle().fill(.bar) } }
7778 .overlay(alignment: .top) { Divider() }
7879 .task(id: session.path) {
7980 // HEAD changes outside the app, so look again every few seconds.
@@ -104,7 +105,7 @@ struct ModeLine: View {
104105 if !path.isEmpty {
105106 Text(path.joined(separator: " › "))
106107 .truncationMode(.head)
107 .foregroundStyle(.primary)
108 .foregroundStyle(theme.color("modeline-highlight") ?? .primary)
108109 .help(path.joined(separator: " › "))
109110 }
110111 }
Sources/Orgstar/OrgstarApp.swift +3
@@ -161,6 +161,7 @@ struct OrgstarApp: App {
161161 switch workspace {
162162 case .success(let model):
163163 ContentView(workspace: model, session: session, commands: commands, scheduler: scheduler, clock: clock)
164 .environment(\.orgTheme, ConfigSync.shared.theme)
164165 .onAppear {
165166 delegate.session = session
166167 delegate.commands = commands
@@ -173,12 +174,14 @@ struct OrgstarApp: App {
173174 Window("Agenda", id: "agenda") {
174175 if case .success(let model) = workspace {
175176 AgendaView(workspace: model, session: session, commands: commands, scheduler: scheduler)
177 .environment(\.orgTheme, ConfigSync.shared.theme)
176178 }
177179 }
178180 .defaultSize(width: 760, height: 720)
179181 Window("Board", id: "board") {
180182 if case .success(let model) = workspace {
181183 BoardView(workspace: model, session: session, commands: commands)
184 .environment(\.orgTheme, ConfigSync.shared.theme)
182185 }
183186 }
184187 .defaultSize(width: 1100, height: 700)
Sources/Orgstar/ThemeColors.swift added +37
@@ -0,0 +1,37 @@
1import AppKit
2import OrgPresentation
3import SwiftUI
4
5extension EnvironmentValues {
6 /// The theme's colors, for the views around the editor.
7 @Entry var orgTheme: ThemeSpec = .default
8}
9
10extension ThemeSpec {
11 /// A color that follows the appearance, or nil when the theme leaves `key` to the system.
12 func color(_ key: String) -> Color? {
13 guard let light = self.light[key] ?? self.dark[key], let dark = self.dark[key] ?? self.light[key] else { return nil }
14 return Color(nsColor: NSColor(name: nil) { appearance in
15 let c = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua ? dark : light
16 return NSColor(srgbRed: c.red, green: c.green, blue: c.blue, alpha: c.alpha)
17 })
18 }
19
20 /// A TODO keyword's color: its own from `[theme.todo]`, else the theme's TODO or DONE.
21 func keywordColor(_ keyword: String, done: Bool) -> Color {
22 if let c = todo[keyword] { return Color(.sRGB, red: c.red, green: c.green, blue: c.blue, opacity: c.alpha) }
23 return color(done ? "done" : "todo") ?? (done ? .green : .red)
24 }
25}
26
27extension View {
28 /// A list's background from the theme, or the system's when the theme has none.
29 @ViewBuilder
30 func themedListBackground(_ color: Color?) -> some View {
31 if let color {
32 scrollContentBackground(.hidden).background(color)
33 } else {
34 self
35 }
36 }
37}
Tests/OrgPresentationTests/PresentationTests.swift +3 −2
@@ -156,7 +156,8 @@ struct ThemeSpecTests {
156156 let (spec, problems) = ThemeSpec.reading(try TOML.parse(ThemeSpec.defaultThemeFile()), over: ThemeSpec(light: [:], dark: [:]))
157157 #expect(problems.isEmpty)
158158 #expect(spec.light == ThemeSpec.default.light && spec.dark == ThemeSpec.default.dark)
159 #expect(Set(ThemeSpec.default.light.keys) == Set(ThemeSpec.slots.map(\.key)))
160 #expect(Set(ThemeSpec.default.dark.keys) == Set(ThemeSpec.slots.map(\.key)))
159 let defaulted = Set(ThemeSpec.slots.map(\.key)).subtracting(ThemeSpec.systemDefaults)
160 #expect(Set(ThemeSpec.default.light.keys) == defaulted)
161 #expect(Set(ThemeSpec.default.dark.keys) == defaulted)
161162 }
162163}