diff --git a/Dory/DesignSystem/Theme.swift b/Dory/DesignSystem/Theme.swift index 15362dbe..c26aa625 100644 --- a/Dory/DesignSystem/Theme.swift +++ b/Dory/DesignSystem/Theme.swift @@ -1,10 +1,77 @@ +import AppKit +import Observation import SwiftUI enum DoryAppearance: String, CaseIterable, Sendable { - case light, dark + case light, dark, system - var palette: DoryPalette { self == .dark ? .dark : .light } - var colorScheme: ColorScheme { self == .dark ? .dark : .light } + var label: String { + switch self { + case .light: "Light" + case .dark: "Dark" + case .system: "System" + } + } + + func resolved(systemIsDark: Bool) -> DoryAppearance { + self == .system ? (systemIsDark ? .dark : .light) : self + } + + var palette: DoryPalette { self == .light ? .light : .dark } + + /// `nil` for `.system` so SwiftUI keeps following the OS setting. + var colorScheme: ColorScheme? { + switch self { + case .light: .light + case .dark: .dark + case .system: nil + } + } +} + +/// Tracks the OS light/dark setting so `DoryAppearance.system` follows it live. +@MainActor +@Observable +final class DorySystemAppearance { + static let shared = DorySystemAppearance() + + private(set) var isDark: Bool = DorySystemAppearance.currentIsDark() + @ObservationIgnored private var observation: NSKeyValueObservation? + + private init() { + observe() + if NSApp == nil { + // The app object does not exist yet during early launch; attach once it does. + NotificationCenter.default.addObserver( + forName: NSApplication.didFinishLaunchingNotification, + object: nil, + queue: .main + ) { _ in + MainActor.assumeIsolated { + DorySystemAppearance.shared.observe() + DorySystemAppearance.shared.refresh() + } + } + } + } + + private func observe() { + guard observation == nil, let app = NSApp else { return } + observation = app.observe(\.effectiveAppearance, options: [.new]) { _, _ in + // KVO for effectiveAppearance is delivered on the main thread. + MainActor.assumeIsolated { DorySystemAppearance.shared.refresh() } + } + } + + private func refresh() { + let value = Self.currentIsDark() + if value != isDark { isDark = value } + } + + private static func currentIsDark() -> Bool { + let appearance = NSApp?.effectiveAppearance ?? NSAppearance.currentDrawing() + return appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + } } struct DoryPalette: Sendable, Equatable { diff --git a/Dory/Features/Settings/SettingsView.swift b/Dory/Features/Settings/SettingsView.swift index ae62a970..e58aa49c 100644 --- a/Dory/Features/Settings/SettingsView.swift +++ b/Dory/Features/Settings/SettingsView.swift @@ -864,8 +864,16 @@ struct SettingsView: View { groupLabel("APPEARANCE") HStack(spacing: 10) { - appearanceCard(.light, "Light", LinearGradient(colors: [Color(hex: 0xDCE9F7), .white], startPoint: .topLeading, endPoint: .bottomTrailing)) - appearanceCard(.dark, "Dark", LinearGradient(colors: [Color(hex: 0x1B1D21), Color(hex: 0x2A2C33)], startPoint: .topLeading, endPoint: .bottomTrailing)) + appearanceCard(.light, "Always light", LinearGradient(colors: [Color(hex: 0xDCE9F7), .white], startPoint: .topLeading, endPoint: .bottomTrailing)) + appearanceCard(.dark, "Always dark", LinearGradient(colors: [Color(hex: 0x1B1D21), Color(hex: 0x2A2C33)], startPoint: .topLeading, endPoint: .bottomTrailing)) + appearanceCard(.system, "Follows macOS", LinearGradient( + stops: [ + .init(color: Color(hex: 0xDCE9F7), location: 0.5), + .init(color: Color(hex: 0x1B1D21), location: 0.5) + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + )) } } } @@ -949,13 +957,16 @@ struct SettingsView: View { .overlay(alignment: .bottom) { if divider { Rectangle().fill(p.border).frame(height: 1) } } } - private func appearanceCard(_ appearance: DoryAppearance, _ label: String, _ preview: LinearGradient) -> some View { + private func appearanceCard(_ appearance: DoryAppearance, _ subtitle: String, _ preview: LinearGradient) -> some View { let selected = store.appearance == appearance return Button { store.setAppearance(appearance) } label: { VStack(alignment: .leading, spacing: 9) { RoundedRectangle(cornerRadius: 7).fill(preview).frame(height: 46) .overlay(RoundedRectangle(cornerRadius: 7).strokeBorder(p.border)) - Text(label).font(.system(size: 12.5, weight: .semibold)).foregroundStyle(p.text) + VStack(alignment: .leading, spacing: 1) { + Text(appearance.label).font(.system(size: 12.5, weight: .semibold)).foregroundStyle(p.text) + Text(subtitle).font(.system(size: 11)).foregroundStyle(p.text3) + } } .padding(13) .frame(maxWidth: .infinity) diff --git a/Dory/Models/AppStore.swift b/Dory/Models/AppStore.swift index df2a1be3..b96720af 100644 --- a/Dory/Models/AppStore.swift +++ b/Dory/Models/AppStore.swift @@ -78,6 +78,7 @@ struct ContainerGroup: Identifiable, Sendable { @MainActor final class AppStore { var appearance: DoryAppearance = .dark + let systemAppearance = DorySystemAppearance.shared var section: AppSection = .containers { didSet { if oldValue != section { filter = "" } } } @@ -391,7 +392,7 @@ final class AppStore { } if let raw = env["DORY_SETTINGS_TAB"], let parsed = SettingsTab(rawValue: raw) { settingsTab = parsed } if let raw = env["DORY_DETAIL_TAB"], let parsed = DetailTab(rawValue: raw) { detailTab = parsed } - if env["DORY_APPEARANCE"] == "light" { appearance = .light } + if let raw = env["DORY_APPEARANCE"], let parsed = DoryAppearance(rawValue: raw) { appearance = parsed } if let raw = env["DORY_SHEET"], let parsed = AppSheet(rawValue: raw) { activeSheet = parsed if parsed == .inspectImage { inspectedImage = images.first } @@ -728,7 +729,7 @@ final class AppStore { func setAppearance(_ value: DoryAppearance) { appearance = value UserDefaults.standard.set(value.rawValue, forKey: Self.appearanceKey) - showSettingsSuccess("\(value.rawValue.capitalized) appearance applied.") + showSettingsSuccess(value == .system ? "Appearance now follows macOS." : "\(value.label) appearance applied.") } func setLaunchAtLogin(_ on: Bool) { @@ -760,7 +761,11 @@ final class AppStore { UserDefaults.standard.set(true, forKey: Self.onboardingDoneKey) } - var palette: DoryPalette { appearance.palette } + var resolvedAppearance: DoryAppearance { + appearance.resolved(systemIsDark: systemAppearance.isDark) + } + + var palette: DoryPalette { resolvedAppearance.palette } func clearSettingsNotice() { settingsNotice = nil @@ -5306,7 +5311,7 @@ final class AppStore { } func toggleTheme() { - appearance = appearance == .dark ? .light : .dark + appearance = resolvedAppearance == .dark ? .light : .dark } @discardableResult diff --git a/DoryTests/AppearanceTests.swift b/DoryTests/AppearanceTests.swift new file mode 100644 index 00000000..6e3c97b7 --- /dev/null +++ b/DoryTests/AppearanceTests.swift @@ -0,0 +1,36 @@ +import Testing +import SwiftUI +@testable import Dory + +@MainActor +struct AppearanceTests { + @Test func systemResolvesToTheOSAppearance() { + #expect(DoryAppearance.system.resolved(systemIsDark: true) == .dark) + #expect(DoryAppearance.system.resolved(systemIsDark: false) == .light) + } + + @Test func explicitAppearancesIgnoreTheOSAppearance() { + #expect(DoryAppearance.light.resolved(systemIsDark: true) == .light) + #expect(DoryAppearance.dark.resolved(systemIsDark: false) == .dark) + } + + @Test func systemLeavesTheColorSchemeToSwiftUI() { + #expect(DoryAppearance.system.colorScheme == nil) + #expect(DoryAppearance.light.colorScheme == .light) + #expect(DoryAppearance.dark.colorScheme == .dark) + } + + @Test func paletteFollowsTheSystemAppearance() { + let store = AppStore() + store.appearance = .system + #expect(store.palette == (store.systemAppearance.isDark ? .dark : .light)) + } + + @Test func toggleThemeLeavesSystemForAnExplicitOverride() { + let store = AppStore() + store.appearance = .system + let wasDark = store.resolvedAppearance == .dark + store.toggleTheme() + #expect(store.appearance == (wasDark ? .light : .dark)) + } +} diff --git a/DoryUITests/DoryScreensUITests.swift b/DoryUITests/DoryScreensUITests.swift index 51f4816f..305dec35 100644 --- a/DoryUITests/DoryScreensUITests.swift +++ b/DoryUITests/DoryScreensUITests.swift @@ -69,8 +69,9 @@ final class DoryScreensUITests: XCTestCase { func testThemeToggleAndAppearancePicker() { nav("settings") - // Appearance picker selects light/dark without crashing. + // Appearance picker selects light/dark/system without crashing. app.buttons["appearance-light"].click() + app.buttons["appearance-system"].click() app.buttons["appearance-dark"].click() // The sidebar theme toggle flips appearance. let toggle = app.buttons["theme-toggle"]