Polish clipboard panel UX and interactions
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user