Polish clipboard panel UX and interactions

This commit is contained in:
Akshay Kolli
2026-07-09 19:32:57 -04:00
parent 23cd8b64a9
commit 52f712eb73
18 changed files with 2109 additions and 3273 deletions

View File

@@ -44,7 +44,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private var statusMenu: NSMenu?
private var pauseResumeTimer: Timer?
private var cloudSyncPushWorkItem: DispatchWorkItem?
private let cloudSyncOperationQueue: OperationQueue = {
let queue = OperationQueue()
queue.name = "clipbored.cloud-sync"
queue.qualityOfService = .utility
queue.maxConcurrentOperationCount = 1
return queue
}()
private let cloudSyncStateLock = NSLock()
private var suppressCloudSyncPush = false
private var cloudSyncOperationGeneration = 0
func applicationDidFinishLaunching(_ notification: Notification) {
settings = SettingsModel()
@@ -57,7 +66,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
settings: settings,
cacheService: cacheService,
pollClipboardNow: { [weak monitor] in
monitor?.pollNowAndWait()
monitor?.pollNow()
},
openSettings: { [weak self] in
self?.openSettings()
@@ -79,12 +88,6 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
self?.panelController.toggle()
}
},
onOpenSettings: { [weak self] in
DispatchQueue.main.async {
self?.refreshAccessibilityPermissionMessage()
self?.settingsController.show()
}
},
onToggleStackCapture: { [weak self] in
DispatchQueue.main.async {
self?.panelController.toggleStackCaptureMode()
@@ -95,8 +98,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
self?.settings.setShortcutStatus(message: status.message)
}
},
openShortcut: settings.openShortcut,
settingsShortcut: settings.settingsShortcut
openShortcut: settings.openShortcut
)
bindSettings()
bindCloudSync()
@@ -120,6 +122,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
func applicationWillTerminate(_ notification: Notification) {
pauseResumeTimer?.invalidate()
cloudSyncPushWorkItem?.cancel()
cloudSyncOperationQueue.cancelAllOperations()
monitor.stop()
shortcutManager.stop()
cacheService.clearTemporaryPreviews(wait: true)
@@ -571,7 +574,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
observedInitialItems = true
return
}
guard self.settings.iCloudSyncEnabled, !self.suppressCloudSyncPush else { return }
guard self.settings.iCloudSyncEnabled, !self.isCloudSyncPushSuppressed else { return }
self.scheduleCloudSyncPush()
}
@@ -590,22 +593,21 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
store.normalizeHistoryLength()
case .imageCacheMaxBytes:
cacheService.purgeIfNeeded(maxBytes: settings.imageCacheMaxBytes)
case .openShortcut, .settingsShortcut:
let status = shortcutManager.reconfigure(openShortcut: settings.openShortcut, settingsShortcut: settings.settingsShortcut)
case .openShortcut:
let status = shortcutManager.reconfigure(openShortcut: settings.openShortcut)
settings.setShortcutStatus(message: status.message)
refreshStatusItem()
configureMainMenu()
case .settingsShortcut:
refreshStatusItem()
configureMainMenu()
case .launchAtLogin:
applyLaunchAtLoginSetting(settings.launchAtLogin)
case .showMenuBarIcon:
applyPresentation(changedSurface: .menuBar)
case .showDockIcon:
applyPresentation(changedSurface: .dock)
case .compactMode:
break
case .panelLayout:
break
case .panelSizing:
case .panelSide:
break
case .cloudSync:
applyCloudSyncSetting()
@@ -627,30 +629,43 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private func applyCloudSyncSetting() {
cloudSyncPushWorkItem?.cancel()
cloudSyncPushWorkItem = nil
cloudSyncOperationGeneration += 1
let generation = cloudSyncOperationGeneration
guard settings.iCloudSyncEnabled else {
setCloudSyncPushSuppressed(false)
settings.setCloudSyncStatus(message: "iCloud Sync is off.")
return
}
let status = cloudSyncService.status()
settings.setCloudSyncStatus(message: status.message)
guard status.isAvailable else { return }
pullCloudSyncArchiveIfAvailable()
}
private func pullCloudSyncArchiveIfAvailable() {
suppressCloudSyncPush = true
defer { suppressCloudSyncPush = false }
do {
let summary = try cloudSyncService.pull(store: store)
settings.setCloudSyncStatus(message: "Restored \(summary.itemCount) clips from iCloud.")
} catch ClipboardCloudSyncError.noRemoteArchive(_) {
settings.setCloudSyncStatus(message: "iCloud Sync is ready. No remote archive yet.")
scheduleCloudSyncPush(after: 1.0)
} catch {
settings.setCloudSyncStatus(message: "iCloud Sync failed: \(error.localizedDescription)")
settings.setCloudSyncStatus(message: "Checking iCloud Sync…")
setCloudSyncPushSuppressed(true)
cloudSyncOperationQueue.addOperation { [weak self] in
guard let self else { return }
let status = self.cloudSyncService.status()
let result: (message: String, schedulesInitialPush: Bool)
if !status.isAvailable {
result = (status.message, false)
} else {
do {
let summary = try self.cloudSyncService.pull(store: self.store)
result = ("Restored \(summary.itemCount) clips from iCloud.", false)
} catch ClipboardCloudSyncError.noRemoteArchive(_) {
result = ("iCloud Sync is ready. No remote archive yet.", true)
} catch {
result = ("iCloud Sync failed: \(error.localizedDescription)", false)
}
}
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.setCloudSyncPushSuppressed(false)
guard generation == self.cloudSyncOperationGeneration,
self.settings.iCloudSyncEnabled else { return }
self.settings.setCloudSyncStatus(message: result.message)
if result.schedulesInitialPush {
self.scheduleCloudSyncPush(after: 1.0)
}
}
}
}
@@ -665,14 +680,37 @@ final class AppDelegate: NSObject, NSApplicationDelegate {
private func pushCloudSyncArchive() {
guard settings.iCloudSyncEnabled else { return }
do {
let summary = try cloudSyncService.push(store: store)
settings.setCloudSyncStatus(message: "Synced \(summary.itemCount) clips to iCloud.")
} catch {
settings.setCloudSyncStatus(message: "iCloud Sync failed: \(error.localizedDescription)")
let generation = cloudSyncOperationGeneration
cloudSyncOperationQueue.addOperation { [weak self] in
guard let self else { return }
let message: String
do {
let summary = try self.cloudSyncService.push(store: self.store)
message = "Synced \(summary.itemCount) clips to iCloud."
} catch {
message = "iCloud Sync failed: \(error.localizedDescription)"
}
DispatchQueue.main.async { [weak self] in
guard let self,
generation == self.cloudSyncOperationGeneration,
self.settings.iCloudSyncEnabled else { return }
self.settings.setCloudSyncStatus(message: message)
}
}
}
private var isCloudSyncPushSuppressed: Bool {
cloudSyncStateLock.lock()
defer { cloudSyncStateLock.unlock() }
return suppressCloudSyncPush
}
private func setCloudSyncPushSuppressed(_ suppressed: Bool) {
cloudSyncStateLock.lock()
suppressCloudSyncPush = suppressed
cloudSyncStateLock.unlock()
}
private func applyCapturePauseSetting(now: Date = Date()) {
if Self.shouldResumeExpiredCapturePause(
isCapturePaused: settings.pauseCapture,

View File

@@ -25,15 +25,6 @@ enum HistoryRetention: Int {
}
}
enum ClipboardPanelLayout: Int, CaseIterable {
case horizontal = 0
case vertical = 1
var title: String {
"Side Shelf"
}
}
enum ClipboardPanelSide: Int, CaseIterable {
case left = 0
case right = 1
@@ -59,9 +50,7 @@ final class SettingsModel {
case launchAtLogin
case showMenuBarIcon
case showDockIcon
case compactMode
case panelLayout
case panelSizing
case panelSide
case cloudSync
case pauseCapture
case ignoredApps
@@ -87,10 +76,7 @@ final class SettingsModel {
static let launchAtLogin = "launchAtLogin"
static let showMenuBarIcon = "showMenuBarIcon"
static let showDockIcon = "showDockIcon"
static let compactMode = "compactMode"
static let panelLayout = "panelLayout"
static let panelSide = "panelSide"
static let panelShelfHeight = "panelShelfHeight"
static let iCloudSyncEnabled = "iCloudSyncEnabled"
static let openShortcut = "openShortcut"
static let settingsShortcut = "settingsShortcut"
@@ -142,22 +128,8 @@ final class SettingsModel {
var showDockIcon: Bool {
didSet { if oldValue != showDockIcon { storeAndNotify(.showDockIcon) } }
}
var compactMode: Bool {
didSet { if oldValue != compactMode { storeAndNotify(.compactMode) } }
}
var panelLayout: ClipboardPanelLayout {
didSet {
if panelLayout != .vertical {
panelLayout = .vertical
}
if oldValue != panelLayout { storeAndNotify(.panelLayout) }
}
}
var panelSide: ClipboardPanelSide {
didSet { if oldValue != panelSide { storeAndNotify(.panelLayout) } }
}
var panelShelfHeight: Double {
didSet { if oldValue != panelShelfHeight { storeAndNotify(.panelSizing) } }
didSet { if oldValue != panelSide { storeAndNotify(.panelSide) } }
}
var iCloudSyncEnabled: Bool {
didSet {
@@ -241,10 +213,7 @@ final class SettingsModel {
launchAtLogin = defaults.object(forKey: Keys.launchAtLogin) as? Bool ?? false
showMenuBarIcon = defaults.object(forKey: Keys.showMenuBarIcon) as? Bool ?? true
showDockIcon = defaults.object(forKey: Keys.showDockIcon) as? Bool ?? false
compactMode = defaults.object(forKey: Keys.compactMode) as? Bool ?? false
panelLayout = .vertical
panelSide = savedPanelSide.flatMap(ClipboardPanelSide.init(rawValue:)) ?? .right
panelShelfHeight = defaults.object(forKey: Keys.panelShelfHeight) as? Double ?? 0
iCloudSyncEnabled = defaults.object(forKey: Keys.iCloudSyncEnabled) as? Bool ?? false
openShortcut = Self.readShortcut(from: defaults.string(forKey: Keys.openShortcut)) ?? AppConfiguration.defaultOpenShortcut
settingsShortcut = Self.readShortcut(from: defaults.string(forKey: Keys.settingsShortcut)) ?? AppConfiguration.defaultSettingsShortcut
@@ -271,10 +240,6 @@ final class SettingsModel {
maxHistoryItems = Self.clampedMaxHistoryItems(maxHistoryItems)
imageCacheMaxBytes = Self.clampedImageCacheMaxBytes(imageCacheMaxBytes)
if panelShelfHeight < 0 {
panelShelfHeight = 0
}
if defaults.object(forKey: Keys.maxHistoryItems) == nil
|| savedHistory != maxHistoryItems
|| defaults.object(forKey: Keys.historyRetention) == nil
@@ -282,7 +247,6 @@ final class SettingsModel {
|| savedCache <= 0
|| Int64(savedCache) != imageCacheMaxBytes
|| storedIgnoredItemKinds != ignoredItemKindsRaw
|| defaults.object(forKey: Keys.panelLayout) as? Int != ClipboardPanelLayout.vertical.rawValue
|| savedPanelSide == nil
|| ClipboardPanelSide(rawValue: savedPanelSide ?? -1) == nil {
store()
@@ -299,10 +263,7 @@ final class SettingsModel {
defaults.set(launchAtLogin, forKey: Keys.launchAtLogin)
defaults.set(showMenuBarIcon, forKey: Keys.showMenuBarIcon)
defaults.set(showDockIcon, forKey: Keys.showDockIcon)
defaults.set(compactMode, forKey: Keys.compactMode)
defaults.set(panelLayout.rawValue, forKey: Keys.panelLayout)
defaults.set(panelSide.rawValue, forKey: Keys.panelSide)
defaults.set(panelShelfHeight, forKey: Keys.panelShelfHeight)
defaults.set(iCloudSyncEnabled, forKey: Keys.iCloudSyncEnabled)
defaults.set(openShortcut.encoded(), forKey: Keys.openShortcut)
defaults.set(settingsShortcut.encoded(), forKey: Keys.settingsShortcut)

View File

@@ -24,36 +24,28 @@ final class ShortcutManager {
private enum HotKeyID: UInt32 {
case openPanel = 1
case openSettings = 2
case stackCapture = 3
}
private let onOpenClipboardPanel: () -> Void
private let onOpenSettings: () -> Void
private let onToggleStackCapture: () -> Void
private let onStatusChange: (RegistrationStatus) -> Void
private var openBinding: ShortcutBinding
private var settingsBinding: ShortcutBinding
private var openHotKey: EventHotKeyRef?
private var settingsHotKey: EventHotKeyRef?
private var stackCaptureHotKey: EventHotKeyRef?
private var eventHandler: EventHandlerRef?
init(
onOpenClipboardPanel: @escaping () -> Void,
onOpenSettings: @escaping () -> Void,
onToggleStackCapture: @escaping () -> Void = {},
onStatusChange: @escaping (RegistrationStatus) -> Void = { _ in },
openShortcut: ShortcutBinding,
settingsShortcut: ShortcutBinding
openShortcut: ShortcutBinding
) {
self.onOpenClipboardPanel = onOpenClipboardPanel
self.onOpenSettings = onOpenSettings
self.onToggleStackCapture = onToggleStackCapture
self.onStatusChange = onStatusChange
self.openBinding = openShortcut
self.settingsBinding = settingsShortcut
}
deinit {
@@ -64,16 +56,11 @@ final class ShortcutManager {
func start() -> RegistrationStatus {
stop()
if let status = Self.validationFailure(for: openBinding) ?? Self.validationFailure(for: settingsBinding) {
if let status = Self.validationFailure(for: openBinding) {
onStatusChange(status)
return status
}
if openBinding == settingsBinding {
let status = RegistrationStatus.conflict(openBinding.displayText)
onStatusChange(status)
return status
}
if openBinding == Self.stackCaptureShortcut || settingsBinding == Self.stackCaptureShortcut {
if openBinding == Self.stackCaptureShortcut {
let status = RegistrationStatus.conflict(Self.stackCaptureShortcut.displayText)
onStatusChange(status)
return status
@@ -107,13 +94,6 @@ final class ShortcutManager {
return openStatus
}
let settingsStatus = register(binding: settingsBinding, id: .openSettings, target: &settingsHotKey)
guard settingsStatus == .registered else {
stop()
onStatusChange(settingsStatus)
return settingsStatus
}
let stackCaptureStatus = register(binding: Self.stackCaptureShortcut, id: .stackCapture, target: &stackCaptureHotKey)
guard stackCaptureStatus == .registered else {
stop()
@@ -126,9 +106,8 @@ final class ShortcutManager {
}
@discardableResult
func reconfigure(openShortcut: ShortcutBinding, settingsShortcut: ShortcutBinding) -> RegistrationStatus {
func reconfigure(openShortcut: ShortcutBinding) -> RegistrationStatus {
openBinding = openShortcut
settingsBinding = settingsShortcut
return start()
}
@@ -136,9 +115,6 @@ final class ShortcutManager {
if let openHotKey {
UnregisterEventHotKey(openHotKey)
}
if let settingsHotKey {
UnregisterEventHotKey(settingsHotKey)
}
if let stackCaptureHotKey {
UnregisterEventHotKey(stackCaptureHotKey)
}
@@ -147,7 +123,6 @@ final class ShortcutManager {
}
openHotKey = nil
settingsHotKey = nil
stackCaptureHotKey = nil
eventHandler = nil
}
@@ -206,8 +181,6 @@ final class ShortcutManager {
switch HotKeyID(rawValue: hotKeyID.id) {
case .openPanel:
onOpenClipboardPanel()
case .openSettings:
onOpenSettings()
case .stackCapture:
onToggleStackCapture()
case nil:
@@ -288,6 +261,10 @@ final class ShortcutManager {
modifierFlags: NSEvent.ModifierFlags([.command, .shift]).rawValue
)
static func globalShortcutBindings(openShortcut: ShortcutBinding) -> [ShortcutBinding] {
[openShortcut, stackCaptureShortcut]
}
private func osStatusMessage(_ status: OSStatus) -> String {
"OSStatus \(status)"
}

View File

@@ -59,14 +59,12 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
static let hideDuration: TimeInterval = 0.16
static let reflowDuration: TimeInterval = 0.18
static let easing: CAMediaTimingFunctionName = .easeInEaseOut
static func duration(_ preferredDuration: TimeInterval) -> TimeInterval {
NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? 0 : preferredDuration
}
}
private enum Metrics {
static let shelfHeightRatio: CGFloat = 0.18
static let minimumShelfHeight: CGFloat = 156
static let maximumShelfHeight: CGFloat = 176
static let minimumUserShelfHeight: CGFloat = 150
static let maximumUserShelfHeight: CGFloat = 680
static let maximumUserShelfHeightRatio: CGFloat = 0.72
static let preferredVerticalShelfWidth: CGFloat = 336
static let minimumVerticalShelfWidth: CGFloat = 320
static let maximumVerticalShelfWidthRatio: CGFloat = 0.30
@@ -170,7 +168,7 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
applyPanelSharingSetting()
settings.observe { [weak self] change in
guard change == .hideFromScreenCapture || change == .panelLayout else { return }
guard change == .hideFromScreenCapture || change == .panelSide else { return }
DispatchQueue.main.async {
if change == .hideFromScreenCapture {
self?.applyPanelSharingSetting()
@@ -205,13 +203,6 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
}
}
func createTextClip() {
performWhenVisible { [weak self] in
guard let self else { return }
self.panelView.createTextClip()
}
}
func createCollection() {
performWhenVisible { [weak self] in
guard let self else { return }
@@ -256,7 +247,7 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
panelView.beginOpeningTransition()
NSAnimationContext.runAnimationGroup { context in
context.duration = Animation.showDuration
context.duration = Animation.duration(Animation.showDuration)
context.allowsImplicitAnimation = true
context.timingFunction = CAMediaTimingFunction(name: Animation.easing)
panel.animator().setFrame(frames.shown, display: true)
@@ -299,7 +290,7 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
isAnimating = true
NSAnimationContext.runAnimationGroup { context in
context.duration = Animation.hideDuration
context.duration = Animation.duration(Animation.hideDuration)
context.allowsImplicitAnimation = true
context.timingFunction = CAMediaTimingFunction(name: Animation.easing)
panel.animator().alphaValue = 0.0
@@ -323,7 +314,9 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
}
show()
DispatchQueue.main.asyncAfter(deadline: .now() + Animation.showDuration + 0.03) { [weak self] in
let animationDelay = Animation.duration(Animation.showDuration)
let deadline = DispatchTime.now() + animationDelay + (animationDelay > 0 ? 0.03 : 0)
DispatchQueue.main.asyncAfter(deadline: deadline) { [weak self] in
guard let self, self.isVisible else { return }
action()
}
@@ -374,7 +367,6 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
static func panelFrames(
forScreenFrame screenFrame: CGRect,
visibleFrame: CGRect,
preferredHeight _: CGFloat? = nil,
side: ClipboardPanelSide = .right
) -> (shown: NSRect, hidden: NSRect) {
let intersectedFrame = visibleFrame.intersection(screenFrame)
@@ -454,7 +446,6 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
static func reflowPlan(
forScreenFrame screenFrame: CGRect,
visibleFrame: CGRect,
preferredHeight _: CGFloat? = nil,
side: ClipboardPanelSide = .right
) -> ClipboardPanelReflowPlan {
let frames = panelFrames(
@@ -526,6 +517,15 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
self.viewModel.sortMode = mode
return nil
}
if self.shouldHandlePanelKeyEvent(event, allowSearchFieldEditing: true),
Self.matchesShortcut(
keyCode: event.keyCode,
modifiers: event.modifierFlags,
binding: self.settings.settingsShortcut
) {
self.openSettings()
return nil
}
if self.shouldHandlePanelKeyEvent(event, allowSearchFieldEditing: true),
let action = Self.commandShortcutAction(forKeyCode: event.keyCode, modifiers: event.modifierFlags) {
self.performShortcutAction(action)
@@ -624,7 +624,7 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
case .edit:
panelView.editSelectedClip()
case .focusSearch:
panelView.focusSearchOrShowFilters()
panelView.focusSearch()
case .newCollection:
panelView.createCollection()
case .nextCollection:
@@ -775,6 +775,18 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
return keyCode == 49 && relevantModifiers.isEmpty && searchText.clipboardTrimmed.isEmpty
}
static func matchesShortcut(
keyCode: UInt16,
modifiers: NSEvent.ModifierFlags,
binding: ShortcutBinding
) -> Bool {
guard ShortcutManager.virtualKeyCode(for: binding.key) == keyCode else { return false }
let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask)
let bindingModifiers = NSEvent.ModifierFlags(rawValue: binding.modifierFlags)
.intersection(.deviceIndependentFlagsMask)
return relevantModifiers == bindingModifiers
}
static func commandShortcutAction(forKeyCode keyCode: UInt16, modifiers: NSEvent.ModifierFlags) -> ClipboardPanelShortcutAction? {
let relevantModifiers = modifiers.intersection(.deviceIndependentFlagsMask)
guard relevantModifiers == .command else { return nil }
@@ -924,7 +936,7 @@ final class ClipboardPanelController: NSObject, NSWindowDelegate, QLPreviewPanel
isAnimating = true
NSAnimationContext.runAnimationGroup { context in
context.duration = Animation.reflowDuration
context.duration = Animation.duration(Animation.reflowDuration)
context.allowsImplicitAnimation = true
context.timingFunction = CAMediaTimingFunction(name: Animation.easing)
panel.animator().setFrame(plan.frame, display: true)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -8,11 +8,12 @@ private final class TopAlignedSettingsDocumentView: NSView {
final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDelegate, NSTextViewDelegate {
private enum Metrics {
static let windowSize = NSSize(width: 720, height: 620)
static let minimumWindowSize = NSSize(width: 620, height: 560)
static let settingsContentMinimumWidth: CGFloat = 520
static let settingsLabelWidth: CGFloat = 150
static let windowSize = NSSize(width: 620, height: 520)
static let minimumWindowSize = NSSize(width: 560, height: 440)
static let settingsContentMinimumWidth: CGFloat = 440
static let settingsLabelWidth: CGFloat = 128
}
private static let tabTitles = ["General", "Shortcuts", "Capture", "Privacy", "Performance", "Data"]
private static let allowedContentTypesValidationMessage = "At least one content type must stay enabled."
private static let allowedContentTypesUpdatedMessage = "Allowed content types updated."
@@ -20,9 +21,23 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
private let store: ClipboardStore
private let cacheService: ClipboardCacheService
private let cloudSyncService: ClipboardCloudSyncServicing
private let dataOperationQueue: OperationQueue = {
let queue = OperationQueue()
queue.name = "clipbored.settings.data-operations"
queue.qualityOfService = .userInitiated
queue.maxConcurrentOperationCount = 1
return queue
}()
private var dataOperationInProgress = false
private var window: NSWindow?
private var cachedCloudSyncStatus: ClipboardCloudSyncStatus?
private let tabView = NSTabView()
private let tabSelector = NSSegmentedControl(
labels: SettingsWindowController.tabTitles,
trackingMode: .selectOne,
target: nil,
action: nil
)
private let historyLabel = NSTextField(labelWithString: "")
private let historyStepper = NSStepper()
@@ -64,6 +79,8 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
private let iCloudRestoreButton = NSButton()
private let iCloudRevealButton = NSButton()
private let cloudSyncStatusLabel = NSTextField(labelWithString: "")
private let exportArchiveButton = NSButton()
private let importArchiveButton = NSButton()
#if DEBUG
private var debugFullRefreshCountValue = 0
@@ -118,24 +135,44 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
private func makeContentView() -> NSView {
tabView.translatesAutoresizingMaskIntoConstraints = false
tabView.tabViewType = .noTabsNoBorder
tabView.addTabViewItem(tab("General", generalSettingsView()))
tabView.addTabViewItem(tab("Shortcuts", shortcutSettingsView()))
tabView.addTabViewItem(tab("Capture", captureSettingsView()))
tabView.addTabViewItem(tab("Privacy", privacySettingsView()))
tabView.addTabViewItem(tab("Performance", performanceSettingsView()))
tabView.addTabViewItem(tab("Data ", dataSettingsView()))
tabView.addTabViewItem(tab("Data", dataSettingsView()))
tabSelector.translatesAutoresizingMaskIntoConstraints = false
tabSelector.target = self
tabSelector.action = #selector(settingsTabChanged(_:))
tabSelector.selectedSegment = 0
tabSelector.segmentStyle = .rounded
let container = NSView()
container.wantsLayer = true
container.layer?.backgroundColor = NSColor.windowBackgroundColor.cgColor
container.addSubview(tabSelector)
container.addSubview(tabView)
NSLayoutConstraint.activate([
tabSelector.topAnchor.constraint(equalTo: container.topAnchor, constant: 10),
tabSelector.centerXAnchor.constraint(equalTo: container.centerXAnchor),
tabSelector.leadingAnchor.constraint(greaterThanOrEqualTo: container.leadingAnchor, constant: 12),
tabSelector.trailingAnchor.constraint(lessThanOrEqualTo: container.trailingAnchor, constant: -12),
tabView.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 12),
tabView.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -12),
tabView.topAnchor.constraint(equalTo: container.topAnchor, constant: 12),
tabView.topAnchor.constraint(equalTo: tabSelector.bottomAnchor, constant: 10),
tabView.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -12)
])
return container
}
@objc private func settingsTabChanged(_ sender: NSSegmentedControl) {
let index = sender.selectedSegment
guard index >= 0, index < tabView.numberOfTabViewItems else { return }
tabView.selectTabViewItem(at: index)
}
private func tab(_ title: String, _ view: NSView) -> NSTabViewItem {
let item = NSTabViewItem(identifier: title)
item.label = title
@@ -159,16 +196,18 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
content.translatesAutoresizingMaskIntoConstraints = false
documentView.addSubview(content)
scrollView.documentView = documentView
let documentFillsViewport = documentView.bottomAnchor.constraint(greaterThanOrEqualTo: scrollView.contentView.bottomAnchor)
let documentContainsContent = documentView.bottomAnchor.constraint(greaterThanOrEqualTo: content.bottomAnchor)
NSLayoutConstraint.activate([
documentView.leadingAnchor.constraint(equalTo: scrollView.contentView.leadingAnchor),
documentView.trailingAnchor.constraint(equalTo: scrollView.contentView.trailingAnchor),
documentView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor),
documentView.bottomAnchor.constraint(greaterThanOrEqualTo: scrollView.contentView.bottomAnchor),
documentFillsViewport,
documentContainsContent,
documentView.widthAnchor.constraint(equalTo: scrollView.contentView.widthAnchor),
content.leadingAnchor.constraint(equalTo: documentView.leadingAnchor),
content.trailingAnchor.constraint(equalTo: documentView.trailingAnchor),
content.topAnchor.constraint(equalTo: documentView.topAnchor),
content.bottomAnchor.constraint(equalTo: documentView.bottomAnchor)
content.topAnchor.constraint(equalTo: documentView.topAnchor)
])
return scrollView
}
@@ -354,6 +393,8 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
configureButton(iCloudSyncNowButton, title: "Sync Now", action: #selector(pushICloudSyncArchive))
configureButton(iCloudRestoreButton, title: "Restore from iCloud", action: #selector(pullICloudSyncArchive))
configureButton(iCloudRevealButton, title: "Reveal Sync File", action: #selector(revealICloudSyncFile))
configureButton(exportArchiveButton, title: "Export Archive...", action: #selector(exportClipboardArchive))
configureButton(importArchiveButton, title: "Import Archive...", action: #selector(importClipboardArchive))
let archiveLabel = caption("Export a portable archive for history, Pinboards, and managed attachments. Archives are not encrypted; file references stay path-based.")
let cloudLabel = caption("Uses the same archive in ClipBored's private iCloud container when iCloud signing and iCloud Drive are available.")
return page([
@@ -370,8 +411,8 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
section("Archive", [
archiveLabel,
row([
button("Export Archive...", #selector(exportClipboardArchive)),
button("Import Archive...", #selector(importClipboardArchive))
exportArchiveButton,
importArchiveButton
])
]),
section("Data", [
@@ -387,8 +428,8 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
let stack = NSStackView(views: views)
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 18
stack.edgeInsets = NSEdgeInsets(top: 18, left: 18, bottom: 18, right: 18)
stack.spacing = 12
stack.edgeInsets = NSEdgeInsets(top: 14, left: 14, bottom: 14, right: 14)
return stack
}
@@ -398,7 +439,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
let stack = NSStackView(views: [titleLabel] + views)
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 8
stack.spacing = 7
let minimumWidth = stack.widthAnchor.constraint(greaterThanOrEqualToConstant: Metrics.settingsContentMinimumWidth)
minimumWidth.priority = .defaultLow
minimumWidth.isActive = true
@@ -513,7 +554,10 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
private func modifierButton(_ title: String, _ tag: Int) -> NSButton {
let control = NSButton()
configureCheckbox(control, title: title, action: #selector(shortcutChanged(_:)))
control.toolTip = modifierTooltip(title)
let modifierName = modifierTooltip(title)
control.toolTip = modifierName
control.setAccessibilityLabel("\(modifierName) modifier")
control.setAccessibilityHelp("Include or remove the \(modifierName) key from this shortcut.")
control.tag = tag
return control
}
@@ -572,7 +616,7 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
clearHistoryOnQuitButton.state = settings.clearHistoryOnQuit ? .on : .off
case .pollProfile:
select(pollProfilePopup, rawValue: settings.pollProfileRaw.rawValue)
case .panelLayout:
case .panelSide:
select(panelSidePopup, rawValue: settings.panelSide.rawValue)
case .showMenuBarIcon:
refreshVisibilityControls()
@@ -859,7 +903,9 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
)
cloudSyncStatusLabel.stringValue = presentation.message
cloudSyncStatusLabel.textColor = presentation.textColor
let cloudActionsEnabled = settings.iCloudSyncEnabled && cloudStatus?.isAvailable == true
let cloudActionsEnabled = settings.iCloudSyncEnabled
&& cloudStatus?.isAvailable == true
&& !dataOperationInProgress
iCloudSyncNowButton.isEnabled = cloudActionsEnabled
iCloudRestoreButton.isEnabled = cloudActionsEnabled
iCloudRevealButton.isEnabled = cloudActionsEnabled
@@ -1244,11 +1290,16 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
configureArchivePanel(panel)
guard panel.runModal() == .OK, let url = panel.url else { return }
do {
let summary = try store.exportArchive(to: url)
setDataStatus("Exported \(summary.itemCount) clips and \(summary.sidecarCount) attachments.")
} catch {
setDataStatus(error.localizedDescription)
setDataStatus("Exporting archive…")
performDataOperation({ [store] in
Result { try store.exportArchive(to: url) }
}) { [weak self] result in
switch result {
case .success(let summary):
self?.setDataStatus("Exported \(summary.itemCount) clips and \(summary.sidecarCount) attachments.")
case .failure(let error):
self?.setDataStatus(error.localizedDescription)
}
}
}
@@ -1261,15 +1312,20 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
configureArchivePanel(panel)
guard panel.runModal() == .OK, let url = panel.url else { return }
do {
let summary = try store.importArchive(from: url)
var message = "Imported \(summary.itemCount) clips and \(summary.sidecarCount) attachments."
if summary.skippedItemCount > 0 || summary.skippedSidecarCount > 0 {
message += " Skipped \(summary.skippedItemCount) clips and \(summary.skippedSidecarCount) attachments."
setDataStatus("Importing archive…")
performDataOperation({ [store] in
Result { try store.importArchive(from: url) }
}) { [weak self] result in
switch result {
case .success(let summary):
var message = "Imported \(summary.itemCount) clips and \(summary.sidecarCount) attachments."
if summary.skippedItemCount > 0 || summary.skippedSidecarCount > 0 {
message += " Skipped \(summary.skippedItemCount) clips and \(summary.skippedSidecarCount) attachments."
}
self?.setDataStatus(message)
case .failure(let error):
self?.setDataStatus(error.localizedDescription)
}
setDataStatus(message)
} catch {
setDataStatus(error.localizedDescription)
}
}
@@ -1279,12 +1335,20 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
return
}
do {
let summary = try cloudSyncService.push(store: store)
cachedCloudSyncStatus = cloudSyncService.status()
settings.setCloudSyncStatus(message: "Synced \(summary.itemCount) clips and \(summary.sidecarCount) attachments to iCloud.")
} catch {
settings.setCloudSyncStatus(message: "iCloud Sync failed: \(error.localizedDescription)")
settings.setCloudSyncStatus(message: "Syncing with iCloud…")
performDataOperation({ [cloudSyncService, store] in
Result {
let summary = try cloudSyncService.push(store: store)
return (summary, cloudSyncService.status())
}
}) { [weak self] result in
switch result {
case .success(let (summary, status)):
self?.cachedCloudSyncStatus = status
self?.settings.setCloudSyncStatus(message: "Synced \(summary.itemCount) clips and \(summary.sidecarCount) attachments to iCloud.")
case .failure(let error):
self?.settings.setCloudSyncStatus(message: "iCloud Sync failed: \(error.localizedDescription)")
}
}
}
@@ -1294,16 +1358,45 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
return
}
do {
let summary = try cloudSyncService.pull(store: store)
cachedCloudSyncStatus = cloudSyncService.status()
var message = "Restored \(summary.itemCount) clips and \(summary.sidecarCount) attachments from iCloud."
if summary.skippedItemCount > 0 || summary.skippedSidecarCount > 0 {
message += " Skipped \(summary.skippedItemCount) clips and \(summary.skippedSidecarCount) attachments."
settings.setCloudSyncStatus(message: "Restoring from iCloud…")
performDataOperation({ [cloudSyncService, store] in
Result {
let summary = try cloudSyncService.pull(store: store)
return (summary, cloudSyncService.status())
}
}) { [weak self] result in
switch result {
case .success(let (summary, status)):
self?.cachedCloudSyncStatus = status
var message = "Restored \(summary.itemCount) clips and \(summary.sidecarCount) attachments from iCloud."
if summary.skippedItemCount > 0 || summary.skippedSidecarCount > 0 {
message += " Skipped \(summary.skippedItemCount) clips and \(summary.skippedSidecarCount) attachments."
}
self?.settings.setCloudSyncStatus(message: message)
case .failure(let error):
self?.settings.setCloudSyncStatus(message: "iCloud Sync failed: \(error.localizedDescription)")
}
}
}
private func performDataOperation<ResultValue>(
_ operation: @escaping () -> ResultValue,
completion: @escaping (ResultValue) -> Void
) {
dataOperationInProgress = true
exportArchiveButton.isEnabled = false
importArchiveButton.isEnabled = false
refreshCloudSyncControls(refreshStatus: false)
dataOperationQueue.addOperation { [weak self] in
let result = operation()
DispatchQueue.main.async { [weak self] in
guard let self else { return }
self.dataOperationInProgress = false
self.exportArchiveButton.isEnabled = true
self.importArchiveButton.isEnabled = true
completion(result)
self.refreshCloudSyncControls(refreshStatus: false)
}
settings.setCloudSyncStatus(message: message)
} catch {
settings.setCloudSyncStatus(message: "iCloud Sync failed: \(error.localizedDescription)")
}
}
@@ -1736,6 +1829,36 @@ final class SettingsWindowController: NSObject, NSWindowDelegate, NSTextFieldDel
shortcutStatusLabel.stringValue
}
var debugOpenShortcutModifierAccessibilityLabels: [String] {
guard let controls = openShortcutControls else { return [] }
return [controls.command, controls.option, controls.control, controls.shift]
.compactMap { $0.accessibilityLabel() }
}
var debugOpenShortcutModifierAccessibilityHelps: [String] {
guard let controls = openShortcutControls else { return [] }
return [controls.command, controls.option, controls.control, controls.shift]
.compactMap { $0.accessibilityHelp() }
}
var debugSettingsContentView: NSView? {
window?.contentView
}
func debugSelectSettingsTab(at index: Int) {
guard index >= 0, index < tabView.numberOfTabViewItems else { return }
tabSelector.selectedSegment = index
tabView.selectTabViewItem(at: index)
window?.contentView?.layoutSubtreeIfNeeded()
}
func debugPrepareWindowForSnapshot() {
window?.setFrameOrigin(.zero)
window?.orderFront(nil)
window?.contentView?.layoutSubtreeIfNeeded()
window?.contentView?.displayIfNeeded()
}
var debugFullRefreshCount: Int {
debugFullRefreshCountValue
}