Files
andros d2ab6ea377 Add color theme system with 6 built-in themes; fix modal sheet theming
New files: AppTheme (palette + env key), ThemeManager (Observable singleton,
UserDefaults persistence). Themes: Default, Emacs, Dracula, One Dark,
Monokai, Material. Each controls background, accent, text, code block
colors and a highlight.js theme for syntax highlighting.

RootView applies preferredColorScheme, tint and appTheme env to the root
group. SettingsView applies the same modifiers to its NavigationStack so
the modal sheet is themed immediately (sheets run in a separate UIWindow
and don't inherit preferredColorScheme from the parent hierarchy).
Theme selection in Settings uses onTapGesture instead of buttonStyle(.plain)
inside Form, which was silently intercepting the button action.
CodeBlockView, TimelineView, NotificationsView all read from appTheme env.
2026-05-24 09:50:16 +02:00

136 lines
4.7 KiB
Swift

import SwiftUI
import OrgSocialKit
import Highlighter
// Single JS runtime shared across all cells initialisation is expensive.
private final class SharedHighlighter: @unchecked Sendable {
static let shared = SharedHighlighter()
let engine: Highlighter?
private init() { engine = Highlighter() }
}
/// Renders a single `OrgBlock` as a styled, optionally syntax-highlighted view.
///
/// - `.src(language:)` blocks are syntax-highlighted using highlight.js via
/// HighlighterSwift. The theme adapts to the current color scheme.
/// - `.quote` blocks are shown with a left accent border and secondary color.
/// - `.example` blocks are shown in monospace without highlighting.
struct CodeBlockView: View {
let block: OrgBlock
@Environment(\.colorScheme) private var colorScheme
@Environment(\.appTheme) private var theme
var body: some View {
switch block.kind {
case .src(let language):
srcBlock(language: language)
case .quote:
quoteBlock
case .example:
exampleBlock
}
}
// MARK: - SRC
private func srcBlock(language: String?) -> some View {
VStack(alignment: .leading, spacing: 0) {
if let lang = language, !lang.isEmpty {
Text(lang.lowercased())
.font(.caption2.weight(.medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 10)
.padding(.top, 8)
.padding(.bottom, 4)
}
HighlightedCodeView(code: block.content,
language: language,
colorScheme: colorScheme,
highlightTheme: theme.highlightTheme)
.padding(10)
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(theme.codeBackground, in: RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(theme.codeBorder.opacity(0.5)))
}
// MARK: - Quote
private var quoteBlock: some View {
HStack(alignment: .top, spacing: 0) {
Rectangle()
.fill(Color.accentColor.opacity(0.6))
.frame(width: 3)
Text(block.content)
.font(.body)
.italic()
.foregroundStyle(.secondary)
.padding(.horizontal, 10)
.padding(.vertical, 6)
}
.frame(maxWidth: .infinity, alignment: .leading)
.background(theme.codeBackground, in: RoundedRectangle(cornerRadius: 4))
}
// MARK: - Example
private var exampleBlock: some View {
Text(block.content)
.font(.system(.footnote, design: .monospaced))
.foregroundStyle(.secondary)
.padding(10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(theme.codeBackground, in: RoundedRectangle(cornerRadius: 8))
.overlay(RoundedRectangle(cornerRadius: 8).strokeBorder(theme.codeBorder.opacity(0.5)))
}
}
// MARK: - Highlighted code using HighlighterSwift
private struct HighlightedCodeView: View {
let code: String
let language: String?
let colorScheme: ColorScheme
let highlightTheme: String
@State private var highlighted: AttributedString? = nil
var body: some View {
Group {
if let highlighted {
Text(highlighted)
.font(.system(.footnote, design: .monospaced))
.frame(maxWidth: .infinity, alignment: .leading)
.textSelection(.enabled)
} else {
Text(code)
.font(.system(.footnote, design: .monospaced))
.foregroundStyle(.secondary)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.task(id: highlightTheme) { await highlight() }
}
private func highlight() async {
guard let h = SharedHighlighter.shared.engine else { return }
// Use the theme's highlight.js theme; fall back to color-scheme default.
let resolvedTheme = highlightTheme == "xcode" && colorScheme == .dark
? "atom-one-dark"
: highlightTheme
h.setTheme(resolvedTheme)
let lang = language?.lowercased().trimmingCharacters(in: .whitespaces)
let ns: NSAttributedString?
if let lang, !lang.isEmpty {
ns = h.highlight(code, as: lang)
} else {
ns = h.highlight(code, as: "plaintext") ?? h.highlight(code)
}
if let ns, let attr = try? AttributedString(ns, including: \.uiKit) {
highlighted = attr
}
}
}