From 10a178644f4f9168e76bd0e8ace7aa172e10e5ba Mon Sep 17 00:00:00 2001 From: Alvie Stoddard Date: Sat, 15 Aug 2026 19:15:10 -0700 Subject: [PATCH] Turn history retention into a discrete slider with named presets Replaces the "remove clips older than" day-choice Picker with a 10-stop slider (1 Day through Forever) matching Alvie's Pesty's granularity, via a new HistoryRetentionPreset enum. Adds three new stops baseline didn't have (3 Weeks, 2 Months, Forever) - Forever is represented as historyRetentionDays == 0, meaning "no automatic pruning by age", with only the existing safety cap still applying. Keeps the confirm-before-apply destructive-change dialog baseline already had; only the presentation of the choice changes, not the safety UX around it. The slider itself needed a fix unrelated to its content: on macOS, Form(.formStyle(.grouped)) lays out every row in a Section's body on a shared label/control NSGridView, which clamps and right-shifts any bare control placed there regardless of SwiftUI-side frame modifiers. Moving the slider block into the Section's footer (plain full-width content, never part of that grid) fixes it - see the comment at the call site for how this was confirmed. --- Sources/Pesty/Settings/Settings.swift | 86 ++++++++++++++++++++++- Sources/Pesty/Settings/SettingsView.swift | 65 ++++++++++++++--- Sources/Pesty/Store/ClipboardStore.swift | 18 +++-- 3 files changed, 153 insertions(+), 16 deletions(-) diff --git a/Sources/Pesty/Settings/Settings.swift b/Sources/Pesty/Settings/Settings.swift index 3c2bcc7..680340f 100644 --- a/Sources/Pesty/Settings/Settings.swift +++ b/Sources/Pesty/Settings/Settings.swift @@ -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 { @@ -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) } } @@ -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) diff --git a/Sources/Pesty/Settings/SettingsView.swift b/Sources/Pesty/Settings/SettingsView.swift index 44e1854..b8e845f 100644 --- a/Sources/Pesty/Settings/SettingsView.swift +++ b/Sources/Pesty/Settings/SettingsView.swift @@ -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 { + 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) @@ -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() } diff --git a/Sources/Pesty/Store/ClipboardStore.swift b/Sources/Pesty/Store/ClipboardStore.swift index c0bcd40..c71ee08 100644 --- a/Sources/Pesty/Store/ClipboardStore.swift +++ b/Sources/Pesty/Store/ClipboardStore.swift @@ -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) @@ -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...])