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)]) }