From 97b19ee0b4755c9dbe604b0d7c9c3bb35c8aadbe Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Sat, 15 Aug 2026 21:12:10 -0700 Subject: [PATCH] Add clip color themes and a selected-clip position preference Clips can now use one of three color themes: Default keeps the existing per-source-app palette assignment unchanged, Vibrant boosts each source color's saturation and brightness for a punchier header, and Accent Shades derives ten deterministic hue/saturation/brightness variants from a single user-picked base color, so every source app keeps a stable, recognizable shade without a stored per-app palette. The choice and base color live in Settings under a new "Clip Colors" section, with a live swatch preview when Accent Shades is selected. A "Clip Navigation" section adds a Center vs. Right edge preference for where the selected clip parks in the bar's horizontal strip - Right edge mirrors Paste, keeping the active card pinned to the trailing edge instead of centered. The bar's scroll-to-selection now reads this preference for its anchor and re-scrolls immediately if the preference changes while a clip is selected. Ported from an unmerged branch, scoped to just these two features - the Quick Look focus fixes, play-sound-on-copy toggle, Pinboard tab pill styling, and Settings sidebar-row-clickability tweaks bundled in the source commit are left out, either already built elsewhere or being handled as their own PR. --- Sources/Pesty/Settings/Settings.swift | 75 +++++++++++++++++- Sources/Pesty/Settings/SettingsView.swift | 50 ++++++++++++ Sources/Pesty/UI/BarView.swift | 16 +++- Sources/Pesty/Util/SourceColor.swift | 95 +++++++++++++++++++++++ 4 files changed, 232 insertions(+), 4 deletions(-) diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index 3c2bcc7..702914f 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -59,6 +59,58 @@ enum HistoryRetentionMode: String, CaseIterable, Identifiable { } } +enum ClipColorTheme: Int, CaseIterable, Identifiable { + // The stronger treatment is the default: side by side, the plain source + // color and its boosted version were close enough to be hard to tell + // apart, which made "Default" the wrong name for the quieter one. + // Classic keeps the unmodified color as the named alternative. + case `default` + case classic + case accentShades + + var id: Int { rawValue } + + var title: String { + switch self { + case .default: "Default" + case .classic: "Classic" + case .accentShades: "Accent shades" + } + } + + var detail: String { + switch self { + case .default: + "Use a stronger, higher-contrast version of each source app color." + case .classic: + "Match each clip to its source app\u{2019}s familiar card color." + case .accentShades: + "Give each source app a stable lighter or darker shade of one color." + } + } +} + +enum SelectedClipPosition: Int, CaseIterable, Identifiable { + case center + case rightEdge + + var id: Int { rawValue } + + var title: String { + switch self { + case .center: "Center" + case .rightEdge: "Right edge" + } + } + + var detail: String { + switch self { + case .center: "Keep the selected clip centered with surrounding context visible." + case .rightEdge: "Place the selected clip at the far right, like Paste." + } + } +} + @Observable @MainActor final class Settings { @@ -86,6 +138,9 @@ final class Settings { static let onboarded = "onboarded" static let iCloudSync = "iCloudSync" static let cloudKitSync = "cloudKitSync" + static let clipColorTheme = "clipColorTheme" + static let clipColorAccentHex = "clipColorAccentHex" + static let selectedClipPosition = "selectedClipPosition" } var historyLimit: Int { @@ -194,6 +249,18 @@ final class Settings { didSet { guard isLoaded else { return }; d.set(cloudKitSync, forKey: Keys.cloudKitSync) } } + var clipColorTheme: ClipColorTheme { + didSet { guard isLoaded else { return }; d.set(clipColorTheme.rawValue, forKey: Keys.clipColorTheme) } + } + + var clipColorAccentHex: String { + didSet { guard isLoaded else { return }; d.set(clipColorAccentHex, forKey: Keys.clipColorAccentHex) } + } + + var selectedClipPosition: SelectedClipPosition { + didSet { guard isLoaded else { return }; d.set(selectedClipPosition.rawValue, forKey: Keys.selectedClipPosition) } + } + private init() { d.register(defaults: [ Keys.historyLimit: 500, @@ -213,7 +280,10 @@ final class Settings { Keys.showMenuBarIcon: true, Keys.onboarded: false, Keys.iCloudSync: false, - Keys.cloudKitSync: true + Keys.cloudKitSync: true, + Keys.clipColorTheme: ClipColorTheme.default.rawValue, + Keys.clipColorAccentHex: "#FF5A9F", + Keys.selectedClipPosition: SelectedClipPosition.center.rawValue ]) historyLimit = d.integer(forKey: Keys.historyLimit) historyRetentionMode = HistoryRetentionMode(rawValue: d.string(forKey: Keys.historyRetentionMode) ?? "") @@ -235,6 +305,9 @@ final class Settings { onboarded = d.bool(forKey: Keys.onboarded) iCloudSync = d.bool(forKey: Keys.iCloudSync) cloudKitSync = d.bool(forKey: Keys.cloudKitSync) + clipColorTheme = ClipColorTheme(rawValue: d.integer(forKey: Keys.clipColorTheme)) ?? .default + clipColorAccentHex = d.string(forKey: Keys.clipColorAccentHex) ?? "#FF5A9F" + selectedClipPosition = SelectedClipPosition(rawValue: d.integer(forKey: Keys.selectedClipPosition)) ?? .center isLoaded = true } diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 44e1854..6b0b74f 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -146,6 +146,49 @@ private struct GeneralSettings: View { #endif } + Section("Clip Colors") { + Picker("Color theme", selection: $settings.clipColorTheme) { + ForEach(ClipColorTheme.allCases) { theme in + Text(theme.title).tag(theme) + } + } + .pickerStyle(.segmented) + Text(settings.clipColorTheme.detail) + .font(.caption) + .foregroundStyle(.secondary) + if settings.clipColorTheme == .accentShades { + ColorPicker("Base color", selection: clipColorAccent, supportsOpacity: false) + HStack(spacing: 12) { + Text("Preview") + .foregroundStyle(.secondary) + HStack(spacing: 4) { + ForEach(Array(SourceColor.accentShades(for: settings.clipColorAccentHex).enumerated()), + id: \.offset) { _, color in + Circle() + .fill(color) + .frame(width: 13, height: 13) + } + } + .accessibilityLabel("Ten stable shades of the selected base color") + } + Text("Each source app keeps one of ten deterministic shades, so its cards stay recognizable without drifting too far from your chosen color.") + .font(.caption) + .foregroundStyle(.secondary) + } + } + + Section("Clip Navigation") { + Picker("Selected clip position", selection: $settings.selectedClipPosition) { + ForEach(SelectedClipPosition.allCases) { position in + Text(position.title).tag(position) + } + } + .pickerStyle(.segmented) + Text(settings.selectedClipPosition.detail) + .font(.caption) + .foregroundStyle(.secondary) + } + #if MAS Section("Sync") { Toggle("Sync history with iCloud", isOn: Binding( @@ -223,6 +266,13 @@ private struct GeneralSettings: View { } #endif + private var clipColorAccent: Binding { + Binding( + get: { Color(hex: settings.clipColorAccentHex) ?? .pink }, + set: { settings.clipColorAccentHex = NSColor($0).hexString } + ) + } + private func modifierPicker(selection: Binding) -> some View { Picker("", selection: selection) { ForEach(ShortcutModifier.allCases) { modifier in diff --git a/Sources/Pesty/UI/BarView.swift b/Sources/Pesty/UI/BarView.swift index 87dcd77..d483106 100644 --- a/Sources/Pesty/UI/BarView.swift +++ b/Sources/Pesty/UI/BarView.swift @@ -157,15 +157,25 @@ struct BarView: View { } .onChange(of: store.selectedID) { _, id in guard let id else { return } - withAnimation(.spring(response: 0.3, dampingFraction: 0.78)) { - proxy.scrollTo(id, anchor: .center) - } + scrollToSelected(id, proxy: proxy) + } + .onChange(of: settings.selectedClipPosition) { _, _ in + guard let id = store.selectedID else { return } + scrollToSelected(id, proxy: proxy) } .overlay { if store.visibleItems.isEmpty { emptyState } } } .frame(maxHeight: .infinity) } + /// The anchor preference decides whether the strip parks the selected + /// card centered or against the right edge, Paste-style. + private func scrollToSelected(_ id: UUID, proxy: ScrollViewProxy) { + withAnimation(.spring(response: 0.3, dampingFraction: 0.78)) { + proxy.scrollTo(id, anchor: settings.selectedClipPosition == .rightEdge ? .trailing : .center) + } + } + private var emptyState: some View { VStack(spacing: 10) { Image(systemName: store.searchText.isEmpty ? "doc.on.clipboard" : "magnifyingglass") diff --git a/Sources/Pesty/Util/SourceColor.swift b/Sources/Pesty/Util/SourceColor.swift index 7f57273..582b4ac 100644 --- a/Sources/Pesty/Util/SourceColor.swift +++ b/Sources/Pesty/Util/SourceColor.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI @MainActor @@ -22,7 +23,45 @@ enum SourceColor { UserDefaults.standard.dictionary(forKey: key) as? [String: Int] ?? [:] }() + // Ten deterministic variants of a single user-picked accent color, used + // by the "Accent shades" theme so every source app keeps a stable, + // recognizable shade without drifting far from the chosen color. + private static let accentVariants: [AccentVariant] = [ + AccentVariant(hueOffset: -0.055, saturationOffset: 0.08, brightnessOffset: -0.34), + AccentVariant(hueOffset: 0.040, saturationOffset: -0.08, brightnessOffset: -0.27), + AccentVariant(hueOffset: -0.025, saturationOffset: 0.10, brightnessOffset: -0.19), + AccentVariant(hueOffset: 0.020, saturationOffset: -0.10, brightnessOffset: -0.11), + AccentVariant(hueOffset: -0.010, saturationOffset: 0.06, brightnessOffset: -0.03), + AccentVariant(hueOffset: 0.010, saturationOffset: -0.05, brightnessOffset: 0.05), + AccentVariant(hueOffset: -0.020, saturationOffset: 0.10, brightnessOffset: 0.13), + AccentVariant(hueOffset: 0.025, saturationOffset: -0.10, brightnessOffset: 0.21), + AccentVariant(hueOffset: -0.040, saturationOffset: 0.04, brightnessOffset: 0.29), + AccentVariant(hueOffset: 0.055, saturationOffset: -0.14, brightnessOffset: 0.35) + ] + static func color(for bundleID: String?) -> Color { + switch Settings.shared.clipColorTheme { + case .default: + return vibrantColor(from: paletteColor(for: bundleID)) + case .classic: + return paletteColor(for: bundleID) + case .accentShades: + return accentShade( + for: bundleID?.isEmpty == false ? bundleID! : "unknown", + accentHex: Settings.shared.clipColorAccentHex + ) + } + } + + /// The ten deterministic shades for a given accent color, used by + /// Settings to preview the "Accent shades" theme. + static func accentShades(for accentHex: String) -> [Color] { + accentVariants.map { accentShade(variant: $0, accentHex: accentHex) } + } + + /// Upstream's original per-app color: a stable palette slot assigned on + /// first sight and persisted, so a given app keeps its color across launches. + private static func paletteColor(for bundleID: String?) -> Color { guard let id = bundleID, !id.isEmpty else { return palette[0] } if let i = map[id] { return palette[i % palette.count] } let i = map.count % palette.count @@ -30,4 +69,60 @@ enum SourceColor { UserDefaults.standard.set(map, forKey: key) return palette[i] } + + private static func vibrantColor(from color: Color) -> Color { + guard let nsColor = NSColor(color).usingColorSpace(.sRGB) else { return color } + var hue: CGFloat = 0 + var saturation: CGFloat = 0 + var brightness: CGFloat = 0 + nsColor.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: nil) + + // Make the vibrant option visibly distinct while keeping headers dark + // enough for their white labels to remain readable. + return Color( + hue: Double(hue), + saturation: min(0.99, max(0.92, Double(saturation) * 1.08)), + brightness: min(0.90, max(0.68, Double(brightness) * 0.90)) + ) + } + + private static func accentShade(for bundleID: String, accentHex: String) -> Color { + let index = stableIndex(for: bundleID, count: accentVariants.count) + return accentShade(variant: accentVariants[index], accentHex: accentHex) + } + + private static func accentShade(variant: AccentVariant, accentHex: String) -> Color { + let nsColor = NSColor(hex: accentHex)?.usingColorSpace(.sRGB) + ?? NSColor.systemPink.usingColorSpace(.sRGB)! + var hue: CGFloat = 0 + var saturation: CGFloat = 0 + var brightness: CGFloat = 0 + nsColor.getHue(&hue, saturation: &saturation, brightness: &brightness, alpha: nil) + + // Normalizing the middle brightness prevents very light or very dark + // user selections from collapsing several variants into the same color. + let middleBrightness = min(0.76, max(0.66, Double(brightness))) + let adjustedHue = (Double(hue) + variant.hueOffset + 1).truncatingRemainder(dividingBy: 1) + + return Color( + hue: adjustedHue, + saturation: min(0.98, max(0.60, Double(saturation) + variant.saturationOffset)), + brightness: min(0.98, max(0.32, middleBrightness + variant.brightnessOffset)) + ) + } + + private static func stableIndex(for bundleID: String, count: Int) -> Int { + var hash: UInt64 = 1_469_598_103_934_665_603 + for byte in bundleID.utf8 { + hash ^= UInt64(byte) + hash &*= 1_099_511_628_211 + } + return Int(hash % UInt64(count)) + } + + private struct AccentVariant { + let hueOffset: Double + let saturationOffset: Double + let brightnessOffset: Double + } }