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
36 changes: 36 additions & 0 deletions Cotabby.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

49 changes: 39 additions & 10 deletions Cotabby/App/Coordinators/EmojiPickerController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,12 @@ 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.
/// `@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.
/// `@MainActor`: it mutates main-actor `EmojiUsageStore` state.
private let recordEmojiUsage: @MainActor (String) -> Void

private var currentQuery = ""
private var matches: [EmojiMatch] = []
Expand Down Expand Up @@ -58,7 +64,9 @@ final class EmojiPickerController {
inserter: any EmojiTextInserting,
isEnabled: @escaping () -> Bool,
emojiPreferences: @escaping () -> EmojiVariantPreferences,
acceptKeyLabel: @escaping () -> String?
acceptKeyLabel: @escaping () -> String?,
Comment thread
greptile-apps[bot] marked this conversation as resolved.
emojiUsage: @MainActor @escaping () -> EmojiUsageSnapshot,
recordEmojiUsage: @MainActor @escaping (String) -> Void
) {
self.matcher = matcher
self.panel = panel
Expand All @@ -68,6 +76,8 @@ final class EmojiPickerController {
self.isEnabled = isEnabled
self.emojiPreferences = emojiPreferences
self.acceptKeyLabel = acceptKeyLabel
self.emojiUsage = emojiUsage
self.recordEmojiUsage = recordEmojiUsage
}

func start() {
Expand Down Expand Up @@ -251,8 +261,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)
Expand All @@ -262,11 +274,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() {
Expand All @@ -291,7 +311,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
}

Expand All @@ -306,13 +332,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
Expand Down
8 changes: 6 additions & 2 deletions Cotabby/App/Coordinators/SettingsCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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?

Expand All @@ -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
Expand All @@ -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.
Expand All @@ -78,7 +81,8 @@ final class SettingsCoordinator: NSObject, NSWindowDelegate {
performanceMetricsStore: performanceMetricsStore,
suggestionEngine: suggestionEngine,
configuration: configuration,
onShowWelcome: onShowWelcome
onShowWelcome: onShowWelcome,
clearEmojiHistory: clearEmojiHistory
)
)
)
Expand Down
13 changes: 11 additions & 2 deletions Cotabby/App/Core/CotabbyAppEnvironment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -165,7 +170,8 @@ final class CotabbyAppEnvironment {
performanceMetricsStore: performanceMetricsStore,
onShowWelcome: { [weak welcomeCoordinator] in
welcomeCoordinator?.showWelcome()
}
},
clearEmojiHistory: { emojiUsageStore.clear() }
)

let interactionState = SuggestionInteractionState()
Expand Down Expand Up @@ -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`.
Expand All @@ -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
Expand Down
31 changes: 31 additions & 0 deletions Cotabby/Models/EmojiUsageModels.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
92 changes: 92 additions & 0 deletions Cotabby/Models/EmojiUsageStore.swift
Original file line number Diff line number Diff line change
@@ -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)
}
}
21 changes: 20 additions & 1 deletion Cotabby/Support/EmojiCatalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,18 +25,37 @@ 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() },
lowerKeywords: entry.keywords.map { $0.lowercased() },
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
}
}

Expand Down
Loading