Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
15 changes: 15 additions & 0 deletions Sources/Mojito/App/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down Expand Up @@ -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
}
Expand Down
7 changes: 5 additions & 2 deletions Sources/Mojito/App/Engine.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand Down
6 changes: 4 additions & 2 deletions Sources/Mojito/Browser/EmojiBrowserViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion Sources/Mojito/Debug/DebugReport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
12 changes: 3 additions & 9 deletions Sources/Mojito/EmojiDB/FuzzyMatcher.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down
30 changes: 18 additions & 12 deletions Sources/Mojito/EmojiDB/QuickAccessStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -61,34 +61,32 @@ 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()
var used = pinned
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
Expand All @@ -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)
}
}
13 changes: 9 additions & 4 deletions Sources/Mojito/EmojiDB/SymbolsDatabase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
)

Expand Down
5 changes: 4 additions & 1 deletion Sources/Mojito/Settings/QuickAccessSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
34 changes: 30 additions & 4 deletions Tests/MojitoTests/FavoritesBrowseTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -286,19 +286,45 @@ 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 }
let store = makeStore()
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)])
}
Expand Down
Loading