diff --git a/Copy/App/AppCoordinator.swift b/Copy/App/AppCoordinator.swift index cadf9fb..87788e6 100644 --- a/Copy/App/AppCoordinator.swift +++ b/Copy/App/AppCoordinator.swift @@ -35,7 +35,7 @@ final class AppCoordinator { private lazy var pasteStackController = PasteStackController( model: pasteStackModel, hideDuringScreenSharing: settings.hideDuringScreenSharing, - proDark: settings.shelfProDark) + theme: settings.shelfTheme) private lazy var pasteStackEngine = PasteStackEngine(onIntercept: { [weak self] in self?.pasteNextViaEngine() }) @@ -52,7 +52,7 @@ final class AppCoordinator { let controller = ShelfPanelController( hideDuringScreenSharing: settings.hideDuringScreenSharing, compactShelf: settings.compactShelf, - proDark: settings.shelfProDark) { [weak self] in + theme: settings.shelfTheme) { [weak self] in guard let self else { return NSView() } return NSHostingView(rootView: ShelfRootView(viewModel: self.shelfViewModel)) } @@ -424,9 +424,10 @@ final class AppCoordinator { settings.onCompactShelfChange = { [weak self] compact in self?.shelfController.setCompactShelf(compact) } - settings.onShelfProDarkChange = { [weak self] proDark in - self?.shelfController.setProDark(proDark) - self?.pasteStackController.setProDark(proDark) + settings.onShelfThemeChange = { [weak self] theme in + self?.shelfController.setTheme(theme) + self?.pasteStackController.setTheme(theme) + self?.settingsWindowController.setTheme(theme) } settings.onShowOnboarding = { [weak self] in self?.showOnboarding() diff --git a/Copy/PasteStack/PasteStackController.swift b/Copy/PasteStack/PasteStackController.swift index c4f8ed7..1a9f296 100644 --- a/Copy/PasteStack/PasteStackController.swift +++ b/Copy/PasteStack/PasteStackController.swift @@ -23,26 +23,21 @@ final class PasteStackController { // same split the shelf uses (`ShelfPanelController.presentModal`). private var modalPanel: KeyablePanel? private var hideDuringScreenSharing: Bool - private var proDark: Bool + private var theme: ShelfTheme - init(model: PasteStackModel, hideDuringScreenSharing: Bool, proDark: Bool) { + init(model: PasteStackModel, hideDuringScreenSharing: Bool, theme: ShelfTheme) { self.model = model self.hideDuringScreenSharing = hideDuringScreenSharing - self.proDark = proDark + self.theme = theme } - /// Pushed live by `AppCoordinator` via `SettingsStore.onShelfProDarkChange`. Forces - /// the palette (and its hosted SwiftUI content) to a dark appearance so it matches - /// the pro-dark shelf; `nil` returns to following the system, mirroring - /// `ShelfPanelController.setProDark`. The accent tint is baked into the hosted view at - /// `makePanel` time, so when the (cached) palette isn't on screen we drop it and let - /// the next `show()` rebuild it with both the appearance and the tint from the new - /// value; when it is on screen we can only update the window appearance live (the tint - /// refreshes on its next open). - func setProDark(_ on: Bool) { - proDark = on + /// Updates the palette and any open editor. Cached hidden palettes rebuild their + /// content on next open, preserving the existing accent-refresh behavior. + func setTheme(_ theme: ShelfTheme) { + self.theme = theme + modalPanel?.appearance = theme.appearance if let panel, panel.isVisible { - panel.appearance = on ? NSAppearance(named: .darkAqua) : nil + panel.appearance = theme.appearance } else { panel?.orderOut(nil) panel = nil @@ -154,7 +149,7 @@ final class PasteStackController { panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] panel.isOpaque = false panel.backgroundColor = .clear - panel.appearance = proDark ? NSAppearance(named: .darkAqua) : nil + panel.appearance = theme.appearance panel.hidesOnDeactivate = false panel.becomesKeyOnlyIfNeeded = true panel.isFloatingPanel = true @@ -182,7 +177,7 @@ final class PasteStackController { DispatchQueue.main.async { self?.resizeToFit() } } ) - .tint(proDark ? Tokens.electricBlue : nil)) + .tint(theme == .dark ? Tokens.electricBlue : nil)) hosting.translatesAutoresizingMaskIntoConstraints = false // The material view (a real NSView) makes every pixel non-transparent, so the @@ -231,7 +226,7 @@ final class PasteStackController { host.contentView = NSHostingView(rootView: PasteStackEditorHost( item: item, store: model.store, - proDark: proDark, + theme: theme, onCancel: { [weak self] in self?.dismissEditor() }, onSave: { [weak self] attributed in self?.model.commitEdit(attributed, for: item) @@ -267,7 +262,7 @@ final class PasteStackController { host.hasShadow = false host.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] host.hidesOnDeactivate = false - host.appearance = proDark ? NSAppearance(named: .darkAqua) : nil + host.appearance = theme.appearance host.sharingType = hideDuringScreenSharing ? .none : .readOnly modalPanel = host return host diff --git a/Copy/PasteStack/PasteStackView.swift b/Copy/PasteStack/PasteStackView.swift index ef44349..208b0e8 100644 --- a/Copy/PasteStack/PasteStackView.swift +++ b/Copy/PasteStack/PasteStackView.swift @@ -292,7 +292,7 @@ private struct PasteStackRow: View { struct PasteStackEditorHost: View { let item: ClipItem let store: ItemStore - var proDark: Bool + var theme: ShelfTheme let onCancel: () -> Void let onSave: (NSAttributedString) -> Void @@ -303,6 +303,6 @@ struct PasteStackEditorHost: View { EditItemSheet(item: item, store: store, onCancel: onCancel, onSave: onSave) } .frame(maxWidth: .infinity, maxHeight: .infinity) - .tint(proDark ? Tokens.electricBlue : nil) + .tint(theme == .dark ? Tokens.electricBlue : nil) } } diff --git a/Copy/Settings/GeneralSettings.swift b/Copy/Settings/GeneralSettings.swift index 56d1784..f29040b 100644 --- a/Copy/Settings/GeneralSettings.swift +++ b/Copy/Settings/GeneralSettings.swift @@ -34,9 +34,12 @@ struct GeneralSettings: View { } Section { - Toggle("Always Use Dark Shelf", isOn: $settings.shelfProDark) + Picker("Appearance", selection: $settings.shelfTheme) { + ForEach(ShelfTheme.allCases) { theme in Text(theme.title).tag(theme) } + } + .pickerStyle(.segmented) } footer: { - Text("Keeps the shelf and Paste Stack dark with a blue accent, even in Light Mode. Off by default, so they follow your system appearance.") + Text("Applies to the shelf, Paste Stack and Settings. Follow System uses your macOS appearance.") .font(.footnote) .foregroundStyle(.secondary) } diff --git a/Copy/Settings/SettingsStore.swift b/Copy/Settings/SettingsStore.swift index 40a3983..0d77d56 100644 --- a/Copy/Settings/SettingsStore.swift +++ b/Copy/Settings/SettingsStore.swift @@ -28,6 +28,26 @@ enum CopySound: String, CaseIterable, Identifiable { } } +/// Persisted appearance choice shared by the shelf, Paste Stack and Settings. +enum ShelfTheme: String, CaseIterable, Identifiable { + case system, light, dark + var id: Self { self } + var title: String { + switch self { + case .system: return "Follow System" + case .light: return "Light" + case .dark: return "Dark" + } + } + var appearance: NSAppearance? { + switch self { + case .system: return nil + case .light: return NSAppearance(named: .aqua) + case .dark: return NSAppearance(named: .darkAqua) + } + } +} + /// How long unfavorited, unpinned history items are kept before pruning. enum RetentionPeriod: String, CaseIterable { case unlimited @@ -81,6 +101,7 @@ final class SettingsStore { static let excludedBundleIDsKey = "excludedBundleIDs" static let hideDuringScreenSharingKey = "hideDuringScreenSharing" static let compactShelfKey = "compactShelf" + static let shelfThemeKey = "shelfTheme" static let shelfProDarkKey = "shelfProDark" static let hideMenuBarIconKey = "hideMenuBarIcon" static let doubleClickToPasteKey = "doubleClickToPaste" @@ -141,17 +162,11 @@ final class SettingsStore { } } - /// A fixed "pro dark" look for the shelf and paste stack: a forced dark appearance - /// plus an electric-blue accent, regardless of the system appearance or accent color - /// (so the app matches its own marketing look). Off by default, so the shelf follows - /// the system otherwise. `onShelfProDarkChange` pushes it to the panel controllers - /// (which set the window appearance live); `ShelfRootView` reads it via - /// `ShelfViewModel.settings` to apply the tint. - var shelfProDark: Bool { + var shelfTheme: ShelfTheme { didSet { - guard shelfProDark != oldValue else { return } - defaults.set(shelfProDark, forKey: Self.shelfProDarkKey) - onShelfProDarkChange?(shelfProDark) + guard shelfTheme != oldValue else { return } + defaults.set(shelfTheme.rawValue, forKey: Self.shelfThemeKey) + onShelfThemeChange?(shelfTheme) } } @@ -201,7 +216,7 @@ final class SettingsStore { @ObservationIgnored var onShowOnboarding: (() -> Void)? @ObservationIgnored var onHideDuringScreenSharingChange: ((Bool) -> Void)? @ObservationIgnored var onCompactShelfChange: ((Bool) -> Void)? - @ObservationIgnored var onShelfProDarkChange: ((Bool) -> Void)? + @ObservationIgnored var onShelfThemeChange: ((ShelfTheme) -> Void)? @ObservationIgnored var onHideMenuBarIconChange: ((Bool) -> Void)? /// Not backed by a stored property here — the shelf summon hotkey itself lives in /// `KeyboardShortcuts`' own storage (see `KeyboardShortcuts.Name.toggleShelf`), not @@ -225,7 +240,13 @@ final class SettingsStore { recognizeImageText = (defaults.object(forKey: Self.recognizeImageTextKey) as? Bool) ?? true hideDuringScreenSharing = (defaults.object(forKey: Self.hideDuringScreenSharingKey) as? Bool) ?? false compactShelf = (defaults.object(forKey: Self.compactShelfKey) as? Bool) ?? false - shelfProDark = (defaults.object(forKey: Self.shelfProDarkKey) as? Bool) ?? false + if let raw = defaults.string(forKey: Self.shelfThemeKey), let saved = ShelfTheme(rawValue: raw) { + shelfTheme = saved + } else { + let migrated: ShelfTheme = defaults.bool(forKey: Self.shelfProDarkKey) ? .dark : .system + shelfTheme = migrated + defaults.set(migrated.rawValue, forKey: Self.shelfThemeKey) + } hideMenuBarIcon = (defaults.object(forKey: Self.hideMenuBarIconKey) as? Bool) ?? false doubleClickToPaste = (defaults.object(forKey: Self.doubleClickToPasteKey) as? Bool) ?? true copySound = defaults.string(forKey: Self.copySoundKey) diff --git a/Copy/Settings/SettingsWindowController.swift b/Copy/Settings/SettingsWindowController.swift index ec541d6..b5c3ac1 100644 --- a/Copy/Settings/SettingsWindowController.swift +++ b/Copy/Settings/SettingsWindowController.swift @@ -27,6 +27,7 @@ final class SettingsWindowController: NSWindowController { hosting.sizingOptions = [] let window = NSWindow(contentViewController: hosting) window.title = "Settings" + window.appearance = settings.shelfTheme.appearance window.styleMask = [.titled, .closable, .miniaturizable, .resizable] window.isReleasedWhenClosed = false window.contentMinSize = NSSize(width: 640, height: 460) @@ -34,6 +35,10 @@ final class SettingsWindowController: NSWindowController { self.init(window: window) } + func setTheme(_ theme: ShelfTheme) { + window?.appearance = theme.appearance + } + func show() { // Only center on first appearance — recentering on every reopen would undo a // user-moved window position. diff --git a/Copy/Shelf/ShelfModalHostView.swift b/Copy/Shelf/ShelfModalHostView.swift index 6db01cb..c50ae4d 100644 --- a/Copy/Shelf/ShelfModalHostView.swift +++ b/Copy/Shelf/ShelfModalHostView.swift @@ -17,7 +17,7 @@ struct ShelfModalHostView: View { content } .frame(maxWidth: .infinity, maxHeight: .infinity) - .tint(viewModel.settings.shelfProDark ? Tokens.electricBlue : nil) + .tint(viewModel.settings.shelfTheme == .dark ? Tokens.electricBlue : nil) } @ViewBuilder diff --git a/Copy/Shelf/ShelfPanelController.swift b/Copy/Shelf/ShelfPanelController.swift index aa7d72f..c9946d6 100644 --- a/Copy/Shelf/ShelfPanelController.swift +++ b/Copy/Shelf/ShelfPanelController.swift @@ -52,21 +52,20 @@ final class ShelfPanelController: NSObject, NSWindowDelegate { private var closeToken = 0 private var hideDuringScreenSharing: Bool private var compactShelf: Bool - private var proDark: Bool + private var theme: ShelfTheme - init(hideDuringScreenSharing: Bool, compactShelf: Bool, proDark: Bool, makeContent: @escaping () -> NSView) { + init(hideDuringScreenSharing: Bool, compactShelf: Bool, theme: ShelfTheme, makeContent: @escaping () -> NSView) { self.hideDuringScreenSharing = hideDuringScreenSharing self.compactShelf = compactShelf - self.proDark = proDark + self.theme = theme self.makeContent = makeContent } - /// Pushed live by `AppCoordinator` via `SettingsStore.onShelfProDarkChange`. Forces - /// the panel (and its hosted SwiftUI content) to a dark appearance so the whole shelf - /// matches the marketing "pro dark" look; `nil` returns to following the system. - func setProDark(_ on: Bool) { - proDark = on - panel?.appearance = on ? NSAppearance(named: .darkAqua) : nil + /// Updates existing shelf and modal windows; nil appearance follows macOS. + func setTheme(_ theme: ShelfTheme) { + self.theme = theme + panel?.appearance = theme.appearance + modalPanel?.appearance = theme.appearance } private var currentShelfHeight: CGFloat { @@ -220,7 +219,7 @@ final class ShelfPanelController: NSObject, NSWindowDelegate { panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] panel.isOpaque = false panel.backgroundColor = .clear - panel.appearance = proDark ? NSAppearance(named: .darkAqua) : nil + panel.appearance = theme.appearance panel.hidesOnDeactivate = false panel.becomesKeyOnlyIfNeeded = false panel.isFloatingPanel = true @@ -318,6 +317,7 @@ final class ShelfPanelController: NSObject, NSWindowDelegate { host.isOpaque = false host.backgroundColor = .clear host.hasShadow = false + host.appearance = theme.appearance host.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] host.hidesOnDeactivate = false host.sharingType = hideDuringScreenSharing ? .none : .readOnly diff --git a/Copy/Shelf/ShelfRootView.swift b/Copy/Shelf/ShelfRootView.swift index 7e5889d..095d4ea 100644 --- a/Copy/Shelf/ShelfRootView.swift +++ b/Copy/Shelf/ShelfRootView.swift @@ -76,7 +76,7 @@ struct ShelfRootView: View { // Pro-dark: force the marketing electric-blue accent regardless of the system // accent color. The forced dark appearance itself is set on the panel window in // `ShelfPanelController`, which cascades to this hosted content. - .tint(viewModel.settings.shelfProDark ? Tokens.electricBlue : nil) + .tint(viewModel.settings.shelfTheme == .dark ? Tokens.electricBlue : nil) // Edit/Create/Adjust Color/Tips are shown as a centered child window over the // shelf (see `ShelfModalHostView`), not as attached sheets that overflow off the // bottom of the screen. This view is always on screen while the shelf is open, so diff --git a/Scripts/Tests/AppearanceTests.swift b/Scripts/Tests/AppearanceTests.swift new file mode 100644 index 0000000..6a63263 --- /dev/null +++ b/Scripts/Tests/AppearanceTests.swift @@ -0,0 +1,33 @@ +import AppKit +import Foundation + +@main +struct AppearanceTests { + @MainActor static func main() { + for legacy in [false, true] { + let name = "CopyTests.Appearance.\(UUID().uuidString)" + let defaults = UserDefaults(suiteName: name)! + defer { defaults.removePersistentDomain(forName: name) } + defaults.set(legacy, forKey: SettingsStore.shelfProDarkKey) + let settings = SettingsStore(defaults: defaults) + precondition(settings.shelfTheme == (legacy ? .dark : .system)) + var callbacks = 0 + settings.onShelfThemeChange = { _ in callbacks += 1 } + let current = settings.shelfTheme + settings.shelfTheme = current + precondition(callbacks == 0) + for theme in ShelfTheme.allCases { + settings.shelfTheme = theme + precondition(SettingsStore(defaults: defaults).shelfTheme == theme) + } + defaults.set("light", forKey: SettingsStore.shelfThemeKey) + precondition(SettingsStore(defaults: defaults).shelfTheme == .light) + defaults.set("invalid", forKey: SettingsStore.shelfThemeKey) + precondition(SettingsStore(defaults: defaults).shelfTheme == (legacy ? .dark : .system)) + } + precondition(ShelfTheme.system.appearance == nil) + precondition(ShelfTheme.light.appearance?.name == .aqua) + precondition(ShelfTheme.dark.appearance?.name == .darkAqua) + print("PASS: legacy migration, saved choice, round-trip, callbacks and appearance mapping") + } +}