Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 70 additions & 3 deletions Dory/DesignSystem/Theme.swift
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
19 changes: 15 additions & 4 deletions Dory/Features/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Comment on lines +867 to +869

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Perhaps something like this would be better to match the macOS settings picker:

Suggested change
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(
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(.system, "Auto", LinearGradient(
Image

stops: [
.init(color: Color(hex: 0xDCE9F7), location: 0.5),
.init(color: Color(hex: 0x1B1D21), location: 0.5)
],
startPoint: .topLeading,
endPoint: .bottomTrailing
))
}
}
}
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 9 additions & 4 deletions Dory/Models/AppStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "" } }
}
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -5306,7 +5311,7 @@ final class AppStore {
}

func toggleTheme() {
appearance = appearance == .dark ? .light : .dark
appearance = resolvedAppearance == .dark ? .light : .dark
}

@discardableResult
Expand Down
36 changes: 36 additions & 0 deletions DoryTests/AppearanceTests.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
3 changes: 2 additions & 1 deletion DoryUITests/DoryScreensUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down