diff --git a/CHANGELOG.md b/CHANGELOG.md index 8889b7ea6..7769889d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Option to group all connection tabs in one window instead of separate windows per connection +### Changed + +- Separate preferred themes for Light and Dark appearance modes, with automatic switching in Auto mode + ## [0.27.1] - 2026-04-01 ### Fixed diff --git a/TablePro/Core/Storage/AppSettingsManager.swift b/TablePro/Core/Storage/AppSettingsManager.swift index fea26200a..110f0e7cf 100644 --- a/TablePro/Core/Storage/AppSettingsManager.swift +++ b/TablePro/Core/Storage/AppSettingsManager.swift @@ -36,8 +36,11 @@ final class AppSettingsManager { var appearance: AppearanceSettings { didSet { storage.saveAppearance(appearance) - ThemeEngine.shared.activateTheme(id: appearance.activeThemeId) - ThemeEngine.shared.updateAppearanceMode(appearance.appearanceMode) + ThemeEngine.shared.updateAppearanceAndTheme( + mode: appearance.appearanceMode, + lightThemeId: appearance.preferredLightThemeId, + darkThemeId: appearance.preferredDarkThemeId + ) SyncChangeTracker.shared.markDirty(.settings, id: "appearance") } } @@ -157,9 +160,12 @@ final class AppSettingsManager { // Apply language immediately general.language.apply() - // ThemeEngine initializes itself from persisted theme ID - // Apply app-level appearance mode - ThemeEngine.shared.updateAppearanceMode(appearance.appearanceMode) + // Activate the correct theme based on appearance mode + preferred themes + ThemeEngine.shared.updateAppearanceAndTheme( + mode: appearance.appearanceMode, + lightThemeId: appearance.preferredLightThemeId, + darkThemeId: appearance.preferredDarkThemeId + ) // Sync editor behavioral settings to ThemeEngine ThemeEngine.shared.updateEditorSettings( diff --git a/TablePro/Models/Settings/AppSettings.swift b/TablePro/Models/Settings/AppSettings.swift index a2070bdb0..6181b063c 100644 --- a/TablePro/Models/Settings/AppSettings.swift +++ b/TablePro/Models/Settings/AppSettings.swift @@ -101,7 +101,7 @@ struct GeneralSettings: Codable, Equatable { // MARK: - Appearance Settings -/// Controls NSApp.appearance independent of the active theme. +/// Controls which appearance the app uses: forced light, forced dark, or follow system. enum AppAppearanceMode: String, Codable, CaseIterable { case light case dark @@ -116,46 +116,41 @@ enum AppAppearanceMode: String, Codable, CaseIterable { } } -/// Appearance settings +/// Appearance settings — couples appearance mode with theme selection. +/// Each appearance (light/dark) has its own preferred theme so the active theme +/// always matches the window chrome. struct AppearanceSettings: Codable, Equatable { - var activeThemeId: String var appearanceMode: AppAppearanceMode + var preferredLightThemeId: String + var preferredDarkThemeId: String static let `default` = AppearanceSettings( - activeThemeId: "tablepro.default-light", - appearanceMode: .auto + appearanceMode: .auto, + preferredLightThemeId: "tablepro.default-light", + preferredDarkThemeId: "tablepro.default-dark" ) - init(activeThemeId: String = "tablepro.default-light", appearanceMode: AppAppearanceMode = .auto) { - self.activeThemeId = activeThemeId + init( + appearanceMode: AppAppearanceMode = .auto, + preferredLightThemeId: String = "tablepro.default-light", + preferredDarkThemeId: String = "tablepro.default-dark" + ) { self.appearanceMode = appearanceMode + self.preferredLightThemeId = preferredLightThemeId + self.preferredDarkThemeId = preferredDarkThemeId } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - // Migration: try new field first, then fall back to old theme field - if let themeId = try container.decodeIfPresent(String.self, forKey: .activeThemeId) { - activeThemeId = themeId - } else if let oldTheme = try? container.decodeIfPresent(String.self, forKey: .theme) { - // Migrate from old AppTheme enum - switch oldTheme { - case "dark": activeThemeId = "tablepro.default-dark" - default: activeThemeId = "tablepro.default-light" - } - } else { - activeThemeId = "tablepro.default-light" - } appearanceMode = try container.decodeIfPresent(AppAppearanceMode.self, forKey: .appearanceMode) ?? .auto + preferredLightThemeId = try container.decodeIfPresent(String.self, forKey: .preferredLightThemeId) + ?? "tablepro.default-light" + preferredDarkThemeId = try container.decodeIfPresent(String.self, forKey: .preferredDarkThemeId) + ?? "tablepro.default-dark" } private enum CodingKeys: String, CodingKey { - case activeThemeId, theme, appearanceMode - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(activeThemeId, forKey: .activeThemeId) - try container.encode(appearanceMode, forKey: .appearanceMode) + case appearanceMode, preferredLightThemeId, preferredDarkThemeId } } diff --git a/TablePro/Theme/ThemeEngine.swift b/TablePro/Theme/ThemeEngine.swift index 63dd555f1..946295b5a 100644 --- a/TablePro/Theme/ThemeEngine.swift +++ b/TablePro/Theme/ThemeEngine.swift @@ -109,8 +109,9 @@ internal final class ThemeEngine { private init() { let allThemes = ThemeStorage.loadAllThemes() - let activeId = ThemeStorage.loadActiveThemeId() - let theme = allThemes.first { $0.id == activeId } ?? .default + // Start with the default theme; AppSettingsManager.init() will call + // updateAppearanceAndTheme() to activate the correct preferred theme. + let theme = ThemeDefinition.default self.activeTheme = theme self.colors = ResolvedThemeColors(from: theme) @@ -140,7 +141,6 @@ internal final class ThemeEngine { editorFonts = EditorFontCache(from: theme.fonts) dataGridFonts = DataGridFontCacheResolved(from: theme.fonts) - ThemeStorage.saveActiveThemeId(theme.id) notifyThemeDidChange() Self.logger.info("Activated theme: \(theme.name) (\(theme.id))") @@ -163,9 +163,27 @@ internal final class ThemeEngine { try ThemeStorage.deleteUserTheme(id: id) reloadAvailableThemes() - // If deleted the active theme, fall back to default - if id == activeTheme.id { - activateTheme(id: "tablepro.default-light") + // If deleted a preferred theme, reset that slot to default + var appearance = AppSettingsManager.shared.appearance + var changed = false + if id == appearance.preferredLightThemeId { + appearance.preferredLightThemeId = "tablepro.default-light" + changed = true + } + if id == appearance.preferredDarkThemeId { + appearance.preferredDarkThemeId = "tablepro.default-dark" + changed = true + } + if changed { + AppSettingsManager.shared.appearance = appearance + } else if id == activeTheme.id { + // Deleted a non-preferred but currently active theme — re-anchor to preferred + let appearance = AppSettingsManager.shared.appearance + updateAppearanceAndTheme( + mode: appearance.appearanceMode, + lightThemeId: appearance.preferredLightThemeId, + darkThemeId: appearance.preferredDarkThemeId + ) } } @@ -209,6 +227,11 @@ internal final class ThemeEngine { activeTheme = theme editorFonts = EditorFontCache(from: theme.fonts) notifyThemeDidChange() + + // Persist so the zoom survives re-activation (e.g. system appearance change) + if theme.isEditable { + try? ThemeStorage.saveUserTheme(theme) + } } // MARK: - Font Cache Reload (accessibility) @@ -273,13 +296,51 @@ internal final class ThemeEngine { // MARK: - Appearance @ObservationIgnored private(set) var appearanceMode: AppAppearanceMode = .auto - - func updateAppearanceMode(_ mode: AppAppearanceMode) { + private(set) var effectiveAppearance: ThemeAppearance = .light + @ObservationIgnored private var currentLightThemeId: String = "tablepro.default-light" + @ObservationIgnored private var currentDarkThemeId: String = "tablepro.default-dark" + @ObservationIgnored private var systemAppearanceObserver: NSObjectProtocol? + + /// Central entry point: resolves effective appearance, picks the correct theme, activates it, + /// and derives NSApp.appearance from the theme's own appearance metadata. + func updateAppearanceAndTheme( + mode: AppAppearanceMode, + lightThemeId: String, + darkThemeId: String + ) { appearanceMode = mode - applyAppearance(mode) + currentLightThemeId = lightThemeId + currentDarkThemeId = darkThemeId + + let resolved = resolveEffectiveAppearance(mode) + effectiveAppearance = resolved + + let themeId = resolved == .dark ? darkThemeId : lightThemeId + activateTheme(id: themeId) + applyNSAppAppearance(mode: mode) + + updateSystemAppearanceObserver(mode: mode) } - private func applyAppearance(_ mode: AppAppearanceMode) { + /// Resolve which appearance is in effect right now. + private func resolveEffectiveAppearance(_ mode: AppAppearanceMode) -> ThemeAppearance { + switch mode { + case .light: return .light + case .dark: return .dark + case .auto: return systemIsDark() ? .dark : .light + } + } + + /// Check if the system is currently in dark mode. + /// Reads the global `AppleInterfaceStyle` default directly so we get the real + /// system setting, not the app's own forced appearance. + private func systemIsDark() -> Bool { + UserDefaults.standard.string(forKey: "AppleInterfaceStyle") == "Dark" + } + + /// Set NSApp.appearance based on the appearance mode (not the theme). + /// Auto mode sets nil so the system controls the chrome. + private func applyNSAppAppearance(mode: AppAppearanceMode) { switch mode { case .light: NSApp?.appearance = NSAppearance(named: .aqua) @@ -290,6 +351,35 @@ internal final class ThemeEngine { } } + // MARK: - System Appearance Observer + + private func updateSystemAppearanceObserver(mode: AppAppearanceMode) { + // Remove existing observer + if let observer = systemAppearanceObserver { + DistributedNotificationCenter.default().removeObserver(observer) + systemAppearanceObserver = nil + } + + guard mode == .auto else { return } + + // Install observer for system appearance changes + systemAppearanceObserver = DistributedNotificationCenter.default().addObserver( + forName: Notification.Name("AppleInterfaceThemeChangedNotification"), + object: nil, + queue: .main + ) { [weak self] _ in + Task { @MainActor [weak self] in + guard let self, self.appearanceMode == .auto else { return } + let newAppearance = self.systemIsDark() ? ThemeAppearance.dark : ThemeAppearance.light + guard newAppearance != self.effectiveAppearance else { return } + self.effectiveAppearance = newAppearance + let themeId = newAppearance == .dark ? self.currentDarkThemeId : self.currentLightThemeId + self.activateTheme(id: themeId) + self.applyNSAppAppearance(mode: .auto) + } + } + } + // MARK: - Notifications private func notifyThemeDidChange() { diff --git a/TablePro/Theme/ThemeRegistryInstaller.swift b/TablePro/Theme/ThemeRegistryInstaller.swift index 6abb10899..4927d978b 100644 --- a/TablePro/Theme/ThemeRegistryInstaller.swift +++ b/TablePro/Theme/ThemeRegistryInstaller.swift @@ -61,9 +61,21 @@ internal final class ThemeRegistryInstaller { ThemeEngine.shared.reloadAvailableThemes() - // Fall back if the active theme was uninstalled - if removedThemeIds.contains(ThemeEngine.shared.activeTheme.id) { - ThemeEngine.shared.activateTheme(id: "tablepro.default-light") + // Reset preferred theme slots if the uninstalled theme was preferred + var appearance = AppSettingsManager.shared.appearance + var changed = false + for id in removedThemeIds { + if id == appearance.preferredLightThemeId { + appearance.preferredLightThemeId = "tablepro.default-light" + changed = true + } + if id == appearance.preferredDarkThemeId { + appearance.preferredDarkThemeId = "tablepro.default-dark" + changed = true + } + } + if changed { + AppSettingsManager.shared.appearance = appearance } Self.logger.info("Uninstalled registry themes for plugin: \(registryPluginId)") @@ -102,9 +114,13 @@ internal final class ThemeRegistryInstaller { // Single reload after swap is complete — no intermediate flicker ThemeEngine.shared.reloadAvailableThemes() - if ThemeEngine.shared.availableThemes.contains(where: { $0.id == activeId }) { - ThemeEngine.shared.activateTheme(id: activeId) - } + // Re-activate the correct theme for the current appearance + let appearance = AppSettingsManager.shared.appearance + ThemeEngine.shared.updateAppearanceAndTheme( + mode: appearance.appearanceMode, + lightThemeId: appearance.preferredLightThemeId, + darkThemeId: appearance.preferredDarkThemeId + ) Self.logger.info("Updated \(installedThemes.count) theme(s) for registry plugin: \(plugin.id)") } diff --git a/TablePro/Theme/ThemeStorage.swift b/TablePro/Theme/ThemeStorage.swift index c34e6fb84..6f0169cdc 100644 --- a/TablePro/Theme/ThemeStorage.swift +++ b/TablePro/Theme/ThemeStorage.swift @@ -179,18 +179,6 @@ internal struct ThemeStorage { logger.info("Exported theme: \(theme.id) to \(destinationURL.lastPathComponent)") } - // MARK: - Active Theme Persistence - - private static let activeThemeKey = "com.TablePro.settings.activeThemeId" - - static func loadActiveThemeId() -> String { - UserDefaults.standard.string(forKey: activeThemeKey) ?? "tablepro.default-light" - } - - static func saveActiveThemeId(_ id: String) { - UserDefaults.standard.set(id, forKey: activeThemeKey) - } - // MARK: - Helpers private static func ensureUserDirectory() { diff --git a/TablePro/Views/Settings/Appearance/ThemeListView.swift b/TablePro/Views/Settings/Appearance/ThemeListView.swift index 16122f97b..803d7d7f1 100644 --- a/TablePro/Views/Settings/Appearance/ThemeListView.swift +++ b/TablePro/Views/Settings/Appearance/ThemeListView.swift @@ -142,7 +142,6 @@ internal struct ThemeListView: View { let copy = engine.duplicateTheme(theme, newName: theme.name + " (Copy)") do { try engine.saveUserTheme(copy) - engine.activateTheme(copy) selectedThemeId = copy.id } catch { errorMessage = error.localizedDescription @@ -190,7 +189,6 @@ internal struct ThemeListView: View { guard panel.runModal() == .OK, let url = panel.url else { return } do { let imported = try engine.importTheme(from: url) - engine.activateTheme(imported) selectedThemeId = imported.id } catch { errorMessage = error.localizedDescription diff --git a/TablePro/Views/Settings/AppearanceSettingsView.swift b/TablePro/Views/Settings/AppearanceSettingsView.swift index 6f66088ef..ae9dece50 100644 --- a/TablePro/Views/Settings/AppearanceSettingsView.swift +++ b/TablePro/Views/Settings/AppearanceSettingsView.swift @@ -10,6 +10,45 @@ import SwiftUI struct AppearanceSettingsView: View { @Binding var settings: AppearanceSettings + /// Computed binding that reads/writes the correct preferred theme slot. + /// On read: returns the theme for the current effective appearance. + /// On write: uses the selected theme's appearance metadata to determine the correct slot, + /// and switches the appearance mode so the user sees the change immediately. + private var effectiveThemeIdBinding: Binding { + Binding( + get: { + ThemeEngine.shared.effectiveAppearance == .dark + ? settings.preferredDarkThemeId + : settings.preferredLightThemeId + }, + set: { newId in + guard let theme = ThemeEngine.shared.availableThemes + .first(where: { $0.id == newId }) else { return } + + // Assign to the correct slot based on the theme's appearance and + // switch mode to match so the user sees the change immediately. + // Mutate a local copy so didSet fires only once. + var updated = settings + switch theme.appearance { + case .dark: + updated.preferredDarkThemeId = newId + updated.appearanceMode = .dark + case .light: + updated.preferredLightThemeId = newId + updated.appearanceMode = .light + case .auto: + updated.appearanceMode = .auto + if ThemeEngine.shared.effectiveAppearance == .dark { + updated.preferredDarkThemeId = newId + } else { + updated.preferredLightThemeId = newId + } + } + settings = updated + } + ) + } + var body: some View { VStack(spacing: 0) { HStack(spacing: 12) { @@ -33,10 +72,10 @@ struct AppearanceSettingsView: View { Divider() HSplitView { - ThemeListView(selectedThemeId: $settings.activeThemeId) + ThemeListView(selectedThemeId: effectiveThemeIdBinding) .frame(minWidth: 180, idealWidth: 210, maxWidth: 250) - ThemeEditorView(selectedThemeId: $settings.activeThemeId) + ThemeEditorView(selectedThemeId: effectiveThemeIdBinding) .frame(minWidth: 400) } } diff --git a/docs/customization/appearance.mdx b/docs/customization/appearance.mdx index cf905cece..dd84af394 100644 --- a/docs/customization/appearance.mdx +++ b/docs/customization/appearance.mdx @@ -54,9 +54,11 @@ At the bottom of the sidebar, an action bar provides: | **GitHub Dark** | Dark | GitHub UI | | **Nord** | Dark | Arctic north-bluish palette | -### Theme Appearance Mode +### Appearance Mode -Each theme has a fixed `appearance` property: `light`, `dark`, or `auto`. Activating a theme sets `NSApp.appearance` to match. One active theme at a time; there is no separate light/dark assignment. +Pick **Light**, **Dark**, or **Auto** at the top. Light and Dark each store a separate preferred theme. Auto follows the system and switches between the two automatically. + +When you select a theme from the list, it becomes the preferred theme for that theme's appearance (light or dark). Selecting a dark theme while in Light mode switches to Dark mode so you see the change right away. {/* Screenshot: Side-by-side light and dark themes */}