spb/zyquo-atlas Public License
The AI-native macOS web browser — every surface, intelligent.
Swift 75.2%
JavaScript 22%
Shell 2%
Makefile 0.9%
1//2// ThemeEngine.swift3// Zyquo Atlas4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//8// The customization engine (Phase 4.2 headline). Publishes the active theme,9// layout, and any user custom themes; every change applies live (SwiftUI10// re-renders because views observe this object). Persists per profile as JSON11// under the Atlas data root, imports/exports themes as small JSON files, and12// can follow the system light/dark appearance.13//1415import SwiftUI16import Combine1718@MainActor19final class ThemeEngine: ObservableObject {20 /// The active resolved theme (built-in or custom).21 @Published private(set) var theme: AtlasTheme22 /// Layout/typography preferences (top/left tabs, density, zoom…).23 @Published var layout: LayoutSettings24 /// User-created themes (editable, exportable).25 @Published private(set) var customThemes: [AtlasTheme]26 /// When true, the engine swaps between `lightThemeID`/`darkThemeID` to match27 /// the system appearance.28 @Published var matchSystemAppearance: Bool29 @Published var selectedThemeID: String30 @Published var lightThemeID: String31 @Published var darkThemeID: String3233 private let storeURL: URL34 private var saveTask: Task<Void, Never>?3536 init(profile: Profile = .defaultProfile) {37 let dir = PersistenceService.shared.rootDirectory38 storeURL = dir.appendingPathComponent("theme-\(profile.id.uuidString).json")3940 // Defaults.41 var restored = Persisted()42 if let data = try? Data(contentsOf: storeURL),43 let decoded = try? JSONDecoder().decode(Persisted.self, from: data) {44 restored = decoded45 }46 layout = restored.layout47 customThemes = restored.customThemes48 matchSystemAppearance = restored.matchSystemAppearance49 selectedThemeID = restored.selectedThemeID50 lightThemeID = restored.lightThemeID51 darkThemeID = restored.darkThemeID5253 let all = BuiltInThemes.all + restored.customThemes54 theme = all.first { $0.id == restored.selectedThemeID } ?? BuiltInThemes.atlasLight55 resolveActive()56 }5758 // MARK: - Catalog5960 var gallery: [AtlasTheme] { BuiltInThemes.all + customThemes }6162 func theme(id: String) -> AtlasTheme? { gallery.first { $0.id == id } }6364 // MARK: - Selection6566 func select(_ id: String) {67 selectedThemeID = id68 if let t = theme(id: id) {69 if t.appearance == .dark { darkThemeID = id } else { lightThemeID = id }70 }71 matchSystemAppearance = false72 resolveActive()73 persist()74 }7576 /// Called when the system appearance changes (from the view layer).77 func systemAppearanceChanged(toDark: Bool) {78 guard matchSystemAppearance else { return }79 selectedThemeID = toDark ? darkThemeID : lightThemeID80 resolveActive()81 }8283 func setMatchSystem(_ on: Bool, systemIsDark: Bool) {84 matchSystemAppearance = on85 if on { selectedThemeID = systemIsDark ? darkThemeID : lightThemeID }86 resolveActive()87 persist()88 }8990 // MARK: - Layout9192 func updateLayout(_ mutate: (inout LayoutSettings) -> Void) {93 mutate(&layout)94 persist()95 }9697 // MARK: - Custom themes9899 /// Creates an editable copy of a theme and selects it.100 @discardableResult101 func duplicateForEditing(_ base: AtlasTheme, name: String) -> AtlasTheme {102 var copy = base103 copy.id = "custom-\(UUID().uuidString.prefix(8))"104 copy.name = name105 customThemes.append(copy)106 select(copy.id)107 return copy108 }109110 /// Updates an existing custom theme in place (live editor).111 func updateCustom(_ theme: AtlasTheme) {112 guard let idx = customThemes.firstIndex(where: { $0.id == theme.id }) else { return }113 customThemes[idx] = theme114 if selectedThemeID == theme.id { self.theme = theme }115 persist()116 }117118 func deleteCustom(_ id: String) {119 customThemes.removeAll { $0.id == id }120 if selectedThemeID == id { select(BuiltInThemes.defaultLightID) }121 persist()122 }123124 // MARK: - Import / export125126 func exportTheme(_ theme: AtlasTheme, to url: URL) throws {127 let encoder = JSONEncoder()128 encoder.outputFormatting = [.prettyPrinted, .sortedKeys]129 try encoder.encode(theme).write(to: url, options: .atomic)130 }131132 @discardableResult133 func importTheme(from url: URL) throws -> AtlasTheme {134 var t = try JSONDecoder().decode(AtlasTheme.self, from: Data(contentsOf: url))135 // Give imported themes a fresh custom id to avoid collisions.136 if BuiltInThemes.theme(id: t.id) != nil || customThemes.contains(where: { $0.id == t.id }) {137 t.id = "custom-\(UUID().uuidString.prefix(8))"138 }139 customThemes.append(t)140 select(t.id)141 return t142 }143144 // MARK: - Internals145146 private func resolveActive() {147 theme = theme(id: selectedThemeID) ?? BuiltInThemes.atlasLight148 }149150 private func persist() {151 let snapshot = Persisted(152 layout: layout, customThemes: customThemes,153 matchSystemAppearance: matchSystemAppearance,154 selectedThemeID: selectedThemeID,155 lightThemeID: lightThemeID, darkThemeID: darkThemeID)156 let url = storeURL157 saveTask?.cancel()158 saveTask = Task.detached(priority: .utility) {159 let encoder = JSONEncoder()160 encoder.outputFormatting = [.sortedKeys]161 if let data = try? encoder.encode(snapshot) {162 try? data.write(to: url, options: .atomic)163 }164 }165 }166167 private struct Persisted: Codable {168 var layout = LayoutSettings()169 var customThemes: [AtlasTheme] = []170 var matchSystemAppearance = true171 var selectedThemeID = BuiltInThemes.defaultLightID172 var lightThemeID = BuiltInThemes.defaultLightID173 var darkThemeID = BuiltInThemes.defaultDarkID174 }175}176