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
86 changes: 84 additions & 2 deletions Sources/Pesty/Settings/Settings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,85 @@ enum HistoryRetentionMode: String, CaseIterable, Identifiable {
}
}

/// The discrete stops on the time-based retention slider. `historyRetentionDays`
/// stays the underlying source of truth (a raw day count, with 0 meaning
/// "forever" - never prune by age) - this just names the stops so the slider
/// can snap between them instead of offering a raw number field.
enum HistoryRetentionPreset: Int, CaseIterable, Identifiable {
case day
case week
case twoWeeks
case threeWeeks
case month
case twoMonths
case threeMonths
case sixMonths
case year
case forever

var id: Int { rawValue }

/// 0 means forever - no automatic time-based pruning.
var days: Int {
switch self {
case .day: return 1
case .week: return 7
case .twoWeeks: return 14
case .threeWeeks: return 21
case .month: return 30
case .twoMonths: return 60
case .threeMonths: return 90
case .sixMonths: return 180
case .year: return 365
case .forever: return 0
}
}

var title: String {
switch self {
case .day: return "1 Day"
case .week: return "1 Week"
case .twoWeeks: return "2 Weeks"
case .threeWeeks: return "3 Weeks"
case .month: return "1 Month"
case .twoMonths: return "2 Months"
case .threeMonths: return "3 Months"
case .sixMonths: return "6 Months"
case .year: return "1 Year"
case .forever: return "Forever"
}
}

var shortTitle: String {
switch self {
case .day: return "1d"
case .week: return "1w"
case .twoWeeks: return "2w"
case .threeWeeks: return "3w"
case .month: return "1m"
case .twoMonths: return "2m"
case .threeMonths: return "3m"
case .sixMonths: return "6m"
case .year: return "1y"
case .forever: return "∞"
}
}

var sliderIndex: Double { Double(rawValue) }

/// Snaps an arbitrary day count (including ones from before this preset
/// set existed) to its nearest stop, so old UserDefaults values still
/// land on a sensible position on the slider.
init(nearestDays days: Int) {
self = Self.allCases.min(by: { abs($0.days - days) < abs($1.days - days) }) ?? .month
}

init(sliderIndex: Double) {
let index = min(Self.allCases.count - 1, max(0, Int(sliderIndex.rounded())))
self = Self.allCases[index]
}
}

@Observable
@MainActor
final class Settings {
Expand Down Expand Up @@ -103,10 +182,13 @@ final class Settings {
}
}

/// A raw day count for time-based pruning; 0 means forever (no automatic
/// pruning by age). See `HistoryRetentionPreset` for the named stops the
/// Settings slider snaps this to.
var historyRetentionDays: Int {
didSet {
guard isLoaded else { return }
if historyRetentionDays < 1 { historyRetentionDays = 1; return }
if historyRetentionDays < 0 { historyRetentionDays = 0; return }
d.set(historyRetentionDays, forKey: Keys.historyRetentionDays)
}
}
Expand Down Expand Up @@ -218,7 +300,7 @@ final class Settings {
historyLimit = d.integer(forKey: Keys.historyLimit)
historyRetentionMode = HistoryRetentionMode(rawValue: d.string(forKey: Keys.historyRetentionMode) ?? "")
?? .itemCount
historyRetentionDays = max(1, d.integer(forKey: Keys.historyRetentionDays))
historyRetentionDays = max(0, d.integer(forKey: Keys.historyRetentionDays))
hotkeyKeyCode = d.integer(forKey: Keys.hotkeyKeyCode)
hotkeyModifiers = d.integer(forKey: Keys.hotkeyModifiers)
quickPasteModifier = d.integer(forKey: Keys.quickPasteModifier)
Expand Down
65 changes: 56 additions & 9 deletions Sources/Pesty/Settings/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -243,13 +243,17 @@ private struct HistoryRetentionSettings: View {
@State private var pendingRemovalCount = 0
@State private var confirmingChange = false

private static let dayChoices: [(days: Int, label: String)] = [
(1, "1 Day"), (7, "1 Week"), (14, "2 Weeks"), (30, "1 Month"),
(90, "3 Months"), (180, "6 Months"), (365, "1 Year")
]
private var draftPreset: HistoryRetentionPreset { HistoryRetentionPreset(nearestDays: draftDays) }

private var draftPresetSliderValue: Binding<Double> {
Binding(
get: { draftPreset.sliderIndex },
set: { draftDays = HistoryRetentionPreset(sliderIndex: $0).days }
)
}

var body: some View {
Section("History") {
Section {
Picker("Limit history by", selection: $draftMode) {
ForEach(HistoryRetentionMode.allCases) { mode in
Text(mode.title).tag(mode)
Expand All @@ -260,16 +264,59 @@ private struct HistoryRetentionSettings: View {
Stepper(value: $draftLimit, in: 50...5000, step: 50) {
LabeledContent("Keep at most", value: "\(draftLimit) clips")
}
} else {
Picker("Remove clips older than", selection: $draftDays) {
ForEach(Self.dayChoices, id: \.days) { choice in
Text(choice.label).tag(choice.days)
}
} header: {
Text("History")
} footer: {
// On macOS, Form(.formStyle(.grouped)) lays out every row placed
// in a Section's *body* on a shared two-column label/control grid
// (backed by NSGridView), sized from the widest label anywhere in
// that Section - here "Limit history by". Any native AppKit
// control dropped into that body (Slider, Picker, Stepper, ...)
// gets clamped and shifted into the trailing "control column" of
// that grid, even with no visible label of its own and even with
// an explicit SwiftUI .frame() on it, because the constraint is
// applied by the grid to the control's AppKit host view *after*
// SwiftUI layout runs - which is why .frame(maxWidth: .infinity),
// an HStack wrapper, and a GeometryReader forcing an exact width
// all had zero effect (confirmed by dumping the live NSView tree:
// the same Slider measured ~253pt wide starting at x≈217 in a
// 480pt-wide row when placed in the body, vs. ~460pt wide
// starting at x≈30 when placed here). A Section's footer is
// rendered as plain full-width content below that grid, not as a
// grid row, so it never gets pulled into the label/control
// layout - which is why moving this block here (rather than
// tweaking the Slider itself yet again) actually fixes the width
// and alignment, and also guarantees the three rows below line
// up with each other since they're now plain VStack siblings
// sharing one container instead of being split across the grid.
if draftMode != .itemCount {
VStack(alignment: .leading, spacing: 9) {
HStack {
Text("Remove clips older than")
Spacer()
Text(draftPreset.title)
.fontWeight(.semibold)
.foregroundStyle(Color.accentColor)
}
Slider(value: draftPresetSliderValue,
in: 0...Double(HistoryRetentionPreset.allCases.count - 1),
step: 1)
HStack(spacing: 0) {
ForEach(HistoryRetentionPreset.allCases) { preset in
Text(preset.shortTitle)
.font(.system(size: 10, weight: preset == draftPreset ? .bold : .medium))
.foregroundStyle(preset == draftPreset ? Color.accentColor : .secondary)
.frame(maxWidth: .infinity)
}
}
}
.padding(.vertical, 2)
}
Text(footnote)
.font(.caption)
.foregroundStyle(.secondary)
.padding(.top, draftMode == .itemCount ? 0 : 4)
}
.onChange(of: draftMode) { evaluateDraft() }
.onChange(of: draftLimit) { evaluateDraft() }
Expand Down
18 changes: 13 additions & 5 deletions Sources/Pesty/Store/ClipboardStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,10 @@ final class ClipboardStore {
case .itemCount:
return max(0, history.count - max(20, limit))
case .timeInterval:
// days == 0 means "forever" - no age-based cutoff, just the safety cap.
guard days > 0 else {
return max(0, history.count - Self.timeRetentionSafetyCap)
}
let cutoff = Self.retentionCutoff(daysAgo: days)
let byAge = history.filter { $0.createdAt < cutoff }.count
return byAge + max(0, (history.count - byAge) - Self.timeRetentionSafetyCap)
Expand Down Expand Up @@ -157,11 +161,15 @@ final class ClipboardStore {
history.removeLast(history.count - historyLimit)
}
case .timeInterval:
let cutoff = Self.retentionCutoff(daysAgo: Settings.shared.historyRetentionDays)
let old = history.filter { $0.createdAt < cutoff }
if !old.isEmpty {
removed += old
history.removeAll { $0.createdAt < cutoff }
// days == 0 means "forever" - skip the age cutoff, keep the safety cap.
let days = Settings.shared.historyRetentionDays
if days > 0 {
let cutoff = Self.retentionCutoff(daysAgo: days)
let old = history.filter { $0.createdAt < cutoff }
if !old.isEmpty {
removed += old
history.removeAll { $0.createdAt < cutoff }
}
}
if history.count > Self.timeRetentionSafetyCap {
removed += Array(history[Self.timeRetentionSafetyCap...])
Expand Down
Loading