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%
10.2 KB · 274 lines swift
Raw Blame History
1//2//  CustomizationView.swift3//  Zyquo Atlas4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  The customization panel (Phase 4.2 headline UI): pick a built-in theme with9//  live preview, create/edit a custom theme (accent + background + chrome),10//  choose the tab layout and density, and set UI scale. Every change applies11//  live through ThemeEngine. Import/export wire to small JSON theme files.12//1314import SwiftUI15import AppKit1617struct CustomizationView: View {18    @EnvironmentObject private var themeEngine: ThemeEngine19    @Environment(\.dismiss) private var dismiss20    private var theme: AtlasTheme { themeEngine.theme }2122    var body: some View {23        VStack(spacing: 0) {24            header25            Divider().overlay(theme.border)26            ScrollView {27                VStack(alignment: .leading, spacing: ZyquoSpacing.xl) {28                    themesSection29                    customEditorSection30                    layoutSection31                    typographySection32                }33                .padding(ZyquoSpacing.lg)34            }35        }36        .frame(width: 640, height: 620)37        .background(theme.backgroundColor)38    }3940    // MARK: - Header4142    private var header: some View {43        HStack {44            Label("Customize", systemImage: "paintbrush")45                .font(ZyquoFont.title)46                .foregroundStyle(theme.textPrimary)47            Spacer()48            Button("Done") { dismiss() }49                .foregroundStyle(theme.accent)50        }51        .padding(ZyquoSpacing.md)52        .background(theme.surface)53    }5455    // MARK: - Theme gallery5657    private var themesSection: some View {58        section("Theme") {59            LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: ZyquoSpacing.sm), count: 3),60                      spacing: ZyquoSpacing.sm) {61                ForEach(themeEngine.gallery) { t in62                    ThemeSwatch(theme: t, isSelected: t.id == themeEngine.selectedThemeID)63                        .onTapGesture { themeEngine.select(t.id) }64                }65            }66            HStack(spacing: ZyquoSpacing.sm) {67                Toggle("Match system light/dark", isOn: Binding(68                    get: { themeEngine.matchSystemAppearance },69                    set: { themeEngine.setMatchSystem($0, systemIsDark: systemIsDark) }70                ))71                .toggleStyle(.switch)72                .font(ZyquoFont.body())73                .foregroundStyle(theme.textPrimary)74                Spacer()75                Button("Import…") { importTheme() }.foregroundStyle(theme.accent)76                Button("Export…") { exportTheme() }.foregroundStyle(theme.accent)77            }78        }79    }8081    // MARK: - Custom editor8283    private var customEditorSection: some View {84        section("Custom theme") {85            HStack(spacing: ZyquoSpacing.md) {86                colorWell("Accent", get: { theme.palette.accent }, set: { v in editPalette { $0.accent = v } })87                colorWell("Background", get: { theme.palette.background }, set: { v in editPalette { $0.background = v } })88                colorWell("Chrome", get: { theme.palette.surface }, set: { v in editPalette { $0.surface = v } })89                Spacer()90            }91            Text(isCustom92                 ? "Editing “\(theme.name)”. Colors apply live."93                 : "Editing a built-in theme creates an editable copy.")94                .font(ZyquoFont.caption)95                .foregroundStyle(theme.textSecondary)96        }97    }9899    // MARK: - Layout100101    private var layoutSection: some View {102        section("Layout") {103            HStack(spacing: ZyquoSpacing.lg) {104                labeledPicker("Tabs") {105                    Picker("", selection: Binding(106                        get: { themeEngine.layout.tabPosition },107                        set: { pos in themeEngine.updateLayout { $0.tabPosition = pos } }108                    )) {109                        Text("Top").tag(TabPosition.top)110                        Text("Left").tag(TabPosition.left)111                    }.pickerStyle(.segmented).labelsHidden().frame(width: 160)112                }113                labeledPicker("Density") {114                    Picker("", selection: Binding(115                        get: { themeEngine.layout.density },116                        set: { d in themeEngine.updateLayout { $0.density = d } }117                    )) {118                        Text("Comfortable").tag(Density.comfortable)119                        Text("Compact").tag(Density.compact)120                    }.pickerStyle(.segmented).labelsHidden().frame(width: 200)121                }122            }123            Toggle("Bookmarks bar", isOn: Binding(124                get: { themeEngine.layout.showBookmarksBar },125                set: { on in themeEngine.updateLayout { $0.showBookmarksBar = on } }126            ))127            .toggleStyle(.switch).font(ZyquoFont.body()).foregroundStyle(theme.textPrimary)128        }129    }130131    // MARK: - Typography132133    private var typographySection: some View {134        section("Display") {135            HStack {136                Text("UI size").font(ZyquoFont.body()).foregroundStyle(theme.textPrimary)137                Slider(value: Binding(138                    get: { themeEngine.layout.uiScale },139                    set: { s in themeEngine.updateLayout { $0.uiScale = s } }140                ), in: 0.85...1.30, step: 0.05)141                Text(String(format: "%.0f%%", themeEngine.layout.uiScale * 100))142                    .font(ZyquoFont.caption).foregroundStyle(theme.textSecondary).frame(width: 44)143            }144        }145    }146147    // MARK: - Helpers148149    private var isCustom: Bool { theme.id.hasPrefix("custom-") }150    private var systemIsDark: Bool {151        NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua152    }153154    private func editPalette(_ mutate: (inout AtlasPalette) -> Void) {155        var t = ensureCustom()156        mutate(&t.palette)157        themeEngine.updateCustom(t)158    }159160    /// Ensures edits target a custom theme (duplicates a built-in on first edit).161    private func ensureCustom() -> AtlasTheme {162        if isCustom { return theme }163        return themeEngine.duplicateForEditing(theme, name: theme.name + " (Custom)")164    }165166    private func colorWell(_ label: String, get: @escaping () -> UInt32,167                           set: @escaping (UInt32) -> Void) -> some View {168        VStack(spacing: ZyquoSpacing.xxs) {169            ColorWellBridge(hex: get(), onChange: set).frame(width: 44, height: 28)170            Text(label).font(ZyquoFont.caption).foregroundStyle(theme.textSecondary)171        }172    }173174    private func labeledPicker<Content: View>(_ label: String, @ViewBuilder _ content: () -> Content) -> some View {175        VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) {176            Text(label).font(ZyquoFont.caption).foregroundStyle(theme.textSecondary)177            content()178        }179    }180181    private func section<Content: View>(_ title: String, @ViewBuilder _ content: () -> Content) -> some View {182        VStack(alignment: .leading, spacing: ZyquoSpacing.sm) {183            Text(title.uppercased())184                .font(ZyquoFont.caption)185                .foregroundStyle(theme.textTertiary)186            content()187        }188    }189190    // MARK: - Import / export191192    private func importTheme() {193        let panel = NSOpenPanel()194        panel.allowedContentTypes = [.json]195        panel.allowsMultipleSelection = false196        if panel.runModal() == .OK, let url = panel.url {197            _ = try? themeEngine.importTheme(from: url)198        }199    }200201    private func exportTheme() {202        let panel = NSSavePanel()203        panel.allowedContentTypes = [.json]204        panel.nameFieldStringValue = theme.name.replacingOccurrences(of: " ", with: "-") + ".json"205        if panel.runModal() == .OK, let url = panel.url {206            try? themeEngine.exportTheme(theme, to: url)  // best-effort207        }208    }209}210211// MARK: - Theme swatch212213private struct ThemeSwatch: View {214    let theme: AtlasTheme215    let isSelected: Bool216217    var body: some View {218        VStack(spacing: ZyquoSpacing.xxs) {219            ZStack(alignment: .bottomLeading) {220                RoundedRectangle(cornerRadius: ZyquoRadius.medium)221                    .fill(theme.backgroundColor)222                HStack(spacing: 3) {223                    Circle().fill(theme.accent).frame(width: 10, height: 10)224                    Circle().fill(theme.accentIndigo).frame(width: 10, height: 10)225                    RoundedRectangle(cornerRadius: 2).fill(theme.surface).frame(width: 22, height: 8)226                }227                .padding(ZyquoSpacing.xs)228            }229            .frame(height: 54)230            .overlay(RoundedRectangle(cornerRadius: ZyquoRadius.medium)231                .strokeBorder(isSelected ? theme.accent : theme.border,232                              lineWidth: isSelected ? 2 : ZyquoMetrics.hairline))233            Text(theme.name)234                .font(ZyquoFont.caption)235                .foregroundStyle(theme.textSecondary)236                .lineLimit(1)237        }238    }239}240241/// Bridges an NSColorWell so the custom-theme editor gets a native color picker.242private struct ColorWellBridge: NSViewRepresentable {243    let hex: UInt32244    let onChange: (UInt32) -> Void245246    func makeNSView(context: Context) -> NSColorWell {247        let well = NSColorWell()248        well.color = NSColor(hex: hex)249        well.target = context.coordinator250        well.action = #selector(Coordinator.changed(_:))251        return well252    }253254    func updateNSView(_ nsView: NSColorWell, context: Context) {255        context.coordinator.onChange = onChange256        nsView.color = NSColor(hex: hex)257    }258259    func makeCoordinator() -> Coordinator { Coordinator(onChange: onChange) }260261    final class Coordinator: NSObject {262        var onChange: (UInt32) -> Void263        init(onChange: @escaping (UInt32) -> Void) { self.onChange = onChange }264265        @objc func changed(_ sender: NSColorWell) {266            guard let rgb = sender.color.usingColorSpace(.sRGB) else { return }267            let r = UInt32(round(rgb.redComponent * 255))268            let g = UInt32(round(rgb.greenComponent * 255))269            let b = UInt32(round(rgb.blueComponent * 255))270            onChange((r << 16) | (g << 8) | b)271        }272    }273}274