ソースを参照

Add System, Light, and Dark theme picker in Settings.

Persist appearance preference and apply it at launch so users can match macOS or force light or dark chrome.

Co-authored-by: Cursor <cursoragent@cursor.com>
AhtashamShahzad1 2 ヶ月 前
コミット
ce75d79d9c

+ 3 - 4
App for Indeed/AppDelegate.swift

@@ -20,8 +20,8 @@ enum AppWindowConfiguration {
         window.titlebarAppearsTransparent = true
         window.titleVisibility = .hidden
         window.isMovableByWindowBackground = true
-        // Same as `DashboardView` chrome — avoids a white halo outside the grey frame with fullSizeContentView.
-        window.backgroundColor = NSColor(srgbRed: 247 / 255, green: 247 / 255, blue: 247 / 255, alpha: 1)
+        // Same as dashboard chrome — avoids a halo outside the frame with fullSizeContentView.
+        window.backgroundColor = AppAppearanceManager.shared.windowChromeColor
 
         let targetContent = NSRect(origin: .zero, size: defaultContentSize)
         let targetFrame = window.frameRect(forContentRect: targetContent)
@@ -48,8 +48,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
     private var lastSubscriptionRefreshAt: Date?
 
     func applicationWillFinishLaunching(_ notification: Notification) {
-        // Dashboard is light-themed; without this, a Dark Mode Mac paints a dark title bar.
-        NSApp.appearance = NSAppearance(named: .aqua)
+        AppAppearanceManager.shared.apply()
     }
 
     func applicationDidFinishLaunching(_ aNotification: Notification) {

+ 98 - 0
App for Indeed/Services/AppAppearanceManager.swift

@@ -0,0 +1,98 @@
+//
+//  AppAppearanceManager.swift
+//  App for Indeed
+//
+
+import AppKit
+
+/// Persists and applies the user’s light / dark / system appearance preference.
+@MainActor
+final class AppAppearanceManager {
+    static let shared = AppAppearanceManager()
+
+    static let didChangeNotification = Notification.Name("AppAppearanceManager.didChange")
+
+    enum Mode: String, CaseIterable {
+        case light
+        case dark
+        case system
+
+        var segmentIndex: Int {
+            switch self {
+            case .system: 0
+            case .light: 1
+            case .dark: 2
+            }
+        }
+
+        init?(segmentIndex: Int) {
+            switch segmentIndex {
+            case 0: self = .system
+            case 1: self = .light
+            case 2: self = .dark
+            default: return nil
+            }
+        }
+    }
+
+    private enum UserDefaultsKey {
+        static let appearanceMode = "com.appforindeed.appearanceMode"
+    }
+
+    private var systemThemeObserver: NSObjectProtocol?
+
+    private init() {
+        systemThemeObserver = DistributedNotificationCenter.default().addObserver(
+            forName: Notification.Name("AppleInterfaceThemeChangedNotification"),
+            object: nil,
+            queue: .main
+        ) { [weak self] _ in
+            guard let self, self.mode == .system else { return }
+            self.updateWindowChrome()
+        }
+    }
+
+    var mode: Mode {
+        get {
+            guard let raw = UserDefaults.standard.string(forKey: UserDefaultsKey.appearanceMode),
+                  let stored = Mode(rawValue: raw) else {
+                return .light
+            }
+            return stored
+        }
+        set {
+            guard newValue != mode else { return }
+            UserDefaults.standard.set(newValue.rawValue, forKey: UserDefaultsKey.appearanceMode)
+            apply()
+            NotificationCenter.default.post(name: Self.didChangeNotification, object: self)
+        }
+    }
+
+    /// Window backing color aligned with dashboard chrome for the active appearance.
+    var windowChromeColor: NSColor {
+        let isDark = NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
+        if isDark {
+            return NSColor(srgbRed: 28 / 255, green: 28 / 255, blue: 30 / 255, alpha: 1)
+        }
+        return NSColor(srgbRed: 247 / 255, green: 247 / 255, blue: 247 / 255, alpha: 1)
+    }
+
+    func apply() {
+        switch mode {
+        case .light:
+            NSApp.appearance = NSAppearance(named: .aqua)
+        case .dark:
+            NSApp.appearance = NSAppearance(named: .darkAqua)
+        case .system:
+            NSApp.appearance = nil
+        }
+        updateWindowChrome()
+    }
+
+    private func updateWindowChrome() {
+        let color = windowChromeColor
+        for window in NSApp.windows where window.isVisible || window.canBecomeKey {
+            window.backgroundColor = color
+        }
+    }
+}

+ 41 - 0
App for Indeed/Views/DashboardView.swift

@@ -119,6 +119,7 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
     private let savedJobsDocumentView = JobListingsDocumentView()
     private let savedJobsStack = NSStackView()
     private let settingsPageContainer = NSView()
+    private weak var appearanceModeSegment: NSSegmentedControl?
     private let cvMakerPageContainer = NSView()
     private lazy var cvMakerPageView: CVMakerPageView = {
         CVMakerPageView()
@@ -1557,6 +1558,23 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
         contentStack.alignment = .leading
         contentStack.translatesAutoresizingMaskIntoConstraints = false
 
+        let appearanceTitle = NSTextField(labelWithString: "Appearance")
+        appearanceTitle.font = .systemFont(ofSize: 12, weight: .semibold)
+        appearanceTitle.textColor = Theme.secondaryText
+        appearanceTitle.alignment = .left
+
+        let themeSegment = makeAppearanceModeSegment()
+        appearanceModeSegment = themeSegment
+        let appearanceSection = makeSettingsSection(rows: [
+            makeSettingsRow(title: "Theme", systemImage: "circle.lefthalf.filled", accessory: themeSegment, tapAction: nil)
+        ])
+
+        let appearanceStack = NSStackView(views: [appearanceTitle, appearanceSection])
+        appearanceStack.orientation = .vertical
+        appearanceStack.spacing = 14
+        appearanceStack.alignment = .leading
+        appearanceStack.translatesAutoresizingMaskIntoConstraints = false
+
         let settingsSection = makeSettingsSection(rows: [
             makeSettingsRow(title: "Share App", systemImage: "square.and.arrow.up", accessory: nil, tapAction: #selector(didTapShareApp)),
             makeSettingsRow(title: "More Apps", systemImage: "square.grid.2x2", accessory: nil, tapAction: #selector(didTapMoreApps))
@@ -1580,6 +1598,7 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
         aboutStack.alignment = .leading
         aboutStack.translatesAutoresizingMaskIntoConstraints = false
 
+        contentStack.addArrangedSubview(appearanceStack)
         contentStack.addArrangedSubview(settingsSection)
         contentStack.addArrangedSubview(aboutStack)
         settingsPageContainer.addSubview(contentStack)
@@ -1588,6 +1607,8 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
             contentStack.leadingAnchor.constraint(equalTo: settingsPageContainer.leadingAnchor, constant: 42),
             contentStack.trailingAnchor.constraint(lessThanOrEqualTo: settingsPageContainer.trailingAnchor, constant: -42),
             contentStack.topAnchor.constraint(equalTo: settingsPageContainer.topAnchor, constant: 48),
+            appearanceStack.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
+            appearanceSection.widthAnchor.constraint(equalTo: appearanceStack.widthAnchor),
             settingsSection.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
             aboutStack.widthAnchor.constraint(equalTo: contentStack.widthAnchor),
             aboutSection.widthAnchor.constraint(equalTo: aboutStack.widthAnchor),
@@ -1595,6 +1616,26 @@ final class DashboardView: NSView, NSTextFieldDelegate, NSSharingServicePickerDe
         ])
     }
 
+    private func makeAppearanceModeSegment() -> NSSegmentedControl {
+        let segment = NSSegmentedControl(
+            labels: ["System", "Light", "Dark"],
+            trackingMode: .selectOne,
+            target: self,
+            action: #selector(appearanceModeChanged(_:))
+        )
+        segment.translatesAutoresizingMaskIntoConstraints = false
+        segment.segmentStyle = .automatic
+        segment.selectedSegment = AppAppearanceManager.shared.mode.segmentIndex
+        segment.setContentHuggingPriority(.required, for: .horizontal)
+        segment.setContentCompressionResistancePriority(.required, for: .horizontal)
+        return segment
+    }
+
+    @objc private func appearanceModeChanged(_ sender: NSSegmentedControl) {
+        guard let mode = AppAppearanceManager.Mode(segmentIndex: sender.selectedSegment) else { return }
+        AppAppearanceManager.shared.mode = mode
+    }
+
     private func makeSettingsSection(rows: [NSView]) -> NSView {
         let section = NSStackView()
         section.orientation = .vertical