From 08932a2138e527f4386a3085b6da77a35c6e0713 Mon Sep 17 00:00:00 2001 From: Seth Fitzsimmons Date: Tue, 3 Nov 2026 11:22:42 -0800 Subject: [PATCH] Fill Quick Access auto slots from used symbols too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A used emoji filled a Quick Access auto slot; a used symbol (β†’) never did. Auto-fill dropped every SYM_ key from usageCounts and resolved the rest only through the emoji database. It now keeps them and resolves them through SymbolsDatabase, as pinned symbol slots already did. While the Symbols trigger is off, used symbols stay out of the auto slots; a pinned symbol still shows. Symbols typed in full (`::arrow_right:`) were never recorded as used, so they could not reach a slot either. The exact-match symbol path now goes through recordUsage like the emoji one, which also feeds the search frequency boost and the symbol telemetry count. Resolving a symbol runs the CoreText sweep on first access, and the bare-`:` pill is built inside the event-tap callback. Search, the browser and byHexcode now share one sweep (SymbolsDatabase.all), and launch runs it on a utility queue when a pin, or with Symbols on a used symbol, needs it, so the first `:` doesn't wait on it. --- CLAUDE.md | 2 +- Sources/Mojito/App/AppDelegate.swift | 15 ++++++++ Sources/Mojito/App/Engine.swift | 7 ++-- .../Browser/EmojiBrowserViewModel.swift | 6 ++-- Sources/Mojito/Debug/DebugReport.swift | 2 +- Sources/Mojito/EmojiDB/FuzzyMatcher.swift | 12 ++----- Sources/Mojito/EmojiDB/QuickAccessStore.swift | 30 +++++++++------- Sources/Mojito/EmojiDB/SymbolsDatabase.swift | 13 ++++--- .../Mojito/Settings/QuickAccessSettings.swift | 5 ++- Tests/MojitoTests/FavoritesBrowseTests.swift | 34 ++++++++++++++++--- 10 files changed, 90 insertions(+), 36 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bfb62da..ac008a3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -146,7 +146,7 @@ Cost scales with total haystack count (~24k for the English corpus: shortcodes + Semantic keywords in `t` come from two upstreams, merged by `build_emoji_db.py`: emojibase's literal CLDR tags, and Emoogle's concept keywords (MIT) which cover associations CLDR omits (`deploy` on πŸš€, `ghosting` on πŸ‘»). Emoogle keys are emoji characters with inconsistent FE0F, and its phrases contain spaces and punctuation that `:query:` can't capture β€” `emoji_key()` and `normalize_keyword()` handle both. Emojibase's own tags are deliberately **not** run through `normalize_keyword` (it would eat the keycap digit tags `0`–`9` and `-1`). -Special "pinned" rows are appended after the fzy results when the lowercased query hashes to an entry in `EggIndex.prefix` (which already covers 3+ char prefixes of every registered easter-egg trigger). The pinned hexcode is the opaque id (`k01`, `k02`, …) returned by `EggIndex`, so `Engine.insert` and `PickerView` route on that id without ever string-matching the keyword. Symbols (β˜… ⌘ βŒ₯ etc.) live in `SymbolsDatabase.indexed()` and get prepended to the corpus only when `PrefsKey.symbolsEnabled` is true. +Special "pinned" rows are appended after the fzy results when the lowercased query hashes to an entry in `EggIndex.prefix` (which already covers 3+ char prefixes of every registered easter-egg trigger). The pinned hexcode is the opaque id (`k01`, `k02`, …) returned by `EggIndex`, so `Engine.insert` and `PickerView` route on that id without ever string-matching the keyword. Symbols (β˜… ⌘ βŒ₯ etc.) live in `SymbolsDatabase.all`, swept once per launch, and get appended to the corpus only when the Symbols trigger is enabled. The sweep is slow and its first access blocks, so `AppDelegate` warms it off the main thread when Quick Access holds a used or pinned symbol; call `all` or `byHexcode`, never `indexed()`, outside tests. ### Skin tone diff --git a/Sources/Mojito/App/AppDelegate.swift b/Sources/Mojito/App/AppDelegate.swift index f424ee0..c7945da 100644 --- a/Sources/Mojito/App/AppDelegate.swift +++ b/Sources/Mojito/App/AppDelegate.swift @@ -46,6 +46,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { engine.attach(permissions: permissions) // Start observing focus changes before the user types their first `:`. _ = FocusedElementCache.shared + warmSymbolsIfQuickAccessNeedsThem() UpdaterCoordinator.shared.start() // About β†’ Stats reads this for "User since". @@ -184,6 +185,20 @@ final class AppDelegate: NSObject, NSApplicationDelegate, ObservableObject { .store(in: &observers) } + /// Quick Access resolves a pinned `SYM_…` hexcode, and a used one while + /// the Symbols trigger is on, through `SymbolsDatabase`, whose first access + /// runs the slow CoreText sweep. The bare-`:` pill is built inside the + /// event-tap callback, so build it here, off the main thread, instead of on + /// the first `:`. + private func warmSymbolsIfQuickAccessNeedsThem() { + let usage = (UserDefaults.standard.dictionary(forKey: PrefsKey.usageCounts) as? [String: Int]) ?? [:] + let isSymbol = { (hex: String) in hex.hasPrefix("SYM_") } + guard (TriggerConfigStore.load().symbols.enabled && usage.keys.contains(where: isSymbol)) + || QuickAccessStore.shared.slots.contains(where: { $0.map(isSymbol) ?? false }) + else { return } + DispatchQueue.global(qos: .utility).async { _ = SymbolsDatabase.byHexcode } + } + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { false } diff --git a/Sources/Mojito/App/Engine.swift b/Sources/Mojito/App/Engine.swift index 22da205..020ff41 100644 --- a/Sources/Mojito/App/Engine.swift +++ b/Sources/Mojito/App/Engine.swift @@ -966,7 +966,10 @@ final class Engine: ObservableObject, KeyMonitorDelegate { /// auto-fill), then a trailing "Browse all emojis…" row. private func emptyQueryResults() -> [ScoredEmoji] { var rows = QuickAccess - .resolved(store: quickAccess, database: database, usage: usage) + .resolved( + store: quickAccess, database: database, usage: usage, + symbolsEnabled: stateMachine.config.symbols.enabled + ) .map { ScoredEmoji(emoji: $0, matchedShortcode: $0.primaryShortcode) } rows.append(EmojiBrowser.browseRow) return rows @@ -1175,7 +1178,7 @@ final class Engine: ObservableObject, KeyMonitorDelegate { ) if let exact = hits.first(where: { $0.matchedShortcode.lowercased() == key }) { TextInserter.replace(charactersToDelete: charsToDelete, with: exact.emoji.character) - bumpSymbolCounter() + recordUsage(emoji: exact.emoji) } return } diff --git a/Sources/Mojito/Browser/EmojiBrowserViewModel.swift b/Sources/Mojito/Browser/EmojiBrowserViewModel.swift index ecab1c0..773fc5f 100644 --- a/Sources/Mojito/Browser/EmojiBrowserViewModel.swift +++ b/Sources/Mojito/Browser/EmojiBrowserViewModel.swift @@ -291,7 +291,9 @@ final class EmojiBrowserViewModel: ObservableObject { } // Quick Access first (mirrors the bare-`:` pill), then the categories. - add(.quickAccess, QuickAccess.resolved(store: quickAccess, database: database, usage: usage)) + add(.quickAccess, QuickAccess.resolved( + store: quickAccess, database: database, usage: usage, symbolsEnabled: symbolsEnabled + )) for category in EmojiCategory.allCases where !category.isDynamic { let groups = Set(category.groups) @@ -302,7 +304,7 @@ final class EmojiBrowserViewModel: ObservableObject { // is slow and the corpus is large. Forced on in the alias picker so a // symbol can be chosen as an alias target. if symbolsEnabled { - add(.specialCharacters, SymbolsDatabase.indexed().map(\.emoji)) + add(.specialCharacters, SymbolsDatabase.all.map(\.emoji)) } return sections } diff --git a/Sources/Mojito/Debug/DebugReport.swift b/Sources/Mojito/Debug/DebugReport.swift index 9399ce9..82fb923 100644 --- a/Sources/Mojito/Debug/DebugReport.swift +++ b/Sources/Mojito/Debug/DebugReport.swift @@ -175,7 +175,7 @@ enum DebugReport { private static func databaseSection() -> String { var s = "## Database\n" s += "- emoji.count: \(EmojiDatabase.shared.all.count)\n" - s += "- symbols.count: \(SymbolsDatabase.indexed().count)\n" + s += "- symbols.count: \(SymbolsDatabase.all.count)\n" return s } diff --git a/Sources/Mojito/EmojiDB/FuzzyMatcher.swift b/Sources/Mojito/EmojiDB/FuzzyMatcher.swift index 5a4c6fa..f0e4d34 100644 --- a/Sources/Mojito/EmojiDB/FuzzyMatcher.swift +++ b/Sources/Mojito/EmojiDB/FuzzyMatcher.swift @@ -16,12 +16,6 @@ struct ScoredEmoji { } } -/// Built lazily so apps that never enable Symbols don't pay for it. -@MainActor -private enum SymbolsCorpus { - static let entries: [IndexedEmoji] = SymbolsDatabase.indexed() -} - /// Set by Engine from symbol prefs + state-machine scope. enum SearchCorpus { case emojiOnly // `:foo` with symbols off, OR symbols on + double-colon required @@ -174,11 +168,11 @@ struct FuzzyMatcher { // rendered) twice, colliding on hexcode in the picker's ForEach. let promoted = database.aliasedSymbolHexcodes let symbols = promoted.isEmpty - ? SymbolsCorpus.entries - : SymbolsCorpus.entries.filter { !promoted.contains($0.emoji.hexcode) } + ? SymbolsDatabase.all + : SymbolsDatabase.all.filter { !promoted.contains($0.emoji.hexcode) } pool = database.indexed + symbols case .symbolsOnly: - pool = SymbolsCorpus.entries + pool = SymbolsDatabase.all } var trimmed = rankedResults( diff --git a/Sources/Mojito/EmojiDB/QuickAccessStore.swift b/Sources/Mojito/EmojiDB/QuickAccessStore.swift index 46a8b83..1e99519 100644 --- a/Sources/Mojito/EmojiDB/QuickAccessStore.swift +++ b/Sources/Mojito/EmojiDB/QuickAccessStore.swift @@ -61,18 +61,20 @@ struct ResolvedSlot { } /// Resolves the Quick Access slots to concrete emoji: pinned slots show their -/// emoji; auto slots fill, in slot order, from most-used (skipping symbols, -/// pinned glyphs, and anything already shown). +/// emoji; auto slots fill, in slot order, from most-used (emoji, plus symbols +/// while the Symbols trigger is on), skipping pinned glyphs and anything +/// already shown. A pinned symbol shows either way. @MainActor enum QuickAccess { static func resolvedPerSlot( store: QuickAccessStore, database: EmojiDatabase, - usage: [String: Int] + usage: [String: Int], + symbolsEnabled: Bool ) -> [ResolvedSlot] { let pinned = Set(store.slots.compactMap { $0 }) var pool = usage - .filter { $0.value > 0 && !$0.key.hasPrefix("SYM_") && !pinned.contains($0.key) } + .filter { $0.value > 0 && !pinned.contains($0.key) && (symbolsEnabled || !$0.key.hasPrefix("SYM_")) } .sorted { $0.value != $1.value ? $0.value > $1.value : $0.key < $1.key } .map(\.key) .makeIterator() @@ -80,15 +82,11 @@ enum QuickAccess { var out: [ResolvedSlot] = [] for slot in store.slots { if let hex = slot { - // A pinned symbol stores a `SYM_…` hexcode, which lives in - // SymbolsDatabase, not the emoji DB. - let emoji = database.byHexcode[hex] - ?? (hex.hasPrefix("SYM_") ? SymbolsDatabase.byHexcode[hex] : nil) - out.append(ResolvedSlot(emoji: emoji, pinned: true)) + out.append(ResolvedSlot(emoji: lookup(hex, in: database), pinned: true)) } else { var fill: Emoji? while let hex = pool.next() { - guard !used.contains(hex), let emoji = database.byHexcode[hex] else { continue } + guard !used.contains(hex), let emoji = lookup(hex, in: database) else { continue } used.insert(hex) fill = emoji break @@ -99,13 +97,21 @@ enum QuickAccess { return out } + /// A `SYM_…` hexcode lives in SymbolsDatabase, not the emoji DB. Its index + /// is built on first access, so it is only touched for a symbol hexcode. + private static func lookup(_ hex: String, in database: EmojiDatabase) -> Emoji? { + database.byHexcode[hex] ?? (hex.hasPrefix("SYM_") ? SymbolsDatabase.byHexcode[hex] : nil) + } + /// Flat list of the resolved emoji (drops empty auto slots) β€” the bare-`:` /// pill corpus and the browser's Quick Access section. static func resolved( store: QuickAccessStore, database: EmojiDatabase, - usage: [String: Int] + usage: [String: Int], + symbolsEnabled: Bool ) -> [Emoji] { - resolvedPerSlot(store: store, database: database, usage: usage).compactMap(\.emoji) + resolvedPerSlot(store: store, database: database, usage: usage, symbolsEnabled: symbolsEnabled) + .compactMap(\.emoji) } } diff --git a/Sources/Mojito/EmojiDB/SymbolsDatabase.swift b/Sources/Mojito/EmojiDB/SymbolsDatabase.swift index 94f3ee8..3fa0baf 100644 --- a/Sources/Mojito/EmojiDB/SymbolsDatabase.swift +++ b/Sources/Mojito/EmojiDB/SymbolsDatabase.swift @@ -6,11 +6,16 @@ import Foundation /// programmatic sweep that derives shortcodes from Unicode names /// (`PLACE OF INTEREST SIGN` β†’ `place_of_interest_sign`). Opt-in. enum SymbolsDatabase { - /// `SYM_…` hexcode β†’ symbol, for resolving a pinned Quick Access slot. - /// Built once on first access (the sweep is the slow part); only touched - /// when a symbol is actually pinned. + /// The symbol corpus, swept once per launch. The CoreText sweep is slow, + /// so `AppDelegate` warms this off the main thread when Quick Access will + /// need it, rather than letting the first `:` pay for it. + static let all: [IndexedEmoji] = indexed() + + /// `SYM_…` hexcode β†’ symbol, for resolving a stored hexcode (a Quick Access + /// slot or used symbol, an alias target). Reach for it only for a `SYM_` + /// key: first access builds `all`. static let byHexcode: [String: Emoji] = Dictionary( - indexed().map { ($0.emoji.hexcode, $0.emoji) }, + all.map { ($0.emoji.hexcode, $0.emoji) }, uniquingKeysWith: { first, _ in first } ) diff --git a/Sources/Mojito/Settings/QuickAccessSettings.swift b/Sources/Mojito/Settings/QuickAccessSettings.swift index b7fce4c..1ff566b 100644 --- a/Sources/Mojito/Settings/QuickAccessSettings.swift +++ b/Sources/Mojito/Settings/QuickAccessSettings.swift @@ -55,7 +55,10 @@ struct QuickAccessSection: View { } private var slotGrid: some View { - let slots = QuickAccess.resolvedPerSlot(store: store, database: database, usage: usage) + let slots = QuickAccess.resolvedPerSlot( + store: store, database: database, usage: usage, + symbolsEnabled: TriggerConfigStore.load().symbols.enabled + ) return VStack(alignment: .leading, spacing: 10) { HStack(spacing: 10) { // Mirror the live pill: a glassy capsule of cells. diff --git a/Tests/MojitoTests/FavoritesBrowseTests.swift b/Tests/MojitoTests/FavoritesBrowseTests.swift index 2c9946f..398fb52 100644 --- a/Tests/MojitoTests/FavoritesBrowseTests.swift +++ b/Tests/MojitoTests/FavoritesBrowseTests.swift @@ -272,7 +272,7 @@ struct QuickAccessStoreTests { let b = db.all[2].hexcode store.pin(pin, at: 1) // slot 0 auto β†’ top most-used (b:10), slot 1 pinned, slot 2 auto β†’ next (a:3) - let resolved = QuickAccess.resolved(store: store, database: db, usage: [a: 3, b: 10]).map(\.hexcode) + let resolved = QuickAccess.resolved(store: store, database: db, usage: [a: 3, b: 10], symbolsEnabled: true).map(\.hexcode) #expect(resolved.count >= 3) #expect(resolved[0] == b) #expect(resolved[1] == pin) @@ -286,10 +286,36 @@ struct QuickAccessStoreTests { let x = db.all[0].hexcode store.pin(x, at: 0) // x is also the most-used β€” it must not also appear in an auto slot. - let resolved = QuickAccess.resolved(store: store, database: db, usage: [x: 99]).map(\.hexcode) + let resolved = QuickAccess.resolved(store: store, database: db, usage: [x: 99], symbolsEnabled: true).map(\.hexcode) #expect(resolved.filter { $0 == x }.count == 1) } + @Test func resolvedAutoFillsUsedSymbols() throws { + let db = EmojiDatabase.shared + let e = try #require(db.all.first).hexcode + let sym = "SYM_cmd" + try #require(SymbolsDatabase.byHexcode[sym] != nil) + let store = makeStore() + // A symbol used more than an emoji takes the first auto slot. + let resolved = QuickAccess.resolved(store: store, database: db, usage: [e: 2, sym: 7], symbolsEnabled: true).map(\.hexcode) + #expect(Array(resolved.prefix(2)) == [sym, e]) + } + + @Test func resolvedSkipsUsedSymbolsWhenSymbolsOff() throws { + let db = EmojiDatabase.shared + let e = try #require(db.all.first).hexcode + let used = "SYM_cmd", pinned = "SYM_option" + try #require(SymbolsDatabase.byHexcode[used] != nil) + try #require(SymbolsDatabase.byHexcode[pinned] != nil) + let store = makeStore() + store.pin(pinned, at: 0) + // With Symbols off, a used symbol never takes an auto slot; a pin stays. + let resolved = QuickAccess.resolved( + store: store, database: db, usage: [e: 2, used: 7], symbolsEnabled: false + ).map(\.hexcode) + #expect(resolved == [pinned, e]) + } + @Test func resolvedIsDeterministic() { let db = EmojiDatabase.shared guard db.all.count >= 2 else { return } @@ -297,8 +323,8 @@ struct QuickAccessStoreTests { let x = db.all[0].hexcode let y = db.all[1].hexcode let usage = [x: 5, y: 5] - let first = QuickAccess.resolved(store: store, database: db, usage: usage).map(\.hexcode) - let second = QuickAccess.resolved(store: store, database: db, usage: usage).map(\.hexcode) + let first = QuickAccess.resolved(store: store, database: db, usage: usage, symbolsEnabled: true).map(\.hexcode) + let second = QuickAccess.resolved(store: store, database: db, usage: usage, symbolsEnabled: true).map(\.hexcode) #expect(first == second) #expect(Array(first.prefix(2)) == [min(x, y), max(x, y)]) }