Skip to content
Open
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
75 changes: 74 additions & 1 deletion Sources/Pesty/Settings/Settings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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) ?? "")
Expand All @@ -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
}

Expand Down
50 changes: 50 additions & 0 deletions Sources/Pesty/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -223,6 +266,13 @@ private struct GeneralSettings: View {
}
#endif

private var clipColorAccent: Binding<Color> {
Binding(
get: { Color(hex: settings.clipColorAccentHex) ?? .pink },
set: { settings.clipColorAccentHex = NSColor($0).hexString }
)
}

private func modifierPicker(selection: Binding<Int>) -> some View {
Picker("", selection: selection) {
ForEach(ShortcutModifier.allCases) { modifier in
Expand Down
16 changes: 13 additions & 3 deletions Sources/Pesty/UI/BarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
95 changes: 95 additions & 0 deletions Sources/Pesty/Util/SourceColor.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AppKit
import SwiftUI

@MainActor
Expand All @@ -22,12 +23,106 @@ 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
map[id] = i
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
}
}
Loading