diff --git a/Copy/App/AppCoordinator.swift b/Copy/App/AppCoordinator.swift index 124852e..90d4743 100644 --- a/Copy/App/AppCoordinator.swift +++ b/Copy/App/AppCoordinator.swift @@ -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) @@ -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)) } @@ -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() diff --git a/Copy/Settings/GeneralSettings.swift b/Copy/Settings/GeneralSettings.swift index 288652c..c532a76 100644 --- a/Copy/Settings/GeneralSettings.swift +++ b/Copy/Settings/GeneralSettings.swift @@ -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 { diff --git a/Copy/Settings/SettingsStore.swift b/Copy/Settings/SettingsStore.swift index 76d2c4d..3466c45 100644 --- a/Copy/Settings/SettingsStore.swift +++ b/Copy/Settings/SettingsStore.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation import Observation @@ -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 @@ -94,6 +121,7 @@ final class SettingsStore { excludedBundleIDsKey, hideDuringScreenSharingKey, compactShelfKey, + shelfThemeKey, shelfProDarkKey, hideMenuBarIconKey, doubleClickToPasteKey, @@ -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" @@ -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) } } @@ -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 @@ -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) diff --git a/Copy/Settings/SettingsWindowController.swift b/Copy/Settings/SettingsWindowController.swift index ec541d6..8bbaafa 100644 --- a/Copy/Settings/SettingsWindowController.swift +++ b/Copy/Settings/SettingsWindowController.swift @@ -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() { diff --git a/Copy/Shelf/ItemCardView.swift b/Copy/Shelf/ItemCardView.swift index 5172c18..ba937db 100644 --- a/Copy/Shelf/ItemCardView.swift +++ b/Copy/Shelf/ItemCardView.swift @@ -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 @@ -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: @@ -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 } } } } diff --git a/Copy/Shelf/PinboardEditPopover.swift b/Copy/Shelf/PinboardEditPopover.swift index 3f2c8f6..07916e2 100644 --- a/Copy/Shelf/PinboardEditPopover.swift +++ b/Copy/Shelf/PinboardEditPopover.swift @@ -1,23 +1,15 @@ import SwiftUI import CopyCore -/// Create/rename UI for a pinboard: name field + symbol picker + emoji picker + color -/// swatches, shared by the "+" button (create mode) and a tab's "Rename…" context menu -/// item (rename mode). Emoji and color are optional, user-chosen identity (like Finder -/// tags or Things areas) — never an auto-applied card decoration. +/// Create/rename UI for a pinboard: name field + emoji picker, shared by the "+" +/// button (create mode) and a tab's "Edit…" context menu item. Emoji is the only +/// optional visual identity shown on pinboard tabs. struct PinboardEditPopover: View { enum Mode { case create case rename(Pinboard) } - static let symbols = [ - "pin", "star", "folder", "briefcase", "chevron.left.forwardslash.chevron.right", - "doc.text", "photo", "link", "envelope", "cart", "creditcard", "key", - "terminal", "paintbrush", "book", "bookmark", "tag", "tray", - "archivebox", "calendar", "person", "globe", "lightbulb", "heart", - ] - /// Curated emoji categories for the full picker, modeled on the standard system /// character-viewer groupings. Not exhaustive (Unicode defines thousands of /// emoji) but broad enough that any commonly used emoji, including ZWJ sequences @@ -120,46 +112,25 @@ struct PinboardEditPopover: View { } } - /// A curated, system-ish palette. Empty hex means "no color". - static let colorOptions: [(name: String, hex: String)] = [ - ("None", ""), - ("Red", "FF3B30"), - ("Orange", "FF9500"), - ("Yellow", "FFCC00"), - ("Green", "34C759"), - ("Blue", "007AFF"), - ("Purple", "AF52DE"), - ("Pink", "FF2D55"), - ("Graphite", "8E8E93"), - ] - let mode: Mode - let onCommit: (String, String, String?, String) -> Void + let onCommit: (String, String?) -> Void @Environment(\.dismiss) private var dismiss @State private var name: String - @State private var symbol: String @State private var emoji: String? - @State private var tint: String @State private var emojiCategoryIndex = 0 @FocusState private var nameFocused: Bool - init(mode: Mode, onCommit: @escaping (String, String, String?, String) -> Void) { + init(mode: Mode, onCommit: @escaping (String, String?) -> Void) { self.mode = mode self.onCommit = onCommit switch mode { case .create: _name = State(initialValue: "") - _symbol = State(initialValue: "pin") _emoji = State(initialValue: nil) - // New pinboards get a color by default (Blue) since the color is now their - // primary identity on the tabs; the user can change or clear it below. - _tint = State(initialValue: "007AFF") case .rename(let pinboard): _name = State(initialValue: pinboard.name) - _symbol = State(initialValue: pinboard.symbol) _emoji = State(initialValue: pinboard.emoji) - _tint = State(initialValue: pinboard.tint) } } @@ -254,30 +225,6 @@ struct PinboardEditPopover: View { .frame(height: 168) } - VStack(alignment: .leading, spacing: 6) { - Text("Color") - .font(.caption) - .foregroundStyle(.secondary) - HStack(spacing: 5) { - ForEach(Self.colorOptions, id: \.hex) { option in - ColorSwatch(name: option.name, hex: option.hex, isSelected: option.hex == tint) { - tint = option.hex - } - } - // Custom color: the system picker, selected when the tint isn't a preset. - ColorPicker("", selection: customColorBinding, supportsOpacity: false) - .labelsHidden() - .frame(width: 22, height: 22) - .help("Custom color") - .overlay( - Circle() - .strokeBorder(Color.accentColor, lineWidth: 2) - .padding(-2) - .opacity(isCustomTint ? 1 : 0) - ) - } - } - HStack { Spacer() Button("Cancel") { dismiss() } @@ -291,62 +238,14 @@ struct PinboardEditPopover: View { .onAppear { nameFocused = true } } - /// True when the current tint isn't one of the presets — i.e. a custom color. - private var isCustomTint: Bool { - !tint.isEmpty && !Self.colorOptions.contains { $0.hex == tint } - } - - /// Bridges the `tint` hex (stored without a leading `#`) to the system `ColorPicker`. - private var customColorBinding: Binding { - Binding( - get: { tint.isEmpty ? Color.accentColor : Tokens.color(fromHex: tint) }, - set: { tint = Tokens.hex(from: $0).replacingOccurrences(of: "#", with: "") } - ) - } - private func commit() { guard !trimmedName.isEmpty else { return } Self.recordRecent(emoji) - onCommit(trimmedName, symbol, emoji, tint) + onCommit(trimmedName, emoji) dismiss() } } -private struct SymbolSwatch: View { - let symbol: String - let isSelected: Bool - let action: () -> Void - @State private var isHovering = false - - /// Human-readable name for VoiceOver; most symbol identifiers already read fine as - /// a single word, a couple need a friendlier label. - private var accessibleName: String { - switch symbol { - case "chevron.left.forwardslash.chevron.right": return "Code" - case "doc.text": return "Document" - case "creditcard": return "Credit Card" - default: return symbol.capitalized - } - } - - var body: some View { - Button(action: action) { - Image(systemName: symbol) - .font(.system(size: 13)) - .foregroundStyle(isSelected ? Color.white : Color.primary.opacity(0.8)) - .frame(width: 30, height: 30) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(isSelected ? Color.accentColor : (isHovering ? Color.primary.opacity(0.08) : .clear)) - ) - } - .buttonStyle(.plain) - .onHover { isHovering = $0 } - .accessibilityLabel(accessibleName) - .accessibilityAddTraits(isSelected ? .isSelected : []) - } -} - private struct EmojiSwatch: View { let emoji: String let isSelected: Bool @@ -371,7 +270,7 @@ private struct EmojiSwatch: View { } /// A small category tab above the emoji grid (Recent + the standard groupings). -/// Icon-only with a tooltip, mirroring `SymbolSwatch`'s selection styling. +/// Icon-only with a tooltip, using the same quiet selection styling as emoji swatches. private struct EmojiCategoryTab: View { let symbol: String let name: String @@ -397,38 +296,3 @@ private struct EmojiCategoryTab: View { .accessibilityAddTraits(isSelected ? .isSelected : []) } } - -private struct ColorSwatch: View { - let name: String - let hex: String - let isSelected: Bool - let action: () -> Void - - private var swatchColor: Color? { - hex.isEmpty ? nil : Tokens.color(fromHex: hex) - } - - var body: some View { - Button(action: action) { - ZStack { - Circle() - .fill(swatchColor ?? Color.clear) - if swatchColor == nil { - Circle().stroke(Color.primary.opacity(0.35), lineWidth: 1) - Image(systemName: "slash.circle") - .font(.system(size: 11)) - .foregroundStyle(.secondary) - } - } - .frame(width: 20, height: 20) - .overlay( - Circle() - .stroke(Color.primary, lineWidth: isSelected ? 2 : 0) - .padding(-2) - ) - } - .buttonStyle(.plain) - .accessibilityLabel(name) - .accessibilityAddTraits(isSelected ? .isSelected : []) - } -} 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 3505c6a..cc532a2 100644 --- a/Copy/Shelf/ShelfPanelController.swift +++ b/Copy/Shelf/ShelfPanelController.swift @@ -24,10 +24,6 @@ final class ShelfPanelController: NSObject, NSWindowDelegate { /// horizontal sides makes the panel read as one floating surface, while the bottom /// gap leaves room for its newly rounded lower corners and shadow. static let shelfInset: CGFloat = 12 - /// Liquid Glass lenses and shadows slightly outside its shape. The window needs a - /// transparent gutter for that optical edge; otherwise WindowServer clips it at the - /// rectangular panel bounds. `ShelfRootView` applies the matching outer padding. - static let glassBleed: CGFloat = 8 /// Panel height while `SettingsStore.compactShelf` is on, sized to `Tokens.compactCardHeight` /// plus the same header/divider/padding chrome `shelfHeight` allows for above the /// (shorter) card row. `ShelfHeader` isn't shortened in compact mode, so the fixed @@ -60,21 +56,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 + /// Pushed live by `AppCoordinator` via `SettingsStore.onShelfThemeChange`. + func setTheme(_ theme: ShelfTheme) { + self.theme = theme + panel?.appearance = theme.appearance + modalPanel?.appearance = theme.appearance } private var currentShelfHeight: CGFloat { @@ -82,11 +77,16 @@ final class ShelfPanelController: NSObject, NSWindowDelegate { } private func shelfFrame(in visibleFrame: NSRect) -> NSRect { - let windowInset = Self.shelfInset - Self.glassBleed - return NSRect(x: visibleFrame.minX + windowInset, - y: visibleFrame.minY + windowInset, - width: visibleFrame.width - (windowInset * 2), - height: currentShelfHeight + (Self.glassBleed * 2)) + NSRect(x: visibleFrame.minX + Self.shelfInset, + y: visibleFrame.minY + Self.shelfInset, + width: visibleFrame.width - (Self.shelfInset * 2), + height: currentShelfHeight) + } + + /// Places the shelf immediately beyond the same bottom edge it is attached to, so + /// its transition has a clear spatial origin instead of looking like a dissolve. + private func frameBelowScreen(_ frame: NSRect, visibleFrame: NSRect) -> NSRect { + frame.offsetBy(dx: 0, dy: visibleFrame.minY - frame.maxY) } /// Applied at panel creation and pushed live here when the setting changes @@ -135,15 +135,17 @@ final class ShelfPanelController: NSObject, NSWindowDelegate { isHiding = false let reduceMotion = NSWorkspace.shared.accessibilityDisplayShouldReduceMotion - panel.setFrame(reduceMotion ? frame : frame.offsetBy(dx: 0, dy: -24), display: false) - panel.alphaValue = 0 + panel.setFrame(reduceMotion ? frame : frameBelowScreen(frame, visibleFrame: screen.visibleFrame), display: false) + panel.alphaValue = 1 panel.makeKeyAndOrderFront(nil) - NSApp.activate(ignoringOtherApps: true) - NSAnimationContext.runAnimationGroup { context in - context.duration = 0.22 - context.timingFunction = CAMediaTimingFunction(name: .easeOut) - panel.animator().alphaValue = 1 - panel.animator().setFrame(frame, display: true) + if !reduceMotion { + NSAnimationContext.runAnimationGroup { context in + context.duration = 0.13 + context.timingFunction = CAMediaTimingFunction( + controlPoints: 0.22, 1, 0.36, 1 + ) + panel.animator().setFrame(frame, display: true) + } } installKeyMonitor() // The shelf opens in browse mode: keep the search field from auto-becoming first @@ -162,17 +164,17 @@ final class ShelfPanelController: NSObject, NSWindowDelegate { if let completion { pendingHideCompletions.append(completion) } - // A repeated action during the 180ms fade joins the current close instead of + // A repeated action during the close joins the current transition instead of // being dropped or running before the destination app regains focus. guard !isHiding else { return } - // Keep Copy active until the panel is fully out. On macOS 26 the shelf is live - // Liquid Glass; activating the previous app while that glass is still visible - // changes its sampled backdrop mid-fade and produces a one-frame flash. + // Keep the non-activating panel key until it is fully out. On macOS 26 the shelf + // is live Liquid Glass; returning key focus while that glass is still visible + // changes its sampled backdrop mid-transition and produces a one-frame flash. isHiding = true removeKeyMonitor() - // Mirror of `show()`'s entrance: fade out while sliding down 18pt, then order out. - // Reduce Motion skips straight to the orderOut, matching the entrance's own gate. + // Mirror the entrance direction and move the whole shelf beyond the bottom edge. + // Reduce Motion skips straight to orderOut. if NSWorkspace.shared.accessibilityDisplayShouldReduceMotion { finishHide(panel, restoreFocus: restoreFocus) return @@ -180,11 +182,13 @@ final class ShelfPanelController: NSObject, NSWindowDelegate { closeToken += 1 let token = closeToken + let visibleFrame = panel.screen?.visibleFrame ?? NSScreen.main?.visibleFrame + let destination = visibleFrame.map { frameBelowScreen(panel.frame, visibleFrame: $0) } + ?? panel.frame.offsetBy(dx: 0, dy: -(panel.frame.height + Self.shelfInset)) NSAnimationContext.runAnimationGroup { context in - context.duration = 0.18 - context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) - panel.animator().alphaValue = 0 - panel.animator().setFrame(panel.frame.offsetBy(dx: 0, dy: -18), display: true) + context.duration = 0.13 + context.timingFunction = CAMediaTimingFunction(name: .easeIn) + panel.animator().setFrame(destination, display: true) } completionHandler: { [weak self] in // NSAnimationContext runs its completion on the main thread; the closure's // `@Sendable` type just can't see that statically. @@ -195,15 +199,12 @@ final class ShelfPanelController: NSObject, NSWindowDelegate { } } - /// Orders the panel out and resets it so the next `show()` starts clean. `onDidHide` + /// Orders the panel out after it has cleared the screen. `onDidHide` /// (which clears the shelf's transient selection/preview state) fires here, at the end /// of the close animation, so that content doesn't visibly reset while the panel is - /// still fading out. + /// still sliding out. private func finishHide(_ panel: KeyablePanel, restoreFocus: Bool) { panel.orderOut(nil) - // Leave alpha at zero while hidden. Resetting it to one in the same run-loop - // turn as `orderOut` can race WindowServer and briefly re-show the fully opaque - // surface. `show()` always establishes its own alpha-zero starting state. isHiding = false onDidHide?() if restoreFocus { @@ -224,7 +225,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 @@ -327,6 +328,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 ebb1c51..8ab9847 100644 --- a/Copy/Shelf/ShelfRootView.swift +++ b/Copy/Shelf/ShelfRootView.swift @@ -46,7 +46,6 @@ struct ShelfRootView: View { ShelfHeader(viewModel: viewModel) // Above the cards so the search suggestions dropdown floats over them. .zIndex(1) - Divider() ShelfItemsRow(viewModel: viewModel) if showsLegend { KeyboardLegend(onDismiss: dismissLegend) @@ -54,15 +53,11 @@ struct ShelfRootView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) - // `glassEffect` shapes the material, but does not clip the view hierarchy laid - // over it. Without this, the header/items backgrounds still draw to the hosting - // view's rectangular bounds and leave faint square pixels around every corner. - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - // Apply glass *after* clipping the content so its own lensing and edge remain - // intact, then reserve transparent room for that edge inside the NSPanel. This - // mirrors native floating glass surfaces instead of painting a fixed border. .glassSurface(cornerRadius: 12) - .padding(ShelfPanelController.glassBleed) + // Clip *after* glass is drawn. Clipping only the content leaves the glass + // shader's outer pixels visible in the rectangular NSPanel corners; giving the + // shader an inset gutter instead produces a second outline around the shelf. + .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) // Card → pinboard filing is handled here, at the shelf root, because a per-tab // `.onDrop` never establishes a working drop region on the small pills inside this // borderless non-activating glass panel (a shelf-level drop, by contrast, fires @@ -80,10 +75,9 @@ struct ShelfRootView: View { viewModel.tab = .pinboard(id) } )) - // 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) + // Dark keeps the marketing electric-blue accent. The forced light/dark window + // appearance itself is applied by `ShelfPanelController`. + .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 @@ -266,7 +260,6 @@ private struct ShelfTabs: View { label: pinboard.name, symbol: pinboard.symbol, emoji: pinboard.emoji, - tint: pinboard.tint, showsSymbol: false, isSelected: pinboard.id.map { viewModel.tab == .pinboard($0) } ?? false, isDropTargeted: pinboard.id != nil && viewModel.dropTargetedPinboardID == pinboard.id, @@ -285,8 +278,8 @@ private struct ShelfTabs: View { } ) .contextMenu { - // "Edit…" opens PinboardEditPopover, which edits name, symbol, emoji, - // and color (including a custom color picker) in one place. + // "Edit…" opens PinboardEditPopover, which edits the pinboard's + // name and emoji in one place. Button("Edit…") { renamingPinboard = pinboard } Button("Delete Pinboard", role: .destructive) { confirmDelete(pinboard) } } @@ -294,12 +287,10 @@ private struct ShelfTabs: View { get: { pinboard.id != nil && renamingPinboard?.id == pinboard.id }, set: { if !$0 { renamingPinboard = nil } } )) { - PinboardEditPopover(mode: .rename(pinboard)) { name, symbol, emoji, tint in + PinboardEditPopover(mode: .rename(pinboard)) { name, emoji in if let id = pinboard.id { viewModel.renamePinboard(id: id, to: name) - viewModel.setPinboardSymbol(id: id, symbol) viewModel.setPinboardEmoji(id: id, emoji) - viewModel.setPinboardTint(id: id, tint) } renamingPinboard = nil } @@ -322,8 +313,8 @@ private struct ShelfTabs: View { createPresented = true } .popover(isPresented: $createPresented) { - PinboardEditPopover(mode: .create) { name, symbol, emoji, tint in - viewModel.createPinboard(name: name, symbol: symbol, emoji: emoji, tint: tint) + PinboardEditPopover(mode: .create) { name, emoji in + viewModel.createPinboard(name: name, emoji: emoji) } } } @@ -331,9 +322,9 @@ private struct ShelfTabs: View { // onto a pinboard tab routes to THIS panel instead of passing through the glass // surface to the window behind (which pasted the card's text into the app // underneath). The window server treats a pixel as pass-through only when its - // composited alpha rounds to zero, so this needs alpha above 1/255 (0.001 rounds - // to 0 and still passed drags through); 0.02 is ~2% and imperceptible over glass. - .background(Color.black.opacity(0.02)) + // composited alpha rounds to zero. A dynamic system background at 1% stays above + // that threshold while visually merging into both light and dark shelf material. + .background(Color(nsColor: .windowBackgroundColor).opacity(0.01)) .onChange(of: createPresented) { _, isPresented in viewModel.pinboardPopoverShown = isPresented || renamingPinboard != nil } @@ -363,13 +354,10 @@ private struct ShelfTabs: View { private struct TabPill: View { let label: String let symbol: String - /// A user-chosen emoji shown in place of the SF Symbol, when set. `nil`/untinted - /// pinboards (and the History tab) render exactly as before this feature. + /// A user-chosen emoji shown in place of the SF Symbol, when set. var emoji: String? = nil - /// A user-chosen hex color (e.g. "FF3B30"); empty means no color identity. - var tint: String = "" /// Whether to draw the SF Symbol when no emoji is set. History uses it (a clock); - /// pinboards don't — their identity is the color dot and optional emoji. + /// pinboards don't — their optional emoji is the only visual identity. var showsSymbol: Bool = true let isSelected: Bool var isDropTargeted: Bool = false @@ -378,10 +366,6 @@ private struct TabPill: View { let action: () -> Void @State private var isHovering = false - private var tintColor: Color? { - tint.isEmpty ? nil : Tokens.color(fromHex: tint) - } - var body: some View { HStack(spacing: 4) { if let emoji, !emoji.isEmpty { @@ -390,12 +374,6 @@ private struct TabPill: View { Image(systemName: symbol) } Text(label) - if let tintColor { - Circle() - .fill(tintColor) - .frame(width: 6, height: 6) - .accessibilityHidden(true) - } if let shortcutHint { Text("⌘\(shortcutHint)") .font(.system(size: 9, weight: .semibold, design: .rounded)) @@ -435,10 +413,7 @@ private struct TabPill: View { private var backgroundFill: Color { if isDropTargeted { return Color.accentColor.opacity(0.24) } - if isSelected { - if let tintColor { return tintColor.opacity(0.18) } - return Color.primary.opacity(0.08) - } + if isSelected { return Color.primary.opacity(0.08) } return isHovering ? Color.primary.opacity(0.05) : .clear } } @@ -510,7 +485,10 @@ private struct ShelfItemsRow: View { // The LazyHStack only builds a card once it scrolls into view, // so this fires as the user nears the oldest card and widens the // fetch window. Without it the shelf stopped at the first page. - .onAppear { viewModel.loadMoreIfNeeded(at: index) } + .onAppear { + viewModel.loadMoreIfNeeded(at: index) + viewModel.fetchLinkPreviewIfNeeded(for: item) + } .popover(isPresented: Binding( get: { viewModel.isSelected(item) && item.uuid == viewModel.selection.primary && viewModel.previewShown }, set: { viewModel.previewShown = $0 } @@ -520,7 +498,8 @@ private struct ShelfItemsRow: View { } } .padding(.horizontal, Tokens.shelfPadding(compact: compact)) - .padding(.vertical, compact ? 10 : 16) + .padding(.top, compact ? 4 : 8) + .padding(.bottom, compact ? 10 : 16) // Give the whole row (including the gaps between cards) a real backing // view so a scroll wheel over a gap routes to the scroll view instead // of passing through to the panel behind. A near-zero-opacity fill is diff --git a/Copy/Shelf/ShelfViewModel.swift b/Copy/Shelf/ShelfViewModel.swift index 6758c36..7bb7454 100644 --- a/Copy/Shelf/ShelfViewModel.swift +++ b/Copy/Shelf/ShelfViewModel.swift @@ -22,6 +22,7 @@ final class ShelfViewModel { let store: ItemStore let pinboardStore: PinboardStore + private let linkFetcher: LinkMetadataFetcher /// Held so `ShelfRootView`/`ItemCardView` can read `settings.compactShelf` live — /// both this view model and `SettingsStore` are `@Observable`, so a body that reads /// `viewModel.settings.compactShelf` re-renders on toggle without any extra @@ -146,10 +147,16 @@ final class ShelfViewModel { /// every item on the board, so there's nothing to page there. @ObservationIgnored private var isPaged = true - init(store: ItemStore, pinboardStore: PinboardStore, settings: SettingsStore) { + init( + store: ItemStore, + pinboardStore: PinboardStore, + settings: SettingsStore, + linkFetcher: LinkMetadataFetcher + ) { self.store = store self.pinboardStore = pinboardStore self.settings = settings + self.linkFetcher = linkFetcher pinboardsToken = pinboardStore.observeAll( onError: { NSLog("Copy: pinboard observation failed: \($0)") }, onChange: { [weak self] in self?.pinboards = $0 }) @@ -165,6 +172,13 @@ final class ShelfViewModel { selection.selected.contains(item.uuid) } + /// Backfills metadata for older visible links after link previews are enabled. + /// Fresh captures already take the same path in `AppCoordinator`; calling it here + /// is cheap because `LinkMetadataFetcher` skips resolved and in-flight items. + func fetchLinkPreviewIfNeeded(for item: ClipItem) { + linkFetcher.fetchIfNeeded(for: item, enabled: settings.fetchLinkPreviews) + } + var orderedSelectedItems: [ClipItem] { let ordered = selection.orderedSelection(in: items.map(\.uuid)) return ordered.compactMap { uuid in items.first(where: { $0.uuid == uuid }) } @@ -525,9 +539,9 @@ final class ShelfViewModel { // MARK: - Pinboard actions passthrough - func createPinboard(name: String, symbol: String, emoji: String? = nil, tint: String = "") { + func createPinboard(name: String, emoji: String? = nil) { do { - try pinboardStore.create(name: name, symbol: symbol, emoji: emoji, tint: tint) + try pinboardStore.create(name: name, symbol: "pin", emoji: emoji, tint: "") } catch { NSLog("Copy: failed to create pinboard: \(error)") HUD.show("Couldn't complete that") @@ -543,15 +557,6 @@ final class ShelfViewModel { } } - func setPinboardSymbol(id: Int64, _ symbol: String) { - do { - try pinboardStore.setSymbol(id: id, symbol) - } catch { - NSLog("Copy: failed to set pinboard symbol: \(error)") - HUD.show("Couldn't complete that") - } - } - func setPinboardEmoji(id: Int64, _ emoji: String?) { do { try pinboardStore.setEmoji(id: id, emoji) @@ -561,15 +566,6 @@ final class ShelfViewModel { } } - func setPinboardTint(id: Int64, _ tint: String) { - do { - try pinboardStore.setTint(id: id, tint) - } catch { - NSLog("Copy: failed to set pinboard tint: \(error)") - HUD.show("Couldn't complete that") - } - } - func deletePinboard(id: Int64) { do { try pinboardStore.delete(id: id)