// // CustomizationView.swift // Zyquo Atlas // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // The customization panel (Phase 4.2 headline UI): pick a built-in theme with // live preview, create/edit a custom theme (accent + background + chrome), // choose the tab layout and density, and set UI scale. Every change applies // live through ThemeEngine. Import/export wire to small JSON theme files. // import SwiftUI import AppKit struct CustomizationView: View { @EnvironmentObject private var themeEngine: ThemeEngine @Environment(\.dismiss) private var dismiss private var theme: AtlasTheme { themeEngine.theme } var body: some View { VStack(spacing: 0) { header Divider().overlay(theme.border) ScrollView { VStack(alignment: .leading, spacing: ZyquoSpacing.xl) { themesSection customEditorSection layoutSection typographySection } .padding(ZyquoSpacing.lg) } } .frame(width: 640, height: 620) .background(theme.backgroundColor) } // MARK: - Header private var header: some View { HStack { Label("Customize", systemImage: "paintbrush") .font(ZyquoFont.title) .foregroundStyle(theme.textPrimary) Spacer() Button("Done") { dismiss() } .foregroundStyle(theme.accent) } .padding(ZyquoSpacing.md) .background(theme.surface) } // MARK: - Theme gallery private var themesSection: some View { section("Theme") { LazyVGrid(columns: Array(repeating: GridItem(.flexible(), spacing: ZyquoSpacing.sm), count: 3), spacing: ZyquoSpacing.sm) { ForEach(themeEngine.gallery) { t in ThemeSwatch(theme: t, isSelected: t.id == themeEngine.selectedThemeID) .onTapGesture { themeEngine.select(t.id) } } } HStack(spacing: ZyquoSpacing.sm) { Toggle("Match system light/dark", isOn: Binding( get: { themeEngine.matchSystemAppearance }, set: { themeEngine.setMatchSystem($0, systemIsDark: systemIsDark) } )) .toggleStyle(.switch) .font(ZyquoFont.body()) .foregroundStyle(theme.textPrimary) Spacer() Button("Import…") { importTheme() }.foregroundStyle(theme.accent) Button("Export…") { exportTheme() }.foregroundStyle(theme.accent) } } } // MARK: - Custom editor private var customEditorSection: some View { section("Custom theme") { HStack(spacing: ZyquoSpacing.md) { colorWell("Accent", get: { theme.palette.accent }, set: { v in editPalette { $0.accent = v } }) colorWell("Background", get: { theme.palette.background }, set: { v in editPalette { $0.background = v } }) colorWell("Chrome", get: { theme.palette.surface }, set: { v in editPalette { $0.surface = v } }) Spacer() } Text(isCustom ? "Editing “\(theme.name)”. Colors apply live." : "Editing a built-in theme creates an editable copy.") .font(ZyquoFont.caption) .foregroundStyle(theme.textSecondary) } } // MARK: - Layout private var layoutSection: some View { section("Layout") { HStack(spacing: ZyquoSpacing.lg) { labeledPicker("Tabs") { Picker("", selection: Binding( get: { themeEngine.layout.tabPosition }, set: { pos in themeEngine.updateLayout { $0.tabPosition = pos } } )) { Text("Top").tag(TabPosition.top) Text("Left").tag(TabPosition.left) }.pickerStyle(.segmented).labelsHidden().frame(width: 160) } labeledPicker("Density") { Picker("", selection: Binding( get: { themeEngine.layout.density }, set: { d in themeEngine.updateLayout { $0.density = d } } )) { Text("Comfortable").tag(Density.comfortable) Text("Compact").tag(Density.compact) }.pickerStyle(.segmented).labelsHidden().frame(width: 200) } } Toggle("Bookmarks bar", isOn: Binding( get: { themeEngine.layout.showBookmarksBar }, set: { on in themeEngine.updateLayout { $0.showBookmarksBar = on } } )) .toggleStyle(.switch).font(ZyquoFont.body()).foregroundStyle(theme.textPrimary) } } // MARK: - Typography private var typographySection: some View { section("Display") { HStack { Text("UI size").font(ZyquoFont.body()).foregroundStyle(theme.textPrimary) Slider(value: Binding( get: { themeEngine.layout.uiScale }, set: { s in themeEngine.updateLayout { $0.uiScale = s } } ), in: 0.85...1.30, step: 0.05) Text(String(format: "%.0f%%", themeEngine.layout.uiScale * 100)) .font(ZyquoFont.caption).foregroundStyle(theme.textSecondary).frame(width: 44) } } } // MARK: - Helpers private var isCustom: Bool { theme.id.hasPrefix("custom-") } private var systemIsDark: Bool { NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua } private func editPalette(_ mutate: (inout AtlasPalette) -> Void) { var t = ensureCustom() mutate(&t.palette) themeEngine.updateCustom(t) } /// Ensures edits target a custom theme (duplicates a built-in on first edit). private func ensureCustom() -> AtlasTheme { if isCustom { return theme } return themeEngine.duplicateForEditing(theme, name: theme.name + " (Custom)") } private func colorWell(_ label: String, get: @escaping () -> UInt32, set: @escaping (UInt32) -> Void) -> some View { VStack(spacing: ZyquoSpacing.xxs) { ColorWellBridge(hex: get(), onChange: set).frame(width: 44, height: 28) Text(label).font(ZyquoFont.caption).foregroundStyle(theme.textSecondary) } } private func labeledPicker(_ label: String, @ViewBuilder _ content: () -> Content) -> some View { VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { Text(label).font(ZyquoFont.caption).foregroundStyle(theme.textSecondary) content() } } private func section(_ title: String, @ViewBuilder _ content: () -> Content) -> some View { VStack(alignment: .leading, spacing: ZyquoSpacing.sm) { Text(title.uppercased()) .font(ZyquoFont.caption) .foregroundStyle(theme.textTertiary) content() } } // MARK: - Import / export private func importTheme() { let panel = NSOpenPanel() panel.allowedContentTypes = [.json] panel.allowsMultipleSelection = false if panel.runModal() == .OK, let url = panel.url { _ = try? themeEngine.importTheme(from: url) } } private func exportTheme() { let panel = NSSavePanel() panel.allowedContentTypes = [.json] panel.nameFieldStringValue = theme.name.replacingOccurrences(of: " ", with: "-") + ".json" if panel.runModal() == .OK, let url = panel.url { try? themeEngine.exportTheme(theme, to: url) // best-effort } } } // MARK: - Theme swatch private struct ThemeSwatch: View { let theme: AtlasTheme let isSelected: Bool var body: some View { VStack(spacing: ZyquoSpacing.xxs) { ZStack(alignment: .bottomLeading) { RoundedRectangle(cornerRadius: ZyquoRadius.medium) .fill(theme.backgroundColor) HStack(spacing: 3) { Circle().fill(theme.accent).frame(width: 10, height: 10) Circle().fill(theme.accentIndigo).frame(width: 10, height: 10) RoundedRectangle(cornerRadius: 2).fill(theme.surface).frame(width: 22, height: 8) } .padding(ZyquoSpacing.xs) } .frame(height: 54) .overlay(RoundedRectangle(cornerRadius: ZyquoRadius.medium) .strokeBorder(isSelected ? theme.accent : theme.border, lineWidth: isSelected ? 2 : ZyquoMetrics.hairline)) Text(theme.name) .font(ZyquoFont.caption) .foregroundStyle(theme.textSecondary) .lineLimit(1) } } } /// Bridges an NSColorWell so the custom-theme editor gets a native color picker. private struct ColorWellBridge: NSViewRepresentable { let hex: UInt32 let onChange: (UInt32) -> Void func makeNSView(context: Context) -> NSColorWell { let well = NSColorWell() well.color = NSColor(hex: hex) well.target = context.coordinator well.action = #selector(Coordinator.changed(_:)) return well } func updateNSView(_ nsView: NSColorWell, context: Context) { context.coordinator.onChange = onChange nsView.color = NSColor(hex: hex) } func makeCoordinator() -> Coordinator { Coordinator(onChange: onChange) } final class Coordinator: NSObject { var onChange: (UInt32) -> Void init(onChange: @escaping (UInt32) -> Void) { self.onChange = onChange } @objc func changed(_ sender: NSColorWell) { guard let rgb = sender.color.usingColorSpace(.sRGB) else { return } let r = UInt32(round(rgb.redComponent * 255)) let g = UInt32(round(rgb.greenComponent * 255)) let b = UInt32(round(rgb.blueComponent * 255)) onChange((r << 16) | (g << 8) | b) } } }