Theme the sidebar, outline, modeline, agenda and board !51
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 @@ | |||
| 1 | import OrgApp | 1 | import OrgApp |
| 2 | import OrgCore | 2 | import OrgCore |
| 3 | import OrgPresentation | ||
| 3 | import SwiftUI | 4 | import 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. |
| 396 | struct HabitGraph: View { | 403 | struct 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 @@ | |||
| 1 | import OrgApp | 1 | import OrgApp |
| 2 | import OrgCore | 2 | import OrgCore |
| 3 | import OrgPresentation | ||
| 3 | import SwiftUI | 4 | import SwiftUI |
| 4 | import UniformTypeIdentifiers | 5 | import 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 | ||
| 570 | struct OutlineColumn: View { | 573 | struct 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. |
| 13 | struct EchoArea: View { | 13 | struct 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. |
| 94 | struct StateTag: View { | 96 | struct 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 @@ | |||
| 1 | import AppKit | ||
| 2 | import OrgPresentation | ||
| 3 | import SwiftUI | ||
| 4 | |||
| 5 | extension EnvironmentValues { | ||
| 6 | /// The theme's colors, for the views around the editor. | ||
| 7 | @Entry var orgTheme: ThemeSpec = .default | ||
| 8 | } | ||
| 9 | |||
| 10 | extension 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 | |||
| 27 | extension 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 | } |