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
18 changes: 15 additions & 3 deletions Cotabby/App/Coordinators/EmojiPickerController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,19 @@ import Logging
@MainActor
final class EmojiPickerController {
private var machine = EmojiTriggerStateMachine()
private let matcher: EmojiMatcher
/// Built on first use rather than injected ready-made: the bundled catalog decode plus index
/// costs a few MB of resident strings, and most sessions never open the picker. The one-time
/// build lands on the first `:` capture's first match request, which is user-paced.
private let matcherProvider: @MainActor () -> EmojiMatcher
private var cachedMatcher: EmojiMatcher?
private var matcher: EmojiMatcher {
if let cachedMatcher {
return cachedMatcher
}
let built = matcherProvider()
cachedMatcher = built
return built
}
private let panel: any EmojiPickerPanelPresenting
private let focusModel: any SuggestionFocusProviding
private let inputMonitor: any EmojiInputIntercepting
Expand Down Expand Up @@ -66,7 +78,7 @@ final class EmojiPickerController {
private static let longPauseNanoseconds: UInt64 = 8_000_000_000

init(
matcher: EmojiMatcher,
matcherProvider: @MainActor @escaping () -> EmojiMatcher,
panel: any EmojiPickerPanelPresenting,
focusModel: any SuggestionFocusProviding,
inputMonitor: any EmojiInputIntercepting,
Expand All @@ -77,7 +89,7 @@ final class EmojiPickerController {
emojiUsage: @MainActor @escaping () -> EmojiUsageSnapshot,
recordEmojiUsage: @MainActor @escaping (String) -> Void
) {
self.matcher = matcher
self.matcherProvider = matcherProvider
self.panel = panel
self.focusModel = focusModel
self.inputMonitor = inputMonitor
Expand Down
20 changes: 10 additions & 10 deletions Cotabby/App/Core/CotabbyAppEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -188,16 +188,14 @@ final class CotabbyAppEnvironment {
// Constructed once at app scope so the underlying `NSSpellChecker` document tag survives
// across coordinator state transitions instead of churning per keystroke.
let spellChecker = CurrentWordSpellChecker()
let enabledSpellingLanguages = SpellingDictionaryCatalog.languages(
for: suggestionSettings.enabledSpellingDictionaryCodes
)
// Preserve the existing warm English path when it is enabled. A sole non-English choice is
// also preloaded; broader multilingual sets stay lazy so app launch never builds every index.
let preloadSpellingLanguage = enabledSpellingLanguages.count == 1
? enabledSpellingLanguages.first
: enabledSpellingLanguages.first(where: { $0 == .english })
// No launch-time preload: a fully built index costs tens of MB resident for a feature that
// is only consulted once the typo gate actually finds a misspelling, and the first
// consultation triggers the same background build (`cachedIndexOrRequestLoad`) with the
// designed NSSpellChecker fallback ranking corrections until it lands. The only cost of
// staying cold is that the first correction or two after launch rank via the system
// checker instead of corpus frequency.
let symSpellCorrector = SymSpellCorrector(
preloadLanguage: preloadSpellingLanguage
preloadLanguage: nil
)
let suggestionCoordinator = SuggestionCoordinator(
permissionManager: permissionManager,
Expand All @@ -221,7 +219,9 @@ final class CotabbyAppEnvironment {
// The emoji picker is a sibling to the suggestion coordinator. It reuses the input monitor,
// focus model, and inserter, but owns its own trigger state machine and floating panel.
let emojiPickerController = EmojiPickerController(
matcher: EmojiMatcher(catalog: EmojiCatalog.bundled()),
// Deferred: decoding and indexing the bundled emoji catalog costs a few MB of resident
// strings that most sessions never use; the picker builds it on first `:` capture.
matcherProvider: { EmojiMatcher(catalog: EmojiCatalog.bundled()) },
panel: EmojiPickerPanelController(),
focusModel: focusModel,
inputMonitor: inputMonitor,
Expand Down
28 changes: 28 additions & 0 deletions Cotabby/Models/EmojiUsageStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ final class EmojiUsageStore {
/// 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
/// Bounds for the frequency map, which previously grew one entry per unique emoji forever.
/// Frequency only breaks ties inside a relevance tier, so trimming the rarest entries cannot
/// change which emoji match a query. Trimming triggers above `frequencyCap` and cuts down to
/// `frequencyTrimTarget` so steady use does not re-sort the map on every commit.
private static let frequencyCap = 300
private static let frequencyTrimTarget = 200
private static let storageKey = "cotabbyEmojiUsage"

private struct Persisted: Codable {
Expand Down Expand Up @@ -68,9 +74,31 @@ final class EmojiUsageStore {
recents.removeLast(recents.count - Self.recentsCap)
}
frequency[alias, default: 0] += 1
trimFrequencyIfNeeded()
persist()
}

/// Drops the least-used aliases once the map outgrows its cap, keeping current recents so a
/// just-used emoji can never lose its favorite ranking to the trim.
private func trimFrequencyIfNeeded() {
guard frequency.count > Self.frequencyCap else {
return
}

let keepAlways = Set(recents)
let removable = frequency
.filter { !keepAlways.contains($0.key) }
.sorted { $0.value < $1.value }
// The effective floor is the larger of the trim target and the protected recents, so the
// bound holds by construction even if `recentsCap` is ever raised past
// `frequencyTrimTarget` instead of silently under-trimming.
let effectiveTarget = max(Self.frequencyTrimTarget, keepAlways.count)
let overflow = frequency.count - effectiveTarget
for (alias, _) in removable.prefix(overflow) {
frequency.removeValue(forKey: alias)
}
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.

/// Immutable snapshot for the pure ranker and recents helper.
func snapshot() -> EmojiUsageSnapshot {
EmojiUsageSnapshot(recentAliases: recents, frequency: frequency)
Expand Down
2 changes: 1 addition & 1 deletion CotabbyTests/EmojiPickerControllerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ final class EmojiPickerControllerTests: XCTestCase {
let usageRecorder = UsageRecorder()
usage = usageRecorder
controller = EmojiPickerController(
matcher: EmojiMatcher(catalog: catalog),
matcherProvider: { EmojiMatcher(catalog: catalog) },
panel: panel,
focusModel: focus,
inputMonitor: monitor,
Expand Down
29 changes: 29 additions & 0 deletions CotabbyTests/EmojiUsageStoreTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,35 @@ final class EmojiUsageStoreTests: XCTestCase {
}
}

func test_frequencyIsBoundedAndKeepsHeavyHitters() {
runOnMainActor {
let sut = EmojiUsageStore(defaults: InMemoryDefaults())
// A heavy favorite that must survive trimming even after it falls out of recents.
for _ in 0..<10 {
sut.record(alias: "joy")
}
// Flood with one-shot unique aliases to push the map past its cap several times over.
for index in 0..<320 {
sut.record(alias: "flood\(index)")
}

let snapshot = sut.snapshot()
// Exactly 220: the 301st unique alias trips the cap and trims down to the 200 target,
// and the remaining 20 flood inserts land afterwards. An exact bound catches both a
// trim that never fires and one that removes fewer entries than the target demands.
XCTAssertEqual(
snapshot.frequency.count,
220,
"The frequency map must trim to its target instead of growing one entry per unique emoji forever."
)
XCTAssertEqual(
snapshot.frequency["joy"],
10,
"Trimming removes the rarest entries; heavy hitters keep their counts."
)
}
}

func test_clearForgetsEverything() {
runOnMainActor {
let sut = EmojiUsageStore(defaults: InMemoryDefaults())
Expand Down