Theme the sidebar, outline, modeline, agenda and board !51

merged merged by cmc on 2026-10-05 20:43 UTC · krz/orgstar:theme-chrome into main

9 files changed, +139 −36

Layout: unified · split

Sources/OrgPresentation/ThemeSpec.swift +40 −1
@@ -95,6 +95,33 @@ public struct ThemeSpec: Equatable, Sendable {
95 ("syntax-number", "code: numbers, constants and escapes"), 95 ("syntax-number", "code: numbers, constants and escapes"),
96 ("syntax-property", "code: properties, attributes and tags"), 96 ("syntax-property", "code: properties, attributes and tags"),
97 ("syntax-label", "code: labels"), 97 ("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",
98 ] 125 ]
99 126
100 // MARK: - The default theme 127 // MARK: - The default theme
@@ -117,6 +144,10 @@ public struct ThemeSpec: Equatable, Sendable {
117 "line-number": "#8c959f", "line-number-current": "#1f2328", 144 "line-number": "#8c959f", "line-number-current": "#1f2328",
118 "syntax-keyword": "#cf222e", "syntax-string": "#0a3069", "syntax-comment": "#6e7781", "syntax-function": "#8250df", 145 "syntax-keyword": "#cf222e", "syntax-string": "#0a3069", "syntax-comment": "#6e7781", "syntax-function": "#8250df",
119 "syntax-type": "#953800", "syntax-number": "#0550ae", "syntax-property": "#0550ae", "syntax-label": "#8250df", 146 "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",
120 ]), 151 ]),
121 dark: palette([ 152 dark: palette([
122 "background": "#0d1117", "foreground": "#e6edf3", "cursor": "#58a6ff", "selection": "#388bfd66", 153 "background": "#0d1117", "foreground": "#e6edf3", "cursor": "#58a6ff", "selection": "#388bfd66",
@@ -129,6 +160,10 @@ public struct ThemeSpec: Equatable, Sendable {
129 "line-number": "#6e7681", "line-number-current": "#e6edf3", 160 "line-number": "#6e7681", "line-number-current": "#e6edf3",
130 "syntax-keyword": "#ff7b72", "syntax-string": "#a5d6ff", "syntax-comment": "#8b949e", "syntax-function": "#d2a8ff", 161 "syntax-keyword": "#ff7b72", "syntax-string": "#a5d6ff", "syntax-comment": "#8b949e", "syntax-function": "#d2a8ff",
131 "syntax-type": "#ffa657", "syntax-number": "#79c0ff", "syntax-property": "#79c0ff", "syntax-label": "#d2a8ff", 162 "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",
132 ]) 167 ])
133 ) 168 )
134 169
@@ -191,7 +226,11 @@ public struct ThemeSpec: Equatable, Sendable {
191 for (name, colors) in [("theme.light", ThemeSpec.default.light), ("theme.dark", ThemeSpec.default.dark)] { 226 for (name, colors) in [("theme.light", ThemeSpec.default.light), ("theme.dark", ThemeSpec.default.dark)] {
192 out += "\n[\(name)]\n" 227 out += "\n[\(name)]\n"
193 for slot in slots { 228 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 }
195 let line = "\(slot.key) = \"\(color.hex)\"" 234 let line = "\(slot.key) = \"\(color.hex)\""
196 out += line + String(repeating: " ", count: max(2, 36 - line.count)) + "# \(slot.description)\n" 235 out += line + String(repeating: " ", count: max(2, 36 - line.count)) + "# \(slot.description)\n"
197 } 236 }
Sources/Orgstar/AgendaView.swift +32 −22
@@ -1,5 +1,6 @@
1import OrgApp 1import OrgApp
2import OrgCore 2import OrgCore
3import OrgPresentation
3import SwiftUI 4import SwiftUI
4 5
5/// The agenda window: the day/week agenda, the TODO list, tag matches and saved views. 6/// The agenda window: the day/week agenda, the TODO list, tag matches and saved views.
@@ -74,6 +75,8 @@ struct AgendaView: View {
74 75
75 private var isMatch: Bool { viewName == Self.match || viewName == Self.todoMatch } 76 private var isMatch: Bool { viewName == Self.match || viewName == Self.todoMatch }
76 77
78 @Environment(\.orgTheme) private var theme
79
77 var body: some View { 80 var body: some View {
78 List(selection: $selection) { 81 List(selection: $selection) {
79 if isAgenda { 82 if isAgenda {
@@ -83,7 +86,7 @@ struct AgendaView: View {
83 } header: { 86 } header: {
84 Text(day.header) 87 Text(day.header)
85 .font(.headline) 88 .font(.headline)
86 .foregroundStyle(day.isToday ? Color.accentColor : .primary) 89 .foregroundStyle(day.isToday ? theme.color("agenda-today") ?? .accentColor : theme.color("agenda-date") ?? .primary)
87 } 90 }
88 } 91 }
89 } else { 92 } else {
@@ -94,6 +97,7 @@ struct AgendaView: View {
94 } 97 }
95 } 98 }
96 } 99 }
100 .themedListBackground(theme.color("agenda-background"))
97 .contextMenu(forSelectionType: String.self) { _ in 101 .contextMenu(forSelectionType: String.self) { _ in
98 } primaryAction: { ids in 102 } primaryAction: { ids in
99 if let id = ids.first { jump(id) } 103 if let id = ids.first { jump(id) }
@@ -318,6 +322,7 @@ struct AgendaRow: View {
318 let item: AgendaItem 322 let item: AgendaItem
319 /// The agenda day the row is on, for habit graphs. 323 /// The agenda day the row is on, for habit graphs.
320 var day: Int? 324 var day: Int?
325 @Environment(\.orgTheme) private var theme
321 326
322 var body: some View { 327 var body: some View {
323 switch item.kind { 328 switch item.kind {
@@ -329,25 +334,26 @@ struct AgendaRow: View {
329 VStack { Divider() } 334 VStack { Divider() }
330 } 335 }
331 .font(.caption) 336 .font(.caption)
332 .foregroundStyle(.tertiary) 337 .foregroundStyle(theme.color("agenda-time").map(AnyShapeStyle.init) ?? AnyShapeStyle(.tertiary))
333 case .currentTime: 338 case .currentTime:
334 HStack(spacing: 8) { 339 HStack(spacing: 8) {
335 Text("now " + Self.trim(item.time)) 340 Text("now " + Self.trim(item.time))
336 .monospacedDigit() 341 .monospacedDigit()
337 .frame(width: 140, alignment: .trailing) 342 .frame(width: 140, alignment: .trailing)
338 Rectangle().fill(Color.accentColor).frame(height: 1) 343 Rectangle().fill(theme.color("agenda-today") ?? .accentColor).frame(height: 1)
339 } 344 }
340 .font(.caption) 345 .font(.caption)
341 .foregroundStyle(Color.accentColor) 346 .foregroundStyle(theme.color("agenda-today") ?? .accentColor)
342 default: 347 default:
343 HStack(spacing: 8) { 348 HStack(spacing: 8) {
344 Text(item.category) 349 Text(item.category)
345 .lineLimit(1) 350 .lineLimit(1)
346 .foregroundStyle(.secondary) 351 .foregroundStyle(theme.color("agenda-category") ?? .secondary)
347 .frame(width: 80, alignment: .leading) 352 .frame(width: 80, alignment: .leading)
348 if item.kind != .todo, item.kind != .tagsMatch { 353 if item.kind != .todo, item.kind != .tagsMatch {
349 Text(Self.trim(item.time)) 354 Text(Self.trim(item.time))
350 .monospacedDigit() 355 .monospacedDigit()
356 .foregroundStyle(theme.color("agenda-time") ?? .primary)
351 .frame(width: 80, alignment: .leading) 357 .frame(width: 80, alignment: .leading)
352 } 358 }
353 if !item.extra.isEmpty { 359 if !item.extra.isEmpty {
@@ -359,12 +365,12 @@ struct AgendaRow: View {
359 .lineLimit(1) 365 .lineLimit(1)
360 Spacer(minLength: 8) 366 Spacer(minLength: 8)
361 if let habit = item.habit, let day { 367 if let habit = item.habit, let day {
362 HabitGraph(cells: habit.graph(today: day)) 368 HabitGraph(cells: habit.graph(today: day), theme: theme)
363 } 369 }
364 if !item.tags.isEmpty { 370 if !item.tags.isEmpty {
365 Text(":" + item.tags.joined(separator: ":") + ":") 371 Text(":" + item.tags.joined(separator: ":") + ":")
366 .font(.caption) 372 .font(.caption)
367 .foregroundStyle(.secondary) 373 .foregroundStyle(theme.color("tags") ?? .secondary)
368 } 374 }
369 } 375 }
370 } 376 }
@@ -376,10 +382,10 @@ struct AgendaRow: View {
376 382
377 private var leaderColor: Color { 383 private var leaderColor: Color {
378 switch item.kind { 384 switch item.kind {
379 case .deadline: .red 385 case .deadline: theme.color("agenda-deadline") ?? .red
380 case .upcomingDeadline: .orange 386 case .upcomingDeadline: theme.color("agenda-upcoming") ?? .orange
381 case .pastScheduled: .brown 387 case .pastScheduled: theme.color("agenda-scheduled-past") ?? .brown
382 default: .secondary 388 default: theme.color("agenda-scheduled") ?? .secondary
383 } 389 }
384 } 390 }
385 391
@@ -388,13 +394,15 @@ struct AgendaRow: View {
388 let text = item.text.trimmingCharacters(in: .whitespaces) 394 let text = item.text.trimmingCharacters(in: .whitespaces)
389 guard let todo = item.todo, text.hasPrefix(todo + " ") else { return Text(text) } 395 guard let todo = item.todo, text.hasPrefix(todo + " ") else { return Text(text) }
390 let rest = String(text.dropFirst(todo.count)) 396 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_
392 } 399 }
393} 400}
394 401
395/// org-habit's consistency graph: a cell per day, `*` where the habit was done, `!` today. 402/// org-habit's consistency graph: a cell per day, `*` where the habit was done, `!` today.
396struct HabitGraph: View { 403struct HabitGraph: View {
397 let cells: [Habit.Cell] 404 let cells: [Habit.Cell]
405 var theme: ThemeSpec = .default
398 406
399 var body: some View { 407 var body: some View {
400 HStack(spacing: 1) { 408 HStack(spacing: 1) {
@@ -402,7 +410,7 @@ struct HabitGraph: View {
402 Text(String(cell.glyph)) 410 Text(String(cell.glyph))
403 .font(.system(size: 9, weight: .bold, design: .monospaced)) 411 .font(.system(size: 9, weight: .bold, design: .monospaced))
404 .frame(width: 8, height: 14) 412 .frame(width: 8, height: 14)
405 .background(Self.color(cell.face)) 413 .background(color(cell.face))
406 .help(Self.label(cell)) 414 .help(Self.label(cell))
407 } 415 }
408 } 416 }
@@ -410,16 +418,18 @@ struct HabitGraph: View {
410 .accessibilityLabel("Done \(cells.filter { $0.glyph == "*" }.count) of the last \(Habit.precedingDays) days") 418 .accessibilityLabel("Done \(cells.filter { $0.glyph == "*" }.count) of the last \(Habit.precedingDays) days")
411 } 419 }
412 420
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 }
414 switch face { 424 switch face {
415 case .clear: .blue.opacity(0.6) 425 case .clear: return themed("habit-clear", .blue).opacity(0.7)
416 case .clearFuture: .blue.opacity(0.3) 426 case .clearFuture: return themed("habit-clear", .blue).opacity(0.35)
417 case .ready: .green.opacity(0.7) 427 case .ready: return themed("habit-ready", .green).opacity(0.75)
418 case .readyFuture: .green.opacity(0.35) 428 case .readyFuture: return themed("habit-ready", .green).opacity(0.35)
419 case .alert: .yellow.opacity(0.8) 429 case .alert: return themed("habit-alert", .yellow).opacity(0.85)
420 case .alertFuture: .yellow.opacity(0.4) 430 case .alertFuture: return themed("habit-alert", .yellow).opacity(0.4)
421 case .overdue: .red.opacity(0.7) 431 case .overdue: return themed("habit-overdue", .red).opacity(0.75)
422 case .overdueFuture: .red.opacity(0.35) 432 case .overdueFuture: return themed("habit-overdue", .red).opacity(0.35)
423 } 433 }
424 } 434 }
425 435
Sources/Orgstar/BoardView.swift +5 −2
@@ -1,5 +1,6 @@
1import OrgApp 1import OrgApp
2import OrgCore 2import OrgCore
3import OrgPresentation
3import SwiftUI 4import SwiftUI
4import UniformTypeIdentifiers 5import UniformTypeIdentifiers
5 6
@@ -22,6 +23,7 @@ struct BoardView: View {
22 @AppStorage("boardColumns") private var columnText = "EFFORT" 23 @AppStorage("boardColumns") private var columnText = "EFFORT"
23 @AppStorage("agendaSubfolders") private var includeSubfolders = false 24 @AppStorage("agendaSubfolders") private var includeSubfolders = false
24 @Environment(\.openWindow) private var openWindow 25 @Environment(\.openWindow) private var openWindow
26 @Environment(\.orgTheme) private var theme
25 27
26 init(workspace: WorkspaceModel, session: DocumentSession, commands: AppCommands) { 28 init(workspace: WorkspaceModel, session: DocumentSession, commands: AppCommands) {
27 self.workspace = workspace 29 self.workspace = workspace
@@ -88,7 +90,7 @@ struct BoardView: View {
88 private var table: some View { 90 private var table: some View {
89 Table(rows, selection: $selection, sortOrder: $sortOrder) { 91 Table(rows, selection: $selection, sortOrder: $sortOrder) {
90 TableColumn("TODO", value: \.todoOrder) { row in 92 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()
92 } 94 }
93 .width(min: 50, ideal: 60, max: 90) 95 .width(min: 50, ideal: 60, max: 90)
94 TableColumn("P", value: \.priority) { row in Text(row.priority) } 96 TableColumn("P", value: \.priority) { row in Text(row.priority) }
@@ -134,13 +136,14 @@ struct BoardView: View {
134 } 136 }
135 .padding(12) 137 .padding(12)
136 } 138 }
139 .background(theme.color("agenda-background") ?? .clear)
137 } 140 }
138 141
139 private func column(_ keyword: String) -> some View { 142 private func column(_ keyword: String) -> some View {
140 let cards = entries.filter { $0.todo == keyword } 143 let cards = entries.filter { $0.todo == keyword }
141 return VStack(alignment: .leading, spacing: 8) { 144 return VStack(alignment: .leading, spacing: 8) {
142 HStack { 145 HStack {
143 Text(keyword).font(.headline) 146 Text(keyword).font(.headline).foregroundStyle(theme.keywordColor(keyword, done: cards.first?.isDone ?? false))
144 Text("\(cards.count)").foregroundStyle(.secondary) 147 Text("\(cards.count)").foregroundStyle(.secondary)
145 } 148 }
146 ScrollView { 149 ScrollView {
Sources/Orgstar/ContentView.swift +7 −1
@@ -468,6 +468,7 @@ struct Sidebar: View {
468 @AppStorage("collapsedFolders") private var collapsedText = "" 468 @AppStorage("collapsedFolders") private var collapsedText = ""
469 @State private var removing: String? 469 @State private var removing: String?
470 /// A file operation waiting for a name or a confirmation. 470 /// A file operation waiting for a name or a confirmation.
471 @Environment(\.orgTheme) private var theme
471 @State var pending: FileOperation? 472 @State var pending: FileOperation?
472 @State var name = "" 473 @State var name = ""
473 @State var failure: String? 474 @State var failure: String?
@@ -490,18 +491,20 @@ struct Sidebar: View {
490 Section(isExpanded: expanded(root)) { 491 Section(isExpanded: expanded(root)) {
491 OutlineGroup(workspace.tree(forRoot: root).children ?? [], children: \.children) { node in 492 OutlineGroup(workspace.tree(forRoot: root).children ?? [], children: \.children) { node in
492 Label(node.name, systemImage: icon(node)) 493 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)
494 .tag(node.file?.path) 495 .tag(node.file?.path)
495 .contextMenu { fileMenu(node) } 496 .contextMenu { fileMenu(node) }
496 } 497 }
497 } header: { 498 } header: {
498 Text((root as NSString).lastPathComponent) 499 Text((root as NSString).lastPathComponent)
500 .foregroundStyle(theme.color("sidebar-header") ?? .secondary)
499 .help(root) 501 .help(root)
500 .contextMenu { folderMenu(root) } 502 .contextMenu { folderMenu(root) }
501 } 503 }
502 } 504 }
503 } 505 }
504 .listStyle(.sidebar) 506 .listStyle(.sidebar)
507 .themedListBackground(theme.color("sidebar-background"))
505 .safeAreaInset(edge: .bottom, spacing: 0) { 508 .safeAreaInset(edge: .bottom, spacing: 0) {
506 HStack(spacing: 4) { 509 HStack(spacing: 4) {
507 Button("Add Folder…", systemImage: "plus", action: addFolder) 510 Button("Add Folder…", systemImage: "plus", action: addFolder)
@@ -569,6 +572,7 @@ struct Sidebar: View {
569 572
570struct OutlineColumn: View { 573struct OutlineColumn: View {
571 let session: DocumentSession 574 let session: DocumentSession
575 @Environment(\.orgTheme) private var theme
572 576
573 var body: some View { 577 var body: some View {
574 List(Array(session.outline.enumerated()), id: \.element.start) { _, entry in 578 List(Array(session.outline.enumerated()), id: \.element.start) { _, entry in
@@ -576,11 +580,13 @@ struct OutlineColumn: View {
576 session.reveal(entry.start) 580 session.reveal(entry.start)
577 } label: { 581 } label: {
578 Text(entry.title.isEmpty ? "(untitled)" : entry.title) 582 Text(entry.title.isEmpty ? "(untitled)" : entry.title)
583 .foregroundStyle(theme.color("sidebar-foreground") ?? .primary)
579 .lineLimit(1) 584 .lineLimit(1)
580 .padding(.leading, CGFloat(entry.level - 1) * 12) 585 .padding(.leading, CGFloat(entry.level - 1) * 12)
581 } 586 }
582 .buttonStyle(.plain) 587 .buttonStyle(.plain)
583 } 588 }
589 .themedListBackground(theme.color("sidebar-background"))
584 .overlay { 590 .overlay {
585 if session.path != nil, !session.isOrg { 591 if session.path != nil, !session.isOrg {
586 ContentUnavailableView("No outline", systemImage: "doc.plaintext", description: Text("Only org files have an outline.")) 592 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 {
12/// short pause on a prefix, the keys that can follow it. 12/// short pause on a prefix, the keys that can follow it.
13struct EchoArea: View { 13struct EchoArea: View {
14 let commands: AppCommands 14 let commands: AppCommands
15 @Environment(\.orgTheme) private var theme
15 @State private var showingHints = false 16 @State private var showingHints = false
16 @State private var answer = "" 17 @State private var answer = ""
17 @FocusState private var answerFocused: Bool 18 @FocusState private var answerFocused: Bool
@@ -80,7 +81,8 @@ struct EchoArea: View {
80 .frame(maxWidth: .infinity, alignment: .leading) 81 .frame(maxWidth: .infinity, alignment: .leading)
81 } 82 }
82 } 83 }
83 .background(.bar) 84 .foregroundStyle(theme.color("modeline-foreground") ?? .primary)
85 .background { if let bg = theme.color("modeline-background") { bg } else { Rectangle().fill(.bar) } }
84 .task(id: commands.pendingKeys) { 86 .task(id: commands.pendingKeys) {
85 showingHints = false 87 showingHints = false
86 guard commands.pendingKeys != nil else { return } 88 guard commands.pendingKeys != nil else { return }
@@ -93,6 +95,7 @@ struct EchoArea: View {
93/// The evil state, as Doom's modeline shows it. 95/// The evil state, as Doom's modeline shows it.
94struct StateTag: View { 96struct StateTag: View {
95 let state: String 97 let state: String
98 @Environment(\.orgTheme) private var theme
96 99
97 var body: some View { 100 var body: some View {
98 Text(label) 101 Text(label)
@@ -113,9 +116,9 @@ struct StateTag: View {
113 116
114 private var color: Color { 117 private var color: Color {
115 switch state { 118 switch state {
116 case "insert": .green 119 case "insert": theme.color("state-insert") ?? .green
117 case "visual", "visual-line": .orange 120 case "visual", "visual-line": theme.color("state-visual") ?? .orange
118 default: .blue 121 default: theme.color("state-normal") ?? .blue
119 } 122 }
120 } 123 }
121} 124}
Sources/Orgstar/ModeLine.swift +5 −4
@@ -12,6 +12,7 @@ struct ModeLine: View {
12 let clock: ClockModel 12 let clock: ClockModel
13 /// Runs an app command (clock out, go to the clocked entry). 13 /// Runs an app command (clock out, go to the clocked entry).
14 let perform: (String) -> Void 14 let perform: (String) -> Void
15 @Environment(\.orgTheme) private var theme
15 @State private var words: Int? 16 @State private var words: Int?
16 @State private var crlf = false 17 @State private var crlf = false
17 @State private var branch: String? 18 @State private var branch: String?
@@ -44,7 +45,7 @@ struct ModeLine: View {
44 .menuStyle(.borderlessButton) 45 .menuStyle(.borderlessButton)
45 .menuIndicator(.hidden) 46 .menuIndicator(.hidden)
46 .fixedSize() 47 .fixedSize()
47 .foregroundStyle(.primary) 48 .foregroundStyle(theme.color("modeline-highlight") ?? .primary)
48 .help("Clocked in since \(running.startedAt.formatted(date: .omitted, time: .shortened))") 49 .help("Clocked in since \(running.startedAt.formatted(date: .omitted, time: .shortened))")
49 } 50 }
50 if selection.length > 0, let region = region(text, selection) { 51 if selection.length > 0, let region = region(text, selection) {
@@ -70,10 +71,10 @@ struct ModeLine: View {
70 .font(.system(.caption, design: .monospaced)) 71 .font(.system(.caption, design: .monospaced))
71 .monospacedDigit() 72 .monospacedDigit()
72 .lineLimit(1) 73 .lineLimit(1)
73 .foregroundStyle(.secondary) 74 .foregroundStyle(theme.color("modeline-foreground") ?? .secondary)
74 .padding(.horizontal, 10) 75 .padding(.horizontal, 10)
75 .frame(height: 22) 76 .frame(height: 22)
76 .background(.bar) 77 .background { if let bg = theme.color("modeline-background") { bg } else { Rectangle().fill(.bar) } }
77 .overlay(alignment: .top) { Divider() } 78 .overlay(alignment: .top) { Divider() }
78 .task(id: session.path) { 79 .task(id: session.path) {
79 // HEAD changes outside the app, so look again every few seconds. 80 // HEAD changes outside the app, so look again every few seconds.
@@ -104,7 +105,7 @@ struct ModeLine: View {
104 if !path.isEmpty { 105 if !path.isEmpty {
105 Text(path.joined(separator: " › ")) 106 Text(path.joined(separator: " › "))
106 .truncationMode(.head) 107 .truncationMode(.head)
107 .foregroundStyle(.primary) 108 .foregroundStyle(theme.color("modeline-highlight") ?? .primary)
108 .help(path.joined(separator: " › ")) 109 .help(path.joined(separator: " › "))
109 } 110 }
110 } 111 }
Sources/Orgstar/OrgstarApp.swift +3
@@ -161,6 +161,7 @@ struct OrgstarApp: App {
161 switch workspace { 161 switch workspace {
162 case .success(let model): 162 case .success(let model):
163 ContentView(workspace: model, session: session, commands: commands, scheduler: scheduler, clock: clock) 163 ContentView(workspace: model, session: session, commands: commands, scheduler: scheduler, clock: clock)
164 .environment(\.orgTheme, ConfigSync.shared.theme)
164 .onAppear { 165 .onAppear {
165 delegate.session = session 166 delegate.session = session
166 delegate.commands = commands 167 delegate.commands = commands
@@ -173,12 +174,14 @@ struct OrgstarApp: App {
173 Window("Agenda", id: "agenda") { 174 Window("Agenda", id: "agenda") {
174 if case .success(let model) = workspace { 175 if case .success(let model) = workspace {
175 AgendaView(workspace: model, session: session, commands: commands, scheduler: scheduler) 176 AgendaView(workspace: model, session: session, commands: commands, scheduler: scheduler)
177 .environment(\.orgTheme, ConfigSync.shared.theme)
176 } 178 }
177 } 179 }
178 .defaultSize(width: 760, height: 720) 180 .defaultSize(width: 760, height: 720)
179 Window("Board", id: "board") { 181 Window("Board", id: "board") {
180 if case .success(let model) = workspace { 182 if case .success(let model) = workspace {
181 BoardView(workspace: model, session: session, commands: commands) 183 BoardView(workspace: model, session: session, commands: commands)
184 .environment(\.orgTheme, ConfigSync.shared.theme)
182 } 185 }
183 } 186 }
184 .defaultSize(width: 1100, height: 700) 187 .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 {
156 let (spec, problems) = ThemeSpec.reading(try TOML.parse(ThemeSpec.defaultThemeFile()), over: ThemeSpec(light: [:], dark: [:])) 156 let (spec, problems) = ThemeSpec.reading(try TOML.parse(ThemeSpec.defaultThemeFile()), over: ThemeSpec(light: [:], dark: [:]))
157 #expect(problems.isEmpty) 157 #expect(problems.isEmpty)
158 #expect(spec.light == ThemeSpec.default.light && spec.dark == ThemeSpec.default.dark) 158 #expect(spec.light == ThemeSpec.default.light && spec.dark == ThemeSpec.default.dark)
159 #expect(Set(ThemeSpec.default.light.keys) == Set(ThemeSpec.slots.map(\.key))) 159 let defaulted = Set(ThemeSpec.slots.map(\.key)).subtracting(ThemeSpec.systemDefaults)
160 #expect(Set(ThemeSpec.default.dark.keys) == Set(ThemeSpec.slots.map(\.key))) 160 #expect(Set(ThemeSpec.default.light.keys) == defaulted)
161 #expect(Set(ThemeSpec.default.dark.keys) == defaulted)
161 } 162 }
162} 163}