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// 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