SPB Git

spb/zyquo-atlas Public License

The AI-native macOS web browser — every surface, intelligent.

Swift 75.2% JavaScript 22% Shell 2% Makefile 0.9%
5.9 KB · 176 lines swift
Raw Blame History
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