d2ab6ea377
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.
136 lines
4.7 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|