Skip to content
Merged
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
14 changes: 10 additions & 4 deletions Copy/App/AppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ final class AppCoordinator {
let isDemoMode: Bool
/// `UserDefaults` key the status-menu toggle flips; read at launch to enter demo mode.
static let demoModeKey = "demoMode"
private(set) lazy var shelfViewModel = ShelfViewModel(store: store, pinboardStore: pinboardStore, settings: settings)
private(set) lazy var shelfViewModel = ShelfViewModel(
store: store,
pinboardStore: pinboardStore,
settings: settings,
linkFetcher: linkFetcher
)
private(set) lazy var linkFetcher = LinkMetadataFetcher(store: store)
private(set) lazy var ocrController = OCRController(store: store)
private(set) lazy var archiveController = ArchiveController(store: store, pinboardStore: pinboardStore)
Expand All @@ -38,7 +43,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 @@ -387,8 +392,9 @@ final class AppCoordinator {
settings.onCompactShelfChange = { [weak self] compact in
self?.shelfController.setCompactShelf(compact)
}
settings.onShelfProDarkChange = { [weak self] proDark in
self?.shelfController.setProDark(proDark)
settings.onShelfThemeChange = { [weak self] theme in
self?.shelfController.setTheme(theme)
self?.settingsWindowController.setTheme(theme)
}
settings.onShowOnboarding = { [weak self] in
self?.showOnboarding()
Expand Down
11 changes: 6 additions & 5 deletions Copy/Settings/GeneralSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,12 @@ struct GeneralSettings: View {
}

Section {
Toggle("Always Use Dark Shelf", isOn: $settings.shelfProDark)
} footer: {
Text("Keeps the shelf dark with a blue accent, even in Light Mode. Off by default, so it follows your system appearance.")
.font(.footnote)
.foregroundStyle(.secondary)
Picker("Theme", selection: $settings.shelfTheme) {
ForEach(ShelfTheme.allCases) { theme in
Text(theme.title).tag(theme)
}
}
.pickerStyle(.menu)
}

Section {
Expand Down
60 changes: 48 additions & 12 deletions Copy/Settings/SettingsStore.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AppKit
import Foundation
import Observation

Expand Down Expand Up @@ -27,6 +28,32 @@ enum CopySound: String, CaseIterable, Identifiable {
}
}

/// Appearance used by the shelf and its modal content. Raw values are persisted, so
/// keep them stable across releases.
enum ShelfTheme: String, CaseIterable, Identifiable {
case system
case light
case 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: nil
case .light: NSAppearance(named: .aqua)
case .dark: NSAppearance(named: .darkAqua)
}
}
}

/// How long unpinned history items are kept before pruning.
enum RetentionPeriod: String, CaseIterable {
case unlimited
Expand Down Expand Up @@ -94,6 +121,7 @@ final class SettingsStore {
excludedBundleIDsKey,
hideDuringScreenSharingKey,
compactShelfKey,
shelfThemeKey,
shelfProDarkKey,
hideMenuBarIconKey,
doubleClickToPasteKey,
Expand All @@ -105,6 +133,8 @@ final class SettingsStore {
static let excludedBundleIDsKey = "excludedBundleIDs"
static let hideDuringScreenSharingKey = "hideDuringScreenSharing"
static let compactShelfKey = "compactShelf"
static let shelfThemeKey = "shelfTheme"
/// Previous binary theme preference, retained only to migrate existing profiles.
static let shelfProDarkKey = "shelfProDark"
static let hideMenuBarIconKey = "hideMenuBarIcon"
static let doubleClickToPasteKey = "doubleClickToPaste"
Expand Down Expand Up @@ -163,17 +193,14 @@ final class SettingsStore {
}
}

/// A fixed "pro dark" look for the shelf: 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 controller
/// (which sets the window appearance live); `ShelfRootView` reads it via
/// `ShelfViewModel.settings` to apply the tint.
var shelfProDark: Bool {
/// Controls the shelf independently of the system appearance when requested.
/// `onShelfThemeChange` pushes the choice to the panel controller live;
/// `ShelfRootView` reads it to retain the electric-blue accent in Dark mode.
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 @@ -223,7 +250,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 Down Expand Up @@ -262,7 +289,16 @@ 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
let initialShelfTheme: ShelfTheme
if let rawTheme = defaults.string(forKey: Self.shelfThemeKey),
let savedTheme = ShelfTheme(rawValue: rawTheme) {
initialShelfTheme = savedTheme
} else {
// Preserve the old toggle exactly: on meant Dark, off meant system-driven.
initialShelfTheme = defaults.bool(forKey: Self.shelfProDarkKey) ? .dark : .system
defaults.set(initialShelfTheme.rawValue, forKey: Self.shelfThemeKey)
}
shelfTheme = initialShelfTheme
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 @@ -32,6 +32,11 @@ final class SettingsWindowController: NSWindowController {
window.contentMinSize = NSSize(width: 640, height: 460)
window.setContentSize(NSSize(width: 720, height: 520))
self.init(window: window)
setTheme(settings.shelfTheme)
}

func setTheme(_ theme: ShelfTheme) {
window?.appearance = theme.appearance
}

func show() {
Expand Down
66 changes: 26 additions & 40 deletions Copy/Shelf/ItemCardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -431,36 +431,22 @@ struct ItemCardView: View {
case .text, .richText:
textBody
case .link:
if let linkTitle = item.linkTitle {
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 4) {
LinkFaviconView(item: item, store: store)
Text(URL(string: item.plainText ?? "")?.host ?? "Link")
.font(Tokens.cardSubtitle)
.lineLimit(1)
}
Text(linkTitle)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 5) {
LinkFaviconView(item: item, store: store)
Text(item.linkTitle ?? URL(string: item.plainText ?? "")?.host ?? "Link")
.font(.system(size: 13, weight: .semibold))
.lineLimit(bodyLineLimit(standard: 2, compact: 1))
.multilineTextAlignment(.leading)
Text(String((item.plainText ?? "").prefix(1_500)))
.font(Tokens.cardBody)
.foregroundStyle(.secondary)
.lineLimit(bodyLineLimit(standard: 2, compact: 1))
}
} else {
VStack(alignment: .leading, spacing: 4) {
Image(systemName: "link")
.font(.system(size: 14))
.foregroundStyle(.secondary)
Text(URL(string: item.plainText ?? "")?.host ?? "Link")
.font(Tokens.cardSubtitle)
.lineLimit(1)
Text(String((item.plainText ?? "").prefix(1_500)))
.font(Tokens.cardBody)
.foregroundStyle(.secondary)
.lineLimit(bodyLineLimit(standard: 5, compact: 3))
}
// URLs are identifiers, not prose: let SwiftUI wrap long path segments
// character-by-character instead of truncating the useful tail.
Text(String((item.plainText ?? "").prefix(1_500)))
.font(Tokens.cardBody)
.foregroundStyle(.secondary)
.lineLimit(nil)
.fixedSize(horizontal: false, vertical: true)
.multilineTextAlignment(.leading)
}
case .image:
imageBody
Expand Down Expand Up @@ -506,7 +492,7 @@ struct ItemCardView: View {
}
return "\(item.plainText?.count ?? 0) characters"
case .link:
return URL(string: item.plainText ?? "")?.host ?? "Link"
return "Link"
case .image:
return "Image"
case .file:
Expand All @@ -529,21 +515,21 @@ struct LinkFaviconView: View {
Image(nsImage: image)
.resizable()
.aspectRatio(contentMode: .fit)
} else {
Image(systemName: "link")
.resizable()
.aspectRatio(contentMode: .fit)
.foregroundStyle(.secondary)
.frame(width: 16, height: 16)
}
}
.frame(width: 16, height: 16)
.onAppear {
if image == nil {
image = FaviconCache.shared.cached(for: item)
if image == nil {
FaviconCache.shared.favicon(for: item, store: store) { image = $0 }
}
}
.onAppear(perform: loadImage)
// Metadata persistence updates the item title after writing its favicon. The
// view may already be mounted by then, so `onAppear` alone would leave its
// earlier nil lookup stuck until the app relaunched.
.onChange(of: item.linkTitle) { _, _ in loadImage() }
}

private func loadImage() {
guard image == nil else { return }
image = FaviconCache.shared.cached(for: item)
if image == nil {
FaviconCache.shared.favicon(for: item, store: store) { image = $0 }
}
}
}
Expand Down
Loading