From 5fdefb48f4107b20e80a26a9c75b44f84b4d388a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Thu, 2 Apr 2026 09:07:08 +0700 Subject: [PATCH 1/7] refactor: couple appearance mode with theme selection (#550) --- CHANGELOG.md | 6 ++ .../Core/Storage/AppSettingsManager.swift | 16 +++- TablePro/Models/Settings/AppSettings.swift | 64 +++++++++---- TablePro/Theme/ThemeEngine.swift | 96 +++++++++++++++++-- TablePro/Theme/ThemeRegistryInstaller.swift | 28 ++++-- TablePro/Theme/ThemeStorage.swift | 12 --- .../Settings/AppearanceSettingsView.swift | 23 ++++- docs/customization/appearance.mdx | 10 +- 8 files changed, 201 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8889b7ea6..a1028fbe4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,12 @@ 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 + +- Appearance mode and theme are now coupled — selecting Dark mode automatically activates your preferred dark theme, and vice versa +- Each appearance mode (Light/Dark) has its own preferred theme that persists independently +- Auto mode now auto-switches between preferred light and dark themes when the system appearance changes + ## [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..b42031078 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,76 @@ 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 + + appearanceMode = try container.decodeIfPresent(AppAppearanceMode.self, forKey: .appearanceMode) ?? .auto + + // New format: dual theme preferences + if let lightId = try container.decodeIfPresent(String.self, forKey: .preferredLightThemeId) { + preferredLightThemeId = lightId + preferredDarkThemeId = try container.decodeIfPresent(String.self, forKey: .preferredDarkThemeId) + ?? "tablepro.default-dark" + } else if let oldActiveId = try container.decodeIfPresent(String.self, forKey: .activeThemeId) { + // Migration from single activeThemeId — place in correct slot based on theme metadata + let themeAppearance = ThemeStorage.loadTheme(id: oldActiveId)?.appearance ?? .light + if themeAppearance == .dark { + preferredDarkThemeId = oldActiveId + preferredLightThemeId = "tablepro.default-light" + } else { + preferredLightThemeId = oldActiveId + preferredDarkThemeId = "tablepro.default-dark" + } } else if let oldTheme = try? container.decodeIfPresent(String.self, forKey: .theme) { - // Migrate from old AppTheme enum + // Legacy migration from old AppTheme enum switch oldTheme { - case "dark": activeThemeId = "tablepro.default-dark" - default: activeThemeId = "tablepro.default-light" + case "dark": + preferredDarkThemeId = "tablepro.default-dark" + preferredLightThemeId = "tablepro.default-light" + default: + preferredLightThemeId = "tablepro.default-light" + preferredDarkThemeId = "tablepro.default-dark" } } else { - activeThemeId = "tablepro.default-light" + preferredLightThemeId = "tablepro.default-light" + preferredDarkThemeId = "tablepro.default-dark" } - appearanceMode = try container.decodeIfPresent(AppAppearanceMode.self, forKey: .appearanceMode) ?? .auto } private enum CodingKeys: String, CodingKey { - case activeThemeId, theme, appearanceMode + case appearanceMode, preferredLightThemeId, preferredDarkThemeId + case activeThemeId, theme // legacy keys for migration } 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) + try container.encode(preferredLightThemeId, forKey: .preferredLightThemeId) + try container.encode(preferredDarkThemeId, forKey: .preferredDarkThemeId) } } diff --git a/TablePro/Theme/ThemeEngine.swift b/TablePro/Theme/ThemeEngine.swift index 63dd555f1..77e5586e2 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,19 @@ 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 } } @@ -273,14 +283,50 @@ internal final class ThemeEngine { // MARK: - Appearance @ObservationIgnored private(set) var appearanceMode: AppAppearanceMode = .auto - - func updateAppearanceMode(_ mode: AppAppearanceMode) { + @ObservationIgnored 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(from: activeTheme) + + 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. + private func systemIsDark() -> Bool { + let name = NSApp?.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) + return name == .darkAqua + } + + /// Set NSApp.appearance based on the active theme's appearance metadata. + private func applyNSAppAppearance(from theme: ThemeDefinition) { + switch theme.appearance { case .light: NSApp?.appearance = NSAppearance(named: .aqua) case .dark: @@ -290,6 +336,36 @@ 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(from: self.activeTheme) + Self.logger.info("System appearance changed → \(newAppearance.rawValue)") + } + } + } + // 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/AppearanceSettingsView.swift b/TablePro/Views/Settings/AppearanceSettingsView.swift index 6f66088ef..671b3d8e6 100644 --- a/TablePro/Views/Settings/AppearanceSettingsView.swift +++ b/TablePro/Views/Settings/AppearanceSettingsView.swift @@ -10,6 +10,25 @@ import SwiftUI struct AppearanceSettingsView: View { @Binding var settings: AppearanceSettings + /// Computed binding that reads/writes the correct preferred theme slot + /// based on the current effective appearance. + private var effectiveThemeIdBinding: Binding { + Binding( + get: { + ThemeEngine.shared.effectiveAppearance == .dark + ? settings.preferredDarkThemeId + : settings.preferredLightThemeId + }, + set: { newId in + if ThemeEngine.shared.effectiveAppearance == .dark { + settings.preferredDarkThemeId = newId + } else { + settings.preferredLightThemeId = newId + } + } + ) + } + var body: some View { VStack(spacing: 0) { HStack(spacing: 12) { @@ -33,10 +52,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..f50487110 100644 --- a/docs/customization/appearance.mdx +++ b/docs/customization/appearance.mdx @@ -54,9 +54,15 @@ 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 & Theme Pairing -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. +Use the segmented picker at the top to choose **Light**, **Dark**, or **Auto** (follow system). Each mode has its own preferred theme: + +- **Light mode** uses your preferred light theme +- **Dark mode** uses your preferred dark theme +- **Auto mode** switches between the two when the system appearance changes + +Selecting a theme from the list sets it as the preferred theme for the current appearance. This ensures the window chrome and editor colors always match. {/* Screenshot: Side-by-side light and dark themes */} From 903717d9e2fbaf8d8525427a62bfcb2ec4d12c74 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Thu, 2 Apr 2026 09:20:33 +0700 Subject: [PATCH 2/7] fix: address code review findings from PR #552 --- TablePro/Models/Settings/AppSettings.swift | 9 ++++++--- TablePro/Theme/ThemeEngine.swift | 15 ++++++++++++++- .../Views/Settings/Appearance/ThemeListView.swift | 2 -- 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/TablePro/Models/Settings/AppSettings.swift b/TablePro/Models/Settings/AppSettings.swift index b42031078..c885680b9 100644 --- a/TablePro/Models/Settings/AppSettings.swift +++ b/TablePro/Models/Settings/AppSettings.swift @@ -151,9 +151,12 @@ struct AppearanceSettings: Codable, Equatable { preferredDarkThemeId = try container.decodeIfPresent(String.self, forKey: .preferredDarkThemeId) ?? "tablepro.default-dark" } else if let oldActiveId = try container.decodeIfPresent(String.self, forKey: .activeThemeId) { - // Migration from single activeThemeId — place in correct slot based on theme metadata - let themeAppearance = ThemeStorage.loadTheme(id: oldActiveId)?.appearance ?? .light - if themeAppearance == .dark { + // Migration from single activeThemeId — place in correct slot based on theme metadata. + // If the theme file can't be loaded (deleted/not yet indexed), infer from the id string. + let loadedAppearance = ThemeStorage.loadTheme(id: oldActiveId)?.appearance + let isDark = loadedAppearance == .dark + || (loadedAppearance == nil && (oldActiveId.contains("dark") || oldActiveId.contains("dracula"))) + if isDark { preferredDarkThemeId = oldActiveId preferredLightThemeId = "tablepro.default-light" } else { diff --git a/TablePro/Theme/ThemeEngine.swift b/TablePro/Theme/ThemeEngine.swift index 77e5586e2..4292bc667 100644 --- a/TablePro/Theme/ThemeEngine.swift +++ b/TablePro/Theme/ThemeEngine.swift @@ -176,6 +176,14 @@ internal final class ThemeEngine { } 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 + ) } } @@ -219,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) @@ -283,7 +296,7 @@ internal final class ThemeEngine { // MARK: - Appearance @ObservationIgnored private(set) var appearanceMode: AppAppearanceMode = .auto - @ObservationIgnored private(set) var effectiveAppearance: ThemeAppearance = .light + 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? 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 From d89edbc06bfd928c6e1178bd677149b37c7c3600 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Thu, 2 Apr 2026 09:30:39 +0700 Subject: [PATCH 3/7] refactor: remove backward compatibility migration from AppearanceSettings --- TablePro/Models/Settings/AppSettings.swift | 46 ++-------------------- 1 file changed, 4 insertions(+), 42 deletions(-) diff --git a/TablePro/Models/Settings/AppSettings.swift b/TablePro/Models/Settings/AppSettings.swift index c885680b9..6181b063c 100644 --- a/TablePro/Models/Settings/AppSettings.swift +++ b/TablePro/Models/Settings/AppSettings.swift @@ -142,53 +142,15 @@ struct AppearanceSettings: Codable, Equatable { init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) - appearanceMode = try container.decodeIfPresent(AppAppearanceMode.self, forKey: .appearanceMode) ?? .auto - - // New format: dual theme preferences - if let lightId = try container.decodeIfPresent(String.self, forKey: .preferredLightThemeId) { - preferredLightThemeId = lightId - preferredDarkThemeId = try container.decodeIfPresent(String.self, forKey: .preferredDarkThemeId) - ?? "tablepro.default-dark" - } else if let oldActiveId = try container.decodeIfPresent(String.self, forKey: .activeThemeId) { - // Migration from single activeThemeId — place in correct slot based on theme metadata. - // If the theme file can't be loaded (deleted/not yet indexed), infer from the id string. - let loadedAppearance = ThemeStorage.loadTheme(id: oldActiveId)?.appearance - let isDark = loadedAppearance == .dark - || (loadedAppearance == nil && (oldActiveId.contains("dark") || oldActiveId.contains("dracula"))) - if isDark { - preferredDarkThemeId = oldActiveId - preferredLightThemeId = "tablepro.default-light" - } else { - preferredLightThemeId = oldActiveId - preferredDarkThemeId = "tablepro.default-dark" - } - } else if let oldTheme = try? container.decodeIfPresent(String.self, forKey: .theme) { - // Legacy migration from old AppTheme enum - switch oldTheme { - case "dark": - preferredDarkThemeId = "tablepro.default-dark" - preferredLightThemeId = "tablepro.default-light" - default: - preferredLightThemeId = "tablepro.default-light" - preferredDarkThemeId = "tablepro.default-dark" - } - } else { - preferredLightThemeId = "tablepro.default-light" - preferredDarkThemeId = "tablepro.default-dark" - } + 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 appearanceMode, preferredLightThemeId, preferredDarkThemeId - case activeThemeId, theme // legacy keys for migration - } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - try container.encode(appearanceMode, forKey: .appearanceMode) - try container.encode(preferredLightThemeId, forKey: .preferredLightThemeId) - try container.encode(preferredDarkThemeId, forKey: .preferredDarkThemeId) } } From f5f304bb2ffa6e220f36d9acacfa53769def0ea9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Thu, 2 Apr 2026 10:41:14 +0700 Subject: [PATCH 4/7] fix: correct system appearance detection and theme slot assignment --- TablePro/Theme/ThemeEngine.swift | 19 +++++++---- .../Settings/AppearanceSettingsView.swift | 34 +++++++++++++++---- 2 files changed, 40 insertions(+), 13 deletions(-) diff --git a/TablePro/Theme/ThemeEngine.swift b/TablePro/Theme/ThemeEngine.swift index 4292bc667..039ddbc41 100644 --- a/TablePro/Theme/ThemeEngine.swift +++ b/TablePro/Theme/ThemeEngine.swift @@ -308,6 +308,7 @@ internal final class ThemeEngine { lightThemeId: String, darkThemeId: String ) { + Self.logger.info("updateAppearanceAndTheme: mode=\(mode.rawValue) light=\(lightThemeId) dark=\(darkThemeId)") appearanceMode = mode currentLightThemeId = lightThemeId currentDarkThemeId = darkThemeId @@ -316,8 +317,10 @@ internal final class ThemeEngine { effectiveAppearance = resolved let themeId = resolved == .dark ? darkThemeId : lightThemeId + Self.logger.info("Resolved: effective=\(resolved.rawValue) → activating theme=\(themeId)") activateTheme(id: themeId) - applyNSAppAppearance(from: activeTheme) + Self.logger.info("After activate: activeTheme=\(self.activeTheme.id) themeAppearance=\(self.activeTheme.appearance.rawValue)") + applyNSAppAppearance(mode: mode) updateSystemAppearanceObserver(mode: mode) } @@ -332,14 +335,16 @@ internal final class ThemeEngine { } /// 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 { - let name = NSApp?.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) - return name == .darkAqua + UserDefaults.standard.string(forKey: "AppleInterfaceStyle") == "Dark" } - /// Set NSApp.appearance based on the active theme's appearance metadata. - private func applyNSAppAppearance(from theme: ThemeDefinition) { - switch theme.appearance { + /// 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) case .dark: @@ -373,7 +378,7 @@ internal final class ThemeEngine { self.effectiveAppearance = newAppearance let themeId = newAppearance == .dark ? self.currentDarkThemeId : self.currentLightThemeId self.activateTheme(id: themeId) - self.applyNSAppAppearance(from: self.activeTheme) + self.applyNSAppAppearance(mode: .auto) Self.logger.info("System appearance changed → \(newAppearance.rawValue)") } } diff --git a/TablePro/Views/Settings/AppearanceSettingsView.swift b/TablePro/Views/Settings/AppearanceSettingsView.swift index 671b3d8e6..06502888c 100644 --- a/TablePro/Views/Settings/AppearanceSettingsView.swift +++ b/TablePro/Views/Settings/AppearanceSettingsView.swift @@ -5,13 +5,18 @@ // Settings for theme browsing, customization, and accent color. // +import os import SwiftUI struct AppearanceSettingsView: View { @Binding var settings: AppearanceSettings - /// Computed binding that reads/writes the correct preferred theme slot - /// based on the current effective appearance. + private static let logger = Logger(subsystem: "com.TablePro", category: "AppearanceSettingsView") + + /// 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: { @@ -20,11 +25,28 @@ struct AppearanceSettingsView: View { : settings.preferredLightThemeId }, set: { newId in - if ThemeEngine.shared.effectiveAppearance == .dark { - settings.preferredDarkThemeId = newId - } else { - settings.preferredLightThemeId = newId + 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: + if ThemeEngine.shared.effectiveAppearance == .dark { + updated.preferredDarkThemeId = newId + } else { + updated.preferredLightThemeId = newId + } } + settings = updated } ) } From 64f4bcb80026375e2a3a3661a3308113b2822cc8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Thu, 2 Apr 2026 10:49:20 +0700 Subject: [PATCH 5/7] fix: handle .auto theme appearance, remove debug logging --- TablePro/Theme/ThemeEngine.swift | 4 ---- TablePro/Views/Settings/AppearanceSettingsView.swift | 4 +--- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/TablePro/Theme/ThemeEngine.swift b/TablePro/Theme/ThemeEngine.swift index 039ddbc41..946295b5a 100644 --- a/TablePro/Theme/ThemeEngine.swift +++ b/TablePro/Theme/ThemeEngine.swift @@ -308,7 +308,6 @@ internal final class ThemeEngine { lightThemeId: String, darkThemeId: String ) { - Self.logger.info("updateAppearanceAndTheme: mode=\(mode.rawValue) light=\(lightThemeId) dark=\(darkThemeId)") appearanceMode = mode currentLightThemeId = lightThemeId currentDarkThemeId = darkThemeId @@ -317,9 +316,7 @@ internal final class ThemeEngine { effectiveAppearance = resolved let themeId = resolved == .dark ? darkThemeId : lightThemeId - Self.logger.info("Resolved: effective=\(resolved.rawValue) → activating theme=\(themeId)") activateTheme(id: themeId) - Self.logger.info("After activate: activeTheme=\(self.activeTheme.id) themeAppearance=\(self.activeTheme.appearance.rawValue)") applyNSAppAppearance(mode: mode) updateSystemAppearanceObserver(mode: mode) @@ -379,7 +376,6 @@ internal final class ThemeEngine { let themeId = newAppearance == .dark ? self.currentDarkThemeId : self.currentLightThemeId self.activateTheme(id: themeId) self.applyNSAppAppearance(mode: .auto) - Self.logger.info("System appearance changed → \(newAppearance.rawValue)") } } } diff --git a/TablePro/Views/Settings/AppearanceSettingsView.swift b/TablePro/Views/Settings/AppearanceSettingsView.swift index 06502888c..ae9dece50 100644 --- a/TablePro/Views/Settings/AppearanceSettingsView.swift +++ b/TablePro/Views/Settings/AppearanceSettingsView.swift @@ -5,14 +5,11 @@ // Settings for theme browsing, customization, and accent color. // -import os import SwiftUI struct AppearanceSettingsView: View { @Binding var settings: AppearanceSettings - private static let logger = Logger(subsystem: "com.TablePro", category: "AppearanceSettingsView") - /// 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, @@ -40,6 +37,7 @@ struct AppearanceSettingsView: View { updated.preferredLightThemeId = newId updated.appearanceMode = .light case .auto: + updated.appearanceMode = .auto if ThemeEngine.shared.effectiveAppearance == .dark { updated.preferredDarkThemeId = newId } else { From 8f63cb1ef773c67ac5c0da6cab37a42cd5d8bbec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Thu, 2 Apr 2026 10:51:17 +0700 Subject: [PATCH 6/7] docs: simplify changelog entry --- CHANGELOG.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1028fbe4..7769889d3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,9 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- Appearance mode and theme are now coupled — selecting Dark mode automatically activates your preferred dark theme, and vice versa -- Each appearance mode (Light/Dark) has its own preferred theme that persists independently -- Auto mode now auto-switches between preferred light and dark themes when the system appearance changes +- Separate preferred themes for Light and Dark appearance modes, with automatic switching in Auto mode ## [0.27.1] - 2026-04-01 From adedf7e2422bb512519ab0e49153bb174e5ec870 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ng=C3=B4=20Qu=E1=BB=91c=20=C4=90=E1=BA=A1t?= Date: Thu, 2 Apr 2026 10:56:14 +0700 Subject: [PATCH 7/7] docs: rewrite appearance mode section for clarity --- docs/customization/appearance.mdx | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/docs/customization/appearance.mdx b/docs/customization/appearance.mdx index f50487110..dd84af394 100644 --- a/docs/customization/appearance.mdx +++ b/docs/customization/appearance.mdx @@ -54,15 +54,11 @@ At the bottom of the sidebar, an action bar provides: | **GitHub Dark** | Dark | GitHub UI | | **Nord** | Dark | Arctic north-bluish palette | -### Appearance Mode & Theme Pairing +### Appearance Mode -Use the segmented picker at the top to choose **Light**, **Dark**, or **Auto** (follow system). Each mode has its own preferred theme: +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. -- **Light mode** uses your preferred light theme -- **Dark mode** uses your preferred dark theme -- **Auto mode** switches between the two when the system appearance changes - -Selecting a theme from the list sets it as the preferred theme for the current appearance. This ensures the window chrome and editor colors always match. +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 */}