Skip to content
Closed
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
11 changes: 6 additions & 5 deletions Copy/App/AppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
})
Expand All @@ -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))
}
Expand Down Expand Up @@ -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()
Expand Down
31 changes: 13 additions & 18 deletions Copy/PasteStack/PasteStackController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions Copy/PasteStack/PasteStackView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
}
}
7 changes: 5 additions & 2 deletions Copy/Settings/GeneralSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
45 changes: 33 additions & 12 deletions Copy/Settings/SettingsStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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)
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions Copy/Settings/SettingsWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,18 @@ 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)
window.setContentSize(NSSize(width: 720, height: 520))
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.
Expand Down
2 changes: 1 addition & 1 deletion Copy/Shelf/ShelfModalHostView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 10 additions & 10 deletions Copy/Shelf/ShelfPanelController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion Copy/Shelf/ShelfRootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions Scripts/Tests/AppearanceTests.swift
Original file line number Diff line number Diff line change
@@ -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")
}
}