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.xcodeproj/project.pbxproj b/Maccy.xcodeproj/project.pbxproj index 85681f31..fc2241f1 100644 --- a/Maccy.xcodeproj/project.pbxproj +++ b/Maccy.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 0ABDD5122BB47F1E0054963B /* NSWorkspace+ApplicationName.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0ABDD5112BB47F1E0054963B /* NSWorkspace+ApplicationName.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 */; }; @@ -21,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 */; }; @@ -28,19 +30,24 @@ 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 */; }; + 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 */; }; 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 */; }; 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 */; }; - 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 */; }; @@ -50,6 +57,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 */; }; 3D631C6E751644348016503A /* JSPluginRuntimeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16F1601C15344D979FAB63B1 /* JSPluginRuntimeTests.swift */; }; DA0EE7B9204657830025FC60 /* MaccyUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA0EE7B8204657830025FC60 /* MaccyUITests.swift */; }; @@ -69,6 +77,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 */; }; @@ -157,7 +166,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 */; }; @@ -195,6 +203,8 @@ 9A24A8FBAE374CE7A26FBE80 /* CapabilityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C62F70F50DE34AC39AE0A4F0 /* CapabilityManager.swift */; }; 2BAFEB039C2D4D6F906C5EA2 /* CapabilityManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4CF289D88564240B630E4FC /* CapabilityManagerTests.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 */ @@ -235,6 +245,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 = ""; }; @@ -246,16 +257,23 @@ 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 = ""; }; 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 = ""; }; 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 = ""; }; @@ -265,6 +283,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 = ""; }; @@ -277,25 +296,24 @@ 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 = ""; }; 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 = ""; }; @@ -331,6 +349,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 = ""; }; @@ -577,7 +596,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 = ""; }; @@ -667,9 +685,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 */ @@ -710,6 +730,7 @@ 2FBB7A252B5FECBB00C65BBE /* Extensions */ = { isa = PBXGroup; children = ( + 2F59CC4B302B57FC00136053 /* Array+MoveElements.swift */, 2FA81FDF2E43D9A600C12F92 /* Dictionary+RemoveItem.swift */, DA19691B2C3F3EAC00258481 /* Collection+Surrounding.swift */, DAA072D22C40A961006DDFD2 /* Color+Random.swift */, @@ -753,6 +774,7 @@ children = ( DA05B5132C234DCF006980FE /* HistoryItem.swift */, DA05B5122C234DCF006980FE /* HistoryItemContent.swift */, + DFC13FFCB59043F2883A4437 /* SizedString.swift */, ); path = Models; sourceTree = ""; @@ -779,7 +801,6 @@ DA9C3C482C20D4B40056795D /* IgnoreSettings.strings */, DA44C5E32C1C858400819834 /* StorageSettingsPane.swift */, DA689FAE2C1CD3B00009B887 /* StorageSettings.strings */, - DAA365CD2C4BF9DD00A394F8 /* PinsSettingsPane.swift */, DAA365CF2C4C147400A394F8 /* PinsSettings.strings */, ); path = Settings; @@ -788,6 +809,10 @@ DA19690C2C3EEF9300258481 /* Views */ = { isa = PBXGroup; children = ( + 2FD5DE502FFE6DCE0023FF60 /* LegacyReorderableContainer.swift */, + 2F0E055E2FFD2EAF00CCD1DE /* ReorderableContainer.swift */, + 2FD499442FFC487F00149F02 /* MovableByWindowBackgroundModifier.swift */, + 2F1B61CE301BE559000905F9 /* EnumeratedForEach.swift */, 2F1091D62F2B6E8100F5C454 /* AsyncView.swift */, 2FF2E94F2E7808420093D72C /* AppImageView.swift */, DAA072DE2C41C63C006DDFD2 /* ConfirmationView.swift */, @@ -809,9 +834,11 @@ 2F0EC7862E774093003E2EA9 /* PasteStackItemView.swift */, 2F162E7F2F1E7B28001FBAFF /* PasteStackPreviewView.swift */, 2F0EC7842E77407C003E2EA9 /* PasteStackView.swift */, + 2FD499462FFC4AEF00149F02 /* ItemEditorView.swift */, 2F0EC77A2E772899003E2EA9 /* PinsView.swift */, DA5E627E2C39E53F00F4C710 /* PreviewItemView.strings */, DA1969132C3F11D600258481 /* PreviewItemView.swift */, + BBCCA6C3F9374C9C91414E3A /* LargeTextView.swift */, DA1969172C3F327500258481 /* SearchFieldView.swift */, 2F578C402EFFE8920088B759 /* SlideoutContentView.swift */, 2F578C412EFFE8920088B759 /* SlideoutView.swift */, @@ -862,6 +889,7 @@ BF3278099ACA4CC884BEA407 /* PluginsSettingsLogicTests.swift */, 7D0D57B2D755174C9873B839 /* ScreenshotRenderTests.swift */, DA360DB41E3DF137005C6F6B /* Info.plist */, + 4CB7148030897A9E00A357EC /* SharedStateTests.swift */, ); path = MaccyTests; sourceTree = ""; @@ -876,6 +904,7 @@ DA3BCB912C3EEC3C00B01BC1 /* History.swift */, DA3BCB932C3EECBB00B01BC1 /* HistoryItemDecorator.swift */, DA3BCB8D2C3E015000B01BC1 /* ModifierFlags.swift */, + 2FD4993D2FFBA57400149F02 /* PinManager.swift */, DAE8F5D32C43262B00851CA9 /* Popup.swift */, 2F578C3E2EFFE8720088B759 /* SlideoutController.swift */, ); @@ -957,6 +986,7 @@ DA555F072CF0F98F009608BD /* ApplicationImageCache.swift */, DA0EF1871E444B2A00E58577 /* Clipboard.swift */, DAE284FF232257D20080E394 /* ColorImage.swift */, + 2F8A0DBD303A1F9D004BA51F /* ConcatenatedCollection.swift */, DAA54BF92C3C951900B7FDD8 /* FloatingPanel.swift */, DA689FC92C1D18890009B887 /* HighlightMatch.swift */, DA1969202C3F6C6800258481 /* HistoryItemAction.swift */, @@ -1229,6 +1259,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 */, @@ -1262,6 +1293,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 */, @@ -1289,6 +1321,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 */, @@ -1296,7 +1329,9 @@ 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 */, DA13D7D92C1A223E00FA9E23 /* Get.swift in Sources */, 2FF2E94E2E774B5B0093D72C /* NavigationManager.swift in Sources */, @@ -1321,6 +1356,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 */, @@ -1328,12 +1364,14 @@ 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 */, 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 */, @@ -1356,7 +1394,9 @@ 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 */, + 2F1B61CF301BE55F000905F9 /* EnumeratedForEach.swift in Sources */, DAA072DB2C41C5DD006DDFD2 /* FooterItem.swift in Sources */, DA689FC62C1D14F10009B887 /* PopupPosition.swift in Sources */, DA49EE7528B59468002752E0 /* NSRect+Centered.swift in Sources */, @@ -1375,10 +1415,10 @@ 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 */, + 2F59CC4C302B580300136053 /* Array+MoveElements.swift in Sources */, DAE28500232257D20080E394 /* ColorImage.swift in Sources */, DA7A753E26A52F0F00DC16EF /* NSImage+Names.swift in Sources */, 2F0EC7852E774083003E2EA9 /* PasteStackView.swift in Sources */, @@ -1757,7 +1797,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; @@ -1784,7 +1825,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; @@ -1808,7 +1850,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)/MaccyPlus.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MaccyPlus"; }; name = Debug; @@ -1832,7 +1875,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)/MaccyPlus.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/MaccyPlus"; }; name = Release; @@ -1895,8 +1939,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; }; @@ -1950,9 +1997,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; }; @@ -1990,7 +2040,7 @@ PRODUCT_MODULE_NAME = Maccy; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Debug; }; @@ -2026,7 +2076,7 @@ PRODUCT_NAME = "MaccyPlus"; PRODUCT_MODULE_NAME = Maccy; PROVISIONING_PROFILE_SPECIFIER = ""; - SWIFT_VERSION = 5.0; + SWIFT_VERSION = 6.0; }; name = Release; }; @@ -2077,7 +2127,7 @@ repositoryURL = "https://github.com/apple/swift-log"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 1.6.4; + minimumVersion = 1.15.1; }; }; 2F39CB022AD9A93C00B749FD /* XCRemoteSwiftPackageReference "Sparkle" */ = { @@ -2085,7 +2135,7 @@ repositoryURL = "https://github.com/sparkle-project/Sparkle"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 2.6.3; + minimumVersion = 2.10.0; }; }; 2F39CB082AD9AE1F00B749FD /* XCRemoteSwiftPackageReference "Settings" */ = { @@ -2093,7 +2143,7 @@ repositoryURL = "https://github.com/sindresorhus/Settings.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 3.1.1; + minimumVersion = 3.1.2; }; }; DA009945256414800030E697 /* XCRemoteSwiftPackageReference "SwiftHEXColors" */ = { @@ -2109,7 +2159,7 @@ repositoryURL = "https://github.com/sindresorhus/KeyboardShortcuts"; requirement = { kind = upToNextMinorVersion; - minimumVersion = 2.0.2; + minimumVersion = 3.1.0; }; }; DA13D7C82C19F43E00FA9E23 /* XCRemoteSwiftPackageReference "LaunchAtLogin-Modern" */ = { @@ -2125,7 +2175,7 @@ repositoryURL = "https://github.com/sindresorhus/Defaults"; requirement = { kind = upToNextMinorVersion; - minimumVersion = 8.2.0; + minimumVersion = 9.0.9; }; }; DA5154D62564102900C01004 /* XCRemoteSwiftPackageReference "Sauce" */ = { @@ -2133,7 +2183,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.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/Accessibility.swift b/Maccy/Accessibility.swift index ac8818b3..f3674dae 100644 --- a/Maccy/Accessibility.swift +++ b/Maccy/Accessibility.swift @@ -10,7 +10,8 @@ struct Accessibility { // Show the system "grant Accessibility" prompt. This lets macOS register the // correct (current) binary itself, instead of relying on a manual drag-in // that TCC churn keeps invalidating. - let options = [kAXTrustedCheckOptionPrompt.takeUnretainedValue() as String: true] as CFDictionary + // Literal value of kAXTrustedCheckOptionPrompt; Swift 6 rejects reading that global var. + let options = ["AXTrustedCheckOptionPrompt": true] as CFDictionary _ = AXIsProcessTrustedWithOptions(options) } } diff --git a/Maccy/Actions/ActionRule.swift b/Maccy/Actions/ActionRule.swift index 3c1b4d5b..e49f21b3 100644 --- a/Maccy/Actions/ActionRule.swift +++ b/Maccy/Actions/ActionRule.swift @@ -3,7 +3,7 @@ import Defaults import Foundation // MatchMode: unchanged contract. -enum MatchMode: String, Codable, CaseIterable, Identifiable { +nonisolated enum MatchMode: String, Codable, CaseIterable, Identifiable { case all // AND case any // OR @@ -12,14 +12,14 @@ enum MatchMode: String, Codable, CaseIterable, Identifiable { } // A single condition referencing a provider by id. -struct RuleCondition: Codable, Identifiable, Hashable { +nonisolated struct RuleCondition: Codable, Identifiable, Hashable { var id: UUID = UUID() var provider: String // e.g. "builtin.kind", "com.maccyplus.soft-wrap" var params: JSONValue = .object([:]) } // Persisted configuration for one action within a rule. -struct ActionConfig: Codable, Identifiable, Hashable { +nonisolated struct ActionConfig: Codable, Identifiable, Hashable { var id: UUID = UUID() var provider: String // e.g. "builtin.openURL", "com.maccyplus.unwrap" var params: JSONValue = .object([:]) @@ -37,7 +37,7 @@ struct ActionConfig: Codable, Identifiable, Hashable { // A user-defined rule: when its conditions match, its (ordered) actions become // available. The first action is the default. -struct ActionRule: Codable, Identifiable, Hashable, Defaults.Serializable { +nonisolated struct ActionRule: Codable, Identifiable, Hashable, Defaults.Serializable { var id: UUID = UUID() var schemaVersion: Int = 3 var name: String = "New rule" @@ -47,7 +47,7 @@ struct ActionRule: Codable, Identifiable, Hashable, Defaults.Serializable { var actions: [ActionConfig] = [] var autoRunDefault: Bool = false - static let presets: [ActionRule] = [ + @MainActor static let presets: [ActionRule] = [ // Open links — kind == url → openURL or webSearch ActionRule( name: "Open links", diff --git a/Maccy/Actions/ActionsCLI.swift b/Maccy/Actions/ActionsCLI.swift index 5e793e72..b7d3365c 100644 --- a/Maccy/Actions/ActionsCLI.swift +++ b/Maccy/Actions/ActionsCLI.swift @@ -187,8 +187,8 @@ enum ActionsCLI { // Built from the LIVE provider registry so the catalog can't drift from the // installed providers. `ProviderRegistry` is @MainActor; enter synchronously // (the CLI has no run loop). `registerProviders()` ran in `run(_:)` first. - let catalog: [String: Any] = MainActor.assumeIsolated { - func encode(_ descriptors: [ProviderDescriptor]) -> [[String: Any]] { + let catalog: [String: Any] = { + @MainActor func encode(_ descriptors: [ProviderDescriptor]) -> [[String: Any]] { descriptors.map { descriptor -> [String: Any] in [ "id": descriptor.id, @@ -235,7 +235,7 @@ enum ActionsCLI { "runs that action unconditionally on the most recent clip.", "defaultTerminalApps": TerminalApps.defaults ] - } + }() return emitJSONObject(catalog) } @@ -303,7 +303,7 @@ enum ActionsCLI { } private static func pluginsList() -> Int32 { - let payload: [String: Any] = MainActor.assumeIsolated { + let payload: [String: Any] = { var packages: [String: [String: Any]] = [:] for d in ProviderRegistry.shared.descriptors() { let pkgID = d.pluginID ?? "builtin" @@ -324,7 +324,7 @@ enum ActionsCLI { "localFolders": Defaults[.localMarketplaceFolders], "marketplaces": MarketplaceStore.shared.registeredMarketplaceURLs().map(\.absoluteString) ] - } + }() return emitJSONObject(payload) } diff --git a/Maccy/Actions/KeyboardLayoutFixer.swift b/Maccy/Actions/KeyboardLayoutFixer.swift index 24c2d635..3541ff24 100644 --- a/Maccy/Actions/KeyboardLayoutFixer.swift +++ b/Maccy/Actions/KeyboardLayoutFixer.swift @@ -4,7 +4,7 @@ import Foundation // between US-QWERTY and Israeli SI-1452. Direction is auto-detected by script // count. Ported from the standalone KeyLayoutSwitcher engine; dumb + // deterministic, with no per-word plausibility scoring. -enum KeyboardLayoutFixer { +nonisolated enum KeyboardLayoutFixer { // English-typed character -> Hebrew character the user intended. static let enToHe: [Character: Character] = [ // letters lowercase diff --git a/Maccy/AppDelegate.swift b/Maccy/AppDelegate.swift index f704d21f..c8e544d2 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 @@ -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 { @@ -192,6 +193,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 @@ -203,18 +208,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) @@ -242,7 +247,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/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 { 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/ConcatenatedCollection.swift b/Maccy/ConcatenatedCollection.swift new file mode 100644 index 00000000..daefe899 --- /dev/null +++ b/Maccy/ConcatenatedCollection.swift @@ -0,0 +1,45 @@ +/// A random-access view over two arrays that does not allocate combined storage. +nonisolated 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/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/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/Extensions/Defaults.Keys+Names.swift b/Maccy/Extensions/Defaults.Keys+Names.swift index bac19571..a41a093d 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) @@ -53,6 +53,7 @@ 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/Extensions/KeyboardShortcuts.Name+Shortcuts.swift b/Maccy/Extensions/KeyboardShortcuts.Name+Shortcuts.swift index 8ae0a77b..fe5cd33b 100644 --- a/Maccy/Extensions/KeyboardShortcuts.Name+Shortcuts.swift +++ b/Maccy/Extensions/KeyboardShortcuts.Name+Shortcuts.swift @@ -2,8 +2,8 @@ import KeyboardShortcuts extension KeyboardShortcuts.Name { // Default differs from upstream Maccy (⌘⇧C) so MaccyPlus can run alongside it. - static let popup = Self("popup", default: Shortcut(.c, modifiers: [.command, .option])) - 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, .option])) + 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/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/NSPasteboard.PasteboardType+Types.swift b/Maccy/Extensions/NSPasteboard.PasteboardType+Types.swift index 976d9066..0da5ae0c 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") @@ -24,8 +24,12 @@ 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/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 { } diff --git a/Maccy/Extensions/Settings.PaneIdentifier+Panes.swift b/Maccy/Extensions/Settings.PaneIdentifier+Panes.swift index 1f39946a..a7c7b9c2 100644 --- a/Maccy/Extensions/Settings.PaneIdentifier+Panes.swift +++ b/Maccy/Extensions/Settings.PaneIdentifier+Panes.swift @@ -6,7 +6,6 @@ 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 plugins = Self("plugins") static let storage = Self("storage") } 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/FloatingPanel.swift b/Maccy/FloatingPanel.swift index 0514d598..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 {} @@ -37,9 +40,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 @@ -54,15 +56,25 @@ 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() + } + ) ) - 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]) { @@ -200,15 +212,19 @@ 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() } } 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() @@ -219,3 +235,22 @@ class FloatingPanel: NSPanel, NSWindowDelegate { return true } } + +private struct FloatingPanelRootView: View { + @State private var appState = AppState.shared + + let content: Content + let onWindowDragEnded: () -> 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() + } + ) + } +} diff --git a/Maccy/Info.plist b/Maccy/Info.plist index 5df0187d..9ad741e2 100644 --- a/Maccy/Info.plist +++ b/Maccy/Info.plist @@ -40,5 +40,20 @@ https://raw.githubusercontent.com/roypadina/maccyplus/main/appcast.xml SUEnableAutomaticChecks + UTExportedTypeDeclarations + + + UTTypeIdentifier + org.p0deje.Maccy.pin-reorder + UTTypeTagSpecification + + UTTypeDescription + Maccy Pin Reorder Type + UTTypeConformsTo + + public.data + + + 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/ItemsProtocol.swift b/Maccy/ItemsProtocol.swift index fe77111d..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 set } + var items: Items { get } } extension ItemsContainer { @@ -16,11 +17,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 +36,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/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 dc69d70a..e24013f6 100644 --- a/Maccy/Models/HistoryItem.swift +++ b/Maccy/Models/HistoryItem.swift @@ -6,40 +6,50 @@ import Vision @Model class HistoryItem { + @MainActor static var supportedPins: Set { // "a" reserved for select all // "q" reserved for quit // "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, - 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) } 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] { - availablePins(in: History.shared.all.compactMap { - if $0.isPinned { return $0.item } - return nil - }) + History.shared.availablePins } @MainActor @@ -98,10 +108,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 "" } @@ -251,31 +269,44 @@ class HistoryItem { .compactMap { $0.value } } - private func performTextRecognition() { - guard let cgImage = image?.cgImage(forProposedRect: nil, context: nil, hints: nil) else { - return + 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)) + } } + } - let requestHandler = VNImageRequestHandler(cgImage: cgImage) - let request = VNRecognizeTextRequest(completionHandler: recognizeTextHandler) - request.recognitionLevel = .fast + nonisolated private static func performTextRecognition(imageData: Data?, fileURL: URL?) -> String? { + guard let data = imageData ?? fileURL.flatMap({ try? Data(contentsOf: $0) }) else { + return nil + } + + let requestHandler = VNImageRequestHandler(data: data) + let request = VNRecognizeTextRequest() + request.recognitionLevel = .accurate + request.automaticallyDetectsLanguage = true 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/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/Notifier.swift b/Maccy/Notifier.swift index 08f92f3f..91b22b79 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/AppState.swift b/Maccy/Observables/AppState.swift index f67e73af..aa2ba4ed 100644 --- a/Maccy/Observables/AppState.swift +++ b/Maccy/Observables/AppState.swift @@ -17,6 +17,11 @@ class AppState: Sendable { var navigator: NavigationManager var preview: SlideoutController + var isEditingItem: Bool = false + var suppressPopupAutoClose: Bool { + return navigator.isDragAndDropInProgress || isEditingItem + } + var searchVisible: Bool { if !Defaults[.showSearch] { return false } switch Defaults[.searchVisibility] { @@ -26,7 +31,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) @@ -91,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 @@ -111,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 @@ -149,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, diff --git a/Maccy/Observables/History.swift b/Maccy/Observables/History.swift index c86c3046..55c12c9d 100644 --- a/Maccy/Observables/History.swift +++ b/Maccy/Observables/History.swift @@ -13,19 +13,39 @@ 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: ConcatenatedCollection { + switch Defaults[.pinTo] { + case .top: + ConcatenatedCollection(pinnedItems, unpinnedItems) + case .bottom: + ConcatenatedCollection(unpinnedItems, pinnedItems) + } + } + var pinnedItems: [HistoryItemDecorator] { + searchQuery.isEmpty ? allPinnedItems : filteredPinnedItems + } + var unpinnedItems: [HistoryItemDecorator] { + searchQuery.isEmpty ? allUnpinnedItems : filteredUnpinnedItems + } + // Unfiltered list in display order — the agent API and actions read it. + var all: [HistoryItemDecorator] { + Defaults[.pinTo] == .top ? allPinnedItems + allUnpinnedItems : allUnpinnedItems + allPinnedItems + } + var availablePins: [String] { pinManager.availablePins } + + var firstPinnedItem: HistoryItemDecorator? { pinnedItems.first } + var firstUnpinnedItem: HistoryItemDecorator? { unpinnedItems.first } var searchQuery: String = "" { - didSet { + 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: unpinnedItems.first) + AppState.shared.navigator.select(item: firstUnpinnedItem) } else { AppState.shared.navigator.highlightFirst() } @@ -55,17 +75,24 @@ 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() + 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 init() { + #if DEBUG + guard !Self.isRunningUnitTests else { return } + #endif + Task { for await _ in Defaults.updates(.pasteByDefault, initial: false) { updateShortcuts() @@ -86,16 +113,14 @@ 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()) - } + updateTitlesForSpecialSymbols() } } Task { for await _ in Defaults.updates(.imageMaxHeight, initial: false) { for item in items { - await item.cleanupImages() + item.cleanupImages() } } } @@ -111,8 +136,11 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length missingUID.forEach { $0.uid = UUID().uuidString } try? Storage.shared.context.save() } - all = sorter.sort(results).map { HistoryItemDecorator($0) } - 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]) @@ -125,9 +153,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) } } @@ -149,7 +176,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) @@ -165,14 +192,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) @@ -181,31 +208,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) } } 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 } @@ -225,14 +250,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 } - items = all try? Storage.shared.context.transaction { try? Storage.shared.context.delete( @@ -258,12 +279,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 @@ -301,8 +323,10 @@ class History: ItemsContainer { // swiftlint:disable:this type_body_length try? Storage.shared.context.save() } - all.removeAll { $0 == item } - items.removeAll { $0 == item } + pinManager.remove(item) + allUnpinnedItems.removeAll { $0 == item } + filteredPinnedItems.removeAll { $0 == item } + filteredUnpinnedItems.removeAll { $0 == item } sessionLog.removeValues { $0 == item.item } updateUnpinnedShortcuts() @@ -431,15 +455,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 } @@ -459,17 +483,16 @@ 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), - 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 { @@ -477,9 +500,27 @@ 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 updatePin(_ item: HistoryItem, to pin: String) { + 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 } @@ -494,19 +535,40 @@ 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() { - for item in pinnedItems { + for item in pinManager.pinnedItems { if let pin = item.item.pin { item.shortcuts = KeyShortcut.create(character: pin) } @@ -515,6 +577,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 @@ -522,13 +591,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/HistoryItemDecorator.swift b/Maccy/Observables/HistoryItemDecorator.swift index a82e15b4..b5ddd88f 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 } @@ -40,6 +42,9 @@ class HistoryItemDecorator: Identifiable, Hashable, HasVisibility { } var hasImage: Bool { item.hasImageData } + var hasFileURLs: Bool { !item.fileURLs.isEmpty } + var hasPlainText: Bool { item.text != nil } + var hasRichText: Bool { item.rtf != nil || item.html != nil } // Memoized resolved actions for this item's row menu. resolvedActions is // expensive (value classification + rule matching); recomputing it on every @@ -64,14 +69,17 @@ 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 + @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.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 } @@ -84,20 +92,26 @@ 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 } - + // Describe the complete item independently of its potentially truncated visual content. var accessibilityLabel: String { 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) } @@ -111,7 +125,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: ", ") } @@ -124,6 +144,7 @@ class HistoryItemDecorator: Identifiable, Hashable, HasVisibility { synchronizeItemPin() synchronizeItemTitle() + synchronizeItemText() } @MainActor @@ -228,24 +249,17 @@ 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 - } 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) } + // History assigns numeric shortcuts when unpinning. Preserve them when + // this observation callback runs after the history has been updated. self.synchronizeItemPin() } } @@ -254,11 +268,22 @@ 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() } } } + + 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/Observables/NavigationManager.swift b/Maccy/Observables/NavigationManager.swift index b7ed9216..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) } @@ -64,15 +66,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) } } @@ -101,20 +112,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 } } @@ -127,7 +143,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 diff --git a/Maccy/Observables/PinManager.swift b/Maccy/Observables/PinManager.swift new file mode 100644 index 00000000..f2f3a23a --- /dev/null +++ b/Maccy/Observables/PinManager.swift @@ -0,0 +1,183 @@ +import Defaults +import Observation +import SwiftUI + +nonisolated 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.sortedPins(HistoryItem.supportedPins.subtracting(assignedPins)) + } + + func load(from items: [HistoryItemDecorator]) { + pinnedItems = items.filter(\.isPinned) + reconcileOrder() + sortPinnedItems() + } + + func toggle(_ item: HistoryItemDecorator) { + if item.isPinned { + unpin(item) + } else { + pin(item) + } + } + + 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 } + 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 else { return } + + let pin = availablePins.randomElement() ?? "" + 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) + } + } +} diff --git a/Maccy/Observables/Popup.swift b/Maccy/Observables/Popup.swift index e0abbaa2..3d97410a 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() { @@ -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/Plugins/JSPluginRuntime.swift b/Maccy/Plugins/JSPluginRuntime.swift index 84500322..3cb69a46 100644 --- a/Maccy/Plugins/JSPluginRuntime.swift +++ b/Maccy/Plugins/JSPluginRuntime.swift @@ -12,7 +12,7 @@ import JavaScriptCore // bind to them by their unmangled C symbol names via `@_silgen_name`. The public // `JSContextGroupRef` / `JSContextRef` opaque types come from `import JavaScriptCore`. @_silgen_name("JSContextGroupSetExecutionTimeLimit") -private func JSContextGroupSetExecutionTimeLimit( +nonisolated private func JSContextGroupSetExecutionTimeLimit( _ group: JSContextGroupRef?, _ limit: Double, _ callback: @convention(c) (JSContextRef?, UnsafeMutableRawPointer?) -> Bool, @@ -20,7 +20,7 @@ private func JSContextGroupSetExecutionTimeLimit( ) @_silgen_name("JSContextGroupClearExecutionTimeLimit") -private func JSContextGroupClearExecutionTimeLimit(_ group: JSContextGroupRef?) +nonisolated private func JSContextGroupClearExecutionTimeLimit(_ group: JSContextGroupRef?) enum JSPluginError: Error, Equatable { case compileFailed(String) @@ -34,7 +34,7 @@ enum JSPluginError: Error, Equatable { /// (no fetch/require/XMLHttpRequest/setTimeout/process — only ECMAScript built-ins), /// guarded by a wall-clock watchdog via `JSContextGroupSetExecutionTimeLimit`. /// Not `@MainActor`: pure compute, callable off the main actor. -final class JSPluginRuntime { +nonisolated final class JSPluginRuntime { private let context: JSContext private let timeLimitSeconds: Double diff --git a/Maccy/Plugins/PluginCore.swift b/Maccy/Plugins/PluginCore.swift index fdaec3d6..7845dd42 100644 --- a/Maccy/Plugins/PluginCore.swift +++ b/Maccy/Plugins/PluginCore.swift @@ -2,7 +2,7 @@ import Foundation // MARK: - JSONValue -enum JSONValue: Codable, Hashable { +nonisolated enum JSONValue: Codable, Hashable { case string(String) case number(Double) case bool(Bool) @@ -82,7 +82,7 @@ enum JSONValue: Codable, Hashable { // MARK: - PluginInput -struct PluginInput { +nonisolated struct PluginInput { let string: String let kinds: Set let sourceAppBundleID: String? @@ -91,7 +91,7 @@ struct PluginInput { // MARK: - ActionOutcome -enum ActionOutcome: Equatable { +nonisolated enum ActionOutcome: Equatable { case replace(String) case sideEffect case none @@ -99,7 +99,7 @@ enum ActionOutcome: Equatable { // MARK: - Capability -enum Capability: String, Codable, Hashable, CaseIterable { +nonisolated enum Capability: String, Codable, Hashable, CaseIterable { case network case fileRead case fileWrite @@ -130,12 +130,12 @@ enum Capability: String, Codable, Hashable, CaseIterable { // MARK: - ProviderKind / ProviderEngine -enum ProviderKind: String, Codable, Hashable { +nonisolated enum ProviderKind: String, Codable, Hashable { case condition case action } -enum ProviderEngine: String, Codable, Hashable { +nonisolated enum ProviderEngine: String, Codable, Hashable { case native case declarative case javascript @@ -143,7 +143,7 @@ enum ProviderEngine: String, Codable, Hashable { // MARK: - ProviderSource -enum ProviderSource: Codable, Hashable { +nonisolated enum ProviderSource: Codable, Hashable { case builtin case bundled case marketplace(String) @@ -199,7 +199,7 @@ enum ProviderSource: Codable, Hashable { // MARK: - ParamKind / ParamSpec -enum ParamKind: String, Codable, Hashable { +nonisolated enum ParamKind: String, Codable, Hashable { case text case valueKind case bundleID @@ -208,7 +208,7 @@ enum ParamKind: String, Codable, Hashable { } /// Whether a copied local path points at a regular file or at a folder. -enum PathType: String, Codable, CaseIterable, Identifiable { +nonisolated enum PathType: String, Codable, CaseIterable, Identifiable { case file case folder @@ -221,7 +221,7 @@ enum PathType: String, Codable, CaseIterable, Identifiable { } } -struct ParamSpec: Codable, Hashable, Identifiable { +nonisolated struct ParamSpec: Codable, Hashable, Identifiable { var id: String { key } let key: String let label: String @@ -231,7 +231,7 @@ struct ParamSpec: Codable, Hashable, Identifiable { // MARK: - ProviderDescriptor -struct ProviderDescriptor: Identifiable, Hashable { +nonisolated struct ProviderDescriptor: Identifiable, Hashable { let id: String let name: String let description: String diff --git a/Maccy/Plugins/PluginManifest.swift b/Maccy/Plugins/PluginManifest.swift index 0f63615e..82a0e43d 100644 --- a/Maccy/Plugins/PluginManifest.swift +++ b/Maccy/Plugins/PluginManifest.swift @@ -2,7 +2,7 @@ import Foundation // MARK: - Error -enum PluginManifestError: Error, Equatable { +nonisolated enum PluginManifestError: Error, Equatable { case missingField(String) case badEngineEntry // engine == .javascript but entry is nil/empty case descriptionTooLong // description exceeds 120 characters @@ -12,7 +12,7 @@ enum PluginManifestError: Error, Equatable { /// One provider declared inside a package manifest's `providers` list. /// Each keeps its own stable `id` (rules/presets reference provider ids). -struct ProviderSpec: Codable, Hashable { +nonisolated struct ProviderSpec: Codable, Hashable { let id: String let name: String let description: String // required, <= 120 chars @@ -30,7 +30,7 @@ struct ProviderSpec: Codable, Hashable { /// A package manifest: ONE folder + ONE `plugin.json` declaring a LIST of /// providers (any mix of conditions and actions). The package id is the /// install/manage unit; capabilities are consented once per package. -struct PluginManifest: Codable, Hashable { +nonisolated struct PluginManifest: Codable, Hashable { // MARK: Nested types struct Author: Codable, Hashable { 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) 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) { 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) -} 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/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/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" = ""; 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/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/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/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/Maccy/Views/ContentView.swift b/Maccy/Views/ContentView.swift index 72fe0d94..9a6f0541 100644 --- a/Maccy/Views/ContentView.swift +++ b/Maccy/Views/ContentView.swift @@ -60,8 +60,15 @@ 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 } + if let window = $0.object as? NSWindow, let bundleIdentifier = Bundle.main.bundleIdentifier, window.identifier == NSUserInterfaceItemIdentifier(bundleIdentifier) { @@ -69,6 +76,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/EnumeratedForEach.swift b/Maccy/Views/EnumeratedForEach.swift new file mode 100644 index 00000000..b17c0475 --- /dev/null +++ b/Maccy/Views/EnumeratedForEach.swift @@ -0,0 +1,25 @@ +import SwiftUI + +public struct EnumeratedForEach: DynamicViewContent +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/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/HistoryListView.swift b/Maccy/Views/HistoryListView.swift index 1c0fc92a..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 @@ -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() 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/ItemEditorView.swift b/Maccy/Views/ItemEditorView.swift new file mode 100644 index 00000000..7d28fc18 --- /dev/null +++ b/Maccy/Views/ItemEditorView.swift @@ -0,0 +1,225 @@ +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: LargeTextView.EditorState + @State private var selectedPin: String? + @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 + case pin + } + + @FocusState private var focusedField: Field? + + init(for item: HistoryItemDecorator) { + self.item = item + self._editableTitle = State(initialValue: item.item.title) + 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 + ) + 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) + .accessibilityLabel(Text("Alias", tableName: "PinsSettings")) + .disabled(item.hasImage) + .focused($focusedField, equals: .title) + .onSubmit(saveChanges) + } + + 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() + .accessibilityLabel(Text("Key", tableName: "PinsSettings")) + .focused($focusedField, equals: .pin) + .frame(maxWidth: 220, alignment: .leading) + } + } + } + + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Text("Content", tableName: "PinsSettings") + .font(.headline) + + if hasWarning { + Label { + 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) + } + } + } + + contentEditor + } + + HStack { + Spacer() + Button(Self.cancelButtonTitle, role: .cancel) { + dismiss() + } + .keyboardShortcut(.cancelAction) + + Button(Self.doneButtonTitle, action: saveChanges) + .keyboardShortcut(.return, modifiers: .command) + .disabled(!hasChangesToApply) + } + } + .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 + } + .onExitCommand { dismiss() } + } + + 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 { + 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) + .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] { + HistoryItem.sortedPins(Set(availablePins + [item.item.pin, selectedPin].compactMap { $0 })) + } + + 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 && editableTitle != item.item.title { + item.item.title = editableTitle + item.title = editableTitle + } + + if let selectedPin { + appState.history.updatePin(item.item, to: selectedPin) + } + + guard isTextContent || isRichText else { return } + guard editableContent.hasChanges, + let data = editableContent.text.data(using: .utf8) 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 + }) { + historyItem.contents[index].value = data + } else { + historyItem.contents.append( + HistoryItemContent(type: stringType, value: data) + ) + } + } + + private func saveChanges() { + guard hasChangesToApply else { return } + applyChanges() + dismiss() + } +} diff --git a/Maccy/Views/KeyHandlingView.swift b/Maccy/Views/KeyHandlingView.swift index f67f37d9..c4a37469 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 diff --git a/Maccy/Views/LargeTextView.swift b/Maccy/Views/LargeTextView.swift new file mode 100644 index 00000000..1cd132de --- /dev/null +++ b/Maccy/Views/LargeTextView.swift @@ -0,0 +1,203 @@ +import AppKit +import Observation +import SwiftUI + +struct LargeTextView: NSViewRepresentable { + @Environment(\.isEnabled) private var isEnabled + + 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, + textColor: isEnabled ? .labelColor : .disabledControlTextColor + ) + 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.textColor = isEnabled ? .labelColor : .disabledControlTextColor + 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, + textColor: NSColor = .labelColor + ) -> 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 = textColor + 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/LegacyReorderableContainer.swift b/Maccy/Views/LegacyReorderableContainer.swift new file mode 100644 index 00000000..71fa4035 --- /dev/null +++ b/Maccy/Views/LegacyReorderableContainer.swift @@ -0,0 +1,264 @@ +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 + @State private var dropTargetSize: CGSize = .zero + + let item: Item + + @ViewBuilder + func body(content: Content) -> some View { + 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, + dragContext: dragContext, + appState: appState, + modifierFlags: modifierFlags + ) + ) + .onDrop( + of: [dragContext.contentType], + delegate: LegacyDraggableItemDropDelegate( + item: item, + dragContext: dragContext, + targetSize: dropTargetSize + ) + ) + } else { + content + } + } +} + +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) { + move(info) + } + + func dropUpdated(info: DropInfo) -> DropProposal? { + move(info) + return DropProposal(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + dragContext.resetDragState() + 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 { + 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() + } + ) + } +} + +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() + } + } + + nonisolated(unsafe) 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 { + if let localEventMonitor { + NSEvent.removeMonitor(localEventMonitor) + } + } + + 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/ListItemView.swift b/Maccy/Views/ListItemView.swift index b731a0f3..243b6017 100644 --- a/Maccy/Views/ListItemView.swift +++ b/Maccy/Views/ListItemView.swift @@ -168,8 +168,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 : []) 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..ec57b8c2 100644 --- a/Maccy/Views/MultipleSelectionListView.swift +++ b/Maccy/Views/MultipleSelectionListView.swift @@ -1,17 +1,41 @@ import SwiftUI -struct MultipleSelectionListView: View - where ID: Hashable, Content: View, ID == Element.ID, Element: Identifiable { +struct MultipleSelectionListView: View +where Element: Identifiable, RowContent: View, ModifiedForEach: View { var items: [Element] - var content: (Element?, Element, Element?, Int) -> Content + var content: (Element?, Element, Element?, Int) -> RowContent + var forEachModifier: (EnumeratedForEach<[Element], Element.ID, RowContent>) -> ModifiedForEach + + init( + items: [Element], + @ViewBuilder content: @escaping (Element?, Element, Element?, Int) -> RowContent + ) where ModifiedForEach == EnumeratedForEach<[Element], 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 ( + EnumeratedForEach<[Element], Element.ID, RowContent> + ) -> ModifiedForEach + ) { + self.items = items + self.content = content + self.forEachModifier = forEachModifier + } var body: some View { 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( + EnumeratedForEach(data: items) { element, index in + let previous = index > 0 ? items[index - 1] : nil + let next = index < items.count - 1 ? items[index + 1] : nil + return content(previous, element, next, index) + } + ) } } } 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[..= Self.largeTextThreshold { - LargeTextPreviewView(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) } @@ -103,6 +102,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) @@ -123,51 +129,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 - } -} diff --git a/Maccy/Views/ReorderableContainer.swift b/Maccy/Views/ReorderableContainer.swift new file mode 100644 index 00000000..f6c65e89 --- /dev/null +++ b/Maccy/Views/ReorderableContainer.swift @@ -0,0 +1,256 @@ +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 + + @State private var active: Item? + @State private var lastDropPosition: DropPosition? + + 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() { + draggedItems = [] + active = nil + lastDropPosition = 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, 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 } + guard let destinationIndex = reorderDestination( + currentIndex: currentIndex, + targetIndex: targetIndex, + edge: edge + ) else { return } + + let source = sourceIndexes + guard !source.isEmpty else { return } + moveAction(source, destinationIndex) + } + + private var sourceIndexes: IndexSet { + var source = IndexSet() + for (index, item) in items.enumerated() where draggedItems.contains(item) { + source.insert(index) + } + return source + } +} + +private struct DropPosition: Equatable { + let itemID: ItemID + let edge: ReorderableDropEdge +} + +@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 { + dragContext.startDrag(item) + return 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 .ended: + dragContext.resetDragState() + default: + break + } + } + .onDrop(of: [dragContext.contentType], isTargeted: nil) { _ in + dragContext.resetDragState() + return true + } + .onDropSessionUpdated { session in + switch session.phase { + case .entering, .active: + dragContext.move( + item, + dropEdge(at: session.location, in: session.size) + ) + case .ended: + dragContext.resetDragState() + default: + break + } + } + } else { + content + } + } + +} + +private func itemProvider( + for item: Item, + 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(data, nil) + return nil + } + return provider +} + +struct ReorderableDragContext { + let contentType: UTType + let isDragged: (Any) -> Bool + let dragPreview: (Any) -> AnyView + let startDrag: (Any) -> Void + let resetDragState: () -> Void + let move: (Any, ReorderableDropEdge) -> Void +} + +enum ReorderableDropEdge: Equatable { + 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 { + static let defaultValue: ReorderableDragContext? = nil +} + +extension EnvironmentValues { + var reorderableDragContext: ReorderableDragContext? { + get { self[ReorderableDragContextKey.self] } + set { self[ReorderableDragContextKey.self] = newValue } + } +} + +extension View { + @ViewBuilder + func draggableContainer( + items: [Item], + contentType: UTType, + @ViewBuilder dragPreview: @escaping ([Item]) -> DragPreview, + 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 + ) + ) + } + + @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 { + modifier(LegacyDraggableItemModifier(item: item)) + } + } +} diff --git a/Maccy/Views/SlideoutView.swift b/Maccy/Views/SlideoutView.swift index 3e21a54f..2f0db1d5 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() @@ -116,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, @@ -134,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) diff --git a/Maccy/Views/ToolbarView.swift b/Maccy/Views/ToolbarView.swift index b3e72ff7..ea18f80f 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( @@ -72,6 +66,7 @@ struct ToolbarButton: View { struct ToolbarView: View { @State private var appState = AppState.shared + @State private var editingItem: HistoryItemDecorator? @Namespace var unionNamespace @@ -107,6 +102,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 item = appState.navigator.selection.first else { return false } + return item.hasPlainText || item.hasRichText || item.hasImage + } + + private var editableItem: HistoryItemDecorator? { + guard editItemEnabled else { return nil } + return appState.navigator.selection.first + } + var body: some View { HStack { if !appState.navigator.selection.isEmpty { @@ -129,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") } @@ -142,6 +148,16 @@ struct ToolbarView: View { ) .disabled(pinActionDisabled) + ToolbarButton { + appState.isEditingItem = true + editingItem = editableItem + } label: { + Image(systemName: "pencil.and.list.clipboard") + } + .shortcutKeyHelp(key: "EditItem", tableName: "PreviewItemView") + .disabled(!editItemEnabled) + .accessibilityIdentifier("edit-item") + ToolbarButton { appState.deleteSelection() } label: { @@ -164,5 +180,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 } } diff --git a/Maccy/Views/ar.lproj/PreviewItemView.strings b/Maccy/Views/ar.lproj/PreviewItemView.strings index d7c15c2e..0527623e 100644 --- a/Maccy/Views/ar.lproj/PreviewItemView.strings +++ b/Maccy/Views/ar.lproj/PreviewItemView.strings @@ -8,4 +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 07204923..b3483b07 100644 --- a/Maccy/Views/be.lproj/PreviewItemView.strings +++ b/Maccy/Views/be.lproj/PreviewItemView.strings @@ -8,4 +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 4228b409..2c8125f5 100644 --- a/Maccy/Views/bn.lproj/PreviewItemView.strings +++ b/Maccy/Views/bn.lproj/PreviewItemView.strings @@ -8,4 +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 6fa0e965..574f2255 100644 --- a/Maccy/Views/bs.lproj/PreviewItemView.strings +++ b/Maccy/Views/bs.lproj/PreviewItemView.strings @@ -8,4 +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 cc814786..6b6d8690 100644 --- a/Maccy/Views/ca.lproj/PreviewItemView.strings +++ b/Maccy/Views/ca.lproj/PreviewItemView.strings @@ -8,4 +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 db8758f2..73317a7f 100644 --- a/Maccy/Views/ckb.lproj/PreviewItemView.strings +++ b/Maccy/Views/ckb.lproj/PreviewItemView.strings @@ -8,4 +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 436a071f..c4dfa120 100644 --- a/Maccy/Views/cs.lproj/PreviewItemView.strings +++ b/Maccy/Views/cs.lproj/PreviewItemView.strings @@ -8,4 +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 5cf442b4..76d116ef 100644 --- a/Maccy/Views/de.lproj/PreviewItemView.strings +++ b/Maccy/Views/de.lproj/PreviewItemView.strings @@ -8,4 +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 c59be9be..29639bf9 100644 --- a/Maccy/Views/el.lproj/PreviewItemView.strings +++ b/Maccy/Views/el.lproj/PreviewItemView.strings @@ -8,4 +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 2da917b1..6ca1f46b 100644 --- a/Maccy/Views/en.lproj/PreviewItemView.strings +++ b/Maccy/Views/en.lproj/PreviewItemView.strings @@ -3,9 +3,11 @@ "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."; "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..9509800f 100644 --- a/Maccy/Views/eo.lproj/PreviewItemView.strings +++ b/Maccy/Views/eo.lproj/PreviewItemView.strings @@ -8,4 +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 c450aed2..4222caca 100644 --- a/Maccy/Views/es.lproj/PreviewItemView.strings +++ b/Maccy/Views/es.lproj/PreviewItemView.strings @@ -8,4 +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 49353ac8..e3c6d257 100644 --- a/Maccy/Views/fa.lproj/PreviewItemView.strings +++ b/Maccy/Views/fa.lproj/PreviewItemView.strings @@ -8,4 +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 157def3f..05213d4f 100644 --- a/Maccy/Views/fi.lproj/PreviewItemView.strings +++ b/Maccy/Views/fi.lproj/PreviewItemView.strings @@ -6,3 +6,5 @@ "Application" = ""; "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 53356dc2..d8591bdb 100644 --- a/Maccy/Views/fr.lproj/PreviewItemView.strings +++ b/Maccy/Views/fr.lproj/PreviewItemView.strings @@ -8,4 +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 07a9d3ae..858cd8ae 100644 --- a/Maccy/Views/he.lproj/PreviewItemView.strings +++ b/Maccy/Views/he.lproj/PreviewItemView.strings @@ -8,4 +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 ac328591..03061a60 100644 --- a/Maccy/Views/hi.lproj/PreviewItemView.strings +++ b/Maccy/Views/hi.lproj/PreviewItemView.strings @@ -8,4 +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 605b910c..d14b777d 100644 --- a/Maccy/Views/hr.lproj/PreviewItemView.strings +++ b/Maccy/Views/hr.lproj/PreviewItemView.strings @@ -8,4 +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 25687155..473d0cf8 100644 --- a/Maccy/Views/hu.lproj/PreviewItemView.strings +++ b/Maccy/Views/hu.lproj/PreviewItemView.strings @@ -8,4 +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 796f3726..dc8f5414 100644 --- a/Maccy/Views/id.lproj/PreviewItemView.strings +++ b/Maccy/Views/id.lproj/PreviewItemView.strings @@ -8,4 +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 de46c5a2..74f4aebf 100644 --- a/Maccy/Views/it.lproj/PreviewItemView.strings +++ b/Maccy/Views/it.lproj/PreviewItemView.strings @@ -8,4 +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 a46dde47..7506d0a7 100644 --- a/Maccy/Views/ja.lproj/PreviewItemView.strings +++ b/Maccy/Views/ja.lproj/PreviewItemView.strings @@ -8,4 +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 456109e9..36b5a64a 100644 --- a/Maccy/Views/km.lproj/PreviewItemView.strings +++ b/Maccy/Views/km.lproj/PreviewItemView.strings @@ -6,7 +6,9 @@ "Application" = "កម្មវិធី:"; "CopyExtractedText" = "ចម្លងអត្ថបទដែលបានស្រង់។"; "Dimensions" = "ទំហំ:"; +"Size" = "ទំហំឯកសារ:"; "PreviewKey" = "ចុច {previewKey} ដើម្បី បើក/បិទ ការមើលមុន។"; "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..b96cec52 100644 --- a/Maccy/Views/ko.lproj/PreviewItemView.strings +++ b/Maccy/Views/ko.lproj/PreviewItemView.strings @@ -8,4 +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 11fc2a3d..d828e067 100644 --- a/Maccy/Views/lt.lproj/PreviewItemView.strings +++ b/Maccy/Views/lt.lproj/PreviewItemView.strings @@ -8,4 +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 67dd53cc..4a631822 100644 --- a/Maccy/Views/lv.lproj/PreviewItemView.strings +++ b/Maccy/Views/lv.lproj/PreviewItemView.strings @@ -8,4 +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 f036d969..553570dc 100644 --- a/Maccy/Views/nb.lproj/PreviewItemView.strings +++ b/Maccy/Views/nb.lproj/PreviewItemView.strings @@ -8,4 +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 623c3180..7b0fb456 100644 --- a/Maccy/Views/nl.lproj/PreviewItemView.strings +++ b/Maccy/Views/nl.lproj/PreviewItemView.strings @@ -8,4 +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 ab72a008..b89773a6 100644 --- a/Maccy/Views/pl.lproj/PreviewItemView.strings +++ b/Maccy/Views/pl.lproj/PreviewItemView.strings @@ -8,4 +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 e6eb9615..59f7fe2d 100644 --- a/Maccy/Views/pt-BR.lproj/PreviewItemView.strings +++ b/Maccy/Views/pt-BR.lproj/PreviewItemView.strings @@ -8,4 +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 e8280314..b21eaecc 100644 --- a/Maccy/Views/pt.lproj/PreviewItemView.strings +++ b/Maccy/Views/pt.lproj/PreviewItemView.strings @@ -8,4 +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 a3c9f3c2..9aef22e3 100644 --- a/Maccy/Views/ro.lproj/PreviewItemView.strings +++ b/Maccy/Views/ro.lproj/PreviewItemView.strings @@ -8,4 +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 6b895f05..6f594a2d 100644 --- a/Maccy/Views/ru.lproj/PreviewItemView.strings +++ b/Maccy/Views/ru.lproj/PreviewItemView.strings @@ -8,4 +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 03bf96c3..52772235 100644 --- a/Maccy/Views/sl.lproj/PreviewItemView.strings +++ b/Maccy/Views/sl.lproj/PreviewItemView.strings @@ -8,4 +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 3a23ec7b..be12df0e 100644 --- a/Maccy/Views/sv.lproj/PreviewItemView.strings +++ b/Maccy/Views/sv.lproj/PreviewItemView.strings @@ -8,4 +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 091b5b6b..7197c6a9 100644 --- a/Maccy/Views/ta.lproj/PreviewItemView.strings +++ b/Maccy/Views/ta.lproj/PreviewItemView.strings @@ -8,4 +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 f09a74f4..6f4b8e31 100644 --- a/Maccy/Views/th.lproj/PreviewItemView.strings +++ b/Maccy/Views/th.lproj/PreviewItemView.strings @@ -8,4 +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 80837131..e738fa52 100644 --- a/Maccy/Views/tr.lproj/PreviewItemView.strings +++ b/Maccy/Views/tr.lproj/PreviewItemView.strings @@ -8,4 +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 ed70d387..e0720dbc 100644 --- a/Maccy/Views/uk.lproj/PreviewItemView.strings +++ b/Maccy/Views/uk.lproj/PreviewItemView.strings @@ -8,4 +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 119658fb..2ebdfa0f 100644 --- a/Maccy/Views/uz.lproj/PreviewItemView.strings +++ b/Maccy/Views/uz.lproj/PreviewItemView.strings @@ -8,4 +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 de43e3e3..a5ab4349 100644 --- a/Maccy/Views/vi.lproj/PreviewItemView.strings +++ b/Maccy/Views/vi.lproj/PreviewItemView.strings @@ -8,4 +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 3b6ebb5b..c590c184 100644 --- a/Maccy/Views/zh-Hans.lproj/PreviewItemView.strings +++ b/Maccy/Views/zh-Hans.lproj/PreviewItemView.strings @@ -8,4 +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 38fe43e4..c5eb6df3 100644 --- a/Maccy/Views/zh-Hant.lproj/PreviewItemView.strings +++ b/Maccy/Views/zh-Hant.lproj/PreviewItemView.strings @@ -8,4 +8,6 @@ "OpenPreview" = "按 {previewKey} 顯示預覽。"; "ClosePreview" = "按 {previewKey} 隱藏預覽。"; "Dimensions" = "尺寸:"; +"Size" = "檔案大小:"; "CopyExtractedText" = "複製擷取的文字。"; +"EditItem" = "編輯項目"; diff --git a/MaccyTests/ClipboardTests.swift b/MaccyTests/ClipboardTests.swift index e7af7225..fca19604 100644 --- a/MaccyTests/ClipboardTests.swift +++ b/MaccyTests/ClipboardTests.swift @@ -1,327 +1,325 @@ -import XCTest +import AppKit +import Testing import Defaults @testable import Maccy -// swiftlint:disable type_body_length -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 - 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) - } - - 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) - } - - 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) - } - - 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]) - } - - func testIgnoreApplication() { - Defaults[.ignoredApps] = ["com.apple.dt.Xcode", "com.apple.finder"] // Finder is on Bitrise - - 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) - } - - func testIgnoreAllApplicationsExcept() { - Defaults[.ignoreAllAppsExceptListed] = true - Defaults[.ignoredApps] = ["com.apple.dt.Xcode", "com.apple.finder"] // Finder is on Bitrise - - 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 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) - } - - 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) - } - - 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) - } - +extension SharedStateTests { + // swiftlint:disable type_body_length @MainActor - 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") - } - - @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)!), - 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)) - } - - 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) - } - - 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]) + 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() + } + + deinit { + Defaults[.enabledPasteboardTypes] = savedEnabledTypes + Defaults[.ignoreEvents] = savedIgnoreEvents + Defaults[.ignoreOnlyNextEvent] = false + Defaults[.ignoreAllAppsExceptListed] = savedIgnoreAllAppsExceptListed + Defaults[.ignoredApps] = savedIgnoredApps + Defaults[.ignoredPasteboardTypes] = savedIgnoredPasteboardTypes + } + + @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) + } + + @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) + } + + @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) + } + + @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) + } + + @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) + } + + @Test("Ignore events is enabled") + func ignoreEventsIsEnabled() async throws { + Defaults[.ignoreEvents] = true + + let copies = try await waitForCopies(expectingCopy: false, after: { + pasteboard.declareTypes([.string], owner: nil) + pasteboard.setString("foo", forType: .string) + }) + #expect(copies.isEmpty) + } + + @Test("Ignore only next event is enabled") + func ignoreOnlyNextEventIsEnabled() async throws { + Defaults[.ignoreEvents] = true + Defaults[.ignoreOnlyNextEvent] = true + + let copies = try await waitForCopies(expectingCopy: false, after: { + pasteboard.declareTypes([.string], owner: nil) + pasteboard.setString("foo", forType: .string) + }) + #expect(copies.isEmpty) + + #expect(!Defaults[.ignoreEvents]) + #expect(!Defaults[.ignoreOnlyNextEvent]) + } + + @Test("Ignore application") + func ignoreApplication() async throws { + Defaults[.ignoredApps] = [try frontmostAppBundleIdentifier()] + + let copies = try await waitForCopies(expectingCopy: false, after: { + pasteboard.declareTypes([.string], owner: nil) + pasteboard.setString("bar", forType: .string) + }) + #expect(copies.isEmpty) + } + + @Test("Ignore all applications except") + func ignoreAllApplicationsExcept() async throws { + Defaults[.ignoreAllAppsExceptListed] = true + Defaults[.ignoredApps] = [try frontmostAppBundleIdentifier()] + + let copies = try await waitForCopies(after: { + pasteboard.declareTypes([.string], owner: nil) + pasteboard.setString("bar", forType: .string) + }) + #expect(copies.count == 1) + } + + @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) + } + + @Test("Ignore custom types") + func ignoreCustomTypes() async throws { + Defaults[.ignoredPasteboardTypes] = [customType.rawValue] + + let copies = try await waitForCopies(expectingCopy: false, after: { + pasteboard.declareTypes([.string, customType], owner: nil) + pasteboard.setString("bar", forType: .string) + }) + #expect(copies.isEmpty) + } + + @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) + } + + @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") + } + + @Test("Copy string") + func copyString() { + clipboard.copyInMaccy("foo") + #expect(pasteboard.string(forType: .string) == "foo") + #expect(pasteboard.string(forType: .source) == NSPasteboard.PasteboardType.fromMaccy.rawValue) + } + + @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) + } + + @Test("Handles items without data") + func handlesItemsWithoutData() async throws { + pasteboard.clearContents() + + 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) + } + + @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]) ) - hookExpectation.fulfill() - }) - - let item1 = NSPasteboardItem() - item1.setString("foo", forType: .string) - let item2 = NSPasteboardItem() - item2.setData(image.tiffRepresentation!, forType: .tiff) - - clipboard.start() - pasteboard.clearContents() - pasteboard.writeObjects([item1, item2]) - - waitForExpectations(timeout: 2) - } - - func testRemovesDisabledTypes() { - Defaults[.enabledPasteboardTypes] = [.fileURL] - - let hookExpectation = expectation(description: "Hook is called") - clipboard.onNewCopy({ (item: HistoryItem) in - XCTAssertEqual(item.contents.map({ $0.type }), [self.fileURLType.rawValue]) - hookExpectation.fulfill() - }) - - let item = NSPasteboardItem() - item.setString("foo", forType: .string) - item.setData(image.tiffRepresentation!, forType: .tiff) - item.setData("file://foo.bar".data(using: .utf8)!, forType: .fileURL) - - clipboard.start() - pasteboard.clearContents() - pasteboard.writeObjects([item]) - - waitForExpectations(timeout: 2) - } - - 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() - }) - - let item = NSPasteboardItem() - item.setString("foo", forType: .string) - item.setData("".data(using: .utf8)!, forType: dynamicType) - - clipboard.start() - pasteboard.clearContents() - pasteboard.writeObjects([item]) - - 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]) + } + + @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]) + } + + @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]) + } + + 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 + } + + private func frontmostAppBundleIdentifier() throws -> String { + guard let bundleIdentifier = NSWorkspace.shared.frontmostApplication?.bundleIdentifier else { + try Test.cancel("No frontmost application to attribute a copy to") + } + + return bundleIdentifier + } } + // swiftlint:enable type_body_length } -// swiftlint:enable type_body_length diff --git a/MaccyTests/CollectionSurroundingTests.swift b/MaccyTests/CollectionSurroundingTests.swift index 74910d31..4ce978e6 100644 --- a/MaccyTests/CollectionSurroundingTests.swift +++ b/MaccyTests/CollectionSurroundingTests.swift @@ -1,20 +1,33 @@ -import XCTest +import Testing @testable import Maccy -class CollectionSurroundingTests: XCTestCase { +@MainActor +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 c2d4b8ca..727725d1 100644 --- a/MaccyTests/ColorImageTests.swift +++ b/MaccyTests/ColorImageTests.swift @@ -1,16 +1,15 @@ -import XCTest +import Testing @testable import Maccy -class ColorImageTests: XCTestCase { - func testColorImageFromShortHex() { - XCTAssertNotNil(ColorImage.from("fff")) +@MainActor +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 6aef658e..f6ae3ebd 100644 --- a/MaccyTests/HistoryDecoratorTests.swift +++ b/MaccyTests/HistoryDecoratorTests.swift @@ -1,230 +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] - - 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") - } - - override func setUp() { - super.setUp() - Defaults[.highlightMatch] = .bold - Defaults[.imageMaxHeight] = 40 - } - - override func tearDown() { - super.tearDown() - 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) - } - - 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) - } - - func testHTML() { - let html = "foo".data(using: .utf8) - let itemDecorator = historyItemDecorator(html, .html) - XCTAssertEqual(itemDecorator.title, "foo") - XCTAssertNil(itemDecorator.previewImage) - XCTAssertNil(itemDecorator.thumbnailImage) - } - - 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) - } - - // 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)) - } - - 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) - } - - func testFileWithEscapedChars() { - let url = URL(fileURLWithPath: "/tmp/产品培训/产品培训.txt") - let itemDecorator = historyItemDecorator(url) - XCTAssertEqual(itemDecorator.title, "file:///tmp/产品培训/产品培训.txt") - XCTAssertNil(itemDecorator.previewImage) - XCTAssertNil(itemDecorator.thumbnailImage) - } - - func testItemWithoutData() { - let itemDecorator = historyItemDecorator(nil) - XCTAssertEqual(itemDecorator.title, "") - XCTAssertNil(itemDecorator.previewImage) - XCTAssertNil(itemDecorator.thumbnailImage) - } - - func testUnpinnedByDefault() { - let itemDecorator = historyItemDecorator("foo") - XCTAssertNil(itemDecorator.item.pin) - 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 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) - } - - 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: NSImage) -> HistoryItemDecorator { - let contents = [ - HistoryItemContent( - type: NSPasteboard.PasteboardType.tiff.rawValue, - value: value.tiffRepresentation! +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 lastCopiedAt: Date! { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy/MM/dd HH:mm:ss" + return formatter.date(from: "2020/07/10 12:41:34") + } + + init() { + Defaults[.highlightMatch] = .bold + Defaults[.imageMaxHeight] = 40 + } + + deinit { + Defaults[.imageMaxHeight] = savedImageMaxHeight + Defaults[.highlightMatch] = savedHighlightMatch + } + + @Test("String") + func string() { + let title = "foo" + let itemDecorator = historyItemDecorator(title) + #expect(itemDecorator.title == title) + #expect(itemDecorator.previewImage == nil) + #expect(itemDecorator.thumbnailImage == nil) + } + + @Test("RTF") + func rtf() { + let rtf = NSAttributedString(string: "foo").rtf( + from: NSRange(0...2), + documentAttributes: [:] ) - ] - 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) - - return lowerBound.. 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: 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) + } + + // 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 c856a825..537a4fc7 100644 --- a/MaccyTests/HistoryTests.swift +++ b/MaccyTests/HistoryTests.swift @@ -1,418 +1,520 @@ -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 savedPinTo = Defaults[.pinTo] - let history = History.shared - - override func setUp() { - super.setUp() - history.clearAll() - Defaults[.size] = 10 - Defaults[.sortBy] = .firstCopiedAt - Defaults[.pinTo] = .bottom - } +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 + } - override func tearDown() { - super.tearDown() - Defaults[.size] = savedSize - Defaults[.sortBy] = savedSortBy - Defaults[.pinTo] = savedPinTo - } + deinit { + Defaults[.size] = savedSize + Defaults[.sortBy] = savedSortBy + Defaults[.pinOrder] = savedPinOrder + Defaults[.pinTo] = savedPinTo + } - func testDefaultIsEmpty() { - XCTAssertEqual(history.items, []) - } + @Test("Default is empty") + func defaultIsEmpty() { + #expect(history.items.toArray() == []) + } - func testAdding() { - let first = history.add(historyItem("foo")) - let second = history.add(historyItem("bar")) - XCTAssertEqual(history.items, [second, first]) - } + @Test("Adding") + func adding() { + let first = history.add(historyItem("foo")) + let second = history.add(historyItem("bar")) + #expect(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.all, [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 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 testAddingDuplicateKeepsAgentFields() { - let first = historyItem("foo") - first.label = "session-a" - first.note = "run in DataGrip" - history.add(first) - let uid = first.uid + @Test("Adding duplicate keeps agent fields") + func addingDuplicateKeepsAgentFields() { + let first = historyItem("foo") + first.label = "session-a" + first.note = "run in DataGrip" + history.add(first) + let uid = first.uid - let merged = history.add(historyItem("foo")) + let merged = history.add(historyItem("foo")) - XCTAssertNotNil(uid) - XCTAssertEqual(merged.item.uid, uid) - XCTAssertEqual(merged.item.label, "session-a") - XCTAssertEqual(merged.item.note, "run in DataGrip") - } + #expect(uid != nil) + #expect(merged.item.uid == uid) + #expect(merged.item.label == "session-a") + #expect(merged.item.note == "run in DataGrip") + } - func testHistoryAPIAddUpdateListDelete() async throws { - let added = try await HistoryAPI.handle( - ["cmd": "add", "text": "SELECT 1;", "label": "session-a", "note": "dev db"]) as? [String: Any] - let id = try XCTUnwrap(added?["id"] as? String) - XCTAssertEqual(added?["label"] as? String, "session-a") + @Test("History API add, update, list, delete") + func historyAPIAddUpdateListDelete() async throws { + let added = try await HistoryAPI.handle( + ["cmd": "add", "text": "SELECT 1;", "label": "session-a", "note": "dev db"]) as? [String: Any] + let id = try #require(added?["id"] as? String) + #expect(added?["label"] as? String == "session-a") - let updated = try await HistoryAPI.handle(["cmd": "update", "id": id, "note": ""]) as? [String: Any] - XCTAssertTrue(updated?["note"] is NSNull) + let updated = try await HistoryAPI.handle(["cmd": "update", "id": id, "note": ""]) as? [String: Any] + #expect(updated?["note"] is NSNull) - let listed = try await HistoryAPI.handle(["cmd": "list", "label": "SESSION"]) as? [[String: Any]] - XCTAssertEqual(listed?.map { $0["id"] as? String }, [id]) + let listed = try await HistoryAPI.handle(["cmd": "list", "label": "SESSION"]) as? [[String: Any]] + #expect(listed?.map { $0["id"] as? String } == [id]) - _ = try await HistoryAPI.handle(["cmd": "delete", "id": id]) - XCTAssertEqual(history.all.count, 0) - do { - _ = try await HistoryAPI.handle(["cmd": "update", "id": id, "label": "x\ny"]) - XCTFail("expected an error") - } catch {} - } + _ = try await HistoryAPI.handle(["cmd": "delete", "id": id]) + #expect(history.all.isEmpty) + await #expect(throws: (any Error).self) { + _ = try await HistoryAPI.handle(["cmd": "update", "id": id, "label": "x\ny"]) + } + } - func testAddingUnsavedDuplicate() throws { - guard #available(macOS 15.0, *) else { - throw XCTSkip("Incoming history items are inserted before add on macOS 14") + @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) } - 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.all, [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 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 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, [second]) - XCTAssertEqual(Set(history.items[0].item.contents), Set(firstContents)) - try assertStorageCounts(items: 1, contents: firstContents.count) - } + @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)) + } - 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( + @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)) + } + + @Test("Modified after copying") + func modifiedAfterCopying() { + history.add(historyItem("foo")) + + let modifiedItem = historyItem("bar") + modifiedItem.contents.append(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, [second]) - XCTAssertEqual(Set(history.items[0].item.contents), Set(firstContents)) - } + value: String(Clipboard.shared.changeCount).data(using: .utf8) + )) + let modifiedItemDecorator = history.add(modifiedItem) - 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( + #expect(history.items.toArray() == [modifiedItemDecorator]) + #expect(history.items[0].text == "bar") + } + + @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: "one".data(using: .utf8) - ), - HistoryItemContent( - type: NSPasteboard.PasteboardType.fromMaccy.rawValue, - value: "".data(using: .utf8) + value: "orphan".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, [secondDecorator]) - XCTAssertEqual(history.items[0].item.application, "Xcode.app") - XCTAssertEqual(Set(history.items[0].item.contents), Set(firstContents)) - } + Storage.shared.context.insert(orphan) + try Storage.shared.context.save() - func testModifiedAfterCopying() { - history.add(historyItem("foo")) + history.clear() - 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) + #expect(history.items.toArray() == [pinned]) + try assertStorageCounts(items: 1, contents: 1) + } - XCTAssertEqual(history.items, [modifiedItemDecorator]) - XCTAssertEqual(history.items[0].text, "bar") - } + @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() - func testClearingUnpinned() throws { - let pinned = history.add(historyItem("foo")) - pinned.togglePin() - 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, [pinned]) - try assertStorageCounts(items: 1, contents: 1) - } + history.clearAll() - func testClearingAll() throws { - history.add(historyItem("foo")) - let pinned = history.add(historyItem("bar")) - pinned.togglePin() - Storage.shared.context.insert(HistoryItemContent( - type: NSPasteboard.PasteboardType.string.rawValue, - value: "orphan".data(using: .utf8) - )) - try Storage.shared.context.save() + #expect(history.items.toArray() == []) + try assertStorageCounts(items: 0, contents: 0) + } - history.clearAll() + @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.items, []) - try assertStorageCounts(items: 0, contents: 0) - } + @Test("Max size ignores pinned") + func maxSizeIgnoresPinned() { + var items: [HistoryItemDecorator] = [] - func testMaxSize() throws { - var items: [HistoryItemDecorator] = [] - for index in 0...10 { - items.append(history.add(historyItem(String(index)))) - } + let item = history.add(historyItem("0")) + items.append(item) + history.togglePin(item) - XCTAssertEqual(history.items.count, 10) - XCTAssertTrue(history.items.contains(items[10])) - XCTAssertFalse(history.items.contains(items[0])) - try assertStorageCounts(items: 10, contents: 10) - } + for index in 1...11 { + items.append(history.add(historyItem(String(index)))) + } - func testMaxSizeIgnoresPinned() { - var items: [HistoryItemDecorator] = [] + #expect(history.items.count == 11) + #expect(history.items.contains(items[10])) + #expect(history.items.contains(items[0])) + #expect(!history.items.contains(items[1])) + } - let item = history.add(historyItem("0")) - items.append(item) - item.togglePin() + @Test("Pinning updates pin order") + func pinningUpdatesPinOrder() { + let item = history.add(historyItem("foo")) + history.togglePin(item) - for index in 1...11 { - items.append(history.add(historyItem(String(index)))) + #expect(history.pinnedItems == [item]) + #expect(Defaults[.pinOrder].pins == [item.item.pin].compactMap { $0 }) } - XCTAssertEqual(history.items.count, 11) - XCTAssertTrue(history.items.contains(items[10])) - XCTAssertTrue(history.items.contains(items[0])) - XCTAssertFalse(history.items.contains(items[1])) - } + @Test("Pinning after all shortcuts are assigned") + func pinningAfterAllShortcutsAreAssigned() 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 - ) - } + @Test("Cleaning up orphaned contents") + func cleaningUpOrphanedContents() throws { + let live = history.add(historyItem("live")) + let liveContent = live.item.contents[0] + for value in ["orphan-1", "orphan-2"] { + Storage.shared.context.insert(HistoryItemContent( + type: NSPasteboard.PasteboardType.string.rawValue, + value: value.data(using: .utf8) + )) + } + try Storage.shared.context.save() + + #expect(try Storage.shared.cleanupOrphanedContents() == 2) + #expect(try Storage.shared.cleanupOrphanedContents() == 0) + #expect(live.item.contents == [liveContent]) + 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) + @Test("Removing unpersistable contents") + func removingUnpersistableContents() 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() + + #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 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 e1bc5327..f64828e8 100644 --- a/MaccyTests/SearchTests.swift +++ b/MaccyTests/SearchTests.swift @@ -1,264 +1,256 @@ -import XCTest +import AppKit +import Testing import Defaults @testable import Maccy -class SearchTests: XCTestCase { - let savedSearchMode = Defaults[.searchMode] - var items: [Search.Searchable]! - - override func tearDown() { - super.tearDown() - Defaults[.searchMode] = savedSearchMode - } - +extension SharedStateTests { @MainActor - 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")) - ] + final class SearchTests { + let savedSearchMode = Defaults[.searchMode] + let items: [Search.Searchable] - 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")) + ] + } - @MainActor - 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") == []) + } - @MainActor - 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 aec8a7a2..cf072c1b 100644 --- a/MaccyTests/ShortenedTests.swift +++ b/MaccyTests/ShortenedTests.swift @@ -1,26 +1,28 @@ -import XCTest +import Testing @testable import Maccy -final class ShortenedTests: XCTestCase { - func testShortenedReturnsAtMostMaxLength() { - XCTAssertEqual("abcdef".shortened(to: 3), "abc") +@MainActor +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 6fb9619f..c40b674f 100644 --- a/MaccyTests/SorterTests.swift +++ b/MaccyTests/SorterTests.swift @@ -1,70 +1,78 @@ -import XCTest +import Foundation +import Testing import Defaults @testable import Maccy -class SorterTests: XCTestCase { - 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 - @MainActor - override func setUp() { - 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) - } + 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[.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]) + } - @MainActor - 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 85d50848..e27b42a5 100644 --- a/MaccyTests/UnsafeForTitleLayoutTests.swift +++ b/MaccyTests/UnsafeForTitleLayoutTests.swift @@ -1,68 +1,61 @@ -import XCTest +import Testing @testable import Maccy -final class UnsafeForTitleLayoutTests: XCTestCase { - private let objectReplacement = "\u{FFFC}" +private let objectReplacement = "\u{FFFC}" - func testRemovesLeadingOccurrence() { - XCTAssertEqual((objectReplacement + "foo").removingScalarsUnsafeForTitleLayout(), "foo") - } - - 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 1186cd55..1c33fd7f 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,17 +160,47 @@ 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) } + 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) popUpWithMouse() - XCTAssertEqual(itemTitles[0...1], [ + XCTAssertEqual(Array(itemTitles.prefix(2)), [ file1.absoluteString.removingPercentEncoding!, file2.absoluteString.removingPercentEncoding! ]) @@ -182,7 +215,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 +225,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 +320,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 +333,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 +341,21 @@ 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 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() { @@ -329,7 +376,7 @@ class MaccyUITests: XCTestCase { func testDisablesOnOptionClickingMenubarIcon() { XCUIElement.perform(withKeyModifiers: .option) { - app.statusItems.firstMatch.click() + clickStatusItem() } let copy3 = UUID().uuidString @@ -343,13 +390,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 +557,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)! @@ -567,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) @@ -593,6 +743,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()