From dc46c90815a039c21c81f6f039747dc1b0e2f6f2 Mon Sep 17 00:00:00 2001 From: Denis Mordvinov <8133967+Sublio@users.noreply.github.com> Date: Fri, 2 Oct 2026 22:36:52 +0200 Subject: [PATCH 01/59] Migrate to Swift 6 (#1532) --- Maccy.xcodeproj/project.pbxproj | 36 +++-- Maccy.xctestplan | 6 + Maccy/AppDelegate.swift | 20 +-- Maccy/Extensions/Defaults.Keys+Names.swift | 8 +- .../NSPasteboard.PasteboardType+Types.swift | 2 +- Maccy/Intents/Clear.swift | 4 +- Maccy/Intents/Delete.swift | 18 +-- Maccy/Intents/Get.swift | 52 +++++--- Maccy/Intents/HistoryItemAppEntity.swift | 2 +- Maccy/Intents/Select.swift | 22 +-- Maccy/Models/HistoryItem.swift | 40 +++--- Maccy/Observables/History.swift | 8 ++ Maccy/Observables/HistoryItemDecorator.swift | 4 +- Maccy/Observables/Popup.swift | 4 +- Maccy/Settings/StorageSettingsPane.swift | 2 +- MaccyTests/ClipboardTests.swift | 20 ++- MaccyTests/CollectionSurroundingTests.swift | 1 + MaccyTests/ColorImageTests.swift | 1 + MaccyTests/SearchTests.swift | 5 +- MaccyTests/ShortenedTests.swift | 1 + MaccyTests/SorterTests.swift | 7 +- MaccyTests/UnsafeForTitleLayoutTests.swift | 1 + MaccyUITests/MaccyUITests.swift | 125 +++++++++++++++--- README.md | 1 - 24 files changed, 267 insertions(+), 123 deletions(-) diff --git a/Maccy.xcodeproj/project.pbxproj b/Maccy.xcodeproj/project.pbxproj index 2fb76223..82b750bc 100644 --- a/Maccy.xcodeproj/project.pbxproj +++ b/Maccy.xcodeproj/project.pbxproj @@ -39,8 +39,6 @@ 4762D6972467226100B3A2BA /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 4762D6992467226100B3A2BA /* Localizable.strings */; }; 9B24FC30A093450F29E2CC4A /* View+Invisible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DC045710F4F8773D1A5EC87 /* View+Invisible.swift */; }; C0FFEE000000000000000003 /* ShortenedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0FFEE000000000000000004 /* ShortenedTests.swift */; }; - FFFC00000000000000000001 /* String+UnsafeForTitleLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFFC00000000000000000002 /* String+UnsafeForTitleLayout.swift */; }; - FFFC00000000000000000003 /* UnsafeForTitleLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFFC00000000000000000004 /* UnsafeForTitleLayoutTests.swift */; }; CA110ED10000000000000001 /* CollectionSurroundingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA110ED10000000000000002 /* CollectionSurroundingTests.swift */; }; DA009931256411F90030E697 /* appcast.xml in Resources */ = {isa = PBXBuildFile; fileRef = DA00992C256411F90030E697 /* appcast.xml */; }; DA009932256411F90030E697 /* README.md in Resources */ = {isa = PBXBuildFile; fileRef = DA00992D256411F90030E697 /* README.md */; }; @@ -154,6 +152,8 @@ DAFE2DE9268A9B1B00990986 /* HistoryItemTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFE2DE8268A9B1B00990986 /* HistoryItemTests.swift */; }; DAFEF0B8249D7DEE006029E8 /* KeyboardShortcuts.Name+Shortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFEF0B7249D7DEE006029E8 /* KeyboardShortcuts.Name+Shortcuts.swift */; }; F18F166970893C503065D056 /* View+ButtonAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB2945CD7912BE7C7D330C27 /* View+ButtonAction.swift */; }; + FFFC00000000000000000001 /* String+UnsafeForTitleLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFFC00000000000000000002 /* String+UnsafeForTitleLayout.swift */; }; + FFFC00000000000000000003 /* UnsafeForTitleLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FFFC00000000000000000004 /* UnsafeForTitleLayoutTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -236,19 +236,17 @@ 67B1B7B16C214A9A8146309C /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = pt.lproj/GeneralSettings.strings; sourceTree = ""; }; 72C94C4B662B4F0BBD150C82 /* km */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = km; path = km.lproj/Localizable.strings; sourceTree = ""; }; 7CEEFDF124F41F8500ECAD2A /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/Localizable.strings"; sourceTree = ""; }; - 9DC045710F4F8773D1A5EC87 /* View+Invisible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Invisible.swift"; sourceTree = ""; }; 82BF523B9E3C48AE8C38EB5B /* km */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = km; path = km.lproj/GeneralSettings.strings; sourceTree = ""; }; 9C200975CB6B4E258D86BCA2 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = pt.lproj/PreviewItemView.strings; sourceTree = ""; }; + 9DC045710F4F8773D1A5EC87 /* View+Invisible.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Invisible.swift"; sourceTree = ""; }; ABC72863283A9053001EE086 /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/Localizable.strings; sourceTree = ""; }; + B21DCA6C3D17E48D33D6E608 /* VoiceOver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceOver.swift; sourceTree = ""; }; BA40A81B03FF445A83CCD4E7 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = pt.lproj/IgnoreSettings.strings; sourceTree = ""; }; BC59C5474269463B8A32BCEF /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = pt.lproj/AppearanceSettings.strings; sourceTree = ""; }; C0FFEE000000000000000004 /* ShortenedTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortenedTests.swift; sourceTree = ""; }; - FFFC00000000000000000002 /* String+UnsafeForTitleLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+UnsafeForTitleLayout.swift"; sourceTree = ""; }; - FFFC00000000000000000004 /* UnsafeForTitleLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnsafeForTitleLayoutTests.swift; sourceTree = ""; }; C2BC0DBD627544668E9BE9C1 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = pt.lproj/StorageSettings.strings; sourceTree = ""; }; CA110ED10000000000000002 /* CollectionSurroundingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionSurroundingTests.swift; sourceTree = ""; }; CF8FD822BE40469D80B10A06 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = pt.lproj/AdvancedSettings.strings; sourceTree = ""; }; - B21DCA6C3D17E48D33D6E608 /* VoiceOver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceOver.swift; sourceTree = ""; }; DA00992C256411F90030E697 /* appcast.xml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xml; path = appcast.xml; sourceTree = ""; }; DA00992D256411F90030E697 /* README.md */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; DA00992F256411F90030E697 /* LICENSE */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = LICENSE; sourceTree = ""; }; @@ -585,9 +583,11 @@ DAFE2DD9268A521A00990986 /* String+Shortened.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Shortened.swift"; sourceTree = ""; }; DAFE2DE8268A9B1B00990986 /* HistoryItemTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryItemTests.swift; sourceTree = ""; }; DAFEF0B7249D7DEE006029E8 /* KeyboardShortcuts.Name+Shortcuts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KeyboardShortcuts.Name+Shortcuts.swift"; sourceTree = ""; }; - EB2945CD7912BE7C7D330C27 /* View+ButtonAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ButtonAction.swift"; sourceTree = ""; }; E5885F0587B04A99933245E6 /* km */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = km; path = km.lproj/AdvancedSettings.strings; sourceTree = ""; }; + EB2945CD7912BE7C7D330C27 /* View+ButtonAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+ButtonAction.swift"; sourceTree = ""; }; F2E5FA9061DA4866BCFAFE90 /* km */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = km; path = km.lproj/AppearanceSettings.strings; sourceTree = ""; }; + FFFC00000000000000000002 /* String+UnsafeForTitleLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+UnsafeForTitleLayout.swift"; sourceTree = ""; }; + FFFC00000000000000000004 /* UnsafeForTitleLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnsafeForTitleLayoutTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1593,7 +1593,8 @@ PRODUCT_BUNDLE_IDENTIFIER = org.p0deje.MaccyUITests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; + SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; + SWIFT_VERSION = 6.0; TEST_TARGET_NAME = Maccy; }; name = Debug; @@ -1620,7 +1621,8 @@ PRODUCT_BUNDLE_IDENTIFIER = org.p0deje.MaccyUITests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; + SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; + SWIFT_VERSION = 6.0; TEST_TARGET_NAME = Maccy; }; name = Release; @@ -1644,7 +1646,8 @@ PRODUCT_BUNDLE_IDENTIFIER = org.p0deje.MaccyTests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; + SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; + SWIFT_VERSION = 6.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Maccy.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Maccy"; }; name = Debug; @@ -1668,7 +1671,8 @@ PRODUCT_BUNDLE_IDENTIFIER = org.p0deje.MaccyTests; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; + SWIFT_DEFAULT_ACTOR_ISOLATION = nonisolated; + SWIFT_VERSION = 6.0; TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Maccy.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/Maccy"; }; name = Release; @@ -1731,8 +1735,11 @@ ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_SWIFT3_OBJC_INFERENCE = Default; + SWIFT_VERSION = 6.0; }; name = Debug; }; @@ -1786,9 +1793,12 @@ MACOSX_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = macosx; + SWIFT_APPROACHABLE_CONCURRENCY = YES; SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_SWIFT3_OBJC_INFERENCE = Default; + SWIFT_VERSION = 6.0; }; name = Release; }; @@ -1824,7 +1834,7 @@ PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Debug; }; @@ -1858,7 +1868,7 @@ PRODUCT_BUNDLE_IDENTIFIER = org.p0deje.Maccy; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Release; }; diff --git a/Maccy.xctestplan b/Maccy.xctestplan index fa06aefb..f3ad0bb9 100644 --- a/Maccy.xctestplan +++ b/Maccy.xctestplan @@ -14,6 +14,12 @@ "argument" : "enable-testing" } ], + "environmentVariableEntries" : [ + { + "key" : "CI", + "value" : "$(CI)" + } + ], "targetForVariableExpansion" : { "containerPath" : "container:Maccy.xcodeproj", "identifier" : "DAEE38421E3DBEB100DD2966", diff --git a/Maccy/AppDelegate.swift b/Maccy/AppDelegate.swift index 33f1ccb1..430891ca 100644 --- a/Maccy/AppDelegate.swift +++ b/Maccy/AppDelegate.swift @@ -4,7 +4,7 @@ import Sparkle import SwiftUI class AppDelegate: NSObject, NSApplicationDelegate { - static let isTesting = CommandLine.arguments.contains("enable-testing") + nonisolated static let isTesting = CommandLine.arguments.contains("enable-testing") var panel: FloatingPanel! @objc @@ -179,18 +179,18 @@ class AppDelegate: NSObject, NSApplicationDelegate { @objc private func performStatusItemClick() { - if let event = NSApp.currentEvent { - let modifierFlags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + let modifierFlags = (NSApp.currentEvent?.modifierFlags ?? []) + .union(NSEvent.modifierFlags) + .intersection(.deviceIndependentFlagsMask) - if modifierFlags.contains(.option) { - Defaults[.ignoreEvents].toggle() + if modifierFlags.contains(.option) { + Defaults[.ignoreEvents].toggle() - if modifierFlags.contains(.shift) { - Defaults[.ignoreOnlyNextEvent] = Defaults[.ignoreEvents] - } - - return + if modifierFlags.contains(.shift) { + Defaults[.ignoreOnlyNextEvent] = Defaults[.ignoreEvents] } + + return } panel.toggle(height: AppState.shared.popup.height, at: .statusItem) diff --git a/Maccy/Extensions/Defaults.Keys+Names.swift b/Maccy/Extensions/Defaults.Keys+Names.swift index 9df897ae..73b37071 100644 --- a/Maccy/Extensions/Defaults.Keys+Names.swift +++ b/Maccy/Extensions/Defaults.Keys+Names.swift @@ -1,7 +1,7 @@ import AppKit import Defaults -struct StorageType { +nonisolated struct StorageType: Sendable { static let files = StorageType(types: [.fileURL]) static let images = StorageType(types: [.png, .tiff, .jpeg, .heic]) static let text = StorageType(types: [.html, .rtf, .string]) @@ -10,18 +10,18 @@ struct StorageType { var types: [NSPasteboard.PasteboardType] } -extension Defaults.Keys { +nonisolated extension Defaults.Keys { #if DEBUG // UI Tests bundle preferences static let testingSuiteName = "\(Bundle.main.bundleIdentifier ?? "org.p0deje.Maccy").uitests" // When UI tests run with the `enable-testing` argument, window and pin // preferences are stored in a separate xcuitest bundle - private static let preferencesSuite: UserDefaults = AppDelegate.isTesting + nonisolated(unsafe) private static let preferencesSuite: UserDefaults = AppDelegate.isTesting ? (UserDefaults(suiteName: testingSuiteName) ?? .standard) : .standard #else - private static let preferencesSuite: UserDefaults = .standard + nonisolated(unsafe) private static let preferencesSuite: UserDefaults = .standard #endif static let clearOnQuit = Key("clearOnQuit", default: false, suite: preferencesSuite) diff --git a/Maccy/Extensions/NSPasteboard.PasteboardType+Types.swift b/Maccy/Extensions/NSPasteboard.PasteboardType+Types.swift index 976d9066..7c1893fb 100644 --- a/Maccy/Extensions/NSPasteboard.PasteboardType+Types.swift +++ b/Maccy/Extensions/NSPasteboard.PasteboardType+Types.swift @@ -1,7 +1,7 @@ import AppKit.NSPasteboard import Defaults -extension NSPasteboard.PasteboardType: Defaults.Serializable { +nonisolated extension NSPasteboard.PasteboardType: Defaults.Serializable { static let heic = NSPasteboard.PasteboardType(rawValue: "public.heic") static let jpeg = NSPasteboard.PasteboardType(rawValue: "public.jpeg") static let universalClipboard = NSPasteboard.PasteboardType(rawValue: "com.apple.is-remote-clipboard") diff --git a/Maccy/Intents/Clear.swift b/Maccy/Intents/Clear.swift index 77d057e9..8c85883f 100644 --- a/Maccy/Intents/Clear.swift +++ b/Maccy/Intents/Clear.swift @@ -4,8 +4,8 @@ import Defaults struct Clear: AppIntent, CustomIntentMigratedAppIntent { static let intentClassName = "ClearIntent" - static var title: LocalizedStringResource = "Clear Clipboard History" - static var description = IntentDescription("Clears all Maccy clipboard history except for pinned items.") + static let title: LocalizedStringResource = "Clear Clipboard History" + static let description = IntentDescription("Clears all Maccy clipboard history except for pinned items.") static var parameterSummary: some ParameterSummary { Summary("Clear Clipboard History") diff --git a/Maccy/Intents/Delete.swift b/Maccy/Intents/Delete.swift index 360c678f..b55dbf79 100644 --- a/Maccy/Intents/Delete.swift +++ b/Maccy/Intents/Delete.swift @@ -3,8 +3,8 @@ import AppIntents struct Delete: AppIntent, CustomIntentMigratedAppIntent { static let intentClassName = "DeleteIntent" - static var title: LocalizedStringResource = "Delete Item from Clipboard History" - static var description = IntentDescription("Deletes an item from Maccy clipboard history.") + static let title: LocalizedStringResource = "Delete Item from Clipboard History" + static let description = IntentDescription("Deletes an item from Maccy clipboard history.") @Parameter(title: "Number", default: 1) var number: Int @@ -16,14 +16,16 @@ struct Delete: AppIntent, CustomIntentMigratedAppIntent { private let positionOffset = 1 func perform() async throws -> some IntentResult { - let items = AppState.shared.history.items - let index = number - positionOffset - guard items.count >= index else { - throw AppIntentError.notFound + try await MainActor.run { + let items = AppState.shared.history.items + let index = number - positionOffset + guard items.count >= index else { + throw AppIntentError.notFound + } + + AppState.shared.history.delete(items[index]) } - await AppState.shared.history.delete(items[index]) - return .result() } } diff --git a/Maccy/Intents/Get.swift b/Maccy/Intents/Get.swift index bee67c9f..7620b8d4 100644 --- a/Maccy/Intents/Get.swift +++ b/Maccy/Intents/Get.swift @@ -1,11 +1,19 @@ import Foundation import AppIntents +private nonisolated struct ItemContents: Sendable { + let text: String? + let html: Data? + let file: URL? + let image: Data? + let rtf: Data? +} + struct Get: AppIntent, CustomIntentMigratedAppIntent { static let intentClassName = "GetIntent" - static var title: LocalizedStringResource = "Get Item from Clipboard History" - static var description = IntentDescription(""" + static let title: LocalizedStringResource = "Get Item from Clipboard History" + static let description = IntentDescription(""" Gets an item from Maccy clipboard history. The returned item can be used to access its plain/rich/HTML text, image contents or file location. """) @@ -32,38 +40,48 @@ struct Get: AppIntent, CustomIntentMigratedAppIntent { } func perform() async throws -> some IntentResult & ReturnsValue { - var item: HistoryItem? - if selected { - item = AppState.shared.navigator.selection.first?.item - } else { - let index = number - positionOffset - if AppState.shared.history.items.count >= index { - item = AppState.shared.history.items[index].item + let contents = try await MainActor.run { () -> ItemContents in + var item: HistoryItem? + if selected { + item = AppState.shared.navigator.selection.first?.item + } else { + let index = number - positionOffset + if AppState.shared.history.items.count >= index { + item = AppState.shared.history.items[index].item + } + } + + guard let item else { + throw AppIntentError.notFound } - } - guard let item else { - throw AppIntentError.notFound + return ItemContents( + text: item.text, + html: item.htmlData, + file: item.fileURLs.first, + image: item.imageData, + rtf: item.rtfData + ) } let intentItem = HistoryItemAppEntity() - intentItem.text = item.text + intentItem.text = contents.text - if let html = item.htmlData { + if let html = contents.html { intentItem.html = String(data: html, encoding: .utf8) } - if let fileURL = item.fileURLs.first { + if let fileURL = contents.file { intentItem.file = fileURL } - if let imageData = item.imageData { + if let imageData = contents.image { let file = URL.documentsDirectory.appending(path: "image.png") try imageData.write(to: file, options: [.atomic, .completeFileProtection]) intentItem.image = file } - if let rtf = item.rtfData { + if let rtf = contents.rtf { intentItem.richText = String(data: rtf, encoding: .utf8) } diff --git a/Maccy/Intents/HistoryItemAppEntity.swift b/Maccy/Intents/HistoryItemAppEntity.swift index 98e22a27..5e51a6b7 100644 --- a/Maccy/Intents/HistoryItemAppEntity.swift +++ b/Maccy/Intents/HistoryItemAppEntity.swift @@ -1,7 +1,7 @@ import AppIntents struct HistoryItemAppEntity: TransientAppEntity { - static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Clipboard item") + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "Clipboard item") @Property(title: "File") var file: URL? diff --git a/Maccy/Intents/Select.swift b/Maccy/Intents/Select.swift index d2806663..eb2e5e5e 100644 --- a/Maccy/Intents/Select.swift +++ b/Maccy/Intents/Select.swift @@ -3,8 +3,8 @@ import AppIntents struct Select: AppIntent, CustomIntentMigratedAppIntent { static let intentClassName = "SelectIntent" - static var title: LocalizedStringResource = "Select Item in Clipboard History" - static var description = IntentDescription(""" + static let title: LocalizedStringResource = "Select Item in Clipboard History" + static let description = IntentDescription(""" Selects an item in Maccy clipboard history. Depending on Maccy settings, it might trigger pasting of the selected item. """) @@ -19,14 +19,18 @@ struct Select: AppIntent, CustomIntentMigratedAppIntent { private let positionOffset = 1 func perform() async throws -> some IntentResult & ReturnsValue { - let items = AppState.shared.history.items - let index = number - positionOffset - guard items.count >= index else { - throw AppIntentError.notFound - } + let value = try await MainActor.run { () -> String in + let items = AppState.shared.history.items + let index = number - positionOffset + guard items.count >= index else { + throw AppIntentError.notFound + } + + let value = items[index].title + AppState.shared.history.select(items[index], flags: .currentModifierFlags) - let value = items[index].title - await AppState.shared.history.select(items[index], flags: .currentModifierFlags) + return value + } return .result(value: value) } diff --git a/Maccy/Models/HistoryItem.swift b/Maccy/Models/HistoryItem.swift index 37e2f66a..5d6008e8 100644 --- a/Maccy/Models/HistoryItem.swift +++ b/Maccy/Models/HistoryItem.swift @@ -6,6 +6,7 @@ import Vision @Model class HistoryItem { + @MainActor static var supportedPins: Set { // "a" reserved for select all // "q" reserved for quit @@ -92,10 +93,18 @@ class HistoryItem { } } + @MainActor func generateTitle() -> String { - guard image == nil else { + let pasteboardImageData = contentData(Self.imageTypes) + let universalClipboardImageURL = universalClipboardImage ? fileURLs.first : nil + guard pasteboardImageData == nil && universalClipboardImageURL == nil else { Task { - self.performTextRecognition() + if let recognizedText = await Self.recognizeText( + imageData: pasteboardImageData, + fileURL: universalClipboardImageURL + ) { + self.title = recognizedText + } } return "" } @@ -231,31 +240,30 @@ class HistoryItem { .compactMap { $0.value } } - private func performTextRecognition() { - guard let cgImage = image?.cgImage(forProposedRect: nil, context: nil, hints: nil) else { - return + /// Runs OCR on the global concurrent executor so the main thread stays free. + @concurrent + nonisolated private static func recognizeText(imageData: Data?, fileURL: URL?) async -> String? { + guard let data = imageData ?? fileURL.flatMap({ try? Data(contentsOf: $0) }) else { + return nil } - let requestHandler = VNImageRequestHandler(cgImage: cgImage) - let request = VNRecognizeTextRequest(completionHandler: recognizeTextHandler) + let requestHandler = VNImageRequestHandler(data: data) + let request = VNRecognizeTextRequest() request.recognitionLevel = .fast do { try requestHandler.perform([request]) } catch { print("Unable to perform the request: \(error).") - } - } - - private func recognizeTextHandler(request: VNRequest, error: Error?) { - guard let observations = request.results as? [VNRecognizedTextObservation] else { - return + return nil } - let recognizedStrings = observations.compactMap { observation in - return observation.topCandidates(1).first?.string + guard let observations = request.results else { + return nil } - self.title = recognizedStrings.joined(separator: "\n") + return observations + .compactMap { $0.topCandidates(1).first?.string } + .joined(separator: "\n") } } diff --git a/Maccy/Observables/History.swift b/Maccy/Observables/History.swift index cd55f861..5016b9f8 100644 --- a/Maccy/Observables/History.swift +++ b/Maccy/Observables/History.swift @@ -65,7 +65,15 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length @ObservationIgnored var all: [HistoryItemDecorator] = [] +#if DEBUG + static let isRunningUnitTests = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil +#endif + init() { + #if DEBUG + guard !Self.isRunningUnitTests else { return } + #endif + Task { for await _ in Defaults.updates(.pasteByDefault, initial: false) { updateShortcuts() diff --git a/Maccy/Observables/HistoryItemDecorator.swift b/Maccy/Observables/HistoryItemDecorator.swift index 6eeb66e1..f27f4a9f 100644 --- a/Maccy/Observables/HistoryItemDecorator.swift +++ b/Maccy/Observables/HistoryItemDecorator.swift @@ -64,14 +64,14 @@ class HistoryItemDecorator: Identifiable, Hashable, HasVisibility { } private(set) var item: HistoryItem - + var multiSelectionIndex: Int? { guard AppState.shared.navigator.isMultiSelectInProgress else { return nil } return selectionIndex } - + // Describe the complete item independently of its potentially truncated visual content. var accessibilityLabel: String { var parts: [String] = [] diff --git a/Maccy/Observables/Popup.swift b/Maccy/Observables/Popup.swift index e0abbaa2..3248459b 100644 --- a/Maccy/Observables/Popup.swift +++ b/Maccy/Observables/Popup.swift @@ -58,8 +58,8 @@ class Popup { initEventsMonitor() } - deinit { - deinitEventsMonitor() + isolated deinit { + deinitEventsMonitor() } func initEventsMonitor() { diff --git a/Maccy/Settings/StorageSettingsPane.swift b/Maccy/Settings/StorageSettingsPane.swift index 4be87ebc..25ee28b3 100644 --- a/Maccy/Settings/StorageSettingsPane.swift +++ b/Maccy/Settings/StorageSettingsPane.swift @@ -51,7 +51,7 @@ struct StorageSettingsPane: View { } } - deinit { + isolated deinit { observer?.invalidate() } } diff --git a/MaccyTests/ClipboardTests.swift b/MaccyTests/ClipboardTests.swift index e7af7225..f442bf47 100644 --- a/MaccyTests/ClipboardTests.swift +++ b/MaccyTests/ClipboardTests.swift @@ -3,6 +3,7 @@ import Defaults @testable import Maccy // swiftlint:disable type_body_length +@MainActor class ClipboardTests: XCTestCase { let clipboard = Clipboard.shared let pasteboard = NSPasteboard.general @@ -136,8 +137,8 @@ class ClipboardTests: XCTestCase { XCTAssertFalse(Defaults[.ignoreOnlyNextEvent]) } - func testIgnoreApplication() { - Defaults[.ignoredApps] = ["com.apple.dt.Xcode", "com.apple.finder"] // Finder is on Bitrise + func testIgnoreApplication() throws { + Defaults[.ignoredApps] = [try frontmostAppBundleIdentifier()] let hookExpectation = expectation(description: "Hook is called") hookExpectation.isInverted = true @@ -150,9 +151,9 @@ class ClipboardTests: XCTestCase { waitForExpectations(timeout: 2) } - func testIgnoreAllApplicationsExcept() { + func testIgnoreAllApplicationsExcept() throws { Defaults[.ignoreAllAppsExceptListed] = true - Defaults[.ignoredApps] = ["com.apple.dt.Xcode", "com.apple.finder"] // Finder is on Bitrise + Defaults[.ignoredApps] = [try frontmostAppBundleIdentifier()] let hookExpectation = expectation(description: "Hook is called") clipboard.onNewCopy({ (_: HistoryItem) in @@ -202,7 +203,6 @@ class ClipboardTests: XCTestCase { waitForExpectations(timeout: 2) } - @MainActor func testCopy() { let imageData = image.tiffRepresentation! let contents = [ @@ -222,14 +222,12 @@ class ClipboardTests: XCTestCase { XCTAssertEqual(pasteboard.string(forType: .source), "com.foo.bar") } - @MainActor func testCopyString() { clipboard.copyInMaccy("foo") XCTAssertEqual(pasteboard.string(forType: .string), "foo") XCTAssertEqual(pasteboard.string(forType: .source), NSPasteboard.PasteboardType.fromMaccy.rawValue) } - @MainActor func testCopyWithoutFormatting() { let contents = [ HistoryItemContent(type: stringType.rawValue, value: "foo".data(using: .utf8)!), @@ -323,5 +321,13 @@ class ClipboardTests: XCTestCase { waitForExpectations(timeout: 2) } + + private func frontmostAppBundleIdentifier() throws -> String { + guard let bundleIdentifier = NSWorkspace.shared.frontmostApplication?.bundleIdentifier else { + throw XCTSkip("No frontmost application to attribute a copy to") + } + + return bundleIdentifier + } } // swiftlint:enable type_body_length diff --git a/MaccyTests/CollectionSurroundingTests.swift b/MaccyTests/CollectionSurroundingTests.swift index 74910d31..f2025ea2 100644 --- a/MaccyTests/CollectionSurroundingTests.swift +++ b/MaccyTests/CollectionSurroundingTests.swift @@ -1,6 +1,7 @@ import XCTest @testable import Maccy +@MainActor class CollectionSurroundingTests: XCTestCase { let items = ["pin1", "pin2", "recent1", "recent2", "recent3"] diff --git a/MaccyTests/ColorImageTests.swift b/MaccyTests/ColorImageTests.swift index c2d4b8ca..f974bc6e 100644 --- a/MaccyTests/ColorImageTests.swift +++ b/MaccyTests/ColorImageTests.swift @@ -1,6 +1,7 @@ import XCTest @testable import Maccy +@MainActor class ColorImageTests: XCTestCase { func testColorImageFromShortHex() { XCTAssertNotNil(ColorImage.from("fff")) diff --git a/MaccyTests/SearchTests.swift b/MaccyTests/SearchTests.swift index e1bc5327..bdfe56e0 100644 --- a/MaccyTests/SearchTests.swift +++ b/MaccyTests/SearchTests.swift @@ -2,6 +2,7 @@ import XCTest import Defaults @testable import Maccy +@MainActor class SearchTests: XCTestCase { let savedSearchMode = Defaults[.searchMode] var items: [Search.Searchable]! @@ -11,7 +12,6 @@ class SearchTests: XCTestCase { Defaults[.searchMode] = savedSearchMode } - @MainActor func testSimpleSearch() { // swiftlint:disable:this function_body_length Defaults[.searchMode] = Search.Mode.exact items = [ @@ -72,7 +72,6 @@ class SearchTests: XCTestCase { XCTAssertEqual(search("m"), []) } - @MainActor func testFuzzySearch() { // swiftlint:disable:this function_body_length Defaults[.searchMode] = Search.Mode.fuzzy items = [ @@ -157,7 +156,6 @@ class SearchTests: XCTestCase { XCTAssertEqual(search("m"), []) } - @MainActor func testRegexpSearch() { // swiftlint:disable:this function_body_length Defaults[.searchMode] = Search.Mode.regexp items = [ @@ -246,7 +244,6 @@ class SearchTests: XCTestCase { return lowerBound.. HistoryItem { let contents = [ HistoryItemContent( diff --git a/MaccyTests/ShortenedTests.swift b/MaccyTests/ShortenedTests.swift index aec8a7a2..12778109 100644 --- a/MaccyTests/ShortenedTests.swift +++ b/MaccyTests/ShortenedTests.swift @@ -1,6 +1,7 @@ import XCTest @testable import Maccy +@MainActor final class ShortenedTests: XCTestCase { func testShortenedReturnsAtMostMaxLength() { XCTAssertEqual("abcdef".shortened(to: 3), "abc") diff --git a/MaccyTests/SorterTests.swift b/MaccyTests/SorterTests.swift index 6fb9619f..7e558083 100644 --- a/MaccyTests/SorterTests.swift +++ b/MaccyTests/SorterTests.swift @@ -2,6 +2,7 @@ import XCTest import Defaults @testable import Maccy +@MainActor class SorterTests: XCTestCase { let savedPinTo = Defaults[.pinTo] let sorter = Sorter() @@ -10,9 +11,8 @@ class SorterTests: XCTestCase { var item2: HistoryItem! var item3: HistoryItem! - @MainActor - override func setUp() { - super.setUp() + override func setUp() async throws { + try await super.setUp() item1 = historyItem(value: "foo", firstCopiedAt: -300, lastCopiedAt: -100, numberOfCopies: 3) item2 = historyItem(value: "bar", firstCopiedAt: -400, lastCopiedAt: -300, numberOfCopies: 2) item3 = historyItem(value: "bar", firstCopiedAt: -200, lastCopiedAt: -200, numberOfCopies: 1) @@ -51,7 +51,6 @@ class SorterTests: XCTestCase { XCTAssertEqual(sorter.sort([item1, item2, item3], by: .lastCopiedAt), [item2, item1, item3]) } - @MainActor private func historyItem( value: String, firstCopiedAt: Int, diff --git a/MaccyTests/UnsafeForTitleLayoutTests.swift b/MaccyTests/UnsafeForTitleLayoutTests.swift index 85d50848..ae94e689 100644 --- a/MaccyTests/UnsafeForTitleLayoutTests.swift +++ b/MaccyTests/UnsafeForTitleLayoutTests.swift @@ -1,6 +1,7 @@ import XCTest @testable import Maccy +@MainActor final class UnsafeForTitleLayoutTests: XCTestCase { private let objectReplacement = "\u{FFFC}" diff --git a/MaccyUITests/MaccyUITests.swift b/MaccyUITests/MaccyUITests.swift index 1186cd55..ecbc08ac 100644 --- a/MaccyUITests/MaccyUITests.swift +++ b/MaccyUITests/MaccyUITests.swift @@ -1,8 +1,10 @@ +import ApplicationServices import Carbon import XCTest // swiftlint:disable file_length // swiftlint:disable type_body_length +@MainActor private struct HistoryItemQuery { let query: XCUIElementQuery @@ -17,6 +19,7 @@ private struct HistoryItemQuery { } } +@MainActor class MaccyUITests: XCTestCase { let app = XCUIApplication() let pasteboard = NSPasteboard.general @@ -56,8 +59,8 @@ class MaccyUITests: XCTestCase { .compactMap { $0.label.components(separatedBy: ", ").first } } - override func setUp() { - super.setUp() + override func setUp() async throws { + try await super.setUp() try? "Hello world".write(to: file1, atomically: true, encoding: .utf8) try? "Hello world".write(to: file2, atomically: true, encoding: .utf8) @@ -71,11 +74,11 @@ class MaccyUITests: XCTestCase { copyToClipboard(copy2) copyToClipboard(copy1) - } - override func tearDown() { - super.tearDown() + override func tearDown() async throws { + try await super.tearDown() + app.terminate() } @@ -157,8 +160,13 @@ class MaccyUITests: XCTestCase { copyToClipboard(image2) copyToClipboard(image1) popUpWithMouse() - scrollIntoViewIfNeeded(items.allElementsBoundByIndex[1]) - hoverAndClick(items.allElementsBoundByIndex[1]) + let allItems = items.allElementsBoundByIndex + guard allItems.count > 1 else { + XCTFail("Expected at least 2 history items, found \(allItems.count)") + return + } + scrollIntoViewIfNeeded(allItems[1]) + hoverAndClick(allItems[1]) assertPasteboardDataCountEquals(image2.tiffRepresentation!.count, forType: .tiff) } @@ -167,7 +175,7 @@ class MaccyUITests: XCTestCase { copyToClipboard(file1) popUpWithMouse() - XCTAssertEqual(itemTitles[0...1], [ + XCTAssertEqual(Array(itemTitles.prefix(2)), [ file1.absoluteString.removingPercentEncoding!, file2.absoluteString.removingPercentEncoding! ]) @@ -182,7 +190,7 @@ class MaccyUITests: XCTestCase { closePopupByClickingOutside() copyToClipboard(rtf1, .rtf) popUpWithHotkey() - XCTAssertEqual(itemTitles[0...1], ["foo", "bar"]) + XCTAssertEqual(Array(itemTitles.prefix(2)), ["foo", "bar"]) scrollIntoViewIfNeeded(items["bar"].firstMatch) hoverAndClick(items["bar"].firstMatch) XCTAssertEqual(pasteboard.data(forType: .rtf), rtf2) @@ -192,7 +200,7 @@ class MaccyUITests: XCTestCase { copyToClipboard(html2, .html) copyToClipboard(html1, .html) popUpWithMouse() - XCTAssertEqual(itemTitles[0...1], ["foo", "bar"]) + XCTAssertEqual(Array(itemTitles.prefix(2)), ["foo", "bar"]) scrollIntoViewIfNeeded(items["bar"].firstMatch) hoverAndClick(items["bar"].firstMatch) assertPasteboardDataEquals(html2, forType: .html) @@ -287,11 +295,11 @@ class MaccyUITests: XCTestCase { popUpWithMouse() scrollIntoViewIfNeeded(items[copy2].firstMatch) pin(copy2) - XCTAssertEqual(itemTitles[0...1], [copy2, copy1]) + XCTAssertEqual(Array(itemTitles.prefix(2)), [copy2, copy1]) app.typeKey(.escape, modifierFlags: []) popUpWithMouse() - XCTAssertEqual(itemTitles[0...1], [copy2, copy1]) + XCTAssertEqual(Array(itemTitles.prefix(2)), [copy2, copy1]) } func testPinDuringSearch() { @@ -300,7 +308,7 @@ class MaccyUITests: XCTestCase { scrollIntoViewIfNeeded(items[copy2].firstMatch) pin(copy2) assertSearchFieldValue("") - XCTAssertEqual(itemTitles[0...1], [copy2, copy1]) + XCTAssertEqual(Array(itemTitles.prefix(2)), [copy2, copy1]) } func testUnpin() { @@ -308,7 +316,7 @@ class MaccyUITests: XCTestCase { scrollIntoViewIfNeeded(items[copy2].firstMatch) pin(copy2) pin(copy2) - XCTAssertEqual(itemTitles[0...1], [copy1, copy2]) + XCTAssertEqual(Array(itemTitles.prefix(2)), [copy1, copy2]) } func testRemoveLastWordFromSearchWithControlW() { @@ -329,7 +337,7 @@ class MaccyUITests: XCTestCase { func testDisablesOnOptionClickingMenubarIcon() { XCUIElement.perform(withKeyModifiers: .option) { - app.statusItems.firstMatch.click() + clickStatusItem() } let copy3 = UUID().uuidString @@ -343,13 +351,13 @@ class MaccyUITests: XCTestCase { app.typeKey(.escape, modifierFlags: []) XCUIElement.perform(withKeyModifiers: .option) { - app.statusItems.firstMatch.click() + clickStatusItem() } } func testDisablesOnlyForNextCopyOnOptionShiftClickingMenubarIcon() { XCUIElement.perform(withKeyModifiers: [.option, .shift]) { - app.statusItems.firstMatch.click() + clickStatusItem() } let copy3 = UUID().uuidString @@ -510,16 +518,91 @@ class MaccyUITests: XCTestCase { // Click outside the popup to close it private func closePopupByClickingOutside() { - let statusBar = app.statusItems.firstMatch - let coordinate = statusBar.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 10.0)) - coordinate.click() + let popup = app.dialogs.firstMatch + guard popup.exists else { + return + } + + let popupFrame = popup.frame + let screenWidth = NSScreen.screens.first?.frame.width ?? popupFrame.maxX + 100 + let margin: CGFloat = 50 + let targetX = popupFrame.maxX + margin < screenWidth + ? popupFrame.maxX + margin + : popupFrame.minX - margin + popup.coordinate(withNormalizedOffset: .zero) + .withOffset(CGVector(dx: targetX - popupFrame.minX, dy: popupFrame.height / 2)) + .click() } private func popUpWithMouse() { - app.statusItems.firstMatch.click() + clickStatusItem() waitUntilPoppedUp() } + private var isRunningOnCI: Bool { + ProcessInfo.processInfo.environment["CI"] == "true" + } + + private func clickStatusItem() { + if isRunningOnCI { + pressStatusItemViaAccessibility() + return + } + + let statusItem = app.statusItems.firstMatch + if statusItem.waitForExistence(timeout: 3), statusItem.isHittable { + statusItem.click() + } else { + pressStatusItemViaAccessibility() + } + } + + private func pressStatusItemViaAccessibility() { + let pid = NSRunningApplication.runningApplications(withBundleIdentifier: "org.p0deje.Maccy") + .max { ($0.launchDate ?? .distantPast) < ($1.launchDate ?? .distantPast) }? + .processIdentifier + guard let pid else { + XCTFail("Maccy is not running") + return + } + + let appElement = AXUIElementCreateApplication(pid) + let deadline = Date.now.addingTimeInterval(3) + var statusItem = axStatusItem(of: appElement) + while statusItem == nil && Date.now < deadline { + Thread.sleep(forTimeInterval: 0.1) + statusItem = axStatusItem(of: appElement) + } + guard let statusItem else { + XCTFail("Maccy has no status item exposed via accessibility") + return + } + + let result = AXUIElementPerformAction(statusItem, kAXPressAction as CFString) + XCTAssertEqual(result, .success, "Failed to press status item via accessibility") + } + + private func axStatusItem(of appElement: AXUIElement) -> AXUIElement? { + guard let extrasMenuBar = axElement(appElement, attribute: kAXExtrasMenuBarAttribute) else { + return nil + } + + var children: CFTypeRef? + guard AXUIElementCopyAttributeValue(extrasMenuBar, kAXChildrenAttribute as CFString, &children) == .success else { + return nil + } + return (children as? [AXUIElement])?.first + } + + private func axElement(_ element: AXUIElement, attribute: String) -> AXUIElement? { + var value: CFTypeRef? + guard AXUIElementCopyAttributeValue(element, attribute as CFString, &value) == .success, + let value, CFGetTypeID(value) == AXUIElementGetTypeID() else { + return nil + } + return (value as! AXUIElement) // swiftlint:disable:this force_cast + } + private func simulatePopupHotkey() { let commandDown = CGEvent( keyboardEventSource: nil, virtualKey: CGKeyCode(kVK_Command), keyDown: true)! diff --git a/README.md b/README.md index 9b1d0610..67002987 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,6 @@ but I couldn't. So I've decided to build one. Also, I wanted to learn Swift and get acquainted with macOS application development. - ## License [MIT](./LICENSE) From 9165a3cc0945b02901b3ffe5e73ed0223e8106c4 Mon Sep 17 00:00:00 2001 From: Denis Mordvinov <8133967+Sublio@users.noreply.github.com> Date: Sat, 3 Oct 2026 01:19:23 +0200 Subject: [PATCH 02/59] Update dependencies (#1574) --- Maccy.xcodeproj/project.pbxproj | 12 +++---- .../xcshareddata/swiftpm/Package.resolved | 35 ++++++++++++------- .../KeyboardShortcuts.Name+Shortcuts.swift | 8 ++--- Maccy/KeyShortcut.swift | 2 +- Maccy/Models/HistoryItem.swift | 6 ++-- 5 files changed, 36 insertions(+), 27 deletions(-) diff --git a/Maccy.xcodeproj/project.pbxproj b/Maccy.xcodeproj/project.pbxproj index 82b750bc..c6dde05b 100644 --- a/Maccy.xcodeproj/project.pbxproj +++ b/Maccy.xcodeproj/project.pbxproj @@ -1919,7 +1919,7 @@ repositoryURL = "https://github.com/apple/swift-log"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 1.6.4; + minimumVersion = 1.15.1; }; }; 2F39CB022AD9A93C00B749FD /* XCRemoteSwiftPackageReference "Sparkle" */ = { @@ -1927,7 +1927,7 @@ repositoryURL = "https://github.com/sparkle-project/Sparkle"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 2.6.3; + minimumVersion = 2.10.0; }; }; 2F39CB082AD9AE1F00B749FD /* XCRemoteSwiftPackageReference "Settings" */ = { @@ -1935,7 +1935,7 @@ repositoryURL = "https://github.com/sindresorhus/Settings.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 3.1.1; + minimumVersion = 3.1.2; }; }; DA009945256414800030E697 /* XCRemoteSwiftPackageReference "SwiftHEXColors" */ = { @@ -1951,7 +1951,7 @@ repositoryURL = "https://github.com/sindresorhus/KeyboardShortcuts"; requirement = { kind = upToNextMinorVersion; - minimumVersion = 2.0.2; + minimumVersion = 3.1.0; }; }; DA13D7C82C19F43E00FA9E23 /* XCRemoteSwiftPackageReference "LaunchAtLogin-Modern" */ = { @@ -1967,7 +1967,7 @@ repositoryURL = "https://github.com/sindresorhus/Defaults"; requirement = { kind = upToNextMinorVersion; - minimumVersion = 8.2.0; + minimumVersion = 9.0.9; }; }; DA5154D62564102900C01004 /* XCRemoteSwiftPackageReference "Sauce" */ = { @@ -1975,7 +1975,7 @@ repositoryURL = "https://github.com/Clipy/Sauce"; requirement = { kind = upToNextMinorVersion; - minimumVersion = 2.4.1; + minimumVersion = 2.5.2; }; }; DAC0CA3C2C88E7A4003BF498 /* XCRemoteSwiftPackageReference "fuse-swift" */ = { diff --git a/Maccy.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Maccy.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index bc4ab3d0..86af03d0 100644 --- a/Maccy.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Maccy.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "f942e634855df514d2ad3df28082c064282b9265128929b3ff6924f0bae466c1", + "originHash" : "e3df2547fdb5e8125d6dfb2314c9cac7ef744da61dfe12c46972b1666ecedb5b", "pins" : [ { "identity" : "defaults", "kind" : "remoteSourceControl", "location" : "https://github.com/sindresorhus/Defaults", "state" : { - "revision" : "38925e3cfacf3fb89a81a35b1cd44fd5a5b7e0fa", - "version" : "8.2.0" + "revision" : "00a7465a0668a87fa159e779b9d80f1f9652357e", + "version" : "9.0.9" } }, { @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/sindresorhus/KeyboardShortcuts", "state" : { - "revision" : "e6b60117ec266e1e5d059f7f34815144f9762b36", - "version" : "2.0.2" + "revision" : "772133d9dbe800fdac0473226822994c5c162c58", + "version" : "3.1.0" } }, { @@ -42,8 +42,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/Clipy/Sauce", "state" : { - "revision" : "df657bc1beba23ffd580953f183cd756b5bcd514", - "version" : "2.4.1" + "revision" : "1fce89ccc9cbb091022c17ef8f9939901d4e951a", + "version" : "2.5.2" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/sindresorhus/Settings.git", "state" : { - "revision" : "879ea83a7bbc6dbebf62bed8c547f090146372a6", - "version" : "3.1.1" + "revision" : "f41475771f65379ca10852c95119a7f53f0de5a5", + "version" : "3.1.2" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/sparkle-project/Sparkle", "state" : { - "revision" : "0ef1ee0220239b3776f433314515fd849025673f", - "version" : "2.6.4" + "revision" : "eef1a539a373c1f1a320624b1130fc5de7b2e100", + "version" : "2.10.0" } }, { @@ -69,8 +69,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log", "state" : { - "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", - "version" : "1.6.4" + "revision" : "9c6fb14227f55d8f711ce3847dc2f419fb0ecacb", + "version" : "1.15.1" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "050f1a346fbbac0ca2cfb15a95274f7bd1cf0ccf", + "version" : "604.0.0" } }, { diff --git a/Maccy/Extensions/KeyboardShortcuts.Name+Shortcuts.swift b/Maccy/Extensions/KeyboardShortcuts.Name+Shortcuts.swift index e6ae5532..9c0ac3db 100644 --- a/Maccy/Extensions/KeyboardShortcuts.Name+Shortcuts.swift +++ b/Maccy/Extensions/KeyboardShortcuts.Name+Shortcuts.swift @@ -1,8 +1,8 @@ import KeyboardShortcuts extension KeyboardShortcuts.Name { - static let popup = Self("popup", default: Shortcut(.c, modifiers: [.command, .shift])) - static let pin = Self("pin", default: Shortcut(.p, modifiers: [.option])) - static let delete = Self("delete", default: Shortcut(.delete, modifiers: [.option])) - static let togglePreview = Self("togglePreview", default: Shortcut(.space, modifiers: [.control])) + static let popup = Self("popup", initial: Shortcut(.c, modifiers: [.command, .shift])) + static let pin = Self("pin", initial: Shortcut(.p, modifiers: [.option])) + static let delete = Self("delete", initial: Shortcut(.delete, modifiers: [.option])) + static let togglePreview = Self("togglePreview", initial: Shortcut(.space, modifiers: [.control])) } diff --git a/Maccy/KeyShortcut.swift b/Maccy/KeyShortcut.swift index 2e5146c8..5b8c585f 100644 --- a/Maccy/KeyShortcut.swift +++ b/Maccy/KeyShortcut.swift @@ -20,7 +20,7 @@ struct KeyShortcut: Identifiable { var description: String { guard let key, let character = Sauce.shared.currentASCIICapableCharacter( for: Int(Sauce.shared.keyCode(for: key)), - cocoaModifiers: [] + modifiers: .cocoa([]) ) else { return "" } diff --git a/Maccy/Models/HistoryItem.swift b/Maccy/Models/HistoryItem.swift index 5d6008e8..64181ec6 100644 --- a/Maccy/Models/HistoryItem.swift +++ b/Maccy/Models/HistoryItem.swift @@ -19,16 +19,16 @@ class HistoryItem { ]) if let deleteKey = KeyChord.deleteKey, - let character = Sauce.shared.character(for: Int(deleteKey.QWERTYKeyCode), cocoaModifiers: []) { + let character = Sauce.shared.character(for: Int(deleteKey.QWERTYKeyCode), modifiers: .cocoa([])) { keys.remove(character) } if let pinKey = KeyChord.pinKey, - let character = Sauce.shared.character(for: Int(pinKey.QWERTYKeyCode), cocoaModifiers: []) { + let character = Sauce.shared.character(for: Int(pinKey.QWERTYKeyCode), modifiers: .cocoa([])) { keys.remove(character) } if let previewKey = KeyChord.previewKey, - let character = Sauce.shared.character(for: Int(previewKey.QWERTYKeyCode), cocoaModifiers: []) { + let character = Sauce.shared.character(for: Int(previewKey.QWERTYKeyCode), modifiers: .cocoa([])) { keys.remove(character) } From 49f28dbb7b68b72035f2e39a8b729b1d5f034286 Mon Sep 17 00:00:00 2001 From: ddelange <14880945+ddelange@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:21:54 +0200 Subject: [PATCH 03/59] Stop scanning the whole history to render the menu bar title (#1551) --- Maccy/Observables/AppState.swift | 2 +- Maccy/Observables/History.swift | 5 ++++- Maccy/Views/HistoryListView.swift | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Maccy/Observables/AppState.swift b/Maccy/Observables/AppState.swift index bdf268d8..682f2e03 100644 --- a/Maccy/Observables/AppState.swift +++ b/Maccy/Observables/AppState.swift @@ -26,7 +26,7 @@ class AppState: Sendable { } var menuIconText: String { - var title = history.unpinnedItems.first?.text.shortened(to: 100) + var title = history.firstUnpinnedItem?.text.shortened(to: 100) .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" title.unicodeScalars.removeAll(where: CharacterSet.newlines.contains) return title.shortened(to: 20) diff --git a/Maccy/Observables/History.swift b/Maccy/Observables/History.swift index 5016b9f8..a85ea83d 100644 --- a/Maccy/Observables/History.swift +++ b/Maccy/Observables/History.swift @@ -19,13 +19,16 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length var pinnedItems: [HistoryItemDecorator] { items.filter(\.isPinned) } var unpinnedItems: [HistoryItemDecorator] { items.filter(\.isUnpinned) } + var firstPinnedItem: HistoryItemDecorator? { items.first(where: \.isPinned) } + var firstUnpinnedItem: HistoryItemDecorator? { items.first(where: \.isUnpinned) } + var searchQuery: String = "" { didSet { throttler.throttle { [self] in updateItems(search.search(string: searchQuery, within: all)) if searchQuery.isEmpty { - AppState.shared.navigator.select(item: unpinnedItems.first) + AppState.shared.navigator.select(item: firstUnpinnedItem) } else { AppState.shared.navigator.highlightFirst() } diff --git a/Maccy/Views/HistoryListView.swift b/Maccy/Views/HistoryListView.swift index 1c0fc92a..a5ecfee8 100644 --- a/Maccy/Views/HistoryListView.swift +++ b/Maccy/Views/HistoryListView.swift @@ -120,7 +120,7 @@ struct HistoryListView: View { if scenePhase == .active { searchFocused = true appState.navigator.isKeyboardNavigating = true - appState.navigator.select(item: appState.history.unpinnedItems.first ?? appState.history.pinnedItems.first) + appState.navigator.select(item: appState.history.firstUnpinnedItem ?? appState.history.firstPinnedItem) appState.preview.enableAutoOpen() appState.preview.resetAutoOpenSuppression() appState.preview.startAutoOpen() From b2a7c413f7ecb6f2e5930dad1519e7b495177fab Mon Sep 17 00:00:00 2001 From: ddelange <14880945+ddelange@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:22:38 +0200 Subject: [PATCH 04/59] Fix HistoryItemDecorator leaking every history item (#1552) --- Maccy/Observables/HistoryItemDecorator.swift | 6 ++++-- MaccyTests/HistoryDecoratorTests.swift | 12 ++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/Maccy/Observables/HistoryItemDecorator.swift b/Maccy/Observables/HistoryItemDecorator.swift index f27f4a9f..78b88fc4 100644 --- a/Maccy/Observables/HistoryItemDecorator.swift +++ b/Maccy/Observables/HistoryItemDecorator.swift @@ -218,8 +218,9 @@ class HistoryItemDecorator: Identifiable, Hashable, HasVisibility { private func synchronizeItemPin() { _ = withObservationTracking { item.pin - } onChange: { + } onChange: { [weak self] in DispatchQueue.main.async { + guard let self else { return } if let pin = self.item.pin { self.shortcuts = KeyShortcut.create(character: pin) } @@ -231,8 +232,9 @@ class HistoryItemDecorator: Identifiable, Hashable, HasVisibility { private func synchronizeItemTitle() { _ = withObservationTracking { item.title - } onChange: { + } onChange: { [weak self] in DispatchQueue.main.async { + guard let self else { return } self.title = self.item.title self.synchronizeItemTitle() } diff --git a/MaccyTests/HistoryDecoratorTests.swift b/MaccyTests/HistoryDecoratorTests.swift index 6aef658e..813d7cc9 100644 --- a/MaccyTests/HistoryDecoratorTests.swift +++ b/MaccyTests/HistoryDecoratorTests.swift @@ -120,6 +120,18 @@ class HistoryItemDecoratorTests: XCTestCase { XCTAssertFalse(itemDecorator.isPinned) } + func testDeallocatesWhenNoLongerReferenced() { + weak var weakDecorator: HistoryItemDecorator? + + autoreleasepool { + let itemDecorator = historyItemDecorator("foo") + weakDecorator = itemDecorator + XCTAssertNotNil(weakDecorator) + } + + XCTAssertNil(weakDecorator) + } + func testHighlight() { let itemDecorator = historyItemDecorator("foo bar baz") itemDecorator.highlight("random", [ From d21175f57cf8cda9cef9e2c7bac96c6cbf0db222 Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:40:33 +0200 Subject: [PATCH 05/59] Edit pinned items using a modal sheet instead of in settings --- Maccy.xcodeproj/project.pbxproj | 4 + Maccy/FloatingPanel.swift | 4 +- Maccy/Observables/AppState.swift | 5 + Maccy/Observables/HistoryItemDecorator.swift | 3 + Maccy/Views/ContentView.swift | 4 + Maccy/Views/ItemEditorView.swift | 177 +++++++++++++++++++ Maccy/Views/ToolbarView.swift | 34 ++++ 7 files changed, 229 insertions(+), 2 deletions(-) create mode 100644 Maccy/Views/ItemEditorView.swift diff --git a/Maccy.xcodeproj/project.pbxproj b/Maccy.xcodeproj/project.pbxproj index c6dde05b..ebcbf4d7 100644 --- a/Maccy.xcodeproj/project.pbxproj +++ b/Maccy.xcodeproj/project.pbxproj @@ -33,6 +33,7 @@ 2F9B42F12F1A499D006548E5 /* ToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F9B42F02F1A4999006548E5 /* ToolbarView.swift */; }; 2FA81FE02E43D9B700C12F92 /* Dictionary+RemoveItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA81FDF2E43D9A600C12F92 /* Dictionary+RemoveItem.swift */; }; 2FB5BCA02CD8F73F008B33F4 /* ApplicationImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FB5BC9F2CD8F73C008B33F4 /* ApplicationImage.swift */; }; + 2FD499472FFC4AEF00149F02 /* ItemEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FD499462FFC4AEF00149F02 /* ItemEditorView.swift */; }; 2FF2E94E2E774B5B0093D72C /* NavigationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FF2E94D2E774B570093D72C /* NavigationManager.swift */; }; 2FF2E9502E7808470093D72C /* AppImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FF2E94F2E7808420093D72C /* AppImageView.swift */; }; 42E9C329301B7F9C00B6B55B /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 42E9C328301B7F9C00B6B55B /* AppIcon.icon */; }; @@ -215,6 +216,7 @@ 2F9B42F02F1A4999006548E5 /* ToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarView.swift; sourceTree = ""; }; 2FA81FDF2E43D9A600C12F92 /* Dictionary+RemoveItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dictionary+RemoveItem.swift"; sourceTree = ""; }; 2FB5BC9F2CD8F73C008B33F4 /* ApplicationImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationImage.swift; sourceTree = ""; }; + 2FD499462FFC4AEF00149F02 /* ItemEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemEditorView.swift; sourceTree = ""; }; 2FF2E94D2E774B570093D72C /* NavigationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationManager.swift; sourceTree = ""; }; 2FF2E94F2E7808420093D72C /* AppImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppImageView.swift; sourceTree = ""; }; 37AC3B8902CB40CA8294C336 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = pt.lproj/Localizable.strings; sourceTree = ""; }; @@ -727,6 +729,7 @@ 2F0EC7862E774093003E2EA9 /* PasteStackItemView.swift */, 2F162E7F2F1E7B28001FBAFF /* PasteStackPreviewView.swift */, 2F0EC7842E77407C003E2EA9 /* PasteStackView.swift */, + 2FD499462FFC4AEF00149F02 /* ItemEditorView.swift */, 2F0EC77A2E772899003E2EA9 /* PinsView.swift */, DA5E627E2C39E53F00F4C710 /* PreviewItemView.strings */, DA1969132C3F11D600258481 /* PreviewItemView.swift */, @@ -1135,6 +1138,7 @@ 2F0EC7812E773F1D003E2EA9 /* PasteStack.swift in Sources */, DA5078992CF2676B00215488 /* MouseMovedViewModifer.swift in Sources */, DA19690E2C3EEFA900258481 /* KeyboardShortcutView.swift in Sources */, + 2FD499472FFC4AEF00149F02 /* ItemEditorView.swift in Sources */, DA243D122C2F64830012A27F /* ContentView.swift in Sources */, DAD665662898A1C000975096 /* KeyChord.swift in Sources */, DA689FC42C1CF0F00009B887 /* AppearanceSettingsPane.swift in Sources */, diff --git a/Maccy/FloatingPanel.swift b/Maccy/FloatingPanel.swift index 0514d598..0c6b8af2 100644 --- a/Maccy/FloatingPanel.swift +++ b/Maccy/FloatingPanel.swift @@ -200,8 +200,8 @@ class FloatingPanel: NSPanel, NSWindowDelegate { // Close automatically when out of focus, e.g. outside click. override func resignKey() { super.resignKey() - // Don't hide if confirmation is shown. - if NSApp.alertWindow == nil { + // Don't hide while a modal interaction from this panel is active. + if NSApp.alertWindow == nil && !AppState.shared.suppressPopupAutoClose { close() } } diff --git a/Maccy/Observables/AppState.swift b/Maccy/Observables/AppState.swift index 682f2e03..b9c6a225 100644 --- a/Maccy/Observables/AppState.swift +++ b/Maccy/Observables/AppState.swift @@ -16,6 +16,11 @@ class AppState: Sendable { var footer: Footer var navigator: NavigationManager var preview: SlideoutController + + var isEditingItem: Bool = false + var suppressPopupAutoClose: Bool { + return isEditingItem + } var searchVisible: Bool { if !Defaults[.showSearch] { return false } diff --git a/Maccy/Observables/HistoryItemDecorator.swift b/Maccy/Observables/HistoryItemDecorator.swift index 78b88fc4..66e5db79 100644 --- a/Maccy/Observables/HistoryItemDecorator.swift +++ b/Maccy/Observables/HistoryItemDecorator.swift @@ -40,6 +40,9 @@ class HistoryItemDecorator: Identifiable, Hashable, HasVisibility { } var hasImage: Bool { item.image != nil } + var hasFileURLs: Bool { !item.fileURLs.isEmpty } + var hasPlainText: Bool { item.text != nil } + var hasRichText: Bool { item.rtf != nil || item.html != nil } var previewImageGenerationTask: Task<(), Error>? var thumbnailImageGenerationTask: Task<(), Error>? diff --git a/Maccy/Views/ContentView.swift b/Maccy/Views/ContentView.swift index 72fe0d94..766483f6 100644 --- a/Maccy/Views/ContentView.swift +++ b/Maccy/Views/ContentView.swift @@ -62,6 +62,8 @@ struct ContentView: View { .environment(\.scenePhase, scenePhase) // FloatingPanel is not a scene, so let's implement custom scenePhase.. .onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { + guard !appState.isEditingItem else { return } + if let window = $0.object as? NSWindow, let bundleIdentifier = Bundle.main.bundleIdentifier, window.identifier == NSUserInterfaceItemIdentifier(bundleIdentifier) { @@ -69,6 +71,8 @@ struct ContentView: View { } } .onReceive(NotificationCenter.default.publisher(for: NSWindow.didResignKeyNotification)) { + guard !appState.isEditingItem else { return } + if let window = $0.object as? NSWindow, let bundleIdentifier = Bundle.main.bundleIdentifier, window.identifier == NSUserInterfaceItemIdentifier(bundleIdentifier) { diff --git a/Maccy/Views/ItemEditorView.swift b/Maccy/Views/ItemEditorView.swift new file mode 100644 index 00000000..5d02e9c8 --- /dev/null +++ b/Maccy/Views/ItemEditorView.swift @@ -0,0 +1,177 @@ +import AppKit +import SwiftUI + +struct ItemEditorView: View { + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + + let item: HistoryItemDecorator + + @State private var editableTitle: String + @State private var editableContent: String + @State private var selectedPin: String? + @State private var availablePins: [String] = [] + @State private var isTextContent: Bool + @State private var isRichText: Bool + + init(for item: HistoryItemDecorator) { + self.item = item + self._editableTitle = State(initialValue: item.item.title) + self._editableContent = State(initialValue: item.item.previewableText) + self._selectedPin = State(initialValue: item.item.pin) + + // Content can only be edited safely as plain text. + self._isTextContent = State( + initialValue: item.hasPlainText && !item.hasImage && !item.hasFileURLs + ) + self._isRichText = State( + initialValue: item.hasRichText && !item.hasImage && !item.hasFileURLs + ) + } + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 10) { + HStack(alignment: .firstTextBaseline) { + Text("Alias", tableName: "PinsSettings") + .frame(width: 80, alignment: .leading) + + TextField("", text: $editableTitle) + } + + if item.isPinned { + HStack(alignment: .firstTextBaseline) { + Text("Key", tableName: "PinsSettings") + .frame(width: 80, alignment: .leading) + + Picker("", selection: $selectedPin) { + ForEach(uniquePins, id: \.self) { pin in + Text(pin).tag(pin as String?) + } + } + .labelsHidden() + .frame(maxWidth: 220, alignment: .leading) + } + } + } + + VStack(alignment: .leading, spacing: 8) { + Text("Content", tableName: "PinsSettings") + .font(.headline) + + contentEditor + } + + if isRichText { + Label { + Text("RichTextEditWarning", tableName: "PinsSettings") + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + } + .font(.footnote) + } + + HStack { + Spacer() + Button(Self.cancelButtonTitle, role: .cancel) { + dismiss() + } + .keyboardShortcut(.cancelAction) + + Button(Self.doneButtonTitle) { + applyChanges() + dismiss() + } + .keyboardShortcut(.defaultAction) + } + } + .padding(16) + .frame(minWidth: 540, minHeight: 360) + .onAppear { + availablePins = appState.history.availablePins + } + } + + private static var cancelButtonTitle: String { + appKitString("Cancel") + } + + private static var doneButtonTitle: String { + appKitString("Done") + } + + private static func appKitString(_ key: String) -> String { + guard let bundle = Bundle(identifier: "com.apple.AppKit") else { + return key + } + + return NSLocalizedString(key, bundle: bundle, value: key, comment: "") + } + + @ViewBuilder + private var contentEditor: some View { + Group { + if isTextContent || isRichText { + TextEditor(text: $editableContent) + .font(.body) + .scrollContentBackground(.hidden) + .background(Color.clear) + .padding(.horizontal, 8) + .padding(.vertical, 6) + } else { + Text("ContentIsNotText", tableName: "PinsSettings") + .foregroundStyle(.secondary) + .italic() + .frame( + maxWidth: .infinity, + maxHeight: .infinity, + alignment: .topLeading + ) + .padding(12) + } + } + .frame(maxWidth: .infinity, minHeight: 180) + .background { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color(nsColor: .textBackgroundColor)) + } + .overlay { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(Color(nsColor: .separatorColor), lineWidth: 1) + } + .clipShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + } + + private var uniquePins: [String] { + Array(Set(availablePins + [item.item.pin, selectedPin].compactMap { $0 })) + .sorted() + } + + private func applyChanges() { + item.item.title = editableTitle + item.title = editableTitle + + if let selectedPin { + appState.history.updatePin(item.item, to: selectedPin) + } + + guard isTextContent || isRichText else { return } + + let historyItem = item.item + let stringType = NSPasteboard.PasteboardType.string.rawValue + historyItem.contents.removeAll { $0.type != stringType } + + if let index = historyItem.contents.firstIndex(where: { + $0.type == stringType + }) { + if let data = editableContent.data(using: .utf8) { + historyItem.contents[index].value = data + } + } else if let data = editableContent.data(using: .utf8) { + historyItem.contents.append( + HistoryItemContent(type: stringType, value: data) + ) + } + } +} diff --git a/Maccy/Views/ToolbarView.swift b/Maccy/Views/ToolbarView.swift index b3e72ff7..568ea13d 100644 --- a/Maccy/Views/ToolbarView.swift +++ b/Maccy/Views/ToolbarView.swift @@ -72,6 +72,7 @@ struct ToolbarButton: View { struct ToolbarView: View { @State private var appState = AppState.shared + @State private var editingItem: HistoryItemDecorator? @Namespace var unionNamespace @@ -107,6 +108,17 @@ struct ToolbarView: View { return text.isEmpty ? nil : item.title } + private var editItemEnabled: Bool { + guard appState.navigator.selection.count == 1 else { return false } + guard let pinned = appState.navigator.selection.first else { return false } + return pinned.hasPlainText || pinned.hasRichText + } + + private var editableItem: HistoryItemDecorator? { + guard editItemEnabled else { return nil } + return appState.navigator.selection.first + } + var body: some View { HStack { if !appState.navigator.selection.isEmpty { @@ -142,6 +154,18 @@ struct ToolbarView: View { ) .disabled(pinActionDisabled) + ToolbarButton { + appState.isEditingItem = true + editingItem = editableItem + } label: { + if editItemEnabled { + Image(systemName: "pencil") + } else { + Image(systemName: "pencil.slash") + } + } + .disabled(!editItemEnabled) + ToolbarButton { appState.deleteSelection() } label: { @@ -164,5 +188,15 @@ struct ToolbarView: View { .accessibilityLabel(Text("toolbar_remove_paste_stack_action")) } } + .sheet(item: $editingItem, onDismiss: { + finishEditingPinnedItem() + }) { item in + ItemEditorView(for: item) + } + } + + private func finishEditingPinnedItem() { + editingItem = nil + appState.isEditingItem = false } } From 509ef43b35a116238da50aa681f1ad4c12c3a99c Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:51:55 +0200 Subject: [PATCH 06/59] Remove PinsSettingsPane from settings --- Maccy.xcodeproj/project.pbxproj | 4 ---- Maccy/Extensions/NSImage+Names.swift | 1 - .../Settings.PaneIdentifier+Panes.swift | 1 - Maccy/Observables/AppState.swift | 15 ++------------- 4 files changed, 2 insertions(+), 19 deletions(-) diff --git a/Maccy.xcodeproj/project.pbxproj b/Maccy.xcodeproj/project.pbxproj index ebcbf4d7..c8948b7f 100644 --- a/Maccy.xcodeproj/project.pbxproj +++ b/Maccy.xcodeproj/project.pbxproj @@ -131,7 +131,6 @@ DAA072E32C41D1D5006DDFD2 /* ListItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAA072E22C41D1D5006DDFD2 /* ListItemView.swift */; }; DAA072E72C423617006DDFD2 /* NSSize+DefaultsSerializable.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAA072E62C423617006DDFD2 /* NSSize+DefaultsSerializable.swift */; }; DAA072E92C42C6AA006DDFD2 /* ListItemTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAA072E82C42C6AA006DDFD2 /* ListItemTitleView.swift */; }; - DAA365CE2C4BF9DD00A394F8 /* PinsSettingsPane.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAA365CD2C4BF9DD00A394F8 /* PinsSettingsPane.swift */; }; DAA365D12C4C147400A394F8 /* PinsSettings.strings in Resources */ = {isa = PBXBuildFile; fileRef = DAA365CF2C4C147400A394F8 /* PinsSettings.strings */; }; DAA365E72C4C57E600A394F8 /* KeyEquivalent+Keys.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAA365E62C4C57E600A394F8 /* KeyEquivalent+Keys.swift */; }; DAA54BFA2C3C951900B7FDD8 /* FloatingPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAA54BF92C3C951900B7FDD8 /* FloatingPanel.swift */; }; @@ -495,7 +494,6 @@ DAA072E62C423617006DDFD2 /* NSSize+DefaultsSerializable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSSize+DefaultsSerializable.swift"; sourceTree = ""; }; DAA072E82C42C6AA006DDFD2 /* ListItemTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListItemTitleView.swift; sourceTree = ""; }; DAA2BDCE24676CA7007FE090 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/Localizable.strings; sourceTree = ""; }; - DAA365CD2C4BF9DD00A394F8 /* PinsSettingsPane.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinsSettingsPane.swift; sourceTree = ""; }; DAA365D02C4C147400A394F8 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/PinsSettings.strings; sourceTree = ""; }; DAA365D22C4C147C00A394F8 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/PinsSettings.strings; sourceTree = ""; }; DAA365D32C4C147D00A394F8 /* bs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = bs; path = bs.lproj/PinsSettings.strings; sourceTree = ""; }; @@ -699,7 +697,6 @@ DA9C3C482C20D4B40056795D /* IgnoreSettings.strings */, DA44C5E32C1C858400819834 /* StorageSettingsPane.swift */, DA689FAE2C1CD3B00009B887 /* StorageSettings.strings */, - DAA365CD2C4BF9DD00A394F8 /* PinsSettingsPane.swift */, DAA365CF2C4C147400A394F8 /* PinsSettings.strings */, ); path = Settings; @@ -1225,7 +1222,6 @@ DA0FFA2A2C8D100C00A66C97 /* ModifierFlags+Description.swift in Sources */, DAEE38471E3DBEB100DD2966 /* AppDelegate.swift in Sources */, DAA072DF2C41C63C006DDFD2 /* ConfirmationView.swift in Sources */, - DAA365CE2C4BF9DD00A394F8 /* PinsSettingsPane.swift in Sources */, DA3BCB8E2C3E015000B01BC1 /* ModifierFlags.swift in Sources */, DA181177247D14DA00066D55 /* Settings.PaneIdentifier+Panes.swift in Sources */, DA3BCB942C3EECBB00B01BC1 /* HistoryItemDecorator.swift in Sources */, diff --git a/Maccy/Extensions/NSImage+Names.swift b/Maccy/Extensions/NSImage+Names.swift index 1adfa55e..7338c4a5 100644 --- a/Maccy/Extensions/NSImage+Names.swift +++ b/Maccy/Extensions/NSImage+Names.swift @@ -4,7 +4,6 @@ extension NSImage { static let gearshape = NSImage(systemSymbolName: "gearshape", accessibilityDescription: "gearshape") static let externaldrive = NSImage(systemSymbolName: "externaldrive", accessibilityDescription: "externaldrive") static let paintpalette = NSImage(systemSymbolName: "paintpalette", accessibilityDescription: "paintpalette") - static let pincircle = NSImage(systemSymbolName: "pin.circle", accessibilityDescription: "pin.cirlce") static let nosign = NSImage(systemSymbolName: "nosign", accessibilityDescription: "nosign") static let gearshape2 = NSImage(systemSymbolName: "gearshape.2", accessibilityDescription: "gearshape2") } diff --git a/Maccy/Extensions/Settings.PaneIdentifier+Panes.swift b/Maccy/Extensions/Settings.PaneIdentifier+Panes.swift index dfa9c673..f7f436a5 100644 --- a/Maccy/Extensions/Settings.PaneIdentifier+Panes.swift +++ b/Maccy/Extensions/Settings.PaneIdentifier+Panes.swift @@ -5,6 +5,5 @@ extension Settings.PaneIdentifier { static let appearance = Self("appearance") static let general = Self("general") static let ignore = Self("ignore") - static let pins = Self("pins") static let storage = Self("storage") } diff --git a/Maccy/Observables/AppState.swift b/Maccy/Observables/AppState.swift index b9c6a225..4f75341d 100644 --- a/Maccy/Observables/AppState.swift +++ b/Maccy/Observables/AppState.swift @@ -16,7 +16,7 @@ class AppState: Sendable { var footer: Footer var navigator: NavigationManager var preview: SlideoutController - + var isEditingItem: Bool = false var suppressPopupAutoClose: Bool { return isEditingItem @@ -116,10 +116,9 @@ class AppState: Sendable { let generalTitle = NSLocalizedString("Title", tableName: "GeneralSettings", comment: "") let storageTitle = NSLocalizedString("Title", tableName: "StorageSettings", comment: "") let appearanceTitle = NSLocalizedString("Title", tableName: "AppearanceSettings", comment: "") - let pinsTitle = NSLocalizedString("Title", tableName: "PinsSettings", comment: "") let ignoreTitle = NSLocalizedString("Title", tableName: "IgnoreSettings", comment: "") let advancedTitle = NSLocalizedString("Title", tableName: "AdvancedSettings", comment: "") - let toolbarTitles = [generalTitle, storageTitle, appearanceTitle, pinsTitle, ignoreTitle, advancedTitle] + let toolbarTitles = [generalTitle, storageTitle, appearanceTitle, ignoreTitle, advancedTitle] let titleAttributes: [NSAttributedString.Key: Any] = [.font: NSFont.systemFont(ofSize: NSFont.systemFontSize)] let titleWidth = toolbarTitles.reduce(CGFloat.zero) { $0 + ($1 as NSString).size(withAttributes: titleAttributes).width @@ -154,16 +153,6 @@ class AppState: Sendable { AppearanceSettingsPane() .frame(minWidth: minimumWidth) }, - Settings.Pane( - identifier: Settings.PaneIdentifier.pins, - title: pinsTitle, - toolbarIcon: NSImage.pincircle! - ) { - PinsSettingsPane() - .environment(self) - .modelContainer(Storage.shared.container) - .frame(minWidth: minimumWidth) - }, Settings.Pane( identifier: Settings.PaneIdentifier.ignore, title: ignoreTitle, From 82cd643bf105a45fd5baf7903310fb6c53dca940 Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:03:38 +0200 Subject: [PATCH 07/59] Fix rounded corners for sheets --- Maccy/FloatingPanel.swift | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Maccy/FloatingPanel.swift b/Maccy/FloatingPanel.swift index 0c6b8af2..11b62745 100644 --- a/Maccy/FloatingPanel.swift +++ b/Maccy/FloatingPanel.swift @@ -62,7 +62,18 @@ class FloatingPanel: NSPanel, NSWindowDelegate { self.saveWindowPosition() }) ) - contentView?.layer?.cornerRadius = Popup.cornerRadius + Popup.horizontalPadding + applyRoundedCorners() + } + + private func applyRoundedCorners() { + let radius = Popup.cornerRadius + Popup.horizontalPadding + + for view in [contentView, contentView?.superview].compactMap({ $0 }) { + view.wantsLayer = true + view.layer?.cornerRadius = radius + view.layer?.cornerCurve = .continuous + view.layer?.masksToBounds = true + } } func toggle(height: CGFloat, at popupPosition: PopupPosition = Defaults[.popupPosition]) { From b0e0f9d6e6a2e9d5412cc74f2145c6bcb8d84f8a Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Thu, 23 Jul 2026 22:41:37 +0200 Subject: [PATCH 08/59] Implement custom pin ordering through drag and drop --- Maccy.xcodeproj/project.pbxproj | 16 ++ Maccy/Extensions/Defaults.Keys+Names.swift | 1 + Maccy/FloatingPanel.swift | 6 +- Maccy/Info.plist | 15 ++ Maccy/Observables/AppState.swift | 2 +- Maccy/Observables/History.swift | 38 ++- Maccy/Observables/HistoryItemDecorator.swift | 12 +- Maccy/Observables/NavigationManager.swift | 19 +- Maccy/Observables/PinManager.swift | 193 ++++++++++++++ Maccy/Sorter.swift | 37 ++- Maccy/Views/HoverSelectionModifier.swift | 2 +- Maccy/Views/LegacyReorderableContainer.swift | 249 ++++++++++++++++++ .../MovableByWindowBackgroundModifier.swift | 22 ++ Maccy/Views/MultipleSelectionListView.swift | 60 ++++- Maccy/Views/PinsView.swift | 67 ++++- Maccy/Views/ReorderableContainer.swift | 219 +++++++++++++++ Maccy/Views/SlideoutView.swift | 6 +- Maccy/Views/ToolbarView.swift | 8 +- MaccyTests/HistoryDecoratorTests.swift | 15 -- MaccyTests/HistoryTests.swift | 36 ++- 20 files changed, 954 insertions(+), 69 deletions(-) create mode 100644 Maccy/Observables/PinManager.swift create mode 100644 Maccy/Views/LegacyReorderableContainer.swift create mode 100644 Maccy/Views/MovableByWindowBackgroundModifier.swift create mode 100644 Maccy/Views/ReorderableContainer.swift diff --git a/Maccy.xcodeproj/project.pbxproj b/Maccy.xcodeproj/project.pbxproj index c8948b7f..3f334725 100644 --- a/Maccy.xcodeproj/project.pbxproj +++ b/Maccy.xcodeproj/project.pbxproj @@ -8,6 +8,7 @@ /* Begin PBXBuildFile section */ 0ABDD5122BB47F1E0054963B /* NSWorkspace+ApplicationName.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ABDD5112BB47F1E0054963B /* NSWorkspace+ApplicationName.swift */; }; + 2F0E055F2FFD2EB300CCD1DE /* ReorderableContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F0E055E2FFD2EAF00CCD1DE /* ReorderableContainer.swift */; }; 21FFF308D9217B43FD4C393F /* VoiceOver.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21DCA6C3D17E48D33D6E608 /* VoiceOver.swift */; }; 2F0EC7792E7727E3003E2EA9 /* HeightReaderModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F0EC7782E7727E3003E2EA9 /* HeightReaderModifier.swift */; }; 2F0EC77B2E772899003E2EA9 /* PinsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F0EC77A2E772899003E2EA9 /* PinsView.swift */; }; @@ -33,7 +34,10 @@ 2F9B42F12F1A499D006548E5 /* ToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F9B42F02F1A4999006548E5 /* ToolbarView.swift */; }; 2FA81FE02E43D9B700C12F92 /* Dictionary+RemoveItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FA81FDF2E43D9A600C12F92 /* Dictionary+RemoveItem.swift */; }; 2FB5BCA02CD8F73F008B33F4 /* ApplicationImage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FB5BC9F2CD8F73C008B33F4 /* ApplicationImage.swift */; }; + 2FD4993E2FFBA57800149F02 /* PinManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FD4993D2FFBA57400149F02 /* PinManager.swift */; }; + 2FD499452FFC489400149F02 /* MovableByWindowBackgroundModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FD499442FFC487F00149F02 /* MovableByWindowBackgroundModifier.swift */; }; 2FD499472FFC4AEF00149F02 /* ItemEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FD499462FFC4AEF00149F02 /* ItemEditorView.swift */; }; + 2FD5DE512FFE6DD50023FF60 /* LegacyReorderableContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FD5DE502FFE6DCE0023FF60 /* LegacyReorderableContainer.swift */; }; 2FF2E94E2E774B5B0093D72C /* NavigationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FF2E94D2E774B570093D72C /* NavigationManager.swift */; }; 2FF2E9502E7808470093D72C /* AppImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FF2E94F2E7808420093D72C /* AppImageView.swift */; }; 42E9C329301B7F9C00B6B55B /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 42E9C328301B7F9C00B6B55B /* AppIcon.icon */; }; @@ -194,6 +198,7 @@ 11EB892C281DADFF00A78CB4 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Localizable.strings; sourceTree = ""; }; 2BA57666588D49AFA9B68B93 /* km */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = km; path = km.lproj/PreviewItemView.strings; sourceTree = ""; }; 2C2CB1D293534FF0958B3C90 /* km */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = km; path = km.lproj/IgnoreSettings.strings; sourceTree = ""; }; + 2F0E055E2FFD2EAF00CCD1DE /* ReorderableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReorderableContainer.swift; sourceTree = ""; }; 2F0EC7782E7727E3003E2EA9 /* HeightReaderModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeightReaderModifier.swift; sourceTree = ""; }; 2F0EC77A2E772899003E2EA9 /* PinsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinsView.swift; sourceTree = ""; }; 2F0EC77C2E773314003E2EA9 /* Selection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Selection.swift; sourceTree = ""; }; @@ -215,7 +220,10 @@ 2F9B42F02F1A4999006548E5 /* ToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarView.swift; sourceTree = ""; }; 2FA81FDF2E43D9A600C12F92 /* Dictionary+RemoveItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Dictionary+RemoveItem.swift"; sourceTree = ""; }; 2FB5BC9F2CD8F73C008B33F4 /* ApplicationImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationImage.swift; sourceTree = ""; }; + 2FD4993D2FFBA57400149F02 /* PinManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PinManager.swift; sourceTree = ""; }; + 2FD499442FFC487F00149F02 /* MovableByWindowBackgroundModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MovableByWindowBackgroundModifier.swift; sourceTree = ""; }; 2FD499462FFC4AEF00149F02 /* ItemEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemEditorView.swift; sourceTree = ""; }; + 2FD5DE502FFE6DCE0023FF60 /* LegacyReorderableContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LegacyReorderableContainer.swift; sourceTree = ""; }; 2FF2E94D2E774B570093D72C /* NavigationManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationManager.swift; sourceTree = ""; }; 2FF2E94F2E7808420093D72C /* AppImageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppImageView.swift; sourceTree = ""; }; 37AC3B8902CB40CA8294C336 /* pt */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = pt; path = pt.lproj/Localizable.strings; sourceTree = ""; }; @@ -705,6 +713,9 @@ DA19690C2C3EEF9300258481 /* Views */ = { isa = PBXGroup; children = ( + 2FD5DE502FFE6DCE0023FF60 /* LegacyReorderableContainer.swift */, + 2F0E055E2FFD2EAF00CCD1DE /* ReorderableContainer.swift */, + 2FD499442FFC487F00149F02 /* MovableByWindowBackgroundModifier.swift */, 2F1091D62F2B6E8100F5C454 /* AsyncView.swift */, 2FF2E94F2E7808420093D72C /* AppImageView.swift */, DAA072DE2C41C63C006DDFD2 /* ConfirmationView.swift */, @@ -778,6 +789,7 @@ DA3BCB912C3EEC3C00B01BC1 /* History.swift */, DA3BCB932C3EECBB00B01BC1 /* HistoryItemDecorator.swift */, DA3BCB8D2C3E015000B01BC1 /* ModifierFlags.swift */, + 2FD4993D2FFBA57400149F02 /* PinManager.swift */, DAE8F5D32C43262B00851CA9 /* Popup.swift */, 2F578C3E2EFFE8720088B759 /* SlideoutController.swift */, ); @@ -1144,6 +1156,7 @@ DA9C3C452C207AF80056795D /* IgnoreSettingsPane.swift in Sources */, DA13D7D82C1A223E00FA9E23 /* Select.swift in Sources */, DA689FC82C1D15140009B887 /* PinsPosition.swift in Sources */, + 2FD5DE512FFE6DD50023FF60 /* LegacyReorderableContainer.swift in Sources */, 2F9B42EF2F1A494C006548E5 /* ListHeaderView.swift in Sources */, DA13D7D92C1A223E00FA9E23 /* Get.swift in Sources */, 2FF2E94E2E774B5B0093D72C /* NavigationManager.swift in Sources */, @@ -1168,6 +1181,7 @@ DA0EF1881E444B2A00E58577 /* Clipboard.swift in Sources */, DA3F1B452C90084600267632 /* Sauce+KeyboardShortcuts.swift in Sources */, DA689FCC2C1D1D510009B887 /* MenuIcon.swift in Sources */, + 2FD499452FFC489400149F02 /* MovableByWindowBackgroundModifier.swift in Sources */, DAAEB196219694AE00A7883C /* About.swift in Sources */, DA6D98E22AEABE03008A77CE /* Accessibility.swift in Sources */, 2F0EC7792E7727E3003E2EA9 /* HeightReaderModifier.swift in Sources */, @@ -1175,6 +1189,7 @@ DAFEF0B8249D7DEE006029E8 /* KeyboardShortcuts.Name+Shortcuts.swift in Sources */, DA1969212C3F6C6800258481 /* HistoryItemAction.swift in Sources */, 0ABDD5122BB47F1E0054963B /* NSWorkspace+ApplicationName.swift in Sources */, + 2F0E055F2FFD2EB300CCD1DE /* ReorderableContainer.swift in Sources */, DA19691A2C3F369800258481 /* HeaderView.swift in Sources */, DA555F082CF0F994009608BD /* ApplicationImageCache.swift in Sources */, DA44C5E42C1C858400819834 /* StorageSettingsPane.swift in Sources */, @@ -1203,6 +1218,7 @@ DA9C3C5E2C20E1190056795D /* IgnoreApplicationsSettingsView.swift in Sources */, 2F0EC7872E774099003E2EA9 /* PasteStackItemView.swift in Sources */, DA20FA722B082DD600056DD5 /* Notifier.swift in Sources */, + 2FD4993E2FFBA57800149F02 /* PinManager.swift in Sources */, 2F578C3F2EFFE8720088B759 /* SlideoutController.swift in Sources */, DAA072DB2C41C5DD006DDFD2 /* FooterItem.swift in Sources */, DA689FC62C1D14F10009B887 /* PopupPosition.swift in Sources */, diff --git a/Maccy/Extensions/Defaults.Keys+Names.swift b/Maccy/Extensions/Defaults.Keys+Names.swift index 73b37071..6b91d4c4 100644 --- a/Maccy/Extensions/Defaults.Keys+Names.swift +++ b/Maccy/Extensions/Defaults.Keys+Names.swift @@ -53,6 +53,7 @@ nonisolated extension Defaults.Keys { static let migrations = Key<[String: Bool]>("migrations", default: [:], suite: preferencesSuite) static let numberOfUsages = Key("numberOfUsages", default: 0, suite: preferencesSuite) static let pasteByDefault = Key("pasteByDefault", default: false, suite: preferencesSuite) + static let pinOrder = Key("pinOrder", default: PinOrder(), suite: preferencesSuite) static let pinTo = Key("pinTo", default: .top, suite: preferencesSuite) static let popupPosition = Key("popupPosition", default: .cursor, suite: preferencesSuite) static let popupScreen = Key("popupScreen", default: 0, suite: preferencesSuite) diff --git a/Maccy/FloatingPanel.swift b/Maccy/FloatingPanel.swift index 11b62745..f9ba1edc 100644 --- a/Maccy/FloatingPanel.swift +++ b/Maccy/FloatingPanel.swift @@ -219,7 +219,11 @@ class FloatingPanel: NSPanel, NSWindowDelegate { override func close() { super.close() - AppState.shared.preview.state = .closed + let appState = AppState.shared + appState.preview.state = .closed + appState.isEditingItem = false + appState.navigator.isDragAndDropInProgress = false + appState.navigator.isManualMultiSelect = false isPresented = false statusBarButton?.isHighlighted = false onClose() diff --git a/Maccy/Info.plist b/Maccy/Info.plist index 6052c6d7..7288b082 100644 --- a/Maccy/Info.plist +++ b/Maccy/Info.plist @@ -34,5 +34,20 @@ SUFeedURL https://raw.githubusercontent.com/p0deje/Maccy/master/appcast.xml + UTExportedTypeDeclarations + + + UTTypeIdentifier + org.p0deje.Maccy.pin-reorder + UTTypeTagSpecification + + UTTypeDescription + Maccy Pin Reorder Type + UTTypeConformsTo + + public.data + + + diff --git a/Maccy/Observables/AppState.swift b/Maccy/Observables/AppState.swift index 4f75341d..2e0e562f 100644 --- a/Maccy/Observables/AppState.swift +++ b/Maccy/Observables/AppState.swift @@ -19,7 +19,7 @@ class AppState: Sendable { var isEditingItem: Bool = false var suppressPopupAutoClose: Bool { - return isEditingItem + return navigator.isDragAndDropInProgress || isEditingItem } var searchVisible: Bool { diff --git a/Maccy/Observables/History.swift b/Maccy/Observables/History.swift index a85ea83d..fb039481 100644 --- a/Maccy/Observables/History.swift +++ b/Maccy/Observables/History.swift @@ -18,6 +18,7 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length var pinnedItems: [HistoryItemDecorator] { items.filter(\.isPinned) } var unpinnedItems: [HistoryItemDecorator] { items.filter(\.isUnpinned) } + var availablePins: [String] { pinManager.availablePins } var firstPinnedItem: HistoryItemDecorator? { items.first(where: \.isPinned) } var firstUnpinnedItem: HistoryItemDecorator? { items.first(where: \.isUnpinned) } @@ -58,6 +59,7 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length private let search = Search() private let sorter = Sorter() private let throttler = Throttler(minimumDelay: 0.2) + private let pinManager = PinManager() @ObservationIgnored private var sessionLog: [Int: HistoryItem] = [:] @@ -117,6 +119,7 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length let descriptor = FetchDescriptor() let results = try Storage.shared.context.fetch(descriptor) all = sorter.sort(results).map { HistoryItemDecorator($0) } + pinManager.load(from: all) items = all limitHistorySize(to: Defaults[.size]) @@ -195,6 +198,8 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length // Or to the last all array place. all.insert(itemDecorator, at: min(removedItemIndex, all.count)) } + pinManager.load(from: all) + items = all } else { itemDecorator = HistoryItemDecorator(item) @@ -234,6 +239,7 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length } all.removeAll(where: \.isUnpinned) sessionLog.removeValues { $0.pin == nil } + pinManager.load(from: all) items = all try? Storage.shared.context.transaction { @@ -264,6 +270,7 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length cleanup(item) } all.removeAll() + pinManager.removeAll() sessionLog.removeAll() items = all @@ -303,6 +310,7 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length try? Storage.shared.context.save() } + pinManager.remove(item) all.removeAll { $0 == item } items.removeAll { $0 == item } sessionLog.removeValues { $0 == item.item } @@ -461,7 +469,9 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length func togglePin(_ item: HistoryItemDecorator?) { guard let item else { return } - item.togglePin() + let wasPinned = item.isPinned + pinManager.toggle(item) + guard item.isPinned != wasPinned else { return } let sortedItems = sorter.sort(all.map(\.item)) if let currentIndex = all.firstIndex(of: item), @@ -479,6 +489,30 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length } } + @MainActor + func movePin(from source: IndexSet, to destination: Int) { + guard searchQuery.isEmpty else { return } + pinManager.move(from: source, to: destination) + } + + @MainActor + func recomputeItemsAfterReordering() { + guard searchQuery.isEmpty else { return } + let sortedItems = sorter.sort(all.map(\.item)) + let decoratorsByItem = Dictionary(uniqueKeysWithValues: all.map { (ObjectIdentifier($0.item), $0) }) + + all = sortedItems.compactMap { decoratorsByItem[ObjectIdentifier($0)] } + items = all + } + + @MainActor + func updatePin(_ item: HistoryItem, to pin: String) { + guard let itemDecorator = all.first(where: { $0.item.id == item.id }) else { return } + + pinManager.updatePin(of: itemDecorator, to: pin) + updateShortcuts() + } + @MainActor private func findSimilarItem(_ item: HistoryItem) -> HistoryItem? { if let duplicate = all.first(where: { $0.item != item && $0.item.supersedes(item) }) { @@ -508,7 +542,7 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length } private func updateShortcuts() { - for item in pinnedItems { + for item in pinManager.pinnedItems { if let pin = item.item.pin { item.shortcuts = KeyShortcut.create(character: pin) } diff --git a/Maccy/Observables/HistoryItemDecorator.swift b/Maccy/Observables/HistoryItemDecorator.swift index 66e5db79..d2706d56 100644 --- a/Maccy/Observables/HistoryItemDecorator.swift +++ b/Maccy/Observables/HistoryItemDecorator.swift @@ -208,16 +208,6 @@ class HistoryItemDecorator: Identifiable, Hashable, HasVisibility { attributedTitle = attributedString } - @MainActor - func togglePin() { - if item.pin != nil { - item.pin = nil - } else { - let pin = HistoryItem.randomAvailablePin - item.pin = pin - } - } - private func synchronizeItemPin() { _ = withObservationTracking { item.pin @@ -226,6 +216,8 @@ class HistoryItemDecorator: Identifiable, Hashable, HasVisibility { guard let self else { return } if let pin = self.item.pin { self.shortcuts = KeyShortcut.create(character: pin) + } else { + self.shortcuts = [] } self.synchronizeItemPin() } diff --git a/Maccy/Observables/NavigationManager.swift b/Maccy/Observables/NavigationManager.swift index b7ed9216..13b74852 100644 --- a/Maccy/Observables/NavigationManager.swift +++ b/Maccy/Observables/NavigationManager.swift @@ -64,15 +64,24 @@ class NavigationManager { // swiftlint:disable:this type_body_length var isMultiSelectInProgress: Bool { return isManualMultiSelect || selection.count > 1 } + var isDragAndDropInProgress: Bool = false { + didSet { + updateSelectionState() + } + } var hoverSelectionWhileKeyboardNavigating: UUID? var isKeyboardNavigating: Bool = true { didSet { - if !isKeyboardNavigating && !isMultiSelectInProgress, - let hoverSelection = hoverSelectionWhileKeyboardNavigating { - hoverSelectionWhileKeyboardNavigating = nil - select(id: hoverSelection) - } + updateSelectionState() + } + } + + private func updateSelectionState() { + if !isKeyboardNavigating && !isMultiSelectInProgress && !isDragAndDropInProgress, + let hoverSelection = hoverSelectionWhileKeyboardNavigating { + hoverSelectionWhileKeyboardNavigating = nil + select(id: hoverSelection) } } diff --git a/Maccy/Observables/PinManager.swift b/Maccy/Observables/PinManager.swift new file mode 100644 index 00000000..d0534d41 --- /dev/null +++ b/Maccy/Observables/PinManager.swift @@ -0,0 +1,193 @@ +import Defaults +import Observation +import SwiftUI + +struct PinOrder: Codable, Equatable, Defaults.Serializable { + var pins: [String] + + init(pins: [String] = []) { + self.pins = Self.uniquePins(pins) + } + + mutating func reconcile(with assignedPins: [String]) { + let assignedPins = Self.uniquePins(assignedPins) + let assignedPinSet = Set(assignedPins) + pins = Self.uniquePins(pins).filter { assignedPinSet.contains($0) } + + for pin in assignedPins where !pins.contains(pin) { + pins.append(pin) + } + } + + mutating func append(_ pin: String) { + guard !pin.isEmpty, !pins.contains(pin) else { return } + pins.append(pin) + } + + mutating func remove(_ pin: String) { + pins.removeAll { $0 == pin } + } + + mutating func replace(_ oldPin: String?, with newPin: String) { + guard !newPin.isEmpty else { return } + + pins.removeAll { $0 == newPin } + if let oldPin, let index = pins.firstIndex(of: oldPin) { + pins[index] = newPin + } else { + pins.append(newPin) + } + } + + private static func uniquePins(_ pins: [String]) -> [String] { + var uniquePins: [String] = [] + for pin in pins where !pin.isEmpty && !uniquePins.contains(pin) { + uniquePins.append(pin) + } + return uniquePins + } +} + +@Observable +final class PinManager { + private(set) var pinnedItems: [HistoryItemDecorator] = [] + + var availablePins: [String] { + let assignedPins = Set(pinnedItems.compactMap(\.item.pin)) + return HistoryItem.supportedPins.subtracting(assignedPins).sorted() + } + + func load(from items: [HistoryItemDecorator]) { + pinnedItems = items.filter(\.isPinned) + reconcileOrder() + sortPinnedItems() + } + + func toggle(_ item: HistoryItemDecorator) { + if item.isPinned { + unpin(item) + } else { + pin(item) + } + } + + func updatePin(of item: HistoryItemDecorator, to pin: String) { + guard HistoryItem.supportedPins.contains(pin) else { return } + guard item.item.pin != pin else { return } + guard !pinnedItems.contains(where: { $0 != item && $0.item.pin == pin }) + else { return } + + let oldPin = item.item.pin + item.item.pin = pin + + if !pinnedItems.contains(item) { + pinnedItems.append(item) + } + + var order = Defaults[.pinOrder] + order.replace(oldPin, with: pin) + Defaults[.pinOrder] = order + sortPinnedItems() + } + + func remove(_ item: HistoryItemDecorator) { + guard let pin = item.item.pin else { return } + + pinnedItems.removeAll { $0 == item } + if !pinnedItems.contains(where: { $0.item.pin == pin }) { + var order = Defaults[.pinOrder] + order.remove(pin) + Defaults[.pinOrder] = order + } + } + + func removeAll() { + pinnedItems.removeAll() + Defaults[.pinOrder] = PinOrder() + } + + func move(from source: IndexSet, to destination: Int) { + pinnedItems.moveElements(from: source, to: destination) + Defaults[.pinOrder] = PinOrder(pins: pinnedItems.compactMap(\.item.pin)) + } + + private func pin(_ item: HistoryItemDecorator) { + guard item.isUnpinned, let pin = availablePins.randomElement() else { + return + } + + item.item.pin = pin + pinnedItems.append(item) + + var order = Defaults[.pinOrder] + order.append(pin) + Defaults[.pinOrder] = order + sortPinnedItems() + } + + private func unpin(_ item: HistoryItemDecorator) { + guard let pin = item.item.pin else { return } + + item.item.pin = nil + pinnedItems.removeAll { $0 == item } + + if !pinnedItems.contains(where: { $0.item.pin == pin }) { + var order = Defaults[.pinOrder] + order.remove(pin) + Defaults[.pinOrder] = order + } + } + + private func reconcileOrder() { + var order = Defaults[.pinOrder] + order.reconcile(with: pinnedItems.compactMap(\.item.pin)) + Defaults[.pinOrder] = order + } + + private func sortPinnedItems() { + let originalIndexes = Dictionary( + uniqueKeysWithValues: pinnedItems.enumerated().map { + ($0.element.id, $0.offset) + } + ) + let order = Defaults[.pinOrder].pins + + pinnedItems.sort { lhs, rhs in + let lhsIndex = + lhs.item.pin.flatMap { order.firstIndex(of: $0) } ?? Int.max + let rhsIndex = + rhs.item.pin.flatMap { order.firstIndex(of: $0) } ?? Int.max + + if lhsIndex != rhsIndex { + return lhsIndex < rhsIndex + } + + return (originalIndexes[lhs.id] ?? 0) < (originalIndexes[rhs.id] ?? 0) + } + } +} + +extension Array where Element == HistoryItemDecorator { + fileprivate mutating func moveElements( + from source: IndexSet, + to destination: Int + ) { + let movingIndexes = source.sorted() + let movingElements = movingIndexes.compactMap { + indices.contains($0) ? self[$0] : nil + } + guard !movingElements.isEmpty else { return } + + for index in movingIndexes.reversed() where indices.contains(index) { + remove(at: index) + } + + let removedBeforeDestination = movingIndexes.filter { $0 < destination } + .count + let insertionIndex = Swift.max( + 0, + Swift.min(destination - removedBeforeDestination, count) + ) + insert(contentsOf: movingElements, at: insertionIndex) + } +} diff --git a/Maccy/Sorter.swift b/Maccy/Sorter.swift index 778fd1db..cbf2978e 100644 --- a/Maccy/Sorter.swift +++ b/Maccy/Sorter.swift @@ -24,9 +24,33 @@ class Sorter { } func sort(_ items: [HistoryItem], by: By = Defaults[.sortBy]) -> [HistoryItem] { - return items - .sorted(by: { return bySortingAlgorithm($0, $1, by) }) - .sorted(by: byPinned) + let sortedUnpinned = items + .filter { $0.pin == nil } + .sorted(by: { bySortingAlgorithm($0, $1, by) }) + + let pinnedItems = items.filter { $0.pin != nil } + let pinOrder = Defaults[.pinOrder].pins + + var pinnedByPin: [String: HistoryItem] = [:] + for item in pinnedItems { + if let pin = item.pin { + pinnedByPin[pin] = item + } + } + + var orderedPinned: [HistoryItem] = pinOrder.compactMap { pinnedByPin[$0] } + let orderedPinSet = Set(pinOrder) + orderedPinned += pinnedItems.filter { + guard let pin = $0.pin else { return false } + return !orderedPinSet.contains(pin) + } + + switch Defaults[.pinTo] { + case .top: + return orderedPinned + sortedUnpinned + case .bottom: + return sortedUnpinned + orderedPinned + } } private func bySortingAlgorithm(_ lhs: HistoryItem, _ rhs: HistoryItem, _ by: By) -> Bool { @@ -40,13 +64,6 @@ class Sorter { } } - private func byPinned(_ lhs: HistoryItem, _ rhs: HistoryItem) -> Bool { - if Defaults[.pinTo] == .bottom { - return (lhs.pin == nil) && (rhs.pin != nil) - } else { - return (lhs.pin != nil) && (rhs.pin == nil) - } - } } // swiftlint:enable identifier_name // swiftlint:enable type_name diff --git a/Maccy/Views/HoverSelectionModifier.swift b/Maccy/Views/HoverSelectionModifier.swift index 4f99c5f2..d8a90534 100644 --- a/Maccy/Views/HoverSelectionModifier.swift +++ b/Maccy/Views/HoverSelectionModifier.swift @@ -6,7 +6,7 @@ private struct HoverSelectionModifier: ViewModifier { func body(content: Content) -> some View { content.onHover { hovering in - if hovering { + if hovering && !appState.navigator.isDragAndDropInProgress { if !appState.navigator.isKeyboardNavigating && !appState.navigator.isMultiSelectInProgress { appState.navigator.selectWithoutScrolling(id: id) } else { diff --git a/Maccy/Views/LegacyReorderableContainer.swift b/Maccy/Views/LegacyReorderableContainer.swift new file mode 100644 index 00000000..88b8dbe6 --- /dev/null +++ b/Maccy/Views/LegacyReorderableContainer.swift @@ -0,0 +1,249 @@ +import AppKit +import SwiftUI +import UniformTypeIdentifiers + +internal struct LegacyDraggableItemModifier: ViewModifier { + @Environment(AppState.self) private var appState + @Environment(ModifierFlags.self) private var modifierFlags + @Environment(\.reorderableDragContext) private var dragContext + + let item: Item + + @ViewBuilder + func body(content: Content) -> some View { + if let dragContext { + content + .opacity(dragContext.isDragged(item) ? 0.5 : 1) + .background( + LegacyDragSourceView( + item: item, + dragContext: dragContext, + appState: appState, + modifierFlags: modifierFlags + ) + ) + .onDrop( + of: [dragContext.contentType], + delegate: LegacyDraggableItemDropDelegate( + item: item, + dragContext: dragContext + ) + ) + } else { + content + } + } +} + +private struct LegacyDraggableItemDropDelegate: DropDelegate +{ + let item: Item + let dragContext: ReorderableDragContext + + func validateDrop(info: DropInfo) -> Bool { + info.hasItemsConforming(to: [dragContext.contentType]) + } + + func dropEntered(info: DropInfo) { + dragContext.move(item) + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + DropProposal(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + dragContext.resetDragState(true) + return true + } +} + +private struct LegacyDragSourceView: NSViewRepresentable { + let item: Item + let dragContext: ReorderableDragContext + let appState: AppState + let modifierFlags: ModifierFlags + + func makeNSView(context: Context) -> DragSourceView { + let view = DragSourceView() + view.configuration = configuration + return view + } + + func updateNSView(_ view: DragSourceView, context: Context) { + view.configuration = configuration + } + + static func dismantleNSView(_ view: DragSourceView, coordinator: ()) { + view.removeEventMonitor() + } + + private var configuration: LegacyDragSourceConfiguration { + LegacyDragSourceConfiguration( + contentType: dragContext.contentType, + makeDragPreview: { sourceView in + dragPreviewImage( + for: dragContext.dragPreview(item), + sourceView: sourceView, + appState: appState, + modifierFlags: modifierFlags + ) + }, + onDragStarted: { + dragContext.startDrag(item) + }, + onDragEnded: { + dragContext.resetDragState(true) + } + ) + } +} + +private struct LegacyDragSourceConfiguration { + let contentType: UTType + let makeDragPreview: (NSView) -> NSImage + let onDragStarted: () -> Void + let onDragEnded: () -> Void +} + +private final class DragSourceView: NSView, NSDraggingSource { + var configuration: LegacyDragSourceConfiguration? { + didSet { + updateEventMonitor() + } + } + + private var localEventMonitor: Any? + private var mouseDownEvent: NSEvent? + private var isDragging = false + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + updateEventMonitor() + } + + override var isFlipped: Bool { + true + } + + func updateEventMonitor() { + if window != nil, configuration != nil, localEventMonitor == nil { + localEventMonitor = NSEvent.addLocalMonitorForEvents( + matching: [.leftMouseDown, .leftMouseDragged, .leftMouseUp] + ) { [weak self] event in + self?.handle(event) ?? event + } + } else if window == nil || configuration == nil { + removeEventMonitor() + } + } + + func removeEventMonitor() { + if let localEventMonitor { + NSEvent.removeMonitor(localEventMonitor) + self.localEventMonitor = nil + } + mouseDownEvent = nil + isDragging = false + } + + deinit { + removeEventMonitor() + } + + private func handle(_ event: NSEvent) -> NSEvent? { + switch event.type { + case .leftMouseDown: + mouseDownEvent = contains(event) ? event : nil + return event + case .leftMouseDragged: + guard mouseDownEvent != nil, !isDragging else { return event } + startDragging(with: event) + return nil + case .leftMouseUp: + mouseDownEvent = nil + return event + default: + return event + } + } + + private func contains(_ event: NSEvent) -> Bool { + guard event.window == window else { return false } + return bounds.contains(convert(event.locationInWindow, from: nil)) + } + + private func startDragging(with event: NSEvent) { + guard let configuration else { return } + + isDragging = true + configuration.onDragStarted() + + let pasteboardItem = NSPasteboardItem() + pasteboardItem.setString( + UUID().uuidString, + forType: NSPasteboard.PasteboardType(configuration.contentType.identifier) + ) + + let preview = configuration.makeDragPreview(self) + let draggingItem = NSDraggingItem(pasteboardWriter: pasteboardItem) + draggingItem.setDraggingFrame( + NSRect(origin: .zero, size: preview.size), + contents: preview + ) + + beginDraggingSession(with: [draggingItem], event: event, source: self) + } + + func draggingSession( + _ session: NSDraggingSession, + sourceOperationMaskFor context: NSDraggingContext + ) -> NSDragOperation { + context == .withinApplication ? .move : [] + } + + func draggingSession( + _ session: NSDraggingSession, + endedAt screenPoint: NSPoint, + operation: NSDragOperation + ) { + isDragging = false + mouseDownEvent = nil + configuration?.onDragEnded() + } +} + +private func dragPreviewImage( + for preview: AnyView, + sourceView: NSView, + appState: AppState, + modifierFlags: ModifierFlags +) -> NSImage { + let hostingView = NSHostingView( + rootView: + preview + .environment(appState) + .environment(modifierFlags) + ) + let fittingSize = hostingView.fittingSize + let size = NSSize( + width: max(sourceView.bounds.width, 1), + height: max(sourceView.bounds.height, fittingSize.height, 1) + ) + + hostingView.frame = NSRect(origin: .zero, size: size) + hostingView.layoutSubtreeIfNeeded() + + guard + let bitmap = hostingView.bitmapImageRepForCachingDisplay( + in: hostingView.bounds + ) + else { + return NSImage(size: size) + } + hostingView.cacheDisplay(in: hostingView.bounds, to: bitmap) + + let image = NSImage(size: size) + image.addRepresentation(bitmap) + return image +} diff --git a/Maccy/Views/MovableByWindowBackgroundModifier.swift b/Maccy/Views/MovableByWindowBackgroundModifier.swift new file mode 100644 index 00000000..2556306e --- /dev/null +++ b/Maccy/Views/MovableByWindowBackgroundModifier.swift @@ -0,0 +1,22 @@ +import SwiftUI + +private struct ExcludeFromWindowMovableByBackgroundModifier: ViewModifier { + @Environment(AppState.self) private var appState + + func body(content: Content) -> some View { + content + .onHover { inside in + if let window = appState.appDelegate?.panel { + window.isMovableByWindowBackground = !inside + } + } + } +} + +extension View { + func excludeFromWindowMovableByBackground() -> some View { + self.modifier( + ExcludeFromWindowMovableByBackgroundModifier() + ) + } +} diff --git a/Maccy/Views/MultipleSelectionListView.swift b/Maccy/Views/MultipleSelectionListView.swift index 69f9ae6a..6bcc3e83 100644 --- a/Maccy/Views/MultipleSelectionListView.swift +++ b/Maccy/Views/MultipleSelectionListView.swift @@ -1,17 +1,61 @@ import SwiftUI -struct MultipleSelectionListView: View - where ID: Hashable, Content: View, ID == Element.ID, Element: Identifiable { +struct IndexedElement: Identifiable, Equatable { + let index: Int + let element: Element + + var id: Element.ID { + element.id + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.element == rhs.element + } +} + +struct MultipleSelectionListView: View +where Element: Identifiable & Equatable, RowContent: View, ModifiedForEach: View { var items: [Element] - var content: (Element?, Element, Element?, Int) -> Content + var content: (Element?, Element, Element?, Int) -> RowContent + var forEachModifier: (ForEach<[IndexedElement], Element.ID, RowContent>) -> ModifiedForEach + + init( + items: [Element], + @ViewBuilder content: @escaping (Element?, Element, Element?, Int) -> RowContent + ) where ModifiedForEach == ForEach<[IndexedElement], Element.ID, RowContent> { + self.items = items + self.content = content + self.forEachModifier = { $0 } + } + + init( + items: [Element], + @ViewBuilder content: @escaping (Element?, Element, Element?, Int) -> RowContent, + @ViewBuilder forEachModifier: @escaping ( + ForEach<[IndexedElement], Element.ID, RowContent> + ) -> ModifiedForEach + ) { + self.items = items + self.content = content + self.forEachModifier = forEachModifier + } var body: some View { + let indexedItems = items.enumerated().map { + IndexedElement(index: $0.offset, element: $0.element) + } + LazyVStack(spacing: 0) { - ForEach(Array(items.enumerated()), id: \.element.id) { (index, element) in - let previous = index > 0 ? items[index - 1] : nil - let next = index < items.count - 1 ? items[index + 1] : nil - content(previous, element, next, index) - } + forEachModifier( + ForEach(indexedItems, id: \.id) { indexed in + let index = indexed.index + let element = indexed.element + let previous = index > 0 ? items[index - 1] : nil + let next = index < items.count - 1 ? items[index + 1] : nil + + content(previous, element, next, index) + } + ) } } } diff --git a/Maccy/Views/PinsView.swift b/Maccy/Views/PinsView.swift index f70310d7..bfb3bbc3 100644 --- a/Maccy/Views/PinsView.swift +++ b/Maccy/Views/PinsView.swift @@ -1,13 +1,76 @@ import SwiftUI +import UniformTypeIdentifiers + +private enum PinReorderDrag { + static let contentType = UTType(exportedAs: "org.p0deje.Maccy.pin-reorder") +} struct PinsView: View { @Environment(AppState.self) private var appState var items: [HistoryItemDecorator] + @State private var draggedItems: [HistoryItemDecorator] = [] + + private var isDragAndDropEnabled: Bool { + appState.history.searchQuery.isEmpty + } var body: some View { - MultipleSelectionListView(items: items) { previous, item, next, index in - HistoryItemView(item: item, previous: previous, next: next, index: index) + LazyVStack(spacing: 0) { + if isDragAndDropEnabled { + MultipleSelectionListView(items: items) { prev, item, next, index in + HistoryItemView( + item: item, + previous: prev, + next: next, + index: index + ) + .draggableItem(item) + } + .draggableContainer( + items: items, + contentType: PinReorderDrag.contentType, + dragPreview: { draggedItems in + MultipleSelectionListView(items: draggedItems) { prev, item, next, index in + HistoryItemView( + item: item, + previous: prev, + next: next, + index: index + ) + } + .background(.regularMaterial) + .clipShape(SelectionAppearance.none.rect(cornerRadius: Popup.cornerRadius)) + }, + selection: Binding( + get: { appState.navigator.selection }, + set: { appState.navigator.selection = $0 } + ), + draggedItems: $draggedItems, + moveAction: { from, to in + withAnimation(.default.speed(2)) { + move(from: from, to: to) + } + }, + moveCompletedAction: { + appState.history.recomputeItemsAfterReordering() + } + ) + } else { + MultipleSelectionListView(items: items) { prev, item, next, index in + HistoryItemView( + item: item, + previous: prev, + next: prev, + index: index + ) + } + } } + .excludeFromWindowMovableByBackground() + } + + func move(from source: IndexSet, to destination: Int) { + appState.history.movePin(from: source, to: destination) } } diff --git a/Maccy/Views/ReorderableContainer.swift b/Maccy/Views/ReorderableContainer.swift new file mode 100644 index 00000000..bc12ab44 --- /dev/null +++ b/Maccy/Views/ReorderableContainer.swift @@ -0,0 +1,219 @@ +import SwiftUI +import UniformTypeIdentifiers + +typealias Reorderable = Identifiable & Equatable + +private struct DraggableContainerModifier: + ViewModifier +{ + let items: [Item] + let contentType: UTType + let dragPreview: ([Item]) -> DragPreview + @Binding var selection: Selection + @Binding var draggedItems: [Item] + + let moveAction: (IndexSet, Int) -> Void + let moveCompletedAction: () -> Void + + @State private var active: Item? + @State private var hasChangedLocation = false + + func body(content: Content) -> some View { + content + .environment(\.reorderableDragContext, context) + .onChange(of: active) { + if active == nil { + resetDragState() + } + } + .onDisappear { + resetDragState() + } + } + + private var context: ReorderableDragContext { + ReorderableDragContext( + contentType: contentType, + isDragged: isDragged, + dragPreview: preview, + startDrag: startDrag, + resetDragState: resetDragState, + move: move + ) + } + + private func isDragged(_ item: Any) -> Bool { + guard let item = item as? Item else { return false } + return draggedItems.contains(item) + } + + private func preview(for item: Any) -> AnyView { + guard let item = item as? Item else { return AnyView(EmptyView()) } + return AnyView(dragPreview(itemsBeingDragged(startingWith: item))) + } + + private func startDrag(_ item: Any) { + guard let item = item as? Item else { return } + resetDragState() + active = item + draggedItems = itemsBeingDragged(startingWith: item) + AppState.shared.navigator.isDragAndDropInProgress = true + } + + private func resetDragState(didCompleteMove: Bool = false) { + if didCompleteMove && hasChangedLocation { + moveCompletedAction() + } + hasChangedLocation = false + draggedItems = [] + active = nil + AppState.shared.navigator.isDragAndDropInProgress = false + } + + private func itemsBeingDragged(startingWith item: Item) -> [Item] { + selection.items.contains(item) + ? items.filter { selection.items.contains($0) } + : [item] + } + + private func move(to item: Any) { + guard let item = item as? Item else { return } + guard !draggedItems.contains(item), let current = active else { return } + guard let from = items.firstIndex(of: current) else { return } + guard let to = items.firstIndex(of: item) else { return } + let source = sourceIndexes + guard !source.isEmpty else { return } + + hasChangedLocation = true + moveAction(source, to > from ? to + 1 : to) + } + + private var sourceIndexes: IndexSet { + var source = IndexSet() + for (index, item) in items.enumerated() where draggedItems.contains(item) { + source.insert(index) + } + return source + } +} + +@available(macOS 26.0, *) +private struct DraggableItemModifier: ViewModifier { + @Environment(\.reorderableDragContext) private var dragContext + + let item: Item + + @ViewBuilder + func body(content: Content) -> some View { + if let dragContext { + content + .opacity(dragContext.isDragged(item) ? 0.5 : 1) + .onDrag { + itemProvider(for: item, contentType: dragContext.contentType) + } preview: { + dragContext.dragPreview(item) + } + .dragConfiguration( + DragConfiguration( + operationsWithinApp: .init(allowCopy: false, allowMove: true), + operationsOutsideApp: .init(allowCopy: false, allowMove: false) + ) + ) + .onDragSessionUpdated { session in + switch session.phase { + case .initial: + dragContext.startDrag(item) + case .ended(_): + dragContext.resetDragState(true) + default: + break + } + } + .onDrop(of: [dragContext.contentType], isTargeted: nil) { _ in + dragContext.resetDragState(true) + return true + } + .onDropSessionUpdated { session in + switch session.phase { + case .entering: + dragContext.move(item) + case .ended(_): + dragContext.resetDragState(true) + default: + break + } + } + } else { + content + } + } + +} + +private func itemProvider( + for item: Item, + contentType: UTType +) -> NSItemProvider { + let provider = NSItemProvider() + provider.registerDataRepresentation( + forTypeIdentifier: contentType.identifier, + visibility: .ownProcess + ) { completion in + completion(String(describing: item.id).data(using: .utf8), nil) + return nil + } + return provider +} + +struct ReorderableDragContext { + let contentType: UTType + let isDragged: (Any) -> Bool + let dragPreview: (Any) -> AnyView + let startDrag: (Any) -> Void + let resetDragState: (Bool) -> Void + let move: (Any) -> Void +} + +private struct ReorderableDragContextKey: EnvironmentKey { + static let defaultValue: ReorderableDragContext? = nil +} + +extension EnvironmentValues { + var reorderableDragContext: ReorderableDragContext? { + get { self[ReorderableDragContextKey.self] } + set { self[ReorderableDragContextKey.self] = newValue } + } +} + +extension View { + func draggableContainer( + items: [Item], + contentType: UTType, + @ViewBuilder dragPreview: @escaping ([Item]) -> DragPreview, + selection: Binding>, + draggedItems: Binding<[Item]>, + moveAction: @escaping (IndexSet, Int) -> Void, + moveCompletedAction: @escaping () -> Void = {} + ) -> some View { + modifier( + DraggableContainerModifier( + items: items, + contentType: contentType, + dragPreview: dragPreview, + selection: selection, + draggedItems: draggedItems, + moveAction: moveAction, + moveCompletedAction: moveCompletedAction + ) + ) + } + + @ViewBuilder + func draggableItem(_ item: Item) -> some View { + if #available(macOS 26.0, *) { + modifier(DraggableItemModifier(item: item)) + } else { + modifier(LegacyDraggableItemModifier(item: item)) + } + } +} diff --git a/Maccy/Views/SlideoutView.swift b/Maccy/Views/SlideoutView.swift index 3e21a54f..2ce969f4 100644 --- a/Maccy/Views/SlideoutView.swift +++ b/Maccy/Views/SlideoutView.swift @@ -25,8 +25,6 @@ extension View { struct SlideoutView: View where Content: View, Slideout: View { - @Environment(AppState.self) private var appState - let controller: SlideoutController @ViewBuilder var content: () -> Content @@ -54,10 +52,8 @@ where Content: View, Slideout: View { // macOS 26 broke gestures if no background is present. // The slight opcaity white background is a workaround .background(Color.white.opacity(0.001)) + .excludeFromWindowMovableByBackground() .onHover(perform: { inside in - if let window = appState.appDelegate?.panel { - window.isMovableByWindowBackground = !inside - } if inside { if #available(macOS 15.0, *) { NSCursor.columnResize.push() diff --git a/Maccy/Views/ToolbarView.swift b/Maccy/Views/ToolbarView.swift index 568ea13d..ad14b55a 100644 --- a/Maccy/Views/ToolbarView.swift +++ b/Maccy/Views/ToolbarView.swift @@ -34,8 +34,6 @@ private struct KeyboardShortcutHelpModifier: ViewModifier { } struct ToolbarButton: View { - @Environment(AppState.self) private var appState - let action: @MainActor () -> Void let label: () -> Label @@ -45,11 +43,7 @@ struct ToolbarButton: View { } .buttonStyle(.plain) .frame(height: 23) - .onHover(perform: { inside in - if let window = appState.appDelegate?.panel { - window.isMovableByWindowBackground = !inside - } - }) + .excludeFromWindowMovableByBackground() } func shortcutKeyHelp( diff --git a/MaccyTests/HistoryDecoratorTests.swift b/MaccyTests/HistoryDecoratorTests.swift index 813d7cc9..1ad2910e 100644 --- a/MaccyTests/HistoryDecoratorTests.swift +++ b/MaccyTests/HistoryDecoratorTests.swift @@ -105,21 +105,6 @@ class HistoryItemDecoratorTests: XCTestCase { XCTAssertFalse(itemDecorator.isPinned) } - func testPin() { - let itemDecorator = historyItemDecorator("foo") - itemDecorator.togglePin() - XCTAssertNotNil(itemDecorator.item.pin) - XCTAssertTrue(itemDecorator.isPinned) - } - - func testUnpin() { - let itemDecorator = historyItemDecorator("foo") - itemDecorator.togglePin() - itemDecorator.togglePin() - XCTAssertNil(itemDecorator.item.pin) - XCTAssertFalse(itemDecorator.isPinned) - } - func testDeallocatesWhenNoLongerReferenced() { weak var weakDecorator: HistoryItemDecorator? diff --git a/MaccyTests/HistoryTests.swift b/MaccyTests/HistoryTests.swift index 68ab83e5..ed047d87 100644 --- a/MaccyTests/HistoryTests.swift +++ b/MaccyTests/HistoryTests.swift @@ -7,6 +7,7 @@ import SwiftData class HistoryTests: XCTestCase { // swiftlint:disable:this type_body_length let savedSize = Defaults[.size] let savedSortBy = Defaults[.sortBy] + let savedPinOrder = Defaults[.pinOrder] let savedPinTo = Defaults[.pinTo] let history = History.shared @@ -15,6 +16,7 @@ class HistoryTests: XCTestCase { // swiftlint:disable:this type_body_length history.clearAll() Defaults[.size] = 10 Defaults[.sortBy] = .firstCopiedAt + Defaults[.pinOrder] = PinOrder() Defaults[.pinTo] = .bottom } @@ -22,6 +24,7 @@ class HistoryTests: XCTestCase { // swiftlint:disable:this type_body_length super.tearDown() Defaults[.size] = savedSize Defaults[.sortBy] = savedSortBy + Defaults[.pinOrder] = savedPinOrder Defaults[.pinTo] = savedPinTo } @@ -204,7 +207,7 @@ class HistoryTests: XCTestCase { // swiftlint:disable:this type_body_length func testClearingUnpinned() throws { let pinned = history.add(historyItem("foo")) - pinned.togglePin() + history.togglePin(pinned) history.add(historyItem("bar")) let orphan = HistoryItemContent( type: NSPasteboard.PasteboardType.string.rawValue, @@ -252,7 +255,7 @@ class HistoryTests: XCTestCase { // swiftlint:disable:this type_body_length let item = history.add(historyItem("0")) items.append(item) - item.togglePin() + history.togglePin(item) for index in 1...11 { items.append(history.add(historyItem(String(index)))) @@ -264,6 +267,35 @@ class HistoryTests: XCTestCase { // swiftlint:disable:this type_body_length XCTAssertFalse(history.items.contains(items[1])) } + func testPinningUpdatesPinOrder() { + let item = history.add(historyItem("foo")) + history.togglePin(item) + + XCTAssertEqual(history.pinnedItems, [item]) + XCTAssertEqual(Defaults[.pinOrder].pins, [item.item.pin].compactMap { $0 }) + } + + func testUnpinningUpdatesPinOrder() { + let item = history.add(historyItem("foo")) + history.togglePin(item) + history.togglePin(item) + + XCTAssertEqual(history.pinnedItems, []) + XCTAssertEqual(Defaults[.pinOrder].pins, []) + } + + func testMovingPinsUpdatesPinOrder() { + let first = history.add(historyItem("foo")) + let second = history.add(historyItem("bar")) + history.togglePin(first) + history.togglePin(second) + + history.movePin(from: IndexSet(integer: 0), to: 2) + + XCTAssertEqual(history.pinnedItems, [second, first]) + XCTAssertEqual(Defaults[.pinOrder].pins, [second.item.pin, first.item.pin].compactMap { $0 }) + } + func testMaxSizeIsChanged() { var items: [HistoryItemDecorator] = [] for index in 0...10 { From 0985cbbac4604cf7184702138da5ac8c77137303 Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:04:20 +0200 Subject: [PATCH 09/59] Only update search query if text really changed --- Maccy/Observables/History.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Maccy/Observables/History.swift b/Maccy/Observables/History.swift index fb039481..9db5610d 100644 --- a/Maccy/Observables/History.swift +++ b/Maccy/Observables/History.swift @@ -24,7 +24,8 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length var firstUnpinnedItem: HistoryItemDecorator? { items.first(where: \.isUnpinned) } var searchQuery: String = "" { - didSet { + didSet(previousSearchQuery) { + guard searchQuery != previousSearchQuery else { return } throttler.throttle { [self] in updateItems(search.search(string: searchQuery, within: all)) From 70be934c2c37fb2ed3a32129a94de40a6839023f Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:25:19 +0200 Subject: [PATCH 10/59] Make sort oder for pins configurable --- Maccy/Extensions/Defaults.Keys+Names.swift | 1 + Maccy/Observables/History.swift | 24 +++- Maccy/Observables/PinManager.swift | 33 +++--- Maccy/Settings/StorageSettingsPane.swift | 65 ++++++++++ .../Settings/en.lproj/StorageSettings.strings | 8 ++ Maccy/Sorter.swift | 95 ++++++++++++--- Maccy/Views/PinsView.swift | 10 +- MaccyTests/HistoryTests.swift | 3 + MaccyTests/SorterTests.swift | 112 ++++++++++++++++++ 9 files changed, 311 insertions(+), 40 deletions(-) diff --git a/Maccy/Extensions/Defaults.Keys+Names.swift b/Maccy/Extensions/Defaults.Keys+Names.swift index 6b91d4c4..70a0ba47 100644 --- a/Maccy/Extensions/Defaults.Keys+Names.swift +++ b/Maccy/Extensions/Defaults.Keys+Names.swift @@ -54,6 +54,7 @@ nonisolated extension Defaults.Keys { static let numberOfUsages = Key("numberOfUsages", default: 0, suite: preferencesSuite) static let pasteByDefault = Key("pasteByDefault", default: false, suite: preferencesSuite) static let pinOrder = Key("pinOrder", default: PinOrder(), suite: preferencesSuite) + static let pinSortBy = Key("pinSortBy", default: .lastCopiedAt, suite: preferencesSuite) static let pinTo = Key("pinTo", default: .top, suite: preferencesSuite) static let popupPosition = Key("popupPosition", default: .cursor, suite: preferencesSuite) static let popupScreen = Key("popupScreen", default: 0, suite: preferencesSuite) diff --git a/Maccy/Observables/History.swift b/Maccy/Observables/History.swift index 9db5610d..658d72fe 100644 --- a/Maccy/Observables/History.swift +++ b/Maccy/Observables/History.swift @@ -92,6 +92,12 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length } } + Task { + for await _ in Defaults.updates(.pinSortBy, initial: false) { + try? await load() + } + } + Task { for await _ in Defaults.updates(.pinTo, initial: false) { try? await load() @@ -492,10 +498,26 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length @MainActor func movePin(from source: IndexSet, to destination: Int) { - guard searchQuery.isEmpty else { return } + guard searchQuery.isEmpty, Defaults[.pinSortBy] == .custom else { return } pinManager.move(from: source, to: destination) } + @MainActor + func setPinSorting(_ pinSortBy: Sorter.PinBy) { + let previousPinSortBy = Defaults[.pinSortBy] + guard pinSortBy != previousPinSortBy else { return } + + if pinSortBy == .custom { + pinManager.adoptSortingForCustomOrder(previousPinSortBy) + } + Defaults[.pinSortBy] = pinSortBy + } + + @MainActor + func pinSortingWouldChangeCurrentOrder(_ pinSortBy: Sorter.PinBy) -> Bool { + pinManager.sortingWouldChangeCurrentOrder(pinSortBy) + } + @MainActor func recomputeItemsAfterReordering() { guard searchQuery.isEmpty else { return } diff --git a/Maccy/Observables/PinManager.swift b/Maccy/Observables/PinManager.swift index d0534d41..9cb13616 100644 --- a/Maccy/Observables/PinManager.swift +++ b/Maccy/Observables/PinManager.swift @@ -52,6 +52,8 @@ struct PinOrder: Codable, Equatable, Defaults.Serializable { final class PinManager { private(set) var pinnedItems: [HistoryItemDecorator] = [] + private let sorter = Sorter() + var availablePins: [String] { let assignedPins = Set(pinnedItems.compactMap(\.item.pin)) return HistoryItem.supportedPins.subtracting(assignedPins).sorted() @@ -107,10 +109,21 @@ final class PinManager { } func move(from source: IndexSet, to destination: Int) { + guard Defaults[.pinSortBy] == .custom else { return } pinnedItems.moveElements(from: source, to: destination) Defaults[.pinOrder] = PinOrder(pins: pinnedItems.compactMap(\.item.pin)) } + func adoptSortingForCustomOrder(_ pinSortBy: Sorter.PinBy) { + let sortedPins = sorter.sortPins(pinnedItems, key: \.item, by: pinSortBy) + Defaults[.pinOrder] = PinOrder(pins: sortedPins.compactMap(\.item.pin)) + } + + func sortingWouldChangeCurrentOrder(_ pinSortBy: Sorter.PinBy) -> Bool { + sorter.sortPins(pinnedItems, key: \.item, by: pinSortBy).map(\.id) + != pinnedItems.map(\.id) + } + private func pin(_ item: HistoryItemDecorator) { guard item.isUnpinned, let pin = availablePins.randomElement() else { return @@ -145,25 +158,7 @@ final class PinManager { } private func sortPinnedItems() { - let originalIndexes = Dictionary( - uniqueKeysWithValues: pinnedItems.enumerated().map { - ($0.element.id, $0.offset) - } - ) - let order = Defaults[.pinOrder].pins - - pinnedItems.sort { lhs, rhs in - let lhsIndex = - lhs.item.pin.flatMap { order.firstIndex(of: $0) } ?? Int.max - let rhsIndex = - rhs.item.pin.flatMap { order.firstIndex(of: $0) } ?? Int.max - - if lhsIndex != rhsIndex { - return lhsIndex < rhsIndex - } - - return (originalIndexes[lhs.id] ?? 0) < (originalIndexes[rhs.id] ?? 0) - } + pinnedItems = sorter.sortPins(pinnedItems, key: \.item) } } diff --git a/Maccy/Settings/StorageSettingsPane.swift b/Maccy/Settings/StorageSettingsPane.swift index 25ee28b3..9371d5ab 100644 --- a/Maccy/Settings/StorageSettingsPane.swift +++ b/Maccy/Settings/StorageSettingsPane.swift @@ -58,9 +58,11 @@ struct StorageSettingsPane: View { @Default(.size) private var size @Default(.sortBy) private var sortBy + @Default(.pinSortBy) private var pinSortBy @State private var viewModel = ViewModel() @State private var storageSize = Storage.shared.size + @State private var pendingPinSortBy: Sorter.PinBy? private let sizeFormatter: NumberFormatter = { let formatter = NumberFormatter() @@ -122,7 +124,70 @@ struct StorageSettingsPane: View { .help(Text("SortByTooltip", tableName: "StorageSettings")) .accessibilityLabel(Text("SortBy", tableName: "StorageSettings")) } + + Settings.Section(label: { Text("SortPinsBy", tableName: "StorageSettings") }) { + Picker("", selection: pinSortBySelection) { + ForEach(Sorter.PinBy.allCases) { mode in + Text(mode.description) + } + } + .labelsHidden() + .frame(width: 160, alignment: .leading) + + Text("SortPinsByDescription", tableName: "StorageSettings") + .fixedSize(horizontal: false, vertical: true) + .controlSize(.small) + .foregroundStyle(.gray) + } } + .alert( + Text("PinSortWarningTitle", tableName: "StorageSettings"), + isPresented: isShowingPinSortWarning, + presenting: pendingPinSortBy + ) { pendingPinSortBy in + Button(role: .destructive) { + applyPinSorting(pendingPinSortBy) + } label: { + Text("PinSortWarningConfirm", tableName: "StorageSettings") + } + Button(role: .cancel) { + } label: { + Text("PinSortWarningCancel", tableName: "StorageSettings") + } + } message: { _ in + Text("PinSortWarningMessage", tableName: "StorageSettings") + } + } + + private var pinSortBySelection: Binding { + Binding( + get: { pinSortBy }, + set: { newValue in + if pinSortBy == .custom, + newValue != .custom, + History.shared.pinSortingWouldChangeCurrentOrder(newValue) { + pendingPinSortBy = newValue + } else { + applyPinSorting(newValue) + } + } + ) + } + + private var isShowingPinSortWarning: Binding { + Binding( + get: { pendingPinSortBy != nil }, + set: { isPresented in + if !isPresented { + pendingPinSortBy = nil + } + } + ) + } + + private func applyPinSorting(_ pinSortBy: Sorter.PinBy) { + History.shared.setPinSorting(pinSortBy) + pendingPinSortBy = nil } } diff --git a/Maccy/Settings/en.lproj/StorageSettings.strings b/Maccy/Settings/en.lproj/StorageSettings.strings index 571a68d0..b306ae83 100644 --- a/Maccy/Settings/en.lproj/StorageSettings.strings +++ b/Maccy/Settings/en.lproj/StorageSettings.strings @@ -12,3 +12,11 @@ "FirstCopiedAt" = "Time of first copy"; "NumberOfCopies" = "Number of copies"; "SortByTooltip" = "Default: Time of last copy."; +"SortPinsBy" = "Sort pins by:"; +"PinLetter" = "Pin letter"; +"Custom" = "Custom"; +"SortPinsByDescription" = "Pins can be reordered by drag and drop when Custom is selected."; +"PinSortWarningTitle" = "Change pin sorting?"; +"PinSortWarningMessage" = "Your custom pin order will be lost."; +"PinSortWarningConfirm" = "Change Sorting"; +"PinSortWarningCancel" = "Cancel"; diff --git a/Maccy/Sorter.swift b/Maccy/Sorter.swift index cbf2978e..e7798b91 100644 --- a/Maccy/Sorter.swift +++ b/Maccy/Sorter.swift @@ -23,33 +23,78 @@ class Sorter { } } + enum PinBy: String, CaseIterable, Identifiable, CustomStringConvertible, Defaults.Serializable { + case lastCopiedAt + case firstCopiedAt + case numberOfCopies + case pinLetter + case custom + + var id: Self { self } + + var description: String { + if let by = By(rawValue: rawValue) { + return by.description + } + + switch self { + case .pinLetter: + return NSLocalizedString("PinLetter", tableName: "StorageSettings", comment: "") + case .custom: + return NSLocalizedString("Custom", tableName: "StorageSettings", comment: "") + default: + return rawValue + } + } + } + func sort(_ items: [HistoryItem], by: By = Defaults[.sortBy]) -> [HistoryItem] { let sortedUnpinned = items .filter { $0.pin == nil } .sorted(by: { bySortingAlgorithm($0, $1, by) }) let pinnedItems = items.filter { $0.pin != nil } - let pinOrder = Defaults[.pinOrder].pins + let sortedPinned = sortPins(pinnedItems, key: \.self) - var pinnedByPin: [String: HistoryItem] = [:] - for item in pinnedItems { - if let pin = item.pin { - pinnedByPin[pin] = item - } + switch Defaults[.pinTo] { + case .top: + return sortedPinned + sortedUnpinned + case .bottom: + return sortedUnpinned + sortedPinned } + } + + func sortPins( + _ items: [HistoryItem], + by: PinBy = Defaults[.pinSortBy] + ) -> [HistoryItem] { + return sortPins(items, key: \.self, by: by) + } - var orderedPinned: [HistoryItem] = pinOrder.compactMap { pinnedByPin[$0] } - let orderedPinSet = Set(pinOrder) - orderedPinned += pinnedItems.filter { - guard let pin = $0.pin else { return false } - return !orderedPinSet.contains(pin) + func sortPins( + _ items: [T], + key: (T) -> HistoryItem, + by: PinBy = Defaults[.pinSortBy] + ) -> [T] { + if let by = By(rawValue: by.rawValue) { + return stableSort(items, key: key) { bySortingAlgorithm($0, $1, by) } } - switch Defaults[.pinTo] { - case .top: - return orderedPinned + sortedUnpinned - case .bottom: - return sortedUnpinned + orderedPinned + switch by { + case .pinLetter: + return stableSort(items, key: key) { ($0.pin ?? "") < ($1.pin ?? "") } + case .custom: + let indexes = Dictionary( + uniqueKeysWithValues: Defaults[.pinOrder].pins.enumerated().map { + ($0.element, $0.offset) + } + ) + return stableSort(items, key: key) { + ($0.pin.flatMap { indexes[$0] } ?? Int.max) + < ($1.pin.flatMap { indexes[$0] } ?? Int.max) + } + default: + return items } } @@ -64,6 +109,24 @@ class Sorter { } } + private func stableSort( + _ items: [T], + key: (T) -> HistoryItem, + by areInIncreasingOrder: (HistoryItem, HistoryItem) -> Bool + ) -> [T] { + items.enumerated().sorted { lhs, rhs in + let lhsItem = key(lhs.element) + let rhsItem = key(rhs.element) + if areInIncreasingOrder(lhsItem, rhsItem) { + return true + } + if areInIncreasingOrder(rhsItem, lhsItem) { + return false + } + return lhs.offset < rhs.offset + }.map(\.element) + } + } // swiftlint:enable identifier_name // swiftlint:enable type_name diff --git a/Maccy/Views/PinsView.swift b/Maccy/Views/PinsView.swift index bfb3bbc3..4d613549 100644 --- a/Maccy/Views/PinsView.swift +++ b/Maccy/Views/PinsView.swift @@ -1,3 +1,4 @@ +import Defaults import SwiftUI import UniformTypeIdentifiers @@ -7,12 +8,13 @@ private enum PinReorderDrag { struct PinsView: View { @Environment(AppState.self) private var appState + @Default(.pinSortBy) private var pinSortBy var items: [HistoryItemDecorator] @State private var draggedItems: [HistoryItemDecorator] = [] private var isDragAndDropEnabled: Bool { - appState.history.searchQuery.isEmpty + appState.history.searchQuery.isEmpty && pinSortBy == .custom } var body: some View { @@ -47,9 +49,9 @@ struct PinsView: View { set: { appState.navigator.selection = $0 } ), draggedItems: $draggedItems, - moveAction: { from, to in + moveAction: { source, destination in withAnimation(.default.speed(2)) { - move(from: from, to: to) + move(from: source, to: destination) } }, moveCompletedAction: { @@ -61,7 +63,7 @@ struct PinsView: View { HistoryItemView( item: item, previous: prev, - next: prev, + next: next, index: index ) } diff --git a/MaccyTests/HistoryTests.swift b/MaccyTests/HistoryTests.swift index ed047d87..9d6ad526 100644 --- a/MaccyTests/HistoryTests.swift +++ b/MaccyTests/HistoryTests.swift @@ -8,6 +8,7 @@ class HistoryTests: XCTestCase { // swiftlint:disable:this type_body_length let savedSize = Defaults[.size] let savedSortBy = Defaults[.sortBy] let savedPinOrder = Defaults[.pinOrder] + let savedPinSortBy = Defaults[.pinSortBy] let savedPinTo = Defaults[.pinTo] let history = History.shared @@ -17,6 +18,7 @@ class HistoryTests: XCTestCase { // swiftlint:disable:this type_body_length Defaults[.size] = 10 Defaults[.sortBy] = .firstCopiedAt Defaults[.pinOrder] = PinOrder() + Defaults[.pinSortBy] = .custom Defaults[.pinTo] = .bottom } @@ -25,6 +27,7 @@ class HistoryTests: XCTestCase { // swiftlint:disable:this type_body_length Defaults[.size] = savedSize Defaults[.sortBy] = savedSortBy Defaults[.pinOrder] = savedPinOrder + Defaults[.pinSortBy] = savedPinSortBy Defaults[.pinTo] = savedPinTo } diff --git a/MaccyTests/SorterTests.swift b/MaccyTests/SorterTests.swift index 7e558083..9ed40aa4 100644 --- a/MaccyTests/SorterTests.swift +++ b/MaccyTests/SorterTests.swift @@ -4,6 +4,8 @@ import Defaults @MainActor class SorterTests: XCTestCase { + let savedPinOrder = Defaults[.pinOrder] + let savedPinSortBy = Defaults[.pinSortBy] let savedPinTo = Defaults[.pinTo] let sorter = Sorter() @@ -16,10 +18,14 @@ class SorterTests: XCTestCase { item1 = historyItem(value: "foo", firstCopiedAt: -300, lastCopiedAt: -100, numberOfCopies: 3) item2 = historyItem(value: "bar", firstCopiedAt: -400, lastCopiedAt: -300, numberOfCopies: 2) item3 = historyItem(value: "bar", firstCopiedAt: -200, lastCopiedAt: -200, numberOfCopies: 1) + Defaults[.pinOrder] = PinOrder() + Defaults[.pinSortBy] = .custom } override func tearDown() { super.tearDown() + Defaults[.pinOrder] = savedPinOrder + Defaults[.pinSortBy] = savedPinSortBy Defaults[.pinTo] = savedPinTo } @@ -35,6 +41,44 @@ class SorterTests: XCTestCase { XCTAssertEqual(sorter.sort([item1, item2, item3], by: .numberOfCopies), [item1, item2, item3]) } + func testSortPinsByLastCopiedAt() { + pinItems() + + XCTAssertEqual(sorter.sortPins([item2, item3, item1], by: .lastCopiedAt), [item1, item3, item2]) + } + + func testSortPinsByFirstCopiedAt() { + pinItems() + + XCTAssertEqual(sorter.sortPins([item2, item3, item1], by: .firstCopiedAt), [item3, item1, item2]) + } + + func testSortPinsByNumberOfCopies() { + pinItems() + + XCTAssertEqual(sorter.sortPins([item2, item3, item1], by: .numberOfCopies), [item1, item2, item3]) + } + + func testSortPinsByPinLetter() { + pinItems() + + XCTAssertEqual(sorter.sortPins([item2, item3, item1], by: .pinLetter), [item1, item3, item2]) + } + + func testSortPinsByCustomOrder() { + pinItems() + Defaults[.pinOrder] = PinOrder(pins: ["c", "a", "b"]) + + XCTAssertEqual(sorter.sortPins([item1, item2, item3], by: .custom), [item2, item1, item3]) + } + + func testCustomPinSortAppendsPinsMissingFromOrder() { + pinItems() + Defaults[.pinOrder] = PinOrder(pins: ["b"]) + + XCTAssertEqual(sorter.sortPins([item3, item2, item1], by: .custom), [item3, item2, item1]) + } + func testSortByPinToTop() { Defaults[.pinTo] = .top @@ -51,6 +95,12 @@ class SorterTests: XCTestCase { XCTAssertEqual(sorter.sort([item1, item2, item3], by: .lastCopiedAt), [item2, item1, item3]) } + private func pinItems() { + item1.pin = "a" + item2.pin = "c" + item3.pin = "b" + } + private func historyItem( value: String, firstCopiedAt: Int, @@ -67,3 +117,65 @@ class SorterTests: XCTestCase { return item } } + +@MainActor +class PinManagerTests: XCTestCase { + let savedPinOrder = Defaults[.pinOrder] + let savedPinSortBy = Defaults[.pinSortBy] + + override func setUp() { + super.setUp() + Defaults[.pinOrder] = PinOrder() + Defaults[.pinSortBy] = .custom + } + + override func tearDown() { + super.tearDown() + Defaults[.pinOrder] = savedPinOrder + Defaults[.pinSortBy] = savedPinSortBy + } + + func testMovingPinsIsDisabledForAutomaticSorting() { + let manager = pinManager() + Defaults[.pinSortBy] = .pinLetter + let originalPins = manager.pinnedItems + let originalOrder = Defaults[.pinOrder] + + manager.move(from: IndexSet(integer: 0), to: 3) + + XCTAssertEqual(manager.pinnedItems, originalPins) + XCTAssertEqual(Defaults[.pinOrder], originalOrder) + } + + func testAutomaticSortingCanBeUsedAsCustomOrder() { + let manager = pinManager() + + manager.adoptSortingForCustomOrder(.pinLetter) + + XCTAssertEqual(Defaults[.pinOrder].pins, ["a", "b", "c"]) + } + + func testAutomaticSortingWouldChangeDifferentCustomOrder() { + let manager = pinManager() + + XCTAssertTrue(manager.sortingWouldChangeCurrentOrder(.pinLetter)) + } + + func testAutomaticSortingWouldNotChangeMatchingCustomOrder() { + let manager = pinManager(pins: ["a", "b", "c"]) + + XCTAssertFalse(manager.sortingWouldChangeCurrentOrder(.pinLetter)) + } + + private func pinManager(pins: [String] = ["c", "a", "b"]) -> PinManager { + let items = pins.map { pin in + let item = HistoryItem() + Storage.shared.context.insert(item) + item.pin = pin + return HistoryItemDecorator(item) + } + let manager = PinManager() + manager.load(from: items) + return manager + } +} From af9002a1c2b1a0bd7b0794b767aaf7ed888d3426 Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:25:07 +0200 Subject: [PATCH 11/59] Remove setting for pin ordering --- Maccy/Extensions/Defaults.Keys+Names.swift | 1 - Maccy/Observables/History.swift | 24 +--- Maccy/Observables/PinManager.swift | 33 +++--- Maccy/Settings/StorageSettingsPane.swift | 65 ----------- .../Settings/en.lproj/StorageSettings.strings | 8 -- Maccy/Sorter.swift | 95 +++------------ Maccy/Views/PinsView.swift | 4 +- MaccyTests/HistoryTests.swift | 3 - MaccyTests/SorterTests.swift | 109 ------------------ 9 files changed, 37 insertions(+), 305 deletions(-) diff --git a/Maccy/Extensions/Defaults.Keys+Names.swift b/Maccy/Extensions/Defaults.Keys+Names.swift index 70a0ba47..6b91d4c4 100644 --- a/Maccy/Extensions/Defaults.Keys+Names.swift +++ b/Maccy/Extensions/Defaults.Keys+Names.swift @@ -54,7 +54,6 @@ nonisolated extension Defaults.Keys { static let numberOfUsages = Key("numberOfUsages", default: 0, suite: preferencesSuite) static let pasteByDefault = Key("pasteByDefault", default: false, suite: preferencesSuite) static let pinOrder = Key("pinOrder", default: PinOrder(), suite: preferencesSuite) - static let pinSortBy = Key("pinSortBy", default: .lastCopiedAt, suite: preferencesSuite) static let pinTo = Key("pinTo", default: .top, suite: preferencesSuite) static let popupPosition = Key("popupPosition", default: .cursor, suite: preferencesSuite) static let popupScreen = Key("popupScreen", default: 0, suite: preferencesSuite) diff --git a/Maccy/Observables/History.swift b/Maccy/Observables/History.swift index 658d72fe..9db5610d 100644 --- a/Maccy/Observables/History.swift +++ b/Maccy/Observables/History.swift @@ -92,12 +92,6 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length } } - Task { - for await _ in Defaults.updates(.pinSortBy, initial: false) { - try? await load() - } - } - Task { for await _ in Defaults.updates(.pinTo, initial: false) { try? await load() @@ -498,26 +492,10 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length @MainActor func movePin(from source: IndexSet, to destination: Int) { - guard searchQuery.isEmpty, Defaults[.pinSortBy] == .custom else { return } + guard searchQuery.isEmpty else { return } pinManager.move(from: source, to: destination) } - @MainActor - func setPinSorting(_ pinSortBy: Sorter.PinBy) { - let previousPinSortBy = Defaults[.pinSortBy] - guard pinSortBy != previousPinSortBy else { return } - - if pinSortBy == .custom { - pinManager.adoptSortingForCustomOrder(previousPinSortBy) - } - Defaults[.pinSortBy] = pinSortBy - } - - @MainActor - func pinSortingWouldChangeCurrentOrder(_ pinSortBy: Sorter.PinBy) -> Bool { - pinManager.sortingWouldChangeCurrentOrder(pinSortBy) - } - @MainActor func recomputeItemsAfterReordering() { guard searchQuery.isEmpty else { return } diff --git a/Maccy/Observables/PinManager.swift b/Maccy/Observables/PinManager.swift index 9cb13616..d0534d41 100644 --- a/Maccy/Observables/PinManager.swift +++ b/Maccy/Observables/PinManager.swift @@ -52,8 +52,6 @@ struct PinOrder: Codable, Equatable, Defaults.Serializable { final class PinManager { private(set) var pinnedItems: [HistoryItemDecorator] = [] - private let sorter = Sorter() - var availablePins: [String] { let assignedPins = Set(pinnedItems.compactMap(\.item.pin)) return HistoryItem.supportedPins.subtracting(assignedPins).sorted() @@ -109,21 +107,10 @@ final class PinManager { } func move(from source: IndexSet, to destination: Int) { - guard Defaults[.pinSortBy] == .custom else { return } pinnedItems.moveElements(from: source, to: destination) Defaults[.pinOrder] = PinOrder(pins: pinnedItems.compactMap(\.item.pin)) } - func adoptSortingForCustomOrder(_ pinSortBy: Sorter.PinBy) { - let sortedPins = sorter.sortPins(pinnedItems, key: \.item, by: pinSortBy) - Defaults[.pinOrder] = PinOrder(pins: sortedPins.compactMap(\.item.pin)) - } - - func sortingWouldChangeCurrentOrder(_ pinSortBy: Sorter.PinBy) -> Bool { - sorter.sortPins(pinnedItems, key: \.item, by: pinSortBy).map(\.id) - != pinnedItems.map(\.id) - } - private func pin(_ item: HistoryItemDecorator) { guard item.isUnpinned, let pin = availablePins.randomElement() else { return @@ -158,7 +145,25 @@ final class PinManager { } private func sortPinnedItems() { - pinnedItems = sorter.sortPins(pinnedItems, key: \.item) + let originalIndexes = Dictionary( + uniqueKeysWithValues: pinnedItems.enumerated().map { + ($0.element.id, $0.offset) + } + ) + let order = Defaults[.pinOrder].pins + + pinnedItems.sort { lhs, rhs in + let lhsIndex = + lhs.item.pin.flatMap { order.firstIndex(of: $0) } ?? Int.max + let rhsIndex = + rhs.item.pin.flatMap { order.firstIndex(of: $0) } ?? Int.max + + if lhsIndex != rhsIndex { + return lhsIndex < rhsIndex + } + + return (originalIndexes[lhs.id] ?? 0) < (originalIndexes[rhs.id] ?? 0) + } } } diff --git a/Maccy/Settings/StorageSettingsPane.swift b/Maccy/Settings/StorageSettingsPane.swift index 9371d5ab..25ee28b3 100644 --- a/Maccy/Settings/StorageSettingsPane.swift +++ b/Maccy/Settings/StorageSettingsPane.swift @@ -58,11 +58,9 @@ struct StorageSettingsPane: View { @Default(.size) private var size @Default(.sortBy) private var sortBy - @Default(.pinSortBy) private var pinSortBy @State private var viewModel = ViewModel() @State private var storageSize = Storage.shared.size - @State private var pendingPinSortBy: Sorter.PinBy? private let sizeFormatter: NumberFormatter = { let formatter = NumberFormatter() @@ -124,70 +122,7 @@ struct StorageSettingsPane: View { .help(Text("SortByTooltip", tableName: "StorageSettings")) .accessibilityLabel(Text("SortBy", tableName: "StorageSettings")) } - - Settings.Section(label: { Text("SortPinsBy", tableName: "StorageSettings") }) { - Picker("", selection: pinSortBySelection) { - ForEach(Sorter.PinBy.allCases) { mode in - Text(mode.description) - } - } - .labelsHidden() - .frame(width: 160, alignment: .leading) - - Text("SortPinsByDescription", tableName: "StorageSettings") - .fixedSize(horizontal: false, vertical: true) - .controlSize(.small) - .foregroundStyle(.gray) - } } - .alert( - Text("PinSortWarningTitle", tableName: "StorageSettings"), - isPresented: isShowingPinSortWarning, - presenting: pendingPinSortBy - ) { pendingPinSortBy in - Button(role: .destructive) { - applyPinSorting(pendingPinSortBy) - } label: { - Text("PinSortWarningConfirm", tableName: "StorageSettings") - } - Button(role: .cancel) { - } label: { - Text("PinSortWarningCancel", tableName: "StorageSettings") - } - } message: { _ in - Text("PinSortWarningMessage", tableName: "StorageSettings") - } - } - - private var pinSortBySelection: Binding { - Binding( - get: { pinSortBy }, - set: { newValue in - if pinSortBy == .custom, - newValue != .custom, - History.shared.pinSortingWouldChangeCurrentOrder(newValue) { - pendingPinSortBy = newValue - } else { - applyPinSorting(newValue) - } - } - ) - } - - private var isShowingPinSortWarning: Binding { - Binding( - get: { pendingPinSortBy != nil }, - set: { isPresented in - if !isPresented { - pendingPinSortBy = nil - } - } - ) - } - - private func applyPinSorting(_ pinSortBy: Sorter.PinBy) { - History.shared.setPinSorting(pinSortBy) - pendingPinSortBy = nil } } diff --git a/Maccy/Settings/en.lproj/StorageSettings.strings b/Maccy/Settings/en.lproj/StorageSettings.strings index b306ae83..571a68d0 100644 --- a/Maccy/Settings/en.lproj/StorageSettings.strings +++ b/Maccy/Settings/en.lproj/StorageSettings.strings @@ -12,11 +12,3 @@ "FirstCopiedAt" = "Time of first copy"; "NumberOfCopies" = "Number of copies"; "SortByTooltip" = "Default: Time of last copy."; -"SortPinsBy" = "Sort pins by:"; -"PinLetter" = "Pin letter"; -"Custom" = "Custom"; -"SortPinsByDescription" = "Pins can be reordered by drag and drop when Custom is selected."; -"PinSortWarningTitle" = "Change pin sorting?"; -"PinSortWarningMessage" = "Your custom pin order will be lost."; -"PinSortWarningConfirm" = "Change Sorting"; -"PinSortWarningCancel" = "Cancel"; diff --git a/Maccy/Sorter.swift b/Maccy/Sorter.swift index e7798b91..cbf2978e 100644 --- a/Maccy/Sorter.swift +++ b/Maccy/Sorter.swift @@ -23,78 +23,33 @@ class Sorter { } } - enum PinBy: String, CaseIterable, Identifiable, CustomStringConvertible, Defaults.Serializable { - case lastCopiedAt - case firstCopiedAt - case numberOfCopies - case pinLetter - case custom - - var id: Self { self } - - var description: String { - if let by = By(rawValue: rawValue) { - return by.description - } - - switch self { - case .pinLetter: - return NSLocalizedString("PinLetter", tableName: "StorageSettings", comment: "") - case .custom: - return NSLocalizedString("Custom", tableName: "StorageSettings", comment: "") - default: - return rawValue - } - } - } - func sort(_ items: [HistoryItem], by: By = Defaults[.sortBy]) -> [HistoryItem] { let sortedUnpinned = items .filter { $0.pin == nil } .sorted(by: { bySortingAlgorithm($0, $1, by) }) let pinnedItems = items.filter { $0.pin != nil } - let sortedPinned = sortPins(pinnedItems, key: \.self) + let pinOrder = Defaults[.pinOrder].pins - switch Defaults[.pinTo] { - case .top: - return sortedPinned + sortedUnpinned - case .bottom: - return sortedUnpinned + sortedPinned + var pinnedByPin: [String: HistoryItem] = [:] + for item in pinnedItems { + if let pin = item.pin { + pinnedByPin[pin] = item + } } - } - - func sortPins( - _ items: [HistoryItem], - by: PinBy = Defaults[.pinSortBy] - ) -> [HistoryItem] { - return sortPins(items, key: \.self, by: by) - } - func sortPins( - _ items: [T], - key: (T) -> HistoryItem, - by: PinBy = Defaults[.pinSortBy] - ) -> [T] { - if let by = By(rawValue: by.rawValue) { - return stableSort(items, key: key) { bySortingAlgorithm($0, $1, by) } + var orderedPinned: [HistoryItem] = pinOrder.compactMap { pinnedByPin[$0] } + let orderedPinSet = Set(pinOrder) + orderedPinned += pinnedItems.filter { + guard let pin = $0.pin else { return false } + return !orderedPinSet.contains(pin) } - switch by { - case .pinLetter: - return stableSort(items, key: key) { ($0.pin ?? "") < ($1.pin ?? "") } - case .custom: - let indexes = Dictionary( - uniqueKeysWithValues: Defaults[.pinOrder].pins.enumerated().map { - ($0.element, $0.offset) - } - ) - return stableSort(items, key: key) { - ($0.pin.flatMap { indexes[$0] } ?? Int.max) - < ($1.pin.flatMap { indexes[$0] } ?? Int.max) - } - default: - return items + switch Defaults[.pinTo] { + case .top: + return orderedPinned + sortedUnpinned + case .bottom: + return sortedUnpinned + orderedPinned } } @@ -109,24 +64,6 @@ class Sorter { } } - private func stableSort( - _ items: [T], - key: (T) -> HistoryItem, - by areInIncreasingOrder: (HistoryItem, HistoryItem) -> Bool - ) -> [T] { - items.enumerated().sorted { lhs, rhs in - let lhsItem = key(lhs.element) - let rhsItem = key(rhs.element) - if areInIncreasingOrder(lhsItem, rhsItem) { - return true - } - if areInIncreasingOrder(rhsItem, lhsItem) { - return false - } - return lhs.offset < rhs.offset - }.map(\.element) - } - } // swiftlint:enable identifier_name // swiftlint:enable type_name diff --git a/Maccy/Views/PinsView.swift b/Maccy/Views/PinsView.swift index 4d613549..9922ba84 100644 --- a/Maccy/Views/PinsView.swift +++ b/Maccy/Views/PinsView.swift @@ -1,4 +1,3 @@ -import Defaults import SwiftUI import UniformTypeIdentifiers @@ -8,13 +7,12 @@ private enum PinReorderDrag { struct PinsView: View { @Environment(AppState.self) private var appState - @Default(.pinSortBy) private var pinSortBy var items: [HistoryItemDecorator] @State private var draggedItems: [HistoryItemDecorator] = [] private var isDragAndDropEnabled: Bool { - appState.history.searchQuery.isEmpty && pinSortBy == .custom + appState.history.searchQuery.isEmpty } var body: some View { diff --git a/MaccyTests/HistoryTests.swift b/MaccyTests/HistoryTests.swift index 9d6ad526..ed047d87 100644 --- a/MaccyTests/HistoryTests.swift +++ b/MaccyTests/HistoryTests.swift @@ -8,7 +8,6 @@ class HistoryTests: XCTestCase { // swiftlint:disable:this type_body_length let savedSize = Defaults[.size] let savedSortBy = Defaults[.sortBy] let savedPinOrder = Defaults[.pinOrder] - let savedPinSortBy = Defaults[.pinSortBy] let savedPinTo = Defaults[.pinTo] let history = History.shared @@ -18,7 +17,6 @@ class HistoryTests: XCTestCase { // swiftlint:disable:this type_body_length Defaults[.size] = 10 Defaults[.sortBy] = .firstCopiedAt Defaults[.pinOrder] = PinOrder() - Defaults[.pinSortBy] = .custom Defaults[.pinTo] = .bottom } @@ -27,7 +25,6 @@ class HistoryTests: XCTestCase { // swiftlint:disable:this type_body_length Defaults[.size] = savedSize Defaults[.sortBy] = savedSortBy Defaults[.pinOrder] = savedPinOrder - Defaults[.pinSortBy] = savedPinSortBy Defaults[.pinTo] = savedPinTo } diff --git a/MaccyTests/SorterTests.swift b/MaccyTests/SorterTests.swift index 9ed40aa4..59b23b34 100644 --- a/MaccyTests/SorterTests.swift +++ b/MaccyTests/SorterTests.swift @@ -5,7 +5,6 @@ import Defaults @MainActor class SorterTests: XCTestCase { let savedPinOrder = Defaults[.pinOrder] - let savedPinSortBy = Defaults[.pinSortBy] let savedPinTo = Defaults[.pinTo] let sorter = Sorter() @@ -19,13 +18,11 @@ class SorterTests: XCTestCase { item2 = historyItem(value: "bar", firstCopiedAt: -400, lastCopiedAt: -300, numberOfCopies: 2) item3 = historyItem(value: "bar", firstCopiedAt: -200, lastCopiedAt: -200, numberOfCopies: 1) Defaults[.pinOrder] = PinOrder() - Defaults[.pinSortBy] = .custom } override func tearDown() { super.tearDown() Defaults[.pinOrder] = savedPinOrder - Defaults[.pinSortBy] = savedPinSortBy Defaults[.pinTo] = savedPinTo } @@ -41,44 +38,6 @@ class SorterTests: XCTestCase { XCTAssertEqual(sorter.sort([item1, item2, item3], by: .numberOfCopies), [item1, item2, item3]) } - func testSortPinsByLastCopiedAt() { - pinItems() - - XCTAssertEqual(sorter.sortPins([item2, item3, item1], by: .lastCopiedAt), [item1, item3, item2]) - } - - func testSortPinsByFirstCopiedAt() { - pinItems() - - XCTAssertEqual(sorter.sortPins([item2, item3, item1], by: .firstCopiedAt), [item3, item1, item2]) - } - - func testSortPinsByNumberOfCopies() { - pinItems() - - XCTAssertEqual(sorter.sortPins([item2, item3, item1], by: .numberOfCopies), [item1, item2, item3]) - } - - func testSortPinsByPinLetter() { - pinItems() - - XCTAssertEqual(sorter.sortPins([item2, item3, item1], by: .pinLetter), [item1, item3, item2]) - } - - func testSortPinsByCustomOrder() { - pinItems() - Defaults[.pinOrder] = PinOrder(pins: ["c", "a", "b"]) - - XCTAssertEqual(sorter.sortPins([item1, item2, item3], by: .custom), [item2, item1, item3]) - } - - func testCustomPinSortAppendsPinsMissingFromOrder() { - pinItems() - Defaults[.pinOrder] = PinOrder(pins: ["b"]) - - XCTAssertEqual(sorter.sortPins([item3, item2, item1], by: .custom), [item3, item2, item1]) - } - func testSortByPinToTop() { Defaults[.pinTo] = .top @@ -95,12 +54,6 @@ class SorterTests: XCTestCase { XCTAssertEqual(sorter.sort([item1, item2, item3], by: .lastCopiedAt), [item2, item1, item3]) } - private func pinItems() { - item1.pin = "a" - item2.pin = "c" - item3.pin = "b" - } - private func historyItem( value: String, firstCopiedAt: Int, @@ -117,65 +70,3 @@ class SorterTests: XCTestCase { return item } } - -@MainActor -class PinManagerTests: XCTestCase { - let savedPinOrder = Defaults[.pinOrder] - let savedPinSortBy = Defaults[.pinSortBy] - - override func setUp() { - super.setUp() - Defaults[.pinOrder] = PinOrder() - Defaults[.pinSortBy] = .custom - } - - override func tearDown() { - super.tearDown() - Defaults[.pinOrder] = savedPinOrder - Defaults[.pinSortBy] = savedPinSortBy - } - - func testMovingPinsIsDisabledForAutomaticSorting() { - let manager = pinManager() - Defaults[.pinSortBy] = .pinLetter - let originalPins = manager.pinnedItems - let originalOrder = Defaults[.pinOrder] - - manager.move(from: IndexSet(integer: 0), to: 3) - - XCTAssertEqual(manager.pinnedItems, originalPins) - XCTAssertEqual(Defaults[.pinOrder], originalOrder) - } - - func testAutomaticSortingCanBeUsedAsCustomOrder() { - let manager = pinManager() - - manager.adoptSortingForCustomOrder(.pinLetter) - - XCTAssertEqual(Defaults[.pinOrder].pins, ["a", "b", "c"]) - } - - func testAutomaticSortingWouldChangeDifferentCustomOrder() { - let manager = pinManager() - - XCTAssertTrue(manager.sortingWouldChangeCurrentOrder(.pinLetter)) - } - - func testAutomaticSortingWouldNotChangeMatchingCustomOrder() { - let manager = pinManager(pins: ["a", "b", "c"]) - - XCTAssertFalse(manager.sortingWouldChangeCurrentOrder(.pinLetter)) - } - - private func pinManager(pins: [String] = ["c", "a", "b"]) -> PinManager { - let items = pins.map { pin in - let item = HistoryItem() - Storage.shared.context.insert(item) - item.pin = pin - return HistoryItemDecorator(item) - } - let manager = PinManager() - manager.load(from: items) - return manager - } -} From 506869eca5f6146c11dce73cf1380342d9d89d58 Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Tue, 11 Aug 2026 15:25:54 +0200 Subject: [PATCH 12/59] Extract moveElements into extensions folder --- Maccy.xcodeproj/project.pbxproj | 4 ++++ Maccy/Extensions/Array+MoveElements.swift | 26 +++++++++++++++++++++++ Maccy/Observables/PinManager.swift | 24 --------------------- 3 files changed, 30 insertions(+), 24 deletions(-) create mode 100644 Maccy/Extensions/Array+MoveElements.swift diff --git a/Maccy.xcodeproj/project.pbxproj b/Maccy.xcodeproj/project.pbxproj index 3f334725..d258e8ab 100644 --- a/Maccy.xcodeproj/project.pbxproj +++ b/Maccy.xcodeproj/project.pbxproj @@ -29,6 +29,7 @@ 2F578C3F2EFFE8720088B759 /* SlideoutController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F578C3E2EFFE8720088B759 /* SlideoutController.swift */; }; 2F578C422EFFE8920088B759 /* SlideoutContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F578C402EFFE8920088B759 /* SlideoutContentView.swift */; }; 2F578C432EFFE8920088B759 /* SlideoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F578C412EFFE8920088B759 /* SlideoutView.swift */; }; + 2F59CC4C302B580300136053 /* Array+MoveElements.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F59CC4B302B57FC00136053 /* Array+MoveElements.swift */; }; 2F8B9DE62C5D6E5D0046EF69 /* NSPoint+DefaultsSerializable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F8B9DE52C5D6E5D0046EF69 /* NSPoint+DefaultsSerializable.swift */; }; 2F9B42EF2F1A494C006548E5 /* ListHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F9B42EE2F1A4948006548E5 /* ListHeaderView.swift */; }; 2F9B42F12F1A499D006548E5 /* ToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F9B42F02F1A4999006548E5 /* ToolbarView.swift */; }; @@ -215,6 +216,7 @@ 2F578C3E2EFFE8720088B759 /* SlideoutController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlideoutController.swift; sourceTree = ""; }; 2F578C402EFFE8920088B759 /* SlideoutContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlideoutContentView.swift; sourceTree = ""; }; 2F578C412EFFE8920088B759 /* SlideoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlideoutView.swift; sourceTree = ""; }; + 2F59CC4B302B57FC00136053 /* Array+MoveElements.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+MoveElements.swift"; sourceTree = ""; }; 2F8B9DE52C5D6E5D0046EF69 /* NSPoint+DefaultsSerializable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSPoint+DefaultsSerializable.swift"; sourceTree = ""; }; 2F9B42EE2F1A4948006548E5 /* ListHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListHeaderView.swift; sourceTree = ""; }; 2F9B42F02F1A4999006548E5 /* ToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarView.swift; sourceTree = ""; }; @@ -636,6 +638,7 @@ 2FBB7A252B5FECBB00C65BBE /* Extensions */ = { isa = PBXGroup; children = ( + 2F59CC4B302B57FC00136053 /* Array+MoveElements.swift */, 2FA81FDF2E43D9A600C12F92 /* Dictionary+RemoveItem.swift */, DA19691B2C3F3EAC00258481 /* Collection+Surrounding.swift */, DAA072D22C40A961006DDFD2 /* Color+Random.swift */, @@ -1241,6 +1244,7 @@ DA3BCB8E2C3E015000B01BC1 /* ModifierFlags.swift in Sources */, DA181177247D14DA00066D55 /* Settings.PaneIdentifier+Panes.swift in Sources */, DA3BCB942C3EECBB00B01BC1 /* HistoryItemDecorator.swift in Sources */, + 2F59CC4C302B580300136053 /* Array+MoveElements.swift in Sources */, DAE28500232257D20080E394 /* ColorImage.swift in Sources */, DA7A753E26A52F0F00DC16EF /* NSImage+Names.swift in Sources */, 2F0EC7852E774083003E2EA9 /* PasteStackView.swift in Sources */, diff --git a/Maccy/Extensions/Array+MoveElements.swift b/Maccy/Extensions/Array+MoveElements.swift new file mode 100644 index 00000000..b23e5422 --- /dev/null +++ b/Maccy/Extensions/Array+MoveElements.swift @@ -0,0 +1,26 @@ +import Foundation + +extension Array { + mutating func moveElements( + from source: IndexSet, + to destination: Int + ) { + let movingIndexes = source.sorted() + let movingElements = movingIndexes.compactMap { + indices.contains($0) ? self[$0] : nil + } + guard !movingElements.isEmpty else { return } + + for index in movingIndexes.reversed() where indices.contains(index) { + remove(at: index) + } + + let removedBeforeDestination = movingIndexes.filter { $0 < destination } + .count + let insertionIndex = Swift.max( + 0, + Swift.min(destination - removedBeforeDestination, count) + ) + insert(contentsOf: movingElements, at: insertionIndex) + } +} diff --git a/Maccy/Observables/PinManager.swift b/Maccy/Observables/PinManager.swift index d0534d41..5955a28b 100644 --- a/Maccy/Observables/PinManager.swift +++ b/Maccy/Observables/PinManager.swift @@ -167,27 +167,3 @@ final class PinManager { } } -extension Array where Element == HistoryItemDecorator { - fileprivate mutating func moveElements( - from source: IndexSet, - to destination: Int - ) { - let movingIndexes = source.sorted() - let movingElements = movingIndexes.compactMap { - indices.contains($0) ? self[$0] : nil - } - guard !movingElements.isEmpty else { return } - - for index in movingIndexes.reversed() where indices.contains(index) { - remove(at: index) - } - - let removedBeforeDestination = movingIndexes.filter { $0 < destination } - .count - let insertionIndex = Swift.max( - 0, - Swift.min(destination - removedBeforeDestination, count) - ) - insert(contentsOf: movingElements, at: insertionIndex) - } -} From 3d1767283e1421a2cd81827be7f5e7b45971aa90 Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:46:32 +0200 Subject: [PATCH 13/59] Use History.togglePin in testClearingAll --- MaccyTests/HistoryTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/MaccyTests/HistoryTests.swift b/MaccyTests/HistoryTests.swift index ed047d87..8ef55bec 100644 --- a/MaccyTests/HistoryTests.swift +++ b/MaccyTests/HistoryTests.swift @@ -225,7 +225,7 @@ class HistoryTests: XCTestCase { // swiftlint:disable:this type_body_length func testClearingAll() throws { history.add(historyItem("foo")) let pinned = history.add(historyItem("bar")) - pinned.togglePin() + history.togglePin(pinned) Storage.shared.context.insert(HistoryItemContent( type: NSPasteboard.PasteboardType.string.rawValue, value: "orphan".data(using: .utf8) From a0fb0d1281e7eff2445d6e4d1843deb2ec49ccb0 Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:48:59 +0200 Subject: [PATCH 14/59] Return focus to search field after editing item --- Maccy/Views/ContentView.swift | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/Maccy/Views/ContentView.swift b/Maccy/Views/ContentView.swift index 766483f6..9a6f0541 100644 --- a/Maccy/Views/ContentView.swift +++ b/Maccy/Views/ContentView.swift @@ -60,6 +60,11 @@ struct ContentView: View { .environment(appState) .environment(modifierFlags) .environment(\.scenePhase, scenePhase) + .onChange(of: appState.isEditingItem) { _, isEditingItem in + // A sheet's text controls otherwise remain the logical focus target after + // dismissal, preventing the popup's key handler from receiving shortcuts. + searchFocused = !isEditingItem + } // FloatingPanel is not a scene, so let's implement custom scenePhase.. .onReceive(NotificationCenter.default.publisher(for: NSWindow.didBecomeKeyNotification)) { guard !appState.isEditingItem else { return } From 80a7afff15695fb4a0521b2dbe7575b3a83d82fc Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:12:22 +0200 Subject: [PATCH 15/59] Fix drag and drop preview appearing behind popup --- Maccy/FloatingPanel.swift | 44 ++++++++++++++++++++++++++++++++------- 1 file changed, 37 insertions(+), 7 deletions(-) diff --git a/Maccy/FloatingPanel.swift b/Maccy/FloatingPanel.swift index f9ba1edc..99dca639 100644 --- a/Maccy/FloatingPanel.swift +++ b/Maccy/FloatingPanel.swift @@ -54,13 +54,17 @@ class FloatingPanel: NSPanel, NSWindowDelegate { standardWindowButton(.zoomButton)?.isHidden = true contentView = NSHostingView( - rootView: view() - // The safe area is ignored because the title bar still interferes with the geometry - .ignoresSafeArea() - .gesture(DragGesture() - .onEnded { _ in - self.saveWindowPosition() - }) + rootView: FloatingPanelRootView( + content: view(), + onWindowDragEnded: { [weak self] in + self?.saveWindowPosition() + }, + onDragAndDropStateChange: { [weak self] isDragAndDropInProgress in + // At screenSaver level the drag preview is behind the popup, which prevents + // the drag-and-drop lifecycle from being triggered. + self?.level = isDragAndDropInProgress ? .popUpMenu : .screenSaver + } + ) ) applyRoundedCorners() } @@ -234,3 +238,29 @@ class FloatingPanel: NSPanel, NSWindowDelegate { return true } } + +private struct FloatingPanelRootView: View { + @State private var appState = AppState.shared + + let content: Content + let onWindowDragEnded: () -> Void + let onDragAndDropStateChange: (Bool) -> Void + + var body: some View { + content + // The safe area is ignored because the title bar still interferes with the geometry + .ignoresSafeArea() + .gesture( + DragGesture() + .onEnded { _ in + onWindowDragEnded() + } + ) + .onChange( + of: appState.navigator.isDragAndDropInProgress, + initial: true + ) { _, isDragAndDropInProgress in + onDragAndDropStateChange(isDragAndDropInProgress) + } + } +} From a6816d2614c73de994b26f9a1de18b8157e9626e Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:20:08 +0200 Subject: [PATCH 16/59] Make sure moving pins updates the history view --- Maccy/Observables/History.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Maccy/Observables/History.swift b/Maccy/Observables/History.swift index 9db5610d..35dbfdd6 100644 --- a/Maccy/Observables/History.swift +++ b/Maccy/Observables/History.swift @@ -494,6 +494,7 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length func movePin(from source: IndexSet, to destination: Int) { guard searchQuery.isEmpty else { return } pinManager.move(from: source, to: destination) + recomputeItemsAfterReordering() } @MainActor From c94a133d4396cfa4d2c5a10bca732511964e4441 Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:20:54 +0200 Subject: [PATCH 17/59] Make drang and drop behave more intuitively --- Maccy.xcodeproj/project.pbxproj | 2 +- Maccy/Views/LegacyReorderableContainer.swift | 23 ++++++-- Maccy/Views/ReorderableContainer.swift | 61 +++++++++++++++----- 3 files changed, 65 insertions(+), 21 deletions(-) diff --git a/Maccy.xcodeproj/project.pbxproj b/Maccy.xcodeproj/project.pbxproj index d258e8ab..a92d3a24 100644 --- a/Maccy.xcodeproj/project.pbxproj +++ b/Maccy.xcodeproj/project.pbxproj @@ -8,8 +8,8 @@ /* Begin PBXBuildFile section */ 0ABDD5122BB47F1E0054963B /* NSWorkspace+ApplicationName.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ABDD5112BB47F1E0054963B /* NSWorkspace+ApplicationName.swift */; }; - 2F0E055F2FFD2EB300CCD1DE /* ReorderableContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F0E055E2FFD2EAF00CCD1DE /* ReorderableContainer.swift */; }; 21FFF308D9217B43FD4C393F /* VoiceOver.swift in Sources */ = {isa = PBXBuildFile; fileRef = B21DCA6C3D17E48D33D6E608 /* VoiceOver.swift */; }; + 2F0E055F2FFD2EB300CCD1DE /* ReorderableContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F0E055E2FFD2EAF00CCD1DE /* ReorderableContainer.swift */; }; 2F0EC7792E7727E3003E2EA9 /* HeightReaderModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F0EC7782E7727E3003E2EA9 /* HeightReaderModifier.swift */; }; 2F0EC77B2E772899003E2EA9 /* PinsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F0EC77A2E772899003E2EA9 /* PinsView.swift */; }; 2F0EC77D2E773314003E2EA9 /* Selection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F0EC77C2E773314003E2EA9 /* Selection.swift */; }; diff --git a/Maccy/Views/LegacyReorderableContainer.swift b/Maccy/Views/LegacyReorderableContainer.swift index 88b8dbe6..cc8c31da 100644 --- a/Maccy/Views/LegacyReorderableContainer.swift +++ b/Maccy/Views/LegacyReorderableContainer.swift @@ -6,6 +6,7 @@ internal struct LegacyDraggableItemModifier: ViewModifier { @Environment(AppState.self) private var appState @Environment(ModifierFlags.self) private var modifierFlags @Environment(\.reorderableDragContext) private var dragContext + @State private var dropTargetSize: CGSize = .zero let item: Item @@ -14,6 +15,11 @@ internal struct LegacyDraggableItemModifier: ViewModifier { if let dragContext { content .opacity(dragContext.isDragged(item) ? 0.5 : 1) + .onGeometryChange(for: CGSize.self) { proxy in + proxy.size + } action: { size in + dropTargetSize = size + } .background( LegacyDragSourceView( item: item, @@ -26,7 +32,8 @@ internal struct LegacyDraggableItemModifier: ViewModifier { of: [dragContext.contentType], delegate: LegacyDraggableItemDropDelegate( item: item, - dragContext: dragContext + dragContext: dragContext, + targetSize: dropTargetSize ) ) } else { @@ -35,27 +42,33 @@ internal struct LegacyDraggableItemModifier: ViewModifier { } } -private struct LegacyDraggableItemDropDelegate: DropDelegate -{ +private struct LegacyDraggableItemDropDelegate: DropDelegate { let item: Item let dragContext: ReorderableDragContext + let targetSize: CGSize func validateDrop(info: DropInfo) -> Bool { info.hasItemsConforming(to: [dragContext.contentType]) } func dropEntered(info: DropInfo) { - dragContext.move(item) + move(info) } func dropUpdated(info: DropInfo) -> DropProposal? { - DropProposal(operation: .move) + move(info) + return DropProposal(operation: .move) } func performDrop(info: DropInfo) -> Bool { dragContext.resetDragState(true) return true } + + private func move(_ info: DropInfo) { + guard targetSize.height > 0 else { return } + dragContext.move(item, dropEdge(at: info.location, in: targetSize)) + } } private struct LegacyDragSourceView: NSViewRepresentable { diff --git a/Maccy/Views/ReorderableContainer.swift b/Maccy/Views/ReorderableContainer.swift index bc12ab44..8c2c174c 100644 --- a/Maccy/Views/ReorderableContainer.swift +++ b/Maccy/Views/ReorderableContainer.swift @@ -3,9 +3,7 @@ import UniformTypeIdentifiers typealias Reorderable = Identifiable & Equatable -private struct DraggableContainerModifier: - ViewModifier -{ +private struct DraggableContainerModifier: ViewModifier { let items: [Item] let contentType: UTType let dragPreview: ([Item]) -> DragPreview @@ -76,16 +74,22 @@ private struct DraggableContainerModifier: : [item] } - private func move(to item: Any) { + private func move(to item: Any, at edge: ReorderableDropEdge) { guard let item = item as? Item else { return } guard !draggedItems.contains(item), let current = active else { return } - guard let from = items.firstIndex(of: current) else { return } - guard let to = items.firstIndex(of: item) else { return } + guard let currentIndex = items.firstIndex(of: current) else { return } + guard let targetIndex = items.firstIndex(of: item) else { return } let source = sourceIndexes guard !source.isEmpty else { return } + guard let destinationIndex = reorderDestination( + currentIndex: currentIndex, + targetIndex: targetIndex, + edge: edge + ) else { return } + hasChangedLocation = true - moveAction(source, to > from ? to + 1 : to) + moveAction(source, destinationIndex) } private var sourceIndexes: IndexSet { @@ -109,7 +113,8 @@ private struct DraggableItemModifier: ViewModifier { content .opacity(dragContext.isDragged(item) ? 0.5 : 1) .onDrag { - itemProvider(for: item, contentType: dragContext.contentType) + dragContext.startDrag(item) + return itemProvider(for: item, contentType: dragContext.contentType) } preview: { dragContext.dragPreview(item) } @@ -121,9 +126,7 @@ private struct DraggableItemModifier: ViewModifier { ) .onDragSessionUpdated { session in switch session.phase { - case .initial: - dragContext.startDrag(item) - case .ended(_): + case .ended: dragContext.resetDragState(true) default: break @@ -135,9 +138,12 @@ private struct DraggableItemModifier: ViewModifier { } .onDropSessionUpdated { session in switch session.phase { - case .entering: - dragContext.move(item) - case .ended(_): + case .entering, .active: + dragContext.move( + item, + dropEdge(at: session.location, in: session.size) + ) + case .ended: dragContext.resetDragState(true) default: break @@ -171,7 +177,32 @@ struct ReorderableDragContext { let dragPreview: (Any) -> AnyView let startDrag: (Any) -> Void let resetDragState: (Bool) -> Void - let move: (Any) -> Void + let move: (Any, ReorderableDropEdge) -> Void +} + +enum ReorderableDropEdge { + case before + case after +} + +func dropEdge(at location: CGPoint, in targetSize: CGSize) -> ReorderableDropEdge { + location.y < targetSize.height / 2 ? .before : .after +} + +func reorderDestination( + currentIndex: Int, + targetIndex: Int, + edge: ReorderableDropEdge +) -> Int? { + if currentIndex < targetIndex { + // Move destinations use indexes from before the source is removed, so a + // downward move needs to insert after the target. + return edge == .after ? targetIndex + 1 : nil + } + if currentIndex > targetIndex { + return edge == .before ? targetIndex : nil + } + return nil } private struct ReorderableDragContextKey: EnvironmentKey { From b16123163e04f7e5c26174b0a7bd3afbfb057d6f Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:16:53 +0000 Subject: [PATCH 18/59] Separate pinned and unpinned history storage --- Maccy/ItemsProtocol.swift | 2 +- Maccy/Models/HistoryItem.swift | 5 +- Maccy/Observables/History.swift | 177 ++++++++++--------- Maccy/Observables/PinManager.swift | 17 +- Maccy/Views/HistoryListView.swift | 4 +- Maccy/Views/LegacyReorderableContainer.swift | 4 +- Maccy/Views/PinsView.swift | 3 - Maccy/Views/ReorderableContainer.swift | 23 +-- MaccyTests/HistoryTests.swift | 10 +- 9 files changed, 129 insertions(+), 116 deletions(-) diff --git a/Maccy/ItemsProtocol.swift b/Maccy/ItemsProtocol.swift index fe77111d..6b912a27 100644 --- a/Maccy/ItemsProtocol.swift +++ b/Maccy/ItemsProtocol.swift @@ -5,7 +5,7 @@ protocol HasVisibility { protocol ItemsContainer { associatedtype Item var containerVisible: Bool { get } - var items: [Item] { get set } + var items: [Item] { get } } extension ItemsContainer { diff --git a/Maccy/Models/HistoryItem.swift b/Maccy/Models/HistoryItem.swift index 64181ec6..1768acb6 100644 --- a/Maccy/Models/HistoryItem.swift +++ b/Maccy/Models/HistoryItem.swift @@ -37,10 +37,7 @@ class HistoryItem { @MainActor static var availablePins: [String] { - availablePins(in: History.shared.all.compactMap { - if $0.isPinned { return $0.item } - return nil - }) + History.shared.availablePins } @MainActor diff --git a/Maccy/Observables/History.swift b/Maccy/Observables/History.swift index 35dbfdd6..a1d1e795 100644 --- a/Maccy/Observables/History.swift +++ b/Maccy/Observables/History.swift @@ -13,21 +13,32 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length static let shared = History() let logger = Logger(label: "org.p0deje.Maccy") - var items: [HistoryItemDecorator] = [] var pasteStack: PasteStack? - var pinnedItems: [HistoryItemDecorator] { items.filter(\.isPinned) } - var unpinnedItems: [HistoryItemDecorator] { items.filter(\.isUnpinned) } + var items: [HistoryItemDecorator] { + switch Defaults[.pinTo] { + case .top: + pinnedItems + unpinnedItems + case .bottom: + unpinnedItems + pinnedItems + } + } + var pinnedItems: [HistoryItemDecorator] { + searchQuery.isEmpty ? allPinnedItems : filteredPinnedItems + } + var unpinnedItems: [HistoryItemDecorator] { + searchQuery.isEmpty ? allUnpinnedItems : filteredUnpinnedItems + } var availablePins: [String] { pinManager.availablePins } - var firstPinnedItem: HistoryItemDecorator? { items.first(where: \.isPinned) } - var firstUnpinnedItem: HistoryItemDecorator? { items.first(where: \.isUnpinned) } + var firstPinnedItem: HistoryItemDecorator? { pinnedItems.first } + var firstUnpinnedItem: HistoryItemDecorator? { unpinnedItems.first } var searchQuery: String = "" { didSet(previousSearchQuery) { guard searchQuery != previousSearchQuery else { return } throttler.throttle { [self] in - updateItems(search.search(string: searchQuery, within: all)) + updateSearchResults() if searchQuery.isEmpty { AppState.shared.navigator.select(item: firstUnpinnedItem) @@ -61,16 +72,14 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length private let sorter = Sorter() private let throttler = Throttler(minimumDelay: 0.2) private let pinManager = PinManager() + private var allPinnedItems: [HistoryItemDecorator] { pinManager.pinnedItems } + private var allUnpinnedItems: [HistoryItemDecorator] = [] + private var filteredPinnedItems: [HistoryItemDecorator] = [] + private var filteredUnpinnedItems: [HistoryItemDecorator] = [] @ObservationIgnored private var sessionLog: [Int: HistoryItem] = [:] - // The distinction between `all` and `items` is the following: - // - `all` stores all history items, even the ones that are currently hidden by a search - // - `items` stores only visible history items, updated during a search - @ObservationIgnored - var all: [HistoryItemDecorator] = [] - #if DEBUG static let isRunningUnitTests = ProcessInfo.processInfo.environment["XCTestConfigurationFilePath"] != nil #endif @@ -119,9 +128,11 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length func load() async throws { let descriptor = FetchDescriptor() let results = try Storage.shared.context.fetch(descriptor) - all = sorter.sort(results).map { HistoryItemDecorator($0) } - pinManager.load(from: all) - items = all + let decorators = sorter.sort(results).map { HistoryItemDecorator($0) } + pinManager.load(from: decorators) + allUnpinnedItems = decorators.filter(\.isUnpinned) + filteredPinnedItems = allPinnedItems + filteredUnpinnedItems = allUnpinnedItems limitHistorySize(to: Defaults[.size]) @@ -134,9 +145,8 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length @MainActor private func limitHistorySize(to maxSize: Int) { - let unpinned = all.filter(\.isUnpinned) - if unpinned.count >= maxSize { - unpinned[maxSize...].forEach(delete) + if allUnpinnedItems.count >= maxSize { + allUnpinnedItems[maxSize...].forEach(delete) } } @@ -158,7 +168,7 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length // It was already inserted after creation in Clipboard.swift } - var removedItemIndex: Int? + var replacedPinnedItem: HistoryItemDecorator? if let existingHistoryItem = findSimilarItem(item) { if isModified(item) == nil { transferContents(from: existingHistoryItem, to: item) @@ -171,14 +181,14 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length item.application = existingHistoryItem.application } logger.info("Removing duplicate item '\(item.title)'") - removedItemIndex = all.firstIndex(where: { $0.item == existingHistoryItem }) - if let removedItemIndex { - cleanup(all[removedItemIndex]) + if let existingDecorator = firstStoredItem(where: { $0.item == existingHistoryItem }) { + cleanup(existingDecorator) + allUnpinnedItems.removeAll { $0 == existingDecorator } + if existingDecorator.isPinned { + replacedPinnedItem = existingDecorator + } } deleteFromStorage(existingHistoryItem) - if let removedItemIndex { - all.remove(at: removedItemIndex) - } } else { Task { Notifier.notify(body: item.title, sound: .write) @@ -187,33 +197,29 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length // Remove exceeding items. Do this after the item is added to avoid removing something // if a duplicate was found as then the size already stayed the same. - limitHistorySize(to: Defaults[.size] - 1) + limitHistorySize(to: item.pin == nil ? Defaults[.size] - 1 : Defaults[.size]) sessionLog[Clipboard.shared.changeCount] = item - var itemDecorator: HistoryItemDecorator + let itemDecorator: HistoryItemDecorator if let pin = item.pin { itemDecorator = HistoryItemDecorator(item, shortcuts: KeyShortcut.create(character: pin)) - if let removedItemIndex { - // If pin to bottom -> last element should be inserted to the removedItemIndex - 1 - // Or to the last all array place. - all.insert(itemDecorator, at: min(removedItemIndex, all.count)) + if let replacedPinnedItem { + pinManager.replace(replacedPinnedItem, with: itemDecorator) + } else { + pinManager.add(itemDecorator) } - pinManager.load(from: all) - items = all } else { itemDecorator = HistoryItemDecorator(item) + insertUnpinned(itemDecorator) + } - let sortedItems = sorter.sort(all.map(\.item) + [item]) - if let index = sortedItems.firstIndex(of: item) { - all.insert(itemDecorator, at: index) - } - - items = all + if searchQuery.isEmpty { updateUnpinnedShortcuts() - AppState.shared.popup.needsResize = true + } else { + updateSearchResults() } - + AppState.shared.popup.needsResize = true return itemDecorator } @@ -233,15 +239,10 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length @MainActor func clear() { withLogging("Clearing history") { - all.forEach { item in - if item.isUnpinned { - cleanup(item) - } - } - all.removeAll(where: \.isUnpinned) + allUnpinnedItems.forEach(cleanup) + allUnpinnedItems.removeAll() + filteredUnpinnedItems.removeAll() sessionLog.removeValues { $0.pin == nil } - pinManager.load(from: all) - items = all try? Storage.shared.context.transaction { try? Storage.shared.context.delete( @@ -267,13 +268,13 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length @MainActor func clearAll() { withLogging("Clearing all history") { - all.forEach { item in - cleanup(item) - } - all.removeAll() + allPinnedItems.forEach(cleanup) + allUnpinnedItems.forEach(cleanup) + allUnpinnedItems.removeAll() + filteredPinnedItems.removeAll() + filteredUnpinnedItems.removeAll() pinManager.removeAll() sessionLog.removeAll() - items = all do { let context = Storage.shared.context @@ -312,8 +313,9 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length } pinManager.remove(item) - all.removeAll { $0 == item } - items.removeAll { $0 == item } + allUnpinnedItems.removeAll { $0 == item } + filteredPinnedItems.removeAll { $0 == item } + filteredUnpinnedItems.removeAll { $0 == item } sessionLog.removeValues { $0 == item.item } updateUnpinnedShortcuts() @@ -474,15 +476,12 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length pinManager.toggle(item) guard item.isPinned != wasPinned else { return } - let sortedItems = sorter.sort(all.map(\.item)) - if let currentIndex = all.firstIndex(of: item), - let newIndex = sortedItems.firstIndex(of: item.item) { - all.remove(at: currentIndex) - all.insert(item, at: newIndex) + if wasPinned { + insertUnpinned(item) + } else { + allUnpinnedItems.removeAll { $0 == item } } - items = all - searchQuery = "" updateUnpinnedShortcuts() if item.isUnpinned { @@ -494,30 +493,23 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length func movePin(from source: IndexSet, to destination: Int) { guard searchQuery.isEmpty else { return } pinManager.move(from: source, to: destination) - recomputeItemsAfterReordering() - } - - @MainActor - func recomputeItemsAfterReordering() { - guard searchQuery.isEmpty else { return } - let sortedItems = sorter.sort(all.map(\.item)) - let decoratorsByItem = Dictionary(uniqueKeysWithValues: all.map { (ObjectIdentifier($0.item), $0) }) - - all = sortedItems.compactMap { decoratorsByItem[ObjectIdentifier($0)] } - items = all } @MainActor func updatePin(_ item: HistoryItem, to pin: String) { - guard let itemDecorator = all.first(where: { $0.item.id == item.id }) else { return } + guard let itemDecorator = firstStoredItem(where: { $0.item.id == item.id }) else { return } + let wasPinned = itemDecorator.isPinned pinManager.updatePin(of: itemDecorator, to: pin) + if !wasPinned && itemDecorator.isPinned { + allUnpinnedItems.removeAll { $0 == itemDecorator } + } updateShortcuts() } @MainActor private func findSimilarItem(_ item: HistoryItem) -> HistoryItem? { - if let duplicate = all.first(where: { $0.item != item && $0.item.supersedes(item) }) { + if let duplicate = firstStoredItem(where: { $0.item != item && $0.item.supersedes(item) }) { return duplicate.item } @@ -532,15 +524,36 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length return nil } - private func updateItems(_ newItems: [Search.SearchResult]) { - items = newItems.map { result in + private func updateSearchResults() { + filteredPinnedItems = filteredItems( + from: search.search(string: searchQuery, within: allPinnedItems) + ) + filteredUnpinnedItems = filteredItems( + from: search.search(string: searchQuery, within: allUnpinnedItems) + ) + + updateUnpinnedShortcuts() + } + + private func filteredItems(from results: [Search.SearchResult]) -> [HistoryItemDecorator] { + results.map { result in let item = result.object item.highlight(searchQuery, result.ranges) return item } + } - updateUnpinnedShortcuts() + private func firstStoredItem( + where predicate: (HistoryItemDecorator) -> Bool + ) -> HistoryItemDecorator? { + allPinnedItems.first(where: predicate) ?? allUnpinnedItems.first(where: predicate) + } + + private func insertUnpinned(_ item: HistoryItemDecorator) { + let sortedItems = sorter.sort(allUnpinnedItems.map(\.item) + [item.item]) + guard let index = sortedItems.firstIndex(of: item.item) else { return } + allUnpinnedItems.insert(item, at: index) } private func updateShortcuts() { @@ -560,13 +573,13 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length } private func updateUnpinnedShortcuts() { - let visibleUnpinnedItems = unpinnedItems.filter(\.isVisible) - for item in visibleUnpinnedItems { + let shortcutItems = unpinnedItems.filter(\.isVisible) + for item in shortcutItems { item.shortcuts = [] } var index = 1 - for item in visibleUnpinnedItems.prefix(9) { + for item in shortcutItems.prefix(9) { item.shortcuts = KeyShortcut.create(character: String(index)) index += 1 } diff --git a/Maccy/Observables/PinManager.swift b/Maccy/Observables/PinManager.swift index 5955a28b..bfa21f07 100644 --- a/Maccy/Observables/PinManager.swift +++ b/Maccy/Observables/PinManager.swift @@ -71,6 +71,22 @@ final class PinManager { } } + func add(_ item: HistoryItemDecorator) { + guard item.isPinned, !pinnedItems.contains(item) else { return } + pinnedItems.append(item) + reconcileOrder() + sortPinnedItems() + } + + func replace(_ oldItem: HistoryItemDecorator, with newItem: HistoryItemDecorator) { + guard newItem.isPinned else { return } + guard let index = pinnedItems.firstIndex(of: oldItem) else { + add(newItem) + return + } + pinnedItems[index] = newItem + } + func updatePin(of item: HistoryItemDecorator, to pin: String) { guard HistoryItem.supportedPins.contains(pin) else { return } guard item.item.pin != pin else { return } @@ -166,4 +182,3 @@ final class PinManager { } } } - diff --git a/Maccy/Views/HistoryListView.swift b/Maccy/Views/HistoryListView.swift index a5ecfee8..5e04025a 100644 --- a/Maccy/Views/HistoryListView.swift +++ b/Maccy/Views/HistoryListView.swift @@ -14,10 +14,10 @@ struct HistoryListView: View { @Default(.showFooter) private var showFooter private var pinnedItems: [HistoryItemDecorator] { - appState.history.pinnedItems.filter(\.isVisible) + appState.history.pinnedItems } private var unpinnedItems: [HistoryItemDecorator] { - appState.history.unpinnedItems.filter(\.isVisible) + appState.history.unpinnedItems } private var showPinsSeparator: Bool { pinsVisible && !unpinnedItems.isEmpty diff --git a/Maccy/Views/LegacyReorderableContainer.swift b/Maccy/Views/LegacyReorderableContainer.swift index cc8c31da..86166ed4 100644 --- a/Maccy/Views/LegacyReorderableContainer.swift +++ b/Maccy/Views/LegacyReorderableContainer.swift @@ -61,7 +61,7 @@ private struct LegacyDraggableItemDropDelegate: DropDelegate } func performDrop(info: DropInfo) -> Bool { - dragContext.resetDragState(true) + dragContext.resetDragState() return true } @@ -106,7 +106,7 @@ private struct LegacyDragSourceView: NSViewRepresentable { dragContext.startDrag(item) }, onDragEnded: { - dragContext.resetDragState(true) + dragContext.resetDragState() } ) } diff --git a/Maccy/Views/PinsView.swift b/Maccy/Views/PinsView.swift index 9922ba84..eb6e0252 100644 --- a/Maccy/Views/PinsView.swift +++ b/Maccy/Views/PinsView.swift @@ -51,9 +51,6 @@ struct PinsView: View { withAnimation(.default.speed(2)) { move(from: source, to: destination) } - }, - moveCompletedAction: { - appState.history.recomputeItemsAfterReordering() } ) } else { diff --git a/Maccy/Views/ReorderableContainer.swift b/Maccy/Views/ReorderableContainer.swift index 8c2c174c..74845e08 100644 --- a/Maccy/Views/ReorderableContainer.swift +++ b/Maccy/Views/ReorderableContainer.swift @@ -11,10 +11,8 @@ private struct DraggableContainerModifier: @Binding var draggedItems: [Item] let moveAction: (IndexSet, Int) -> Void - let moveCompletedAction: () -> Void @State private var active: Item? - @State private var hasChangedLocation = false func body(content: Content) -> some View { content @@ -58,11 +56,7 @@ private struct DraggableContainerModifier: AppState.shared.navigator.isDragAndDropInProgress = true } - private func resetDragState(didCompleteMove: Bool = false) { - if didCompleteMove && hasChangedLocation { - moveCompletedAction() - } - hasChangedLocation = false + private func resetDragState() { draggedItems = [] active = nil AppState.shared.navigator.isDragAndDropInProgress = false @@ -88,7 +82,6 @@ private struct DraggableContainerModifier: edge: edge ) else { return } - hasChangedLocation = true moveAction(source, destinationIndex) } @@ -127,13 +120,13 @@ private struct DraggableItemModifier: ViewModifier { .onDragSessionUpdated { session in switch session.phase { case .ended: - dragContext.resetDragState(true) + dragContext.resetDragState() default: break } } .onDrop(of: [dragContext.contentType], isTargeted: nil) { _ in - dragContext.resetDragState(true) + dragContext.resetDragState() return true } .onDropSessionUpdated { session in @@ -144,7 +137,7 @@ private struct DraggableItemModifier: ViewModifier { dropEdge(at: session.location, in: session.size) ) case .ended: - dragContext.resetDragState(true) + dragContext.resetDragState() default: break } @@ -176,7 +169,7 @@ struct ReorderableDragContext { let isDragged: (Any) -> Bool let dragPreview: (Any) -> AnyView let startDrag: (Any) -> Void - let resetDragState: (Bool) -> Void + let resetDragState: () -> Void let move: (Any, ReorderableDropEdge) -> Void } @@ -223,8 +216,7 @@ extension View { @ViewBuilder dragPreview: @escaping ([Item]) -> DragPreview, selection: Binding>, draggedItems: Binding<[Item]>, - moveAction: @escaping (IndexSet, Int) -> Void, - moveCompletedAction: @escaping () -> Void = {} + moveAction: @escaping (IndexSet, Int) -> Void ) -> some View { modifier( DraggableContainerModifier( @@ -233,8 +225,7 @@ extension View { dragPreview: dragPreview, selection: selection, draggedItems: draggedItems, - moveAction: moveAction, - moveCompletedAction: moveCompletedAction + moveAction: moveAction ) ) } diff --git a/MaccyTests/HistoryTests.swift b/MaccyTests/HistoryTests.swift index 8ef55bec..c64326ad 100644 --- a/MaccyTests/HistoryTests.swift +++ b/MaccyTests/HistoryTests.swift @@ -50,7 +50,7 @@ class HistoryTests: XCTestCase { // swiftlint:disable:this type_body_length let transferredContents = first.contents let merged = history.add(third) - XCTAssertEqual(history.all, [merged]) + XCTAssertEqual(history.items, [merged]) XCTAssertEqual(Set(merged.item.contents), Set(transferredContents)) XCTAssertTrue(merged.item.lastCopiedAt > merged.item.firstCopiedAt) XCTAssertEqual(merged.item.numberOfCopies, 2) @@ -76,7 +76,7 @@ class HistoryTests: XCTestCase { // swiftlint:disable:this type_body_length let transferredContents = first.contents let merged = history.add(second) - XCTAssertEqual(history.all, [merged]) + XCTAssertEqual(history.items, [merged]) XCTAssertEqual(Set(merged.item.contents), Set(transferredContents)) XCTAssertTrue(merged.item.lastCopiedAt > merged.item.firstCopiedAt) XCTAssertEqual(merged.item.numberOfCopies, 2) @@ -327,15 +327,15 @@ class HistoryTests: XCTestCase { // swiftlint:disable:this type_body_length history.add(historyItem(String(index))) } - XCTAssertEqual(history.all.last, pinned) + XCTAssertEqual(history.items.last, pinned) // Re-copy the pinned item. It is detected as a duplicate, removed and // re-inserted while `limitHistorySize` trims an exceeding unpinned item. // Before the fix this inserted at a stale, out-of-bounds index and crashed. let readded = history.add(historyItem("pinned")) - XCTAssertTrue(history.all.contains(readded)) - XCTAssertEqual(history.all.filter(\.isPinned).count, 1) + XCTAssertTrue(history.items.contains(readded)) + XCTAssertEqual(history.items.filter(\.isPinned).count, 1) } func testRemoving() throws { From fbaeede762c009782792518052f4352ff388870f Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:17:20 +0000 Subject: [PATCH 19/59] Avoid allocating visible item collections --- Maccy/Extensions/Collection+Surrounding.swift | 4 +-- Maccy/ItemsProtocol.swift | 28 +++++++++++++++---- Maccy/Observables/AppState.swift | 2 +- Maccy/Observables/NavigationManager.swift | 2 +- 4 files changed, 27 insertions(+), 9 deletions(-) diff --git a/Maccy/Extensions/Collection+Surrounding.swift b/Maccy/Extensions/Collection+Surrounding.swift index 31ebc711..4f485d5b 100644 --- a/Maccy/Extensions/Collection+Surrounding.swift +++ b/Maccy/Extensions/Collection+Surrounding.swift @@ -49,7 +49,7 @@ extension Collection where Element: Equatable { } } -extension Array where Element: Equatable { +extension RandomAccessCollection where Element: Equatable { func nearest(to element: Element, where condition: (Element) -> Bool) -> Element? { guard let currentIndex = firstIndex(of: element) else { return nil @@ -64,7 +64,7 @@ extension Array where Element: Equatable { case (.none, .some(let index)): return self[index] case (.some(let next), .some(let previous)): - return (next - currentIndex) <= (currentIndex - previous) + return distance(from: currentIndex, to: next) <= distance(from: previous, to: currentIndex) ? self[next] : self[previous] } diff --git a/Maccy/ItemsProtocol.swift b/Maccy/ItemsProtocol.swift index 6b912a27..00562e4c 100644 --- a/Maccy/ItemsProtocol.swift +++ b/Maccy/ItemsProtocol.swift @@ -16,11 +16,6 @@ private extension ItemsContainer where Item: HasVisibility {} extension ItemsContainer where Item: HasVisibility { - var visibleItems: [Item] { - guard containerVisible else { return [] } - return self.items.lazy.filter(\.isVisible) - } - var firstVisibleItem: Item? { guard containerVisible else { return nil } return self.items.first(where: \.isVisible) @@ -40,6 +35,29 @@ extension ItemsContainer where Item: HasVisibility { } extension ItemsContainer where Item: HasVisibility, Item: Equatable { + func visibleBetween( + from fromItem: Item, + to toItem: Item, + inOrder: Bool = false + ) -> [Item]? { + guard containerVisible, fromItem.isVisible, toItem.isVisible else { return nil } + guard let fromIndex = items.firstIndex(of: fromItem), + let toIndex = items.firstIndex(of: toItem) else { return nil } + + let lowerBound = min(fromIndex, toIndex) + let upperBound = max(fromIndex, toIndex) + let range = items[lowerBound...upperBound].lazy.filter(\.isVisible) + if !inOrder && fromIndex > toIndex { + return Array(range.reversed()) + } + return Array(range) + } + + func nearestVisible(to item: Item, where predicate: (Item) -> Bool) -> Item? { + guard containerVisible, item.isVisible else { return nil } + return items.nearest(to: item) { $0.isVisible && predicate($0) } + } + func visibleItem(before: Item) -> Item? { return self.items.item(before: before, where: \.isVisible) } diff --git a/Maccy/Observables/AppState.swift b/Maccy/Observables/AppState.swift index 2e0e562f..6f80d6f0 100644 --- a/Maccy/Observables/AppState.swift +++ b/Maccy/Observables/AppState.swift @@ -96,7 +96,7 @@ class AppState: Sendable { @MainActor func deleteSelection() { guard let leadItem = navigator.leadHistoryItem else { return } - let nextUnselectedItem = history.visibleItems.nearest(to: leadItem) { !$0.isSelected } + let nextUnselectedItem = history.nearestVisible(to: leadItem) { !$0.isSelected } withTransaction(Transaction()) { navigator.selection.forEach { _, item in diff --git a/Maccy/Observables/NavigationManager.swift b/Maccy/Observables/NavigationManager.swift index 13b74852..8a558e27 100644 --- a/Maccy/Observables/NavigationManager.swift +++ b/Maccy/Observables/NavigationManager.swift @@ -136,7 +136,7 @@ class NavigationManager { // swiftlint:disable:this type_body_length var newSelectionState = selection if isRange { - if let itemRange = history.visibleItems.between( + if let itemRange = history.visibleBetween( from: fromItem, to: toItem, inOrder: false From cc01af068fb19fcc3d03e616fb57d442e1a7fa58 Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:17:48 +0000 Subject: [PATCH 20/59] Add concatenated item collections --- Maccy.xcodeproj/project.pbxproj | 4 +++ Maccy/ConcatenatedCollection.swift | 45 ++++++++++++++++++++++++++++++ Maccy/ItemsProtocol.swift | 3 +- Maccy/Observables/History.swift | 6 ++-- MaccyTests/HistoryTests.swift | 22 +++++++-------- 5 files changed, 65 insertions(+), 15 deletions(-) create mode 100644 Maccy/ConcatenatedCollection.swift diff --git a/Maccy.xcodeproj/project.pbxproj b/Maccy.xcodeproj/project.pbxproj index a92d3a24..41b8b69f 100644 --- a/Maccy.xcodeproj/project.pbxproj +++ b/Maccy.xcodeproj/project.pbxproj @@ -30,6 +30,7 @@ 2F578C422EFFE8920088B759 /* SlideoutContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F578C402EFFE8920088B759 /* SlideoutContentView.swift */; }; 2F578C432EFFE8920088B759 /* SlideoutView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F578C412EFFE8920088B759 /* SlideoutView.swift */; }; 2F59CC4C302B580300136053 /* Array+MoveElements.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F59CC4B302B57FC00136053 /* Array+MoveElements.swift */; }; + 2F8A0DBE303A1FA0004BA51F /* ConcatenatedCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F8A0DBD303A1F9D004BA51F /* ConcatenatedCollection.swift */; }; 2F8B9DE62C5D6E5D0046EF69 /* NSPoint+DefaultsSerializable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F8B9DE52C5D6E5D0046EF69 /* NSPoint+DefaultsSerializable.swift */; }; 2F9B42EF2F1A494C006548E5 /* ListHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F9B42EE2F1A4948006548E5 /* ListHeaderView.swift */; }; 2F9B42F12F1A499D006548E5 /* ToolbarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F9B42F02F1A4999006548E5 /* ToolbarView.swift */; }; @@ -217,6 +218,7 @@ 2F578C402EFFE8920088B759 /* SlideoutContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlideoutContentView.swift; sourceTree = ""; }; 2F578C412EFFE8920088B759 /* SlideoutView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlideoutView.swift; sourceTree = ""; }; 2F59CC4B302B57FC00136053 /* Array+MoveElements.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+MoveElements.swift"; sourceTree = ""; }; + 2F8A0DBD303A1F9D004BA51F /* ConcatenatedCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConcatenatedCollection.swift; sourceTree = ""; }; 2F8B9DE52C5D6E5D0046EF69 /* NSPoint+DefaultsSerializable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSPoint+DefaultsSerializable.swift"; sourceTree = ""; }; 2F9B42EE2F1A4948006548E5 /* ListHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListHeaderView.swift; sourceTree = ""; }; 2F9B42F02F1A4999006548E5 /* ToolbarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolbarView.swift; sourceTree = ""; }; @@ -873,6 +875,7 @@ DA555F072CF0F98F009608BD /* ApplicationImageCache.swift */, DA0EF1871E444B2A00E58577 /* Clipboard.swift */, DAE284FF232257D20080E394 /* ColorImage.swift */, + 2F8A0DBD303A1F9D004BA51F /* ConcatenatedCollection.swift */, DAA54BF92C3C951900B7FDD8 /* FloatingPanel.swift */, DA689FC92C1D18890009B887 /* HighlightMatch.swift */, DA1969202C3F6C6800258481 /* HistoryItemAction.swift */, @@ -1158,6 +1161,7 @@ DAA072E72C423617006DDFD2 /* NSSize+DefaultsSerializable.swift in Sources */, DA9C3C452C207AF80056795D /* IgnoreSettingsPane.swift in Sources */, DA13D7D82C1A223E00FA9E23 /* Select.swift in Sources */, + 2F8A0DBE303A1FA0004BA51F /* ConcatenatedCollection.swift in Sources */, DA689FC82C1D15140009B887 /* PinsPosition.swift in Sources */, 2FD5DE512FFE6DD50023FF60 /* LegacyReorderableContainer.swift in Sources */, 2F9B42EF2F1A494C006548E5 /* ListHeaderView.swift in Sources */, diff --git a/Maccy/ConcatenatedCollection.swift b/Maccy/ConcatenatedCollection.swift new file mode 100644 index 00000000..b8b2d1c2 --- /dev/null +++ b/Maccy/ConcatenatedCollection.swift @@ -0,0 +1,45 @@ +/// A random-access view over two arrays that does not allocate combined storage. +struct ConcatenatedCollection: RandomAccessCollection { + typealias Index = Int + + private let leading: [Element] + private let trailing: [Element] + + init(_ leading: [Element], _ trailing: [Element]) { + self.leading = leading + self.trailing = trailing + } + + var startIndex: Int { 0 } + var endIndex: Int { leading.count + trailing.count } + + subscript(position: Int) -> Element { + precondition(position >= startIndex && position < endIndex, "Index out of bounds") + if position < leading.count { + return leading[position] + } + return trailing[position - leading.count] + } + + func index(after index: Int) -> Int { + index + 1 + } + + func index(before index: Int) -> Int { + index - 1 + } + + func index(_ index: Int, offsetBy distance: Int) -> Int { + index + distance + } + + func distance(from start: Int, to end: Int) -> Int { + end - start + } + + func toArray() -> [Element] { + leading + trailing + } +} + +extension ConcatenatedCollection: Equatable where Element: Equatable {} diff --git a/Maccy/ItemsProtocol.swift b/Maccy/ItemsProtocol.swift index 00562e4c..3222f065 100644 --- a/Maccy/ItemsProtocol.swift +++ b/Maccy/ItemsProtocol.swift @@ -4,8 +4,9 @@ protocol HasVisibility { protocol ItemsContainer { associatedtype Item + associatedtype Items: RandomAccessCollection where Items.Element == Item var containerVisible: Bool { get } - var items: [Item] { get } + var items: Items { get } } extension ItemsContainer { diff --git a/Maccy/Observables/History.swift b/Maccy/Observables/History.swift index a1d1e795..f3e00ba3 100644 --- a/Maccy/Observables/History.swift +++ b/Maccy/Observables/History.swift @@ -15,12 +15,12 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length var pasteStack: PasteStack? - var items: [HistoryItemDecorator] { + var items: ConcatenatedCollection { switch Defaults[.pinTo] { case .top: - pinnedItems + unpinnedItems + ConcatenatedCollection(pinnedItems, unpinnedItems) case .bottom: - unpinnedItems + pinnedItems + ConcatenatedCollection(unpinnedItems, pinnedItems) } } var pinnedItems: [HistoryItemDecorator] { diff --git a/MaccyTests/HistoryTests.swift b/MaccyTests/HistoryTests.swift index c64326ad..6f641caa 100644 --- a/MaccyTests/HistoryTests.swift +++ b/MaccyTests/HistoryTests.swift @@ -29,13 +29,13 @@ class HistoryTests: XCTestCase { // swiftlint:disable:this type_body_length } func testDefaultIsEmpty() { - XCTAssertEqual(history.items, []) + XCTAssertEqual(history.items.toArray(), []) } func testAdding() { let first = history.add(historyItem("foo")) let second = history.add(historyItem("bar")) - XCTAssertEqual(history.items, [second, first]) + XCTAssertEqual(history.items.toArray(), [second, first]) } func testAddingPersistedDuplicate() throws { @@ -50,7 +50,7 @@ class HistoryTests: XCTestCase { // swiftlint:disable:this type_body_length let transferredContents = first.contents let merged = history.add(third) - XCTAssertEqual(history.items, [merged]) + XCTAssertEqual(history.items.toArray(), [merged]) XCTAssertEqual(Set(merged.item.contents), Set(transferredContents)) XCTAssertTrue(merged.item.lastCopiedAt > merged.item.firstCopiedAt) XCTAssertEqual(merged.item.numberOfCopies, 2) @@ -76,7 +76,7 @@ class HistoryTests: XCTestCase { // swiftlint:disable:this type_body_length let transferredContents = first.contents let merged = history.add(second) - XCTAssertEqual(history.items, [merged]) + XCTAssertEqual(history.items.toArray(), [merged]) XCTAssertEqual(Set(merged.item.contents), Set(transferredContents)) XCTAssertTrue(merged.item.lastCopiedAt > merged.item.firstCopiedAt) XCTAssertEqual(merged.item.numberOfCopies, 2) @@ -117,7 +117,7 @@ class HistoryTests: XCTestCase { // swiftlint:disable:this type_body_length secondItem.title = secondItem.generateTitle() let second = history.add(secondItem) - XCTAssertEqual(history.items, [second]) + XCTAssertEqual(history.items.toArray(), [second]) XCTAssertEqual(Set(history.items[0].item.contents), Set(firstContents)) try assertStorageCounts(items: 1, contents: firstContents.count) } @@ -153,7 +153,7 @@ class HistoryTests: XCTestCase { // swiftlint:disable:this type_body_length secondItem.contents = secondContents let second = history.add(secondItem) - XCTAssertEqual(history.items, [second]) + XCTAssertEqual(history.items.toArray(), [second]) XCTAssertEqual(Set(history.items[0].item.contents), Set(firstContents)) } @@ -186,7 +186,7 @@ class HistoryTests: XCTestCase { // swiftlint:disable:this type_body_length second.contents = secondContents let secondDecorator = history.add(second) - XCTAssertEqual(history.items, [secondDecorator]) + XCTAssertEqual(history.items.toArray(), [secondDecorator]) XCTAssertEqual(history.items[0].item.application, "Xcode.app") XCTAssertEqual(Set(history.items[0].item.contents), Set(firstContents)) } @@ -201,7 +201,7 @@ class HistoryTests: XCTestCase { // swiftlint:disable:this type_body_length )) let modifiedItemDecorator = history.add(modifiedItem) - XCTAssertEqual(history.items, [modifiedItemDecorator]) + XCTAssertEqual(history.items.toArray(), [modifiedItemDecorator]) XCTAssertEqual(history.items[0].text, "bar") } @@ -218,7 +218,7 @@ class HistoryTests: XCTestCase { // swiftlint:disable:this type_body_length history.clear() - XCTAssertEqual(history.items, [pinned]) + XCTAssertEqual(history.items.toArray(), [pinned]) try assertStorageCounts(items: 1, contents: 1) } @@ -234,7 +234,7 @@ class HistoryTests: XCTestCase { // swiftlint:disable:this type_body_length history.clearAll() - XCTAssertEqual(history.items, []) + XCTAssertEqual(history.items.toArray(), []) try assertStorageCounts(items: 0, contents: 0) } @@ -342,7 +342,7 @@ class HistoryTests: XCTestCase { // swiftlint:disable:this type_body_length let foo = history.add(historyItem("foo")) let bar = history.add(historyItem("bar")) history.delete(foo) - XCTAssertEqual(history.items, [bar]) + XCTAssertEqual(history.items.toArray(), [bar]) try assertStorageCounts(items: 1, contents: 1) } From aca784ea610cbaf42512244d6840798d145872b9 Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:13:51 +0200 Subject: [PATCH 21/59] Adapt pin ordering value types to Swift 6 isolation --- Maccy/ConcatenatedCollection.swift | 2 +- Maccy/Observables/PinManager.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Maccy/ConcatenatedCollection.swift b/Maccy/ConcatenatedCollection.swift index b8b2d1c2..daefe899 100644 --- a/Maccy/ConcatenatedCollection.swift +++ b/Maccy/ConcatenatedCollection.swift @@ -1,5 +1,5 @@ /// A random-access view over two arrays that does not allocate combined storage. -struct ConcatenatedCollection: RandomAccessCollection { +nonisolated struct ConcatenatedCollection: RandomAccessCollection { typealias Index = Int private let leading: [Element] diff --git a/Maccy/Observables/PinManager.swift b/Maccy/Observables/PinManager.swift index bfa21f07..8f00a7f5 100644 --- a/Maccy/Observables/PinManager.swift +++ b/Maccy/Observables/PinManager.swift @@ -2,7 +2,7 @@ import Defaults import Observation import SwiftUI -struct PinOrder: Codable, Equatable, Defaults.Serializable { +nonisolated struct PinOrder: Codable, Equatable, Defaults.Serializable { var pins: [String] init(pins: [String] = []) { From 55e441797853796f53dc7f00d4c319fdd93ca6d1 Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:22:56 +0200 Subject: [PATCH 22/59] Allow editing pin keys for image items --- Maccy/Views/ItemEditorView.swift | 7 +++++-- Maccy/Views/ToolbarView.swift | 5 +++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/Maccy/Views/ItemEditorView.swift b/Maccy/Views/ItemEditorView.swift index 5d02e9c8..ba48525d 100644 --- a/Maccy/Views/ItemEditorView.swift +++ b/Maccy/Views/ItemEditorView.swift @@ -37,6 +37,7 @@ struct ItemEditorView: View { .frame(width: 80, alignment: .leading) TextField("", text: $editableTitle) + .disabled(item.hasImage) } if item.isPinned { @@ -149,8 +150,10 @@ struct ItemEditorView: View { } private func applyChanges() { - item.item.title = editableTitle - item.title = editableTitle + if !item.hasImage { + item.item.title = editableTitle + item.title = editableTitle + } if let selectedPin { appState.history.updatePin(item.item, to: selectedPin) diff --git a/Maccy/Views/ToolbarView.swift b/Maccy/Views/ToolbarView.swift index ad14b55a..1382ff49 100644 --- a/Maccy/Views/ToolbarView.swift +++ b/Maccy/Views/ToolbarView.swift @@ -104,8 +104,8 @@ struct ToolbarView: View { private var editItemEnabled: Bool { guard appState.navigator.selection.count == 1 else { return false } - guard let pinned = appState.navigator.selection.first else { return false } - return pinned.hasPlainText || pinned.hasRichText + guard let item = appState.navigator.selection.first else { return false } + return item.hasPlainText || item.hasRichText || item.hasImage } private var editableItem: HistoryItemDecorator? { @@ -159,6 +159,7 @@ struct ToolbarView: View { } } .disabled(!editItemEnabled) + .accessibilityIdentifier("edit-item") ToolbarButton { appState.deleteSelection() From 0fd3a87bcfe940eb2645797c12499d294fe3a791 Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:27:51 +0200 Subject: [PATCH 23/59] Keep keyboard focus and actions in the item editor --- Maccy/Observables/Popup.swift | 4 ++++ Maccy/Views/ItemEditorView.swift | 24 +++++++++++++++++++----- Maccy/Views/KeyHandlingView.swift | 2 ++ 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/Maccy/Observables/Popup.swift b/Maccy/Observables/Popup.swift index 3248459b..3d97410a 100644 --- a/Maccy/Observables/Popup.swift +++ b/Maccy/Observables/Popup.swift @@ -120,6 +120,8 @@ class Popup { } private func handleFirstKeyDown() { + guard !AppState.shared.isEditingItem else { return } + if isClosed() { open(height: height) state = .opening @@ -132,6 +134,8 @@ class Popup { } private func handleEvent(_ event: NSEvent) -> NSEvent? { + guard !AppState.shared.isEditingItem else { return event } + switch event.type { case .keyDown: return handleKeyDown(event) diff --git a/Maccy/Views/ItemEditorView.swift b/Maccy/Views/ItemEditorView.swift index ba48525d..bfca5d37 100644 --- a/Maccy/Views/ItemEditorView.swift +++ b/Maccy/Views/ItemEditorView.swift @@ -14,6 +14,13 @@ struct ItemEditorView: View { @State private var isTextContent: Bool @State private var isRichText: Bool + private enum Field: Hashable { + case title + case pin + } + + @FocusState private var focusedField: Field? + init(for item: HistoryItemDecorator) { self.item = item self._editableTitle = State(initialValue: item.item.title) @@ -38,6 +45,8 @@ struct ItemEditorView: View { TextField("", text: $editableTitle) .disabled(item.hasImage) + .focused($focusedField, equals: .title) + .onSubmit(saveChanges) } if item.isPinned { @@ -51,6 +60,7 @@ struct ItemEditorView: View { } } .labelsHidden() + .focused($focusedField, equals: .pin) .frame(maxWidth: 220, alignment: .leading) } } @@ -80,18 +90,17 @@ struct ItemEditorView: View { } .keyboardShortcut(.cancelAction) - Button(Self.doneButtonTitle) { - applyChanges() - dismiss() - } - .keyboardShortcut(.defaultAction) + Button(Self.doneButtonTitle, action: saveChanges) + .keyboardShortcut(.return, modifiers: .command) } } .padding(16) .frame(minWidth: 540, minHeight: 360) .onAppear { availablePins = appState.history.availablePins + focusedField = item.hasImage ? .pin : .title } + .onExitCommand { dismiss() } } private static var cancelButtonTitle: String { @@ -177,4 +186,9 @@ struct ItemEditorView: View { ) } } + + private func saveChanges() { + applyChanges() + dismiss() + } } diff --git a/Maccy/Views/KeyHandlingView.swift b/Maccy/Views/KeyHandlingView.swift index b14a8b85..46afcc1b 100644 --- a/Maccy/Views/KeyHandlingView.swift +++ b/Maccy/Views/KeyHandlingView.swift @@ -12,6 +12,8 @@ struct KeyHandlingView: View { var body: some View { content() .onKeyPress { _ in + guard !appState.isEditingItem else { return .ignored } + // Unfortunately, key presses don't allow access to // key code and don't properly work with multiple inputs, // so pressing ⌘, on non-English layout doesn't open From 74ac00bb296e440a59b134804fed921d18fefd61 Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:28:19 +0200 Subject: [PATCH 24/59] Preserve rich content when only item metadata changes --- Maccy/Views/ItemEditorView.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/Maccy/Views/ItemEditorView.swift b/Maccy/Views/ItemEditorView.swift index bfca5d37..4bcefc50 100644 --- a/Maccy/Views/ItemEditorView.swift +++ b/Maccy/Views/ItemEditorView.swift @@ -6,6 +6,7 @@ struct ItemEditorView: View { @Environment(\.dismiss) private var dismiss let item: HistoryItemDecorator + private let originalContent: String @State private var editableTitle: String @State private var editableContent: String @@ -23,8 +24,9 @@ struct ItemEditorView: View { init(for item: HistoryItemDecorator) { self.item = item + self.originalContent = item.item.previewableText self._editableTitle = State(initialValue: item.item.title) - self._editableContent = State(initialValue: item.item.previewableText) + self._editableContent = State(initialValue: originalContent) self._selectedPin = State(initialValue: item.item.pin) // Content can only be edited safely as plain text. @@ -73,7 +75,7 @@ struct ItemEditorView: View { contentEditor } - if isRichText { + if isRichText && editableContent != originalContent { Label { Text("RichTextEditWarning", tableName: "PinsSettings") } icon: { @@ -169,6 +171,7 @@ struct ItemEditorView: View { } guard isTextContent || isRichText else { return } + guard editableContent != originalContent else { return } let historyItem = item.item let stringType = NSPasteboard.PasteboardType.string.rawValue From 9936c3bb4b054ec10b4e30ea7020923ab0038f3c Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:28:56 +0200 Subject: [PATCH 25/59] Preserve numeric item shortcuts after unpinning --- Maccy/Observables/HistoryItemDecorator.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Maccy/Observables/HistoryItemDecorator.swift b/Maccy/Observables/HistoryItemDecorator.swift index d2706d56..89f017c6 100644 --- a/Maccy/Observables/HistoryItemDecorator.swift +++ b/Maccy/Observables/HistoryItemDecorator.swift @@ -216,9 +216,9 @@ class HistoryItemDecorator: Identifiable, Hashable, HasVisibility { guard let self else { return } if let pin = self.item.pin { self.shortcuts = KeyShortcut.create(character: pin) - } else { - self.shortcuts = [] } + // History assigns numeric shortcuts when unpinning. Preserve them when + // this observation callback runs after the history has been updated. self.synchronizeItemPin() } } From 7bf6194b1929914fdb8f152cc76795b5476fae11 Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:30:16 +0200 Subject: [PATCH 26/59] Label item editor controls for VoiceOver --- Maccy/Views/ItemEditorView.swift | 7 ++++++- Maccy/Views/ToolbarView.swift | 1 + Maccy/Views/ar.lproj/PreviewItemView.strings | 1 + Maccy/Views/be.lproj/PreviewItemView.strings | 1 + Maccy/Views/bn.lproj/PreviewItemView.strings | 1 + Maccy/Views/bs.lproj/PreviewItemView.strings | 1 + Maccy/Views/ca.lproj/PreviewItemView.strings | 1 + Maccy/Views/ckb.lproj/PreviewItemView.strings | 1 + Maccy/Views/cs.lproj/PreviewItemView.strings | 1 + Maccy/Views/de.lproj/PreviewItemView.strings | 1 + Maccy/Views/el.lproj/PreviewItemView.strings | 1 + Maccy/Views/en.lproj/PreviewItemView.strings | 1 + Maccy/Views/eo.lproj/PreviewItemView.strings | 1 + Maccy/Views/es.lproj/PreviewItemView.strings | 1 + Maccy/Views/fa.lproj/PreviewItemView.strings | 1 + Maccy/Views/fi.lproj/PreviewItemView.strings | 1 + Maccy/Views/fr.lproj/PreviewItemView.strings | 1 + Maccy/Views/he.lproj/PreviewItemView.strings | 1 + Maccy/Views/hi.lproj/PreviewItemView.strings | 1 + Maccy/Views/hr.lproj/PreviewItemView.strings | 1 + Maccy/Views/hu.lproj/PreviewItemView.strings | 1 + Maccy/Views/id.lproj/PreviewItemView.strings | 1 + Maccy/Views/it.lproj/PreviewItemView.strings | 1 + Maccy/Views/ja.lproj/PreviewItemView.strings | 1 + Maccy/Views/km.lproj/PreviewItemView.strings | 1 + Maccy/Views/ko.lproj/PreviewItemView.strings | 1 + Maccy/Views/lt.lproj/PreviewItemView.strings | 1 + Maccy/Views/lv.lproj/PreviewItemView.strings | 1 + Maccy/Views/nb.lproj/PreviewItemView.strings | 1 + Maccy/Views/nl.lproj/PreviewItemView.strings | 1 + Maccy/Views/pl.lproj/PreviewItemView.strings | 1 + Maccy/Views/pt-BR.lproj/PreviewItemView.strings | 1 + Maccy/Views/pt.lproj/PreviewItemView.strings | 1 + Maccy/Views/ro.lproj/PreviewItemView.strings | 1 + Maccy/Views/ru.lproj/PreviewItemView.strings | 1 + Maccy/Views/sl.lproj/PreviewItemView.strings | 1 + Maccy/Views/sv.lproj/PreviewItemView.strings | 1 + Maccy/Views/ta.lproj/PreviewItemView.strings | 1 + Maccy/Views/th.lproj/PreviewItemView.strings | 1 + Maccy/Views/tr.lproj/PreviewItemView.strings | 1 + Maccy/Views/uk.lproj/PreviewItemView.strings | 1 + Maccy/Views/uz.lproj/PreviewItemView.strings | 1 + Maccy/Views/vi.lproj/PreviewItemView.strings | 1 + Maccy/Views/zh-Hans.lproj/PreviewItemView.strings | 1 + Maccy/Views/zh-Hant.lproj/PreviewItemView.strings | 1 + 45 files changed, 50 insertions(+), 1 deletion(-) diff --git a/Maccy/Views/ItemEditorView.swift b/Maccy/Views/ItemEditorView.swift index 4bcefc50..e41821b1 100644 --- a/Maccy/Views/ItemEditorView.swift +++ b/Maccy/Views/ItemEditorView.swift @@ -46,6 +46,7 @@ struct ItemEditorView: View { .frame(width: 80, alignment: .leading) TextField("", text: $editableTitle) + .accessibilityLabel(Text("Alias", tableName: "PinsSettings")) .disabled(item.hasImage) .focused($focusedField, equals: .title) .onSubmit(saveChanges) @@ -62,6 +63,7 @@ struct ItemEditorView: View { } } .labelsHidden() + .accessibilityLabel(Text("Key", tableName: "PinsSettings")) .focused($focusedField, equals: .pin) .frame(maxWidth: 220, alignment: .leading) } @@ -93,11 +95,13 @@ struct ItemEditorView: View { .keyboardShortcut(.cancelAction) Button(Self.doneButtonTitle, action: saveChanges) - .keyboardShortcut(.return, modifiers: .command) + .keyboardShortcut(.return, modifiers: .command) } } .padding(16) .frame(minWidth: 540, minHeight: 360) + .accessibilityElement(children: .contain) + .accessibilityLabel(Text("EditItem", tableName: "PreviewItemView")) .onAppear { availablePins = appState.history.availablePins focusedField = item.hasImage ? .pin : .title @@ -126,6 +130,7 @@ struct ItemEditorView: View { Group { if isTextContent || isRichText { TextEditor(text: $editableContent) + .accessibilityLabel(Text("Content", tableName: "PinsSettings")) .font(.body) .scrollContentBackground(.hidden) .background(Color.clear) diff --git a/Maccy/Views/ToolbarView.swift b/Maccy/Views/ToolbarView.swift index 1382ff49..c8192c09 100644 --- a/Maccy/Views/ToolbarView.swift +++ b/Maccy/Views/ToolbarView.swift @@ -158,6 +158,7 @@ struct ToolbarView: View { Image(systemName: "pencil.slash") } } + .shortcutKeyHelp(key: "EditItem", tableName: "PreviewItemView") .disabled(!editItemEnabled) .accessibilityIdentifier("edit-item") diff --git a/Maccy/Views/ar.lproj/PreviewItemView.strings b/Maccy/Views/ar.lproj/PreviewItemView.strings index d7c15c2e..d957d498 100644 --- a/Maccy/Views/ar.lproj/PreviewItemView.strings +++ b/Maccy/Views/ar.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "اضغط على {previewKey} لإخفاء المعاينة."; "Dimensions" = "الأبعاد:"; "CopyExtractedText" = "انسخ النص المستخرج."; +"EditItem" = "تعديل العنصر"; diff --git a/Maccy/Views/be.lproj/PreviewItemView.strings b/Maccy/Views/be.lproj/PreviewItemView.strings index 07204923..b0169809 100644 --- a/Maccy/Views/be.lproj/PreviewItemView.strings +++ b/Maccy/Views/be.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "Націсніце {previewKey}, каб схаваць перадпрагляд."; "Dimensions" = "Памеры:"; "CopyExtractedText" = "Скапіяваць распазнаны тэкст."; +"EditItem" = "Рэдагаваць элемент"; diff --git a/Maccy/Views/bn.lproj/PreviewItemView.strings b/Maccy/Views/bn.lproj/PreviewItemView.strings index 4228b409..c03739ad 100644 --- a/Maccy/Views/bn.lproj/PreviewItemView.strings +++ b/Maccy/Views/bn.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "প্রিভিউ লুকাতে {previewKey} চাপুন।"; "Dimensions" = "মাত্রা:"; "CopyExtractedText" = "শনাক্ত করা লেখা কপি করুন."; +"EditItem" = "আইটেম সম্পাদনা করুন"; diff --git a/Maccy/Views/bs.lproj/PreviewItemView.strings b/Maccy/Views/bs.lproj/PreviewItemView.strings index 6fa0e965..361ad121 100644 --- a/Maccy/Views/bs.lproj/PreviewItemView.strings +++ b/Maccy/Views/bs.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "Pritisnite {previewKey} za skrivanje pregleda."; "Dimensions" = "Dimenzije:"; "CopyExtractedText" = "Kopiraj izdvojeni tekst."; +"EditItem" = "Uredi stavku"; diff --git a/Maccy/Views/ca.lproj/PreviewItemView.strings b/Maccy/Views/ca.lproj/PreviewItemView.strings index cc814786..1426ed88 100644 --- a/Maccy/Views/ca.lproj/PreviewItemView.strings +++ b/Maccy/Views/ca.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "Prem {previewKey} per amagar la previsualització."; "Dimensions" = "Dimensions:"; "CopyExtractedText" = "Copia el text extret."; +"EditItem" = "Edita l’element"; diff --git a/Maccy/Views/ckb.lproj/PreviewItemView.strings b/Maccy/Views/ckb.lproj/PreviewItemView.strings index db8758f2..1e7d1054 100644 --- a/Maccy/Views/ckb.lproj/PreviewItemView.strings +++ b/Maccy/Views/ckb.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "{previewKey} دابگرە بۆ شاردنەوەی پێشبینین."; "Dimensions" = "قەبارە:"; "CopyExtractedText" = "دەقی دەرهێنراو کۆپی بکە."; +"EditItem" = "دەستکاری بڕگە"; diff --git a/Maccy/Views/cs.lproj/PreviewItemView.strings b/Maccy/Views/cs.lproj/PreviewItemView.strings index 436a071f..68d15e99 100644 --- a/Maccy/Views/cs.lproj/PreviewItemView.strings +++ b/Maccy/Views/cs.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "Stiskni {previewKey} pro skrytí náhledu."; "Dimensions" = "Rozměry:"; "CopyExtractedText" = "Zkopírovat extrahovaný text."; +"EditItem" = "Upravit položku"; diff --git a/Maccy/Views/de.lproj/PreviewItemView.strings b/Maccy/Views/de.lproj/PreviewItemView.strings index 5cf442b4..73eafd17 100644 --- a/Maccy/Views/de.lproj/PreviewItemView.strings +++ b/Maccy/Views/de.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "Drücke {previewKey}, um die Vorschau auszublenden."; "Dimensions" = "Abmessungen:"; "CopyExtractedText" = "Extrahierten Text kopieren."; +"EditItem" = "Eintrag bearbeiten"; diff --git a/Maccy/Views/el.lproj/PreviewItemView.strings b/Maccy/Views/el.lproj/PreviewItemView.strings index c59be9be..f2a25ded 100644 --- a/Maccy/Views/el.lproj/PreviewItemView.strings +++ b/Maccy/Views/el.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "Πατήστε {previewKey} για απόκρυψη της προεπισκόπησης."; "Dimensions" = "Διαστάσεις:"; "CopyExtractedText" = "Αντιγραφή εξαγμένου κειμένου."; +"EditItem" = "Επεξεργασία στοιχείου"; diff --git a/Maccy/Views/en.lproj/PreviewItemView.strings b/Maccy/Views/en.lproj/PreviewItemView.strings index 2da917b1..bab03adf 100644 --- a/Maccy/Views/en.lproj/PreviewItemView.strings +++ b/Maccy/Views/en.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "OpenPreview" = "Press {previewKey} to show preview."; "ClosePreview" = "Press {previewKey} to hide preview."; "CopyExtractedText" = "Copy extracted text."; +"EditItem" = "Edit item"; diff --git a/Maccy/Views/eo.lproj/PreviewItemView.strings b/Maccy/Views/eo.lproj/PreviewItemView.strings index f8fbd150..f2207c3b 100644 --- a/Maccy/Views/eo.lproj/PreviewItemView.strings +++ b/Maccy/Views/eo.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "Premu {previewKey} por kaŝi la antaŭrigardon."; "Dimensions" = "Dimensioj:"; "CopyExtractedText" = "Kopii eltiritan tekston."; +"EditItem" = "Redakti eron"; diff --git a/Maccy/Views/es.lproj/PreviewItemView.strings b/Maccy/Views/es.lproj/PreviewItemView.strings index c450aed2..14b23860 100644 --- a/Maccy/Views/es.lproj/PreviewItemView.strings +++ b/Maccy/Views/es.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "Presiona {previewKey} para ocultar la vista previa."; "Dimensions" = "Dimensiones:"; "CopyExtractedText" = "Copiar texto extraído."; +"EditItem" = "Editar elemento"; diff --git a/Maccy/Views/fa.lproj/PreviewItemView.strings b/Maccy/Views/fa.lproj/PreviewItemView.strings index 49353ac8..470041d9 100644 --- a/Maccy/Views/fa.lproj/PreviewItemView.strings +++ b/Maccy/Views/fa.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "برای پنهان کردن پیش‌نمایش، کلید {previewKey} را فشار دهید."; "Dimensions" = "ابعاد:"; "CopyExtractedText" = "متن استخراج‌شده را کپی کنید."; +"EditItem" = "ویرایش مورد"; diff --git a/Maccy/Views/fi.lproj/PreviewItemView.strings b/Maccy/Views/fi.lproj/PreviewItemView.strings index 157def3f..fc9c6044 100644 --- a/Maccy/Views/fi.lproj/PreviewItemView.strings +++ b/Maccy/Views/fi.lproj/PreviewItemView.strings @@ -6,3 +6,4 @@ "Application" = ""; "OpenPreview" = "Paina {previewKey} näyttääksesi esikatselun."; "ClosePreview" = "Paina {previewKey} piilottaaksesi esikatselun."; +"EditItem" = "Muokkaa kohdetta"; diff --git a/Maccy/Views/fr.lproj/PreviewItemView.strings b/Maccy/Views/fr.lproj/PreviewItemView.strings index 53356dc2..c3cb4e7c 100644 --- a/Maccy/Views/fr.lproj/PreviewItemView.strings +++ b/Maccy/Views/fr.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "Appuyez sur {previewKey} pour masquer l'aperçu."; "Dimensions" = "Dimensions :"; "CopyExtractedText" = "Copier le texte extrait."; +"EditItem" = "Modifier l’élément"; diff --git a/Maccy/Views/he.lproj/PreviewItemView.strings b/Maccy/Views/he.lproj/PreviewItemView.strings index 07a9d3ae..643c37f2 100644 --- a/Maccy/Views/he.lproj/PreviewItemView.strings +++ b/Maccy/Views/he.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "יש ללחוץ על {previewKey} כדי להסתיר תצוגה מקדימה."; "Dimensions" = "ממדים:"; "CopyExtractedText" = "העתק את הטקסט שחולץ."; +"EditItem" = "עריכת פריט"; diff --git a/Maccy/Views/hi.lproj/PreviewItemView.strings b/Maccy/Views/hi.lproj/PreviewItemView.strings index ac328591..3a310fc8 100644 --- a/Maccy/Views/hi.lproj/PreviewItemView.strings +++ b/Maccy/Views/hi.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "पूर्वावलोकन छिपाने के लिए {previewKey} दबाएँ।"; "Dimensions" = "आयाम:"; "CopyExtractedText" = "निकाले गए टेक्स्ट को कॉपी करें."; +"EditItem" = "आइटम संपादित करें"; diff --git a/Maccy/Views/hr.lproj/PreviewItemView.strings b/Maccy/Views/hr.lproj/PreviewItemView.strings index 605b910c..aa60117e 100644 --- a/Maccy/Views/hr.lproj/PreviewItemView.strings +++ b/Maccy/Views/hr.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "Pritisnite {previewKey} za skrivanje pregleda."; "Dimensions" = "Dimenzije:"; "CopyExtractedText" = "Kopiraj izdvojeni tekst."; +"EditItem" = "Uredi stavku"; diff --git a/Maccy/Views/hu.lproj/PreviewItemView.strings b/Maccy/Views/hu.lproj/PreviewItemView.strings index 25687155..fb1e2b45 100644 --- a/Maccy/Views/hu.lproj/PreviewItemView.strings +++ b/Maccy/Views/hu.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "Nyomja meg a {previewKey} gombot az előnézet elrejtéséhez."; "Dimensions" = "Méretek:"; "CopyExtractedText" = "Kinyert szöveg másolása."; +"EditItem" = "Elem szerkesztése"; diff --git a/Maccy/Views/id.lproj/PreviewItemView.strings b/Maccy/Views/id.lproj/PreviewItemView.strings index 796f3726..9b028d69 100644 --- a/Maccy/Views/id.lproj/PreviewItemView.strings +++ b/Maccy/Views/id.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "Tekan tombol {previewKey} untuk menyembunyikan pratinjau."; "Dimensions" = "Dimensi:"; "CopyExtractedText" = "Salin teks yang diekstrak."; +"EditItem" = "Edit item"; diff --git a/Maccy/Views/it.lproj/PreviewItemView.strings b/Maccy/Views/it.lproj/PreviewItemView.strings index de46c5a2..123c378e 100644 --- a/Maccy/Views/it.lproj/PreviewItemView.strings +++ b/Maccy/Views/it.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "Premi {previewKey} per nascondere l'anteprima."; "Dimensions" = "Dimensioni:"; "CopyExtractedText" = "Copia il testo estratto."; +"EditItem" = "Modifica elemento"; diff --git a/Maccy/Views/ja.lproj/PreviewItemView.strings b/Maccy/Views/ja.lproj/PreviewItemView.strings index a46dde47..25c0a82d 100644 --- a/Maccy/Views/ja.lproj/PreviewItemView.strings +++ b/Maccy/Views/ja.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "{previewKey}でプレビューを非表示にします。"; "Dimensions" = "サイズ:"; "CopyExtractedText" = "抽出したテキストをコピーします。"; +"EditItem" = "項目を編集"; diff --git a/Maccy/Views/km.lproj/PreviewItemView.strings b/Maccy/Views/km.lproj/PreviewItemView.strings index 456109e9..45d9bfed 100644 --- a/Maccy/Views/km.lproj/PreviewItemView.strings +++ b/Maccy/Views/km.lproj/PreviewItemView.strings @@ -10,3 +10,4 @@ "UnpinKey" = "ចុច {pinKey} ដើម្បី ដកខ្ទាស់។"; "OpenPreview" = "ចុច {previewKey} ដើម្បីបង្ហាញការមើលជាមុន។"; "ClosePreview" = "ចុច {previewKey} ដើម្បីលាក់ការមើលជាមុន។"; +"EditItem" = "កែសម្រួលធាតុ"; diff --git a/Maccy/Views/ko.lproj/PreviewItemView.strings b/Maccy/Views/ko.lproj/PreviewItemView.strings index c982f18a..505a6360 100644 --- a/Maccy/Views/ko.lproj/PreviewItemView.strings +++ b/Maccy/Views/ko.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "{previewKey}를 눌러 미리보기를 숨깁니다."; "Dimensions" = "크기:"; "CopyExtractedText" = "추출된 텍스트를 복사합니다."; +"EditItem" = "항목 편집"; diff --git a/Maccy/Views/lt.lproj/PreviewItemView.strings b/Maccy/Views/lt.lproj/PreviewItemView.strings index 11fc2a3d..1dee1650 100644 --- a/Maccy/Views/lt.lproj/PreviewItemView.strings +++ b/Maccy/Views/lt.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "Paspauskite {previewKey}, kad paslėptumėte peržiūrą."; "Dimensions" = "Matmenys:"; "CopyExtractedText" = "Kopijuoti išgautą tekstą."; +"EditItem" = "Redaguoti elementą"; diff --git a/Maccy/Views/lv.lproj/PreviewItemView.strings b/Maccy/Views/lv.lproj/PreviewItemView.strings index 67dd53cc..a0081cf4 100644 --- a/Maccy/Views/lv.lproj/PreviewItemView.strings +++ b/Maccy/Views/lv.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "Nospiediet {previewKey}, lai paslēptu priekšskatījumu."; "Dimensions" = "Izmēri:"; "CopyExtractedText" = "Kopēt izgūto tekstu."; +"EditItem" = "Rediģēt vienumu"; diff --git a/Maccy/Views/nb.lproj/PreviewItemView.strings b/Maccy/Views/nb.lproj/PreviewItemView.strings index f036d969..664a000d 100644 --- a/Maccy/Views/nb.lproj/PreviewItemView.strings +++ b/Maccy/Views/nb.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "Trykk {previewKey} for å skjule forhåndsvisningen."; "Dimensions" = "Dimensjoner:"; "CopyExtractedText" = "Kopier uttrukket tekst."; +"EditItem" = "Rediger element"; diff --git a/Maccy/Views/nl.lproj/PreviewItemView.strings b/Maccy/Views/nl.lproj/PreviewItemView.strings index 623c3180..c33dd448 100644 --- a/Maccy/Views/nl.lproj/PreviewItemView.strings +++ b/Maccy/Views/nl.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "Druk op {previewKey} om het voorbeeld te verbergen."; "Dimensions" = "Afmetingen:"; "CopyExtractedText" = "Geëxtraheerde tekst kopiëren."; +"EditItem" = "Item bewerken"; diff --git a/Maccy/Views/pl.lproj/PreviewItemView.strings b/Maccy/Views/pl.lproj/PreviewItemView.strings index ab72a008..f2ee293f 100644 --- a/Maccy/Views/pl.lproj/PreviewItemView.strings +++ b/Maccy/Views/pl.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "Naciśnij {previewKey}, aby ukryć podgląd."; "Dimensions" = "Wymiary:"; "CopyExtractedText" = "Skopiuj wyodrębniony tekst."; +"EditItem" = "Edytuj element"; diff --git a/Maccy/Views/pt-BR.lproj/PreviewItemView.strings b/Maccy/Views/pt-BR.lproj/PreviewItemView.strings index e6eb9615..f52778d3 100644 --- a/Maccy/Views/pt-BR.lproj/PreviewItemView.strings +++ b/Maccy/Views/pt-BR.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "Pressione {previewKey} para ocultar a pré-visualização."; "Dimensions" = "Dimensões:"; "CopyExtractedText" = "Copiar texto extraído."; +"EditItem" = "Editar item"; diff --git a/Maccy/Views/pt.lproj/PreviewItemView.strings b/Maccy/Views/pt.lproj/PreviewItemView.strings index e8280314..0ee116b7 100644 --- a/Maccy/Views/pt.lproj/PreviewItemView.strings +++ b/Maccy/Views/pt.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "Pressione {previewKey} para ocultar a pré-visualização."; "Dimensions" = "Dimensões:"; "CopyExtractedText" = "Copiar texto extraído."; +"EditItem" = "Editar item"; diff --git a/Maccy/Views/ro.lproj/PreviewItemView.strings b/Maccy/Views/ro.lproj/PreviewItemView.strings index a3c9f3c2..4177cd90 100644 --- a/Maccy/Views/ro.lproj/PreviewItemView.strings +++ b/Maccy/Views/ro.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "Apasă {previewKey} pentru a ascunde previzualizarea."; "Dimensions" = "Dimensiuni:"; "CopyExtractedText" = "Copiază textul extras."; +"EditItem" = "Editează elementul"; diff --git a/Maccy/Views/ru.lproj/PreviewItemView.strings b/Maccy/Views/ru.lproj/PreviewItemView.strings index 6b895f05..4cdbab1e 100644 --- a/Maccy/Views/ru.lproj/PreviewItemView.strings +++ b/Maccy/Views/ru.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "Нажмите {previewKey}, чтобы скрыть предпросмотр."; "Dimensions" = "Размеры:"; "CopyExtractedText" = "Скопировать извлеченный текст."; +"EditItem" = "Редактировать элемент"; diff --git a/Maccy/Views/sl.lproj/PreviewItemView.strings b/Maccy/Views/sl.lproj/PreviewItemView.strings index 03bf96c3..7bcaf7a2 100644 --- a/Maccy/Views/sl.lproj/PreviewItemView.strings +++ b/Maccy/Views/sl.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "Pritisnite {previewKey} za skrivanje predogleda."; "Dimensions" = "Dimenzije:"; "CopyExtractedText" = "Kopiraj izvlečeno besedilo."; +"EditItem" = "Uredi element"; diff --git a/Maccy/Views/sv.lproj/PreviewItemView.strings b/Maccy/Views/sv.lproj/PreviewItemView.strings index 3a23ec7b..2f2b99b0 100644 --- a/Maccy/Views/sv.lproj/PreviewItemView.strings +++ b/Maccy/Views/sv.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "Tryck {previewKey} för att dölja förhandsvisningen."; "Dimensions" = "Mått:"; "CopyExtractedText" = "Kopiera extraherad text."; +"EditItem" = "Redigera objekt"; diff --git a/Maccy/Views/ta.lproj/PreviewItemView.strings b/Maccy/Views/ta.lproj/PreviewItemView.strings index 091b5b6b..3272a512 100644 --- a/Maccy/Views/ta.lproj/PreviewItemView.strings +++ b/Maccy/Views/ta.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "முன்னோட்டத்தை மறைக்க {previewKey} அழுத்தவும்."; "Dimensions" = "பரிமாணங்கள்:"; "CopyExtractedText" = "பிரித்தெடுக்கப்பட்ட உரையை நகலெடுக்கவும்."; +"EditItem" = "உருப்படியைத் திருத்து"; diff --git a/Maccy/Views/th.lproj/PreviewItemView.strings b/Maccy/Views/th.lproj/PreviewItemView.strings index f09a74f4..b7b30f70 100644 --- a/Maccy/Views/th.lproj/PreviewItemView.strings +++ b/Maccy/Views/th.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "กด {previewKey} เพื่อซ่อนตัวอย่าง"; "Dimensions" = "ขนาด:"; "CopyExtractedText" = "คัดลอกข้อความที่แยกออกมา"; +"EditItem" = "แก้ไขรายการ"; diff --git a/Maccy/Views/tr.lproj/PreviewItemView.strings b/Maccy/Views/tr.lproj/PreviewItemView.strings index 80837131..a6c5aee1 100644 --- a/Maccy/Views/tr.lproj/PreviewItemView.strings +++ b/Maccy/Views/tr.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "Önizlemeyi gizlemek için {previewKey} tuşuna basın."; "Dimensions" = "Boyutlar:"; "CopyExtractedText" = "Çıkarılan metni kopyala."; +"EditItem" = "Öğeyi düzenle"; diff --git a/Maccy/Views/uk.lproj/PreviewItemView.strings b/Maccy/Views/uk.lproj/PreviewItemView.strings index ed70d387..c35be226 100644 --- a/Maccy/Views/uk.lproj/PreviewItemView.strings +++ b/Maccy/Views/uk.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "Натисніть {previewKey}, щоб приховати попередній перегляд."; "Dimensions" = "Розміри:"; "CopyExtractedText" = "Скопіювати розпізнаний текст."; +"EditItem" = "Редагувати елемент"; diff --git a/Maccy/Views/uz.lproj/PreviewItemView.strings b/Maccy/Views/uz.lproj/PreviewItemView.strings index 119658fb..c49babdf 100644 --- a/Maccy/Views/uz.lproj/PreviewItemView.strings +++ b/Maccy/Views/uz.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "Oldindan ko‘rishni yashirish uchun {previewKey} tugmasini bosing."; "Dimensions" = "O'lchamlar:"; "CopyExtractedText" = "Ajratib olingan matnni nusxalash."; +"EditItem" = "Elementni tahrirlash"; diff --git a/Maccy/Views/vi.lproj/PreviewItemView.strings b/Maccy/Views/vi.lproj/PreviewItemView.strings index de43e3e3..6ec266ab 100644 --- a/Maccy/Views/vi.lproj/PreviewItemView.strings +++ b/Maccy/Views/vi.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "Nhấn {previewKey} để ẩn bản xem trước."; "Dimensions" = "Kích thước:"; "CopyExtractedText" = "Sao chép văn bản đã trích xuất."; +"EditItem" = "Chỉnh sửa mục"; diff --git a/Maccy/Views/zh-Hans.lproj/PreviewItemView.strings b/Maccy/Views/zh-Hans.lproj/PreviewItemView.strings index 3b6ebb5b..e8e71e93 100644 --- a/Maccy/Views/zh-Hans.lproj/PreviewItemView.strings +++ b/Maccy/Views/zh-Hans.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "按 {previewKey} 隐藏预览。"; "Dimensions" = "尺寸:"; "CopyExtractedText" = "复制提取的文本。"; +"EditItem" = "编辑项目"; diff --git a/Maccy/Views/zh-Hant.lproj/PreviewItemView.strings b/Maccy/Views/zh-Hant.lproj/PreviewItemView.strings index 38fe43e4..a574948a 100644 --- a/Maccy/Views/zh-Hant.lproj/PreviewItemView.strings +++ b/Maccy/Views/zh-Hant.lproj/PreviewItemView.strings @@ -9,3 +9,4 @@ "ClosePreview" = "按 {previewKey} 隱藏預覽。"; "Dimensions" = "尺寸:"; "CopyExtractedText" = "複製擷取的文字。"; +"EditItem" = "編輯項目"; From c81b41dc81ec6ac3c41cc62ac6f3f761d25d4bf0 Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:30:47 +0200 Subject: [PATCH 27/59] Use a larger square-and-pencil edit button --- Maccy/Views/ToolbarView.swift | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/Maccy/Views/ToolbarView.swift b/Maccy/Views/ToolbarView.swift index c8192c09..d05c7c19 100644 --- a/Maccy/Views/ToolbarView.swift +++ b/Maccy/Views/ToolbarView.swift @@ -152,11 +152,9 @@ struct ToolbarView: View { appState.isEditingItem = true editingItem = editableItem } label: { - if editItemEnabled { - Image(systemName: "pencil") - } else { - Image(systemName: "pencil.slash") - } + Image(systemName: "square.and.pencil") + .frame(width: 23, height: 23) + .contentShape(Rectangle()) } .shortcutKeyHelp(key: "EditItem", tableName: "PreviewItemView") .disabled(!editItemEnabled) From 9e6db5a6e9198bf5fa302876adfdfdfca84b1080 Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:41:48 +0200 Subject: [PATCH 28/59] Reuse the large text view for editing with native change tracking --- Maccy.xcodeproj/project.pbxproj | 4 + Maccy/Views/ItemEditorView.swift | 27 ++-- Maccy/Views/LargeTextView.swift | 198 ++++++++++++++++++++++++++++++ Maccy/Views/PreviewItemView.swift | 50 +------- 4 files changed, 215 insertions(+), 64 deletions(-) create mode 100644 Maccy/Views/LargeTextView.swift diff --git a/Maccy.xcodeproj/project.pbxproj b/Maccy.xcodeproj/project.pbxproj index 41b8b69f..d249b522 100644 --- a/Maccy.xcodeproj/project.pbxproj +++ b/Maccy.xcodeproj/project.pbxproj @@ -73,6 +73,7 @@ DA1969102C3F0AAC00258481 /* KeyShortcut.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA19690F2C3F0AAC00258481 /* KeyShortcut.swift */; }; DA1969122C3F0DD200258481 /* HistoryItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA1969112C3F0DD200258481 /* HistoryItemView.swift */; }; DA1969142C3F11D600258481 /* PreviewItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA1969132C3F11D600258481 /* PreviewItemView.swift */; }; + A9E31C79AD944C78BE9DF2F5 /* LargeTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBCCA6C3F9374C9C91414E3A /* LargeTextView.swift */; }; DA1969182C3F327500258481 /* SearchFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA1969172C3F327500258481 /* SearchFieldView.swift */; }; DA19691A2C3F369800258481 /* HeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA1969192C3F369800258481 /* HeaderView.swift */; }; DA19691C2C3F3EAC00258481 /* Collection+Surrounding.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA19691B2C3F3EAC00258481 /* Collection+Surrounding.swift */; }; @@ -300,6 +301,7 @@ DA19690F2C3F0AAC00258481 /* KeyShortcut.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyShortcut.swift; sourceTree = ""; }; DA1969112C3F0DD200258481 /* HistoryItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryItemView.swift; sourceTree = ""; }; DA1969132C3F11D600258481 /* PreviewItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewItemView.swift; sourceTree = ""; }; + BBCCA6C3F9374C9C91414E3A /* LargeTextView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeTextView.swift; sourceTree = ""; }; DA1969172C3F327500258481 /* SearchFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchFieldView.swift; sourceTree = ""; }; DA1969192C3F369800258481 /* HeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeaderView.swift; sourceTree = ""; }; DA19691B2C3F3EAC00258481 /* Collection+Surrounding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+Surrounding.swift"; sourceTree = ""; }; @@ -746,6 +748,7 @@ 2F0EC77A2E772899003E2EA9 /* PinsView.swift */, DA5E627E2C39E53F00F4C710 /* PreviewItemView.strings */, DA1969132C3F11D600258481 /* PreviewItemView.swift */, + BBCCA6C3F9374C9C91414E3A /* LargeTextView.swift */, DA1969172C3F327500258481 /* SearchFieldView.swift */, 2F578C402EFFE8920088B759 /* SlideoutContentView.swift */, 2F578C412EFFE8920088B759 /* SlideoutView.swift */, @@ -1139,6 +1142,7 @@ files = ( DA19691C2C3F3EAC00258481 /* Collection+Surrounding.swift in Sources */, DA1969142C3F11D600258481 /* PreviewItemView.swift in Sources */, + A9E31C79AD944C78BE9DF2F5 /* LargeTextView.swift in Sources */, 2FF2E9502E7808470093D72C /* AppImageView.swift in Sources */, DAA072D52C40AC52006DDFD2 /* HistoryListView.swift in Sources */, 2F578C422EFFE8920088B759 /* SlideoutContentView.swift in Sources */, diff --git a/Maccy/Views/ItemEditorView.swift b/Maccy/Views/ItemEditorView.swift index e41821b1..661dd236 100644 --- a/Maccy/Views/ItemEditorView.swift +++ b/Maccy/Views/ItemEditorView.swift @@ -6,10 +6,9 @@ struct ItemEditorView: View { @Environment(\.dismiss) private var dismiss let item: HistoryItemDecorator - private let originalContent: String @State private var editableTitle: String - @State private var editableContent: String + @State private var editableContent: LargeTextView.EditorState @State private var selectedPin: String? @State private var availablePins: [String] = [] @State private var isTextContent: Bool @@ -24,9 +23,8 @@ struct ItemEditorView: View { init(for item: HistoryItemDecorator) { self.item = item - self.originalContent = item.item.previewableText self._editableTitle = State(initialValue: item.item.title) - self._editableContent = State(initialValue: originalContent) + self._editableContent = State(initialValue: LargeTextView.EditorState(text: item.item.previewableText)) self._selectedPin = State(initialValue: item.item.pin) // Content can only be edited safely as plain text. @@ -77,7 +75,7 @@ struct ItemEditorView: View { contentEditor } - if isRichText && editableContent != originalContent { + if isRichText && editableContent.hasChanges { Label { Text("RichTextEditWarning", tableName: "PinsSettings") } icon: { @@ -129,11 +127,11 @@ struct ItemEditorView: View { private var contentEditor: some View { Group { if isTextContent || isRichText { - TextEditor(text: $editableContent) - .accessibilityLabel(Text("Content", tableName: "PinsSettings")) - .font(.body) - .scrollContentBackground(.hidden) - .background(Color.clear) + LargeTextView( + editorState: editableContent, + accessibilityLabel: NSLocalizedString("Content", tableName: "PinsSettings", comment: ""), + onCancel: { dismiss() } + ) .padding(.horizontal, 8) .padding(.vertical, 6) } else { @@ -176,7 +174,8 @@ struct ItemEditorView: View { } guard isTextContent || isRichText else { return } - guard editableContent != originalContent else { return } + guard editableContent.hasChanges, + let data = editableContent.text.data(using: .utf8) else { return } let historyItem = item.item let stringType = NSPasteboard.PasteboardType.string.rawValue @@ -185,10 +184,8 @@ struct ItemEditorView: View { if let index = historyItem.contents.firstIndex(where: { $0.type == stringType }) { - if let data = editableContent.data(using: .utf8) { - historyItem.contents[index].value = data - } - } else if let data = editableContent.data(using: .utf8) { + historyItem.contents[index].value = data + } else { historyItem.contents.append( HistoryItemContent(type: stringType, value: data) ) diff --git a/Maccy/Views/LargeTextView.swift b/Maccy/Views/LargeTextView.swift new file mode 100644 index 00000000..20bacd6b --- /dev/null +++ b/Maccy/Views/LargeTextView.swift @@ -0,0 +1,198 @@ +import AppKit +import Observation +import SwiftUI + +struct LargeTextView: NSViewRepresentable { + private let previewText: String? + private let editorState: EditorState? + private let isEditableOverwrite: Bool + private var accessibilityLabel: String? + private var onCancel: (() -> Void)? + + private var isEditable: Bool { isEditableOverwrite && editorState != nil } + + init(text: String, accessibilityLabel: String? = nil) { + self.isEditableOverwrite = false + self.previewText = text + self.editorState = nil + self.accessibilityLabel = accessibilityLabel + } + + init( + editorState: EditorState, + isEditable: Bool = true, + accessibilityLabel: String? = nil, + onCancel: (() -> Void)? = nil + ) { + self.isEditableOverwrite = isEditable + self.previewText = nil + self.editorState = editorState + self.accessibilityLabel = accessibilityLabel + self.onCancel = onCancel + } + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = Self.makeScrollView( + text: editorState?.text ?? previewText ?? "", + isEditable: isEditable, + accessibilityLabel: accessibilityLabel + ) + if let textView = scrollView.documentView as? NSTextView { + textView.delegate = context.coordinator + editorState?.textView = textView + } + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + let previousEditorState = context.coordinator.parent.editorState + context.coordinator.parent = self + guard let textView = scrollView.documentView as? NSTextView else { return } + + textView.isEditable = isEditable + textView.isSelectable = isEditable + textView.allowsUndo = isEditable + textView.setAccessibilityLabel(accessibilityLabel) + + if previousEditorState !== editorState { + previousEditorState?.detach() + textView.string = editorState?.text ?? previewText ?? "" + editorState?.textView = textView + } + + // The native text view owns editable content until it is saved. SwiftUI + // updates must not copy the text or replace its storage after each edit. + guard !isEditable else { return } + guard context.coordinator.lastPreviewText != previewText else { return } + context.coordinator.lastPreviewText = previewText + textView.string = previewText ?? "" + } + + static func dismantleNSView( + _ scrollView: NSScrollView, + coordinator: Coordinator + ) { + coordinator.parent.editorState?.detach() + (scrollView.documentView as? NSTextView)?.delegate = nil + } + + static func makeScrollView( + text: String, + isEditable: Bool = false, + accessibilityLabel: String? = nil + ) -> NSScrollView { + let textView = NSTextView(usingTextLayoutManager: true) + textView.isEditable = isEditable + textView.isSelectable = isEditable + textView.allowsUndo = isEditable + textView.setAccessibilityLabel(accessibilityLabel) + textView.isRichText = false + textView.drawsBackground = false + textView.font = NSFont.systemFont(ofSize: NSFont.systemFontSize) + textView.textColor = .labelColor + textView.textContainerInset = .zero + textView.minSize = .zero + textView.maxSize = NSSize( + width: CGFloat.greatestFiniteMagnitude, + height: CGFloat.greatestFiniteMagnitude + ) + textView.isVerticallyResizable = true + textView.isHorizontallyResizable = false + textView.autoresizingMask = [.width] + textView.textContainer?.lineFragmentPadding = 0 + textView.textContainer?.widthTracksTextView = true + textView.textContainer?.heightTracksTextView = false + textView.string = text + + let scrollView = NSScrollView() + scrollView.documentView = textView + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + scrollView.borderType = .noBorder + scrollView.drawsBackground = false + return scrollView + } + + @Observable + final class EditorState: NSObject { + private(set) var hasChanges = false + + @ObservationIgnored private var storedText: String + @ObservationIgnored fileprivate weak var textView: NSTextView? + fileprivate let undoManager = UndoManager() + + var text: String { textView?.string ?? storedText } + + init(text: String) { + self.storedText = text + super.init() + + for name in [ + Notification.Name.NSUndoManagerDidUndoChange, + .NSUndoManagerDidRedoChange, + ] { + NotificationCenter.default.addObserver( + self, + selector: #selector(undoHistoryDidChange(_:)), + name: name, + object: undoManager + ) + } + } + + isolated deinit { + NotificationCenter.default.removeObserver(self) + } + + fileprivate func markChanged() { + hasChanges = true + } + + fileprivate func detach() { + storedText = text + textView = nil + } + + @objc private func undoHistoryDidChange(_ notification: Notification) { + hasChanges = undoManager.canUndo + } + } + + final class Coordinator: NSObject, NSTextViewDelegate { + var parent: LargeTextView + var lastPreviewText: String? + + init(_ parent: LargeTextView) { + self.parent = parent + self.lastPreviewText = parent.previewText + } + + func textDidChange(_ notification: Notification) { + parent.editorState?.markChanged() + } + + func undoManager(for view: NSTextView) -> UndoManager? { + parent.editorState?.undoManager + } + + func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) + -> Bool + { + guard parent.isEditable, !textView.hasMarkedText() else { return false } + + if commandSelector == #selector(NSResponder.cancelOperation(_:)), + let onCancel = parent.onCancel + { + onCancel() + return true + } + + return false + } + } +} diff --git a/Maccy/Views/PreviewItemView.swift b/Maccy/Views/PreviewItemView.swift index 2c1ddd4a..437153f1 100644 --- a/Maccy/Views/PreviewItemView.swift +++ b/Maccy/Views/PreviewItemView.swift @@ -55,7 +55,7 @@ struct PreviewItemView: View { } else { let text = item.previewText if text.count >= Self.largeTextThreshold { - LargeTextPreviewView(text: text) + LargeTextView(text: text) .id("textpreview-\(item.id)") } else { ScrollView { @@ -110,51 +110,3 @@ struct PreviewItemView: View { .controlSize(.small) } } - -struct LargeTextPreviewView: NSViewRepresentable { - let text: String - - func makeNSView(context: Context) -> NSScrollView { - return Self.makeScrollView(text: text) - } - - func updateNSView(_ scrollView: NSScrollView, context: Context) { - guard let textView = scrollView.documentView as? NSTextView, textView.string != text else { - return - } - - textView.string = text - } - - static func makeScrollView(text: String) -> NSScrollView { - let textView = NSTextView(usingTextLayoutManager: true) - textView.isEditable = false - textView.isSelectable = false - textView.isRichText = false - textView.drawsBackground = false - textView.font = NSFont.systemFont(ofSize: NSFont.systemFontSize) - textView.textColor = .labelColor - textView.textContainerInset = .zero - textView.minSize = .zero - textView.maxSize = NSSize( - width: CGFloat.greatestFiniteMagnitude, - height: CGFloat.greatestFiniteMagnitude - ) - textView.isVerticallyResizable = true - textView.isHorizontallyResizable = false - textView.autoresizingMask = [.width] - textView.textContainer?.lineFragmentPadding = 0 - textView.textContainer?.widthTracksTextView = true - textView.textContainer?.heightTracksTextView = false - textView.string = text - - let scrollView = NSScrollView() - scrollView.documentView = textView - scrollView.hasVerticalScroller = true - scrollView.hasHorizontalScroller = false - scrollView.autohidesScrollers = true - scrollView.borderType = .noBorder - scrollView.drawsBackground = false - return scrollView - } -} From fe408a6be5d1c1b4e84627619c30837d773a20d0 Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:38:45 +0200 Subject: [PATCH 29/59] Avoid layout shift when editing --- Maccy/Views/ItemEditorView.swift | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/Maccy/Views/ItemEditorView.swift b/Maccy/Views/ItemEditorView.swift index 661dd236..415f9558 100644 --- a/Maccy/Views/ItemEditorView.swift +++ b/Maccy/Views/ItemEditorView.swift @@ -69,22 +69,23 @@ struct ItemEditorView: View { } VStack(alignment: .leading, spacing: 8) { - Text("Content", tableName: "PinsSettings") - .font(.headline) + HStack(spacing: 8) { + Text("Content", tableName: "PinsSettings") + .font(.headline) + + if isRichText && editableContent.hasChanges { + Label { + Text("RichTextEditWarning", tableName: "PinsSettings") + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + } + } + } contentEditor } - if isRichText && editableContent.hasChanges { - Label { - Text("RichTextEditWarning", tableName: "PinsSettings") - } icon: { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) - } - .font(.footnote) - } - HStack { Spacer() Button(Self.cancelButtonTitle, role: .cancel) { From 69ef58d5d36d06c76220ac43a6a59d45bd067d38 Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:56:50 +0200 Subject: [PATCH 30/59] =?UTF-8?q?Don=E2=80=99t=20create=20preview=20view?= =?UTF-8?q?=20if=20slidout=20is=20not=20open?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avoid potential expensive view creation if the view is not visible. --- Maccy/Views/SlideoutView.swift | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/Maccy/Views/SlideoutView.swift b/Maccy/Views/SlideoutView.swift index 2ce969f4..2f0db1d5 100644 --- a/Maccy/Views/SlideoutView.swift +++ b/Maccy/Views/SlideoutView.swift @@ -112,7 +112,13 @@ where Content: View, Slideout: View { resizeDivider() VStack(spacing: 0) { - slideout() + Group { + if controller.state != .closed { + slideout() + } else { + EmptyView() + } + } .frame( minWidth: controller.minimumSlideoutWidth, idealWidth: !isSlideoutResizing ? controller.slideoutWidth.rounded() : nil, @@ -130,9 +136,14 @@ where Content: View, Slideout: View { horizontal: isAnimating || isContentResizing, vertical: false ) + .transaction { transaction in + transaction.animation = nil + } .frame( minWidth: controller.state != .open ? 0 : nil, - maxWidth: controller.state == .closed ? 0 : nil + maxWidth: controller.state == .closed ? 0 : nil, + maxHeight: .infinity, + alignment: .top ) .clipped() // Prevent closed slideout from blocking hover events on content items (#1484) From 554ed2a695b7fe6ffbb9b72c4b4404592a41aaa5 Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:03:45 +0200 Subject: [PATCH 31/59] Cache text previews and cap paragraph length --- Maccy.xcodeproj/project.pbxproj | 4 ++ Maccy/Extensions/String+Shortened.swift | 52 ++++++++++++++++++- Maccy/Models/SizedString.swift | 20 +++++++ Maccy/Observables/HistoryItemDecorator.swift | 19 +++++-- Maccy/Settings/ar.lproj/PinsSettings.strings | 1 + Maccy/Settings/be.lproj/PinsSettings.strings | 1 + Maccy/Settings/bn.lproj/PinsSettings.strings | 1 + Maccy/Settings/bs.lproj/PinsSettings.strings | 1 + Maccy/Settings/ca.lproj/PinsSettings.strings | 1 + Maccy/Settings/ckb.lproj/PinsSettings.strings | 1 + Maccy/Settings/cs.lproj/PinsSettings.strings | 1 + Maccy/Settings/de.lproj/PinsSettings.strings | 1 + Maccy/Settings/el.lproj/PinsSettings.strings | 1 + Maccy/Settings/en.lproj/PinsSettings.strings | 1 + Maccy/Settings/eo.lproj/PinsSettings.strings | 1 + Maccy/Settings/es.lproj/PinsSettings.strings | 1 + Maccy/Settings/fa.lproj/PinsSettings.strings | 1 + Maccy/Settings/fi.lproj/PinsSettings.strings | 1 + Maccy/Settings/fr.lproj/PinsSettings.strings | 1 + Maccy/Settings/he.lproj/PinsSettings.strings | 1 + Maccy/Settings/hi.lproj/PinsSettings.strings | 1 + Maccy/Settings/hr.lproj/PinsSettings.strings | 1 + Maccy/Settings/hu.lproj/PinsSettings.strings | 1 + Maccy/Settings/id.lproj/PinsSettings.strings | 1 + Maccy/Settings/it.lproj/PinsSettings.strings | 1 + Maccy/Settings/ja.lproj/PinsSettings.strings | 1 + Maccy/Settings/km.lproj/PinsSettings.strings | 1 + Maccy/Settings/ko.lproj/PinsSettings.strings | 1 + Maccy/Settings/lt.lproj/PinsSettings.strings | 1 + Maccy/Settings/lv.lproj/PinsSettings.strings | 1 + Maccy/Settings/nb.lproj/PinsSettings.strings | 1 + Maccy/Settings/nl.lproj/PinsSettings.strings | 1 + Maccy/Settings/pl.lproj/PinsSettings.strings | 1 + .../Settings/pt-BR.lproj/PinsSettings.strings | 1 + Maccy/Settings/pt.lproj/PinsSettings.strings | 1 + Maccy/Settings/ro.lproj/PinsSettings.strings | 1 + Maccy/Settings/ru.lproj/PinsSettings.strings | 1 + Maccy/Settings/sl.lproj/PinsSettings.strings | 1 + Maccy/Settings/sv.lproj/PinsSettings.strings | 1 + Maccy/Settings/ta.lproj/PinsSettings.strings | 1 + Maccy/Settings/th.lproj/PinsSettings.strings | 1 + Maccy/Settings/tr.lproj/PinsSettings.strings | 1 + Maccy/Settings/uk.lproj/PinsSettings.strings | 1 + Maccy/Settings/uz.lproj/PinsSettings.strings | 1 + Maccy/Settings/vi.lproj/PinsSettings.strings | 1 + .../zh-Hans.lproj/PinsSettings.strings | 1 + .../zh-Hant.lproj/PinsSettings.strings | 1 + Maccy/Views/ItemEditorView.swift | 25 +++++++-- Maccy/Views/LargeTextView.swift | 11 ++-- Maccy/Views/PreviewItemView.swift | 9 ++-- 50 files changed, 165 insertions(+), 18 deletions(-) create mode 100644 Maccy/Models/SizedString.swift diff --git a/Maccy.xcodeproj/project.pbxproj b/Maccy.xcodeproj/project.pbxproj index d249b522..234d926a 100644 --- a/Maccy.xcodeproj/project.pbxproj +++ b/Maccy.xcodeproj/project.pbxproj @@ -55,6 +55,7 @@ DA05B5112C234CB2006980FE /* MaccyApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA05B5102C234CB1006980FE /* MaccyApp.swift */; }; DA05B5142C234DCF006980FE /* HistoryItemContent.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA05B5122C234DCF006980FE /* HistoryItemContent.swift */; }; DA05B5152C234DCF006980FE /* HistoryItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA05B5132C234DCF006980FE /* HistoryItem.swift */; }; + C5B760C9035C4B3594C47811 /* SizedString.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFC13FFCB59043F2883A4437 /* SizedString.swift */; }; DA083A6E2C42E8E8004259A0 /* NSImage+Resized.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA083A6D2C42E8E8004259A0 /* NSImage+Resized.swift */; }; DA0EE7B9204657830025FC60 /* MaccyUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0EE7B8204657830025FC60 /* MaccyUITests.swift */; }; DA0EF1881E444B2A00E58577 /* Clipboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0EF1871E444B2A00E58577 /* Clipboard.swift */; }; @@ -267,6 +268,7 @@ DA05B5102C234CB1006980FE /* MaccyApp.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MaccyApp.swift; sourceTree = ""; }; DA05B5122C234DCF006980FE /* HistoryItemContent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryItemContent.swift; sourceTree = ""; }; DA05B5132C234DCF006980FE /* HistoryItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HistoryItem.swift; sourceTree = ""; }; + DFC13FFCB59043F2883A4437 /* SizedString.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SizedString.swift; sourceTree = ""; }; DA083A6D2C42E8E8004259A0 /* NSImage+Resized.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSImage+Resized.swift"; sourceTree = ""; }; DA08FDE7282596FA0001F3DA /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/Localizable.strings"; sourceTree = ""; }; DA0D57762E4246B3005101AF /* ro */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ro; path = ro.lproj/AdvancedSettings.strings; sourceTree = ""; }; @@ -686,6 +688,7 @@ children = ( DA05B5132C234DCF006980FE /* HistoryItem.swift */, DA05B5122C234DCF006980FE /* HistoryItemContent.swift */, + DFC13FFCB59043F2883A4437 /* SizedString.swift */, ); path = Models; sourceTree = ""; @@ -1207,6 +1210,7 @@ DA05B5142C234DCF006980FE /* HistoryItemContent.swift in Sources */, DAA072D32C40A961006DDFD2 /* Color+Random.swift in Sources */, DA05B5152C234DCF006980FE /* HistoryItem.swift in Sources */, + C5B760C9035C4B3594C47811 /* SizedString.swift in Sources */, DAA072E32C41D1D5006DDFD2 /* ListItemView.swift in Sources */, DAA072D92C41C5AD006DDFD2 /* Footer.swift in Sources */, DA696BCE240177E800DE80CF /* Sorter.swift in Sources */, diff --git a/Maccy/Extensions/String+Shortened.swift b/Maccy/Extensions/String+Shortened.swift index 4c3b4cc4..0ed15f6c 100644 --- a/Maccy/Extensions/String+Shortened.swift +++ b/Maccy/Extensions/String+Shortened.swift @@ -1,9 +1,57 @@ extension String { func shortened(to maxLength: Int) -> String { - guard count > maxLength else { + guard let end = index(startIndex, offsetBy: maxLength, limitedBy: endIndex), end != endIndex else { return self } - return String(self[startIndex.. SubSequence { + let limit = Swift.max(0, maxParagraphBytes) + var cursor = startIndex + var paragraphBytes = 0 + + while cursor != endIndex { + if let lineBreakEnd = indexAfterLineBreak(at: cursor) { + cursor = lineBreakEnd + paragraphBytes = 0 + } else { + if paragraphBytes == limit { + var end = cursor + // Avoid splitting a Unicode scalar by stepping back over continuation bytes. + while self[end] & 0xC0 == 0x80 { + end = index(before: end) + } + return self[startIndex.. Index? { + let next = index(after: start) + switch self[start] { + case 0x0A, 0x0B, 0x0C: // LF, vertical tab, form feed + return next + case 0x0D: // CR, including CRLF as one line break + return next != endIndex && self[next] == 0x0A ? index(after: next) : next + case 0xC2: // U+0085 NEXT LINE + guard next != endIndex, self[next] == 0x85 else { return nil } + return index(after: next) + case 0xE2: // U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR + guard next != endIndex, self[next] == 0x80 else { return nil } + let last = index(after: next) + guard last != endIndex, self[last] == 0xA8 || self[last] == 0xA9 else { return nil } + return index(after: last) + default: + return nil + } } } diff --git a/Maccy/Models/SizedString.swift b/Maccy/Models/SizedString.swift new file mode 100644 index 00000000..50d44b54 --- /dev/null +++ b/Maccy/Models/SizedString.swift @@ -0,0 +1,20 @@ +import Foundation + +nonisolated struct SizedString: Sendable { + let string: String + let byteCount: Int + let isTruncated: Bool + + init(_ string: String, maxParagraphBytes: Int = .max) { + let bytes = string.utf8 + let shortenedBytes = bytes.shortened(maxParagraphBytes: maxParagraphBytes) + self.isTruncated = shortenedBytes.endIndex != bytes.endIndex + self.byteCount = shortenedBytes.count + + if isTruncated { + self.string = String(bytes: shortenedBytes, encoding: .utf8) ?? "" + } else { + self.string = string + } + } +} diff --git a/Maccy/Observables/HistoryItemDecorator.swift b/Maccy/Observables/HistoryItemDecorator.swift index 89f017c6..6567e14b 100644 --- a/Maccy/Observables/HistoryItemDecorator.swift +++ b/Maccy/Observables/HistoryItemDecorator.swift @@ -6,6 +6,8 @@ import Sauce @Observable class HistoryItemDecorator: Identifiable, Hashable, HasVisibility { + private static let previewMaxParagraphSize = 10_000 + static func == (lhs: HistoryItemDecorator, rhs: HistoryItemDecorator) -> Bool { return lhs.id == rhs.id } @@ -47,14 +49,12 @@ class HistoryItemDecorator: Identifiable, Hashable, HasVisibility { var previewImageGenerationTask: Task<(), Error>? var thumbnailImageGenerationTask: Task<(), Error>? var previewImage: NSImage? - var previewText: String { - item.previewableText - } + private(set) var previewText = SizedString("") var thumbnailImage: NSImage? var applicationImage: ApplicationImage // 10k characters seems to be more than enough on large displays - var text: String { previewText.shortened(to: 10_000) } + var text: String { previewText.string.shortened(to: 10_000) } var isPinned: Bool { item.pin != nil } var isUnpinned: Bool { item.pin == nil } @@ -104,6 +104,7 @@ class HistoryItemDecorator: Identifiable, Hashable, HasVisibility { synchronizeItemPin() synchronizeItemTitle() + synchronizeItemText() } @MainActor @@ -235,4 +236,14 @@ class HistoryItemDecorator: Identifiable, Hashable, HasVisibility { } } } + + private func synchronizeItemText() { + previewText = withObservationTracking { + SizedString(item.previewableText, maxParagraphBytes: Self.previewMaxParagraphSize) + } onChange: { [weak self] in + DispatchQueue.main.async { [weak self] in + self?.synchronizeItemText() + } + } + } } diff --git a/Maccy/Settings/ar.lproj/PinsSettings.strings b/Maccy/Settings/ar.lproj/PinsSettings.strings index 1a0ede6c..b7a8c630 100644 --- a/Maccy/Settings/ar.lproj/PinsSettings.strings +++ b/Maccy/Settings/ar.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = "العنوان"; "Content" = "المحتوى"; "ContentIsNotText" = "محتوى غير قابل للتحرير (صورة أو ملف)"; +"ParagraphsTooLongToEdit" = "يحتوي النص على فقرات طويلة جدًا بحيث لا يمكن تحريرها."; "RichTextEditWarning" = "سيؤدي التحرير إلى تجاهل جميع التنسيقات."; "PinCustomizationDescription" = "يمكنك تخصيص مفتاح الاختصار، والعنوان، ومحتوى أي عنصر مثبت. للتعديل، انقر نقرًا مزدوجًا فوق العنصر وأدخل قيمة جديدة.\nيرجى ملاحظة أنه يمكن تغيير النص العادي فقط."; diff --git a/Maccy/Settings/be.lproj/PinsSettings.strings b/Maccy/Settings/be.lproj/PinsSettings.strings index 2446efd7..19ba86b5 100644 --- a/Maccy/Settings/be.lproj/PinsSettings.strings +++ b/Maccy/Settings/be.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = "Назва"; "Content" = "Кантэнт"; "ContentIsNotText" = "Нерэдагавальны кантэнт (малюнак або файл)"; +"ParagraphsTooLongToEdit" = "Тэкст змяшчае абзацы, занадта доўгія для рэдагавання."; "RichTextEditWarning" = "Рэдагаванне адменіць усё фарматаванне."; "PinCustomizationDescription" = "Вы можаце наладзіць гарачую клавішу, назву і змест любога замацаванага элемента. Каб рэдагаваць, двойчы пстрыкніце па элеменце і ўвядзіце новае значэнне.\nЗвярніце ўвагу, што змяніць можна толькі звычайны тэкст."; diff --git a/Maccy/Settings/bn.lproj/PinsSettings.strings b/Maccy/Settings/bn.lproj/PinsSettings.strings index b286068a..d3437c8e 100644 --- a/Maccy/Settings/bn.lproj/PinsSettings.strings +++ b/Maccy/Settings/bn.lproj/PinsSettings.strings @@ -4,4 +4,5 @@ "Title" = "আলপিনগুলো"; "Content" = ""; "ContentIsNotText" = ""; +"ParagraphsTooLongToEdit" = "পাঠ্যে এমন অনুচ্ছেদ রয়েছে যা সম্পাদনার জন্য খুব দীর্ঘ।"; "RichTextEditWarning" = ""; diff --git a/Maccy/Settings/bs.lproj/PinsSettings.strings b/Maccy/Settings/bs.lproj/PinsSettings.strings index 94927f15..950c03eb 100644 --- a/Maccy/Settings/bs.lproj/PinsSettings.strings +++ b/Maccy/Settings/bs.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = "Naziv"; "Content" = "Sadržaj"; "ContentIsNotText" = "Sadržaj koji se ne može uređivati (slika ili datoteka)"; +"ParagraphsTooLongToEdit" = "Tekst sadrži odlomke koji su predugi za uređivanje."; "RichTextEditWarning" = "Uređivanjem će se odbaciti svo formatiranje."; "PinCustomizationDescription" = "Možete prilagoditi prečicu, naslov i sadržaj bilo koje prikvačene stavke. Za uređivanje, dvaput kliknite na stavku i unesite novu vrijednost.\nImajte na umu da se može mijenjati samo običan tekst."; diff --git a/Maccy/Settings/ca.lproj/PinsSettings.strings b/Maccy/Settings/ca.lproj/PinsSettings.strings index 9ffe5857..ff5dcfec 100644 --- a/Maccy/Settings/ca.lproj/PinsSettings.strings +++ b/Maccy/Settings/ca.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = ""; "Content" = ""; "ContentIsNotText" = ""; +"ParagraphsTooLongToEdit" = "El text conté paràgrafs massa llargs per editar-los."; "RichTextEditWarning" = ""; "PinCustomizationDescription" = ""; diff --git a/Maccy/Settings/ckb.lproj/PinsSettings.strings b/Maccy/Settings/ckb.lproj/PinsSettings.strings index 11bafb47..4c2cff90 100644 --- a/Maccy/Settings/ckb.lproj/PinsSettings.strings +++ b/Maccy/Settings/ckb.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = "ناونیشان"; "Content" = "ناوەڕۆک"; "ContentIsNotText" = "ناوەڕۆکی دەستکارینەکراو (وێنە یان فایل)"; +"ParagraphsTooLongToEdit" = "دەقەکە پەڕەگرافی تێدایە کە زۆر درێژن بۆ دەستکاریکردن."; "RichTextEditWarning" = "دەستکاریکردن هەموو فۆرماتەکە لادەبات."; "PinCustomizationDescription" = "تۆ دەتوانیت کورتەدوگمە، ناونیشان، و ناوەڕۆکی هەر ئایتمێکی چەسپێنراو بەدڵی خۆت بگۆڕیت. بۆ دەستکاریکردن، دوو-کلیک لەسەر ئایتمەکە بکە و بەهایەکی نوێ داغڵ بکە.\nتکایە ئاگاداربە کە تەنها دەقی سادە دەتوانرێت بگۆڕدرێت."; diff --git a/Maccy/Settings/cs.lproj/PinsSettings.strings b/Maccy/Settings/cs.lproj/PinsSettings.strings index 48ae62d2..1bfac03a 100644 --- a/Maccy/Settings/cs.lproj/PinsSettings.strings +++ b/Maccy/Settings/cs.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = "Titul"; "Content" = "Obsah"; "ContentIsNotText" = "Neupravitelný obsah (obrázek nebo soubor)"; +"ParagraphsTooLongToEdit" = "Text obsahuje odstavce, které jsou příliš dlouhé pro úpravy."; "RichTextEditWarning" = "Při úpravách se zruší veškeré formátování."; "PinCustomizationDescription" = "Můžete si přizpůsobit klávesovou zkratku, název a obsah každé připnuté položky. Chcete-li položku upravit, dvakrát na ni klikněte a zadejte novou hodnotu.\nVezměte prosím na vědomí, že měnit lze pouze prostý text."; diff --git a/Maccy/Settings/de.lproj/PinsSettings.strings b/Maccy/Settings/de.lproj/PinsSettings.strings index a6bd7cc3..8fb17690 100644 --- a/Maccy/Settings/de.lproj/PinsSettings.strings +++ b/Maccy/Settings/de.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = "Titel"; "Content" = "Inhalt"; "ContentIsNotText" = "Nicht editierbarer Inhalt (Bild oder Datei)"; +"ParagraphsTooLongToEdit" = "Der Text enthält Absätze, die zu lang zum Bearbeiten sind."; "RichTextEditWarning" = "Bei der Bearbeitung werden alle Formatierungen verworfen."; "PinCustomizationDescription" = "Sie können den Hotkey, den Titel und den Inhalt jedes angehefteten Elements anpassen. Zum Bearbeiten doppelklicken Sie auf das Element und geben einen neuen Wert ein.\nBitte beachten Sie, dass nur einfacher Text geändert werden kann."; diff --git a/Maccy/Settings/el.lproj/PinsSettings.strings b/Maccy/Settings/el.lproj/PinsSettings.strings index 9ffe5857..af94f329 100644 --- a/Maccy/Settings/el.lproj/PinsSettings.strings +++ b/Maccy/Settings/el.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = ""; "Content" = ""; "ContentIsNotText" = ""; +"ParagraphsTooLongToEdit" = "Το κείμενο περιέχει παραγράφους που είναι πολύ μεγάλες για επεξεργασία."; "RichTextEditWarning" = ""; "PinCustomizationDescription" = ""; diff --git a/Maccy/Settings/en.lproj/PinsSettings.strings b/Maccy/Settings/en.lproj/PinsSettings.strings index 37541826..232129ad 100644 --- a/Maccy/Settings/en.lproj/PinsSettings.strings +++ b/Maccy/Settings/en.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = "Title"; "Content" = "Content"; "ContentIsNotText" = "Non-editable content (image or file)"; +"ParagraphsTooLongToEdit" = "Text contains paragraphs that are too long to edit."; "RichTextEditWarning" = "Editing will discard all formatting."; "PinCustomizationDescription" = "You can customize the hotkey, title, and content of any pinned item. To edit, double-click the item and enter a new value.\nPlease note that only plain text can be changed."; diff --git a/Maccy/Settings/eo.lproj/PinsSettings.strings b/Maccy/Settings/eo.lproj/PinsSettings.strings index 986a1054..8da1a02d 100644 --- a/Maccy/Settings/eo.lproj/PinsSettings.strings +++ b/Maccy/Settings/eo.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = "Titolo"; "Content" = "Enhavo"; "ContentIsNotText" = "Ne-redaktebla enhavo (bildo aŭ dosiero)"; +"ParagraphsTooLongToEdit" = "La teksto enhavas alineojn tro longajn por redakti."; "RichTextEditWarning" = "Redaktado forigos ĉian tekstaranĝon."; "PinCustomizationDescription" = ""; diff --git a/Maccy/Settings/es.lproj/PinsSettings.strings b/Maccy/Settings/es.lproj/PinsSettings.strings index 881b8250..9bb5789e 100644 --- a/Maccy/Settings/es.lproj/PinsSettings.strings +++ b/Maccy/Settings/es.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = "Título"; "Content" = "Contenido"; "ContentIsNotText" = "Contenido no editable (imagen o archivo)"; +"ParagraphsTooLongToEdit" = "El texto contiene párrafos demasiado largos para editarlos."; "RichTextEditWarning" = "La edición descartará todo el formato."; "PinCustomizationDescription" = "Puedes personalizar la tecla de acceso rápido, el título y el contenido de cualquier elemento anclado.\nPara editarlo, haga doble clic en el elemento e introduzca un nuevo valor."; diff --git a/Maccy/Settings/fa.lproj/PinsSettings.strings b/Maccy/Settings/fa.lproj/PinsSettings.strings index b54c1773..51123e1d 100644 --- a/Maccy/Settings/fa.lproj/PinsSettings.strings +++ b/Maccy/Settings/fa.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = "عنوان"; "Content" = "محتوا"; "ContentIsNotText" = "محتوای غیرقابل ویرایش(عکس یا فایل)"; +"ParagraphsTooLongToEdit" = "متن دارای پاراگراف‌هایی است که برای ویرایش بیش از حد طولانی هستند."; "RichTextEditWarning" = "ویرایش باعث دور انداختن همه فرمت‌ها می‌شود."; "PinCustomizationDescription" = "شما می‌توانید کلید میانبر، اسم، و محتوای هر آیتم پین شده را تغییر دهید. برای ویرایش،‌ دوبار روی آیتم کلیک کرده و مقدار جدید را وارد کنید.\nتوجه داشته باشید که فقط متن قابل تغییر است."; diff --git a/Maccy/Settings/fi.lproj/PinsSettings.strings b/Maccy/Settings/fi.lproj/PinsSettings.strings index f784b6b2..84f18dc2 100644 --- a/Maccy/Settings/fi.lproj/PinsSettings.strings +++ b/Maccy/Settings/fi.lproj/PinsSettings.strings @@ -1,6 +1,7 @@ "Key" = ""; "Content" = ""; "ContentIsNotText" = ""; +"ParagraphsTooLongToEdit" = "Teksti sisältää kappaleita, jotka ovat liian pitkiä muokattaviksi."; "RichTextEditWarning" = ""; "Title" = ""; "Alias" = ""; diff --git a/Maccy/Settings/fr.lproj/PinsSettings.strings b/Maccy/Settings/fr.lproj/PinsSettings.strings index d8c8dbbd..b62073a7 100644 --- a/Maccy/Settings/fr.lproj/PinsSettings.strings +++ b/Maccy/Settings/fr.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = "Titre"; "Content" = "Contenu"; "ContentIsNotText" = "Contenu non modifiable (image ou fichier)"; +"ParagraphsTooLongToEdit" = "Le texte contient des paragraphes trop longs pour être modifiés."; "RichTextEditWarning" = "L'édition ne tient pas compte de la mise en forme."; "PinCustomizationDescription" = "Vous pouvez personnaliser le raccourci clavier, le titre et le contenu de tout élément épinglé.\nPour les modifier, double-cliquez sur l'élément et entrez une nouvelle valeur. Veuillez noter que seul le texte en clair peut être modifié."; diff --git a/Maccy/Settings/he.lproj/PinsSettings.strings b/Maccy/Settings/he.lproj/PinsSettings.strings index 045d47c0..02b2af37 100644 --- a/Maccy/Settings/he.lproj/PinsSettings.strings +++ b/Maccy/Settings/he.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = "כותרת"; "Content" = "תוכן"; "ContentIsNotText" = "תוכן שאינו ניתן לעריכה (תמונה או קובץ)"; +"ParagraphsTooLongToEdit" = "הטקסט מכיל פסקאות ארוכות מדי לעריכה."; "RichTextEditWarning" = "העריכה תבטל את כל העיצוב."; "PinCustomizationDescription" = "ניתן להתאים אישית את מקש הקיצור, הכותרת והתוכן של כל פריט מוצמד. כדי לערוך, לחץ פעמיים על הפריט והזן ערך חדש.\nשימו לב שניתן לשנות רק טקסט רגיל."; diff --git a/Maccy/Settings/hi.lproj/PinsSettings.strings b/Maccy/Settings/hi.lproj/PinsSettings.strings index 9ffe5857..fbba554a 100644 --- a/Maccy/Settings/hi.lproj/PinsSettings.strings +++ b/Maccy/Settings/hi.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = ""; "Content" = ""; "ContentIsNotText" = ""; +"ParagraphsTooLongToEdit" = "पाठ में ऐसे अनुच्छेद हैं जो संपादित करने के लिए बहुत लंबे हैं।"; "RichTextEditWarning" = ""; "PinCustomizationDescription" = ""; diff --git a/Maccy/Settings/hr.lproj/PinsSettings.strings b/Maccy/Settings/hr.lproj/PinsSettings.strings index aa10002d..a9437fa3 100644 --- a/Maccy/Settings/hr.lproj/PinsSettings.strings +++ b/Maccy/Settings/hr.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = "Naslov"; "Content" = "Sadržaj"; "ContentIsNotText" = "Sadržaj koji se ne može uređivati (slika ili datoteka)"; +"ParagraphsTooLongToEdit" = "Tekst sadrži odlomke koji su predugi za uređivanje."; "RichTextEditWarning" = "Uređivanjem će se odbaciti svo formatiranje."; "PinCustomizationDescription" = "Možete prilagoditi prečac, naslov i sadržaj bilo koje prikvačene stavke. Za uređivanje dvaput kliknite stavku i unesite novu vrijednost.\nImajte na umu da se može promijeniti samo običan tekst."; diff --git a/Maccy/Settings/hu.lproj/PinsSettings.strings b/Maccy/Settings/hu.lproj/PinsSettings.strings index b16bad06..8911e4c4 100644 --- a/Maccy/Settings/hu.lproj/PinsSettings.strings +++ b/Maccy/Settings/hu.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = "Cím"; "Content" = "Tartalom"; "ContentIsNotText" = "Nem szerkeszthető tartalom (kép vagy fájl)"; +"ParagraphsTooLongToEdit" = "A szöveg a szerkesztéshez túl hosszú bekezdéseket tartalmaz."; "RichTextEditWarning" = "A szerkesztés elveti az összes formázást."; "PinCustomizationDescription" = "Testre szabhatja bármelyik kitűzött elem gyorsbillentyűjét, címét és tartalmát. A szerkesztéshez kattintson duplán az elemre, és írja be az új értéket.\nFigyeljen arra, hogy csak egyszerű szöveg módosítható."; diff --git a/Maccy/Settings/id.lproj/PinsSettings.strings b/Maccy/Settings/id.lproj/PinsSettings.strings index eca4564d..13934319 100644 --- a/Maccy/Settings/id.lproj/PinsSettings.strings +++ b/Maccy/Settings/id.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = "Judul"; "Content" = "Isi"; "ContentIsNotText" = "Konten yang tidak dapat disunting (gambar atau berkas)"; +"ParagraphsTooLongToEdit" = "Teks berisi paragraf yang terlalu panjang untuk disunting."; "RichTextEditWarning" = "Menyunting akan menghapus semua format."; "PinCustomizationDescription" = "Anda dapat menyesuaikan tombol pintas, judul, dan isi dari setiap item yang disematkan. Untuk menyunting, klik ganda item tersebut dan masukkan nilai baru.\nHarap diperhatikan bahwa hanya teks biasa yang dapat diubah."; diff --git a/Maccy/Settings/it.lproj/PinsSettings.strings b/Maccy/Settings/it.lproj/PinsSettings.strings index 4fa607a5..b732d9fa 100644 --- a/Maccy/Settings/it.lproj/PinsSettings.strings +++ b/Maccy/Settings/it.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = "Titolo"; "Content" = "Contenuto"; "ContentIsNotText" = "Contenuto non modificabile (immagine o file)"; +"ParagraphsTooLongToEdit" = "Il testo contiene paragrafi troppo lunghi per essere modificati."; "RichTextEditWarning" = "La modifica elimina tutta la formattazione."; "PinCustomizationDescription" = "È possibile personalizzare il tasto di scelta rapida, il titolo e il contenuto di qualsiasi elemento appuntato.\nPer modificarlo, fare doppio clic sull'elemento e inserire un nuovo valore."; diff --git a/Maccy/Settings/ja.lproj/PinsSettings.strings b/Maccy/Settings/ja.lproj/PinsSettings.strings index 0662a9f8..923973df 100644 --- a/Maccy/Settings/ja.lproj/PinsSettings.strings +++ b/Maccy/Settings/ja.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = "タイトル"; "Content" = "内容"; "ContentIsNotText" = "編集不可能なコンテンツ(画像またはファイル)"; +"ParagraphsTooLongToEdit" = "テキストに編集できないほど長い段落が含まれています。"; "RichTextEditWarning" = "編集すると、すべてのフォーマットが破棄されます。"; "PinCustomizationDescription" = "ピン留めした項目のホットキー、タイトル、内容をカスタマイズできます。\n編集するには、項目をダブルクリックして新しい値を入力します。"; diff --git a/Maccy/Settings/km.lproj/PinsSettings.strings b/Maccy/Settings/km.lproj/PinsSettings.strings index fa034628..59f0e3c9 100644 --- a/Maccy/Settings/km.lproj/PinsSettings.strings +++ b/Maccy/Settings/km.lproj/PinsSettings.strings @@ -2,6 +2,7 @@ "Key" = "គ្រាប់ចុច"; "Content" = "ខ្លឹមសារ"; "ContentIsNotText" = "ខ្លឹមសារមិនអាចកែបាន (រូបភាព ឬឯកសារ)"; +"ParagraphsTooLongToEdit" = "អត្ថបទមានកថាខណ្ឌវែងពេក មិនអាចកែសម្រួលបានទេ។"; "RichTextEditWarning" = "ការកែនឹងលុបទម្រង់ទាំងអស់។"; "Alias" = "ចំណងជើង"; "PinCustomizationDescription" = "អ្នកអាចកែ គ្រាប់ចុចកាត់ ចំណងជើង និងខ្លឹមសារ នៃធាតុដែលបានដៅទុកបាន។ ដើម្បីកែ សូមចុចពីរដងលើធាតុនោះ រួចវាយអក្សរថ្មីចូល។\nចំណាំ៖ មានតែអត្ថបទ Plain Text ធម្មតា ប៉ុណ្ណោះដែលអាចកែបាន។"; diff --git a/Maccy/Settings/ko.lproj/PinsSettings.strings b/Maccy/Settings/ko.lproj/PinsSettings.strings index 55944df5..20774af5 100644 --- a/Maccy/Settings/ko.lproj/PinsSettings.strings +++ b/Maccy/Settings/ko.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = "타이틀"; "Content" = "콘텐츠"; "ContentIsNotText" = "편집할 수 없는 콘텐츠(이미지 또는 파일)"; +"ParagraphsTooLongToEdit" = "텍스트에 편집하기에 너무 긴 문단이 포함되어 있습니다."; "RichTextEditWarning" = "편집하면 모든 서식이 삭제됩니다."; "PinCustomizationDescription" = "고정된 항목의 단축키, 제목, 콘텐츠를 사용자 지정할 수 있습니다. 수정하려면 항목을 두 번 클릭하고 새 값을 입력합니다.\n단, 일반 텍스트만 변경할 수 있습니다."; diff --git a/Maccy/Settings/lt.lproj/PinsSettings.strings b/Maccy/Settings/lt.lproj/PinsSettings.strings index 1df0d6c3..532ca2aa 100644 --- a/Maccy/Settings/lt.lproj/PinsSettings.strings +++ b/Maccy/Settings/lt.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = "Pavadinimas"; "Content" = "Turinys"; "ContentIsNotText" = "Neredaguojamas turinys (vaizdas arba failas)"; +"ParagraphsTooLongToEdit" = "Tekste yra pastraipų, kurios per ilgos redaguoti."; "RichTextEditWarning" = "Redaguojant bus panaikintas visas formatavimas."; "PinCustomizationDescription" = "Galite pritaikyti bet kurio prisegto elemento sparčiuosius klavišus, pavadinimą ir turinį. Norėdami redaguoti, dukart spustelėkite elementą ir įveskite naują reikšmę.\nAtkreipkite dėmesį, kad galima keisti tik paprastą tekstą."; diff --git a/Maccy/Settings/lv.lproj/PinsSettings.strings b/Maccy/Settings/lv.lproj/PinsSettings.strings index c903d21e..c7a4f7eb 100644 --- a/Maccy/Settings/lv.lproj/PinsSettings.strings +++ b/Maccy/Settings/lv.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = "Nosaukums"; "Content" = "Saturs"; "ContentIsNotText" = "Nerediģējams saturs (attēls vai fails)"; +"ParagraphsTooLongToEdit" = "Tekstā ir rindkopas, kas ir pārāk garas rediģēšanai."; "RichTextEditWarning" = "Rediģējot tiks atteikts viss formatējums."; "PinCustomizationDescription" = "Varat pielāgot jebkura piespraustā elementa karsto taustiņu, nosaukumu un saturu. Lai rediģētu, divreiz noklikšķiniet uz elementa un ievadiet jaunu vērtību.\nPievērsiet uzmanību, ka var mainīt tikai vienkāršu tekstu."; diff --git a/Maccy/Settings/nb.lproj/PinsSettings.strings b/Maccy/Settings/nb.lproj/PinsSettings.strings index 880c2bcd..4fa30bba 100644 --- a/Maccy/Settings/nb.lproj/PinsSettings.strings +++ b/Maccy/Settings/nb.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = "Tittel"; "Content" = "Innhold"; "ContentIsNotText" = "Ikke-redigerbart innhold (bilde eller fil)"; +"ParagraphsTooLongToEdit" = "Teksten inneholder avsnitt som er for lange til å redigeres."; "RichTextEditWarning" = "Hvis du redigerer, slettes all formatering."; "PinCustomizationDescription" = "Du kan tilpasse hurtigtasten, tittelen og innholdet i alle festede elementer. Du redigerer ved å dobbeltklikke på elementet og angi en ny verdi.\nVær oppmerksom på at det bare er ren tekst som kan endres."; diff --git a/Maccy/Settings/nl.lproj/PinsSettings.strings b/Maccy/Settings/nl.lproj/PinsSettings.strings index de41a544..f4758e46 100644 --- a/Maccy/Settings/nl.lproj/PinsSettings.strings +++ b/Maccy/Settings/nl.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = "Titel"; "Content" = "Inhoud"; "ContentIsNotText" = "Niet bewerkbare inhoud (afbeelding of bestand)"; +"ParagraphsTooLongToEdit" = "De tekst bevat alinea's die te lang zijn om te bewerken."; "RichTextEditWarning" = "Bewerken verwijdert alle opmaak."; "PinCustomizationDescription" = "Je kunt de sneltoets, titel en inhoud van elk vastgemaakt item aanpassen.\nOm te bewerken dubbelklikt u op het item en voert u een nieuwe waarde in."; diff --git a/Maccy/Settings/pl.lproj/PinsSettings.strings b/Maccy/Settings/pl.lproj/PinsSettings.strings index 7cdb0db9..7a6de9bd 100644 --- a/Maccy/Settings/pl.lproj/PinsSettings.strings +++ b/Maccy/Settings/pl.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = "Tytuł"; "Content" = "Treść"; "ContentIsNotText" = "Zawartość nieedytowalna (obraz lub plik)"; +"ParagraphsTooLongToEdit" = "Tekst zawiera akapity, które są zbyt długie do edycji."; "RichTextEditWarning" = "Edycja spowoduje usunięcie całego formatowania."; "PinCustomizationDescription" = "Można dostosować klawisz skrótu, tytuł i zawartość dowolnego przypiętego elementu.\nAby edytować, kliknij dwukrotnie element i wprowadź nową wartość. Należy pamiętać, że można zmienić tylko zwykły tekst."; diff --git a/Maccy/Settings/pt-BR.lproj/PinsSettings.strings b/Maccy/Settings/pt-BR.lproj/PinsSettings.strings index 027cea0f..dc602ed0 100644 --- a/Maccy/Settings/pt-BR.lproj/PinsSettings.strings +++ b/Maccy/Settings/pt-BR.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = "Título"; "Content" = "Conteúdo"; "ContentIsNotText" = "Conteúdo não editável (imagem ou ficheiro)"; +"ParagraphsTooLongToEdit" = "O texto contém parágrafos longos demais para serem editados."; "RichTextEditWarning" = "A edição elimina toda a formatação."; "PinCustomizationDescription" = "É possível personalizar a tecla de atalho, o título e o conteúdo de qualquer item fixado. Para editar, faça duplo clique no item e introduza um novo valor.\nPor favor, note que apenas o texto simples pode ser alterado."; diff --git a/Maccy/Settings/pt.lproj/PinsSettings.strings b/Maccy/Settings/pt.lproj/PinsSettings.strings index fd914491..7643a40e 100644 --- a/Maccy/Settings/pt.lproj/PinsSettings.strings +++ b/Maccy/Settings/pt.lproj/PinsSettings.strings @@ -1,5 +1,6 @@ "Content" = "Conteúdo"; "ContentIsNotText" = "Conteúdo não editável (imagem ou ficheiro)"; +"ParagraphsTooLongToEdit" = "O texto contém parágrafos demasiado longos para serem editados."; "RichTextEditWarning" = "A edição elimina toda a formatação."; "PinCustomizationDescription" = "É possível personalizar a tecla de atalho, o título e o conteúdo de qualquer elemento fixado. Para editar, faça duplo clique no elemento e introduza um novo valor.\nPor favor, note que apenas o texto simples pode ser alterado."; "Title" = "Pinos"; diff --git a/Maccy/Settings/ro.lproj/PinsSettings.strings b/Maccy/Settings/ro.lproj/PinsSettings.strings index 1f0f6356..0525f58f 100644 --- a/Maccy/Settings/ro.lproj/PinsSettings.strings +++ b/Maccy/Settings/ro.lproj/PinsSettings.strings @@ -4,4 +4,5 @@ "Title" = "Elemente fixate"; "Content" = "Conținut"; "ContentIsNotText" = "Conținut needitabil (imagine sau fișier)"; +"ParagraphsTooLongToEdit" = "Textul conține paragrafe prea lungi pentru a fi editate."; "RichTextEditWarning" = "Editarea va șterge formatarea existentă."; diff --git a/Maccy/Settings/ru.lproj/PinsSettings.strings b/Maccy/Settings/ru.lproj/PinsSettings.strings index bc361df8..273b599c 100644 --- a/Maccy/Settings/ru.lproj/PinsSettings.strings +++ b/Maccy/Settings/ru.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = "Название"; "Content" = "Содержание"; "ContentIsNotText" = "Не редактируемое содержимое (изображение или файл)"; +"ParagraphsTooLongToEdit" = "Текст содержит абзацы, слишком длинные для редактирования."; "RichTextEditWarning" = "При редактировании все форматирование будет удалено."; "PinCustomizationDescription" = "Вы можете настроить горячую клавишу, заголовок и содержимое любого прикрепленного элемента. Чтобы изменить, дважды щелкните элемент и введите новое значение.\nПримите во внимание, что изменять можно только обычный текст."; diff --git a/Maccy/Settings/sl.lproj/PinsSettings.strings b/Maccy/Settings/sl.lproj/PinsSettings.strings index eb563677..47c14320 100644 --- a/Maccy/Settings/sl.lproj/PinsSettings.strings +++ b/Maccy/Settings/sl.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = "Naslov"; "Content" = "Vsebina"; "ContentIsNotText" = "Vsebina, ki je ni mogoče urejati (slika ali datoteka)"; +"ParagraphsTooLongToEdit" = "Besedilo vsebuje odstavke, ki so predolgi za urejanje."; "RichTextEditWarning" = "Pri urejanju se bodo izgubile vse oblike."; "PinCustomizationDescription" = "Prilagodite lahko tipko za hitri dostop, naslov in vsebino vsakega pripetega elementa. Za urejanje dvakrat kliknite element in vnesite novo vrednost.\nUpoštevajte, da lahko spremenite samo navadno besedilo."; diff --git a/Maccy/Settings/sv.lproj/PinsSettings.strings b/Maccy/Settings/sv.lproj/PinsSettings.strings index 9ffe5857..4bff2a91 100644 --- a/Maccy/Settings/sv.lproj/PinsSettings.strings +++ b/Maccy/Settings/sv.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = ""; "Content" = ""; "ContentIsNotText" = ""; +"ParagraphsTooLongToEdit" = "Texten innehåller stycken som är för långa för att redigeras."; "RichTextEditWarning" = ""; "PinCustomizationDescription" = ""; diff --git a/Maccy/Settings/ta.lproj/PinsSettings.strings b/Maccy/Settings/ta.lproj/PinsSettings.strings index 72255d58..16ce08ad 100644 --- a/Maccy/Settings/ta.lproj/PinsSettings.strings +++ b/Maccy/Settings/ta.lproj/PinsSettings.strings @@ -4,4 +4,5 @@ "PinCustomizationDescription" = "பின் செய்யப்பட்ட எந்தவொரு பொருளின் ஆட்கி, தலைப்பு மற்றும் உள்ளடக்கத்தை நீங்கள் தனிப்பயனாக்கலாம். திருத்த, உருப்படியை இருமுறை சொடுக்கு செய்து புதிய மதிப்பை உள்ளிடவும். \nஎளிய உரையை மட்டுமே மாற்ற முடியும் என்பதை நினைவில் கொள்ளவும்."; "Content" = "உள்ளடக்கம்"; "ContentIsNotText" = "திருத்த முடியாத உள்ளடக்கம் (படம் அல்லது கோப்பு)"; +"ParagraphsTooLongToEdit" = "உரையில் திருத்த முடியாத அளவுக்கு நீளமான பத்திகள் உள்ளன."; "RichTextEditWarning" = "திருத்துதல் அனைத்து வடிவமைப்பையும் நிராகரிக்கும்."; diff --git a/Maccy/Settings/th.lproj/PinsSettings.strings b/Maccy/Settings/th.lproj/PinsSettings.strings index 4654822f..2a81a0d3 100644 --- a/Maccy/Settings/th.lproj/PinsSettings.strings +++ b/Maccy/Settings/th.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = "หัวข้อ"; "Content" = "เนื้อหา"; "ContentIsNotText" = "เนื้อหาที่ไม่สามารถแก้ไขได้ (รูปภาพหรือไฟล์)"; +"ParagraphsTooLongToEdit" = "ข้อความมีย่อหน้าที่ยาวเกินกว่าจะแก้ไขได้"; "RichTextEditWarning" = "การแก้ไขจะลบการจัดรูปแบบทั้งหมด"; "PinCustomizationDescription" = "คุณสามารถปรับแต่งปุ่มลัด, ชื่อ, และเนื้อหาของรายการที่ปักหมุดไว้ได้.\nเพื่อแก้ไข, ให้คลิกสองครั้งที่รายการนั้นและป้อนค่าใหม่. โปรดทราบว่าสามารถเปลี่ยนแปลงได้เฉพาะข้อความธรรมดาเท่านั้น."; diff --git a/Maccy/Settings/tr.lproj/PinsSettings.strings b/Maccy/Settings/tr.lproj/PinsSettings.strings index 66f06313..6ec720ef 100644 --- a/Maccy/Settings/tr.lproj/PinsSettings.strings +++ b/Maccy/Settings/tr.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = "Başlık"; "Content" = "İçerik"; "ContentIsNotText" = "Düzenlenemeyen içerik (resim veya dosya)"; +"ParagraphsTooLongToEdit" = "Metin, düzenlenemeyecek kadar uzun paragraflar içeriyor."; "RichTextEditWarning" = "Düzenleme tüm biçimlendirmeyi atacaktır."; "PinCustomizationDescription" = "Sabitlenmiş herhangi bir öğenin kısayol tuşunu, başlığını ve içeriğini özelleştirebilirsiniz. Düzenlemek için öğeye çift tıklayın ve yeni bir değer girin.\nLütfen yalnızca düz metnin değiştirilebileceğini unutmayın."; diff --git a/Maccy/Settings/uk.lproj/PinsSettings.strings b/Maccy/Settings/uk.lproj/PinsSettings.strings index 106afab8..a17244d3 100644 --- a/Maccy/Settings/uk.lproj/PinsSettings.strings +++ b/Maccy/Settings/uk.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = "Заголовок"; "Content" = "Зміст"; "ContentIsNotText" = "Невідредагований вміст (зображення або файл)"; +"ParagraphsTooLongToEdit" = "Текст містить абзаци, задовгі для редагування."; "RichTextEditWarning" = "Під час редагування все форматування буде відкинуто."; "PinCustomizationDescription" = "Ви можете налаштувати гарячу клавішу, назву і вміст будь-якого закріпленого елемента. Для редагування двічі клацніть елемент і введіть нове значення.\nЗверніть увагу, що можна змінювати лише звичайний текст."; diff --git a/Maccy/Settings/uz.lproj/PinsSettings.strings b/Maccy/Settings/uz.lproj/PinsSettings.strings index c63e919d..21a83201 100644 --- a/Maccy/Settings/uz.lproj/PinsSettings.strings +++ b/Maccy/Settings/uz.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = "Sarlavha"; "Content" = "Kontent"; "ContentIsNotText" = "Tahrirlanmaydigan kontent (rasm yoki fayl)"; +"ParagraphsTooLongToEdit" = "Matnda tahrirlash uchun juda uzun paragraflar bor."; "RichTextEditWarning" = "Tahrirlash barcha formatlashni bekor qiladi."; "PinCustomizationDescription" = "Har qanday mahkamlangan elementning tezkor tugmasi, sarlavhasi va mazmunini sozlashingiz mumkin. Tahrirlash uchun elementni ikki marta bosing va yangi qiymat kiriting.\nYodda tutingki, faqat oddiy matnni oʻzgartirish mumkin."; diff --git a/Maccy/Settings/vi.lproj/PinsSettings.strings b/Maccy/Settings/vi.lproj/PinsSettings.strings index fcf3ed9a..99d5a9be 100644 --- a/Maccy/Settings/vi.lproj/PinsSettings.strings +++ b/Maccy/Settings/vi.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = "Tiêu đề"; "Content" = "Nội dung"; "ContentIsNotText" = "Nội dung không thể chỉnh sửa (hình ảnh hoặc tệp tin)"; +"ParagraphsTooLongToEdit" = "Văn bản chứa các đoạn quá dài để chỉnh sửa."; "RichTextEditWarning" = "Chỉnh sửa sẽ loại bỏ tất cả định dạng."; "PinCustomizationDescription" = "Bạn có thể tùy chỉnh phím tắt, tiêu đề và nội dung của bất kỳ mục nào được ghim. Để chỉnh sửa, hãy nhấp đúp vào mục đó và nhập giá trị mới.\nLưu ý rằng chỉ văn bản thuần túy mới có thể được thay đổi."; diff --git a/Maccy/Settings/zh-Hans.lproj/PinsSettings.strings b/Maccy/Settings/zh-Hans.lproj/PinsSettings.strings index 051188b9..3b475fe5 100644 --- a/Maccy/Settings/zh-Hans.lproj/PinsSettings.strings +++ b/Maccy/Settings/zh-Hans.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = "标题"; "Content" = "内容"; "ContentIsNotText" = "不可编辑的内容(图像或文件)"; +"ParagraphsTooLongToEdit" = "文本包含过长的段落,无法编辑。"; "RichTextEditWarning" = "编辑将放弃所有格式化。"; "PinCustomizationDescription" = "您可以自定义任何置顶项目的热键、标题和内容。\n要编辑,请双击该项目并输入新值。"; diff --git a/Maccy/Settings/zh-Hant.lproj/PinsSettings.strings b/Maccy/Settings/zh-Hant.lproj/PinsSettings.strings index 5597b456..84ff8db8 100644 --- a/Maccy/Settings/zh-Hant.lproj/PinsSettings.strings +++ b/Maccy/Settings/zh-Hant.lproj/PinsSettings.strings @@ -3,5 +3,6 @@ "Alias" = "標題"; "Content" = "內容"; "ContentIsNotText" = "不可編輯的內容(影像或檔案)"; +"ParagraphsTooLongToEdit" = "文字包含過長的段落,無法編輯。"; "RichTextEditWarning" = "編輯時會捨棄所有格式。"; "PinCustomizationDescription" = "您可以自訂任何釘選項目的熱鍵、標題和內容。\n若要編輯,請按兩下該項目,然後輸入新值。"; diff --git a/Maccy/Views/ItemEditorView.swift b/Maccy/Views/ItemEditorView.swift index 415f9558..c3918352 100644 --- a/Maccy/Views/ItemEditorView.swift +++ b/Maccy/Views/ItemEditorView.swift @@ -13,6 +13,11 @@ struct ItemEditorView: View { @State private var availablePins: [String] = [] @State private var isTextContent: Bool @State private var isRichText: Bool + @State private var isContentTooLarge: Bool + + private var hasWarning: Bool { + isContentTooLarge || (isRichText && editableContent.hasChanges) + } private enum Field: Hashable { case title @@ -24,9 +29,11 @@ struct ItemEditorView: View { init(for item: HistoryItemDecorator) { self.item = item self._editableTitle = State(initialValue: item.item.title) - self._editableContent = State(initialValue: LargeTextView.EditorState(text: item.item.previewableText)) + self._editableContent = State(initialValue: LargeTextView.EditorState(text: item.previewText.string)) self._selectedPin = State(initialValue: item.item.pin) + self._isContentTooLarge = State(initialValue: item.previewText.isTruncated) + // Content can only be edited safely as plain text. self._isTextContent = State( initialValue: item.hasPlainText && !item.hasImage && !item.hasFileURLs @@ -73,9 +80,18 @@ struct ItemEditorView: View { Text("Content", tableName: "PinsSettings") .font(.headline) - if isRichText && editableContent.hasChanges { + if hasWarning { Label { - Text("RichTextEditWarning", tableName: "PinsSettings") + if isContentTooLarge { + Text(NSLocalizedString( + "ParagraphsTooLongToEdit", + tableName: "PinsSettings", + value: "Text contains paragraphs that are too long to edit.", + comment: "" + )) + } else { + Text("RichTextEditWarning", tableName: "PinsSettings") + } } icon: { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.orange) @@ -130,15 +146,16 @@ struct ItemEditorView: View { if isTextContent || isRichText { LargeTextView( editorState: editableContent, + isEditable: !isContentTooLarge, accessibilityLabel: NSLocalizedString("Content", tableName: "PinsSettings", comment: ""), onCancel: { dismiss() } ) + .disabled(isContentTooLarge) .padding(.horizontal, 8) .padding(.vertical, 6) } else { Text("ContentIsNotText", tableName: "PinsSettings") .foregroundStyle(.secondary) - .italic() .frame( maxWidth: .infinity, maxHeight: .infinity, diff --git a/Maccy/Views/LargeTextView.swift b/Maccy/Views/LargeTextView.swift index 20bacd6b..1cd132de 100644 --- a/Maccy/Views/LargeTextView.swift +++ b/Maccy/Views/LargeTextView.swift @@ -3,6 +3,8 @@ import Observation import SwiftUI struct LargeTextView: NSViewRepresentable { + @Environment(\.isEnabled) private var isEnabled + private let previewText: String? private let editorState: EditorState? private let isEditableOverwrite: Bool @@ -39,7 +41,8 @@ struct LargeTextView: NSViewRepresentable { let scrollView = Self.makeScrollView( text: editorState?.text ?? previewText ?? "", isEditable: isEditable, - accessibilityLabel: accessibilityLabel + accessibilityLabel: accessibilityLabel, + textColor: isEnabled ? .labelColor : .disabledControlTextColor ) if let textView = scrollView.documentView as? NSTextView { textView.delegate = context.coordinator @@ -56,6 +59,7 @@ struct LargeTextView: NSViewRepresentable { textView.isEditable = isEditable textView.isSelectable = isEditable textView.allowsUndo = isEditable + textView.textColor = isEnabled ? .labelColor : .disabledControlTextColor textView.setAccessibilityLabel(accessibilityLabel) if previousEditorState !== editorState { @@ -83,7 +87,8 @@ struct LargeTextView: NSViewRepresentable { static func makeScrollView( text: String, isEditable: Bool = false, - accessibilityLabel: String? = nil + accessibilityLabel: String? = nil, + textColor: NSColor = .labelColor ) -> NSScrollView { let textView = NSTextView(usingTextLayoutManager: true) textView.isEditable = isEditable @@ -93,7 +98,7 @@ struct LargeTextView: NSViewRepresentable { textView.isRichText = false textView.drawsBackground = false textView.font = NSFont.systemFont(ofSize: NSFont.systemFontSize) - textView.textColor = .labelColor + textView.textColor = textColor textView.textContainerInset = .zero textView.minSize = .zero textView.maxSize = NSSize( diff --git a/Maccy/Views/PreviewItemView.swift b/Maccy/Views/PreviewItemView.swift index 437153f1..341c0f03 100644 --- a/Maccy/Views/PreviewItemView.swift +++ b/Maccy/Views/PreviewItemView.swift @@ -3,7 +3,7 @@ import KeyboardShortcuts import SwiftUI struct PreviewItemView: View { - static var largeTextThreshold = 1_000 + private static let largeTextThreshold = 1_000 var item: HistoryItemDecorator @@ -53,13 +53,12 @@ struct PreviewItemView: View { } } } else { - let text = item.previewText - if text.count >= Self.largeTextThreshold { - LargeTextView(text: text) + if item.previewText.byteCount >= Self.largeTextThreshold { + LargeTextView(text: item.previewText.string) .id("textpreview-\(item.id)") } else { ScrollView { - Text(text) + Text(item.previewText.string) .font(.body) .frame(maxWidth: .infinity, alignment: .leading) } From 2b5c40eacf67c0b827a4181eaae1245fb58d1165 Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:04:31 +0200 Subject: [PATCH 32/59] Disable saving unchanged items --- Maccy/Views/ItemEditorView.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Maccy/Views/ItemEditorView.swift b/Maccy/Views/ItemEditorView.swift index c3918352..3101c207 100644 --- a/Maccy/Views/ItemEditorView.swift +++ b/Maccy/Views/ItemEditorView.swift @@ -111,6 +111,7 @@ struct ItemEditorView: View { Button(Self.doneButtonTitle, action: saveChanges) .keyboardShortcut(.return, modifiers: .command) + .disabled(!hasChangesToApply) } } .padding(16) @@ -181,8 +182,15 @@ struct ItemEditorView: View { .sorted() } + private var hasChangesToApply: Bool { + let titleChanged = !item.hasImage && editableTitle != item.item.title + let pinChanged = selectedPin != nil && selectedPin != item.item.pin + let contentChanged = (isTextContent || isRichText) && editableContent.hasChanges + return titleChanged || pinChanged || contentChanged + } + private func applyChanges() { - if !item.hasImage { + if !item.hasImage && editableTitle != item.item.title { item.item.title = editableTitle item.title = editableTitle } @@ -211,6 +219,7 @@ struct ItemEditorView: View { } private func saveChanges() { + guard hasChangesToApply else { return } applyChanges() dismiss() } From 14622edbf4743a04e4f92abb34098aef473235dd Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Sat, 3 Oct 2026 20:04:37 +0200 Subject: [PATCH 33/59] Remove the unused Pins settings pane --- Maccy/Settings/PinsSettingsPane.swift | 171 -------------------------- 1 file changed, 171 deletions(-) delete mode 100644 Maccy/Settings/PinsSettingsPane.swift diff --git a/Maccy/Settings/PinsSettingsPane.swift b/Maccy/Settings/PinsSettingsPane.swift deleted file mode 100644 index 66b381aa..00000000 --- a/Maccy/Settings/PinsSettingsPane.swift +++ /dev/null @@ -1,171 +0,0 @@ -import SwiftData -import SwiftUI - -struct PinPickerView: View { - @Bindable var item: HistoryItem - var availablePins: [String] - - var body: some View { - if let pin = item.pin { - // Ensure unique pins for ForEach - let uniquePins = Array(Set(availablePins + [pin])).sorted() - Picker("", selection: $item.pin) { - ForEach(uniquePins, id: \.self) { pin in - Text(pin) - .tag(pin as String?) - } - } - .controlSize(.small) - .labelsHidden() - .accessibilityLabel(Text("Key", tableName: "PinsSettings")) - } - } -} - -struct PinTitleView: View { - @Bindable var item: HistoryItem - - var body: some View { - TextField("", text: $item.title) - .accessibilityLabel(Text("Alias", tableName: "PinsSettings")) - } -} - -struct PinValueView: View { - @Bindable var item: HistoryItem - @State private var editableValue: String - @State private var isTextContent: Bool - @State private var isRichText: Bool - @FocusState private var isEditing: Bool - @State private var showWarningPopover: Bool = false - - init(item: HistoryItem) { - self.item = item - self._editableValue = State(initialValue: item.previewableText) - - // Check if this item has editable text content - let hasPlainText = item.text != nil - let hasImage = item.image != nil - let hasFileURLs = !item.fileURLs.isEmpty - let hasRichText = item.rtf != nil || item.html != nil - - // Consider it text content only if it has plain text and doesn't have images or file URLs - self._isTextContent = State(initialValue: hasPlainText && !hasImage && !hasFileURLs) - self._isRichText = State(initialValue: hasRichText && !hasImage && !hasFileURLs) - } - - var body: some View { - Group { - if isTextContent || isRichText { - ZStack(alignment: .trailing) { - TextField("", text: $editableValue) - .focused($isEditing) - .onSubmit { - updateItemContent() - } - .onChange(of: editableValue) { _, _ in - updateItemContent() - } - .padding(.trailing, isRichText ? 40 : 0) // increased space for icon - .accessibilityLabel(Text("Content", tableName: "PinsSettings")) - - if isRichText && isEditing { - HStack(spacing: 0) { - Spacer(minLength: 0) - Image(systemName: "exclamationmark.triangle.fill") - .foregroundColor(.orange) - .help(Text("RichTextEditWarning", tableName: "PinsSettings")) - Spacer().frame(width: 4) - } - .frame(maxHeight: .infinity, alignment: .center) - .padding(.trailing, 4) - } - } - } else { - // Non-editable display for non-text content - Text("ContentIsNotText", tableName: "PinsSettings") - .foregroundStyle(.secondary) - .italic() - } - } - } - - private func updateItemContent() { - // Only update if we're dealing with text or rich text content - guard isTextContent || isRichText else { return } - - // Remove all non-plain-text content - let stringType = NSPasteboard.PasteboardType.string.rawValue - item.contents.removeAll { $0.type != stringType } - - // Update or add the plain text content - if let index = item.contents.firstIndex(where: { $0.type == stringType }) { - if let data = editableValue.data(using: .utf8) { - item.contents[index].value = data - } - } else { - if let data = editableValue.data(using: .utf8) { - let newContent = HistoryItemContent(type: stringType, value: data) - item.contents.append(newContent) - } - } - // We don't automatically update title here since we want to preserve - // OCR-extracted titles for images and other non-text content - } -} - -struct PinsSettingsPane: View { - @Environment(AppState.self) private var appState - @Environment(\.modelContext) private var modelContext - - @Query(filter: #Predicate { $0.pin != nil }, sort: \.firstCopiedAt) - private var items: [HistoryItem] - - @State private var availablePins: [String] = [] - @State private var selection: PersistentIdentifier? - - var body: some View { - VStack(alignment: .leading) { - Table(items, selection: $selection) { - TableColumn(Text("Key", tableName: "PinsSettings")) { item in - PinPickerView(item: item, availablePins: availablePins) - .onChange(of: item.pin) { - availablePins = HistoryItem.availablePins(in: items) - } - } - .width(60) - - TableColumn(Text("Alias", tableName: "PinsSettings")) { item in - PinTitleView(item: item) - } - - TableColumn(Text("Content", tableName: "PinsSettings")) { item in - PinValueView(item: item) - } - } - .onAppear { - availablePins = HistoryItem.availablePins(in: items) - } - .onDeleteCommand { - guard let selection, - let item = appState.history.items.first(where: { $0.item.id == selection }) else { - return - } - - appState.history.delete(item) - } - - Text("PinCustomizationDescription", tableName: "PinsSettings") - .foregroundStyle(.gray) - .controlSize(.small) - } - .frame(minWidth: 500, minHeight: 400) - .padding() - } -} - -#Preview { - return PinsSettingsPane() - .environment(\.locale, .init(identifier: "en")) - .modelContainer(Storage.shared.container) -} From 2ef27fc5d3923ebb9d6aa6a25795e8a034aea238 Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:35:34 +0200 Subject: [PATCH 34/59] Use native .reorderable() on macOS 27 --- Maccy/Views/PinsView.swift | 2 + Maccy/Views/ReorderableContainer.swift | 104 ++++++++++++++++++++++--- 2 files changed, 95 insertions(+), 11 deletions(-) diff --git a/Maccy/Views/PinsView.swift b/Maccy/Views/PinsView.swift index eb6e0252..e26ce916 100644 --- a/Maccy/Views/PinsView.swift +++ b/Maccy/Views/PinsView.swift @@ -26,6 +26,8 @@ struct PinsView: View { index: index ) .draggableItem(item) + } forEachModifier: { + $0.reorderableItems() } .draggableContainer( items: items, diff --git a/Maccy/Views/ReorderableContainer.swift b/Maccy/Views/ReorderableContainer.swift index 74845e08..b53f2151 100644 --- a/Maccy/Views/ReorderableContainer.swift +++ b/Maccy/Views/ReorderableContainer.swift @@ -3,6 +3,58 @@ import UniformTypeIdentifiers typealias Reorderable = Identifiable & Equatable +@available(macOS 27.0, *) +private struct NativeDraggableContainerModifier: ViewModifier where Item.ID: Sendable { + let items: [Item] + @Binding var selection: Selection + @Binding var draggedItems: [Item] + + let moveAction: (IndexSet, Int) -> Void + + func body(content: Content) -> some View { + content + .reorderContainer(for: Item.self, move: move) + .dragContainerSelection(items.filter { selection.items.contains($0) }.map(\.id)) + .onDragSessionUpdated { session in + switch session.phase { + case .initial, .active: + let identifiers = Set(session.draggedItemIDs(for: Item.ID.self)) + draggedItems = items.filter { identifiers.contains($0.id) } + AppState.shared.navigator.isDragAndDropInProgress = true + case .ended, .dataTransferCompleted: + resetDragState() + default: + break + } + } + .onDisappear { + resetDragState() + } + } + + private func move(_ difference: ReorderDifference) { + let identifiers = Set(difference.sources) + let source = IndexSet(items.indices.filter { identifiers.contains(items[$0].id) }) + guard !source.isEmpty else { return } + + let destination: Int + switch difference.destination.position { + case .before(let identifier): + guard let index = items.firstIndex(where: { $0.id == identifier }) else { return } + destination = index + case .end: + destination = items.count + } + + moveAction(source, destination) + } + + private func resetDragState() { + draggedItems = [] + AppState.shared.navigator.isDragAndDropInProgress = false + } +} + private struct DraggableContainerModifier: ViewModifier { let items: [Item] let contentType: UTType @@ -210,6 +262,7 @@ extension EnvironmentValues { } extension View { + @ViewBuilder func draggableContainer( items: [Item], contentType: UTType, @@ -217,25 +270,54 @@ extension View { selection: Binding>, draggedItems: Binding<[Item]>, moveAction: @escaping (IndexSet, Int) -> Void - ) -> some View { - modifier( - DraggableContainerModifier( - items: items, - contentType: contentType, - dragPreview: dragPreview, - selection: selection, - draggedItems: draggedItems, - moveAction: moveAction + ) -> some View where Item.ID: Sendable { + if #available(macOS 27.0, *) { + modifier( + NativeDraggableContainerModifier( + items: items, + selection: selection, + draggedItems: draggedItems, + moveAction: moveAction + ) ) - ) + } else { + modifier( + DraggableContainerModifier( + items: items, + contentType: contentType, + dragPreview: dragPreview, + selection: selection, + draggedItems: draggedItems, + moveAction: moveAction + ) + ) + } } @ViewBuilder func draggableItem(_ item: Item) -> some View { - if #available(macOS 26.0, *) { + // macOS 27 natively supports reorderable containers. + // macOS 26 adds onDragSessionUpdated as an API which makes detecting when a drag session + // has ended much more reliable and smoother. The legacy implementation is to bridge that + // gap but should be removed in the future. + if #available(macOS 27.0, *) { + // The enclosing ForEach supplies native drag sources through reorderable(). + self + } else if #available(macOS 26.0, *) { modifier(DraggableItemModifier(item: item)) } else { modifier(LegacyDraggableItemModifier(item: item)) } } } + +extension DynamicViewContent { + @ViewBuilder + func reorderableItems() -> some View { + if #available(macOS 27.0, *) { + reorderable() + } else { + self + } + } +} From c60a123ca8a03f8b95e4a39a08f013d51b030a7d Mon Sep 17 00:00:00 2001 From: Alex Rodionov Date: Mon, 5 Oct 2026 07:11:28 -0700 Subject: [PATCH 35/59] Improve unpin/edit icons --- Maccy/Views/ToolbarView.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Maccy/Views/ToolbarView.swift b/Maccy/Views/ToolbarView.swift index d05c7c19..ea18f80f 100644 --- a/Maccy/Views/ToolbarView.swift +++ b/Maccy/Views/ToolbarView.swift @@ -135,7 +135,7 @@ struct ToolbarView: View { } } label: { if (appState.navigator.selection.items.allSatisfy { $0.isPinned }) { - Image(systemName: "pin.slash") + Image(systemName: "pin.slash.fill") } else { Image(systemName: "pin") } @@ -152,9 +152,7 @@ struct ToolbarView: View { appState.isEditingItem = true editingItem = editableItem } label: { - Image(systemName: "square.and.pencil") - .frame(width: 23, height: 23) - .contentShape(Rectangle()) + Image(systemName: "pencil.and.list.clipboard") } .shortcutKeyHelp(key: "EditItem", tableName: "PreviewItemView") .disabled(!editItemEnabled) From c95bb478eef14543d07f14cb01eee81972f752e1 Mon Sep 17 00:00:00 2001 From: Alex Rodionov Date: Mon, 5 Oct 2026 07:13:28 -0700 Subject: [PATCH 36/59] Allow pinning items even if there are no pin letters --- Maccy/Observables/PinManager.swift | 5 ++-- MaccyTests/HistoryTests.swift | 39 ++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 3 deletions(-) diff --git a/Maccy/Observables/PinManager.swift b/Maccy/Observables/PinManager.swift index 8f00a7f5..709623be 100644 --- a/Maccy/Observables/PinManager.swift +++ b/Maccy/Observables/PinManager.swift @@ -128,10 +128,9 @@ final class PinManager { } private func pin(_ item: HistoryItemDecorator) { - guard item.isUnpinned, let pin = availablePins.randomElement() else { - return - } + guard item.isUnpinned else { return } + let pin = availablePins.randomElement() ?? "" item.item.pin = pin pinnedItems.append(item) diff --git a/MaccyTests/HistoryTests.swift b/MaccyTests/HistoryTests.swift index 6f641caa..75fe6a01 100644 --- a/MaccyTests/HistoryTests.swift +++ b/MaccyTests/HistoryTests.swift @@ -275,6 +275,45 @@ class HistoryTests: XCTestCase { // swiftlint:disable:this type_body_length XCTAssertEqual(Defaults[.pinOrder].pins, [item.item.pin].compactMap { $0 }) } + func testPinningAfterAllShortcutsAreAssigned() async throws { + let supportedPins = HistoryItem.supportedPins + for index in 0.. Date: Mon, 5 Oct 2026 08:48:42 -0700 Subject: [PATCH 37/59] Revert popup window level to .statusBar --- Maccy/FloatingPanel.swift | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/Maccy/FloatingPanel.swift b/Maccy/FloatingPanel.swift index 99dca639..7403b9c7 100644 --- a/Maccy/FloatingPanel.swift +++ b/Maccy/FloatingPanel.swift @@ -37,9 +37,8 @@ class FloatingPanel: NSPanel, NSWindowDelegate { animationBehavior = .none isFloatingPanel = true - // Chrome autofill uses window layer 999; screenSaver (1000) sits just above it - // while still covering status items / Spotlight. See #1403. - level = .screenSaver + // TODO: Automatically detect Chrome autofill that uses window layer 999 and set to screenSaver (1000). See #1403. + level = .statusBar collectionBehavior = [.auxiliary, .stationary, .moveToActiveSpace, .fullScreenAuxiliary] titleVisibility = .hidden titlebarAppearsTransparent = true @@ -58,11 +57,6 @@ class FloatingPanel: NSPanel, NSWindowDelegate { content: view(), onWindowDragEnded: { [weak self] in self?.saveWindowPosition() - }, - onDragAndDropStateChange: { [weak self] isDragAndDropInProgress in - // At screenSaver level the drag preview is behind the popup, which prevents - // the drag-and-drop lifecycle from being triggered. - self?.level = isDragAndDropInProgress ? .popUpMenu : .screenSaver } ) ) @@ -244,7 +238,6 @@ private struct FloatingPanelRootView: View { let content: Content let onWindowDragEnded: () -> Void - let onDragAndDropStateChange: (Bool) -> Void var body: some View { content @@ -256,11 +249,5 @@ private struct FloatingPanelRootView: View { onWindowDragEnded() } ) - .onChange( - of: appState.navigator.isDragAndDropInProgress, - initial: true - ) { _, isDragAndDropInProgress in - onDragAndDropStateChange(isDragAndDropInProgress) - } } } From f26dfbbc8fb0c881109c43c433983c5570b952ec Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Sun, 8 Feb 2026 17:47:09 +0100 Subject: [PATCH 38/59] Properly set lead item when removing from selection --- Maccy/Observables/NavigationManager.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Maccy/Observables/NavigationManager.swift b/Maccy/Observables/NavigationManager.swift index 8a558e27..eaab14f2 100644 --- a/Maccy/Observables/NavigationManager.swift +++ b/Maccy/Observables/NavigationManager.swift @@ -110,20 +110,25 @@ class NavigationManager { // swiftlint:disable:this type_body_length func addToSelection(item: HistoryItemDecorator) { var newSelectionState = selection + var newLeadItem = leadHistoryItem if item.isSelected { if newSelectionState.count <= 1 { isManualMultiSelect = !isManualMultiSelect } else { newSelectionState.remove(item) + if item == newLeadItem { + newLeadItem = newSelectionState.items.last ?? leadHistoryItem + } } } else { newSelectionState.add(item) + newLeadItem = item } withTransaction(Transaction()) { selection = newSelectionState - leadHistoryItem = item + leadHistoryItem = newLeadItem scrollTarget = leadSelection } } From 90980b074cda6b7d7bf22496de3c41cbd5da228a Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Thu, 30 Jul 2026 22:32:57 +0200 Subject: [PATCH 39/59] Avoid allocating Array in enumerated ForEach Views --- Maccy.xcodeproj/project.pbxproj | 4 ++++ Maccy/Views/EnumeratedForEach.swift | 25 +++++++++++++++++++++++++ Maccy/Views/PasteStackPreviewView.swift | 2 +- Maccy/Views/PasteStackView.swift | 2 +- 4 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 Maccy/Views/EnumeratedForEach.swift diff --git a/Maccy.xcodeproj/project.pbxproj b/Maccy.xcodeproj/project.pbxproj index 234d926a..9c9b0557 100644 --- a/Maccy.xcodeproj/project.pbxproj +++ b/Maccy.xcodeproj/project.pbxproj @@ -22,6 +22,7 @@ 2F12271E2E5C932200A1592A /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = 2F12271D2E5C932200A1592A /* Logging */; }; 2F162E802F1E7B30001FBAFF /* PasteStackPreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F162E7F2F1E7B28001FBAFF /* PasteStackPreviewView.swift */; }; 2F1A79C02C6DFB7800C98EBD /* SearchVisibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F1A79BF2C6DFB7800C98EBD /* SearchVisibility.swift */; }; + 2F1B61CF301BE55F000905F9 /* EnumeratedForEach.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F1B61CE301BE559000905F9 /* EnumeratedForEach.swift */; }; 2F1B61CD301BCFF2000905F9 /* ModifierFlags+Current.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F1B61CC301BCFD2000905F9 /* ModifierFlags+Current.swift */; }; 2F39CB042AD9A93C00B749FD /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 2F39CB032AD9A93C00B749FD /* Sparkle */; }; 2F39CB0A2AD9AE1F00B749FD /* Settings in Frameworks */ = {isa = PBXBuildFile; productRef = 2F39CB092AD9AE1F00B749FD /* Settings */; }; @@ -214,6 +215,7 @@ 2F1091D62F2B6E8100F5C454 /* AsyncView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncView.swift; sourceTree = ""; }; 2F162E7F2F1E7B28001FBAFF /* PasteStackPreviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasteStackPreviewView.swift; sourceTree = ""; }; 2F1A79BF2C6DFB7800C98EBD /* SearchVisibility.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchVisibility.swift; sourceTree = ""; }; + 2F1B61CE301BE559000905F9 /* EnumeratedForEach.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnumeratedForEach.swift; sourceTree = ""; }; 2F1B61CC301BCFD2000905F9 /* ModifierFlags+Current.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "ModifierFlags+Current.swift"; sourceTree = ""; }; 2F578C3C2EFFE8400088B759 /* ItemsProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemsProtocol.swift; sourceTree = ""; }; 2F578C3E2EFFE8720088B759 /* SlideoutController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SlideoutController.swift; sourceTree = ""; }; @@ -726,6 +728,7 @@ 2FD5DE502FFE6DCE0023FF60 /* LegacyReorderableContainer.swift */, 2F0E055E2FFD2EAF00CCD1DE /* ReorderableContainer.swift */, 2FD499442FFC487F00149F02 /* MovableByWindowBackgroundModifier.swift */, + 2F1B61CE301BE559000905F9 /* EnumeratedForEach.swift */, 2F1091D62F2B6E8100F5C454 /* AsyncView.swift */, 2FF2E94F2E7808420093D72C /* AppImageView.swift */, DAA072DE2C41C63C006DDFD2 /* ConfirmationView.swift */, @@ -1235,6 +1238,7 @@ DA20FA722B082DD600056DD5 /* Notifier.swift in Sources */, 2FD4993E2FFBA57800149F02 /* PinManager.swift in Sources */, 2F578C3F2EFFE8720088B759 /* SlideoutController.swift in Sources */, + 2F1B61CF301BE55F000905F9 /* EnumeratedForEach.swift in Sources */, DAA072DB2C41C5DD006DDFD2 /* FooterItem.swift in Sources */, DA689FC62C1D14F10009B887 /* PopupPosition.swift in Sources */, DA49EE7528B59468002752E0 /* NSRect+Centered.swift in Sources */, diff --git a/Maccy/Views/EnumeratedForEach.swift b/Maccy/Views/EnumeratedForEach.swift new file mode 100644 index 00000000..a15f18e0 --- /dev/null +++ b/Maccy/Views/EnumeratedForEach.swift @@ -0,0 +1,25 @@ +import SwiftUI + +public struct EnumeratedForEach: View +where Data: RandomAccessCollection, Data.Element: Identifiable, ID: Hashable, Content: View { + + public var data: Data + @ViewBuilder public var content: (Data.Element, Int) -> Content + + public var body: some View { + buildBody() + } + + @ViewBuilder + private func buildBody() -> some View { + if #available(macOS 26.0, *) { + ForEach(data.enumerated(), id: \.element.id) { item in + self.content(item.element, item.offset) + } + } else { + ForEach(Array(data.enumerated()), id: \.element.id) { item in + self.content(item.element, item.offset) + } + } + } +} diff --git a/Maccy/Views/PasteStackPreviewView.swift b/Maccy/Views/PasteStackPreviewView.swift index cc4b0d6f..676ba1f3 100644 --- a/Maccy/Views/PasteStackPreviewView.swift +++ b/Maccy/Views/PasteStackPreviewView.swift @@ -23,7 +23,7 @@ struct PasteStackPreviewView: View { var body: some View { ScrollView { LazyVStack(spacing: 0) { - ForEach(Array(pasteStack.items.enumerated()), id: \.element.id) { (index, element) in + EnumeratedForEach(data: pasteStack.items) { (element, index) in PasteStackPreviewItemView( stack: pasteStack, item: element diff --git a/Maccy/Views/PasteStackView.swift b/Maccy/Views/PasteStackView.swift index 6fb7ea0a..8d4aa91c 100644 --- a/Maccy/Views/PasteStackView.swift +++ b/Maccy/Views/PasteStackView.swift @@ -74,7 +74,7 @@ struct PasteStackView: View { var body: some View { let maxItems = min(3, stack.items.count) LazyVStack(spacing: 0) { - ForEach(Array(stack.items[.. Date: Tue, 6 Oct 2026 15:36:56 +0200 Subject: [PATCH 40/59] Hide selection indices for unselected history items --- Maccy/Observables/HistoryItemDecorator.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Maccy/Observables/HistoryItemDecorator.swift b/Maccy/Observables/HistoryItemDecorator.swift index 6567e14b..09dcde9d 100644 --- a/Maccy/Observables/HistoryItemDecorator.swift +++ b/Maccy/Observables/HistoryItemDecorator.swift @@ -69,7 +69,7 @@ class HistoryItemDecorator: Identifiable, Hashable, HasVisibility { private(set) var item: HistoryItem var multiSelectionIndex: Int? { - guard AppState.shared.navigator.isMultiSelectInProgress else { + guard selectionIndex >= 0, AppState.shared.navigator.isMultiSelectInProgress else { return nil } return selectionIndex From 8388176524d5735231d0da3c45437fe051a14a81 Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:36:57 +0200 Subject: [PATCH 41/59] Use EnumeratedForEach for selectable history lists --- Maccy/Views/EnumeratedForEach.swift | 2 +- Maccy/Views/MultipleSelectionListView.swift | 32 ++++----------------- 2 files changed, 7 insertions(+), 27 deletions(-) diff --git a/Maccy/Views/EnumeratedForEach.swift b/Maccy/Views/EnumeratedForEach.swift index a15f18e0..b17c0475 100644 --- a/Maccy/Views/EnumeratedForEach.swift +++ b/Maccy/Views/EnumeratedForEach.swift @@ -1,6 +1,6 @@ import SwiftUI -public struct EnumeratedForEach: View +public struct EnumeratedForEach: DynamicViewContent where Data: RandomAccessCollection, Data.Element: Identifiable, ID: Hashable, Content: View { public var data: Data diff --git a/Maccy/Views/MultipleSelectionListView.swift b/Maccy/Views/MultipleSelectionListView.swift index 6bcc3e83..ec57b8c2 100644 --- a/Maccy/Views/MultipleSelectionListView.swift +++ b/Maccy/Views/MultipleSelectionListView.swift @@ -1,28 +1,15 @@ import SwiftUI -struct IndexedElement: Identifiable, Equatable { - let index: Int - let element: Element - - var id: Element.ID { - element.id - } - - static func == (lhs: Self, rhs: Self) -> Bool { - lhs.element == rhs.element - } -} - struct MultipleSelectionListView: View -where Element: Identifiable & Equatable, RowContent: View, ModifiedForEach: View { +where Element: Identifiable, RowContent: View, ModifiedForEach: View { var items: [Element] var content: (Element?, Element, Element?, Int) -> RowContent - var forEachModifier: (ForEach<[IndexedElement], Element.ID, RowContent>) -> ModifiedForEach + var forEachModifier: (EnumeratedForEach<[Element], Element.ID, RowContent>) -> ModifiedForEach init( items: [Element], @ViewBuilder content: @escaping (Element?, Element, Element?, Int) -> RowContent - ) where ModifiedForEach == ForEach<[IndexedElement], Element.ID, RowContent> { + ) where ModifiedForEach == EnumeratedForEach<[Element], Element.ID, RowContent> { self.items = items self.content = content self.forEachModifier = { $0 } @@ -32,7 +19,7 @@ where Element: Identifiable & Equatable, RowContent: View, ModifiedForEach: View items: [Element], @ViewBuilder content: @escaping (Element?, Element, Element?, Int) -> RowContent, @ViewBuilder forEachModifier: @escaping ( - ForEach<[IndexedElement], Element.ID, RowContent> + EnumeratedForEach<[Element], Element.ID, RowContent> ) -> ModifiedForEach ) { self.items = items @@ -41,19 +28,12 @@ where Element: Identifiable & Equatable, RowContent: View, ModifiedForEach: View } var body: some View { - let indexedItems = items.enumerated().map { - IndexedElement(index: $0.offset, element: $0.element) - } - LazyVStack(spacing: 0) { forEachModifier( - ForEach(indexedItems, id: \.id) { indexed in - let index = indexed.index - let element = indexed.element + EnumeratedForEach(data: items) { element, index in let previous = index > 0 ? items[index - 1] : nil let next = index < items.count - 1 ? items[index + 1] : nil - - content(previous, element, next, index) + return content(previous, element, next, index) } ) } From 78858b7796db21772642a7c653a50908c9eddd1b Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Tue, 6 Oct 2026 15:36:57 +0200 Subject: [PATCH 42/59] Draw selection backgrounds directly in their shape --- Maccy/Views/ListItemView.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Maccy/Views/ListItemView.swift b/Maccy/Views/ListItemView.swift index 4ec5c56d..052a4942 100644 --- a/Maccy/Views/ListItemView.swift +++ b/Maccy/Views/ListItemView.swift @@ -124,8 +124,10 @@ struct ListItemView: View { .foregroundStyle(isSelected ? Color.white : .primary) // macOS 26 broke hovering if no background is present. // The slight opcaity white background is a workaround - .background(isSelected ? Color.accentColor.opacity(0.8) : .white.opacity(0.001)) - .clipShape(selectionAppearance.rect(cornerRadius: Popup.cornerRadius)) + .background( + isSelected ? Color.accentColor.opacity(0.8) : .white.opacity(0.001), + in: selectionAppearance.rect(cornerRadius: Popup.cornerRadius) + ) .accessibilityElement(children: .ignore) .accessibilityLabel(Text(accessibilityLabel)) .accessibilityAddTraits(isSelected ? .isSelected : []) From c0fc7c77814c98b40cdee29b6f6e72d154a31382 Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:12:30 +0200 Subject: [PATCH 43/59] Fix multi-item pin reordering --- Maccy/Views/ReorderableContainer.swift | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/Maccy/Views/ReorderableContainer.swift b/Maccy/Views/ReorderableContainer.swift index b53f2151..f25236e2 100644 --- a/Maccy/Views/ReorderableContainer.swift +++ b/Maccy/Views/ReorderableContainer.swift @@ -65,6 +65,7 @@ private struct DraggableContainerModifier: let moveAction: (IndexSet, Int) -> Void @State private var active: Item? + @State private var lastDropPosition: DropPosition? func body(content: Content) -> some View { content @@ -111,6 +112,7 @@ private struct DraggableContainerModifier: private func resetDragState() { draggedItems = [] active = nil + lastDropPosition = nil AppState.shared.navigator.isDragAndDropInProgress = false } @@ -123,17 +125,20 @@ private struct DraggableContainerModifier: private func move(to item: Any, at edge: ReorderableDropEdge) { guard let item = item as? Item else { return } guard !draggedItems.contains(item), let current = active else { return } + let dropPosition = DropPosition(itemID: item.id, edge: edge) + guard dropPosition != lastDropPosition else { return } + lastDropPosition = dropPosition + guard let currentIndex = items.firstIndex(of: current) else { return } guard let targetIndex = items.firstIndex(of: item) else { return } - let source = sourceIndexes - guard !source.isEmpty else { return } - guard let destinationIndex = reorderDestination( currentIndex: currentIndex, targetIndex: targetIndex, edge: edge ) else { return } + let source = sourceIndexes + guard !source.isEmpty else { return } moveAction(source, destinationIndex) } @@ -146,6 +151,11 @@ private struct DraggableContainerModifier: } } +private struct DropPosition: Equatable { + let itemID: ItemID + let edge: ReorderableDropEdge +} + @available(macOS 26.0, *) private struct DraggableItemModifier: ViewModifier { @Environment(\.reorderableDragContext) private var dragContext @@ -225,7 +235,7 @@ struct ReorderableDragContext { let move: (Any, ReorderableDropEdge) -> Void } -enum ReorderableDropEdge { +enum ReorderableDropEdge: Equatable { case before case after } From dd2738ec0032eb369b4ab6175797461137d5ed14 Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Tue, 6 Oct 2026 16:14:46 +0200 Subject: [PATCH 44/59] Remove native macOS 27 reordering --- Maccy/Views/PinsView.swift | 2 - Maccy/Views/ReorderableContainer.swift | 103 +++---------------------- 2 files changed, 11 insertions(+), 94 deletions(-) diff --git a/Maccy/Views/PinsView.swift b/Maccy/Views/PinsView.swift index e26ce916..eb6e0252 100644 --- a/Maccy/Views/PinsView.swift +++ b/Maccy/Views/PinsView.swift @@ -26,8 +26,6 @@ struct PinsView: View { index: index ) .draggableItem(item) - } forEachModifier: { - $0.reorderableItems() } .draggableContainer( items: items, diff --git a/Maccy/Views/ReorderableContainer.swift b/Maccy/Views/ReorderableContainer.swift index f25236e2..b01adbcc 100644 --- a/Maccy/Views/ReorderableContainer.swift +++ b/Maccy/Views/ReorderableContainer.swift @@ -3,58 +3,6 @@ import UniformTypeIdentifiers typealias Reorderable = Identifiable & Equatable -@available(macOS 27.0, *) -private struct NativeDraggableContainerModifier: ViewModifier where Item.ID: Sendable { - let items: [Item] - @Binding var selection: Selection - @Binding var draggedItems: [Item] - - let moveAction: (IndexSet, Int) -> Void - - func body(content: Content) -> some View { - content - .reorderContainer(for: Item.self, move: move) - .dragContainerSelection(items.filter { selection.items.contains($0) }.map(\.id)) - .onDragSessionUpdated { session in - switch session.phase { - case .initial, .active: - let identifiers = Set(session.draggedItemIDs(for: Item.ID.self)) - draggedItems = items.filter { identifiers.contains($0.id) } - AppState.shared.navigator.isDragAndDropInProgress = true - case .ended, .dataTransferCompleted: - resetDragState() - default: - break - } - } - .onDisappear { - resetDragState() - } - } - - private func move(_ difference: ReorderDifference) { - let identifiers = Set(difference.sources) - let source = IndexSet(items.indices.filter { identifiers.contains(items[$0].id) }) - guard !source.isEmpty else { return } - - let destination: Int - switch difference.destination.position { - case .before(let identifier): - guard let index = items.firstIndex(where: { $0.id == identifier }) else { return } - destination = index - case .end: - destination = items.count - } - - moveAction(source, destination) - } - - private func resetDragState() { - draggedItems = [] - AppState.shared.navigator.isDragAndDropInProgress = false - } -} - private struct DraggableContainerModifier: ViewModifier { let items: [Item] let contentType: UTType @@ -280,54 +228,25 @@ extension View { selection: Binding>, draggedItems: Binding<[Item]>, moveAction: @escaping (IndexSet, Int) -> Void - ) -> some View where Item.ID: Sendable { - if #available(macOS 27.0, *) { - modifier( - NativeDraggableContainerModifier( - items: items, - selection: selection, - draggedItems: draggedItems, - moveAction: moveAction - ) - ) - } else { - modifier( - DraggableContainerModifier( - items: items, - contentType: contentType, - dragPreview: dragPreview, - selection: selection, - draggedItems: draggedItems, - moveAction: moveAction - ) + ) -> some View { + modifier( + DraggableContainerModifier( + items: items, + contentType: contentType, + dragPreview: dragPreview, + selection: selection, + draggedItems: draggedItems, + moveAction: moveAction ) - } + ) } @ViewBuilder func draggableItem(_ item: Item) -> some View { - // macOS 27 natively supports reorderable containers. - // macOS 26 adds onDragSessionUpdated as an API which makes detecting when a drag session - // has ended much more reliable and smoother. The legacy implementation is to bridge that - // gap but should be removed in the future. - if #available(macOS 27.0, *) { - // The enclosing ForEach supplies native drag sources through reorderable(). - self - } else if #available(macOS 26.0, *) { + if #available(macOS 26.0, *) { modifier(DraggableItemModifier(item: item)) } else { modifier(LegacyDraggableItemModifier(item: item)) } } } - -extension DynamicViewContent { - @ViewBuilder - func reorderableItems() -> some View { - if #available(macOS 27.0, *) { - reorderable() - } else { - self - } - } -} From f64c6fc62688c517cb774f1af4af3e3087a5d648 Mon Sep 17 00:00:00 2001 From: KullAxel Date: Tue, 6 Oct 2026 10:06:18 -0700 Subject: [PATCH 45/59] Stop storing Safari WebArchives and other unselected pasteboard types (#1543) Co-authored-by: Alex Rodionov --- Maccy/AppDelegate.swift | 4 ++++ Maccy/Clipboard.swift | 13 +++++++++++-- .../NSPasteboard.PasteboardType+Types.swift | 8 ++++++-- Maccy/Storage.swift | 18 ++++++++++++++++++ MaccyTests/ClipboardTests.swift | 19 +++++++++++++++++++ MaccyTests/HistoryTests.swift | 14 ++++++++++++++ 6 files changed, 72 insertions(+), 4 deletions(-) diff --git a/Maccy/AppDelegate.swift b/Maccy/AppDelegate.swift index 430891ca..332be84c 100644 --- a/Maccy/AppDelegate.swift +++ b/Maccy/AppDelegate.swift @@ -168,6 +168,10 @@ class AppDelegate: NSObject, NSApplicationDelegate { _ = try? Storage.shared.sanitizeTitles() } + ensureMigration(key: "2026-09-15-remove-unpersistable-contents") { + _ = try? Storage.shared.removeUnpersistableContents() + } + // The following defaults are not used in Maccy 2.x // and should be removed in 3.x. // - LaunchAtLogin__hasMigrated diff --git a/Maccy/Clipboard.swift b/Maccy/Clipboard.swift index 14c8e955..abb9c6af 100644 --- a/Maccy/Clipboard.swift +++ b/Maccy/Clipboard.swift @@ -29,6 +29,10 @@ class Clipboard { .concealed, .transient ] + private let unpersistableTypes: Set = [ + .appleWebArchive, + .safariWebArchve + ] private var enabledTypes: Set { Defaults[.enabledPasteboardTypes] } private var disabledTypes: Set { supportedTypes.subtracting(enabledTypes) } @@ -62,6 +66,12 @@ class Clipboard { start() } + func isPersistable(_ type: NSPasteboard.PasteboardType) -> Bool { + return !unpersistableTypes.contains(type) && + !type.rawValue.hasPrefix(microsoftSourcePrefix) && + !type.rawValue.hasPrefix(dynamicTypePrefix) + } + @MainActor func copyInMaccy(_ string: String) { pasteboard.clearContents() @@ -198,8 +208,7 @@ class Clipboard { types = types .subtracting(disabledTypes) - .filter { !$0.rawValue.starts(with: dynamicTypePrefix) } - .filter { !$0.rawValue.starts(with: microsoftSourcePrefix) } + .filter(isPersistable) // Avoid reading Microsoft Word links from bookmarks and cross-references. // https://github.com/p0deje/Maccy/issues/613 diff --git a/Maccy/Extensions/NSPasteboard.PasteboardType+Types.swift b/Maccy/Extensions/NSPasteboard.PasteboardType+Types.swift index 7c1893fb..0da5ae0c 100644 --- a/Maccy/Extensions/NSPasteboard.PasteboardType+Types.swift +++ b/Maccy/Extensions/NSPasteboard.PasteboardType+Types.swift @@ -24,8 +24,12 @@ nonisolated extension NSPasteboard.PasteboardType: Defaults.Serializable { // Safari preview and extra metadata that changes frequently. static let linkPresentationMetadata = NSPasteboard.PasteboardType(rawValue: "com.apple.linkpresentation.metadata") - // swiftlint:disable:next line_length - static let customWebKitPasteboardData = NSPasteboard.PasteboardType(rawValue: "com.apple.WebKit.custom-pasteboard-data") + static let customWebKitPasteboardData = + NSPasteboard.PasteboardType(rawValue: "com.apple.WebKit.custom-pasteboard-data") + + // Safari/WebKit page dumps (tens of MBs) + static let safariWebArchve = NSPasteboard.PasteboardType(rawValue: "com.apple.webarchive") + static let appleWebArchive = NSPasteboard.PasteboardType(rawValue: "Apple Web Archive pasteboard type") // Chromium (VSCode) static let customChromiumWebData = NSPasteboard.PasteboardType(rawValue: "org.chromium.web-custom-data") diff --git a/Maccy/Storage.swift b/Maccy/Storage.swift index 2cd04747..c8585736 100644 --- a/Maccy/Storage.swift +++ b/Maccy/Storage.swift @@ -1,3 +1,4 @@ +import AppKit.NSPasteboard import Foundation import SwiftData @@ -52,6 +53,23 @@ class Storage { return count } + func removeUnpersistableContents() throws -> Int { + let contents = try context.fetch(FetchDescriptor()) + let unwanted = contents.filter { + !Clipboard.shared.isPersistable(NSPasteboard.PasteboardType($0.type)) + } + + guard !unwanted.isEmpty else { + return 0 + } + + unwanted.forEach(context.delete) + context.processPendingChanges() + try context.save() + + return unwanted.count + } + // Titles stored before the sanitization in `HistoryItem.generateTitle()` may // contain scalars that hang CoreText on macOS 26. Such an item makes Maccy // spin at 100% CPU on every launch without ever drawing its window, so the diff --git a/MaccyTests/ClipboardTests.swift b/MaccyTests/ClipboardTests.swift index f442bf47..e88ed257 100644 --- a/MaccyTests/ClipboardTests.swift +++ b/MaccyTests/ClipboardTests.swift @@ -322,6 +322,25 @@ class ClipboardTests: XCTestCase { waitForExpectations(timeout: 2) } + func testDoesNotStoreSafariWebArchive() { + let webArchive = NSPasteboard.PasteboardType.safariWebArchve + let hookExpectation = expectation(description: "Hook is called") + clipboard.onNewCopy({ (item: HistoryItem) in + XCTAssertEqual(Set(item.contents.map(\.type)), [self.stringType.rawValue]) + hookExpectation.fulfill() + }) + + let item = NSPasteboardItem() + item.setString("hello from safari", forType: .string) + item.setData(Data(repeating: 0x61, count: 1024 * 1024), forType: webArchive) + + clipboard.start() + pasteboard.clearContents() + pasteboard.writeObjects([item]) + + waitForExpectations(timeout: 2) + } + private func frontmostAppBundleIdentifier() throws -> String { guard let bundleIdentifier = NSWorkspace.shared.frontmostApplication?.bundleIdentifier else { throw XCTSkip("No frontmost application to attribute a copy to") diff --git a/MaccyTests/HistoryTests.swift b/MaccyTests/HistoryTests.swift index 75fe6a01..693888a0 100644 --- a/MaccyTests/HistoryTests.swift +++ b/MaccyTests/HistoryTests.swift @@ -402,6 +402,20 @@ class HistoryTests: XCTestCase { // swiftlint:disable:this type_body_length try assertStorageCounts(items: 1, contents: 1) } + func testRemovingUnpersistableContents() throws { + let live = history.add(historyItem("live")) + live.item.contents.append(HistoryItemContent( + type: NSPasteboard.PasteboardType.safariWebArchve.rawValue, + value: Data(repeating: 0x61, count: 1024) + )) + try Storage.shared.context.save() + + XCTAssertEqual(try Storage.shared.removeUnpersistableContents(), 1) + XCTAssertEqual(try Storage.shared.removeUnpersistableContents(), 0) + XCTAssertEqual(live.item.contents.map(\.type), [NSPasteboard.PasteboardType.string.rawValue]) + try assertStorageCounts(items: 1, contents: 1) + } + private func assertStorageCounts( items: Int, contents: Int, From 130f23a80876d06e2daa074c5089cbc901fa67a8 Mon Sep 17 00:00:00 2001 From: Alex Rodionov Date: Tue, 6 Oct 2026 10:42:14 -0700 Subject: [PATCH 46/59] Fix SwiftLint warnings --- Maccy/AppDelegate.swift | 9 +++++---- Maccy/Observables/HistoryItemDecorator.swift | 16 ++++++++++++++-- Maccy/Observables/NavigationManager.swift | 4 +++- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/Maccy/AppDelegate.swift b/Maccy/AppDelegate.swift index 332be84c..11af1322 100644 --- a/Maccy/AppDelegate.swift +++ b/Maccy/AppDelegate.swift @@ -22,10 +22,11 @@ class AppDelegate: NSObject, NSApplicationDelegate { // dynamic `title` (recent copy text) so VoiceOver always announces something // meaningful even when that preference is off or the app is disabled. private func updateStatusItemAccessibilityLabel() { - let base = NSLocalizedString("status_item_accessibility_label", comment: "") - statusItem.button?.setAccessibilityLabel( - isStatusItemDisabled ? "\(base) — \(NSLocalizedString("status_item_disabled_accessibility_suffix", comment: ""))" : base - ) + var accessibilityLabel = NSLocalizedString("status_item_accessibility_label", comment: "") + if isStatusItemDisabled { + accessibilityLabel += " — \(NSLocalizedString("status_item_disabled_accessibility_suffix", comment: ""))" + } + statusItem.button?.setAccessibilityLabel(accessibilityLabel) } private var isStatusItemDisabled: Bool { diff --git a/Maccy/Observables/HistoryItemDecorator.swift b/Maccy/Observables/HistoryItemDecorator.swift index 09dcde9d..2ec9764d 100644 --- a/Maccy/Observables/HistoryItemDecorator.swift +++ b/Maccy/Observables/HistoryItemDecorator.swift @@ -80,7 +80,13 @@ class HistoryItemDecorator: Identifiable, Hashable, HasVisibility { var parts: [String] = [] if hasImage, let image = item.image { let size = image.pixelSize - parts.append(String(format: NSLocalizedString("history_item_image_accessibility_label_no_app", comment: ""), Int(size.width), Int(size.height))) + parts.append( + String( + format: NSLocalizedString("history_item_image_accessibility_label_no_app", comment: ""), + Int(size.width), + Int(size.height) + ) + ) } else { parts.append(title) } @@ -91,7 +97,13 @@ class HistoryItemDecorator: Identifiable, Hashable, HasVisibility { parts.append(NSLocalizedString("history_item_pinned_accessibility_value", comment: "")) } if let index = multiSelectionIndex { - parts.append(String(format: NSLocalizedString("history_item_selected_accessibility_value", comment: ""), index + 1, AppState.shared.navigator.selection.count)) + parts.append( + String( + format: NSLocalizedString("history_item_selected_accessibility_value", comment: ""), + index + 1, + AppState.shared.navigator.selection.count + ) + ) } return parts.joined(separator: ", ") } diff --git a/Maccy/Observables/NavigationManager.swift b/Maccy/Observables/NavigationManager.swift index eaab14f2..58ce7ab8 100644 --- a/Maccy/Observables/NavigationManager.swift +++ b/Maccy/Observables/NavigationManager.swift @@ -35,7 +35,9 @@ class NavigationManager { // swiftlint:disable:this type_body_length // Announce the visual selection change, keeping repeated navigation updates concise. if let item = leadHistoryItem { announceForAccessibility { - var parts = [item.hasImage ? NSLocalizedString("history_item_image_accessibility_generic", comment: "") : item.title] + var parts = [item.hasImage ? + NSLocalizedString("history_item_image_accessibility_generic", comment: "") : + item.title] if let application = item.application { parts.append(application) } From a2055c34ea5208fc0344b70e1d9b79b6ebbad3c9 Mon Sep 17 00:00:00 2001 From: Anand Hegde Date: Tue, 6 Oct 2026 23:45:57 +0530 Subject: [PATCH 47/59] Allow ignoring apps outside /Applications (#1540) The app picker in the Ignore settings read the selected bundle with Bundle(path:) without starting access to the security-scoped URL. The sandbox allows reading /Applications, so those apps worked, but apps elsewhere (e.g. ~/Applications) returned no bundle and were silently dropped. Fixes #991 --- .../IgnoreApplicationsSettingsView.swift | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Maccy/Settings/IgnoreSettingsPane/IgnoreApplicationsSettingsView.swift b/Maccy/Settings/IgnoreSettingsPane/IgnoreApplicationsSettingsView.swift index 9dcda0ba..7ddb6765 100644 --- a/Maccy/Settings/IgnoreSettingsPane/IgnoreApplicationsSettingsView.swift +++ b/Maccy/Settings/IgnoreSettingsPane/IgnoreApplicationsSettingsView.swift @@ -50,6 +50,15 @@ struct IgnoreApplicationsSettingsView: View { ) { result in switch result { case .success(let appUrl): + // Sandbox only allows reading apps outside /Applications + // (e.g. ~/Applications) through the security-scoped URL. + let isAccessing = appUrl.startAccessingSecurityScopedResource() + defer { + if isAccessing { + appUrl.stopAccessingSecurityScopedResource() + } + } + if let bundle = Bundle(path: appUrl.path), let bundleIdentifier = bundle.bundleIdentifier, !ignoredApps.contains(bundleIdentifier) { From a92c11ae3e7a86a57dc6359bad59e330e4625ede Mon Sep 17 00:00:00 2001 From: Jannis Weis <31143295+weisJ@users.noreply.github.com> Date: Tue, 6 Oct 2026 22:24:18 +0200 Subject: [PATCH 48/59] Restore accidentally deleted comment --- Maccy/Views/ReorderableContainer.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Maccy/Views/ReorderableContainer.swift b/Maccy/Views/ReorderableContainer.swift index b01adbcc..908962f5 100644 --- a/Maccy/Views/ReorderableContainer.swift +++ b/Maccy/Views/ReorderableContainer.swift @@ -243,6 +243,9 @@ extension View { @ViewBuilder func draggableItem(_ item: Item) -> some View { + // macOS 26 adds onDragSessionUpdated as an API which makes detecting when a drag session + // has ended much more reliable and smoother. The legacy implementation is to bridge that + // gap but should be removed in the future. if #available(macOS 26.0, *) { modifier(DraggableItemModifier(item: item)) } else { From 0dd54788f45bfd1c11937af87f3f9a194e397beb Mon Sep 17 00:00:00 2001 From: Anthony Panozzo Date: Mon, 12 Oct 2026 10:33:15 -0400 Subject: [PATCH 49/59] Use accurate OCR with automatic language detection (#1586) .fast garbles small text and only supports a few Latin-script languages. .accurate alone still drops Chinese, Japanese, etc. because recognitionLanguages defaults to English only, so also enable automaticallyDetectsLanguage. Fixes #1514 --- Maccy/Models/HistoryItem.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Maccy/Models/HistoryItem.swift b/Maccy/Models/HistoryItem.swift index 1768acb6..6447c9d9 100644 --- a/Maccy/Models/HistoryItem.swift +++ b/Maccy/Models/HistoryItem.swift @@ -246,7 +246,8 @@ class HistoryItem { let requestHandler = VNImageRequestHandler(data: data) let request = VNRecognizeTextRequest() - request.recognitionLevel = .fast + request.recognitionLevel = .accurate + request.automaticallyDetectsLanguage = true do { try requestHandler.perform([request]) From d62d0bf8268d57cc28b24a976d3443211c740ed5 Mon Sep 17 00:00:00 2001 From: Denis Mordvinov <8133967+Sublio@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:23:52 +0200 Subject: [PATCH 50/59] Fix Swift 6.4 concurrency warnings (#1585) --- Maccy/AppDelegate.swift | 4 +++- Maccy/Notifier.swift | 9 +++++--- Maccy/Observables/History.swift | 12 +++++------ Maccy/SoftwareUpdater.swift | 8 ++++--- Maccy/Views/HeightReaderModifier.swift | 7 ++++--- Maccy/Views/LegacyReorderableContainer.swift | 6 ++++-- Maccy/Views/ReorderableContainer.swift | 3 ++- MaccyTests/ClipboardTests.swift | 4 +++- MaccyTests/HistoryTests.swift | 4 +++- MaccyUITests/MaccyUITests.swift | 22 ++++++++++++++++++++ 10 files changed, 58 insertions(+), 21 deletions(-) diff --git a/Maccy/AppDelegate.swift b/Maccy/AppDelegate.swift index 11af1322..815013ae 100644 --- a/Maccy/AppDelegate.swift +++ b/Maccy/AppDelegate.swift @@ -223,7 +223,9 @@ class AppDelegate: NSObject, NSApplicationDelegate { object: nil, queue: nil ) { notification in - if let name = notification.userInfo?["name"] as? KeyboardShortcuts.Name, names.contains(name) { + guard let name = notification.userInfo?["name"] as? KeyboardShortcuts.Name, + names.contains(name) else { return } + MainActor.assumeIsolated { KeyboardShortcuts.disable(name) } } diff --git a/Maccy/Notifier.swift b/Maccy/Notifier.swift index 7ab31072..c1cefbad 100644 --- a/Maccy/Notifier.swift +++ b/Maccy/Notifier.swift @@ -2,7 +2,7 @@ import AppKit import UserNotifications class Notifier { - private static var center: UNUserNotificationCenter { UNUserNotificationCenter.current() } + nonisolated private static var center: UNUserNotificationCenter { UNUserNotificationCenter.current() } static func authorize() { center.requestAuthorization(options: [.alert, .sound]) { _, error in @@ -21,8 +21,11 @@ class Notifier { guard (settings.authorizationStatus == .authorized) || (settings.authorizationStatus == .provisional) else { return } + let isAlertEnabled = settings.alertSetting == .enabled + let isSoundEnabled = settings.soundSetting == .enabled + let content = UNMutableNotificationContent() - if settings.alertSetting == .enabled { + if isAlertEnabled { content.body = body } @@ -31,7 +34,7 @@ class Notifier { if error != nil { NSLog("Failed to deliver notification: \(String(describing: error))") } else { - if settings.soundSetting == .enabled { + if isSoundEnabled { sound?.play() } } diff --git a/Maccy/Observables/History.swift b/Maccy/Observables/History.swift index f3e00ba3..f3316065 100644 --- a/Maccy/Observables/History.swift +++ b/Maccy/Observables/History.swift @@ -110,7 +110,7 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length Task { for await _ in Defaults.updates(.showSpecialSymbols, initial: false) { for item in items { - await updateTitle(item: item, title: item.item.generateTitle()) + updateTitle(item: item, title: item.item.generateTitle()) } } } @@ -118,7 +118,7 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length Task { for await _ in Defaults.updates(.imageMaxHeight, initial: false) { for item in items { - await item.cleanupImages() + item.cleanupImages() } } } @@ -444,15 +444,15 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length Task { if stack.modifierFlags.isEmpty { - await Clipboard.shared.copy(item.item, removeFormatting: Defaults[.removeFormattingByDefault]) + Clipboard.shared.copy(item.item, removeFormatting: Defaults[.removeFormattingByDefault]) } else { switch HistoryItemAction(stack.modifierFlags) { case .copy: - await Clipboard.shared.copy(item.item) + Clipboard.shared.copy(item.item) case .paste: - await Clipboard.shared.copy(item.item) + Clipboard.shared.copy(item.item) case .pasteWithoutFormatting: - await Clipboard.shared.copy(item.item, removeFormatting: true) + Clipboard.shared.copy(item.item, removeFormatting: true) case .unknown: return } diff --git a/Maccy/SoftwareUpdater.swift b/Maccy/SoftwareUpdater.swift index 9f686562..ce7caed3 100644 --- a/Maccy/SoftwareUpdater.swift +++ b/Maccy/SoftwareUpdater.swift @@ -22,12 +22,14 @@ class SoftwareUpdater { automaticallyChecksForUpdatesObservation = updater.observe( \.automaticallyChecksForUpdates, options: [.initial, .new, .old] - ) { [unowned self] updater, change in - guard change.newValue != change.oldValue else { + ) { [unowned self] _, change in + guard change.newValue != change.oldValue, let newValue = change.newValue else { return } - self.automaticallyChecksForUpdates = updater.automaticallyChecksForUpdates + MainActor.assumeIsolated { + self.automaticallyChecksForUpdates = newValue + } } } diff --git a/Maccy/Views/HeightReaderModifier.swift b/Maccy/Views/HeightReaderModifier.swift index 818ee4e4..e52d1cc1 100644 --- a/Maccy/Views/HeightReaderModifier.swift +++ b/Maccy/Views/HeightReaderModifier.swift @@ -1,11 +1,12 @@ import SwiftUI -struct SizeReaderModifier: ViewModifier { +struct SizeReaderModifier: ViewModifier { @Binding var value: Value - let mapper: (CGSize) -> Value + let mapper: @Sendable (CGSize) -> Value func body(content: Content) -> some View { - content.onGeometryChange(for: Value.self) { proxy in + let mapper = mapper + return content.onGeometryChange(for: Value.self) { proxy in mapper(proxy.size) } action: { newValue in value = newValue diff --git a/Maccy/Views/LegacyReorderableContainer.swift b/Maccy/Views/LegacyReorderableContainer.swift index 86166ed4..71fa4035 100644 --- a/Maccy/Views/LegacyReorderableContainer.swift +++ b/Maccy/Views/LegacyReorderableContainer.swift @@ -126,7 +126,7 @@ private final class DragSourceView: NSView, NSDraggingSource { } } - private var localEventMonitor: Any? + nonisolated(unsafe) private var localEventMonitor: Any? private var mouseDownEvent: NSEvent? private var isDragging = false @@ -161,7 +161,9 @@ private final class DragSourceView: NSView, NSDraggingSource { } deinit { - removeEventMonitor() + if let localEventMonitor { + NSEvent.removeMonitor(localEventMonitor) + } } private func handle(_ event: NSEvent) -> NSEvent? { diff --git a/Maccy/Views/ReorderableContainer.swift b/Maccy/Views/ReorderableContainer.swift index 908962f5..f6c65e89 100644 --- a/Maccy/Views/ReorderableContainer.swift +++ b/Maccy/Views/ReorderableContainer.swift @@ -164,11 +164,12 @@ private func itemProvider( contentType: UTType ) -> NSItemProvider { let provider = NSItemProvider() + let data = String(describing: item.id).data(using: .utf8) provider.registerDataRepresentation( forTypeIdentifier: contentType.identifier, visibility: .ownProcess ) { completion in - completion(String(describing: item.id).data(using: .utf8), nil) + completion(data, nil) return nil } return provider diff --git a/MaccyTests/ClipboardTests.swift b/MaccyTests/ClipboardTests.swift index e88ed257..03c67d0b 100644 --- a/MaccyTests/ClipboardTests.swift +++ b/MaccyTests/ClipboardTests.swift @@ -41,7 +41,9 @@ class ClipboardTests: XCTestCase { Defaults[.ignoreAllAppsExceptListed] = savedIgnoreAllAppsExceptListed Defaults[.ignoredApps] = savedIgnoredApps Defaults[.ignoredPasteboardTypes] = savedIgnoredPasteboardTypes - clipboard.clearHooks() + MainActor.assumeIsolated { + Clipboard.shared.clearHooks() + } } func testChangesListenerAndAddHooks() { diff --git a/MaccyTests/HistoryTests.swift b/MaccyTests/HistoryTests.swift index 693888a0..33b09eaa 100644 --- a/MaccyTests/HistoryTests.swift +++ b/MaccyTests/HistoryTests.swift @@ -13,7 +13,9 @@ class HistoryTests: XCTestCase { // swiftlint:disable:this type_body_length override func setUp() { super.setUp() - history.clearAll() + MainActor.assumeIsolated { + History.shared.clearAll() + } Defaults[.size] = 10 Defaults[.sortBy] = .firstCopiedAt Defaults[.pinOrder] = PinOrder() diff --git a/MaccyUITests/MaccyUITests.swift b/MaccyUITests/MaccyUITests.swift index ecbc08ac..6a99a833 100644 --- a/MaccyUITests/MaccyUITests.swift +++ b/MaccyUITests/MaccyUITests.swift @@ -319,6 +319,20 @@ class MaccyUITests: XCTestCase { XCTAssertEqual(Array(itemTitles.prefix(2)), [copy1, copy2]) } + func testReorderPinsByDragging() { + popUpWithMouse() + pin(copy1) + pin(copy2) + XCTAssertEqual(Array(itemTitles.prefix(2)), [copy1, copy2]) + + drag(items[copy2].firstMatch, before: items[copy1].firstMatch) + XCTAssertEqual(Array(itemTitles.prefix(2)), [copy2, copy1]) + + app.typeKey(.escape, modifierFlags: []) + popUpWithMouse() + XCTAssertEqual(Array(itemTitles.prefix(2)), [copy2, copy1]) + } + func testRemoveLastWordFromSearchWithControlW() { popUpWithMouse() search("foo bar") @@ -676,6 +690,14 @@ class MaccyUITests: XCTestCase { usleep(1_500_000) } + private func drag(_ element: XCUIElement, before target: XCUIElement) { + hover(element) + let source = element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) + let destination = target.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.2)) + source.click(forDuration: 0.5, thenDragTo: destination, withVelocity: .slow, thenHoldForDuration: 0.5) + usleep(500_000) + } + private func hoverAndClick(_ element: XCUIElement) { let coordinate = element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) coordinate.hover() From fe0d74d2a622bdadb6513b8bf8fe4aeb0609f6f2 Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Wed, 14 Oct 2026 05:59:50 +0200 Subject: [PATCH 51/59] Improve Hindi translation (#1546) --- Maccy/Settings/hi.lproj/AdvancedSettings.strings | 4 ++-- Maccy/Settings/hi.lproj/AppearanceSettings.strings | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Maccy/Settings/hi.lproj/AdvancedSettings.strings b/Maccy/Settings/hi.lproj/AdvancedSettings.strings index de0e8d0f..8b62fed1 100644 --- a/Maccy/Settings/hi.lproj/AdvancedSettings.strings +++ b/Maccy/Settings/hi.lproj/AdvancedSettings.strings @@ -2,9 +2,9 @@ "TurnOff" = "बंद करें"; "TurnOffDescription" = "सभी नई कॉपी को अस्थायी रूप से अनदेखा करें।\nआप इसे प्रोग्रामेटिक रूप से उपयोग करने और संवेदनशील डेटा की कॉपी बनाते समय एप्लिकेशन को अक्षम करने की संभावना रखते हैं।"; "TurnOffShellScript" = "defaults write org.p0deje.Maccy ignoreEvents true\n# डेटा कॉपी करें\ndefaults write org.p0deje.Maccy ignoreEvents false"; -"TurnOffViaMenuIconDescription" = ""; +"TurnOffViaMenuIconDescription" = "आप मेनू आइकन पर क्लिक करने के लिए ⌥ बटन को दबाए रख सकते हैं।\nकेवल अगली कॉपी को अनदेखा करने के लिए, ⌥ बटन को दबाए रखकर क्लिक करें ⌥⇧।"; "TurnOffNextShellScript" = ""; -"ClearHistoryOnQuit" = ""; +"ClearHistoryOnQuit" = "बंद करने पर इतिहास मिटाएं"; "ClearHistoryOnQuitTooltip" = ""; "ClearSystemClipboard" = ""; "ClearSystemClipboardTooltip" = ""; diff --git a/Maccy/Settings/hi.lproj/AppearanceSettings.strings b/Maccy/Settings/hi.lproj/AppearanceSettings.strings index d0d345fd..58b57e38 100644 --- a/Maccy/Settings/hi.lproj/AppearanceSettings.strings +++ b/Maccy/Settings/hi.lproj/AppearanceSettings.strings @@ -1,7 +1,7 @@ "Title" = "दिखावट"; "PopupAt" = ""; -"PopupAtCursor" = ""; -"PopupAtMenuBarIcon" = ""; +"PopupAtCursor" = "तीर"; +"PopupAtMenuBarIcon" = "मेनू की तस्वीर"; "PopupAtWindowCenter" = ""; "PopupAtScreenCenter" = ""; "PopupAtLastPosition" = ""; From 07901894d7f537b541a9ebd5846b31707a0cda2e Mon Sep 17 00:00:00 2001 From: David Sanchez Date: Wed, 14 Oct 2026 10:00:49 -0400 Subject: [PATCH 52/59] Show image size in preview (#1589) The preview already shows the pixel dimensions of image items, but not how large the image actually is. Knowing the size helps before pasting into places with upload limits. --- Maccy/Observables/HistoryItemDecorator.swift | 5 +++++ Maccy/Views/PreviewItemView.swift | 7 +++++++ Maccy/Views/ar.lproj/PreviewItemView.strings | 1 + Maccy/Views/be.lproj/PreviewItemView.strings | 1 + Maccy/Views/bn.lproj/PreviewItemView.strings | 1 + Maccy/Views/bs.lproj/PreviewItemView.strings | 1 + Maccy/Views/ca.lproj/PreviewItemView.strings | 1 + Maccy/Views/ckb.lproj/PreviewItemView.strings | 1 + Maccy/Views/cs.lproj/PreviewItemView.strings | 1 + Maccy/Views/de.lproj/PreviewItemView.strings | 1 + Maccy/Views/el.lproj/PreviewItemView.strings | 1 + Maccy/Views/en.lproj/PreviewItemView.strings | 1 + Maccy/Views/eo.lproj/PreviewItemView.strings | 1 + Maccy/Views/es.lproj/PreviewItemView.strings | 1 + Maccy/Views/fa.lproj/PreviewItemView.strings | 1 + Maccy/Views/fi.lproj/PreviewItemView.strings | 1 + Maccy/Views/fr.lproj/PreviewItemView.strings | 1 + Maccy/Views/he.lproj/PreviewItemView.strings | 1 + Maccy/Views/hi.lproj/PreviewItemView.strings | 1 + Maccy/Views/hr.lproj/PreviewItemView.strings | 1 + Maccy/Views/hu.lproj/PreviewItemView.strings | 1 + Maccy/Views/id.lproj/PreviewItemView.strings | 1 + Maccy/Views/it.lproj/PreviewItemView.strings | 1 + Maccy/Views/ja.lproj/PreviewItemView.strings | 1 + Maccy/Views/km.lproj/PreviewItemView.strings | 1 + Maccy/Views/ko.lproj/PreviewItemView.strings | 1 + Maccy/Views/lt.lproj/PreviewItemView.strings | 1 + Maccy/Views/lv.lproj/PreviewItemView.strings | 1 + Maccy/Views/nb.lproj/PreviewItemView.strings | 1 + Maccy/Views/nl.lproj/PreviewItemView.strings | 1 + Maccy/Views/pl.lproj/PreviewItemView.strings | 1 + Maccy/Views/pt-BR.lproj/PreviewItemView.strings | 1 + Maccy/Views/pt.lproj/PreviewItemView.strings | 1 + Maccy/Views/ro.lproj/PreviewItemView.strings | 1 + Maccy/Views/ru.lproj/PreviewItemView.strings | 1 + Maccy/Views/sl.lproj/PreviewItemView.strings | 1 + Maccy/Views/sv.lproj/PreviewItemView.strings | 1 + Maccy/Views/ta.lproj/PreviewItemView.strings | 1 + Maccy/Views/th.lproj/PreviewItemView.strings | 1 + Maccy/Views/tr.lproj/PreviewItemView.strings | 1 + Maccy/Views/uk.lproj/PreviewItemView.strings | 1 + Maccy/Views/uz.lproj/PreviewItemView.strings | 1 + Maccy/Views/vi.lproj/PreviewItemView.strings | 1 + Maccy/Views/zh-Hans.lproj/PreviewItemView.strings | 1 + Maccy/Views/zh-Hant.lproj/PreviewItemView.strings | 1 + 45 files changed, 55 insertions(+) diff --git a/Maccy/Observables/HistoryItemDecorator.swift b/Maccy/Observables/HistoryItemDecorator.swift index 2ec9764d..3698243e 100644 --- a/Maccy/Observables/HistoryItemDecorator.swift +++ b/Maccy/Observables/HistoryItemDecorator.swift @@ -53,6 +53,11 @@ class HistoryItemDecorator: Identifiable, Hashable, HasVisibility { var thumbnailImage: NSImage? var applicationImage: ApplicationImage + @ObservationIgnored + private(set) lazy var imageSize: String? = item.imageData.map { + ByteCountFormatter.string(fromByteCount: Int64($0.count), countStyle: .file) + } + // 10k characters seems to be more than enough on large displays var text: String { previewText.string.shortened(to: 10_000) } diff --git a/Maccy/Views/PreviewItemView.swift b/Maccy/Views/PreviewItemView.swift index 341c0f03..ac93ed33 100644 --- a/Maccy/Views/PreviewItemView.swift +++ b/Maccy/Views/PreviewItemView.swift @@ -89,6 +89,13 @@ struct PreviewItemView: View { } } + if item.hasImage, let size = item.imageSize { + HStack(spacing: 3) { + Text("Size", tableName: "PreviewItemView") + Text(size) + } + } + HStack(spacing: 3) { Text("FirstCopyTime", tableName: "PreviewItemView") Text(item.item.firstCopiedAt, style: .date) diff --git a/Maccy/Views/ar.lproj/PreviewItemView.strings b/Maccy/Views/ar.lproj/PreviewItemView.strings index d957d498..0527623e 100644 --- a/Maccy/Views/ar.lproj/PreviewItemView.strings +++ b/Maccy/Views/ar.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "اضغط على {previewKey} لإظهار المعاينة."; "ClosePreview" = "اضغط على {previewKey} لإخفاء المعاينة."; "Dimensions" = "الأبعاد:"; +"Size" = "الحجم:"; "CopyExtractedText" = "انسخ النص المستخرج."; "EditItem" = "تعديل العنصر"; diff --git a/Maccy/Views/be.lproj/PreviewItemView.strings b/Maccy/Views/be.lproj/PreviewItemView.strings index b0169809..b3483b07 100644 --- a/Maccy/Views/be.lproj/PreviewItemView.strings +++ b/Maccy/Views/be.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "Націсніце {previewKey}, каб паказаць перадпрагляд."; "ClosePreview" = "Націсніце {previewKey}, каб схаваць перадпрагляд."; "Dimensions" = "Памеры:"; +"Size" = "Памер:"; "CopyExtractedText" = "Скапіяваць распазнаны тэкст."; "EditItem" = "Рэдагаваць элемент"; diff --git a/Maccy/Views/bn.lproj/PreviewItemView.strings b/Maccy/Views/bn.lproj/PreviewItemView.strings index c03739ad..2c8125f5 100644 --- a/Maccy/Views/bn.lproj/PreviewItemView.strings +++ b/Maccy/Views/bn.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "প্রিভিউ দেখাতে {previewKey} চাপুন।"; "ClosePreview" = "প্রিভিউ লুকাতে {previewKey} চাপুন।"; "Dimensions" = "মাত্রা:"; +"Size" = "আকার:"; "CopyExtractedText" = "শনাক্ত করা লেখা কপি করুন."; "EditItem" = "আইটেম সম্পাদনা করুন"; diff --git a/Maccy/Views/bs.lproj/PreviewItemView.strings b/Maccy/Views/bs.lproj/PreviewItemView.strings index 361ad121..574f2255 100644 --- a/Maccy/Views/bs.lproj/PreviewItemView.strings +++ b/Maccy/Views/bs.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "Pritisnite {previewKey} za prikaz pregleda."; "ClosePreview" = "Pritisnite {previewKey} za skrivanje pregleda."; "Dimensions" = "Dimenzije:"; +"Size" = "Veličina:"; "CopyExtractedText" = "Kopiraj izdvojeni tekst."; "EditItem" = "Uredi stavku"; diff --git a/Maccy/Views/ca.lproj/PreviewItemView.strings b/Maccy/Views/ca.lproj/PreviewItemView.strings index 1426ed88..6b6d8690 100644 --- a/Maccy/Views/ca.lproj/PreviewItemView.strings +++ b/Maccy/Views/ca.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "Prem {previewKey} per mostrar la previsualització."; "ClosePreview" = "Prem {previewKey} per amagar la previsualització."; "Dimensions" = "Dimensions:"; +"Size" = "Mida:"; "CopyExtractedText" = "Copia el text extret."; "EditItem" = "Edita l’element"; diff --git a/Maccy/Views/ckb.lproj/PreviewItemView.strings b/Maccy/Views/ckb.lproj/PreviewItemView.strings index 1e7d1054..73317a7f 100644 --- a/Maccy/Views/ckb.lproj/PreviewItemView.strings +++ b/Maccy/Views/ckb.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "{previewKey} دابگرە بۆ پیشاندانی پێشبینین."; "ClosePreview" = "{previewKey} دابگرە بۆ شاردنەوەی پێشبینین."; "Dimensions" = "قەبارە:"; +"Size" = "قەبارەی فایل:"; "CopyExtractedText" = "دەقی دەرهێنراو کۆپی بکە."; "EditItem" = "دەستکاری بڕگە"; diff --git a/Maccy/Views/cs.lproj/PreviewItemView.strings b/Maccy/Views/cs.lproj/PreviewItemView.strings index 68d15e99..c4dfa120 100644 --- a/Maccy/Views/cs.lproj/PreviewItemView.strings +++ b/Maccy/Views/cs.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "Stiskni {previewKey} pro zobrazení náhledu."; "ClosePreview" = "Stiskni {previewKey} pro skrytí náhledu."; "Dimensions" = "Rozměry:"; +"Size" = "Velikost:"; "CopyExtractedText" = "Zkopírovat extrahovaný text."; "EditItem" = "Upravit položku"; diff --git a/Maccy/Views/de.lproj/PreviewItemView.strings b/Maccy/Views/de.lproj/PreviewItemView.strings index 73eafd17..76d116ef 100644 --- a/Maccy/Views/de.lproj/PreviewItemView.strings +++ b/Maccy/Views/de.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "Drücke {previewKey}, um die Vorschau anzuzeigen."; "ClosePreview" = "Drücke {previewKey}, um die Vorschau auszublenden."; "Dimensions" = "Abmessungen:"; +"Size" = "Größe:"; "CopyExtractedText" = "Extrahierten Text kopieren."; "EditItem" = "Eintrag bearbeiten"; diff --git a/Maccy/Views/el.lproj/PreviewItemView.strings b/Maccy/Views/el.lproj/PreviewItemView.strings index f2a25ded..29639bf9 100644 --- a/Maccy/Views/el.lproj/PreviewItemView.strings +++ b/Maccy/Views/el.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "Πατήστε {previewKey} για εμφάνιση της προεπισκόπησης."; "ClosePreview" = "Πατήστε {previewKey} για απόκρυψη της προεπισκόπησης."; "Dimensions" = "Διαστάσεις:"; +"Size" = "Μέγεθος:"; "CopyExtractedText" = "Αντιγραφή εξαγμένου κειμένου."; "EditItem" = "Επεξεργασία στοιχείου"; diff --git a/Maccy/Views/en.lproj/PreviewItemView.strings b/Maccy/Views/en.lproj/PreviewItemView.strings index bab03adf..6ca1f46b 100644 --- a/Maccy/Views/en.lproj/PreviewItemView.strings +++ b/Maccy/Views/en.lproj/PreviewItemView.strings @@ -3,6 +3,7 @@ "LastCopyTime" = "Last copy time:"; "NumberOfCopies" = "Number of copies:"; "Dimensions" = "Dimensions:"; +"Size" = "Size:"; "PinKey" = "Press {pinKey} to pin."; "UnpinKey" = "Press {pinKey} to unpin."; "DeleteKey" = "Press {deleteKey} to delete."; diff --git a/Maccy/Views/eo.lproj/PreviewItemView.strings b/Maccy/Views/eo.lproj/PreviewItemView.strings index f2207c3b..9509800f 100644 --- a/Maccy/Views/eo.lproj/PreviewItemView.strings +++ b/Maccy/Views/eo.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "Premu {previewKey} por montri la antaŭrigardon."; "ClosePreview" = "Premu {previewKey} por kaŝi la antaŭrigardon."; "Dimensions" = "Dimensioj:"; +"Size" = "Grandeco:"; "CopyExtractedText" = "Kopii eltiritan tekston."; "EditItem" = "Redakti eron"; diff --git a/Maccy/Views/es.lproj/PreviewItemView.strings b/Maccy/Views/es.lproj/PreviewItemView.strings index 14b23860..4222caca 100644 --- a/Maccy/Views/es.lproj/PreviewItemView.strings +++ b/Maccy/Views/es.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "Presiona {previewKey} para mostrar la vista previa."; "ClosePreview" = "Presiona {previewKey} para ocultar la vista previa."; "Dimensions" = "Dimensiones:"; +"Size" = "Tamaño:"; "CopyExtractedText" = "Copiar texto extraído."; "EditItem" = "Editar elemento"; diff --git a/Maccy/Views/fa.lproj/PreviewItemView.strings b/Maccy/Views/fa.lproj/PreviewItemView.strings index 470041d9..e3c6d257 100644 --- a/Maccy/Views/fa.lproj/PreviewItemView.strings +++ b/Maccy/Views/fa.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "برای نمایش پیش‌نمایش، کلید {previewKey} را فشار دهید."; "ClosePreview" = "برای پنهان کردن پیش‌نمایش، کلید {previewKey} را فشار دهید."; "Dimensions" = "ابعاد:"; +"Size" = "حجم:"; "CopyExtractedText" = "متن استخراج‌شده را کپی کنید."; "EditItem" = "ویرایش مورد"; diff --git a/Maccy/Views/fi.lproj/PreviewItemView.strings b/Maccy/Views/fi.lproj/PreviewItemView.strings index fc9c6044..05213d4f 100644 --- a/Maccy/Views/fi.lproj/PreviewItemView.strings +++ b/Maccy/Views/fi.lproj/PreviewItemView.strings @@ -7,3 +7,4 @@ "OpenPreview" = "Paina {previewKey} näyttääksesi esikatselun."; "ClosePreview" = "Paina {previewKey} piilottaaksesi esikatselun."; "EditItem" = "Muokkaa kohdetta"; +"Size" = "Koko:"; diff --git a/Maccy/Views/fr.lproj/PreviewItemView.strings b/Maccy/Views/fr.lproj/PreviewItemView.strings index c3cb4e7c..d8591bdb 100644 --- a/Maccy/Views/fr.lproj/PreviewItemView.strings +++ b/Maccy/Views/fr.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "Appuyez sur {previewKey} pour afficher l'aperçu."; "ClosePreview" = "Appuyez sur {previewKey} pour masquer l'aperçu."; "Dimensions" = "Dimensions :"; +"Size" = "Taille :"; "CopyExtractedText" = "Copier le texte extrait."; "EditItem" = "Modifier l’élément"; diff --git a/Maccy/Views/he.lproj/PreviewItemView.strings b/Maccy/Views/he.lproj/PreviewItemView.strings index 643c37f2..858cd8ae 100644 --- a/Maccy/Views/he.lproj/PreviewItemView.strings +++ b/Maccy/Views/he.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "יש ללחוץ על {previewKey} כדי להציג תצוגה מקדימה."; "ClosePreview" = "יש ללחוץ על {previewKey} כדי להסתיר תצוגה מקדימה."; "Dimensions" = "ממדים:"; +"Size" = "גודל:"; "CopyExtractedText" = "העתק את הטקסט שחולץ."; "EditItem" = "עריכת פריט"; diff --git a/Maccy/Views/hi.lproj/PreviewItemView.strings b/Maccy/Views/hi.lproj/PreviewItemView.strings index 3a310fc8..03061a60 100644 --- a/Maccy/Views/hi.lproj/PreviewItemView.strings +++ b/Maccy/Views/hi.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "पूर्वावलोकन दिखाने के लिए {previewKey} दबाएँ।"; "ClosePreview" = "पूर्वावलोकन छिपाने के लिए {previewKey} दबाएँ।"; "Dimensions" = "आयाम:"; +"Size" = "आकार:"; "CopyExtractedText" = "निकाले गए टेक्स्ट को कॉपी करें."; "EditItem" = "आइटम संपादित करें"; diff --git a/Maccy/Views/hr.lproj/PreviewItemView.strings b/Maccy/Views/hr.lproj/PreviewItemView.strings index aa60117e..d14b777d 100644 --- a/Maccy/Views/hr.lproj/PreviewItemView.strings +++ b/Maccy/Views/hr.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "Pritisnite {previewKey} za prikaz pregleda."; "ClosePreview" = "Pritisnite {previewKey} za skrivanje pregleda."; "Dimensions" = "Dimenzije:"; +"Size" = "Veličina:"; "CopyExtractedText" = "Kopiraj izdvojeni tekst."; "EditItem" = "Uredi stavku"; diff --git a/Maccy/Views/hu.lproj/PreviewItemView.strings b/Maccy/Views/hu.lproj/PreviewItemView.strings index fb1e2b45..473d0cf8 100644 --- a/Maccy/Views/hu.lproj/PreviewItemView.strings +++ b/Maccy/Views/hu.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "Nyomja meg a {previewKey} gombot az előnézet megjelenítéséhez."; "ClosePreview" = "Nyomja meg a {previewKey} gombot az előnézet elrejtéséhez."; "Dimensions" = "Méretek:"; +"Size" = "Fájlméret:"; "CopyExtractedText" = "Kinyert szöveg másolása."; "EditItem" = "Elem szerkesztése"; diff --git a/Maccy/Views/id.lproj/PreviewItemView.strings b/Maccy/Views/id.lproj/PreviewItemView.strings index 9b028d69..dc8f5414 100644 --- a/Maccy/Views/id.lproj/PreviewItemView.strings +++ b/Maccy/Views/id.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "Tekan tombol {previewKey} untuk menampilkan pratinjau."; "ClosePreview" = "Tekan tombol {previewKey} untuk menyembunyikan pratinjau."; "Dimensions" = "Dimensi:"; +"Size" = "Ukuran:"; "CopyExtractedText" = "Salin teks yang diekstrak."; "EditItem" = "Edit item"; diff --git a/Maccy/Views/it.lproj/PreviewItemView.strings b/Maccy/Views/it.lproj/PreviewItemView.strings index 123c378e..74f4aebf 100644 --- a/Maccy/Views/it.lproj/PreviewItemView.strings +++ b/Maccy/Views/it.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "Premi {previewKey} per mostrare l'anteprima."; "ClosePreview" = "Premi {previewKey} per nascondere l'anteprima."; "Dimensions" = "Dimensioni:"; +"Size" = "Dimensione file:"; "CopyExtractedText" = "Copia il testo estratto."; "EditItem" = "Modifica elemento"; diff --git a/Maccy/Views/ja.lproj/PreviewItemView.strings b/Maccy/Views/ja.lproj/PreviewItemView.strings index 25c0a82d..7506d0a7 100644 --- a/Maccy/Views/ja.lproj/PreviewItemView.strings +++ b/Maccy/Views/ja.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "{previewKey}でプレビューを表示します。"; "ClosePreview" = "{previewKey}でプレビューを非表示にします。"; "Dimensions" = "サイズ:"; +"Size" = "ファイルサイズ:"; "CopyExtractedText" = "抽出したテキストをコピーします。"; "EditItem" = "項目を編集"; diff --git a/Maccy/Views/km.lproj/PreviewItemView.strings b/Maccy/Views/km.lproj/PreviewItemView.strings index 45d9bfed..36b5a64a 100644 --- a/Maccy/Views/km.lproj/PreviewItemView.strings +++ b/Maccy/Views/km.lproj/PreviewItemView.strings @@ -6,6 +6,7 @@ "Application" = "កម្មវិធី:"; "CopyExtractedText" = "ចម្លងអត្ថបទដែលបានស្រង់។"; "Dimensions" = "ទំហំ:"; +"Size" = "ទំហំឯកសារ:"; "PreviewKey" = "ចុច {previewKey} ដើម្បី បើក/បិទ ការមើលមុន។"; "UnpinKey" = "ចុច {pinKey} ដើម្បី ដកខ្ទាស់។"; "OpenPreview" = "ចុច {previewKey} ដើម្បីបង្ហាញការមើលជាមុន។"; diff --git a/Maccy/Views/ko.lproj/PreviewItemView.strings b/Maccy/Views/ko.lproj/PreviewItemView.strings index 505a6360..b96cec52 100644 --- a/Maccy/Views/ko.lproj/PreviewItemView.strings +++ b/Maccy/Views/ko.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "{previewKey}를 눌러 미리보기를 표시합니다."; "ClosePreview" = "{previewKey}를 눌러 미리보기를 숨깁니다."; "Dimensions" = "크기:"; +"Size" = "파일 크기:"; "CopyExtractedText" = "추출된 텍스트를 복사합니다."; "EditItem" = "항목 편집"; diff --git a/Maccy/Views/lt.lproj/PreviewItemView.strings b/Maccy/Views/lt.lproj/PreviewItemView.strings index 1dee1650..d828e067 100644 --- a/Maccy/Views/lt.lproj/PreviewItemView.strings +++ b/Maccy/Views/lt.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "Paspauskite {previewKey}, kad parodytumėte peržiūrą."; "ClosePreview" = "Paspauskite {previewKey}, kad paslėptumėte peržiūrą."; "Dimensions" = "Matmenys:"; +"Size" = "Dydis:"; "CopyExtractedText" = "Kopijuoti išgautą tekstą."; "EditItem" = "Redaguoti elementą"; diff --git a/Maccy/Views/lv.lproj/PreviewItemView.strings b/Maccy/Views/lv.lproj/PreviewItemView.strings index a0081cf4..4a631822 100644 --- a/Maccy/Views/lv.lproj/PreviewItemView.strings +++ b/Maccy/Views/lv.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "Nospiediet {previewKey}, lai parādītu priekšskatījumu."; "ClosePreview" = "Nospiediet {previewKey}, lai paslēptu priekšskatījumu."; "Dimensions" = "Izmēri:"; +"Size" = "Lielums:"; "CopyExtractedText" = "Kopēt izgūto tekstu."; "EditItem" = "Rediģēt vienumu"; diff --git a/Maccy/Views/nb.lproj/PreviewItemView.strings b/Maccy/Views/nb.lproj/PreviewItemView.strings index 664a000d..553570dc 100644 --- a/Maccy/Views/nb.lproj/PreviewItemView.strings +++ b/Maccy/Views/nb.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "Trykk {previewKey} for å vise forhåndsvisningen."; "ClosePreview" = "Trykk {previewKey} for å skjule forhåndsvisningen."; "Dimensions" = "Dimensjoner:"; +"Size" = "Størrelse:"; "CopyExtractedText" = "Kopier uttrukket tekst."; "EditItem" = "Rediger element"; diff --git a/Maccy/Views/nl.lproj/PreviewItemView.strings b/Maccy/Views/nl.lproj/PreviewItemView.strings index c33dd448..7b0fb456 100644 --- a/Maccy/Views/nl.lproj/PreviewItemView.strings +++ b/Maccy/Views/nl.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "Druk op {previewKey} om het voorbeeld te tonen."; "ClosePreview" = "Druk op {previewKey} om het voorbeeld te verbergen."; "Dimensions" = "Afmetingen:"; +"Size" = "Grootte:"; "CopyExtractedText" = "Geëxtraheerde tekst kopiëren."; "EditItem" = "Item bewerken"; diff --git a/Maccy/Views/pl.lproj/PreviewItemView.strings b/Maccy/Views/pl.lproj/PreviewItemView.strings index f2ee293f..b89773a6 100644 --- a/Maccy/Views/pl.lproj/PreviewItemView.strings +++ b/Maccy/Views/pl.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "Naciśnij {previewKey}, aby pokazać podgląd."; "ClosePreview" = "Naciśnij {previewKey}, aby ukryć podgląd."; "Dimensions" = "Wymiary:"; +"Size" = "Rozmiar:"; "CopyExtractedText" = "Skopiuj wyodrębniony tekst."; "EditItem" = "Edytuj element"; diff --git a/Maccy/Views/pt-BR.lproj/PreviewItemView.strings b/Maccy/Views/pt-BR.lproj/PreviewItemView.strings index f52778d3..59f7fe2d 100644 --- a/Maccy/Views/pt-BR.lproj/PreviewItemView.strings +++ b/Maccy/Views/pt-BR.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "Pressione {previewKey} para mostrar a pré-visualização."; "ClosePreview" = "Pressione {previewKey} para ocultar a pré-visualização."; "Dimensions" = "Dimensões:"; +"Size" = "Tamanho:"; "CopyExtractedText" = "Copiar texto extraído."; "EditItem" = "Editar item"; diff --git a/Maccy/Views/pt.lproj/PreviewItemView.strings b/Maccy/Views/pt.lproj/PreviewItemView.strings index 0ee116b7..b21eaecc 100644 --- a/Maccy/Views/pt.lproj/PreviewItemView.strings +++ b/Maccy/Views/pt.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "Pressione {previewKey} para mostrar a pré-visualização."; "ClosePreview" = "Pressione {previewKey} para ocultar a pré-visualização."; "Dimensions" = "Dimensões:"; +"Size" = "Tamanho:"; "CopyExtractedText" = "Copiar texto extraído."; "EditItem" = "Editar item"; diff --git a/Maccy/Views/ro.lproj/PreviewItemView.strings b/Maccy/Views/ro.lproj/PreviewItemView.strings index 4177cd90..9aef22e3 100644 --- a/Maccy/Views/ro.lproj/PreviewItemView.strings +++ b/Maccy/Views/ro.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "Apasă {previewKey} pentru a afișa previzualizarea."; "ClosePreview" = "Apasă {previewKey} pentru a ascunde previzualizarea."; "Dimensions" = "Dimensiuni:"; +"Size" = "Mărime:"; "CopyExtractedText" = "Copiază textul extras."; "EditItem" = "Editează elementul"; diff --git a/Maccy/Views/ru.lproj/PreviewItemView.strings b/Maccy/Views/ru.lproj/PreviewItemView.strings index 4cdbab1e..6f594a2d 100644 --- a/Maccy/Views/ru.lproj/PreviewItemView.strings +++ b/Maccy/Views/ru.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "Нажмите {previewKey}, чтобы показать предпросмотр."; "ClosePreview" = "Нажмите {previewKey}, чтобы скрыть предпросмотр."; "Dimensions" = "Размеры:"; +"Size" = "Размер:"; "CopyExtractedText" = "Скопировать извлеченный текст."; "EditItem" = "Редактировать элемент"; diff --git a/Maccy/Views/sl.lproj/PreviewItemView.strings b/Maccy/Views/sl.lproj/PreviewItemView.strings index 7bcaf7a2..52772235 100644 --- a/Maccy/Views/sl.lproj/PreviewItemView.strings +++ b/Maccy/Views/sl.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "Pritisnite {previewKey} za prikaz predogleda."; "ClosePreview" = "Pritisnite {previewKey} za skrivanje predogleda."; "Dimensions" = "Dimenzije:"; +"Size" = "Velikost:"; "CopyExtractedText" = "Kopiraj izvlečeno besedilo."; "EditItem" = "Uredi element"; diff --git a/Maccy/Views/sv.lproj/PreviewItemView.strings b/Maccy/Views/sv.lproj/PreviewItemView.strings index 2f2b99b0..be12df0e 100644 --- a/Maccy/Views/sv.lproj/PreviewItemView.strings +++ b/Maccy/Views/sv.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "Tryck {previewKey} för att visa förhandsvisningen."; "ClosePreview" = "Tryck {previewKey} för att dölja förhandsvisningen."; "Dimensions" = "Mått:"; +"Size" = "Storlek:"; "CopyExtractedText" = "Kopiera extraherad text."; "EditItem" = "Redigera objekt"; diff --git a/Maccy/Views/ta.lproj/PreviewItemView.strings b/Maccy/Views/ta.lproj/PreviewItemView.strings index 3272a512..7197c6a9 100644 --- a/Maccy/Views/ta.lproj/PreviewItemView.strings +++ b/Maccy/Views/ta.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "முன்னோட்டத்தைக் காட்ட {previewKey} அழுத்தவும்."; "ClosePreview" = "முன்னோட்டத்தை மறைக்க {previewKey} அழுத்தவும்."; "Dimensions" = "பரிமாணங்கள்:"; +"Size" = "அளவு:"; "CopyExtractedText" = "பிரித்தெடுக்கப்பட்ட உரையை நகலெடுக்கவும்."; "EditItem" = "உருப்படியைத் திருத்து"; diff --git a/Maccy/Views/th.lproj/PreviewItemView.strings b/Maccy/Views/th.lproj/PreviewItemView.strings index b7b30f70..6f4b8e31 100644 --- a/Maccy/Views/th.lproj/PreviewItemView.strings +++ b/Maccy/Views/th.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "กด {previewKey} เพื่อแสดงตัวอย่าง"; "ClosePreview" = "กด {previewKey} เพื่อซ่อนตัวอย่าง"; "Dimensions" = "ขนาด:"; +"Size" = "ขนาดไฟล์:"; "CopyExtractedText" = "คัดลอกข้อความที่แยกออกมา"; "EditItem" = "แก้ไขรายการ"; diff --git a/Maccy/Views/tr.lproj/PreviewItemView.strings b/Maccy/Views/tr.lproj/PreviewItemView.strings index a6c5aee1..e738fa52 100644 --- a/Maccy/Views/tr.lproj/PreviewItemView.strings +++ b/Maccy/Views/tr.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "Önizlemeyi göstermek için {previewKey} tuşuna basın."; "ClosePreview" = "Önizlemeyi gizlemek için {previewKey} tuşuna basın."; "Dimensions" = "Boyutlar:"; +"Size" = "Dosya boyutu:"; "CopyExtractedText" = "Çıkarılan metni kopyala."; "EditItem" = "Öğeyi düzenle"; diff --git a/Maccy/Views/uk.lproj/PreviewItemView.strings b/Maccy/Views/uk.lproj/PreviewItemView.strings index c35be226..e0720dbc 100644 --- a/Maccy/Views/uk.lproj/PreviewItemView.strings +++ b/Maccy/Views/uk.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "Натисніть {previewKey}, щоб показати попередній перегляд."; "ClosePreview" = "Натисніть {previewKey}, щоб приховати попередній перегляд."; "Dimensions" = "Розміри:"; +"Size" = "Розмір:"; "CopyExtractedText" = "Скопіювати розпізнаний текст."; "EditItem" = "Редагувати елемент"; diff --git a/Maccy/Views/uz.lproj/PreviewItemView.strings b/Maccy/Views/uz.lproj/PreviewItemView.strings index c49babdf..2ebdfa0f 100644 --- a/Maccy/Views/uz.lproj/PreviewItemView.strings +++ b/Maccy/Views/uz.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "Oldindan ko‘rishni ko‘rsatish uchun {previewKey} tugmasini bosing."; "ClosePreview" = "Oldindan ko‘rishni yashirish uchun {previewKey} tugmasini bosing."; "Dimensions" = "O'lchamlar:"; +"Size" = "Hajmi:"; "CopyExtractedText" = "Ajratib olingan matnni nusxalash."; "EditItem" = "Elementni tahrirlash"; diff --git a/Maccy/Views/vi.lproj/PreviewItemView.strings b/Maccy/Views/vi.lproj/PreviewItemView.strings index 6ec266ab..a5ab4349 100644 --- a/Maccy/Views/vi.lproj/PreviewItemView.strings +++ b/Maccy/Views/vi.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "Nhấn {previewKey} để hiển thị bản xem trước."; "ClosePreview" = "Nhấn {previewKey} để ẩn bản xem trước."; "Dimensions" = "Kích thước:"; +"Size" = "Dung lượng:"; "CopyExtractedText" = "Sao chép văn bản đã trích xuất."; "EditItem" = "Chỉnh sửa mục"; diff --git a/Maccy/Views/zh-Hans.lproj/PreviewItemView.strings b/Maccy/Views/zh-Hans.lproj/PreviewItemView.strings index e8e71e93..c590c184 100644 --- a/Maccy/Views/zh-Hans.lproj/PreviewItemView.strings +++ b/Maccy/Views/zh-Hans.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "按 {previewKey} 显示预览。"; "ClosePreview" = "按 {previewKey} 隐藏预览。"; "Dimensions" = "尺寸:"; +"Size" = "文件大小:"; "CopyExtractedText" = "复制提取的文本。"; "EditItem" = "编辑项目"; diff --git a/Maccy/Views/zh-Hant.lproj/PreviewItemView.strings b/Maccy/Views/zh-Hant.lproj/PreviewItemView.strings index a574948a..c5eb6df3 100644 --- a/Maccy/Views/zh-Hant.lproj/PreviewItemView.strings +++ b/Maccy/Views/zh-Hant.lproj/PreviewItemView.strings @@ -8,5 +8,6 @@ "OpenPreview" = "按 {previewKey} 顯示預覽。"; "ClosePreview" = "按 {previewKey} 隱藏預覽。"; "Dimensions" = "尺寸:"; +"Size" = "檔案大小:"; "CopyExtractedText" = "複製擷取的文字。"; "EditItem" = "編輯項目"; From 22bd46624d5a474615561daee38ade130390ecde Mon Sep 17 00:00:00 2001 From: Alex Rodionov Date: Wed, 14 Oct 2026 07:59:26 -0700 Subject: [PATCH 53/59] Use special symbols in as pin keys --- Maccy/Models/HistoryItem.swift | 14 +++++++++++++- Maccy/Observables/PinManager.swift | 2 +- Maccy/Views/ItemEditorView.swift | 3 +-- MaccyTests/HistoryItemTests.swift | 7 +++++++ 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/Maccy/Models/HistoryItem.swift b/Maccy/Models/HistoryItem.swift index 6447c9d9..3bc685bd 100644 --- a/Maccy/Models/HistoryItem.swift +++ b/Maccy/Models/HistoryItem.swift @@ -13,9 +13,12 @@ class HistoryItem { // "v" reserved for paste // "w" reserved for close window // "z" reserved for undo/redo + // "," reserved for preferences + // "`" reserved for cycling through windows var keys = Set([ "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", - "m", "n", "o", "p", "r", "s", "t", "u", "x", "y" + "m", "n", "o", "p", "r", "s", "t", "u", "x", "y", + "[", "]", ";", "'", ".", "/", "-", "=", "\\" ]) if let deleteKey = KeyChord.deleteKey, @@ -35,6 +38,15 @@ class HistoryItem { return keys } + // Letters first, then symbols. + static func sortedPins(_ pins: some Sequence) -> [String] { + pins.sorted { lhs, rhs in + let lhsIsLetter = lhs.first?.isLetter ?? false + let rhsIsLetter = rhs.first?.isLetter ?? false + return lhsIsLetter == rhsIsLetter ? lhs < rhs : lhsIsLetter + } + } + @MainActor static var availablePins: [String] { History.shared.availablePins diff --git a/Maccy/Observables/PinManager.swift b/Maccy/Observables/PinManager.swift index 709623be..f2f3a23a 100644 --- a/Maccy/Observables/PinManager.swift +++ b/Maccy/Observables/PinManager.swift @@ -54,7 +54,7 @@ final class PinManager { var availablePins: [String] { let assignedPins = Set(pinnedItems.compactMap(\.item.pin)) - return HistoryItem.supportedPins.subtracting(assignedPins).sorted() + return HistoryItem.sortedPins(HistoryItem.supportedPins.subtracting(assignedPins)) } func load(from items: [HistoryItemDecorator]) { diff --git a/Maccy/Views/ItemEditorView.swift b/Maccy/Views/ItemEditorView.swift index 3101c207..7d28fc18 100644 --- a/Maccy/Views/ItemEditorView.swift +++ b/Maccy/Views/ItemEditorView.swift @@ -178,8 +178,7 @@ struct ItemEditorView: View { } private var uniquePins: [String] { - Array(Set(availablePins + [item.item.pin, selectedPin].compactMap { $0 })) - .sorted() + HistoryItem.sortedPins(Set(availablePins + [item.item.pin, selectedPin].compactMap { $0 })) } private var hasChangesToApply: Bool { diff --git a/MaccyTests/HistoryItemTests.swift b/MaccyTests/HistoryItemTests.swift index 2dd0a7af..f33f39be 100644 --- a/MaccyTests/HistoryItemTests.swift +++ b/MaccyTests/HistoryItemTests.swift @@ -5,6 +5,13 @@ import Defaults // swiftlint:disable force_try @MainActor class HistoryItemTests: XCTestCase { + func testSortedPinsPlacesLettersBeforeSymbols() { + XCTAssertEqual( + HistoryItem.sortedPins(["/", "c", "[", "b", "-", "\\", "y"]), + ["b", "c", "y", "-", "/", "[", "\\"] + ) + } + func testTitleForString() { let title = "foo" let item = historyItem(title) From 8360935b7355c5c70b940e7a4a00327cfc28f7f3 Mon Sep 17 00:00:00 2001 From: Denis Mordvinov <8133967+Sublio@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:36:36 +0200 Subject: [PATCH 54/59] Don't rerun OCR for images when toggling special symbols (#1590) --- Maccy/Observables/History.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Maccy/Observables/History.swift b/Maccy/Observables/History.swift index f3316065..1db356cd 100644 --- a/Maccy/Observables/History.swift +++ b/Maccy/Observables/History.swift @@ -109,9 +109,7 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length Task { for await _ in Defaults.updates(.showSpecialSymbols, initial: false) { - for item in items { - updateTitle(item: item, title: item.item.generateTitle()) - } + updateTitlesForSpecialSymbols() } } @@ -566,6 +564,13 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length updateUnpinnedShortcuts() } + @MainActor + private func updateTitlesForSpecialSymbols() { + for item in items where item.item.imageData == nil { + updateTitle(item: item, title: item.item.generateTitle()) + } + } + @MainActor private func updateTitle(item: HistoryItemDecorator, title: String) { item.title = title From 50d2f1c7b1b9be293c3623e044daf7bb9549ae69 Mon Sep 17 00:00:00 2001 From: Ali Nasser <83069287+itssali@users.noreply.github.com> Date: Thu, 15 Oct 2026 21:09:05 +0300 Subject: [PATCH 55/59] Wrap the hidden footer warning in long translations (#1592) Settings.Section offers its content the full container width even though the content starts after the label column, so the warning never wraps and runs past the window edge in French, Russian and other long translations. Cap its width so it wraps. Fixes #1327 --- Maccy/Settings/AppearanceSettingsPane.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Maccy/Settings/AppearanceSettingsPane.swift b/Maccy/Settings/AppearanceSettingsPane.swift index 6c8d2f5b..78293132 100644 --- a/Maccy/Settings/AppearanceSettingsPane.swift +++ b/Maccy/Settings/AppearanceSettingsPane.swift @@ -195,8 +195,11 @@ struct AppearanceSettingsPane: View { Defaults.Toggle(key: .showFooter) { Text("ShowFooter", tableName: "AppearanceSettings") } + // Settings.Section offers its content the full container width even though the content + // starts after the label column, so cap the width to make long translations wrap. Text("OpenPreferencesWarning", tableName: "AppearanceSettings") .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: 380, alignment: .leading) .opacity(showFooter ? 0 : 1) .controlSize(.small) .foregroundStyle(.gray) From 7e14eb8d4578f1509f9a02e7bfd5c465550695cd Mon Sep 17 00:00:00 2001 From: Ali Nasser <83069287+itssali@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:51:02 +0300 Subject: [PATCH 56/59] Fix building with Xcode 26.5 (#1593) --- Maccy/Extensions/NSPoint+DefaultsSerializable.swift | 2 +- Maccy/Extensions/NSSize+DefaultsSerializable.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Maccy/Extensions/NSPoint+DefaultsSerializable.swift b/Maccy/Extensions/NSPoint+DefaultsSerializable.swift index 534d71c5..bc2b31db 100644 --- a/Maccy/Extensions/NSPoint+DefaultsSerializable.swift +++ b/Maccy/Extensions/NSPoint+DefaultsSerializable.swift @@ -2,5 +2,5 @@ import CoreGraphics import Defaults import Foundation -extension NSPoint: Defaults.Serializable { +nonisolated extension NSPoint: Defaults.Serializable { } diff --git a/Maccy/Extensions/NSSize+DefaultsSerializable.swift b/Maccy/Extensions/NSSize+DefaultsSerializable.swift index bf0c8f7e..eae810e0 100644 --- a/Maccy/Extensions/NSSize+DefaultsSerializable.swift +++ b/Maccy/Extensions/NSSize+DefaultsSerializable.swift @@ -2,5 +2,5 @@ import CoreGraphics import Defaults import Foundation -extension NSSize: Defaults.Serializable { +nonisolated extension NSSize: Defaults.Serializable { } From c700182f638d9d29bfa0aec9dc560422698854c6 Mon Sep 17 00:00:00 2001 From: Alex Rodionov Date: Fri, 16 Oct 2026 11:31:51 -0700 Subject: [PATCH 57/59] Fix crash when watched app bundle changes The app icon file watcher ran its handler on a global queue, but the closure is implicitly @MainActor, so Swift 6's isolation check crashed whenever an app with a displayed icon was modified or deleted. --- Maccy/ApplicationImage.swift | 26 ++++++++++++-------------- 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/Maccy/ApplicationImage.swift b/Maccy/ApplicationImage.swift index d15f5973..b37f0160 100644 --- a/Maccy/ApplicationImage.swift +++ b/Maccy/ApplicationImage.swift @@ -50,23 +50,21 @@ class ApplicationImage { let source = DispatchSource.makeFileSystemObjectSource( fileDescriptor: descriptor, eventMask: [.write, .delete], - queue: DispatchQueue.global() + queue: .main ) eventSource = source source.setEventHandler { - DispatchQueue.main.async { - let event = source.data - if event.contains(.delete) { - // File was deleted. - print("Deleted", appURL.path) - source.cancel() - self.image = nil - self.lastChecked = nil - } else if event.contains(.write) { - // File was modified. Fetch new icon - print("Modified", appURL.path) - self.image = NSWorkspace.shared.icon(forFile: appURL.path) - } + let event = source.data + if event.contains(.delete) { + // File was deleted. + print("Deleted", appURL.path) + source.cancel() + self.image = nil + self.lastChecked = nil + } else if event.contains(.write) { + // File was modified. Fetch new icon + print("Modified", appURL.path) + self.image = NSWorkspace.shared.icon(forFile: appURL.path) } } source.setCancelHandler { From 54fb9f00b828a4ec367e706c0e276df90febe76a Mon Sep 17 00:00:00 2001 From: Denis Mordvinov <8133967+Sublio@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:59:04 +0200 Subject: [PATCH 58/59] Migrate unit tests from XCTest to Swift Testing (#1597) --- Maccy.xcodeproj/project.pbxproj | 4 + Maccy/Models/HistoryItem.swift | 17 +- MaccyTests/ClipboardTests.swift | 603 +++++++------- MaccyTests/CollectionSurroundingTests.swift | 30 +- MaccyTests/ColorImageTests.swift | 18 +- MaccyTests/HistoryDecoratorTests.swift | 400 +++++----- MaccyTests/HistoryItemTests.swift | 424 +++++----- MaccyTests/HistoryTests.swift | 840 ++++++++++---------- MaccyTests/SearchTests.swift | 487 ++++++------ MaccyTests/SharedStateTests.swift | 4 + MaccyTests/ShortenedTests.swift | 31 +- MaccyTests/SorterTests.swift | 118 +-- MaccyTests/UnsafeForTitleLayoutTests.swift | 84 +- MaccyUITests/MaccyUITests.swift | 53 ++ 14 files changed, 1598 insertions(+), 1515 deletions(-) create mode 100644 MaccyTests/SharedStateTests.swift diff --git a/Maccy.xcodeproj/project.pbxproj b/Maccy.xcodeproj/project.pbxproj index 9c9b0557..a5625dc8 100644 --- a/Maccy.xcodeproj/project.pbxproj +++ b/Maccy.xcodeproj/project.pbxproj @@ -45,6 +45,7 @@ 2FF2E9502E7808470093D72C /* AppImageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2FF2E94F2E7808420093D72C /* AppImageView.swift */; }; 42E9C329301B7F9C00B6B55B /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = 42E9C328301B7F9C00B6B55B /* AppIcon.icon */; }; 4762D6972467226100B3A2BA /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 4762D6992467226100B3A2BA /* Localizable.strings */; }; + 4CB7148130897A9E00A357EC /* SharedStateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4CB7148030897A9E00A357EC /* SharedStateTests.swift */; }; 9B24FC30A093450F29E2CC4A /* View+Invisible.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9DC045710F4F8773D1A5EC87 /* View+Invisible.swift */; }; C0FFEE000000000000000003 /* ShortenedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0FFEE000000000000000004 /* ShortenedTests.swift */; }; CA110ED10000000000000001 /* CollectionSurroundingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA110ED10000000000000002 /* CollectionSurroundingTests.swift */; }; @@ -241,6 +242,7 @@ 42E9C328301B7F9C00B6B55B /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon.icon; sourceTree = ""; }; 4762D6982467226100B3A2BA /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 4762D69A2467226400B3A2BA /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; + 4CB7148030897A9E00A357EC /* SharedStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedStateTests.swift; sourceTree = ""; }; 4DC4D112252713D700FE5FAC /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; 5212BB662E59AA5200E74A24 /* ckb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ckb; path = ckb.lproj/AdvancedSettings.strings; sourceTree = ""; }; 5212BB672E59AA5200E74A24 /* ckb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ckb; path = ckb.lproj/AppearanceSettings.strings; sourceTree = ""; }; @@ -789,6 +791,7 @@ DA384E85232746D800603999 /* SearchTests.swift */, DA696BD12401EEE900DE80CF /* SorterTests.swift */, DA360DB41E3DF137005C6F6B /* Info.plist */, + 4CB7148030897A9E00A357EC /* SharedStateTests.swift */, ); path = MaccyTests; sourceTree = ""; @@ -1131,6 +1134,7 @@ files = ( DAFE2DE9268A9B1B00990986 /* HistoryItemTests.swift in Sources */, DA3CE8001E44A62500B3AA98 /* ClipboardTests.swift in Sources */, + 4CB7148130897A9E00A357EC /* SharedStateTests.swift in Sources */, DA1EDE432045B35300479723 /* HistoryDecoratorTests.swift in Sources */, DAE2850223225BD90080E394 /* ColorImageTests.swift in Sources */, DA384E86232746D800603999 /* SearchTests.swift in Sources */, diff --git a/Maccy/Models/HistoryItem.swift b/Maccy/Models/HistoryItem.swift index 3bc685bd..12e99939 100644 --- a/Maccy/Models/HistoryItem.swift +++ b/Maccy/Models/HistoryItem.swift @@ -249,9 +249,22 @@ class HistoryItem { .compactMap { $0.value } } - /// Runs OCR on the global concurrent executor so the main thread stays free. - @concurrent + nonisolated private static let textRecognitionQueue = DispatchQueue( + label: "org.p0deje.Maccy.textRecognition", + qos: .utility + ) + + /// Runs OCR on a dedicated queue so the blocking Vision call never occupies + /// a thread of Swift's cooperative pool or the main thread. nonisolated private static func recognizeText(imageData: Data?, fileURL: URL?) async -> String? { + await withCheckedContinuation { continuation in + textRecognitionQueue.async { + continuation.resume(returning: performTextRecognition(imageData: imageData, fileURL: fileURL)) + } + } + } + + nonisolated private static func performTextRecognition(imageData: Data?, fileURL: URL?) -> String? { guard let data = imageData ?? fileURL.flatMap({ try? Data(contentsOf: $0) }) else { return nil } diff --git a/MaccyTests/ClipboardTests.swift b/MaccyTests/ClipboardTests.swift index 03c67d0b..fca19604 100644 --- a/MaccyTests/ClipboardTests.swift +++ b/MaccyTests/ClipboardTests.swift @@ -1,354 +1,325 @@ -import XCTest +import AppKit +import Testing import Defaults @testable import Maccy -// swiftlint:disable type_body_length -@MainActor -class ClipboardTests: XCTestCase { - let clipboard = Clipboard.shared - let pasteboard = NSPasteboard.general - let image = NSImage(named: "NSInfo")! - let coloredString = NSAttributedString(string: "foo", - attributes: [.foregroundColor: NSColor.red]) - - let dynamicType = NSPasteboard.PasteboardType(rawValue: "dyn.ah62d4qmxhk4d425try1g44pdsm11g55gsu1e82xnqzv") - let customType = NSPasteboard.PasteboardType(rawValue: "org.maccy.ConfidentialType") - let fileURLType = NSPasteboard.PasteboardType.fileURL - let htmlType = NSPasteboard.PasteboardType.html - let rtfType = NSPasteboard.PasteboardType.rtf - let stringType = NSPasteboard.PasteboardType.string - let tiffType = NSPasteboard.PasteboardType.tiff - let transientType = NSPasteboard.PasteboardType.transient - let unknownType = NSPasteboard.PasteboardType(rawValue: "com.apple.AnnotationKit.AnnotationItem") - - let savedEnabledTypes = Defaults[.enabledPasteboardTypes] - let savedIgnoreEvents = Defaults[.ignoreEvents] - let savedIgnoreAllAppsExceptListed = Defaults[.ignoreAllAppsExceptListed] - let savedIgnoredApps = Defaults[.ignoredApps] - let savedIgnoredPasteboardTypes = Defaults[.ignoredPasteboardTypes] - - override func setUp() { - super.setUp() - Defaults[.ignoreAllAppsExceptListed] = false - Defaults[.ignoreEvents] = false - } - - override func tearDown() { - super.tearDown() - Defaults[.enabledPasteboardTypes] = savedEnabledTypes - Defaults[.ignoreEvents] = savedIgnoreEvents - Defaults[.ignoreOnlyNextEvent] = false - Defaults[.ignoreAllAppsExceptListed] = savedIgnoreAllAppsExceptListed - Defaults[.ignoredApps] = savedIgnoredApps - Defaults[.ignoredPasteboardTypes] = savedIgnoredPasteboardTypes - MainActor.assumeIsolated { - Clipboard.shared.clearHooks() +extension SharedStateTests { + // swiftlint:disable type_body_length + @MainActor + final class ClipboardTests { + let clipboard = Clipboard.shared + let pasteboard = NSPasteboard.general + let image: NSImage + let coloredString = NSAttributedString(string: "foo", + attributes: [.foregroundColor: NSColor.red]) + + let dynamicType = NSPasteboard.PasteboardType(rawValue: "dyn.ah62d4qmxhk4d425try1g44pdsm11g55gsu1e82xnqzv") + let customType = NSPasteboard.PasteboardType(rawValue: "org.maccy.ConfidentialType") + let fileURLType = NSPasteboard.PasteboardType.fileURL + let htmlType = NSPasteboard.PasteboardType.html + let rtfType = NSPasteboard.PasteboardType.rtf + let stringType = NSPasteboard.PasteboardType.string + let tiffType = NSPasteboard.PasteboardType.tiff + let transientType = NSPasteboard.PasteboardType.transient + let unknownType = NSPasteboard.PasteboardType(rawValue: "com.apple.AnnotationKit.AnnotationItem") + + let savedEnabledTypes = Defaults[.enabledPasteboardTypes] + let savedIgnoreEvents = Defaults[.ignoreEvents] + let savedIgnoreAllAppsExceptListed = Defaults[.ignoreAllAppsExceptListed] + let savedIgnoredApps = Defaults[.ignoredApps] + let savedIgnoredPasteboardTypes = Defaults[.ignoredPasteboardTypes] + + init() throws { + image = try #require(NSImage(named: "NSInfo")) + Defaults[.ignoreAllAppsExceptListed] = false + Defaults[.ignoreEvents] = false + clipboard.clearHooks() } - } - - func testChangesListenerAndAddHooks() { - let hookExpectation = expectation(description: "Hook is called") - clipboard.onNewCopy({ (_: HistoryItem) in - hookExpectation.fulfill() - }) - clipboard.start() - pasteboard.declareTypes([.string], owner: nil) - pasteboard.setString("bar", forType: .string) - waitForExpectations(timeout: 2) - } - - func testIgnoreStringWithOnlySpaces() { - let hookExpectation = expectation(description: "Hook is called") - hookExpectation.isInverted = true - clipboard.onNewCopy({ (_: HistoryItem) in - hookExpectation.fulfill() - }) - clipboard.start() - pasteboard.declareTypes([.string], owner: nil) - pasteboard.setString(" ", forType: .string) - waitForExpectations(timeout: 2) - } - func testIgnoreStringWithOnlyNewlines() { - let hookExpectation = expectation(description: "Hook is called") - hookExpectation.isInverted = true - clipboard.onNewCopy({ (_: HistoryItem) in - hookExpectation.fulfill() - }) - clipboard.start() - pasteboard.declareTypes([.string], owner: nil) - pasteboard.setString("\n", forType: .string) - waitForExpectations(timeout: 2) - } - - func testDoesNotIgnoreRTF() { - let hookExpectation = expectation(description: "Hook is called") - clipboard.onNewCopy({ (_: HistoryItem) in - hookExpectation.fulfill() - }) - clipboard.start() - let rtf = NSAttributedString(string: "foo").rtf( - from: NSRange(0...2), - documentAttributes: [:] - ) - pasteboard.declareTypes([.rtf], owner: nil) - pasteboard.setData(rtf, forType: .rtf) - waitForExpectations(timeout: 2) - } + deinit { + Defaults[.enabledPasteboardTypes] = savedEnabledTypes + Defaults[.ignoreEvents] = savedIgnoreEvents + Defaults[.ignoreOnlyNextEvent] = false + Defaults[.ignoreAllAppsExceptListed] = savedIgnoreAllAppsExceptListed + Defaults[.ignoredApps] = savedIgnoredApps + Defaults[.ignoredPasteboardTypes] = savedIgnoredPasteboardTypes + } - func testDoesNotIgnoreHTML() { - let hookExpectation = expectation(description: "Hook is called") - clipboard.onNewCopy({ (_: HistoryItem) in - hookExpectation.fulfill() - }) - clipboard.start() - pasteboard.declareTypes([.html], owner: nil) - pasteboard.setString("foo", forType: .html) - waitForExpectations(timeout: 2) - } + @Test("Changes listener and add hooks") + func changesListenerAndAddHooks() async throws { + let copies = try await waitForCopies(after: { + pasteboard.declareTypes([.string], owner: nil) + pasteboard.setString("bar", forType: .string) + }) + #expect(copies.count == 1) + } - func testIgnoreEventsIsEnabled() { - Defaults[.ignoreEvents] = true - - let hookExpectation = expectation(description: "Hook is called") - hookExpectation.isInverted = true - clipboard.onNewCopy({ (_: HistoryItem) in - hookExpectation.fulfill() - }) - clipboard.start() - pasteboard.declareTypes([.string], owner: nil) - pasteboard.setString("foo", forType: .string) - waitForExpectations(timeout: 2) - } + @Test("Ignore string with only spaces") + func ignoreStringWithOnlySpaces() async throws { + let copies = try await waitForCopies(expectingCopy: false, after: { + pasteboard.declareTypes([.string], owner: nil) + pasteboard.setString(" ", forType: .string) + }) + #expect(copies.isEmpty) + } - func testIgnoreOnlyNextEventIsEnabled() { - Defaults[.ignoreEvents] = true - Defaults[.ignoreOnlyNextEvent] = true - - let hookExpectation = expectation(description: "Hook is called") - hookExpectation.isInverted = true - clipboard.onNewCopy({ (_: HistoryItem) in - hookExpectation.fulfill() - }) - clipboard.start() - pasteboard.declareTypes([.string], owner: nil) - pasteboard.setString("foo", forType: .string) - waitForExpectations(timeout: 2) - - XCTAssertFalse(Defaults[.ignoreEvents]) - XCTAssertFalse(Defaults[.ignoreOnlyNextEvent]) - } + @Test("Ignore string with only newlines") + func ignoreStringWithOnlyNewlines() async throws { + let copies = try await waitForCopies(expectingCopy: false, after: { + pasteboard.declareTypes([.string], owner: nil) + pasteboard.setString("\n", forType: .string) + }) + #expect(copies.isEmpty) + } - func testIgnoreApplication() throws { - Defaults[.ignoredApps] = [try frontmostAppBundleIdentifier()] - - let hookExpectation = expectation(description: "Hook is called") - hookExpectation.isInverted = true - clipboard.onNewCopy({ (_: HistoryItem) in - hookExpectation.fulfill() - }) - clipboard.start() - pasteboard.declareTypes([.string], owner: nil) - pasteboard.setString("bar", forType: .string) - waitForExpectations(timeout: 2) - } + @Test("Does not ignore RTF") + func doesNotIgnoreRTF() async throws { + let copies = try await waitForCopies(after: { + let rtf = NSAttributedString(string: "foo").rtf( + from: NSRange(0...2), + documentAttributes: [:] + ) + pasteboard.declareTypes([.rtf], owner: nil) + pasteboard.setData(rtf, forType: .rtf) + }) + #expect(copies.count == 1) + } - func testIgnoreAllApplicationsExcept() throws { - Defaults[.ignoreAllAppsExceptListed] = true - Defaults[.ignoredApps] = [try frontmostAppBundleIdentifier()] - - let hookExpectation = expectation(description: "Hook is called") - clipboard.onNewCopy({ (_: HistoryItem) in - hookExpectation.fulfill() - }) - clipboard.start() - pasteboard.declareTypes([.string], owner: nil) - pasteboard.setString("bar", forType: .string) - waitForExpectations(timeout: 2) - } + @Test("Does not ignore HTML") + func doesNotIgnoreHTML() async throws { + let copies = try await waitForCopies(after: { + pasteboard.declareTypes([.html], owner: nil) + pasteboard.setString("foo", forType: .html) + }) + #expect(copies.count == 1) + } - func testIgnoreTransientTypes() { - let hookExpectation = expectation(description: "Hook is called") - hookExpectation.isInverted = true - clipboard.onNewCopy({ (_: HistoryItem) in - hookExpectation.fulfill() - }) - clipboard.start() - pasteboard.declareTypes([.string, transientType], owner: nil) - pasteboard.setString("bar", forType: .string) - waitForExpectations(timeout: 2) - } + @Test("Ignore events is enabled") + func ignoreEventsIsEnabled() async throws { + Defaults[.ignoreEvents] = true - func testIgnoreCustomTypes() { - Defaults[.ignoredPasteboardTypes] = [customType.rawValue] - - let hookExpectation = expectation(description: "Hook is called") - hookExpectation.isInverted = true - clipboard.onNewCopy({ (_: HistoryItem) in - hookExpectation.fulfill() - }) - clipboard.start() - pasteboard.declareTypes([.string, customType], owner: nil) - pasteboard.setString("bar", forType: .string) - waitForExpectations(timeout: 2) - } + let copies = try await waitForCopies(expectingCopy: false, after: { + pasteboard.declareTypes([.string], owner: nil) + pasteboard.setString("foo", forType: .string) + }) + #expect(copies.isEmpty) + } - func testIgnoreCopiesWithUnknownTypes() { - let hookExpectation = expectation(description: "Hook is called") - hookExpectation.isInverted = true - clipboard.onNewCopy({ (_: HistoryItem) in - hookExpectation.fulfill() - }) - clipboard.start() - pasteboard.declareTypes([unknownType], owner: nil) - pasteboard.setString(" ", forType: unknownType) - waitForExpectations(timeout: 2) - } + @Test("Ignore only next event is enabled") + func ignoreOnlyNextEventIsEnabled() async throws { + Defaults[.ignoreEvents] = true + Defaults[.ignoreOnlyNextEvent] = true - func testCopy() { - let imageData = image.tiffRepresentation! - let contents = [ - HistoryItemContent(type: stringType.rawValue, value: "foo".data(using: .utf8)!), - HistoryItemContent(type: tiffType.rawValue, value: imageData), - HistoryItemContent(type: fileURLType.rawValue, value: "file://foo.bar".data(using: .utf8)!) - ] - let item = HistoryItem() - Storage.shared.context.insert(item) - item.contents = contents - item.application = "com.foo.bar" - clipboard.copy(item) - XCTAssertEqual(pasteboard.string(forType: .string), "foo") - XCTAssertEqual(pasteboard.data(forType: .tiff), imageData) - XCTAssertEqual(pasteboard.string(forType: .fileURL), "file://foo.bar") - XCTAssertEqual(pasteboard.string(forType: .fromMaccy), "") - XCTAssertEqual(pasteboard.string(forType: .source), "com.foo.bar") - } + let copies = try await waitForCopies(expectingCopy: false, after: { + pasteboard.declareTypes([.string], owner: nil) + pasteboard.setString("foo", forType: .string) + }) + #expect(copies.isEmpty) - func testCopyString() { - clipboard.copyInMaccy("foo") - XCTAssertEqual(pasteboard.string(forType: .string), "foo") - XCTAssertEqual(pasteboard.string(forType: .source), NSPasteboard.PasteboardType.fromMaccy.rawValue) - } + #expect(!Defaults[.ignoreEvents]) + #expect(!Defaults[.ignoreOnlyNextEvent]) + } - func testCopyWithoutFormatting() { - let contents = [ - HistoryItemContent(type: stringType.rawValue, value: "foo".data(using: .utf8)!), - HistoryItemContent(type: fileURLType.rawValue, value: "file://foo.bar".data(using: .utf8)!), - HistoryItemContent(type: rtfType.rawValue, - value: coloredString.rtf(from: NSRange(location: 0, length: coloredString.length), - documentAttributes: [:])) - ] - let item = HistoryItem() - Storage.shared.context.insert(item) - item.contents = contents - item.application = "com.foo.bar" - clipboard.copy(item, removeFormatting: true) - XCTAssertEqual(pasteboard.string(forType: .string), "foo") - XCTAssertEqual(pasteboard.string(forType: .fromMaccy), "") - XCTAssertEqual(pasteboard.string(forType: .source), "com.foo.bar") - XCTAssertEqual(pasteboard.string(forType: .fileURL), "file://foo.bar") - XCTAssertNil(pasteboard.data(forType: .rtf)) - } + @Test("Ignore application") + func ignoreApplication() async throws { + Defaults[.ignoredApps] = [try frontmostAppBundleIdentifier()] - func testHandlesItemsWithoutData() { - let hookExpectation = expectation(description: "Hook is called") - pasteboard.clearContents() - clipboard.onNewCopy({ (_: HistoryItem) in - hookExpectation.fulfill() - }) - clipboard.start() - pasteboard.declareTypes([.fileURL, .string], owner: nil) - // fileURL is left without data - pasteboard.setString("bar", forType: .string) - waitForExpectations(timeout: 2) - } + let copies = try await waitForCopies(expectingCopy: false, after: { + pasteboard.declareTypes([.string], owner: nil) + pasteboard.setString("bar", forType: .string) + }) + #expect(copies.isEmpty) + } - func testMergesMultipleItems() { - let hookExpectation = expectation(description: "Hook is called") - clipboard.onNewCopy({ (item: HistoryItem) in - XCTAssertEqual( - Set(item.contents.map({ $0.type })), - Set([self.tiffType.rawValue, self.stringType.rawValue]) - ) - hookExpectation.fulfill() - }) + @Test("Ignore all applications except") + func ignoreAllApplicationsExcept() async throws { + Defaults[.ignoreAllAppsExceptListed] = true + Defaults[.ignoredApps] = [try frontmostAppBundleIdentifier()] - let item1 = NSPasteboardItem() - item1.setString("foo", forType: .string) - let item2 = NSPasteboardItem() - item2.setData(image.tiffRepresentation!, forType: .tiff) + let copies = try await waitForCopies(after: { + pasteboard.declareTypes([.string], owner: nil) + pasteboard.setString("bar", forType: .string) + }) + #expect(copies.count == 1) + } - clipboard.start() - pasteboard.clearContents() - pasteboard.writeObjects([item1, item2]) + @Test("Ignore transient types") + func ignoreTransientTypes() async throws { + let copies = try await waitForCopies(expectingCopy: false, after: { + pasteboard.declareTypes([.string, transientType], owner: nil) + pasteboard.setString("bar", forType: .string) + }) + #expect(copies.isEmpty) + } - waitForExpectations(timeout: 2) - } + @Test("Ignore custom types") + func ignoreCustomTypes() async throws { + Defaults[.ignoredPasteboardTypes] = [customType.rawValue] - func testRemovesDisabledTypes() { - Defaults[.enabledPasteboardTypes] = [.fileURL] + let copies = try await waitForCopies(expectingCopy: false, after: { + pasteboard.declareTypes([.string, customType], owner: nil) + pasteboard.setString("bar", forType: .string) + }) + #expect(copies.isEmpty) + } - let hookExpectation = expectation(description: "Hook is called") - clipboard.onNewCopy({ (item: HistoryItem) in - XCTAssertEqual(item.contents.map({ $0.type }), [self.fileURLType.rawValue]) - hookExpectation.fulfill() - }) + @Test("Ignore copies with unknown types") + func ignoreCopiesWithUnknownTypes() async throws { + let copies = try await waitForCopies(expectingCopy: false, after: { + pasteboard.declareTypes([unknownType], owner: nil) + pasteboard.setString(" ", forType: unknownType) + }) + #expect(copies.isEmpty) + } - let item = NSPasteboardItem() - item.setString("foo", forType: .string) - item.setData(image.tiffRepresentation!, forType: .tiff) - item.setData("file://foo.bar".data(using: .utf8)!, forType: .fileURL) + @Test("Copy") + func copy() { + let imageData = image.tiffRepresentation! + let contents = [ + HistoryItemContent(type: stringType.rawValue, value: "foo".data(using: .utf8)!), + HistoryItemContent(type: tiffType.rawValue, value: imageData), + HistoryItemContent(type: fileURLType.rawValue, value: "file://foo.bar".data(using: .utf8)!) + ] + let item = HistoryItem() + Storage.shared.context.insert(item) + item.contents = contents + item.application = "com.foo.bar" + clipboard.copy(item) + #expect(pasteboard.string(forType: .string) == "foo") + #expect(pasteboard.data(forType: .tiff) == imageData) + #expect(pasteboard.string(forType: .fileURL) == "file://foo.bar") + #expect(pasteboard.string(forType: .fromMaccy) == "") + #expect(pasteboard.string(forType: .source) == "com.foo.bar") + } - clipboard.start() - pasteboard.clearContents() - pasteboard.writeObjects([item]) + @Test("Copy string") + func copyString() { + clipboard.copyInMaccy("foo") + #expect(pasteboard.string(forType: .string) == "foo") + #expect(pasteboard.string(forType: .source) == NSPasteboard.PasteboardType.fromMaccy.rawValue) + } - waitForExpectations(timeout: 2) - } + @Test("Copy without formatting") + func copyWithoutFormatting() { + let contents = [ + HistoryItemContent(type: stringType.rawValue, value: "foo".data(using: .utf8)!), + HistoryItemContent(type: fileURLType.rawValue, value: "file://foo.bar".data(using: .utf8)!), + HistoryItemContent(type: rtfType.rawValue, + value: coloredString.rtf(from: NSRange(location: 0, length: coloredString.length), + documentAttributes: [:])) + ] + let item = HistoryItem() + Storage.shared.context.insert(item) + item.contents = contents + item.application = "com.foo.bar" + clipboard.copy(item, removeFormatting: true) + #expect(pasteboard.string(forType: .string) == "foo") + #expect(pasteboard.string(forType: .fromMaccy) == "") + #expect(pasteboard.string(forType: .source) == "com.foo.bar") + #expect(pasteboard.string(forType: .fileURL) == "file://foo.bar") + #expect(pasteboard.data(forType: .rtf) == nil) + } - func testRemovesDynamicTypes() { - let hookExpectation = expectation(description: "Hook is called") - clipboard.onNewCopy({ (item: HistoryItem) in - XCTAssertEqual(item.contents.map({ $0.type }), [self.stringType.rawValue]) - hookExpectation.fulfill() - }) + @Test("Handles items without data") + func handlesItemsWithoutData() async throws { + pasteboard.clearContents() - let item = NSPasteboardItem() - item.setString("foo", forType: .string) - item.setData("".data(using: .utf8)!, forType: dynamicType) + let copies = try await waitForCopies(after: { + pasteboard.declareTypes([.fileURL, .string], owner: nil) + // fileURL is left without data + pasteboard.setString("bar", forType: .string) + }) + #expect(copies.count == 1) + } - clipboard.start() - pasteboard.clearContents() - pasteboard.writeObjects([item]) + @Test("Merges multiple items") + func mergesMultipleItems() async throws { + let item1 = NSPasteboardItem() + item1.setString("foo", forType: .string) + let item2 = NSPasteboardItem() + item2.setData(image.tiffRepresentation!, forType: .tiff) + + let copies = try await waitForCopies(after: { + pasteboard.clearContents() + pasteboard.writeObjects([item1, item2]) + }) + let copied = try #require(copies.first) + #expect(copies.count == 1) + #expect( + Set(copied.contents.map({ $0.type })) == + Set([tiffType.rawValue, stringType.rawValue]) + ) + } - waitForExpectations(timeout: 2) - } + @Test("Removes disabled types") + func removesDisabledTypes() async throws { + Defaults[.enabledPasteboardTypes] = [.fileURL] + + let item = NSPasteboardItem() + item.setString("foo", forType: .string) + item.setData(image.tiffRepresentation!, forType: .tiff) + item.setData("file://foo.bar".data(using: .utf8)!, forType: .fileURL) + + let copies = try await waitForCopies(after: { + pasteboard.clearContents() + pasteboard.writeObjects([item]) + }) + let copied = try #require(copies.first) + #expect(copies.count == 1) + #expect(copied.contents.map({ $0.type }) == [fileURLType.rawValue]) + } - func testDoesNotStoreSafariWebArchive() { - let webArchive = NSPasteboard.PasteboardType.safariWebArchve - let hookExpectation = expectation(description: "Hook is called") - clipboard.onNewCopy({ (item: HistoryItem) in - XCTAssertEqual(Set(item.contents.map(\.type)), [self.stringType.rawValue]) - hookExpectation.fulfill() - }) + @Test("Removes dynamic types") + func removesDynamicTypes() async throws { + let item = NSPasteboardItem() + item.setString("foo", forType: .string) + item.setData("".data(using: .utf8)!, forType: dynamicType) + + let copies = try await waitForCopies(after: { + pasteboard.clearContents() + pasteboard.writeObjects([item]) + }) + let copied = try #require(copies.first) + #expect(copies.count == 1) + #expect(copied.contents.map({ $0.type }) == [stringType.rawValue]) + } - let item = NSPasteboardItem() - item.setString("hello from safari", forType: .string) - item.setData(Data(repeating: 0x61, count: 1024 * 1024), forType: webArchive) + @Test("Does not store Safari web archive") + func doesNotStoreSafariWebArchive() async throws { + let webArchive = NSPasteboard.PasteboardType.safariWebArchve + + let item = NSPasteboardItem() + item.setString("hello from safari", forType: .string) + item.setData(Data(repeating: 0x61, count: 1024 * 1024), forType: webArchive) + + let copies = try await waitForCopies(after: { + pasteboard.clearContents() + pasteboard.writeObjects([item]) + }) + let copied = try #require(copies.first) + #expect(copies.count == 1) + #expect(Set(copied.contents.map(\.type)) == [stringType.rawValue]) + } - clipboard.start() - pasteboard.clearContents() - pasteboard.writeObjects([item]) + private func waitForCopies(expectingCopy: Bool = true, after trigger: () -> Void) async throws -> [HistoryItem] { + var copies: [HistoryItem] = [] + clipboard.onNewCopy { copies.append($0) } + clipboard.start() + trigger() + let deadline = ContinuousClock.now + .seconds(2) + while (!expectingCopy || copies.isEmpty) && ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(50)) + } + return copies + } - waitForExpectations(timeout: 2) - } + private func frontmostAppBundleIdentifier() throws -> String { + guard let bundleIdentifier = NSWorkspace.shared.frontmostApplication?.bundleIdentifier else { + try Test.cancel("No frontmost application to attribute a copy to") + } - private func frontmostAppBundleIdentifier() throws -> String { - guard let bundleIdentifier = NSWorkspace.shared.frontmostApplication?.bundleIdentifier else { - throw XCTSkip("No frontmost application to attribute a copy to") + return bundleIdentifier } - - return bundleIdentifier } + // swiftlint:enable type_body_length } -// swiftlint:enable type_body_length diff --git a/MaccyTests/CollectionSurroundingTests.swift b/MaccyTests/CollectionSurroundingTests.swift index f2025ea2..4ce978e6 100644 --- a/MaccyTests/CollectionSurroundingTests.swift +++ b/MaccyTests/CollectionSurroundingTests.swift @@ -1,21 +1,33 @@ -import XCTest +import Testing @testable import Maccy @MainActor -class CollectionSurroundingTests: XCTestCase { +struct CollectionSurroundingTests { let items = ["pin1", "pin2", "recent1", "recent2", "recent3"] - func testNearest() { - XCTAssertEqual(items.nearest(to: "recent1") { $0 != "recent1" }, "recent2") - XCTAssertEqual(items.nearest(to: "recent2") { $0 != "recent2" }, "recent3") - XCTAssertEqual(items.nearest(to: "recent3") { $0 != "recent3" }, "recent2") - XCTAssertNil(items.nearest(to: "recent1") { _ in false }) - XCTAssertNil(items.nearest(to: "missing") { _ in true }) + @Test("Nearest item other than itself", arguments: [ + ("recent1", "recent2"), + ("recent2", "recent3"), + ("recent3", "recent2") + ]) + func nearestItemOtherThanItself(current: String, expected: String) { + #expect(items.nearest(to: current) { $0 != current } == expected) } + @Test("Nearest is nil when nothing matches") + func nearestIsNilWhenNothingMatches() { + #expect(items.nearest(to: "recent1") { _ in false } == nil) + } + + @Test("Nearest is nil for a missing item") + func nearestIsNilForMissingItem() { + #expect(items.nearest(to: "missing") { _ in true } == nil) + } + + @Test("Nearest skips multiple excluded leading items") func testNearestWithMultipleLeadingMatchesExcluded() { let excluded: Set = ["a", "b", "recent1"] let items = ["a", "b", "recent1", "recent2", "recent3"] - XCTAssertEqual(items.nearest(to: "recent1") { !excluded.contains($0) }, "recent2") + #expect(items.nearest(to: "recent1") { !excluded.contains($0) } == "recent2") } } diff --git a/MaccyTests/ColorImageTests.swift b/MaccyTests/ColorImageTests.swift index f974bc6e..727725d1 100644 --- a/MaccyTests/ColorImageTests.swift +++ b/MaccyTests/ColorImageTests.swift @@ -1,17 +1,15 @@ -import XCTest +import Testing @testable import Maccy @MainActor -class ColorImageTests: XCTestCase { - func testColorImageFromShortHex() { - XCTAssertNotNil(ColorImage.from("fff")) +struct ColorImageTests { + @Test("Color image from hex", arguments: ["fff", "#ff8942"]) + func colorImageFromHex(hex: String) { + #expect(ColorImage.from(hex) != nil) } - func testColorFromFullHex() { - XCTAssertNotNil(ColorImage.from("#ff8942")) - } - - func testColorFromNotHex() { - XCTAssertNil(ColorImage.from("foo")) + @Test("Color image from non-hex string") + func colorImageFromNonHexString() { + #expect(ColorImage.from("foo") == nil) } } diff --git a/MaccyTests/HistoryDecoratorTests.swift b/MaccyTests/HistoryDecoratorTests.swift index 1ad2910e..f6ae3ebd 100644 --- a/MaccyTests/HistoryDecoratorTests.swift +++ b/MaccyTests/HistoryDecoratorTests.swift @@ -1,227 +1,229 @@ -import XCTest +import AppKit +import Testing import Defaults @testable import Maccy -@MainActor -class HistoryItemDecoratorTests: XCTestCase { - let boldFont = NSFont.boldSystemFont(ofSize: NSFont.systemFontSize) - let savedHighlightMatch = Defaults[.highlightMatch] - let savedImageMaxHeight = Defaults[.imageMaxHeight] +extension SharedStateTests { + @MainActor + final class HistoryItemDecoratorTests { + let savedHighlightMatch = Defaults[.highlightMatch] + let savedImageMaxHeight = Defaults[.imageMaxHeight] - var firstCopiedAt: Date! { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy/MM/dd HH:mm:ss" - return formatter.date(from: "2020/07/10 12:31:34") - } + var firstCopiedAt: Date! { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy/MM/dd HH:mm:ss" + return formatter.date(from: "2020/07/10 12:31:34") + } - var lastCopiedAt: Date! { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy/MM/dd HH:mm:ss" - return formatter.date(from: "2020/07/10 12:41:34") - } + var lastCopiedAt: Date! { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy/MM/dd HH:mm:ss" + return formatter.date(from: "2020/07/10 12:41:34") + } - override func setUp() { - super.setUp() - Defaults[.highlightMatch] = .bold - Defaults[.imageMaxHeight] = 40 - } + init() { + Defaults[.highlightMatch] = .bold + Defaults[.imageMaxHeight] = 40 + } - override func tearDown() { - super.tearDown() - Defaults[.imageMaxHeight] = savedImageMaxHeight - Defaults[.highlightMatch] = savedHighlightMatch - } + deinit { + Defaults[.imageMaxHeight] = savedImageMaxHeight + Defaults[.highlightMatch] = savedHighlightMatch + } - func testString() { - let title = "foo" - let itemDecorator = historyItemDecorator(title) - XCTAssertEqual(itemDecorator.title, title) - XCTAssertNil(itemDecorator.previewImage) - XCTAssertNil(itemDecorator.thumbnailImage) - } + @Test("String") + func string() { + let title = "foo" + let itemDecorator = historyItemDecorator(title) + #expect(itemDecorator.title == title) + #expect(itemDecorator.previewImage == nil) + #expect(itemDecorator.thumbnailImage == nil) + } - func testRTF() { - let rtf = NSAttributedString(string: "foo").rtf( - from: NSRange(0...2), - documentAttributes: [:] - ) - let itemDecorator = historyItemDecorator(rtf, .rtf) - XCTAssertEqual(itemDecorator.title, "foo") - XCTAssertNil(itemDecorator.previewImage) - XCTAssertNil(itemDecorator.thumbnailImage) - } + @Test("RTF") + func rtf() { + let rtf = NSAttributedString(string: "foo").rtf( + from: NSRange(0...2), + documentAttributes: [:] + ) + let itemDecorator = historyItemDecorator(rtf, .rtf) + #expect(itemDecorator.title == "foo") + #expect(itemDecorator.previewImage == nil) + #expect(itemDecorator.thumbnailImage == nil) + } - func testHTML() { - let html = "foo".data(using: .utf8) - let itemDecorator = historyItemDecorator(html, .html) - XCTAssertEqual(itemDecorator.title, "foo") - XCTAssertNil(itemDecorator.previewImage) - XCTAssertNil(itemDecorator.thumbnailImage) - } + @Test("HTML") + func html() { + let html = "foo".data(using: .utf8) + let itemDecorator = historyItemDecorator(html, .html) + #expect(itemDecorator.title == "foo") + #expect(itemDecorator.previewImage == nil) + #expect(itemDecorator.thumbnailImage == nil) + } - func testImage() { - let image = NSImage(named: "StatusBarMenuImage")! - let itemDecorator = historyItemDecorator(image) - itemDecorator.sizeImages() - XCTAssertEqual(itemDecorator.title, "") - XCTAssertEqual(itemDecorator.previewImage!.size, image.size) - XCTAssertEqual(itemDecorator.thumbnailImage!.size, image.size) - } + @Test("Image") + func image() throws { + let image = try #require(NSImage(named: "StatusBarMenuImage")) + let itemDecorator = historyItemDecorator(image) + itemDecorator.sizeImages() + #expect(itemDecorator.title == "") + #expect(try #require(itemDecorator.previewImage).size == image.size) + #expect(try #require(itemDecorator.thumbnailImage).size == image.size) + } - // We also need to add test for image with width bigger than max width. - func testImageWithHeightBiggerThanMaxHeight() { - let image = NSImage(named: "NSApplicationIcon")! - let itemDecorator = historyItemDecorator(image) - itemDecorator.sizeImages() - XCTAssertEqual(itemDecorator.thumbnailImage!.size, NSSize(width: 40, height: 40)) - } + // We also need to add test for image with width bigger than max width. + @Test("Image with height bigger than max height") + func imageWithHeightBiggerThanMaxHeight() throws { + let image = try #require(NSImage(named: "NSApplicationIcon")) + let itemDecorator = historyItemDecorator(image) + itemDecorator.sizeImages() + #expect(try #require(itemDecorator.thumbnailImage).size == NSSize(width: 40, height: 40)) + } - func testFile() { - let url = URL(fileURLWithPath: "/tmp/foo.bar") - let itemDecorator = historyItemDecorator(url) - XCTAssertEqual(itemDecorator.title, "file:///tmp/foo.bar") - XCTAssertNil(itemDecorator.previewImage) - XCTAssertNil(itemDecorator.thumbnailImage) - } + @Test("File", arguments: ["/tmp/foo.bar", "/tmp/产品培训/产品培训.txt"]) + func file(path: String) { + let url = URL(fileURLWithPath: path) + let itemDecorator = historyItemDecorator(url) + #expect(itemDecorator.title == "file://\(path)") + #expect(itemDecorator.previewImage == nil) + #expect(itemDecorator.thumbnailImage == nil) + } - func testFileWithEscapedChars() { - let url = URL(fileURLWithPath: "/tmp/产品培训/产品培训.txt") - let itemDecorator = historyItemDecorator(url) - XCTAssertEqual(itemDecorator.title, "file:///tmp/产品培训/产品培训.txt") - XCTAssertNil(itemDecorator.previewImage) - XCTAssertNil(itemDecorator.thumbnailImage) - } + @Test("Item without data") + func itemWithoutData() { + let itemDecorator = historyItemDecorator(nil) + #expect(itemDecorator.title == "") + #expect(itemDecorator.previewImage == nil) + #expect(itemDecorator.thumbnailImage == nil) + } - func testItemWithoutData() { - let itemDecorator = historyItemDecorator(nil) - XCTAssertEqual(itemDecorator.title, "") - XCTAssertNil(itemDecorator.previewImage) - XCTAssertNil(itemDecorator.thumbnailImage) - } + @Test("Unpinned by default") + func unpinnedByDefault() { + let itemDecorator = historyItemDecorator("foo") + #expect(itemDecorator.item.pin == nil) + #expect(!itemDecorator.isPinned) + } - func testUnpinnedByDefault() { - let itemDecorator = historyItemDecorator("foo") - XCTAssertNil(itemDecorator.item.pin) - XCTAssertFalse(itemDecorator.isPinned) - } + @Test("Deallocates when no longer referenced") + func deallocatesWhenNoLongerReferenced() { + weak var weakDecorator: HistoryItemDecorator? - func testDeallocatesWhenNoLongerReferenced() { - weak var weakDecorator: HistoryItemDecorator? + autoreleasepool { + let itemDecorator = historyItemDecorator("foo") + weakDecorator = itemDecorator + #expect(weakDecorator != nil) + } - autoreleasepool { - let itemDecorator = historyItemDecorator("foo") - weakDecorator = itemDecorator - XCTAssertNotNil(weakDecorator) + #expect(weakDecorator == nil) } - XCTAssertNil(weakDecorator) - } - - func testHighlight() { - let itemDecorator = historyItemDecorator("foo bar baz") - itemDecorator.highlight("random", [ - range(from: 1, to: 2, in: itemDecorator), - range(from: 8, to: 10, in: itemDecorator) - ]) - var expectedTitle = AttributedString("foo bar baz") - expectedTitle[expectedTitle.range(of: "oo")!].font = .bold(.body)() - expectedTitle[expectedTitle.range(of: "baz")!].font = .bold(.body)() - XCTAssertEqual(itemDecorator.attributedTitle, expectedTitle) - itemDecorator.highlight("", []) - XCTAssertEqual(itemDecorator.attributedTitle, nil) - } + @Test("Highlight") + func highlight() throws { + let itemDecorator = historyItemDecorator("foo bar baz") + itemDecorator.highlight("random", [ + range(from: 1, to: 2, in: itemDecorator), + range(from: 8, to: 10, in: itemDecorator) + ]) + var expectedTitle = AttributedString("foo bar baz") + expectedTitle[try #require(expectedTitle.range(of: "oo"))].font = .bold(.body)() + expectedTitle[try #require(expectedTitle.range(of: "baz"))].font = .bold(.body)() + #expect(itemDecorator.attributedTitle == expectedTitle) + itemDecorator.highlight("", []) + #expect(itemDecorator.attributedTitle == nil) + } - private func historyItemDecorator( - _ value: String?, - application: String? = "com.apple.finder" - ) -> HistoryItemDecorator { - let contents = [ - HistoryItemContent( - type: NSPasteboard.PasteboardType.string.rawValue, - value: value?.data(using: .utf8) - ) - ] - let item = HistoryItem() - Storage.shared.context.insert(item) - item.contents = contents - item.title = item.generateTitle() - item.application = application - item.firstCopiedAt = firstCopiedAt - item.lastCopiedAt = lastCopiedAt - - return HistoryItemDecorator(item) - } + private func historyItemDecorator( + _ value: String?, + application: String? = "com.apple.finder" + ) -> HistoryItemDecorator { + let contents = [ + HistoryItemContent( + type: NSPasteboard.PasteboardType.string.rawValue, + value: value?.data(using: .utf8) + ) + ] + let item = HistoryItem() + Storage.shared.context.insert(item) + item.contents = contents + item.title = item.generateTitle() + item.application = application + item.firstCopiedAt = firstCopiedAt + item.lastCopiedAt = lastCopiedAt + + return HistoryItemDecorator(item) + } - private func historyItemDecorator( - _ value: Data?, - _ type: NSPasteboard.PasteboardType - ) -> HistoryItemDecorator { - let contents = [ - HistoryItemContent( - type: type.rawValue, - value: value - ) - ] - let item = HistoryItem() - Storage.shared.context.insert(item) - item.contents = contents - item.title = item.generateTitle() - item.application = "com.apple.finder" - item.firstCopiedAt = firstCopiedAt - item.lastCopiedAt = lastCopiedAt - item.numberOfCopies = 2 - - return HistoryItemDecorator(item) - } + private func historyItemDecorator( + _ value: Data?, + _ type: NSPasteboard.PasteboardType + ) -> HistoryItemDecorator { + let contents = [ + HistoryItemContent( + type: type.rawValue, + value: value + ) + ] + let item = HistoryItem() + Storage.shared.context.insert(item) + item.contents = contents + item.title = item.generateTitle() + item.application = "com.apple.finder" + item.firstCopiedAt = firstCopiedAt + item.lastCopiedAt = lastCopiedAt + item.numberOfCopies = 2 + + return HistoryItemDecorator(item) + } - private func historyItemDecorator(_ value: NSImage) -> HistoryItemDecorator { - let contents = [ - HistoryItemContent( - type: NSPasteboard.PasteboardType.tiff.rawValue, - value: value.tiffRepresentation! - ) - ] - let item = HistoryItem() - Storage.shared.context.insert(item) - item.contents = contents - item.title = item.generateTitle() - item.application = "com.apple.finder" - item.firstCopiedAt = firstCopiedAt - item.lastCopiedAt = lastCopiedAt - item.numberOfCopies = 2 - - return HistoryItemDecorator(item) - } + private func historyItemDecorator(_ value: NSImage) -> HistoryItemDecorator { + let contents = [ + HistoryItemContent( + type: NSPasteboard.PasteboardType.tiff.rawValue, + value: value.tiffRepresentation + ) + ] + let item = HistoryItem() + Storage.shared.context.insert(item) + item.contents = contents + item.title = item.generateTitle() + item.application = "com.apple.finder" + item.firstCopiedAt = firstCopiedAt + item.lastCopiedAt = lastCopiedAt + item.numberOfCopies = 2 + + return HistoryItemDecorator(item) + } - private func historyItemDecorator(_ value: URL) -> HistoryItemDecorator { - let contents = [ - HistoryItemContent( - type: NSPasteboard.PasteboardType.fileURL.rawValue, - value: value.dataRepresentation - ), - HistoryItemContent( - type: NSPasteboard.PasteboardType.string.rawValue, - value: value.lastPathComponent.data(using: .utf8) - ) - ] - let item = HistoryItem() - Storage.shared.context.insert(item) - item.contents = contents - item.title = item.generateTitle() - item.application = "com.apple.finder" - item.firstCopiedAt = firstCopiedAt - item.lastCopiedAt = lastCopiedAt - item.numberOfCopies = 2 - - return HistoryItemDecorator(item) - } + private func historyItemDecorator(_ value: URL) -> HistoryItemDecorator { + let contents = [ + HistoryItemContent( + type: NSPasteboard.PasteboardType.fileURL.rawValue, + value: value.dataRepresentation + ), + HistoryItemContent( + type: NSPasteboard.PasteboardType.string.rawValue, + value: value.lastPathComponent.data(using: .utf8) + ) + ] + let item = HistoryItem() + Storage.shared.context.insert(item) + item.contents = contents + item.title = item.generateTitle() + item.application = "com.apple.finder" + item.firstCopiedAt = firstCopiedAt + item.lastCopiedAt = lastCopiedAt + item.numberOfCopies = 2 + + return HistoryItemDecorator(item) + } - // swiftlint:disable:next identifier_name - private func range(from: Int, to: Int, in item: HistoryItemDecorator) -> Range { - let startIndex = item.title.startIndex - let lowerBound = item.title.index(startIndex, offsetBy: from) - let upperBound = item.title.index(startIndex, offsetBy: to + 1) + // swiftlint:disable:next identifier_name + private func range(from: Int, to: Int, in item: HistoryItemDecorator) -> Range { + let startIndex = item.title.startIndex + let lowerBound = item.title.index(startIndex, offsetBy: from) + let upperBound = item.title.index(startIndex, offsetBy: to + 1) - return lowerBound.. NSImage { + let string = NSAttributedString(string: text, attributes: [ + .font: NSFont.boldSystemFont(ofSize: 48), + .foregroundColor: NSColor.black + ]) + let size = NSSize(width: string.size().width + 40, height: string.size().height + 40) + return NSImage(size: size, flipped: false) { rect in + NSColor.white.setFill() + rect.fill() + string.draw(at: NSPoint(x: 20, y: 20)) + return true } +} - private func historyItem(_ value: String?) -> HistoryItem { - let contents = [ - HistoryItemContent( +private let stringTitleCases: [(input: String, title: String)] = [ + ("foo", "foo"), + (" foo bar ", "···foo bar···"), + ("\nfoo\nbar\n", "⏎foo⏎bar⏎"), + ("\tfoo\tbar\t", "⇥foo⇥bar⇥") +] + +private let objectReplacementTitleCases: [(input: String, title: String)] = [ + ("\u{FFFC}foo\u{FFFC}bar\u{FFFC}", "foobar"), + ("\u{FFFC}\u{FFFC}", "") +] + +extension SharedStateTests { + @MainActor + struct HistoryItemTests { + @Test("Sorted pins place letters before symbols") + func sortedPinsPlacesLettersBeforeSymbols() { + #expect( + HistoryItem.sortedPins(["/", "c", "[", "b", "-", "\\", "y"]) == + ["b", "c", "y", "-", "/", "[", "\\"] + ) + } + + @Test("Title for string", arguments: stringTitleCases) + func titleForString(input: String, title: String) { + #expect(historyItem(input).title == title) + } + + // U+FFFC arrives from rich text with inline attachments and hangs CoreText + // on macOS 26. See https://github.com/p0deje/Maccy/issues/1520. + @Test("Title without object replacement characters", arguments: objectReplacementTitleCases) + func titleWithObjectReplacementCharacters(input: String, title: String) { + #expect(historyItem(input).title == title) + } + + @Test("Title for RTF") + func titleWithRTF() { + let rtf = NSAttributedString(string: "foo").rtf( + from: NSRange(0...2), + documentAttributes: [:] + ) + #expect(historyItem(rtf, .rtf).title == "foo") + } + + @Test("Title for HTML") + func titleWithHTML() { + let html = "foo".data(using: .utf8) + #expect(historyItem(html, .html).title == "foo") + } + + @Test("Image has an empty title") + func image() throws { + let image = try #require(NSImage(named: "NSBluetoothTemplate")) + #expect(historyItem(image).title == "") + } + + @Test("Image title becomes the recognized text") + func imageTitleBecomesRecognizedText() async throws { + let item = historyItem(textImage("Hello Maccy")) + let deadline = ContinuousClock.now + .seconds(10) + while item.title.isEmpty && ContinuousClock.now < deadline { + try await Task.sleep(for: .milliseconds(100)) + } + #expect(item.title == "Hello Maccy") + } + + @Test("Title for file", arguments: ["/tmp/foo.bar", "/tmp/产品培训/产品培训.txt"]) + func file(path: String) { + let url = URL(fileURLWithPath: path) + #expect(historyItem(url).title == "file://\(path)") + } + + @Test("Text from Universal Clipboard") + func textFromUniversalClipboard() { + let url = URL(fileURLWithPath: "/tmp/foo.bar") + let fileURLContent = HistoryItemContent( + type: NSPasteboard.PasteboardType.fileURL.rawValue, + value: url.dataRepresentation + ) + let textContent = HistoryItemContent( type: NSPasteboard.PasteboardType.string.rawValue, - value: value?.data(using: .utf8) + value: url.lastPathComponent.data(using: .utf8) ) - ] - let item = HistoryItem() - Storage.shared.context.insert(item) - item.contents = contents - item.title = item.generateTitle() - - return item - } - - private func historyItem(_ data: Data?, _ type: NSPasteboard.PasteboardType) -> HistoryItem { - let contents = [ - HistoryItemContent( - type: type.rawValue, - value: data + let universalClipboardContent = HistoryItemContent( + type: NSPasteboard.PasteboardType.universalClipboard.rawValue, + value: "".data(using: .utf8) ) - ] - let item = HistoryItem() - Storage.shared.context.insert(item) - item.contents = contents - item.title = item.generateTitle() - - return item - } - - private func historyItem(_ value: NSImage) -> HistoryItem { - let contents = [ - HistoryItemContent( - type: NSPasteboard.PasteboardType.tiff.rawValue, - value: value.tiffRepresentation! + let item = HistoryItem() + Storage.shared.context.insert(item) + item.contents = [fileURLContent, textContent, universalClipboardContent] + item.title = item.generateTitle() + #expect(item.title == "foo.bar") + } + + @Test("Image from Universal Clipboard") + func imageFromUniversalClipboard() throws { + let url = try #require(Bundle(for: BundleToken.self).url(forResource: "guy", withExtension: "jpeg")) + let fileURLContent = HistoryItemContent( + type: NSPasteboard.PasteboardType.fileURL.rawValue, + value: url.dataRepresentation ) - ] - let item = HistoryItem() - Storage.shared.context.insert(item) - item.contents = contents - item.title = item.generateTitle() - - return item - } - - private func historyItem(_ value: URL) -> HistoryItem { - let contents = [ - HistoryItemContent( + let universalClipboardContent = HistoryItemContent( + type: NSPasteboard.PasteboardType.universalClipboard.rawValue, + value: "".data(using: .utf8) + ) + let item = HistoryItem() + Storage.shared.context.insert(item) + item.contents = [fileURLContent, universalClipboardContent] + let image = try #require(item.image) + let expected = try #require(NSImage(data: try Data(contentsOf: url))) + #expect(image.tiffRepresentation == expected.tiffRepresentation) + } + + @Test("File from Universal Clipboard") + func fileFromUniversalClipboard() { + let url = URL(fileURLWithPath: "/tmp/foo.bar") + let fileURLContent = HistoryItemContent( type: NSPasteboard.PasteboardType.fileURL.rawValue, - value: value.dataRepresentation - ), - HistoryItemContent( - type: NSPasteboard.PasteboardType.string.rawValue, - value: value.lastPathComponent.data(using: .utf8) + value: url.dataRepresentation ) - ] - let item = HistoryItem() - Storage.shared.context.insert(item) - item.contents = contents - item.title = item.generateTitle() - - return item + let universalClipboardContent = HistoryItemContent( + type: NSPasteboard.PasteboardType.universalClipboard.rawValue, + value: "".data(using: .utf8) + ) + let item = HistoryItem() + Storage.shared.context.insert(item) + item.contents = [fileURLContent, universalClipboardContent] + item.title = item.generateTitle() + #expect(item.title == "file:///tmp/foo.bar") + } + + @Test("Item without data has an empty title") + func itemWithoutData() { + #expect(historyItem(nil).title == "") + } + + @Test("Several items can have an empty pin") + func severalItemsCanHaveEmptyPin() { + let item1 = historyItem("foo") + item1.pin = "" + let item2 = historyItem("bar") + item2.pin = "" + #expect(throws: Never.self) { + try Storage.shared.context.save() + } + #expect(item1.pin == "") + #expect(item2.pin == "") + } + + private func historyItem(_ value: String?) -> HistoryItem { + let contents = [ + HistoryItemContent( + type: NSPasteboard.PasteboardType.string.rawValue, + value: value?.data(using: .utf8) + ) + ] + let item = HistoryItem() + Storage.shared.context.insert(item) + item.contents = contents + item.title = item.generateTitle() + + return item + } + + private func historyItem(_ data: Data?, _ type: NSPasteboard.PasteboardType) -> HistoryItem { + let contents = [ + HistoryItemContent( + type: type.rawValue, + value: data + ) + ] + let item = HistoryItem() + Storage.shared.context.insert(item) + item.contents = contents + item.title = item.generateTitle() + + return item + } + + private func historyItem(_ value: NSImage) -> HistoryItem { + let contents = [ + HistoryItemContent( + type: NSPasteboard.PasteboardType.tiff.rawValue, + value: value.tiffRepresentation + ) + ] + let item = HistoryItem() + Storage.shared.context.insert(item) + item.contents = contents + item.title = item.generateTitle() + + return item + } + + private func historyItem(_ value: URL) -> HistoryItem { + let contents = [ + HistoryItemContent( + type: NSPasteboard.PasteboardType.fileURL.rawValue, + value: value.dataRepresentation + ), + HistoryItemContent( + type: NSPasteboard.PasteboardType.string.rawValue, + value: value.lastPathComponent.data(using: .utf8) + ) + ] + let item = HistoryItem() + Storage.shared.context.insert(item) + item.contents = contents + item.title = item.generateTitle() + + return item + } } } -// swiftlint:enable force_try diff --git a/MaccyTests/HistoryTests.swift b/MaccyTests/HistoryTests.swift index 33b09eaa..f0f243cd 100644 --- a/MaccyTests/HistoryTests.swift +++ b/MaccyTests/HistoryTests.swift @@ -1,470 +1,484 @@ -import XCTest +import AppKit +import Testing import Defaults import SwiftData @testable import Maccy -@MainActor -class HistoryTests: XCTestCase { // swiftlint:disable:this type_body_length - let savedSize = Defaults[.size] - let savedSortBy = Defaults[.sortBy] - let savedPinOrder = Defaults[.pinOrder] - let savedPinTo = Defaults[.pinTo] - let history = History.shared - - override func setUp() { - super.setUp() - MainActor.assumeIsolated { +extension SharedStateTests { + @MainActor + final class HistoryTests { // swiftlint:disable:this type_body_length + let savedSize = Defaults[.size] + let savedSortBy = Defaults[.sortBy] + let savedPinOrder = Defaults[.pinOrder] + let savedPinTo = Defaults[.pinTo] + let history = History.shared + + init() { History.shared.clearAll() + Defaults[.size] = 10 + Defaults[.sortBy] = .firstCopiedAt + Defaults[.pinOrder] = PinOrder() + Defaults[.pinTo] = .bottom } - Defaults[.size] = 10 - Defaults[.sortBy] = .firstCopiedAt - Defaults[.pinOrder] = PinOrder() - Defaults[.pinTo] = .bottom - } - - override func tearDown() { - super.tearDown() - Defaults[.size] = savedSize - Defaults[.sortBy] = savedSortBy - Defaults[.pinOrder] = savedPinOrder - Defaults[.pinTo] = savedPinTo - } - - func testDefaultIsEmpty() { - XCTAssertEqual(history.items.toArray(), []) - } - func testAdding() { - let first = history.add(historyItem("foo")) - let second = history.add(historyItem("bar")) - XCTAssertEqual(history.items.toArray(), [second, first]) - } - - func testAddingPersistedDuplicate() throws { - let first = historyItem("foo") - first.title = "xyz" - first.application = "iTerm.app" - history.add(first) - first.pin = "f" - - let third = historyItem("foo") - third.application = "Xcode.app" - let transferredContents = first.contents - let merged = history.add(third) - - XCTAssertEqual(history.items.toArray(), [merged]) - XCTAssertEqual(Set(merged.item.contents), Set(transferredContents)) - XCTAssertTrue(merged.item.lastCopiedAt > merged.item.firstCopiedAt) - XCTAssertEqual(merged.item.numberOfCopies, 2) - XCTAssertEqual(merged.item.pin, "f") - XCTAssertEqual(merged.item.title, "xyz") - XCTAssertEqual(merged.item.application, "iTerm.app") - try assertStorageCounts(items: 1, contents: 1) - } + deinit { + Defaults[.size] = savedSize + Defaults[.sortBy] = savedSortBy + Defaults[.pinOrder] = savedPinOrder + Defaults[.pinTo] = savedPinTo + } - func testAddingUnsavedDuplicate() throws { - guard #available(macOS 15.0, *) else { - throw XCTSkip("Incoming history items are inserted before add on macOS 14") + @Test("Default is empty") + func defaultIsEmpty() { + #expect(history.items.toArray() == []) } - let first = historyItem("foo") - first.title = "xyz" - first.application = "iTerm.app" - history.add(first) - first.pin = "f" - - let second = historyItem("foo", persisted: false) - second.application = "Xcode.app" - let transferredContents = first.contents - let merged = history.add(second) - - XCTAssertEqual(history.items.toArray(), [merged]) - XCTAssertEqual(Set(merged.item.contents), Set(transferredContents)) - XCTAssertTrue(merged.item.lastCopiedAt > merged.item.firstCopiedAt) - XCTAssertEqual(merged.item.numberOfCopies, 2) - XCTAssertEqual(merged.item.pin, "f") - XCTAssertEqual(merged.item.title, "xyz") - XCTAssertEqual(merged.item.application, "iTerm.app") - try assertStorageCounts(items: 1, contents: 1) - } + @Test("Adding") + func adding() { + let first = history.add(historyItem("foo")) + let second = history.add(historyItem("bar")) + #expect(history.items.toArray() == [second, first]) + } - func testAddingItemThatIsSupersededByExisting() throws { - let firstContents = [ - HistoryItemContent( - type: NSPasteboard.PasteboardType.string.rawValue, - value: "one".data(using: .utf8)! - ), - HistoryItemContent( - type: NSPasteboard.PasteboardType.rtf.rawValue, - value: "two".data(using: .utf8)! - ) - ] - let firstItem = HistoryItem() - Storage.shared.context.insert(firstItem) - firstItem.application = "Maccy.app" - firstItem.contents = firstContents - firstItem.title = firstItem.generateTitle() - history.add(firstItem) - - let secondContents = [ - HistoryItemContent( - type: NSPasteboard.PasteboardType.string.rawValue, - value: "one".data(using: .utf8)! - ) - ] - let secondItem = HistoryItem() - Storage.shared.context.insert(secondItem) - secondItem.application = "Maccy.app" - secondItem.contents = secondContents - secondItem.title = secondItem.generateTitle() - let second = history.add(secondItem) - - XCTAssertEqual(history.items.toArray(), [second]) - XCTAssertEqual(Set(history.items[0].item.contents), Set(firstContents)) - try assertStorageCounts(items: 1, contents: firstContents.count) - } + @Test("Adding persisted duplicate") + func addingPersistedDuplicate() throws { + let first = historyItem("foo") + first.title = "xyz" + first.application = "iTerm.app" + history.add(first) + first.pin = "f" + + let third = historyItem("foo") + third.application = "Xcode.app" + let transferredContents = first.contents + let merged = history.add(third) + + #expect(history.items.toArray() == [merged]) + #expect(Set(merged.item.contents) == Set(transferredContents)) + #expect(merged.item.lastCopiedAt > merged.item.firstCopiedAt) + #expect(merged.item.numberOfCopies == 2) + #expect(merged.item.pin == "f") + #expect(merged.item.title == "xyz") + #expect(merged.item.application == "iTerm.app") + try assertStorageCounts(items: 1, contents: 1) + } - func testAddingItemWithDifferentModifiedType() { - let firstContents = [ - HistoryItemContent( - type: NSPasteboard.PasteboardType.string.rawValue, - value: "one".data(using: .utf8)! - ), - HistoryItemContent( - type: NSPasteboard.PasteboardType.modified.rawValue, - value: "1".data(using: .utf8)! - ) - ] - let firstItem = HistoryItem() - Storage.shared.context.insert(firstItem) - firstItem.contents = firstContents - history.add(firstItem) - - let secondContents = [ - HistoryItemContent( - type: NSPasteboard.PasteboardType.string.rawValue, - value: "one".data(using: .utf8)! - ), - HistoryItemContent( - type: NSPasteboard.PasteboardType.modified.rawValue, - value: "2".data(using: .utf8)! - ) - ] - let secondItem = HistoryItem() - Storage.shared.context.insert(secondItem) - secondItem.contents = secondContents - let second = history.add(secondItem) - - XCTAssertEqual(history.items.toArray(), [second]) - XCTAssertEqual(Set(history.items[0].item.contents), Set(firstContents)) - } + @Test("Adding unsaved duplicate") + @available(macOS 15.0, *) + func addingUnsavedDuplicate() throws { + let first = historyItem("foo") + first.title = "xyz" + first.application = "iTerm.app" + history.add(first) + first.pin = "f" + + let second = historyItem("foo", persisted: false) + second.application = "Xcode.app" + let transferredContents = first.contents + let merged = history.add(second) + + #expect(history.items.toArray() == [merged]) + #expect(Set(merged.item.contents) == Set(transferredContents)) + #expect(merged.item.lastCopiedAt > merged.item.firstCopiedAt) + #expect(merged.item.numberOfCopies == 2) + #expect(merged.item.pin == "f") + #expect(merged.item.title == "xyz") + #expect(merged.item.application == "iTerm.app") + try assertStorageCounts(items: 1, contents: 1) + } - func testAddingItemFromMaccy() { - let firstContents = [ - HistoryItemContent( - type: NSPasteboard.PasteboardType.string.rawValue, - value: "one".data(using: .utf8) - ) - ] - let first = HistoryItem() - Storage.shared.context.insert(first) - first.application = "Xcode.app" - first.contents = firstContents - history.add(first) - - let secondContents = [ - HistoryItemContent( - type: NSPasteboard.PasteboardType.string.rawValue, - value: "one".data(using: .utf8) - ), - HistoryItemContent( - type: NSPasteboard.PasteboardType.fromMaccy.rawValue, - value: "".data(using: .utf8) - ) - ] - let second = HistoryItem() - Storage.shared.context.insert(second) - second.application = "Maccy.app" - second.contents = secondContents - let secondDecorator = history.add(second) - - XCTAssertEqual(history.items.toArray(), [secondDecorator]) - XCTAssertEqual(history.items[0].item.application, "Xcode.app") - XCTAssertEqual(Set(history.items[0].item.contents), Set(firstContents)) - } + @Test("Adding item that is superseded by existing") + func addingItemThatIsSupersededByExisting() throws { + let firstContents = [ + HistoryItemContent( + type: NSPasteboard.PasteboardType.string.rawValue, + value: "one".data(using: .utf8)! + ), + HistoryItemContent( + type: NSPasteboard.PasteboardType.rtf.rawValue, + value: "two".data(using: .utf8)! + ) + ] + let firstItem = HistoryItem() + Storage.shared.context.insert(firstItem) + firstItem.application = "Maccy.app" + firstItem.contents = firstContents + firstItem.title = firstItem.generateTitle() + history.add(firstItem) + + let secondContents = [ + HistoryItemContent( + type: NSPasteboard.PasteboardType.string.rawValue, + value: "one".data(using: .utf8)! + ) + ] + let secondItem = HistoryItem() + Storage.shared.context.insert(secondItem) + secondItem.application = "Maccy.app" + secondItem.contents = secondContents + secondItem.title = secondItem.generateTitle() + let second = history.add(secondItem) + + #expect(history.items.toArray() == [second]) + #expect(Set(history.items[0].item.contents) == Set(firstContents)) + try assertStorageCounts(items: 1, contents: firstContents.count) + } - func testModifiedAfterCopying() { - history.add(historyItem("foo")) + @Test("Adding item with different modified type") + func addingItemWithDifferentModifiedType() { + let firstContents = [ + HistoryItemContent( + type: NSPasteboard.PasteboardType.string.rawValue, + value: "one".data(using: .utf8)! + ), + HistoryItemContent( + type: NSPasteboard.PasteboardType.modified.rawValue, + value: "1".data(using: .utf8)! + ) + ] + let firstItem = HistoryItem() + Storage.shared.context.insert(firstItem) + firstItem.contents = firstContents + history.add(firstItem) + + let secondContents = [ + HistoryItemContent( + type: NSPasteboard.PasteboardType.string.rawValue, + value: "one".data(using: .utf8)! + ), + HistoryItemContent( + type: NSPasteboard.PasteboardType.modified.rawValue, + value: "2".data(using: .utf8)! + ) + ] + let secondItem = HistoryItem() + Storage.shared.context.insert(secondItem) + secondItem.contents = secondContents + let second = history.add(secondItem) + + #expect(history.items.toArray() == [second]) + #expect(Set(history.items[0].item.contents) == Set(firstContents)) + } - let modifiedItem = historyItem("bar") - modifiedItem.contents.append(HistoryItemContent( - type: NSPasteboard.PasteboardType.modified.rawValue, - value: String(Clipboard.shared.changeCount).data(using: .utf8) - )) - let modifiedItemDecorator = history.add(modifiedItem) + @Test("Adding item from Maccy") + func addingItemFromMaccy() { + let firstContents = [ + HistoryItemContent( + type: NSPasteboard.PasteboardType.string.rawValue, + value: "one".data(using: .utf8) + ) + ] + let first = HistoryItem() + Storage.shared.context.insert(first) + first.application = "Xcode.app" + first.contents = firstContents + history.add(first) + + let secondContents = [ + HistoryItemContent( + type: NSPasteboard.PasteboardType.string.rawValue, + value: "one".data(using: .utf8) + ), + HistoryItemContent( + type: NSPasteboard.PasteboardType.fromMaccy.rawValue, + value: "".data(using: .utf8) + ) + ] + let second = HistoryItem() + Storage.shared.context.insert(second) + second.application = "Maccy.app" + second.contents = secondContents + let secondDecorator = history.add(second) + + #expect(history.items.toArray() == [secondDecorator]) + #expect(history.items[0].item.application == "Xcode.app") + #expect(Set(history.items[0].item.contents) == Set(firstContents)) + } - XCTAssertEqual(history.items.toArray(), [modifiedItemDecorator]) - XCTAssertEqual(history.items[0].text, "bar") - } + @Test("Modified after copying") + func modifiedAfterCopying() { + history.add(historyItem("foo")) - func testClearingUnpinned() throws { - let pinned = history.add(historyItem("foo")) - history.togglePin(pinned) - history.add(historyItem("bar")) - let orphan = HistoryItemContent( - type: NSPasteboard.PasteboardType.string.rawValue, - value: "orphan".data(using: .utf8) - ) - Storage.shared.context.insert(orphan) - try Storage.shared.context.save() - - history.clear() - - XCTAssertEqual(history.items.toArray(), [pinned]) - try assertStorageCounts(items: 1, contents: 1) - } + let modifiedItem = historyItem("bar") + modifiedItem.contents.append(HistoryItemContent( + type: NSPasteboard.PasteboardType.modified.rawValue, + value: String(Clipboard.shared.changeCount).data(using: .utf8) + )) + let modifiedItemDecorator = history.add(modifiedItem) - func testClearingAll() throws { - history.add(historyItem("foo")) - let pinned = history.add(historyItem("bar")) - history.togglePin(pinned) - Storage.shared.context.insert(HistoryItemContent( - type: NSPasteboard.PasteboardType.string.rawValue, - value: "orphan".data(using: .utf8) - )) - try Storage.shared.context.save() + #expect(history.items.toArray() == [modifiedItemDecorator]) + #expect(history.items[0].text == "bar") + } - history.clearAll() + @Test("Clearing unpinned") + func clearingUnpinned() throws { + let pinned = history.add(historyItem("foo")) + history.togglePin(pinned) + history.add(historyItem("bar")) + let orphan = HistoryItemContent( + type: NSPasteboard.PasteboardType.string.rawValue, + value: "orphan".data(using: .utf8) + ) + Storage.shared.context.insert(orphan) + try Storage.shared.context.save() - XCTAssertEqual(history.items.toArray(), []) - try assertStorageCounts(items: 0, contents: 0) - } + history.clear() - func testMaxSize() throws { - var items: [HistoryItemDecorator] = [] - for index in 0...10 { - items.append(history.add(historyItem(String(index)))) + #expect(history.items.toArray() == [pinned]) + try assertStorageCounts(items: 1, contents: 1) } - XCTAssertEqual(history.items.count, 10) - XCTAssertTrue(history.items.contains(items[10])) - XCTAssertFalse(history.items.contains(items[0])) - try assertStorageCounts(items: 10, contents: 10) - } - - func testMaxSizeIgnoresPinned() { - var items: [HistoryItemDecorator] = [] + @Test("Clearing all") + func clearingAll() throws { + history.add(historyItem("foo")) + let pinned = history.add(historyItem("bar")) + history.togglePin(pinned) + Storage.shared.context.insert(HistoryItemContent( + type: NSPasteboard.PasteboardType.string.rawValue, + value: "orphan".data(using: .utf8) + )) + try Storage.shared.context.save() - let item = history.add(historyItem("0")) - items.append(item) - history.togglePin(item) + history.clearAll() - for index in 1...11 { - items.append(history.add(historyItem(String(index)))) + #expect(history.items.toArray() == []) + try assertStorageCounts(items: 0, contents: 0) } - XCTAssertEqual(history.items.count, 11) - XCTAssertTrue(history.items.contains(items[10])) - XCTAssertTrue(history.items.contains(items[0])) - XCTAssertFalse(history.items.contains(items[1])) - } - - func testPinningUpdatesPinOrder() { - let item = history.add(historyItem("foo")) - history.togglePin(item) + @Test("Max size") + func maxSize() throws { + var items: [HistoryItemDecorator] = [] + for index in 0...10 { + items.append(history.add(historyItem(String(index)))) + } + + #expect(history.items.count == 10) + #expect(history.items.contains(items[10])) + #expect(!history.items.contains(items[0])) + try assertStorageCounts(items: 10, contents: 10) + } - XCTAssertEqual(history.pinnedItems, [item]) - XCTAssertEqual(Defaults[.pinOrder].pins, [item.item.pin].compactMap { $0 }) - } + @Test("Max size ignores pinned") + func maxSizeIgnoresPinned() { + var items: [HistoryItemDecorator] = [] - func testPinningAfterAllShortcutsAreAssigned() async throws { - let supportedPins = HistoryItem.supportedPins - for index in 0..()), - items, - file: file, - line: line - ) - XCTAssertEqual( - try context.fetchCount(FetchDescriptor()), - contents, - file: file, - line: line - ) - XCTAssertEqual( - try context.fetchCount(FetchDescriptor( - predicate: #Predicate { $0.item == nil } - )), - orphaned, - file: file, - line: line - ) - } + #expect(try Storage.shared.removeUnpersistableContents() == 1) + #expect(try Storage.shared.removeUnpersistableContents() == 0) + #expect(live.item.contents.map(\.type) == [NSPasteboard.PasteboardType.string.rawValue]) + try assertStorageCounts(items: 1, contents: 1) + } - private func historyItem(_ value: String, persisted: Bool = true) -> HistoryItem { - let contents = [ - HistoryItemContent( - type: NSPasteboard.PasteboardType.string.rawValue, - value: value.data(using: .utf8) + private func assertStorageCounts( + items: Int, + contents: Int, + orphaned: Int = 0, + fileID: String = #fileID, + filePath: String = #filePath, + line: Int = #line, + column: Int = #column + ) throws { + let sourceLocation = SourceLocation(fileID: fileID, filePath: filePath, line: line, column: column) + let context = Storage.shared.context + context.processPendingChanges() + try context.save() + #expect( + try context.fetchCount(FetchDescriptor()) == items, + sourceLocation: sourceLocation + ) + #expect( + try context.fetchCount(FetchDescriptor()) == contents, + sourceLocation: sourceLocation + ) + #expect( + try context.fetchCount(FetchDescriptor( + predicate: #Predicate { $0.item == nil } + )) == orphaned, + sourceLocation: sourceLocation ) - ] - let item = HistoryItem() - if persisted { - Storage.shared.context.insert(item) } - item.contents = contents - item.numberOfCopies = 1 - item.title = item.generateTitle() - return item + private func historyItem(_ value: String, persisted: Bool = true) -> HistoryItem { + let contents = [ + HistoryItemContent( + type: NSPasteboard.PasteboardType.string.rawValue, + value: value.data(using: .utf8) + ) + ] + let item = HistoryItem() + if persisted { + Storage.shared.context.insert(item) + } + item.contents = contents + item.numberOfCopies = 1 + item.title = item.generateTitle() + + return item + } } } diff --git a/MaccyTests/SearchTests.swift b/MaccyTests/SearchTests.swift index bdfe56e0..f64828e8 100644 --- a/MaccyTests/SearchTests.swift +++ b/MaccyTests/SearchTests.swift @@ -1,261 +1,256 @@ -import XCTest +import AppKit +import Testing import Defaults @testable import Maccy -@MainActor -class SearchTests: XCTestCase { - let savedSearchMode = Defaults[.searchMode] - var items: [Search.Searchable]! +extension SharedStateTests { + @MainActor + final class SearchTests { + let savedSearchMode = Defaults[.searchMode] + let items: [Search.Searchable] - override func tearDown() { - super.tearDown() - Defaults[.searchMode] = savedSearchMode - } - - func testSimpleSearch() { // swiftlint:disable:this function_body_length - Defaults[.searchMode] = Search.Mode.exact - items = [ - HistoryItemDecorator(historyItemWithTitle("foo bar baz")), - HistoryItemDecorator(historyItemWithTitle("foo bar zaz")), - HistoryItemDecorator(historyItemWithTitle("xxx yyy zzz")) - ] - - XCTAssertEqual(search(""), [ - Search.SearchResult(score: nil, object: items[0], ranges: []), - Search.SearchResult(score: nil, object: items[1], ranges: []), - Search.SearchResult(score: nil, object: items[2], ranges: []) - ]) - XCTAssertEqual(search("z"), [ - Search.SearchResult( - score: nil, - object: items[0], - ranges: [range(from: 10, to: 10, in: items[0])] - ), - Search.SearchResult( - score: nil, - object: items[1], - ranges: [range(from: 8, to: 8, in: items[1])] - ), - Search.SearchResult( - score: nil, - object: items[2], - ranges: [range(from: 8, to: 8, in: items[2])] - ) - ]) - XCTAssertEqual(search("foo"), [ - Search.SearchResult( - score: nil, - object: items[0], - ranges: [range(from: 0, to: 2, in: items[0])] - ), - Search.SearchResult( - score: nil, - object: items[1], - ranges: [range(from: 0, to: 2, in: items[1])] - ) - ]) - XCTAssertEqual(search("za"), [ - Search.SearchResult( - score: nil, - object: items[1], - ranges: [range(from: 8, to: 9, in: items[1])] - ) - ]) - XCTAssertEqual(search("yyy"), [ - Search.SearchResult( - score: nil, - object: items[2], - ranges: [range(from: 4, to: 6, in: items[2])] - ) - ]) - XCTAssertEqual(search("fbb"), []) - XCTAssertEqual(search("m"), []) - } + init() { + items = [ + HistoryItemDecorator(Self.historyItemWithTitle("foo bar baz")), + HistoryItemDecorator(Self.historyItemWithTitle("foo bar zaz")), + HistoryItemDecorator(Self.historyItemWithTitle("xxx yyy zzz")) + ] + } - func testFuzzySearch() { // swiftlint:disable:this function_body_length - Defaults[.searchMode] = Search.Mode.fuzzy - items = [ - HistoryItemDecorator(historyItemWithTitle("foo bar baz")), - HistoryItemDecorator(historyItemWithTitle("foo bar zaz")), - HistoryItemDecorator(historyItemWithTitle("xxx yyy zzz")) - ] + deinit { + Defaults[.searchMode] = savedSearchMode + } - XCTAssertEqual(search(""), [ - Search.SearchResult(score: nil, object: items[0], ranges: []), - Search.SearchResult(score: nil, object: items[1], ranges: []), - Search.SearchResult(score: nil, object: items[2], ranges: []) - ]) - XCTAssertEqual(search("z"), [ - Search.SearchResult( - score: 0.08, - object: items[1], - ranges: [range(from: 8, to: 8, in: items[1]), range(from: 10, to: 10, in: items[1])] - ), - Search.SearchResult( - score: 0.08, - object: items[2], - ranges: [range(from: 8, to: 10, in: items[2])] - ), - Search.SearchResult( - score: 0.1, - object: items[0], - ranges: [range(from: 10, to: 10, in: items[0])] - ) - ]) - XCTAssertEqual(search("foo"), [ - Search.SearchResult( - score: 0.0, - object: items[0], - ranges: [range(from: 0, to: 2, in: items[0])] - ), - Search.SearchResult( - score: 0.0, - object: items[1], - ranges: [range(from: 0, to: 2, in: items[1])] - ) - ]) - XCTAssertEqual(search("za"), [ - Search.SearchResult( - score: 0.08, - object: items[1], - ranges: [range(from: 5, to: 5, in: items[1]), range(from: 8, to: 9, in: items[1])] - ), - Search.SearchResult( - score: 0.54, - object: items[0], - ranges: [range(from: 5, to: 5, in: items[0]), range(from: 9, to: 10, in: items[0])] - ), - Search.SearchResult( - score: 0.58, - object: items[2], - ranges: [range(from: 8, to: 10, in: items[2])] - ) - ]) - XCTAssertEqual(search("yyy"), [ - Search.SearchResult( - score: 0.04, - object: items[2], - ranges: [range(from: 4, to: 6, in: items[2])] - ) - ]) - XCTAssertEqual(search("fbb"), [ - Search.SearchResult( - score: 0.6666666666666666, - object: items[0], - ranges: [ - range(from: 0, to: 0, in: items[0]), - range(from: 4, to: 4, in: items[0]), - range(from: 8, to: 8, in: items[0]) - ] - ), - Search.SearchResult( - score: 0.6666666666666666, - object: items[1], - ranges: [range(from: 0, to: 0, in: items[1]), range(from: 4, to: 4, in: items[1])]) - ]) - XCTAssertEqual(search("m"), []) - } + @Test("Simple search") + func simpleSearch() { // swiftlint:disable:this function_body_length + Defaults[.searchMode] = Search.Mode.exact + #expect(search("") == [ + Search.SearchResult(score: nil, object: items[0], ranges: []), + Search.SearchResult(score: nil, object: items[1], ranges: []), + Search.SearchResult(score: nil, object: items[2], ranges: []) + ]) + #expect(search("z") == [ + Search.SearchResult( + score: nil, + object: items[0], + ranges: [range(from: 10, to: 10, in: items[0])] + ), + Search.SearchResult( + score: nil, + object: items[1], + ranges: [range(from: 8, to: 8, in: items[1])] + ), + Search.SearchResult( + score: nil, + object: items[2], + ranges: [range(from: 8, to: 8, in: items[2])] + ) + ]) + #expect(search("foo") == [ + Search.SearchResult( + score: nil, + object: items[0], + ranges: [range(from: 0, to: 2, in: items[0])] + ), + Search.SearchResult( + score: nil, + object: items[1], + ranges: [range(from: 0, to: 2, in: items[1])] + ) + ]) + #expect(search("za") == [ + Search.SearchResult( + score: nil, + object: items[1], + ranges: [range(from: 8, to: 9, in: items[1])] + ) + ]) + #expect(search("yyy") == [ + Search.SearchResult( + score: nil, + object: items[2], + ranges: [range(from: 4, to: 6, in: items[2])] + ) + ]) + #expect(search("fbb") == []) + #expect(search("m") == []) + } - func testRegexpSearch() { // swiftlint:disable:this function_body_length - Defaults[.searchMode] = Search.Mode.regexp - items = [ - HistoryItemDecorator(historyItemWithTitle("foo bar baz")), - HistoryItemDecorator(historyItemWithTitle("foo bar zaz")), - HistoryItemDecorator(historyItemWithTitle("xxx yyy zzz")) - ] + @Test("Fuzzy search") + func fuzzySearch() { // swiftlint:disable:this function_body_length + Defaults[.searchMode] = Search.Mode.fuzzy + #expect(search("") == [ + Search.SearchResult(score: nil, object: items[0], ranges: []), + Search.SearchResult(score: nil, object: items[1], ranges: []), + Search.SearchResult(score: nil, object: items[2], ranges: []) + ]) + #expect(search("z") == [ + Search.SearchResult( + score: 0.08, + object: items[1], + ranges: [range(from: 8, to: 8, in: items[1]), range(from: 10, to: 10, in: items[1])] + ), + Search.SearchResult( + score: 0.08, + object: items[2], + ranges: [range(from: 8, to: 10, in: items[2])] + ), + Search.SearchResult( + score: 0.1, + object: items[0], + ranges: [range(from: 10, to: 10, in: items[0])] + ) + ]) + #expect(search("foo") == [ + Search.SearchResult( + score: 0.0, + object: items[0], + ranges: [range(from: 0, to: 2, in: items[0])] + ), + Search.SearchResult( + score: 0.0, + object: items[1], + ranges: [range(from: 0, to: 2, in: items[1])] + ) + ]) + #expect(search("za") == [ + Search.SearchResult( + score: 0.08, + object: items[1], + ranges: [range(from: 5, to: 5, in: items[1]), range(from: 8, to: 9, in: items[1])] + ), + Search.SearchResult( + score: 0.54, + object: items[0], + ranges: [range(from: 5, to: 5, in: items[0]), range(from: 9, to: 10, in: items[0])] + ), + Search.SearchResult( + score: 0.58, + object: items[2], + ranges: [range(from: 8, to: 10, in: items[2])] + ) + ]) + #expect(search("yyy") == [ + Search.SearchResult( + score: 0.04, + object: items[2], + ranges: [range(from: 4, to: 6, in: items[2])] + ) + ]) + #expect(search("fbb") == [ + Search.SearchResult( + score: 0.6666666666666666, + object: items[0], + ranges: [ + range(from: 0, to: 0, in: items[0]), + range(from: 4, to: 4, in: items[0]), + range(from: 8, to: 8, in: items[0]) + ] + ), + Search.SearchResult( + score: 0.6666666666666666, + object: items[1], + ranges: [range(from: 0, to: 0, in: items[1]), range(from: 4, to: 4, in: items[1])]) + ]) + #expect(search("m") == []) + } - XCTAssertEqual(search(""), [ - Search.SearchResult(score: nil, object: items[0], ranges: []), - Search.SearchResult(score: nil, object: items[1], ranges: []), - Search.SearchResult(score: nil, object: items[2], ranges: []) - ]) - XCTAssertEqual(search("z+"), [ - Search.SearchResult( - score: nil, - object: items[0], - ranges: [range(from: 10, to: 10, in: items[0])] - ), - Search.SearchResult( - score: nil, - object: items[1], - ranges: [range(from: 8, to: 8, in: items[1])] - ), - Search.SearchResult( - score: nil, - object: items[2], - ranges: [range(from: 8, to: 10, in: items[2])] - ) - ]) - XCTAssertEqual(search("z*"), [ - Search.SearchResult( - score: nil, - object: items[0], - ranges: [range(from: 0, to: -1, in: items[0])] - ), - Search.SearchResult( - score: nil, - object: items[1], - ranges: [range(from: 0, to: -1, in: items[1])] - ), - Search.SearchResult( - score: nil, - object: items[2], - ranges: [range(from: 0, to: -1, in: items[2])] - ) - ]) - XCTAssertEqual(search("^foo"), [ - Search.SearchResult( - score: nil, - object: items[0], ranges: [range(from: 0, to: 2, in: items[0])] - ), - Search.SearchResult( - score: nil, - object: items[1], ranges: [range(from: 0, to: 2, in: items[1])] - ) - ]) - XCTAssertEqual(search(" za"), [ - Search.SearchResult( - score: nil, - object: items[1], - ranges: [range(from: 7, to: 9, in: items[1])] - ) - ]) - XCTAssertEqual(search("[y]+"), [ - Search.SearchResult( - score: nil, - object: items[2], - ranges: [range(from: 4, to: 6, in: items[2])] - ) - ]) - XCTAssertEqual(search("fbb"), []) - XCTAssertEqual(search("m"), []) - } + @Test("Regexp search") + func regexpSearch() { // swiftlint:disable:this function_body_length + Defaults[.searchMode] = Search.Mode.regexp + #expect(search("") == [ + Search.SearchResult(score: nil, object: items[0], ranges: []), + Search.SearchResult(score: nil, object: items[1], ranges: []), + Search.SearchResult(score: nil, object: items[2], ranges: []) + ]) + #expect(search("z+") == [ + Search.SearchResult( + score: nil, + object: items[0], + ranges: [range(from: 10, to: 10, in: items[0])] + ), + Search.SearchResult( + score: nil, + object: items[1], + ranges: [range(from: 8, to: 8, in: items[1])] + ), + Search.SearchResult( + score: nil, + object: items[2], + ranges: [range(from: 8, to: 10, in: items[2])] + ) + ]) + #expect(search("z*") == [ + Search.SearchResult( + score: nil, + object: items[0], + ranges: [range(from: 0, to: -1, in: items[0])] + ), + Search.SearchResult( + score: nil, + object: items[1], + ranges: [range(from: 0, to: -1, in: items[1])] + ), + Search.SearchResult( + score: nil, + object: items[2], + ranges: [range(from: 0, to: -1, in: items[2])] + ) + ]) + #expect(search("^foo") == [ + Search.SearchResult( + score: nil, + object: items[0], ranges: [range(from: 0, to: 2, in: items[0])] + ), + Search.SearchResult( + score: nil, + object: items[1], ranges: [range(from: 0, to: 2, in: items[1])] + ) + ]) + #expect(search(" za") == [ + Search.SearchResult( + score: nil, + object: items[1], + ranges: [range(from: 7, to: 9, in: items[1])] + ) + ]) + #expect(search("[y]+") == [ + Search.SearchResult( + score: nil, + object: items[2], + ranges: [range(from: 4, to: 6, in: items[2])] + ) + ]) + #expect(search("fbb") == []) + #expect(search("m") == []) + } - private func search(_ string: String) -> [Search.SearchResult] { - return Search().search(string: string, within: items) - } + private func search(_ string: String) -> [Search.SearchResult] { + return Search().search(string: string, within: items) + } - // swiftlint:disable:next identifier_name - private func range(from: Int, to: Int, in item: HistoryItemDecorator) -> Range { - let startIndex = item.title.startIndex - let lowerBound = item.title.index(startIndex, offsetBy: from) - let upperBound = item.title.index(startIndex, offsetBy: to + 1) + // swiftlint:disable:next identifier_name + private func range(from: Int, to: Int, in item: HistoryItemDecorator) -> Range { + let startIndex = item.title.startIndex + let lowerBound = item.title.index(startIndex, offsetBy: from) + let upperBound = item.title.index(startIndex, offsetBy: to + 1) - return lowerBound.. HistoryItem { - let contents = [ - HistoryItemContent( - type: NSPasteboard.PasteboardType.string.rawValue, - value: value?.data(using: .utf8) - ) - ] - let item = HistoryItem() - Storage.shared.context.insert(item) - item.contents = contents - item.title = item.generateTitle() + private static func historyItemWithTitle(_ value: String?) -> HistoryItem { + let contents = [ + HistoryItemContent( + type: NSPasteboard.PasteboardType.string.rawValue, + value: value?.data(using: .utf8) + ) + ] + let item = HistoryItem() + Storage.shared.context.insert(item) + item.contents = contents + item.title = item.generateTitle() - return item + return item + } } } diff --git a/MaccyTests/SharedStateTests.swift b/MaccyTests/SharedStateTests.swift new file mode 100644 index 00000000..c0974313 --- /dev/null +++ b/MaccyTests/SharedStateTests.swift @@ -0,0 +1,4 @@ +import Testing + +@Suite(.serialized) +enum SharedStateTests {} diff --git a/MaccyTests/ShortenedTests.swift b/MaccyTests/ShortenedTests.swift index 12778109..cf072c1b 100644 --- a/MaccyTests/ShortenedTests.swift +++ b/MaccyTests/ShortenedTests.swift @@ -1,27 +1,28 @@ -import XCTest +import Testing @testable import Maccy @MainActor -final class ShortenedTests: XCTestCase { - func testShortenedReturnsAtMostMaxLength() { - XCTAssertEqual("abcdef".shortened(to: 3), "abc") +struct ShortenedTests { + @Test("Shortened returns at most max length") + func shortenedReturnsAtMostMaxLength() { + #expect("abcdef".shortened(to: 3) == "abc") } - func testShortenedNoOpWhenWithinLimit() { - XCTAssertEqual("abc".shortened(to: 3), "abc") - XCTAssertEqual("".shortened(to: 3), "") + @Test("Shortened is a no-op within the limit", arguments: ["abc", ""]) + func shortenedNoOpWhenWithinLimit(input: String) { + #expect(input.shortened(to: 3) == input) } - func testShortenedLargeCapIsNoOp() { - XCTAssertEqual("abcdefghij".shortened(to: 1_000), "abcdefghij") + @Test("Shortened with a large cap is a no-op") + func shortenedLargeCapIsNoOp() { + #expect("abcdefghij".shortened(to: 1_000) == "abcdefghij") } - func testShortenedResultNeverExceedsMaxLength() { + @Test("Shortened result never exceeds max length", arguments: 0...8) + func shortenedResultNeverExceedsMaxLength(length: Int) { let input = "abcdef" - for length in 0...8 { - let result = input.shortened(to: length) - XCTAssertLessThanOrEqual(result.count, length, "shortened(to: \(length)) must be <= \(length) chars") - XCTAssertEqual(result.count, min(length, input.count)) - } + let result = input.shortened(to: length) + #expect(result.count <= length) + #expect(result.count == min(length, input.count)) } } diff --git a/MaccyTests/SorterTests.swift b/MaccyTests/SorterTests.swift index 59b23b34..c40b674f 100644 --- a/MaccyTests/SorterTests.swift +++ b/MaccyTests/SorterTests.swift @@ -1,72 +1,78 @@ -import XCTest +import Foundation +import Testing import Defaults @testable import Maccy -@MainActor -class SorterTests: XCTestCase { - let savedPinOrder = Defaults[.pinOrder] - let savedPinTo = Defaults[.pinTo] - let sorter = Sorter() +extension SharedStateTests { + @MainActor + final class SorterTests { + let savedPinOrder = Defaults[.pinOrder] + let savedPinTo = Defaults[.pinTo] + let sorter = Sorter() - var item1: HistoryItem! - var item2: HistoryItem! - var item3: HistoryItem! + let item1: HistoryItem + let item2: HistoryItem + let item3: HistoryItem - override func setUp() async throws { - try await super.setUp() - item1 = historyItem(value: "foo", firstCopiedAt: -300, lastCopiedAt: -100, numberOfCopies: 3) - item2 = historyItem(value: "bar", firstCopiedAt: -400, lastCopiedAt: -300, numberOfCopies: 2) - item3 = historyItem(value: "bar", firstCopiedAt: -200, lastCopiedAt: -200, numberOfCopies: 1) - Defaults[.pinOrder] = PinOrder() - } + init() { + item1 = Self.historyItem(value: "foo", firstCopiedAt: -300, lastCopiedAt: -100, numberOfCopies: 3) + item2 = Self.historyItem(value: "bar", firstCopiedAt: -400, lastCopiedAt: -300, numberOfCopies: 2) + item3 = Self.historyItem(value: "bar", firstCopiedAt: -200, lastCopiedAt: -200, numberOfCopies: 1) + Defaults[.pinOrder] = PinOrder() + } - override func tearDown() { - super.tearDown() - Defaults[.pinOrder] = savedPinOrder - Defaults[.pinTo] = savedPinTo - } + deinit { + Defaults[.pinOrder] = savedPinOrder + Defaults[.pinTo] = savedPinTo + } - func testSortByLastCopiedAt() { - XCTAssertEqual(sorter.sort([item1, item2, item3], by: .lastCopiedAt), [item1, item3, item2]) - } + @Test("Sort by last copied at") + func sortByLastCopiedAt() { + #expect(sorter.sort([item1, item2, item3], by: .lastCopiedAt) == [item1, item3, item2]) + } - func testSortByFirstCopiedAt() { - XCTAssertEqual(sorter.sort([item1, item2, item3], by: .firstCopiedAt), [item3, item1, item2]) - } + @Test("Sort by first copied at") + func sortByFirstCopiedAt() { + #expect(sorter.sort([item1, item2, item3], by: .firstCopiedAt) == [item3, item1, item2]) + } - func testSortByNumberOfCopies() { - XCTAssertEqual(sorter.sort([item1, item2, item3], by: .numberOfCopies), [item1, item2, item3]) - } + @Test("Sort by number of copies") + func sortByNumberOfCopies() { + #expect(sorter.sort([item1, item2, item3], by: .numberOfCopies) == [item1, item2, item3]) + } - func testSortByPinToTop() { - Defaults[.pinTo] = .top + @Test("Pinned items go to the top") + func sortByPinToTop() { + Defaults[.pinTo] = .top - item1.pin = "a" - item3.pin = "b" - XCTAssertEqual(sorter.sort([item1, item2, item3], by: .lastCopiedAt), [item1, item3, item2]) - } + item1.pin = "a" + item3.pin = "b" + #expect(sorter.sort([item1, item2, item3], by: .lastCopiedAt) == [item1, item3, item2]) + } - func testSortByPinToBottom() { - Defaults[.pinTo] = .bottom + @Test("Pinned items go to the bottom") + func sortByPinToBottom() { + Defaults[.pinTo] = .bottom - item1.pin = "a" - item3.pin = "b" - XCTAssertEqual(sorter.sort([item1, item2, item3], by: .lastCopiedAt), [item2, item1, item3]) - } + item1.pin = "a" + item3.pin = "b" + #expect(sorter.sort([item1, item2, item3], by: .lastCopiedAt) == [item2, item1, item3]) + } - private func historyItem( - value: String, - firstCopiedAt: Int, - lastCopiedAt: Int, - numberOfCopies: Int - ) -> HistoryItem { - let contents = [HistoryItemContent(type: "", value: value.data(using: .utf8)!)] - let item = HistoryItem() - Storage.shared.context.insert(item) - item.contents = contents - item.firstCopiedAt = Date(timeIntervalSinceNow: TimeInterval(firstCopiedAt)) - item.lastCopiedAt = Date(timeIntervalSinceNow: TimeInterval(lastCopiedAt)) - item.numberOfCopies = numberOfCopies - return item + private static func historyItem( + value: String, + firstCopiedAt: Int, + lastCopiedAt: Int, + numberOfCopies: Int + ) -> HistoryItem { + let contents = [HistoryItemContent(type: "", value: value.data(using: .utf8)!)] + let item = HistoryItem() + Storage.shared.context.insert(item) + item.contents = contents + item.firstCopiedAt = Date(timeIntervalSinceNow: TimeInterval(firstCopiedAt)) + item.lastCopiedAt = Date(timeIntervalSinceNow: TimeInterval(lastCopiedAt)) + item.numberOfCopies = numberOfCopies + return item + } } } diff --git a/MaccyTests/UnsafeForTitleLayoutTests.swift b/MaccyTests/UnsafeForTitleLayoutTests.swift index ae94e689..e27b42a5 100644 --- a/MaccyTests/UnsafeForTitleLayoutTests.swift +++ b/MaccyTests/UnsafeForTitleLayoutTests.swift @@ -1,69 +1,61 @@ -import XCTest +import Testing @testable import Maccy -@MainActor -final class UnsafeForTitleLayoutTests: XCTestCase { - private let objectReplacement = "\u{FFFC}" - - func testRemovesLeadingOccurrence() { - XCTAssertEqual((objectReplacement + "foo").removingScalarsUnsafeForTitleLayout(), "foo") - } +private let objectReplacement = "\u{FFFC}" - func testRemovesTrailingOccurrence() { - XCTAssertEqual(("foo" + objectReplacement).removingScalarsUnsafeForTitleLayout(), "foo") - } - - func testRemovesInteriorOccurrence() { - XCTAssertEqual(("a" + objectReplacement + "b").removingScalarsUnsafeForTitleLayout(), "ab") - } +private let removalCases: [(input: String, expected: String)] = [ + (objectReplacement + "foo", "foo"), + ("foo" + objectReplacement, "foo"), + ("a" + objectReplacement + "b", "ab"), + (objectReplacement + "复制" + objectReplacement + "粘贴" + objectReplacement, "复制粘贴"), + (String(repeating: objectReplacement, count: 3), "") +] - func testRemovesEveryOccurrence() { - let input = objectReplacement + "复制" + objectReplacement + "粘贴" + objectReplacement - XCTAssertEqual(input.removingScalarsUnsafeForTitleLayout(), "复制粘贴") - } - - func testAllUnsafeBecomesEmpty() { - let input = String(repeating: objectReplacement, count: 3) - XCTAssertEqual(input.removingScalarsUnsafeForTitleLayout(), "") +@MainActor +struct UnsafeForTitleLayoutTests { + @Test("Removes U+FFFC", arguments: removalCases) + func removesObjectReplacement(input: String, expected: String) { + #expect(input.removingScalarsUnsafeForTitleLayout() == expected) } // U+FFFC followed by a combining mark is a single Character, so filtering by // Character would leave the unsafe scalar in place and the hang would survive. - func testRemovesOccurrenceInsideGraphemeCluster() { + @Test("Removes U+FFFC inside a grapheme cluster") + func removesOccurrenceInsideGraphemeCluster() { let input = objectReplacement + "\u{0301}" + "复制" let result = input.removingScalarsUnsafeForTitleLayout() - XCTAssertFalse(result.unicodeScalars.contains { $0.value == 0xFFFC }) - XCTAssertEqual(result, "\u{0301}复制") + #expect(!result.unicodeScalars.contains { $0.value == 0xFFFC }) + #expect(result == "\u{0301}复制") } - func testKeepsSafeStringsUntouched() { - XCTAssertEqual("foo bar".removingScalarsUnsafeForTitleLayout(), "foo bar") - XCTAssertEqual("复制粘贴测试".removingScalarsUnsafeForTitleLayout(), "复制粘贴测试") - XCTAssertEqual("".removingScalarsUnsafeForTitleLayout(), "") + @Test("Keeps safe strings untouched", arguments: ["foo bar", "复制粘贴测试", ""]) + func keepsSafeStringsUntouched(input: String) { + #expect(input.removingScalarsUnsafeForTitleLayout() == input) } // Only U+FFFC is known to trigger the hang, so nothing else may be dropped. - func testKeepsOtherInvisibleAndReplacementScalars() { - XCTAssertEqual("\u{FFFD}foo".removingScalarsUnsafeForTitleLayout(), "\u{FFFD}foo") - XCTAssertEqual("\u{200B}foo".removingScalarsUnsafeForTitleLayout(), "\u{200B}foo") - XCTAssertEqual("\u{FEFF}foo".removingScalarsUnsafeForTitleLayout(), "\u{FEFF}foo") + @Test("Keeps other invisible and replacement scalars", arguments: ["\u{FFFD}foo", "\u{200B}foo", "\u{FEFF}foo"]) + func keepsOtherInvisibleAndReplacementScalars(input: String) { + #expect(input.removingScalarsUnsafeForTitleLayout() == input) + } + + @Test("Keeps special symbols used by titles") + func keepsSpecialSymbolsUsedByTitles() { + #expect("⏎foo⇥bar·".removingScalarsUnsafeForTitleLayout() == "⏎foo⇥bar·") } - func testKeepsSpecialSymbolsUsedByTitles() { - XCTAssertEqual("⏎foo⇥bar·".removingScalarsUnsafeForTitleLayout(), "⏎foo⇥bar·") + @Test("Keeps multi-scalar grapheme clusters", arguments: ["🇨🇳", "🚀foo", "e\u{0301}"]) + func keepsMultiScalarGraphemeClusters(input: String) { + #expect(input.removingScalarsUnsafeForTitleLayout() == input) } - func testKeepsMultiScalarGraphemeClusters() { - XCTAssertEqual("🇨🇳".removingScalarsUnsafeForTitleLayout(), "🇨🇳") - XCTAssertEqual("🚀foo".removingScalarsUnsafeForTitleLayout(), "🚀foo") - XCTAssertEqual("e\u{0301}".removingScalarsUnsafeForTitleLayout(), "e\u{0301}") + @Test("Detects unsafe scalars", arguments: [objectReplacement + "foo", objectReplacement + "\u{0301}foo"]) + func detectsUnsafeScalars(input: String) { + #expect(input.containsScalarsUnsafeForTitleLayout) } - func testDetectsUnsafeScalars() { - XCTAssertTrue((objectReplacement + "foo").containsScalarsUnsafeForTitleLayout) - XCTAssertTrue((objectReplacement + "\u{0301}foo").containsScalarsUnsafeForTitleLayout) - XCTAssertFalse("foo".containsScalarsUnsafeForTitleLayout) - XCTAssertFalse("".containsScalarsUnsafeForTitleLayout) - XCTAssertFalse("\u{FFFD}foo".containsScalarsUnsafeForTitleLayout) + @Test("Does not flag safe strings", arguments: ["foo", "", "\u{FFFD}foo"]) + func doesNotFlagSafeStrings(input: String) { + #expect(!input.containsScalarsUnsafeForTitleLayout) } } diff --git a/MaccyUITests/MaccyUITests.swift b/MaccyUITests/MaccyUITests.swift index 6a99a833..1c33fd7f 100644 --- a/MaccyUITests/MaccyUITests.swift +++ b/MaccyUITests/MaccyUITests.swift @@ -170,6 +170,31 @@ class MaccyUITests: XCTestCase { assertPasteboardDataCountEquals(image2.tiffRepresentation!.count, forType: .tiff) } + func testOCRRecognizesTextInImage() { + copyToClipboard(textImage("Lighthouse")) + popUpWithMouse() + assertImageFound(bySearching: "lighthouse") + } + + func testOCRCopiesRecognizedTextFromSidePanel() { + copyToClipboard(textImage("Lighthouse")) + popUpWithMouse() + + let imageItem = items.query.matching(NSPredicate(format: "label BEGINSWITH %@", "Image, ")).firstMatch + assertExists(imageItem) + hover(imageItem) + + let copyTextButton = app.buttons["Copy extracted text."].firstMatch + XCTAssertTrue(copyTextButton.waitForExistence(timeout: 3), "Side panel has no copy extracted text button") + expectation(for: NSPredicate(format: "isEnabled == true"), evaluatedWith: copyTextButton) + waitForExpectations(timeout: 15) + copyTextButton.click() + + assertExists(items["Lighthouse"]) + XCTAssertEqual(itemTitles.first, "Lighthouse") + assertPasteboardStringEquals("Lighthouse") + } + func testCopyFile() { copyToClipboard(file2) copyToClipboard(file1) @@ -664,6 +689,34 @@ class MaccyUITests: XCTestCase { waitTillClipboardCheck() } + private func textImage(_ text: String) -> NSImage { + let string = NSAttributedString(string: text, attributes: [ + .font: NSFont.boldSystemFont(ofSize: 48), + .foregroundColor: NSColor.black + ]) + let size = NSSize(width: string.size().width + 40, height: string.size().height + 40) + return NSImage(size: size, flipped: false) { rect in + NSColor.white.setFill() + rect.fill() + string.draw(at: NSPoint(x: 20, y: 20)) + return true + } + } + + private func assertImageFound(bySearching text: String) { + let imageItem = items.query.matching(NSPredicate(format: "label BEGINSWITH %@", "Image, ")).firstMatch + for _ in 0..<5 { + search(text) + if imageItem.waitForExistence(timeout: 2) { + break + } + app.typeKey("w", modifierFlags: [.control]) + sleep(2) + } + XCTAssertTrue(imageItem.exists, "No image found by searching \"\(text)\"") + XCTAssertEqual(items.allElementsBoundByIndex.count, 1) + } + private func copyToClipboard(_ content: URL) { pasteboard.clearContents() pasteboard.setData(content.dataRepresentation, forType: .fileURL) From d7a017b794985c9e1fdd98776488c2fecfe8d0f2 Mon Sep 17 00:00:00 2001 From: Roy Padina <4943097+roypadina@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:14:54 +0300 Subject: [PATCH 59/59] fix: Release build crash on Swift 6.3 (Xcode 26.x); skip renamed ClipboardTests suite on CI Swift 6.3 crashes in EarlyPerfInliner on FloatingPanel's implicit deinit when optimizing (upstream code, exposed by the Swift 6 migration). An explicit @_optimize(none) deinit avoids the pass. Xcode 27 is unaffected. Upstream moved ClipboardTests under SharedStateTests (Swift Testing). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XTuA3qbV1J8eNeXceEW8oN --- .github/workflows/ci.yml | 1 + Maccy/FloatingPanel.swift | 3 +++ 2 files changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bc1f077a..3661cbe5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,4 +23,5 @@ jobs: run: | xcodebuild test -project Maccy.xcodeproj -scheme Maccy -destination 'platform=macOS' \ -only-testing:MaccyTests -skip-testing:MaccyTests/ClipboardTests \ + -skip-testing:MaccyTests/SharedStateTests/ClipboardTests \ CODE_SIGN_IDENTITY=- CODE_SIGNING_ALLOWED=NO diff --git a/Maccy/FloatingPanel.swift b/Maccy/FloatingPanel.swift index 7403b9c7..6a54777b 100644 --- a/Maccy/FloatingPanel.swift +++ b/Maccy/FloatingPanel.swift @@ -8,6 +8,9 @@ class FloatingPanel: NSPanel, NSWindowDelegate { var statusBarButton: NSStatusBarButton? let onClose: () -> Void + // Swift 6.3 (Xcode 26.x) crashes in EarlyPerfInliner on the implicit deinit in Release builds. + @_optimize(none) deinit {} + override var isMovable: Bool { get { Defaults[.popupPosition] != .statusItem } set {}