Skip to content

Commit 0c3738c

Browse files
authored
refactor: couple appearance mode with theme selection (#552)
* refactor: couple appearance mode with theme selection (#550) * fix: address code review findings from PR #552 * refactor: remove backward compatibility migration from AppearanceSettings * fix: correct system appearance detection and theme slot assignment * fix: handle .auto theme appearance, remove debug logging * docs: simplify changelog entry * docs: rewrite appearance mode section for clarity
1 parent 5091ef6 commit 0c3738c

File tree

9 files changed

+203
-65
lines changed

9 files changed

+203
-65
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1111

1212
- Option to group all connection tabs in one window instead of separate windows per connection
1313

14+
### Changed
15+
16+
- Separate preferred themes for Light and Dark appearance modes, with automatic switching in Auto mode
17+
1418
## [0.27.1] - 2026-04-01
1519

1620
### Fixed

TablePro/Core/Storage/AppSettingsManager.swift

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,11 @@ final class AppSettingsManager {
3636
var appearance: AppearanceSettings {
3737
didSet {
3838
storage.saveAppearance(appearance)
39-
ThemeEngine.shared.activateTheme(id: appearance.activeThemeId)
40-
ThemeEngine.shared.updateAppearanceMode(appearance.appearanceMode)
39+
ThemeEngine.shared.updateAppearanceAndTheme(
40+
mode: appearance.appearanceMode,
41+
lightThemeId: appearance.preferredLightThemeId,
42+
darkThemeId: appearance.preferredDarkThemeId
43+
)
4144
SyncChangeTracker.shared.markDirty(.settings, id: "appearance")
4245
}
4346
}
@@ -157,9 +160,12 @@ final class AppSettingsManager {
157160
// Apply language immediately
158161
general.language.apply()
159162

160-
// ThemeEngine initializes itself from persisted theme ID
161-
// Apply app-level appearance mode
162-
ThemeEngine.shared.updateAppearanceMode(appearance.appearanceMode)
163+
// Activate the correct theme based on appearance mode + preferred themes
164+
ThemeEngine.shared.updateAppearanceAndTheme(
165+
mode: appearance.appearanceMode,
166+
lightThemeId: appearance.preferredLightThemeId,
167+
darkThemeId: appearance.preferredDarkThemeId
168+
)
163169

164170
// Sync editor behavioral settings to ThemeEngine
165171
ThemeEngine.shared.updateEditorSettings(

TablePro/Models/Settings/AppSettings.swift

Lines changed: 21 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ struct GeneralSettings: Codable, Equatable {
101101

102102
// MARK: - Appearance Settings
103103

104-
/// Controls NSApp.appearance independent of the active theme.
104+
/// Controls which appearance the app uses: forced light, forced dark, or follow system.
105105
enum AppAppearanceMode: String, Codable, CaseIterable {
106106
case light
107107
case dark
@@ -116,46 +116,41 @@ enum AppAppearanceMode: String, Codable, CaseIterable {
116116
}
117117
}
118118

119-
/// Appearance settings
119+
/// Appearance settings — couples appearance mode with theme selection.
120+
/// Each appearance (light/dark) has its own preferred theme so the active theme
121+
/// always matches the window chrome.
120122
struct AppearanceSettings: Codable, Equatable {
121-
var activeThemeId: String
122123
var appearanceMode: AppAppearanceMode
124+
var preferredLightThemeId: String
125+
var preferredDarkThemeId: String
123126

124127
static let `default` = AppearanceSettings(
125-
activeThemeId: "tablepro.default-light",
126-
appearanceMode: .auto
128+
appearanceMode: .auto,
129+
preferredLightThemeId: "tablepro.default-light",
130+
preferredDarkThemeId: "tablepro.default-dark"
127131
)
128132

129-
init(activeThemeId: String = "tablepro.default-light", appearanceMode: AppAppearanceMode = .auto) {
130-
self.activeThemeId = activeThemeId
133+
init(
134+
appearanceMode: AppAppearanceMode = .auto,
135+
preferredLightThemeId: String = "tablepro.default-light",
136+
preferredDarkThemeId: String = "tablepro.default-dark"
137+
) {
131138
self.appearanceMode = appearanceMode
139+
self.preferredLightThemeId = preferredLightThemeId
140+
self.preferredDarkThemeId = preferredDarkThemeId
132141
}
133142

134143
init(from decoder: Decoder) throws {
135144
let container = try decoder.container(keyedBy: CodingKeys.self)
136-
// Migration: try new field first, then fall back to old theme field
137-
if let themeId = try container.decodeIfPresent(String.self, forKey: .activeThemeId) {
138-
activeThemeId = themeId
139-
} else if let oldTheme = try? container.decodeIfPresent(String.self, forKey: .theme) {
140-
// Migrate from old AppTheme enum
141-
switch oldTheme {
142-
case "dark": activeThemeId = "tablepro.default-dark"
143-
default: activeThemeId = "tablepro.default-light"
144-
}
145-
} else {
146-
activeThemeId = "tablepro.default-light"
147-
}
148145
appearanceMode = try container.decodeIfPresent(AppAppearanceMode.self, forKey: .appearanceMode) ?? .auto
146+
preferredLightThemeId = try container.decodeIfPresent(String.self, forKey: .preferredLightThemeId)
147+
?? "tablepro.default-light"
148+
preferredDarkThemeId = try container.decodeIfPresent(String.self, forKey: .preferredDarkThemeId)
149+
?? "tablepro.default-dark"
149150
}
150151

151152
private enum CodingKeys: String, CodingKey {
152-
case activeThemeId, theme, appearanceMode
153-
}
154-
155-
func encode(to encoder: Encoder) throws {
156-
var container = encoder.container(keyedBy: CodingKeys.self)
157-
try container.encode(activeThemeId, forKey: .activeThemeId)
158-
try container.encode(appearanceMode, forKey: .appearanceMode)
153+
case appearanceMode, preferredLightThemeId, preferredDarkThemeId
159154
}
160155
}
161156

TablePro/Theme/ThemeEngine.swift

Lines changed: 100 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,9 @@ internal final class ThemeEngine {
109109

110110
private init() {
111111
let allThemes = ThemeStorage.loadAllThemes()
112-
let activeId = ThemeStorage.loadActiveThemeId()
113-
let theme = allThemes.first { $0.id == activeId } ?? .default
112+
// Start with the default theme; AppSettingsManager.init() will call
113+
// updateAppearanceAndTheme() to activate the correct preferred theme.
114+
let theme = ThemeDefinition.default
114115

115116
self.activeTheme = theme
116117
self.colors = ResolvedThemeColors(from: theme)
@@ -140,7 +141,6 @@ internal final class ThemeEngine {
140141
editorFonts = EditorFontCache(from: theme.fonts)
141142
dataGridFonts = DataGridFontCacheResolved(from: theme.fonts)
142143

143-
ThemeStorage.saveActiveThemeId(theme.id)
144144
notifyThemeDidChange()
145145

146146
Self.logger.info("Activated theme: \(theme.name) (\(theme.id))")
@@ -163,9 +163,27 @@ internal final class ThemeEngine {
163163
try ThemeStorage.deleteUserTheme(id: id)
164164
reloadAvailableThemes()
165165

166-
// If deleted the active theme, fall back to default
167-
if id == activeTheme.id {
168-
activateTheme(id: "tablepro.default-light")
166+
// If deleted a preferred theme, reset that slot to default
167+
var appearance = AppSettingsManager.shared.appearance
168+
var changed = false
169+
if id == appearance.preferredLightThemeId {
170+
appearance.preferredLightThemeId = "tablepro.default-light"
171+
changed = true
172+
}
173+
if id == appearance.preferredDarkThemeId {
174+
appearance.preferredDarkThemeId = "tablepro.default-dark"
175+
changed = true
176+
}
177+
if changed {
178+
AppSettingsManager.shared.appearance = appearance
179+
} else if id == activeTheme.id {
180+
// Deleted a non-preferred but currently active theme — re-anchor to preferred
181+
let appearance = AppSettingsManager.shared.appearance
182+
updateAppearanceAndTheme(
183+
mode: appearance.appearanceMode,
184+
lightThemeId: appearance.preferredLightThemeId,
185+
darkThemeId: appearance.preferredDarkThemeId
186+
)
169187
}
170188
}
171189

@@ -209,6 +227,11 @@ internal final class ThemeEngine {
209227
activeTheme = theme
210228
editorFonts = EditorFontCache(from: theme.fonts)
211229
notifyThemeDidChange()
230+
231+
// Persist so the zoom survives re-activation (e.g. system appearance change)
232+
if theme.isEditable {
233+
try? ThemeStorage.saveUserTheme(theme)
234+
}
212235
}
213236

214237
// MARK: - Font Cache Reload (accessibility)
@@ -273,13 +296,51 @@ internal final class ThemeEngine {
273296
// MARK: - Appearance
274297

275298
@ObservationIgnored private(set) var appearanceMode: AppAppearanceMode = .auto
276-
277-
func updateAppearanceMode(_ mode: AppAppearanceMode) {
299+
private(set) var effectiveAppearance: ThemeAppearance = .light
300+
@ObservationIgnored private var currentLightThemeId: String = "tablepro.default-light"
301+
@ObservationIgnored private var currentDarkThemeId: String = "tablepro.default-dark"
302+
@ObservationIgnored private var systemAppearanceObserver: NSObjectProtocol?
303+
304+
/// Central entry point: resolves effective appearance, picks the correct theme, activates it,
305+
/// and derives NSApp.appearance from the theme's own appearance metadata.
306+
func updateAppearanceAndTheme(
307+
mode: AppAppearanceMode,
308+
lightThemeId: String,
309+
darkThemeId: String
310+
) {
278311
appearanceMode = mode
279-
applyAppearance(mode)
312+
currentLightThemeId = lightThemeId
313+
currentDarkThemeId = darkThemeId
314+
315+
let resolved = resolveEffectiveAppearance(mode)
316+
effectiveAppearance = resolved
317+
318+
let themeId = resolved == .dark ? darkThemeId : lightThemeId
319+
activateTheme(id: themeId)
320+
applyNSAppAppearance(mode: mode)
321+
322+
updateSystemAppearanceObserver(mode: mode)
280323
}
281324

282-
private func applyAppearance(_ mode: AppAppearanceMode) {
325+
/// Resolve which appearance is in effect right now.
326+
private func resolveEffectiveAppearance(_ mode: AppAppearanceMode) -> ThemeAppearance {
327+
switch mode {
328+
case .light: return .light
329+
case .dark: return .dark
330+
case .auto: return systemIsDark() ? .dark : .light
331+
}
332+
}
333+
334+
/// Check if the system is currently in dark mode.
335+
/// Reads the global `AppleInterfaceStyle` default directly so we get the real
336+
/// system setting, not the app's own forced appearance.
337+
private func systemIsDark() -> Bool {
338+
UserDefaults.standard.string(forKey: "AppleInterfaceStyle") == "Dark"
339+
}
340+
341+
/// Set NSApp.appearance based on the appearance mode (not the theme).
342+
/// Auto mode sets nil so the system controls the chrome.
343+
private func applyNSAppAppearance(mode: AppAppearanceMode) {
283344
switch mode {
284345
case .light:
285346
NSApp?.appearance = NSAppearance(named: .aqua)
@@ -290,6 +351,35 @@ internal final class ThemeEngine {
290351
}
291352
}
292353

354+
// MARK: - System Appearance Observer
355+
356+
private func updateSystemAppearanceObserver(mode: AppAppearanceMode) {
357+
// Remove existing observer
358+
if let observer = systemAppearanceObserver {
359+
DistributedNotificationCenter.default().removeObserver(observer)
360+
systemAppearanceObserver = nil
361+
}
362+
363+
guard mode == .auto else { return }
364+
365+
// Install observer for system appearance changes
366+
systemAppearanceObserver = DistributedNotificationCenter.default().addObserver(
367+
forName: Notification.Name("AppleInterfaceThemeChangedNotification"),
368+
object: nil,
369+
queue: .main
370+
) { [weak self] _ in
371+
Task { @MainActor [weak self] in
372+
guard let self, self.appearanceMode == .auto else { return }
373+
let newAppearance = self.systemIsDark() ? ThemeAppearance.dark : ThemeAppearance.light
374+
guard newAppearance != self.effectiveAppearance else { return }
375+
self.effectiveAppearance = newAppearance
376+
let themeId = newAppearance == .dark ? self.currentDarkThemeId : self.currentLightThemeId
377+
self.activateTheme(id: themeId)
378+
self.applyNSAppAppearance(mode: .auto)
379+
}
380+
}
381+
}
382+
293383
// MARK: - Notifications
294384

295385
private func notifyThemeDidChange() {

TablePro/Theme/ThemeRegistryInstaller.swift

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,9 +61,21 @@ internal final class ThemeRegistryInstaller {
6161

6262
ThemeEngine.shared.reloadAvailableThemes()
6363

64-
// Fall back if the active theme was uninstalled
65-
if removedThemeIds.contains(ThemeEngine.shared.activeTheme.id) {
66-
ThemeEngine.shared.activateTheme(id: "tablepro.default-light")
64+
// Reset preferred theme slots if the uninstalled theme was preferred
65+
var appearance = AppSettingsManager.shared.appearance
66+
var changed = false
67+
for id in removedThemeIds {
68+
if id == appearance.preferredLightThemeId {
69+
appearance.preferredLightThemeId = "tablepro.default-light"
70+
changed = true
71+
}
72+
if id == appearance.preferredDarkThemeId {
73+
appearance.preferredDarkThemeId = "tablepro.default-dark"
74+
changed = true
75+
}
76+
}
77+
if changed {
78+
AppSettingsManager.shared.appearance = appearance
6779
}
6880

6981
Self.logger.info("Uninstalled registry themes for plugin: \(registryPluginId)")
@@ -102,9 +114,13 @@ internal final class ThemeRegistryInstaller {
102114
// Single reload after swap is complete — no intermediate flicker
103115
ThemeEngine.shared.reloadAvailableThemes()
104116

105-
if ThemeEngine.shared.availableThemes.contains(where: { $0.id == activeId }) {
106-
ThemeEngine.shared.activateTheme(id: activeId)
107-
}
117+
// Re-activate the correct theme for the current appearance
118+
let appearance = AppSettingsManager.shared.appearance
119+
ThemeEngine.shared.updateAppearanceAndTheme(
120+
mode: appearance.appearanceMode,
121+
lightThemeId: appearance.preferredLightThemeId,
122+
darkThemeId: appearance.preferredDarkThemeId
123+
)
108124

109125
Self.logger.info("Updated \(installedThemes.count) theme(s) for registry plugin: \(plugin.id)")
110126
}

TablePro/Theme/ThemeStorage.swift

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -179,18 +179,6 @@ internal struct ThemeStorage {
179179
logger.info("Exported theme: \(theme.id) to \(destinationURL.lastPathComponent)")
180180
}
181181

182-
// MARK: - Active Theme Persistence
183-
184-
private static let activeThemeKey = "com.TablePro.settings.activeThemeId"
185-
186-
static func loadActiveThemeId() -> String {
187-
UserDefaults.standard.string(forKey: activeThemeKey) ?? "tablepro.default-light"
188-
}
189-
190-
static func saveActiveThemeId(_ id: String) {
191-
UserDefaults.standard.set(id, forKey: activeThemeKey)
192-
}
193-
194182
// MARK: - Helpers
195183

196184
private static func ensureUserDirectory() {

TablePro/Views/Settings/Appearance/ThemeListView.swift

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,6 @@ internal struct ThemeListView: View {
142142
let copy = engine.duplicateTheme(theme, newName: theme.name + " (Copy)")
143143
do {
144144
try engine.saveUserTheme(copy)
145-
engine.activateTheme(copy)
146145
selectedThemeId = copy.id
147146
} catch {
148147
errorMessage = error.localizedDescription
@@ -190,7 +189,6 @@ internal struct ThemeListView: View {
190189
guard panel.runModal() == .OK, let url = panel.url else { return }
191190
do {
192191
let imported = try engine.importTheme(from: url)
193-
engine.activateTheme(imported)
194192
selectedThemeId = imported.id
195193
} catch {
196194
errorMessage = error.localizedDescription

0 commit comments

Comments
 (0)