From 2e9d60b4b80dd37420bfd623590f25a47c51f761 Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Sun, 31 May 2026 23:18:19 -0700 Subject: [PATCH 1/2] Smarter emoji completion: recents, synonyms, fuzzy, popularity Make the inline :emoji: picker rank like a chat app instead of a raw substring search. - EmojiUsageStore: persisted recents + frequency keyed by primary alias (variant-stable). A bare ":" now shows recents (popularity-padded) instead of an empty panel; personal favorites float up within a relevance tier. - EmojiSynonymCatalog: curated intent/slang -> alias map (lol, ty, fire, congrats, ...) so intent ranks first. - EmojiMatcher: synonym pre-tier + fuzzy fallback (subsequence + OSA edit distance for typos) + popularity tiebreak, preserving existing relevance order. - EmojiPopularity: curated popularity prior, validated against the dataset. - Clear Emoji History control in General settings. EmojiUsageStore uses a nonisolated deinit to avoid the macOS 14 isolated-deinit back-deploy crash that aborts the app-hosted tests. New pure helpers are unit tested; regenerated the Xcode project for the new files. --- Cotabby.xcodeproj/project.pbxproj | 36 +++ .../Coordinators/EmojiPickerController.swift | 47 ++- .../Coordinators/SettingsCoordinator.swift | 8 +- Cotabby/App/Core/CotabbyAppEnvironment.swift | 13 +- Cotabby/Models/EmojiUsageModels.swift | 31 ++ Cotabby/Models/EmojiUsageStore.swift | 92 ++++++ Cotabby/Support/EmojiCatalog.swift | 21 +- Cotabby/Support/EmojiMatcher.swift | 270 +++++++++++++++--- Cotabby/Support/EmojiPopularity.swift | 94 ++++++ Cotabby/Support/EmojiRecents.swift | 42 +++ Cotabby/Support/EmojiSynonymCatalog.swift | 225 +++++++++++++++ .../UI/Settings/Panes/GeneralPaneView.swift | 12 + .../UI/Settings/SettingsContainerView.swift | 4 +- CotabbyTests/EmojiCatalogMatcherTests.swift | 68 +++++ CotabbyTests/EmojiPickerControllerTests.swift | 33 ++- CotabbyTests/EmojiPopularityTests.swift | 33 +++ CotabbyTests/EmojiRecentsTests.swift | 47 +++ CotabbyTests/EmojiSynonymCatalogTests.swift | 34 +++ CotabbyTests/EmojiUsageStoreTests.swift | 105 +++++++ 19 files changed, 1151 insertions(+), 64 deletions(-) create mode 100644 Cotabby/Models/EmojiUsageModels.swift create mode 100644 Cotabby/Models/EmojiUsageStore.swift create mode 100644 Cotabby/Support/EmojiPopularity.swift create mode 100644 Cotabby/Support/EmojiRecents.swift create mode 100644 Cotabby/Support/EmojiSynonymCatalog.swift create mode 100644 CotabbyTests/EmojiPopularityTests.swift create mode 100644 CotabbyTests/EmojiRecentsTests.swift create mode 100644 CotabbyTests/EmojiSynonymCatalogTests.swift create mode 100644 CotabbyTests/EmojiUsageStoreTests.swift diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index 2e7f3df..f9b4f9c 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -30,8 +30,10 @@ 0F3267956257401F39386773 /* SuggestionOverlayStabilityGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2F95847D76893C8A5B504B4 /* SuggestionOverlayStabilityGate.swift */; }; 0FF600435A2EFA9437E36B6F /* EmojiVariantResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A8414BEB7E34F57607E37FE /* EmojiVariantResolver.swift */; }; 1003373E13779882503C0E9D /* DisplayCoordinateConverter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74BD1D4DB27D5D96D1E06096 /* DisplayCoordinateConverter.swift */; }; + 1059A308A31A94576FF29CF0 /* EmojiRecents.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E72A3972E15749337539C2D /* EmojiRecents.swift */; }; 12995E5DDB11E3395E6AF82F /* ShortcutsPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB630F9814388203DD1CA2EC /* ShortcutsPaneView.swift */; }; 1450746C690B3D98203B71EC /* DeviceInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29ED42C4BDD0C521101AF95E /* DeviceInfo.swift */; }; + 14C55DC5096F003BD3D2917D /* EmojiPopularityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023144451BB30F981D1F9EE6 /* EmojiPopularityTests.swift */; }; 14D77F0B8A195AC2FA8D24A9 /* MirrorOverlayLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC83D14A7557BC0196E59007 /* MirrorOverlayLayoutTests.swift */; }; 156E6AB3D24134EEC29FDB93 /* FocusSnapshotResolverSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA705EDFE1C41294F0E381F1 /* FocusSnapshotResolverSelectionTests.swift */; }; 157A55EB796BEB7819B90D5D /* ClipboardRelevanceFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A2AC525DC664DB540D4F19 /* ClipboardRelevanceFilter.swift */; }; @@ -48,7 +50,9 @@ 258EAFB0292290C88520E915 /* SystemSettingsWindowLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5976600F428C1265121D4C0C /* SystemSettingsWindowLocator.swift */; }; 25D4FC8D191A50F63E6391F9 /* ModelAndPresentationValueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03766F6253FF17639230C0F6 /* ModelAndPresentationValueTests.swift */; }; 25F91CEF38400FD1ADB6B1AF /* CompletionRenderModePolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D504BEB224E0C176F5FCFF6E /* CompletionRenderModePolicyTests.swift */; }; + 26C5604C3CEF43FC755FD24E /* EmojiUsageStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 224438039A86E5619294EAF7 /* EmojiUsageStoreTests.swift */; }; 26E0331E9E2F92FAE531BDEE /* ActivationIndicatorController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D84D4528EEC9EFEB8AE8E318 /* ActivationIndicatorController.swift */; }; + 279F017530A86AF62EB17918 /* EmojiSynonymCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0846DE4E0293AF13890620D3 /* EmojiSynonymCatalog.swift */; }; 27D4F5CACADE171F142178B4 /* SettingsSidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADB38D0160B47637572FC5E /* SettingsSidebarView.swift */; }; 286B7022E2A2774275004447 /* WelcomeTemplateStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9199B9CEAB320982CA333B8 /* WelcomeTemplateStepView.swift */; }; 2C6159231472A849F15BD0AE /* ScreenFrameReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5484C8A04B9C00CF79D589EB /* ScreenFrameReader.swift */; }; @@ -68,6 +72,7 @@ 39571AB31481959CD5C223AE /* PermissionsPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7113D3373525113CA69E7597 /* PermissionsPaneView.swift */; }; 3985F0F2B3178DBB945B1064 /* CompletionRenderModePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53CF416511099C6818110F01 /* CompletionRenderModePolicy.swift */; }; 3B3E08D1204E85F3776D8853 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = BAADA69C6172DD7F4A642E93 /* Sparkle */; }; + 3B5F96F9CC6D4D81B470DB2C /* EmojiSynonymCatalogTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BF474064973F4752F79BB041 /* EmojiSynonymCatalogTests.swift */; }; 3C23336EE6F6559857DE92EE /* SuggestionDebugLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 003594B09C83EF2DF35577D5 /* SuggestionDebugLogger.swift */; }; 3CBBC3BFAC0DC8952EE24EF7 /* BundledRuntimeLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA33F5FFAC5B99384E15CE3E /* BundledRuntimeLocator.swift */; }; 3CF1A4E39F24917DF0470A7D /* PromptPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4696A84D17890B154533A08F /* PromptPolicyTests.swift */; }; @@ -93,6 +98,7 @@ 52518CF0760DFEE9AF7C786C /* SuggestionEngineRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 384FBCF5D7A3A446C5BE2B8D /* SuggestionEngineRouter.swift */; }; 53FB56A095BCF0389DAC0A56 /* SuggestionTextColorCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CE61E74928C221B8BB261C6 /* SuggestionTextColorCodec.swift */; }; 54BDF0D9C3DC7175555BD0F6 /* LlamaRuntimeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A52D0B550E00EF173A5D157E /* LlamaRuntimeManager.swift */; }; + 54E515A0E75B3902E6497A71 /* EmojiPopularity.swift in Sources */ = {isa = PBXBuildFile; fileRef = E27B962C66727776D00069DE /* EmojiPopularity.swift */; }; 56611BA0087710277140E9E6 /* DisplayCoordinateConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1C5DE0F3FF63545000E2453 /* DisplayCoordinateConverterTests.swift */; }; 58AC3193D846FDE88513377D /* BundledRuntimeLocatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18D990E515E1AE4F312F4E95 /* BundledRuntimeLocatorTests.swift */; }; 5A441797D71A880A7482077D /* TextDirectionDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC24FD54860CE6737E65EF65 /* TextDirectionDetectorTests.swift */; }; @@ -133,6 +139,7 @@ 7E9413CE7C999C4612348248 /* SuggestionSessionReconcilerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9C8F07AC52C7A482F5FE34C5 /* SuggestionSessionReconcilerTests.swift */; }; 7E99F5676A1D1DF7EA7D7702 /* SuggestionCoordinator+Prediction.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ED1EA9282E0AC7592E60889 /* SuggestionCoordinator+Prediction.swift */; }; 7FC103944F4EF39DB965F469 /* InMemoryLogging in Frameworks */ = {isa = PBXBuildFile; productRef = 88921938DC814625ED57D605 /* InMemoryLogging */; }; + 814E348C663B697537594F0C /* EmojiRecentsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 671689F289D45A124639C9C6 /* EmojiRecentsTests.swift */; }; 82D4ADEAF05337ABDE4C586C /* RuntimeBootstrapModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60629DFE309C1A4BD8A7FB3B /* RuntimeBootstrapModel.swift */; }; 83EC3543DC45B1601F119BF9 /* InsertionSafetyGateTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43D627C4A55359EAF4676FF7 /* InsertionSafetyGateTests.swift */; }; 8441299082E6B68F7F88911B /* ShortcutConflictTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0850B07CCDBA67C756C6EC59 /* ShortcutConflictTests.swift */; }; @@ -152,6 +159,7 @@ 91D8189EFCD1BA992EA6F038 /* ConfidenceSuppressionPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06FF2B0A3094A952A8EBA9B5 /* ConfidenceSuppressionPolicyTests.swift */; }; 924489CEE8171F7AD8579D71 /* FocusDebugOverlayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F5E263AB69029D5E13D5EE8 /* FocusDebugOverlayController.swift */; }; 934885ACC2DEA20B27F10948 /* PromptContextSanitizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D80CC2CCAAFE3F23FB8C37A /* PromptContextSanitizerTests.swift */; }; + 959439B4785B996CE6D89944 /* EmojiUsageModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC48B188C6E6E263B876621D /* EmojiUsageModels.swift */; }; 96498E097A5899AFC9F0C853 /* EmojiCatalogMatcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 292DC9D4D9D5D26AE882E39B /* EmojiCatalogMatcherTests.swift */; }; 96782E57CA26A16409368B69 /* TextDirectionDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 328847A0F494360033366791 /* TextDirectionDetector.swift */; }; 9706D778FB549E9E7AE05F4F /* EmojiMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1441B2D89DAE6878DAD11F17 /* EmojiMatcher.swift */; }; @@ -219,6 +227,7 @@ E912D4617AE1376061DF1F00 /* LanguageSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4793D4EA5D36D7E5CC216C27 /* LanguageSupportTests.swift */; }; E994FE418A961FB234D9057A /* DownloadFileRescuerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2F46767D9D1F0D44E239CA8 /* DownloadFileRescuerTests.swift */; }; E9E4CC657771DF9F4C56183C /* VisualContextCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A854CAFB1F557BC4CAED8819 /* VisualContextCoordinator.swift */; }; + EB13A392BFA5349DD8A0DD25 /* EmojiUsageStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE35C7770405ED368AA02448 /* EmojiUsageStore.swift */; }; ED0843752B297D7E9DB2C468 /* EmojiTriggerStateMachineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 723E1EFA85D2E61B6C5F33E8 /* EmojiTriggerStateMachineTests.swift */; }; ED9C51B0D7056F0753AADF2D /* GhostSuggestionLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 043E8AA850F930222DD112C0 /* GhostSuggestionLayout.swift */; }; EDA8E8250FC2F70B206B4894 /* LlamaVisualContextSummarizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D2782B6C7BE3F56BCB22DE /* LlamaVisualContextSummarizer.swift */; }; @@ -253,6 +262,7 @@ 003594B09C83EF2DF35577D5 /* SuggestionDebugLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionDebugLogger.swift; sourceTree = ""; }; 00824BDD8D0E9B3063827C78 /* MenuBarPresentationObserver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarPresentationObserver.swift; sourceTree = ""; }; 01B72736E416910878E8E493 /* OnboardingTemplateRecommenderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTemplateRecommenderTests.swift; sourceTree = ""; }; + 023144451BB30F981D1F9EE6 /* EmojiPopularityTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPopularityTests.swift; sourceTree = ""; }; 03766F6253FF17639230C0F6 /* ModelAndPresentationValueTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelAndPresentationValueTests.swift; sourceTree = ""; }; 043E8AA850F930222DD112C0 /* GhostSuggestionLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhostSuggestionLayout.swift; sourceTree = ""; }; 04D853218B0A77B0CE090828 /* BrowserAppDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserAppDetectorTests.swift; sourceTree = ""; }; @@ -261,6 +271,7 @@ 05E051F74207D1C9A7D2B991 /* VisualContextSummaryPromptRendererTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualContextSummaryPromptRendererTests.swift; sourceTree = ""; }; 06FF2B0A3094A952A8EBA9B5 /* ConfidenceSuppressionPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConfidenceSuppressionPolicyTests.swift; sourceTree = ""; }; 07480CE96ED0EBD94817C6B1 /* GeneralPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeneralPaneView.swift; sourceTree = ""; }; + 0846DE4E0293AF13890620D3 /* EmojiSynonymCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiSynonymCatalog.swift; sourceTree = ""; }; 0850B07CCDBA67C756C6EC59 /* ShortcutConflictTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutConflictTests.swift; sourceTree = ""; }; 09FADF683BE7B3558377FA76 /* FocusPollBackoff.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusPollBackoff.swift; sourceTree = ""; }; 0A3D1125B962CBE0269EEDDB /* SuggestionInserter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionInserter.swift; sourceTree = ""; }; @@ -284,6 +295,7 @@ 1ED1EA9282E0AC7592E60889 /* SuggestionCoordinator+Prediction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionCoordinator+Prediction.swift"; sourceTree = ""; }; 21CB3008986BE7FD2A4D9132 /* WelcomeCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeCoordinator.swift; sourceTree = ""; }; 220CD4AFA1E96A37BC4514AD /* LaunchAtLoginService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchAtLoginService.swift; sourceTree = ""; }; + 224438039A86E5619294EAF7 /* EmojiUsageStoreTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiUsageStoreTests.swift; sourceTree = ""; }; 22544F4B756E3E4144497D17 /* SuggestionCoordinator+Input.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SuggestionCoordinator+Input.swift"; sourceTree = ""; }; 24F613F0E2F7046E6532A09C /* OnboardingTemplateFeatureList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTemplateFeatureList.swift; sourceTree = ""; }; 262BE2F1E97389FE8D7A5FB9 /* Cotabby.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Cotabby.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -341,6 +353,7 @@ 62BD2ADED33249F5BA53D0AD /* EmojiPickerControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerControllerTests.swift; sourceTree = ""; }; 62EDF1199CC5E18BD7651661 /* EmojiPickerPanelLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerPanelLayout.swift; sourceTree = ""; }; 656F58E56FE9BC087B6F1D33 /* PermissionReminderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionReminderView.swift; sourceTree = ""; }; + 671689F289D45A124639C9C6 /* EmojiRecentsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiRecentsTests.swift; sourceTree = ""; }; 67586807ACE8EB13C9014535 /* TickMarkSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TickMarkSlider.swift; sourceTree = ""; }; 6A44BEC8C23FF227731DD0CD /* FocusCapabilityFlickerGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusCapabilityFlickerGate.swift; sourceTree = ""; }; 6B2D97BAA3618A7D0357AC44 /* SuggestionWorkController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionWorkController.swift; sourceTree = ""; }; @@ -389,6 +402,7 @@ 9D598CC3134879999D567455 /* SuggestionOverlayPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionOverlayPresenter.swift; sourceTree = ""; }; 9D82FFC568527700EC17C07D /* PermissionModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionModels.swift; sourceTree = ""; }; 9E5F074ED7E340E9B9E4C5E0 /* EmojiPickerModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerModels.swift; sourceTree = ""; }; + 9E72A3972E15749337539C2D /* EmojiRecents.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiRecents.swift; sourceTree = ""; }; A168A7B6A7AD11559B60C56B /* ApplicationBundleMetadataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ApplicationBundleMetadataTests.swift; sourceTree = ""; }; A3E8E86A14090BC7BD13BA76 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; A3FA53BBC3D81503C1D17477 /* AboutPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutPaneView.swift; sourceTree = ""; }; @@ -423,6 +437,7 @@ BD42C7E2852F59BEF7972663 /* MenuBarStatusLabelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarStatusLabelView.swift; sourceTree = ""; }; BE04620C905041680116BE80 /* LlamaSuggestionEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaSuggestionEngine.swift; sourceTree = ""; }; BE97A8169438D593C6C23412 /* VisualContextModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualContextModels.swift; sourceTree = ""; }; + BF474064973F4752F79BB041 /* EmojiSynonymCatalogTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiSynonymCatalogTests.swift; sourceTree = ""; }; BF4BB93056F291FD24EFAD22 /* LanguageCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageCatalog.swift; sourceTree = ""; }; C046CB4F3CB4BFE9391DB5DE /* AXTextGeometryResolverTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AXTextGeometryResolverTests.swift; sourceTree = ""; }; C05B0439348261163B37C508 /* SuggestionAvailabilityEvaluatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAvailabilityEvaluatorTests.swift; sourceTree = ""; }; @@ -459,6 +474,7 @@ E19A5B462891263BDFB56607 /* TrailingDuplicationFilterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrailingDuplicationFilterTests.swift; sourceTree = ""; }; E1D2782B6C7BE3F56BCB22DE /* LlamaVisualContextSummarizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaVisualContextSummarizer.swift; sourceTree = ""; }; E217A66717D78E1E49350EC8 /* DownloadOutcomeClassifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadOutcomeClassifierTests.swift; sourceTree = ""; }; + E27B962C66727776D00069DE /* EmojiPopularity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPopularity.swift; sourceTree = ""; }; E3C84377F352140759B448C9 /* CaretGeometrySelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaretGeometrySelector.swift; sourceTree = ""; }; E43E587E421AF544A8300CE4 /* CustomRulesCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomRulesCatalog.swift; sourceTree = ""; }; E5DAF68AEBFE334F68A65E82 /* AcceptanceModePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcceptanceModePickerView.swift; sourceTree = ""; }; @@ -475,8 +491,10 @@ FA878B447441BB4F3E327CC8 /* OnboardingTemplateRecommender.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTemplateRecommender.swift; sourceTree = ""; }; FB317C82CE2CBC69056BA4B8 /* TagChip.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TagChip.swift; sourceTree = ""; }; FC24FD54860CE6737E65EF65 /* TextDirectionDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextDirectionDetectorTests.swift; sourceTree = ""; }; + FC48B188C6E6E263B876621D /* EmojiUsageModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiUsageModels.swift; sourceTree = ""; }; FC83D14A7557BC0196E59007 /* MirrorOverlayLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MirrorOverlayLayoutTests.swift; sourceTree = ""; }; FC9ECD5408B0F5708149B5C0 /* EngineAndModelPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EngineAndModelPaneView.swift; sourceTree = ""; }; + FE35C7770405ED368AA02448 /* EmojiUsageStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiUsageStore.swift; sourceTree = ""; }; FE7BF162A12703249726F20A /* FoundationModelPromptRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FoundationModelPromptRenderer.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -667,6 +685,8 @@ 5A03E565A11581FD2150B142 /* CompletionRenderMode.swift */, E43E587E421AF544A8300CE4 /* CustomRulesCatalog.swift */, 9E5F074ED7E340E9B9E4C5E0 /* EmojiPickerModels.swift */, + FC48B188C6E6E263B876621D /* EmojiUsageModels.swift */, + FE35C7770405ED368AA02448 /* EmojiUsageStore.swift */, 0C383AE85B971A9605787358 /* FocusModels.swift */, B6D42CD456B4B3C988B148A6 /* FocusTrackingModel.swift */, A520809E71697E3BB9A8139C /* HuggingFaceModels.swift */, @@ -714,8 +734,12 @@ 292DC9D4D9D5D26AE882E39B /* EmojiCatalogMatcherTests.swift */, 62BD2ADED33249F5BA53D0AD /* EmojiPickerControllerTests.swift */, B7B185BA246A526CBA85E581 /* EmojiPickerPanelLayoutTests.swift */, + 023144451BB30F981D1F9EE6 /* EmojiPopularityTests.swift */, 75396860978E81EFAA506CD4 /* EmojiQueryRunTests.swift */, + 671689F289D45A124639C9C6 /* EmojiRecentsTests.swift */, + BF474064973F4752F79BB041 /* EmojiSynonymCatalogTests.swift */, 723E1EFA85D2E61B6C5F33E8 /* EmojiTriggerStateMachineTests.swift */, + 224438039A86E5619294EAF7 /* EmojiUsageStoreTests.swift */, EE8BB19D8EC9A75CD3458A6B /* EmojiVariantResolverTests.swift */, 54BC85605541E913EE57B258 /* ExtendedContextTests.swift */, 1972BD2C5254D8266618781F /* FocusCapabilityFlickerGateTests.swift */, @@ -866,7 +890,10 @@ 0AC3BF78835C8F2C315932F1 /* EmojiCatalog.swift */, 1441B2D89DAE6878DAD11F17 /* EmojiMatcher.swift */, 62EDF1199CC5E18BD7651661 /* EmojiPickerPanelLayout.swift */, + E27B962C66727776D00069DE /* EmojiPopularity.swift */, DDF6A4E9CE93FD53C60E67E3 /* EmojiQueryRun.swift */, + 9E72A3972E15749337539C2D /* EmojiRecents.swift */, + 0846DE4E0293AF13890620D3 /* EmojiSynonymCatalog.swift */, 312C7306D916963F519CE0D9 /* EmojiTriggerStateMachine.swift */, 1A8414BEB7E34F57607E37FE /* EmojiVariantResolver.swift */, CE8C2569A8217EE9BD3B197F /* FileLogHandler.swift */, @@ -1066,8 +1093,13 @@ B6652D81162C64248AA4CF0B /* EmojiPickerPanelController.swift in Sources */, 902B83CCB82E286FBEB9DAAD /* EmojiPickerPanelLayout.swift in Sources */, FBEDA005ECD53CD645CD4C64 /* EmojiPickerView.swift in Sources */, + 54E515A0E75B3902E6497A71 /* EmojiPopularity.swift in Sources */, 3112A355E61878A6A6D1FDF8 /* EmojiQueryRun.swift in Sources */, + 1059A308A31A94576FF29CF0 /* EmojiRecents.swift in Sources */, + 279F017530A86AF62EB17918 /* EmojiSynonymCatalog.swift in Sources */, 4BFE7446533386550C839F25 /* EmojiTriggerStateMachine.swift in Sources */, + 959439B4785B996CE6D89944 /* EmojiUsageModels.swift in Sources */, + EB13A392BFA5349DD8A0DD25 /* EmojiUsageStore.swift in Sources */, 0FF600435A2EFA9437E36B6F /* EmojiVariantResolver.swift in Sources */, 5DF31F465A3A1233260BD3A4 /* EngineAndModelPaneView.swift in Sources */, 5BE53CE921664582F593B7B0 /* FieldEdgeIconIndicatorView.swift in Sources */, @@ -1212,8 +1244,12 @@ 96498E097A5899AFC9F0C853 /* EmojiCatalogMatcherTests.swift in Sources */, 0B6E28D1CBDF657F71548A3C /* EmojiPickerControllerTests.swift in Sources */, D46A0DB70B07F487431F48F6 /* EmojiPickerPanelLayoutTests.swift in Sources */, + 14C55DC5096F003BD3D2917D /* EmojiPopularityTests.swift in Sources */, 0D15CBF45EB1DB725B9F1A6A /* EmojiQueryRunTests.swift in Sources */, + 814E348C663B697537594F0C /* EmojiRecentsTests.swift in Sources */, + 3B5F96F9CC6D4D81B470DB2C /* EmojiSynonymCatalogTests.swift in Sources */, ED0843752B297D7E9DB2C468 /* EmojiTriggerStateMachineTests.swift in Sources */, + 26C5604C3CEF43FC755FD24E /* EmojiUsageStoreTests.swift in Sources */, C9B815652CED38966C53A5E8 /* EmojiVariantResolverTests.swift in Sources */, 63054CBDCA87560130BF5ADC /* ExtendedContextTests.swift in Sources */, 78A8713A0E5B4C89E2D715BC /* FocusCapabilityFlickerGateTests.swift in Sources */, diff --git a/Cotabby/App/Coordinators/EmojiPickerController.swift b/Cotabby/App/Coordinators/EmojiPickerController.swift index acdc25a..ecd62ff 100644 --- a/Cotabby/App/Coordinators/EmojiPickerController.swift +++ b/Cotabby/App/Coordinators/EmojiPickerController.swift @@ -28,6 +28,10 @@ final class EmojiPickerController { private let emojiPreferences: () -> EmojiVariantPreferences /// The accept-word key label shown as a keycap on the highlighted row; `nil` hides the hint. private let acceptKeyLabel: () -> String? + /// Live personal usage snapshot, read at match time to rank favorites and seed the bare-`:` panel. + private let emojiUsage: () -> EmojiUsageSnapshot + /// Records a committed emoji's primary alias so future ranking and recents reflect it. + private let recordEmojiUsage: (String) -> Void private var currentQuery = "" private var matches: [EmojiMatch] = [] @@ -58,7 +62,9 @@ final class EmojiPickerController { inserter: any EmojiTextInserting, isEnabled: @escaping () -> Bool, emojiPreferences: @escaping () -> EmojiVariantPreferences, - acceptKeyLabel: @escaping () -> String? + acceptKeyLabel: @escaping () -> String?, + emojiUsage: @escaping () -> EmojiUsageSnapshot, + recordEmojiUsage: @escaping (String) -> Void ) { self.matcher = matcher self.panel = panel @@ -68,6 +74,8 @@ final class EmojiPickerController { self.isEnabled = isEnabled self.emojiPreferences = emojiPreferences self.acceptKeyLabel = acceptKeyLabel + self.emojiUsage = emojiUsage + self.recordEmojiUsage = recordEmojiUsage } func start() { @@ -251,8 +259,10 @@ final class EmojiPickerController { cancelCapture() return } - let glyph = matches[selectedIndex].glyph + let selected = matches[selectedIndex] + let glyph = selected.glyph let fallback = currentQuery.utf16.count + 1 // ":" + query + recordUsage(for: selected) CotabbyLogger.suggestion.debug("emoji commit (key) glyph=\(glyph) query=\"\(currentQuery)\"") teardownCapture() scheduleReplaceEmojiQuery(with: glyph, fallbackUTF16: fallback) @@ -262,11 +272,19 @@ final class EmojiPickerController { /// defer one runloop tick, then measure and replace the whole run (EMOJI.md ยง3.2, ยง5.5). private func commitClosingColon() { let query = currentQuery - let glyph = bestGlyphForClosingColon(query: query) + let match = bestMatchForClosingColon(query: query) let fallback = query.utf16.count + 2 // ":" + query + ":" teardownCapture() - guard let glyph else { return } // no match: leave the literal ":query:" untouched - scheduleReplaceEmojiQuery(with: glyph, fallbackUTF16: fallback) + guard let match else { return } // no match: leave the literal ":query:" untouched + recordUsage(for: match) + scheduleReplaceEmojiQuery(with: match.glyph, fallbackUTF16: fallback) + } + + /// Records a committed emoji against the user's usage history, keyed by its base primary alias so + /// the signal is stable across skin-tone and gender variants. + private func recordUsage(for match: EmojiMatch) { + guard let alias = match.entry.aliases.first else { return } + recordEmojiUsage(alias) } private func cancelCapture() { @@ -291,7 +309,13 @@ final class EmojiPickerController { private func refreshMatches(query: String) { currentQuery = query - matches = EmojiVariantResolver.resolve(matcher.matches(for: query), preferences: emojiPreferences()) + let usage = emojiUsage() + // A bare ":" (empty query) shows the user's recents, padded with popular emoji, instead of + // nothing; a typed query runs the ranked search with the same personal usage signal. + let base = query.isEmpty + ? matcher.recents(usage: usage) + : matcher.matches(for: query, usage: usage) + matches = EmojiVariantResolver.resolve(base, preferences: emojiPreferences()) selectedIndex = 0 } @@ -306,13 +330,16 @@ final class EmojiPickerController { ) } - private func bestGlyphForClosingColon(query: String) -> String? { + private func bestMatchForClosingColon(query: String) -> EmojiMatch? { let lowercased = query.lowercased() - let results = EmojiVariantResolver.resolve(matcher.matches(for: query), preferences: emojiPreferences()) + let results = EmojiVariantResolver.resolve( + matcher.matches(for: query, usage: emojiUsage()), + preferences: emojiPreferences() + ) if let exact = results.first(where: { $0.entry.aliases.contains(lowercased) }) { - return exact.glyph + return exact } - return results.first?.glyph + return results.first } /// Posts the delete+glyph replace on the next runloop tick. Both commit modes defer through here diff --git a/Cotabby/App/Coordinators/SettingsCoordinator.swift b/Cotabby/App/Coordinators/SettingsCoordinator.swift index f228c53..b9bd9c2 100644 --- a/Cotabby/App/Coordinators/SettingsCoordinator.swift +++ b/Cotabby/App/Coordinators/SettingsCoordinator.swift @@ -23,6 +23,7 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate { private let configuration: SuggestionConfiguration private let performanceMetricsStore: PerformanceMetricsStore private let onShowWelcome: () -> Void + private let clearEmojiHistory: () -> Void private var settingsWindowController: NSWindowController? @@ -38,7 +39,8 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate { suggestionEngine: any SuggestionGenerating, configuration: SuggestionConfiguration, performanceMetricsStore: PerformanceMetricsStore, - onShowWelcome: @escaping () -> Void + onShowWelcome: @escaping () -> Void, + clearEmojiHistory: @escaping () -> Void ) { self.appUpdateManager = appUpdateManager self.launchAtLoginService = launchAtLoginService @@ -52,6 +54,7 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate { self.configuration = configuration self.performanceMetricsStore = performanceMetricsStore self.onShowWelcome = onShowWelcome + self.clearEmojiHistory = clearEmojiHistory } /// Shows the settings window, reusing the existing instance if it is already open. @@ -78,7 +81,8 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate { performanceMetricsStore: performanceMetricsStore, suggestionEngine: suggestionEngine, configuration: configuration, - onShowWelcome: onShowWelcome + onShowWelcome: onShowWelcome, + clearEmojiHistory: clearEmojiHistory ) ) ) diff --git a/Cotabby/App/Core/CotabbyAppEnvironment.swift b/Cotabby/App/Core/CotabbyAppEnvironment.swift index da64462..90003d0 100644 --- a/Cotabby/App/Core/CotabbyAppEnvironment.swift +++ b/Cotabby/App/Core/CotabbyAppEnvironment.swift @@ -29,6 +29,7 @@ final class CotabbyAppEnvironment { /// same answer the autocomplete pipeline would, not a stand-in. let suggestionEngine: any SuggestionGenerating let emojiPickerController: EmojiPickerController + let emojiUsageStore: EmojiUsageStore let welcomeCoordinator: WelcomeCoordinator let huggingFaceSearchService: HuggingFaceSearchService let performanceMetricsStore: PerformanceMetricsStore @@ -151,6 +152,10 @@ final class CotabbyAppEnvironment { } ) + // Per-user emoji recents/frequency. Built before the settings coordinator so the + // "Clear History" control can reach it, and before the picker which reads and writes it. + let emojiUsageStore = EmojiUsageStore() + let settingsCoordinator = SettingsCoordinator( appUpdateManager: appUpdateManager, launchAtLoginService: launchAtLoginService, @@ -165,7 +170,8 @@ final class CotabbyAppEnvironment { performanceMetricsStore: performanceMetricsStore, onShowWelcome: { [weak welcomeCoordinator] in welcomeCoordinator?.showWelcome() - } + }, + clearEmojiHistory: { emojiUsageStore.clear() } ) let interactionState = SuggestionInteractionState() @@ -196,7 +202,9 @@ final class CotabbyAppEnvironment { inserter: suggestionInserter, isEnabled: { suggestionSettings.isEmojiPickerEnabled }, emojiPreferences: { suggestionSettings.emojiVariantPreferences }, - acceptKeyLabel: { suggestionSettings.emojiPickerAcceptKeyLabel } + acceptKeyLabel: { suggestionSettings.emojiPickerAcceptKeyLabel }, + emojiUsage: { emojiUsageStore.snapshot() }, + recordEmojiUsage: { emojiUsageStore.record(alias: $0) } ) // Give the picker first look at every keystroke the coordinator receives, so it can detect the // `:` trigger and drive its state machine without changing who owns `inputMonitor.onEvent`. @@ -218,6 +226,7 @@ final class CotabbyAppEnvironment { self.suggestionCoordinator = suggestionCoordinator self.suggestionEngine = suggestionEngine self.emojiPickerController = emojiPickerController + self.emojiUsageStore = emojiUsageStore self.welcomeCoordinator = welcomeCoordinator self.huggingFaceSearchService = huggingFaceSearchService self.performanceMetricsStore = performanceMetricsStore diff --git a/Cotabby/Models/EmojiUsageModels.swift b/Cotabby/Models/EmojiUsageModels.swift new file mode 100644 index 0000000..112ae4f --- /dev/null +++ b/Cotabby/Models/EmojiUsageModels.swift @@ -0,0 +1,31 @@ +import Foundation + +/// File overview: +/// The pure value type the emoji ranker reads to personalize results. It is a snapshot: the matcher +/// and recents helper take it by value so they stay pure and testable, while `EmojiUsageStore` owns +/// the mutable, persisted state and hands out fresh snapshots. +/// +/// Usage is keyed by an emoji's primary alias (e.g. `joy`), not its glyph, so a concept's signal is +/// stable across skin-tone and gender variants: using ๐Ÿ‘๐Ÿฝ still boosts the ๐Ÿ‘ concept, and recents +/// render in the user's current variant preference at display time. +struct EmojiUsageSnapshot: Equatable, Sendable { + /// Primary aliases of recently committed emoji, most recent first, de-duplicated. + let recentAliases: [String] + /// Primary alias -> number of times committed. + let frequency: [String: Int] + + static let empty = EmojiUsageSnapshot(recentAliases: [], frequency: [:]) + + /// Commits before frequency alone marks an alias a favorite. Recency marks it regardless, so a + /// just-used emoji floats up immediately even on first use. + static let frequentThreshold = 2 + + /// Whether an alias is a personal favorite. Favorites float to the front of their relevance tier + /// in the matcher, so your go-to emoji lead among equally-relevant options without ever jumping + /// ahead of a more relevant match in a stronger tier. + func isFavorite(_ alias: String) -> Bool { + let key = alias.lowercased() + if frequency[key, default: 0] >= Self.frequentThreshold { return true } + return recentAliases.contains(key) + } +} diff --git a/Cotabby/Models/EmojiUsageStore.swift b/Cotabby/Models/EmojiUsageStore.swift new file mode 100644 index 0000000..edf4315 --- /dev/null +++ b/Cotabby/Models/EmojiUsageStore.swift @@ -0,0 +1,92 @@ +import Foundation + +/// Narrow persistence surface for `EmojiUsageStore`, so it can be unit-tested against an in-memory +/// store instead of process-global `UserDefaults` (which is shared across tests and unreliable to +/// mutate from a sandboxed unit-test host). `UserDefaults` already satisfies every requirement, so +/// production wiring is unchanged. +protocol EmojiUsageDefaults: AnyObject { + func data(forKey defaultName: String) -> Data? + func set(_ value: Any?, forKey defaultName: String) + func removeObject(forKey defaultName: String) +} + +extension UserDefaults: EmojiUsageDefaults {} + +/// File overview: +/// Persists per-user emoji usage (recents + frequency) so the picker ranks a person's go-to emoji +/// first and seeds the bare-`:` panel with them. Keyed by primary alias, which is variant-stable +/// (see `EmojiUsageSnapshot`), so using ๐Ÿ‘๐Ÿฝ still strengthens the ๐Ÿ‘ concept. +/// +/// `@MainActor` because the only writer is the main-actor `EmojiPickerController` at commit time, and +/// reads are cheap snapshots taken between keystrokes. State is stored as a single JSON blob so the +/// read/write is atomic and avoids per-key dictionary bridging quirks. +/// +/// The `deinit` is `nonisolated` to dodge a macOS 14 Swift bug: an isolated deinit on a `@MainActor` +/// class with non-trivial stored properties routes through `swift_task_deinitOnExecutorMainActorBackDeploy`, +/// which over-releases and aborts the process ("pointer being freed was not allocated") when an +/// instance is destroyed โ€” it crashed the app-hosted unit tests deterministically. Releasing the +/// stored UserDefaults reference plus value types is thread-safe and needs no main-actor hop. +@MainActor +final class EmojiUsageStore { + private let defaults: EmojiUsageDefaults + private var recents: [String] + private var frequency: [String: Int] + + /// Cap on stored recents: ample for the panel (which shows ~24) while keeping the persisted blob + /// small. Older aliases fall off the end as new emoji are committed. + private static let recentsCap = 50 + private static let storageKey = "cotabbyEmojiUsage" + + private struct Persisted: Codable { + var recents: [String] + var frequency: [String: Int] + } + + init(defaults: EmojiUsageDefaults = UserDefaults.standard) { + self.defaults = defaults + if let data = defaults.data(forKey: Self.storageKey), + let decoded = try? JSONDecoder().decode(Persisted.self, from: data) { + recents = decoded.recents + frequency = decoded.frequency + } else { + recents = [] + frequency = [:] + } + } + + // See the type doc comment: avoids the macOS 14 isolated-deinit back-deploy crash. + nonisolated deinit {} + + /// Records one commit of `alias` (an emoji's primary alias): moves it to the front of recents and + /// increments its frequency, then persists. No-op for blank input. + func record(alias rawAlias: String) { + let alias = rawAlias.lowercased().trimmingCharacters(in: .whitespaces) + guard !alias.isEmpty else { return } + recents.removeAll { $0 == alias } + recents.insert(alias, at: 0) + if recents.count > Self.recentsCap { + recents.removeLast(recents.count - Self.recentsCap) + } + frequency[alias, default: 0] += 1 + persist() + } + + /// Immutable snapshot for the pure ranker and recents helper. + func snapshot() -> EmojiUsageSnapshot { + EmojiUsageSnapshot(recentAliases: recents, frequency: frequency) + } + + /// Forgets all recents and frequency. Backs the "Clear Emoji History" settings control. + func clear() { + recents = [] + frequency = [:] + defaults.removeObject(forKey: Self.storageKey) + } + + private func persist() { + guard let data = try? JSONEncoder().encode(Persisted(recents: recents, frequency: frequency)) else { + return + } + defaults.set(data, forKey: Self.storageKey) + } +} diff --git a/Cotabby/Support/EmojiCatalog.swift b/Cotabby/Support/EmojiCatalog.swift index 6f387e1..debdb6a 100644 --- a/Cotabby/Support/EmojiCatalog.swift +++ b/Cotabby/Support/EmojiCatalog.swift @@ -25,11 +25,15 @@ struct EmojiCatalog { let indexed: [IndexedEntry] + /// Lowercased alias -> first catalog index, so a stored alias (recents, popularity prior) resolves + /// back to its entry in O(1). First occurrence wins on the rare alias collision. + let aliasIndex: [String: Int] + var isEmpty: Bool { indexed.isEmpty } var count: Int { indexed.count } init(entries: [EmojiEntry]) { - indexed = entries.map { entry in + let indexed = entries.map { entry in IndexedEntry( entry: entry, lowerAliases: entry.aliases.map { $0.lowercased() }, @@ -37,6 +41,21 @@ struct EmojiCatalog { lowerName: entry.name.lowercased() ) } + var aliasIndex: [String: Int] = [:] + for (index, entry) in indexed.enumerated() { + for alias in entry.lowerAliases where aliasIndex[alias] == nil { + aliasIndex[alias] = index + } + } + self.indexed = indexed + self.aliasIndex = aliasIndex + } + + /// The entry whose (lowercased) alias matches, or nil. Resolves recent/popular aliases back to + /// displayable entries for the bare-`:` panel. + func entry(forAlias alias: String) -> EmojiEntry? { + guard let index = aliasIndex[alias.lowercased()] else { return nil } + return indexed[index].entry } } diff --git a/Cotabby/Support/EmojiMatcher.swift b/Cotabby/Support/EmojiMatcher.swift index 85bbbb3..878fb8a 100644 --- a/Cotabby/Support/EmojiMatcher.swift +++ b/Cotabby/Support/EmojiMatcher.swift @@ -2,12 +2,21 @@ import Foundation /// File overview: /// Ranks emoji against a typed query for the inline picker. This is a pure value type: the same -/// query and catalog always produce the same ordered results, which keeps it trivially testable and -/// safe to call on the main actor between keystrokes. +/// query, catalog, and usage snapshot always produce the same ordered results, which keeps it +/// trivially testable and safe to call on the main actor between keystrokes. /// -/// Ranking favors the canonical `:alias:` tokens, then prefix over substring, then name and keyword -/// hits, with a deterministic tiebreak (shorter matched token first, then original catalog order). -/// The matched-token length tiebreak is what makes `smile` rank `:smile:` above `:smiley:`. +/// Relevance tiers (lower is better), evaluated per entry: +/// 0 exact alias +/// 1 alias prefix, or a curated synonym whose key the query exactly matches (`lol` -> ๐Ÿ˜‚) +/// 2 keyword/name prefix, or a synonym whose key the query is a prefix of +/// 3 alias substring +/// 4 keyword/name substring +/// 5 fuzzy (typo) match, only as a fallback when stronger results are sparse +/// +/// Within a tier the order is: personal favorites first (recent or frequent), then the shorter +/// matched token (so `smile` beats `smiley`), then the curated popularity prior, then catalog order. +/// Favorites and popularity only break ties inside a tier, so relevance is never sacrificed to make +/// a popular or frequently-used emoji jump ahead of a genuinely better match. struct EmojiMatcher { let catalog: EmojiCatalog @@ -15,76 +24,243 @@ struct EmojiMatcher { /// thousand-element result array we immediately discard. static let defaultLimit = 24 - func matches(for rawQuery: String, limit: Int = EmojiMatcher.defaultLimit) -> [EmojiMatch] { + /// Fuzzy matching only kicks in for queries this long, so a one- or two-character query cannot + /// pull in loosely-related typo candidates. + private static let minFuzzyQueryLength = 3 + + /// Relevance tiers. Named so the synonym and fuzzy passes can share the lexical scale. + private enum Tier { + static let exactAlias = 0 + static let aliasPrefix = 1 + static let synonymExact = 1 + static let keywordOrNamePrefix = 2 + static let synonymPrefix = 2 + static let aliasSubstring = 3 + static let keywordOrNameSubstring = 4 + static let fuzzy = 5 + } + + func matches( + for rawQuery: String, + usage: EmojiUsageSnapshot = .empty, + limit: Int = EmojiMatcher.defaultLimit + ) -> [EmojiMatch] { let query = rawQuery.lowercased().trimmingCharacters(in: .whitespaces) guard !query.isEmpty, limit > 0 else { return [] } + let synonyms = EmojiSynonymCatalog.boostedAliases(for: query) + var scored: [ScoredMatch] = [] + var matchedIndices = Set() for (index, indexed) in catalog.indexed.enumerated() { - guard let hit = bestHit(query: query, indexed: indexed) else { continue } - scored.append( - ScoredMatch( - match: EmojiMatch(entry: indexed.entry), - tier: hit.tier, - tokenLength: hit.tokenLength, - catalogIndex: index - ) - ) + guard let hit = lexicalHit(query: query, synonyms: synonyms, indexed: indexed) else { continue } + matchedIndices.insert(index) + scored.append(makeScored(indexed: indexed, index: index, tier: hit.tier, tokenLength: hit.tokenLength, usage: usage)) } - scored.sort { lhs, rhs in - if lhs.tier != rhs.tier { return lhs.tier < rhs.tier } - if lhs.tokenLength != rhs.tokenLength { return lhs.tokenLength < rhs.tokenLength } - return lhs.catalogIndex < rhs.catalogIndex + // Fuzzy fallback runs only when lexical results are sparse and the query is long enough to be + // discriminating, so typos still resolve ("hapy" -> ๐Ÿ˜„) without polluting result sets that + // already have strong lexical matches. + if scored.count < limit, query.count >= Self.minFuzzyQueryLength { + let queryChars = Array(query) + for (index, indexed) in catalog.indexed.enumerated() where !matchedIndices.contains(index) { + guard let hit = fuzzyHit(queryChars: queryChars, queryCount: query.count, indexed: indexed) else { continue } + scored.append(makeScored(indexed: indexed, index: index, tier: hit.tier, tokenLength: hit.tokenLength, usage: usage)) + } } + scored.sort(by: Self.isOrderedBefore) return scored.prefix(limit).map { $0.match } } + /// Suggestions for a bare `:` (no query): the user's recents first, padded with popular emoji. + /// Kept here so callers can ask the matcher for "what to show before any typing" symmetrically + /// with `matches(for:)`. + func recents(usage: EmojiUsageSnapshot, limit: Int = EmojiMatcher.defaultLimit) -> [EmojiMatch] { + EmojiRecents.suggestions(usage: usage, catalog: catalog, limit: limit) + } + private struct ScoredMatch { let match: EmojiMatch let tier: Int + /// 0 when the emoji is a personal favorite (recent or frequent), 1 otherwise. + let favoriteBucket: Int let tokenLength: Int + let popularityRank: Int let catalogIndex: Int } + private func makeScored( + indexed: EmojiCatalog.IndexedEntry, + index: Int, + tier: Int, + tokenLength: Int, + usage: EmojiUsageSnapshot + ) -> ScoredMatch { + let primaryAlias = indexed.lowerAliases.first ?? indexed.lowerName + return ScoredMatch( + match: EmojiMatch(entry: indexed.entry), + tier: tier, + favoriteBucket: usage.isFavorite(primaryAlias) ? 0 : 1, + tokenLength: tokenLength, + popularityRank: EmojiPopularity.rank(forAlias: primaryAlias), + catalogIndex: index + ) + } + + private static func isOrderedBefore(_ lhs: ScoredMatch, _ rhs: ScoredMatch) -> Bool { + if lhs.tier != rhs.tier { return lhs.tier < rhs.tier } + if lhs.favoriteBucket != rhs.favoriteBucket { return lhs.favoriteBucket < rhs.favoriteBucket } + if lhs.tokenLength != rhs.tokenLength { return lhs.tokenLength < rhs.tokenLength } + if lhs.popularityRank != rhs.popularityRank { return lhs.popularityRank < rhs.popularityRank } + return lhs.catalogIndex < rhs.catalogIndex + } + /// Lower tier is a better match. Returns the strongest tier this entry achieves plus the length - /// of the matched token (for the secondary tiebreak), or `nil` when nothing matches. - private func bestHit(query: String, indexed: EmojiCatalog.IndexedEntry) -> (tier: Int, tokenLength: Int)? { - var bestTier = Int.max - var bestTokenLength = 0 - - func record(_ tier: Int, _ tokenLength: Int) { - guard tier < bestTier else { return } - bestTier = tier - bestTokenLength = tokenLength + /// of the matched token (for the secondary tiebreak), or `nil` when nothing matches. Synonyms are + /// recorded with token length 0 so an intent hit leads its tier ahead of literal prefix hits. + private func lexicalHit( + query: String, + synonyms: (exact: Set, prefix: Set), + indexed: EmojiCatalog.IndexedEntry + ) -> (tier: Int, tokenLength: Int)? { + var best: (tier: Int, tokenLength: Int)? + func merge(_ candidate: (tier: Int, tokenLength: Int)?) { + best = Self.betterHit(best, candidate) } for alias in indexed.lowerAliases { - if alias == query { - return (0, alias.count) - } - if alias.hasPrefix(query) { - record(1, alias.count) - } else if alias.contains(query) { - record(3, alias.count) - } + merge(Self.aliasHit(query: query, alias: alias)) + merge(Self.synonymHit(synonyms: synonyms, alias: alias)) } - for keyword in indexed.lowerKeywords { - if keyword == query || keyword.hasPrefix(query) { - record(2, keyword.count) - } else if keyword.contains(query) { - record(4, keyword.count) - } + merge(Self.keywordHit(query: query, keyword: keyword)) } + merge(Self.nameHit(query: query, name: indexed.lowerName)) + + return best + } + + /// Keeps the stronger of two hits: lower tier wins, then the shorter matched token. + private static func betterHit( + _ current: (tier: Int, tokenLength: Int)?, + _ candidate: (tier: Int, tokenLength: Int)? + ) -> (tier: Int, tokenLength: Int)? { + guard let candidate else { return current } + guard let current else { return candidate } + if candidate.tier < current.tier { return candidate } + if candidate.tier == current.tier, candidate.tokenLength < current.tokenLength { return candidate } + return current + } + + private static func aliasHit(query: String, alias: String) -> (tier: Int, tokenLength: Int)? { + if alias == query { return (Tier.exactAlias, alias.count) } + if alias.hasPrefix(query) { return (Tier.aliasPrefix, alias.count) } + if alias.contains(query) { return (Tier.aliasSubstring, alias.count) } + return nil + } - if indexed.lowerName.hasPrefix(query) { - record(2, indexed.lowerName.count) - } else if indexed.lowerName.contains(query) { - record(4, indexed.lowerName.count) + /// Synonym hits use token length 0 so an intent match leads its tier ahead of literal prefixes. + private static func synonymHit( + synonyms: (exact: Set, prefix: Set), + alias: String + ) -> (tier: Int, tokenLength: Int)? { + if synonyms.exact.contains(alias) { return (Tier.synonymExact, 0) } + if synonyms.prefix.contains(alias) { return (Tier.synonymPrefix, 0) } + return nil + } + + private static func keywordHit(query: String, keyword: String) -> (tier: Int, tokenLength: Int)? { + if keyword == query || keyword.hasPrefix(query) { return (Tier.keywordOrNamePrefix, keyword.count) } + if keyword.contains(query) { return (Tier.keywordOrNameSubstring, keyword.count) } + return nil + } + + private static func nameHit(query: String, name: String) -> (tier: Int, tokenLength: Int)? { + if name.hasPrefix(query) { return (Tier.keywordOrNamePrefix, name.count) } + if name.contains(query) { return (Tier.keywordOrNameSubstring, name.count) } + return nil + } + + // MARK: - Fuzzy fallback + + /// Caps how much longer a candidate may be than the query for a subsequence match, so a short + /// query cannot match a long unrelated word that merely contains its letters in order. + private static let maxSubsequenceGap = 4 + + private func fuzzyHit( + queryChars: [Character], + queryCount: Int, + indexed: EmojiCatalog.IndexedEntry + ) -> (tier: Int, tokenLength: Int)? { + var bestLength: Int? + func consider(_ candidate: String) { + guard Self.isFuzzyMatch(queryChars: queryChars, queryCount: queryCount, candidate: candidate) else { return } + bestLength = Swift.min(bestLength ?? Int.max, candidate.count) + } + for alias in indexed.lowerAliases { consider(alias) } + consider(indexed.lowerName) + + guard let length = bestLength else { return nil } + return (Tier.fuzzy, length) + } + + /// A fuzzy match is a bounded edit distance (handles transpositions and single typos, e.g. + /// "recieve" -> "receive") or a length-capped subsequence (handles dropped letters and light + /// abbreviations, e.g. "thnk" -> "thinking"). + private static func isFuzzyMatch(queryChars: [Character], queryCount: Int, candidate: String) -> Bool { + let candidateChars = Array(candidate) + let bound = queryCount <= 4 ? 1 : 2 + if abs(candidateChars.count - queryCount) <= bound, + osaDistance(queryChars, candidateChars) <= bound { + return true + } + if candidateChars.count <= queryCount + maxSubsequenceGap, + isSubsequence(queryChars, candidateChars) { + return true + } + return false + } + + /// True when every character of `needle` appears in `haystack` in order (not necessarily + /// contiguously). + private static func isSubsequence(_ needle: [Character], _ haystack: [Character]) -> Bool { + guard !needle.isEmpty else { return true } + var matched = 0 + for character in haystack where character == needle[matched] { + matched += 1 + if matched == needle.count { return true } } + return matched == needle.count + } - return bestTier == Int.max ? nil : (bestTier, bestTokenLength) + /// Optimal string alignment distance: Levenshtein plus adjacent transpositions as a single edit. + /// Strings here are short emoji aliases, so the full matrix is cheap and easier to verify than a + /// rolling-row variant. + private static func osaDistance(_ source: [Character], _ target: [Character]) -> Int { + let sourceCount = source.count + let targetCount = target.count + if sourceCount == 0 { return targetCount } + if targetCount == 0 { return sourceCount } + + var distance = Array(repeating: Array(repeating: 0, count: targetCount + 1), count: sourceCount + 1) + for row in 0...sourceCount { distance[row][0] = row } + for column in 0...targetCount { distance[0][column] = column } + + for row in 1...sourceCount { + for column in 1...targetCount { + let cost = source[row - 1] == target[column - 1] ? 0 : 1 + var value = Swift.min( + distance[row - 1][column] + 1, + distance[row][column - 1] + 1, + distance[row - 1][column - 1] + cost + ) + if row > 1, column > 1, source[row - 1] == target[column - 2], source[row - 2] == target[column - 1] { + value = Swift.min(value, distance[row - 2][column - 2] + 1) + } + distance[row][column] = value + } + } + return distance[sourceCount][targetCount] } } diff --git a/Cotabby/Support/EmojiPopularity.swift b/Cotabby/Support/EmojiPopularity.swift new file mode 100644 index 0000000..627c919 --- /dev/null +++ b/Cotabby/Support/EmojiPopularity.swift @@ -0,0 +1,94 @@ +import Foundation + +/// File overview: +/// A hand-curated popularity prior for emoji, keyed by canonical gemoji alias and ordered most-used +/// first. Two consumers: +/// +/// 1. `EmojiMatcher` uses `rank(forAlias:)` as a late tiebreak so that, among results of equal +/// relevance, the emoji people actually use float up (e.g. โค๏ธ before a obscure heart variant). +/// 2. The inline picker shows `starterAliases` on a bare `:` for a user with no personal history yet, +/// so the very first `:` is useful instead of empty. +/// +/// Why hard-coded: the bundled gemoji dataset carries no frequency or popularity signal, and its file +/// order is roughly age-based, not usage-based. A curated list is the cheapest way to encode "what +/// people reach for" until per-user history (see `EmojiUsageStore`) takes over. Aliases that are not +/// present in the active catalog simply never rank or resolve, so a stale entry here is harmless. +enum EmojiPopularity { + /// Ranked aliases, most popular first. The index is the rank, so order is the contract; keep the + /// highest-traffic reactions at the top. Grouped only for readability. + static let ordered: [String] = [ + // Core reactions + "joy", "heart", "sob", "pray", "thumbsup", "fire", "ok_hand", "tada", "eyes", "heart_eyes", + "smile", "smirk", "grin", "sweat_smile", "rofl", "blush", "clap", "raised_hands", "wave", "100", + "thinking", "cry", "wink", "sunglasses", "sparkles", "rocket", "skull", "pensive", "weary", "muscle", + "thumbsdown", "facepalm", "shrug", "see_no_evil", "smiley", "laughing", "kissing_heart", "yum", "smiling_imp", + // Faces + "slightly_smiling_face", "upside_down_face", "relaxed", "relieved", "neutral_face", "expressionless", + "unamused", "roll_eyes", "flushed", "pleading_face", "disappointed", "tired_face", "sleepy", + "yawning_face", "scream", "fearful", "cold_sweat", "disappointed_relieved", "sweat", "hushed", + "astonished", "dizzy_face", "open_mouth", "grimacing", "confused", "worried", "frowning_face", + "persevere", "confounded", "triumph", "angry", "rage", "innocent", "nerd_face", "partying_face", + "woozy_face", "zany_face", "hugs", "shushing_face", "lying_face", "raised_eyebrow", "star_struck", + "stuck_out_tongue", "stuck_out_tongue_winking_eye", "drooling_face", "sleeping", "mask", "hot_face", + "cold_face", "sneezing_face", "nauseated_face", "money_mouth_face", "cowboy_hat_face", "smiling_face_with_tear", + // Hands and people + "point_up", "point_down", "point_left", "point_right", "v", "crossed_fingers", "fist", "facepunch", + "handshake", "writing_hand", "nail_care", "open_hands", "raised_hand", "vulcan_salute", "call_me_hand", + "metal", "middle_finger", "ok_woman", "raising_hand", "tipping_hand_person", + // Hearts and symbols + "orange_heart", "yellow_heart", "green_heart", "blue_heart", "purple_heart", "black_heart", "white_heart", + "broken_heart", "two_hearts", "revolving_hearts", "heartbeat", "heartpulse", "sparkling_heart", "cupid", + "gift_heart", "anger", "boom", "dizzy", "sweat_drops", "dash", "star", "star2", "zzz", "exclamation", + "question", "bangbang", "white_check_mark", "heavy_check_mark", "x", "negative_squared_cross_mark", + "warning", "no_entry", "recycle", "droplet", "zap", "snowflake", "rainbow", + // Celebration, food, drink + "confetti_ball", "balloon", "gift", "birthday", "cake", "champagne", "clinking_glasses", "beers", "beer", + "wine_glass", "cocktail", "coffee", "pizza", "hamburger", "fries", "hotdog", "taco", "sushi", "ramen", + "doughnut", "cookie", "ice_cream", "lollipop", "candy", "chocolate_bar", "popcorn", "apple", "banana", + "watermelon", "strawberry", "cherries", "peach", "eggplant", "hot_pepper", "avocado", "bread", + // Animals and nature + "dog", "cat", "mouse", "rabbit", "fox_face", "bear", "panda_face", "koala", "tiger", "lion", "cow", "pig", + "frog", "monkey", "monkey_face", "hear_no_evil", "speak_no_evil", "chicken", "penguin", "bird", + "baby_chick", "duck", "owl", "wolf", "horse", "unicorn", "bee", "bug", "butterfly", "snail", "turtle", + "snake", "octopus", "whale", "dolphin", "fish", "shark", "elephant", "giraffe", "hedgehog", "sloth", + "sheep", "deer", "peacock", "parrot", "flamingo", "seedling", "herb", "four_leaf_clover", "evergreen_tree", + "palm_tree", "cactus", "christmas_tree", "maple_leaf", "fallen_leaf", "leaves", "mushroom", "sunflower", + "rose", "tulip", "cherry_blossom", "bouquet", "sunny", "cloud", "ocean", "full_moon", "crescent_moon", + "earth_americas", + // Objects, travel, tech + "poop", "ghost", "alien", "robot", "clown_face", "jack_o_lantern", "gem", "crown", "ring", "lipstick", + "tophat", "mortar_board", "dress", "shirt", "trophy", "medal_sports", "soccer", "basketball", "football", + "baseball", "tennis", "volleyball", "8ball", "dart", "bowling", "video_game", "game_die", "musical_note", + "notes", "microphone", "headphones", "guitar", "clapper", "art", "bulb", "flashlight", "computer", + "keyboard", "iphone", "camera", "movie_camera", "tv", "telephone", "email", "envelope", "package", + "memo", "pencil2", "paperclip", "scissors", "pushpin", "round_pushpin", "calendar", "bar_chart", + "chart_with_upwards_trend", "clipboard", "books", "book", "newspaper", "mag", "lock", "unlock", "key", + "hammer", "wrench", "gear", "link", "moneybag", "dollar", "credit_card", "bell", "loudspeaker", "mega", + "speech_balloon", "thought_balloon", "airplane", "car", "taxi", "bus", "train", "bike", "ship", + "rotating_light", "house", "office", "hospital", "school", "mountain", "beach_umbrella" + ] + + /// Alias -> rank lookup. Lower is more popular. Built once from `ordered`; an alias not present + /// returns `notRanked` so the matcher's tiebreak places it after every curated-popular emoji. + static let notRanked = Int.max + + private static let rankByAlias: [String: Int] = { + var map: [String: Int] = [:] + map.reserveCapacity(ordered.count) + // First occurrence wins, so a duplicate left in `ordered` by accident keeps its better rank. + for (index, alias) in ordered.enumerated() where map[alias] == nil { + map[alias] = index + } + return map + }() + + /// The popularity rank of an alias, or `notRanked` when it is not in the curated list. + static func rank(forAlias alias: String) -> Int { + rankByAlias[alias.lowercased()] ?? notRanked + } + + /// Aliases to seed the bare-`:` panel for a user with no personal history, in popularity order. + static func starterAliases(limit: Int) -> [String] { + Array(ordered.prefix(max(0, limit))) + } +} diff --git a/Cotabby/Support/EmojiRecents.swift b/Cotabby/Support/EmojiRecents.swift new file mode 100644 index 0000000..2121614 --- /dev/null +++ b/Cotabby/Support/EmojiRecents.swift @@ -0,0 +1,42 @@ +import Foundation + +/// File overview: +/// Builds the suggestion list shown when the user types a bare `:` (no query yet). It is pure so it +/// is trivially testable: personal recents first (most recent first), then the curated popularity +/// prior to fill the panel, de-duplicated and resolved against the catalog. +/// +/// This is what makes the very first `:` useful instead of empty. A brand-new user with no history +/// sees popular emoji; a returning user sees what they actually reach for. Variant resolution (skin +/// tone / gender) is applied by the caller, the same way it is for query results. +enum EmojiRecents { + /// Recents-first, popularity-padded suggestions for an empty query, capped at `limit`. + static func suggestions( + usage: EmojiUsageSnapshot, + catalog: EmojiCatalog, + limit: Int = EmojiMatcher.defaultLimit + ) -> [EmojiMatch] { + guard limit > 0 else { return [] } + + var orderedAliases: [String] = [] + var seen = Set() + func append(_ alias: String) { + let key = alias.lowercased() + guard !seen.contains(key) else { return } + seen.insert(key) + orderedAliases.append(key) + } + + usage.recentAliases.forEach(append) + // Pad from a generous slice of the popularity prior so that, after some recents fail to + // resolve (a refreshed dataset dropped an alias) or duplicate the prior, we still fill `limit`. + EmojiPopularity.starterAliases(limit: limit * 3).forEach(append) + + var result: [EmojiMatch] = [] + for alias in orderedAliases { + guard let entry = catalog.entry(forAlias: alias) else { continue } + result.append(EmojiMatch(entry: entry)) + if result.count >= limit { break } + } + return result + } +} diff --git a/Cotabby/Support/EmojiSynonymCatalog.swift b/Cotabby/Support/EmojiSynonymCatalog.swift new file mode 100644 index 0000000..fa0e9d9 --- /dev/null +++ b/Cotabby/Support/EmojiSynonymCatalog.swift @@ -0,0 +1,225 @@ +import Foundation + +/// File overview: +/// A hand-curated intent/slang overlay for emoji search. It maps the words people actually type +/// (`lol`, `omg`, `ty`, `love`, `fire`, `congrats`) to the canonical gemoji aliases they mean, so the +/// picker surfaces the intended emoji first even when the bundled dataset's own `aliases`/`keywords` +/// would rank it low or miss it entirely. +/// +/// Why hard-coded: the gemoji dataset's keyword coverage is sparse and inconsistent (`lol` is not a +/// keyword on ๐Ÿ˜‚, `ty` maps to nothing). Encoding the long tail of common phrasings as data is the +/// cheapest, most predictable way to make search feel like it "reads your mind", and the table is +/// trivial to grow. Values must be real catalog aliases; a value that matches no entry is simply inert. +/// +/// The matcher consumes this through `boostedAliases(for:)`: an exact key match boosts its aliases to +/// the alias-prefix tier, and a prefix key match boosts to the keyword tier, so intent ranks high +/// without ever overriding a literal exact-alias match the user typed. +enum EmojiSynonymCatalog { + /// Lowercased query word -> canonical aliases to boost, in rough preference order (final ordering + /// among equally-boosted aliases is decided by the matcher's popularity tiebreak). + static let map: [String: [String]] = [ + // Laughter and joy + "lol": ["joy", "rofl"], + "lmao": ["rofl", "joy"], + "lmfao": ["rofl", "joy"], + "haha": ["joy", "grin"], + "hahaha": ["joy", "rofl"], + "dying": ["joy", "skull"], + "dead": ["skull", "joy"], + "funny": ["joy", "rofl"], + "laugh": ["joy", "laughing"], + "happy": ["smile", "joy", "blush"], + "smiley": ["smiley", "smile"], + + // Affection + "love": ["heart", "heart_eyes", "kissing_heart"], + "luv": ["heart", "heart_eyes"], + "crush": ["heart_eyes", "smiling_face_with_three_hearts"], + "kiss": ["kissing_heart", "kiss"], + "hug": ["hugs"], + "hugs": ["hugs"], + "cute": ["smiling_face_with_three_hearts", "heart_eyes"], + "adore": ["heart_eyes"], + + // Sadness / pain + "sad": ["cry", "sob", "pensive"], + "crying": ["sob", "cry"], + "sob": ["sob"], + "depressed": ["pensive", "disappointed"], + "heartbroken": ["broken_heart"], + "pain": ["sob", "weary"], + "tired": ["tired_face", "weary"], + "exhausted": ["weary", "tired_face"], + "sleepy": ["sleeping", "yawning_face"], + "bored": ["yawning_face", "expressionless"], + + // Anger + "angry": ["rage", "angry"], + "mad": ["rage", "angry"], + "rage": ["rage"], + "annoyed": ["unamused", "expressionless"], + "ugh": ["unamused", "weary"], + + // Reactions / internet slang + "omg": ["scream", "astonished", "flushed"], + "omfg": ["scream", "astonished"], + "wtf": ["cursing_face", "rage"], + "smh": ["facepalm", "disappointed"], + "idk": ["shrug", "thinking"], + "idc": ["shrug"], + "meh": ["neutral_face", "expressionless"], + "oops": ["sweat_smile", "grimacing"], + "yikes": ["grimacing", "fearful"], + "cringe": ["grimacing", "weary"], + "sus": ["eyes", "raised_eyebrow"], + "shocked": ["astonished", "open_mouth"], + "surprised": ["open_mouth", "astonished"], + "confused": ["confused", "thinking"], + "thinking": ["thinking"], + "facepalm": ["facepalm"], + "shrug": ["shrug"], + "mindblown": ["exploding_head"], + "wow": ["astonished", "star_struck"], + "scared": ["fearful", "scream"], + "nervous": ["sweat_smile", "grimacing"], + "sick": ["nauseated_face", "mask"], + "ill": ["mask", "nauseated_face"], + "drunk": ["woozy_face"], + "crazy": ["zany_face"], + "cool": ["sunglasses"], + "nerd": ["nerd_face"], + "rich": ["money_mouth_face", "moneybag"], + + // Approval / gestures + "ty": ["pray"], + "thanks": ["pray", "clap"], + "thank": ["pray"], + "thx": ["pray"], + "please": ["pray"], + "pls": ["pray"], + "plz": ["pray"], + "yes": ["white_check_mark", "thumbsup"], + "yep": ["thumbsup"], + "no": ["x", "thumbsdown"], + "nope": ["thumbsdown"], + "ok": ["ok_hand", "white_check_mark"], + "okay": ["ok_hand"], + "like": ["thumbsup", "heart"], + "dislike": ["thumbsdown"], + "agree": ["thumbsup", "100"], + "disagree": ["thumbsdown"], + "perfect": ["ok_hand", "100"], + "nice": ["thumbsup", "ok_hand"], + "great": ["thumbsup", "100"], + "clap": ["clap"], + "applause": ["clap"], + "wave": ["wave"], + "hi": ["wave"], + "hey": ["wave"], + "hello": ["wave"], + "bye": ["wave"], + "goodbye": ["wave"], + "strong": ["muscle"], + "flex": ["muscle"], + "gym": ["muscle"], + "this": ["point_up", "100"], + + // Hype / celebration + "fire": ["fire"], + "lit": ["fire"], + "hot": ["fire", "hot_face"], + "100": ["100"], + "hundred": ["100"], + "party": ["tada", "partying_face"], + "celebrate": ["tada", "clinking_glasses"], + "celebration": ["tada"], + "congrats": ["tada", "clap"], + "congratulations": ["tada", "clap"], + "win": ["trophy", "tada"], + "winner": ["trophy", "1st_place_medal"], + "boom": ["boom"], + "explode": ["exploding_head", "boom"], + "magic": ["sparkles"], + "sparkle": ["sparkles"], + "shiny": ["sparkles", "gem"], + + // Common nouns + "money": ["moneybag", "money_mouth_face", "dollar"], + "cash": ["moneybag", "dollar"], + "idea": ["bulb"], + "smart": ["bulb", "nerd_face"], + "food": ["hamburger", "pizza"], + "hungry": ["drooling_face", "fork_and_knife"], + "eat": ["fork_and_knife", "hamburger"], + "drink": ["beer", "cocktail"], + "beer": ["beer", "beers"], + "wine": ["wine_glass"], + "coffee": ["coffee"], + "cake": ["cake", "birthday"], + "bday": ["birthday", "tada"], + "birthday": ["birthday", "tada"], + "gift": ["gift"], + "present": ["gift"], + "music": ["musical_note", "notes"], + "game": ["video_game", "game_die"], + "gaming": ["video_game"], + "work": ["briefcase", "computer"], + "code": ["computer", "keyboard"], + "bug": ["bug"], + "phone": ["iphone"], + "call": ["telephone"], + "email": ["email"], + "time": ["alarm_clock", "watch"], + "search": ["mag"], + "rocket": ["rocket"], + "launch": ["rocket"], + "shipit": ["rocket"], + "fly": ["airplane"], + "travel": ["airplane", "earth_americas"], + "home": ["house"], + "poop": ["poop"], + "ghost": ["ghost"], + "alien": ["alien"], + "robot": ["robot"], + "clown": ["clown_face"], + "skull": ["skull"], + "king": ["crown"], + "queen": ["crown"], + "trophy": ["trophy"], + "soccer": ["soccer"], + "sun": ["sunny"], + "rain": ["umbrella", "cloud_with_rain"], + "rainbow": ["rainbow"], + "snow": ["snowflake", "snowman"], + "star": ["star", "star2"], + "lightning": ["zap"], + "dog": ["dog"], + "puppy": ["dog"], + "cat": ["cat"], + "kitten": ["cat"] + ] + + /// Aliases to boost for a query: `exact` when the query equals a synonym key, `prefix` when the + /// query is a prefix of one or more keys (so partial typing still surfaces intent). `prefix` + /// excludes anything already in `exact`. Empty query yields nothing. + static func boostedAliases(for rawQuery: String) -> (exact: Set, prefix: Set) { + let query = rawQuery.lowercased().trimmingCharacters(in: .whitespaces) + guard !query.isEmpty else { return ([], []) } + + var exact: Set = [] + if let direct = map[query] { + exact.formUnion(direct) + } + + var prefix: Set = [] + // Require two characters before prefix-boosting so a single letter does not pull in dozens of + // intent words. The map is small, so the linear scan is cheap between keystrokes. + if query.count >= 2 { + for (key, aliases) in map where key != query && key.hasPrefix(query) { + prefix.formUnion(aliases) + } + } + prefix.subtract(exact) + return (exact, prefix) + } +} diff --git a/Cotabby/UI/Settings/Panes/GeneralPaneView.swift b/Cotabby/UI/Settings/Panes/GeneralPaneView.swift index 06df67c..a4c31b1 100644 --- a/Cotabby/UI/Settings/Panes/GeneralPaneView.swift +++ b/Cotabby/UI/Settings/Panes/GeneralPaneView.swift @@ -10,6 +10,7 @@ struct GeneralPaneView: View { @ObservedObject var suggestionSettings: SuggestionSettingsModel @ObservedObject var launchAtLoginService: LaunchAtLoginService let onShowWelcome: () -> Void + let clearEmojiHistory: () -> Void @Environment(\.colorScheme) private var colorScheme @@ -129,6 +130,17 @@ struct GeneralPaneView: View { description: "Choose person, man, or woman variants when an emoji offers them." ) } + + LabeledContent { + Button("Clear History") { + clearEmojiHistory() + } + } label: { + SettingsRowLabel( + title: "Emoji History", + description: "Forget recently and frequently used emoji so the picker ranks from scratch." + ) + } } } diff --git a/Cotabby/UI/Settings/SettingsContainerView.swift b/Cotabby/UI/Settings/SettingsContainerView.swift index f7b70fc..8ca72df 100644 --- a/Cotabby/UI/Settings/SettingsContainerView.swift +++ b/Cotabby/UI/Settings/SettingsContainerView.swift @@ -29,6 +29,7 @@ struct SettingsContainerView: View { let suggestionEngine: any SuggestionGenerating let configuration: SuggestionConfiguration let onShowWelcome: () -> Void + let clearEmojiHistory: () -> Void @AppStorage("cotabbySettingsSelectedCategoryV2") private var storedCategoryRawValue: String = SettingsCategory.general.rawValue @@ -99,7 +100,8 @@ struct SettingsContainerView: View { GeneralPaneView( suggestionSettings: suggestionSettings, launchAtLoginService: launchAtLoginService, - onShowWelcome: onShowWelcome + onShowWelcome: onShowWelcome, + clearEmojiHistory: clearEmojiHistory ) case .engineAndModel: EngineAndModelPaneView( diff --git a/CotabbyTests/EmojiCatalogMatcherTests.swift b/CotabbyTests/EmojiCatalogMatcherTests.swift index f854a29..008bca0 100644 --- a/CotabbyTests/EmojiCatalogMatcherTests.swift +++ b/CotabbyTests/EmojiCatalogMatcherTests.swift @@ -76,6 +76,74 @@ final class EmojiCatalogMatcherTests: XCTestCase { XCTAssertEqual(results.first?.glyph, "๐ŸŽ‰") } + // MARK: - Synonyms, fuzzy, and personalization + + func test_synonymSurfacesIntentWordWithNoLexicalMatch() { + // "lol" is not an alias, keyword, or name of ๐Ÿ˜‚, but the synonym overlay maps it to "joy". + let sut = matcher([ + entry("๐ŸŽˆ", "balloon", aliases: ["balloon"]), + entry("๐Ÿ˜‚", "face with tears of joy", aliases: ["joy"]) + ]) + + XCTAssertEqual(sut.matches(for: "lol").first?.glyph, "๐Ÿ˜‚") + } + + func test_fuzzyMatchesDroppedLetterTypo() { + let sut = matcher([entry("๐Ÿ˜€", "happy face", aliases: ["happy"])]) + + XCTAssertEqual(sut.matches(for: "hapy").first?.glyph, "๐Ÿ˜€") + } + + func test_fuzzyMatchesTransposition() { + let sut = matcher([entry("๐Ÿ“ฅ", "incoming", aliases: ["receive"])]) + + XCTAssertEqual(sut.matches(for: "recieve").first?.glyph, "๐Ÿ“ฅ") + } + + func test_exactMatchStillBeatsFuzzy() { + // A literal exact alias must always outrank a fuzzy hit on another entry. + let sut = matcher([ + entry("๐Ÿ˜€", "happy face", aliases: ["happy"]), // only a fuzzy candidate for "hapy" + entry("๐Ÿ…ท", "hapy tag", aliases: ["hapy"]) // exact alias "hapy" + ]) + + XCTAssertEqual(sut.matches(for: "hapy").first?.glyph, "๐Ÿ…ท") + } + + func test_favoriteFloatsAboveShorterTokenWithinTier() { + let sut = matcher([ + entry("๐Ÿ…ฐ๏ธ", "alpha", aliases: ["alpha"]), // shorter token, normally first + entry("๐Ÿ…ฑ๏ธ", "alphabet", aliases: ["alphabet"]) // longer token + ]) + + // Without history, the shorter "alpha" leads for "alph". + XCTAssertEqual(sut.matches(for: "alph").first?.glyph, "๐Ÿ…ฐ๏ธ") + + // Marking "alphabet" a recent favorite lifts it above the shorter token within the same tier. + let usage = EmojiUsageSnapshot(recentAliases: ["alphabet"], frequency: [:]) + XCTAssertEqual(sut.matches(for: "alph", usage: usage).first?.glyph, "๐Ÿ…ฑ๏ธ") + } + + func test_popularityBreaksTiesAtEqualRelevance() { + let sut = matcher([ + entry("๐ŸŒฟ", "hedge", aliases: ["hedge"]), // not in the popularity prior + entry("โค๏ธ", "heart", aliases: ["heart"]) // high in the popularity prior + ]) + + // Both are equal-length prefix matches for "he"; the more popular alias wins the tiebreak. + XCTAssertEqual(sut.matches(for: "he").first?.glyph, "โค๏ธ") + } + + func test_recentsLeadBareColonSuggestions() { + let sut = matcher([ + entry("๐Ÿ˜€", "grinning", aliases: ["grinning"]), + entry("๐Ÿ˜‚", "joy", aliases: ["joy"]) + ]) + let usage = EmojiUsageSnapshot(recentAliases: ["grinning"], frequency: [:]) + + XCTAssertEqual(sut.recents(usage: usage).first?.glyph, "๐Ÿ˜€") + } + // MARK: - Bounds func test_emptyQueryReturnsNothing() { diff --git a/CotabbyTests/EmojiPickerControllerTests.swift b/CotabbyTests/EmojiPickerControllerTests.swift index 37b75cf..8b1f036 100644 --- a/CotabbyTests/EmojiPickerControllerTests.swift +++ b/CotabbyTests/EmojiPickerControllerTests.swift @@ -39,6 +39,22 @@ final class EmojiPickerControllerTests: XCTestCase { } } + func test_commitRecordsEmojiUsageByPrimaryAlias() { + runOnMainActor { + let harness = Harness(precedingText: ":smile") + harness.openAndType(":smile") + + XCTAssertTrue(harness.controller.observe(Harness.keyEvent(48))) + harness.flushMainQueue() + + XCTAssertEqual( + harness.usage.recorded, + ["smile"], + "Commit must record the committed emoji's primary alias for ranking and recents." + ) + } + } + func test_returnDoesNotCommitAndPassesThroughEvenWithMatches() { runOnMainActor { let harness = Harness(precedingText: ":smile") @@ -91,6 +107,7 @@ final class EmojiPickerControllerTests: XCTestCase { let monitor = FakeInputMonitor() let inserter = RecordingInserter() let panel = FakePanel() + let usage: UsageRecorder let controller: EmojiPickerController init(precedingText: String) { @@ -105,6 +122,10 @@ final class EmojiPickerControllerTests: XCTestCase { ) ]) focus = FakeFocus(precedingText: precedingText, focusChangeSequence: 1) + // Captured by the closures instead of `self`, so the controller can be built inside this + // initializer without referencing a not-yet-assigned `self`. + let usageRecorder = UsageRecorder() + usage = usageRecorder controller = EmojiPickerController( matcher: EmojiMatcher(catalog: catalog), panel: panel, @@ -113,7 +134,9 @@ final class EmojiPickerControllerTests: XCTestCase { inserter: inserter, isEnabled: { true }, emojiPreferences: { .default }, - acceptKeyLabel: { "โ‡ฅ" } + acceptKeyLabel: { "โ‡ฅ" }, + emojiUsage: { usageRecorder.snapshot }, + recordEmojiUsage: { usageRecorder.recorded.append($0) } ) controller.start() EmojiPickerControllerTests.retained.append(controller) @@ -205,6 +228,14 @@ private final class RecordingInserter: EmojiTextInserting { } } +/// Captures the controller's usage callbacks without the harness having to capture `self` during its +/// own initializer. +@MainActor +private final class UsageRecorder { + var snapshot = EmojiUsageSnapshot.empty + var recorded: [String] = [] +} + @MainActor private final class FakePanel: EmojiPickerPanelPresenting { var onSelectIndex: ((Int) -> Void)? diff --git a/CotabbyTests/EmojiPopularityTests.swift b/CotabbyTests/EmojiPopularityTests.swift new file mode 100644 index 0000000..a3aebe0 --- /dev/null +++ b/CotabbyTests/EmojiPopularityTests.swift @@ -0,0 +1,33 @@ +import XCTest +@testable import Cotabby + +/// Tests for the curated popularity prior used as a ranking tiebreak and the bare-`:` starter set. +final class EmojiPopularityTests: XCTestCase { + func test_rankIsAscendingByListPosition() { + // "joy" leads the curated list, so it must rank ahead of a later entry like "heart". + XCTAssertLessThan( + EmojiPopularity.rank(forAlias: "joy"), + EmojiPopularity.rank(forAlias: "heart") + ) + } + + func test_absentAliasIsNotRanked() { + XCTAssertEqual( + EmojiPopularity.rank(forAlias: "definitely_not_an_emoji_alias"), + EmojiPopularity.notRanked + ) + } + + func test_rankIsCaseInsensitive() { + XCTAssertEqual(EmojiPopularity.rank(forAlias: "JOY"), EmojiPopularity.rank(forAlias: "joy")) + } + + func test_starterAliasesReturnsPrefixInOrder() { + XCTAssertEqual(EmojiPopularity.starterAliases(limit: 3), Array(EmojiPopularity.ordered.prefix(3))) + } + + func test_starterAliasesClampsToZero() { + XCTAssertTrue(EmojiPopularity.starterAliases(limit: 0).isEmpty) + XCTAssertTrue(EmojiPopularity.starterAliases(limit: -5).isEmpty) + } +} diff --git a/CotabbyTests/EmojiRecentsTests.swift b/CotabbyTests/EmojiRecentsTests.swift new file mode 100644 index 0000000..863409b --- /dev/null +++ b/CotabbyTests/EmojiRecentsTests.swift @@ -0,0 +1,47 @@ +import XCTest +@testable import Cotabby + +/// Tests for the bare-`:` suggestion builder: recents first, popularity-padded, de-duplicated and +/// resolved against the catalog. +final class EmojiRecentsTests: XCTestCase { + private func entry(_ glyph: String, _ alias: String) -> EmojiEntry { + EmojiEntry(glyph: glyph, name: alias, aliases: [alias], keywords: [], group: "Test", unicodeVersion: "1.0") + } + + private func sampleCatalog() -> EmojiCatalog { + EmojiCatalog(entries: [ + entry("๐Ÿ˜€", "grinning"), // not in the popularity prior + entry("๐Ÿ˜‚", "joy"), // popular + entry("โค๏ธ", "heart"), // popular + entry("๐Ÿš€", "rocket"), // popular + entry("๐Ÿฆ„", "unicorn") // popular (animals section) + ]) + } + + func test_recentsLeadInOrderThenPopularityPads() { + let usage = EmojiUsageSnapshot(recentAliases: ["unicorn", "grinning"], frequency: [:]) + let glyphs = EmojiRecents.suggestions(usage: usage, catalog: sampleCatalog(), limit: 10).map { $0.glyph } + + XCTAssertEqual(Array(glyphs.prefix(2)), ["๐Ÿฆ„", "๐Ÿ˜€"]) // recents first, most-recent first + XCTAssertTrue(glyphs.contains("๐Ÿ˜‚")) // joy padded in from the popularity prior + XCTAssertEqual(glyphs.count, Set(glyphs).count) // no duplicates + } + + func test_emptyUsageFallsBackToPopularityAndDropsUnpopular() { + let glyphs = EmojiRecents.suggestions(usage: .empty, catalog: sampleCatalog(), limit: 10).map { $0.glyph } + + XCTAssertTrue(glyphs.contains("๐Ÿ˜‚")) // joy is in the popularity prior + XCTAssertFalse(glyphs.contains("๐Ÿ˜€")) // grinning is neither recent nor popular + } + + func test_unresolvableRecentAliasIsSkipped() { + let usage = EmojiUsageSnapshot(recentAliases: ["not_in_catalog", "joy"], frequency: [:]) + let glyphs = EmojiRecents.suggestions(usage: usage, catalog: sampleCatalog(), limit: 5).map { $0.glyph } + + XCTAssertEqual(glyphs.first, "๐Ÿ˜‚") // the unresolvable alias is skipped, joy leads + } + + func test_limitIsRespected() { + XCTAssertEqual(EmojiRecents.suggestions(usage: .empty, catalog: sampleCatalog(), limit: 2).count, 2) + } +} diff --git a/CotabbyTests/EmojiSynonymCatalogTests.swift b/CotabbyTests/EmojiSynonymCatalogTests.swift new file mode 100644 index 0000000..15f633c --- /dev/null +++ b/CotabbyTests/EmojiSynonymCatalogTests.swift @@ -0,0 +1,34 @@ +import XCTest +@testable import Cotabby + +/// Tests for the curated intent/slang overlay that boosts canonical aliases for words people type. +final class EmojiSynonymCatalogTests: XCTestCase { + func test_exactKeyBoostsMappedAliases() { + let boosted = EmojiSynonymCatalog.boostedAliases(for: "lol") + XCTAssertTrue(boosted.exact.contains("joy")) + } + + func test_prefixKeyBoostsViaPrefix() { + // "lo" is a prefix of "lol" -> joy and "love" -> heart. + let boosted = EmojiSynonymCatalog.boostedAliases(for: "lo") + XCTAssertTrue(boosted.prefix.contains("joy")) + XCTAssertTrue(boosted.prefix.contains("heart")) + } + + func test_prefixExcludesExact() { + // "love" is an exact key; its aliases must not be duplicated into the prefix set. + let boosted = EmojiSynonymCatalog.boostedAliases(for: "love") + XCTAssertTrue(boosted.exact.contains("heart")) + XCTAssertTrue(boosted.exact.isDisjoint(with: boosted.prefix)) + } + + func test_singleCharacterDoesNotPrefixBoost() { + XCTAssertTrue(EmojiSynonymCatalog.boostedAliases(for: "l").prefix.isEmpty) + } + + func test_blankQueryBoostsNothing() { + let boosted = EmojiSynonymCatalog.boostedAliases(for: " ") + XCTAssertTrue(boosted.exact.isEmpty) + XCTAssertTrue(boosted.prefix.isEmpty) + } +} diff --git a/CotabbyTests/EmojiUsageStoreTests.swift b/CotabbyTests/EmojiUsageStoreTests.swift new file mode 100644 index 0000000..8b27528 --- /dev/null +++ b/CotabbyTests/EmojiUsageStoreTests.swift @@ -0,0 +1,105 @@ +import XCTest +@testable import Cotabby + +/// Tests for persisted emoji recents + frequency and the favorite rule the matcher reads. +/// +/// Like the other main-actor suites in this target, the class itself is intentionally NOT +/// `@MainActor` (an `@MainActor` XCTestCase subclass crashes the app-hosted test runner); main-actor +/// work runs inside `runOnMainActor`. Persistence is exercised through an in-memory +/// `EmojiUsageDefaults` so the suite stays hermetic and never touches process-global UserDefaults. +final class EmojiUsageStoreTests: XCTestCase { + /// Minimal in-memory stand-in for UserDefaults. + private final class InMemoryDefaults: EmojiUsageDefaults { + private var storage: [String: Data] = [:] + func data(forKey defaultName: String) -> Data? { storage[defaultName] } + func set(_ value: Any?, forKey defaultName: String) { storage[defaultName] = value as? Data } + func removeObject(forKey defaultName: String) { storage[defaultName] = nil } + } + + func test_recordPlacesMostRecentFirstAndCountsFrequency() { + runOnMainActor { + let sut = EmojiUsageStore(defaults: InMemoryDefaults()) + sut.record(alias: "joy") + sut.record(alias: "fire") + sut.record(alias: "joy") + + let snapshot = sut.snapshot() + XCTAssertEqual(snapshot.recentAliases, ["joy", "fire"]) // re-used joy returns to front, deduped + XCTAssertEqual(snapshot.frequency["joy"], 2) + XCTAssertEqual(snapshot.frequency["fire"], 1) + } + } + + func test_recordNormalizesAndIgnoresBlank() { + runOnMainActor { + let sut = EmojiUsageStore(defaults: InMemoryDefaults()) + sut.record(alias: " JOY ") + sut.record(alias: " ") + + let snapshot = sut.snapshot() + XCTAssertEqual(snapshot.recentAliases, ["joy"]) + XCTAssertEqual(snapshot.frequency["joy"], 1) + } + } + + func test_recentsAreCappedKeepingNewest() { + runOnMainActor { + let sut = EmojiUsageStore(defaults: InMemoryDefaults()) + for index in 0..<60 { + sut.record(alias: "alias\(index)") + } + + let snapshot = sut.snapshot() + XCTAssertEqual(snapshot.recentAliases.count, 50) + XCTAssertEqual(snapshot.recentAliases.first, "alias59") // newest first + XCTAssertFalse(snapshot.recentAliases.contains("alias0")) // oldest fell off the cap + } + } + + func test_clearForgetsEverything() { + runOnMainActor { + let sut = EmojiUsageStore(defaults: InMemoryDefaults()) + sut.record(alias: "joy") + sut.clear() + + let snapshot = sut.snapshot() + XCTAssertTrue(snapshot.recentAliases.isEmpty) + XCTAssertTrue(snapshot.frequency.isEmpty) + } + } + + func test_statePersistsAcrossInstances() { + runOnMainActor { + let defaults = InMemoryDefaults() + EmojiUsageStore(defaults: defaults).record(alias: "rocket") + + let reopened = EmojiUsageStore(defaults: defaults) // new instance, same backing store + XCTAssertEqual(reopened.snapshot().recentAliases, ["rocket"]) + XCTAssertEqual(reopened.snapshot().frequency["rocket"], 1) + } + } + + func test_isFavoriteHonorsRecencyAndFrequency() { + let recentOnly = EmojiUsageSnapshot(recentAliases: ["wave"], frequency: [:]) + XCTAssertTrue(recentOnly.isFavorite("wave")) + XCTAssertFalse(recentOnly.isFavorite("fire")) + + let frequentEnough = EmojiUsageSnapshot(recentAliases: [], frequency: ["fire": 2]) + XCTAssertTrue(frequentEnough.isFavorite("fire")) + + let usedOnce = EmojiUsageSnapshot(recentAliases: [], frequency: ["fire": 1]) + XCTAssertFalse(usedOnce.isFavorite("fire")) + } +} + +private func runOnMainActor( + _ body: @MainActor () throws -> Result +) rethrows -> Result { + if Thread.isMainThread { + return try MainActor.assumeIsolated(body) + } + + return try DispatchQueue.main.sync { + try MainActor.assumeIsolated(body) + } +} From facf0f3902b14419e04901e19e1f6478f76bfb74 Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Sun, 31 May 2026 23:35:24 -0700 Subject: [PATCH 2/2] Address Greptile review: dead-code return and explicit @MainActor usage closures - EmojiMatcher.isSubsequence: the trailing `return matched == needle.count` was always false once the loop exits (the in-loop guard is the only true path); replace it with an explicit `return false`. - EmojiPickerController: annotate the emojiUsage/recordEmojiUsage closures as @MainActor so the isolation contract is explicit, since they read and mutate main-actor EmojiUsageStore state. --- Cotabby/App/Coordinators/EmojiPickerController.swift | 10 ++++++---- Cotabby/Support/EmojiMatcher.swift | 4 +++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/Cotabby/App/Coordinators/EmojiPickerController.swift b/Cotabby/App/Coordinators/EmojiPickerController.swift index ecd62ff..9e4ffc9 100644 --- a/Cotabby/App/Coordinators/EmojiPickerController.swift +++ b/Cotabby/App/Coordinators/EmojiPickerController.swift @@ -29,9 +29,11 @@ final class EmojiPickerController { /// The accept-word key label shown as a keycap on the highlighted row; `nil` hides the hint. private let acceptKeyLabel: () -> String? /// Live personal usage snapshot, read at match time to rank favorites and seed the bare-`:` panel. - private let emojiUsage: () -> EmojiUsageSnapshot + /// `@MainActor`: it reads main-actor `EmojiUsageStore` state, matching where the picker runs. + private let emojiUsage: @MainActor () -> EmojiUsageSnapshot /// Records a committed emoji's primary alias so future ranking and recents reflect it. - private let recordEmojiUsage: (String) -> Void + /// `@MainActor`: it mutates main-actor `EmojiUsageStore` state. + private let recordEmojiUsage: @MainActor (String) -> Void private var currentQuery = "" private var matches: [EmojiMatch] = [] @@ -63,8 +65,8 @@ final class EmojiPickerController { isEnabled: @escaping () -> Bool, emojiPreferences: @escaping () -> EmojiVariantPreferences, acceptKeyLabel: @escaping () -> String?, - emojiUsage: @escaping () -> EmojiUsageSnapshot, - recordEmojiUsage: @escaping (String) -> Void + emojiUsage: @MainActor @escaping () -> EmojiUsageSnapshot, + recordEmojiUsage: @MainActor @escaping (String) -> Void ) { self.matcher = matcher self.panel = panel diff --git a/Cotabby/Support/EmojiMatcher.swift b/Cotabby/Support/EmojiMatcher.swift index 878fb8a..77b7d51 100644 --- a/Cotabby/Support/EmojiMatcher.swift +++ b/Cotabby/Support/EmojiMatcher.swift @@ -231,7 +231,9 @@ struct EmojiMatcher { matched += 1 if matched == needle.count { return true } } - return matched == needle.count + // The in-loop guard returns true the instant all of `needle` is matched, so reaching here + // means the haystack was exhausted first. + return false } /// Optimal string alignment distance: Levenshtein plus adjacent transpositions as a single edit.