diff --git a/java/src/org/futo/inputmethod/latin/EmojiDictionary.kt b/java/src/org/futo/inputmethod/latin/EmojiDictionary.kt index 2dee0beb5e..f9578a0203 100644 --- a/java/src/org/futo/inputmethod/latin/EmojiDictionary.kt +++ b/java/src/org/futo/inputmethod/latin/EmojiDictionary.kt @@ -24,38 +24,37 @@ class EmojiDictionary(locale: Locale) : Dictionary(TYPE_EMOJI, locale) { ): ArrayList? { if(DataStoreHelper.getSetting(SHOW_EMOJI_SUGGESTIONS) == false) return arrayListOf() - var emoji: String? = null + var candidates: List = emptyList() + if(!typedWord.isEmpty()) { - if(isWordValidForShortcut((typedWord))) - emoji = PersistentEmojiState.getShortcut(mLocale, typedWord.lowercase(mLocale)) + if(isWordValidForShortcut(typedWord)) + candidates = PersistentEmojiState.getShortcuts(mLocale, typedWord.lowercase(mLocale)) } else if((ngramContext?.prevWordCount ?: 0) > 0 && isBatchMode == false) { val prevWord = ngramContext?.getNthPrevWord(1)?.toString() ?: "" if(!prevWord.isEmpty()) { if(isWordValidForShortcut(prevWord)) - emoji = PersistentEmojiState.getShortcut(mLocale, prevWord.lowercase(mLocale)) + candidates = PersistentEmojiState.getShortcuts(mLocale, prevWord.lowercase(mLocale)) } } - if(emoji != null) { - emoji = PersistentEmojiState.transformEmojiToLastSkinTone(emoji) - } + // Dedup after skin-tone: two base emojis can collapse to the same toned string. + candidates = candidates.map { PersistentEmojiState.transformEmojiToLastSkinTone(it) }.distinct() - return if(emoji != null) { - val score = Suggest.SUPPRESS_SUGGEST_THRESHOLD + 1 - arrayListOf( - SuggestedWords.SuggestedWordInfo( - emoji, - "", - score, - SuggestedWords.SuggestedWordInfo.KIND_EMOJI_SUGGESTION, - null, - SuggestedWords.SuggestedWordInfo.NOT_AN_INDEX, - SuggestedWords.SuggestedWordInfo.NOT_A_CONFIDENCE - ) + // Threshold filter is strict (<), so THRESHOLD+0 survives. Data layer caps at + // take(MAX_EMOJI_SUGGESTIONS). + val baseScore = Suggest.SUPPRESS_SUGGEST_THRESHOLD + 1 + + return ArrayList(candidates.mapIndexed { i, emoji -> + SuggestedWords.SuggestedWordInfo( + emoji, + "", + baseScore - i, + SuggestedWords.SuggestedWordInfo.KIND_EMOJI_SUGGESTION, + null, + SuggestedWords.SuggestedWordInfo.NOT_AN_INDEX, + SuggestedWords.SuggestedWordInfo.NOT_A_CONFIDENCE ) - } else { - arrayListOf() - } + }) } override fun getSuggestions( @@ -76,4 +75,4 @@ class EmojiDictionary(locale: Locale) : Dictionary(TYPE_EMOJI, locale) { override fun isInDictionary(word: String?): Boolean { return false } -} \ No newline at end of file +} diff --git a/java/src/org/futo/inputmethod/latin/uix/ActionBar.kt b/java/src/org/futo/inputmethod/latin/uix/ActionBar.kt index 8ae519b348..b0eed3642c 100644 --- a/java/src/org/futo/inputmethod/latin/uix/ActionBar.kt +++ b/java/src/org/futo/inputmethod/latin/uix/ActionBar.kt @@ -121,6 +121,7 @@ import org.futo.inputmethod.latin.SuggestionBlacklist import org.futo.inputmethod.latin.common.Constants import org.futo.inputmethod.latin.suggestions.SuggestionStripViewListener import org.futo.inputmethod.latin.uix.actions.FavoriteActions +import org.futo.inputmethod.latin.uix.actions.PersistentEmojiState import org.futo.inputmethod.latin.uix.actions.MoreActionsAction import org.futo.inputmethod.latin.uix.actions.PinnedActions import org.futo.inputmethod.latin.uix.actions.toActionList @@ -293,7 +294,7 @@ fun TextStyle.withCustomFont(orDefault: FontFamily? = null): TextStyle { @OptIn(ExperimentalFoundationApi::class) @Composable -fun RowScope.SuggestionItem(words: SuggestedWords, idx: Int, isPrimary: Boolean, onClick: () -> Unit, onLongClick: () -> Unit, forcePrimary: Boolean, isEmoji: Boolean) { +fun RowScope.SuggestionItem(words: SuggestedWords, idx: Int, isPrimary: Boolean, onClick: () -> Unit, onLongClick: () -> Unit, forcePrimary: Boolean, isEmoji: Boolean, weight: Float = 1.0f) { val wordInfo = words.getInfoOrNull(idx) val isVerbatim = wordInfo?.kind == KIND_TYPED val word = wordInfo?.mWord @@ -346,7 +347,7 @@ fun RowScope.SuggestionItem(words: SuggestedWords, idx: Int, isPrimary: Boolean, Box( modifier = textButtonModifier - .weight(1.0f) + .weight(weight) .fillMaxHeight() .combinedClickable( enabled = word != null, @@ -501,6 +502,33 @@ fun RowScope.SuggestionItems(words: SuggestedWords, onClick: (i: Int) -> Unit, o } } + // Two emoji at half-weight each so together they occupy one normal slot. + val emojiItem = @Composable { suggestion: SuggestedWordInfo -> + val idx = words.indexOf(suggestion) + SuggestionItem( + words, idx, + isPrimary = false, forcePrimary = false, + onClick = { onClick(idx) }, onLongClick = { onLongClick(idx) }, + isEmoji = true, weight = 0.5f + ) + } + + // Renders the leftmost emoji slot(s), or a word fallback when no emoji is available. + // fallback is pre-evaluated so callers can side-effect supplementalSuggestionIndex safely. + val emojiSlots = @Composable { fallback: SuggestedWordInfo? -> + when { + layout.emojiMatches.isEmpty() -> + suggestionItem(fallback) + layout.emojiMatches.size >= PersistentEmojiState.MAX_EMOJI_SUGGESTIONS -> { + emojiItem(layout.emojiMatches[0]) + SuggestionSeparator() + emojiItem(layout.emojiMatches[1]) + } + else -> + suggestionItem(layout.emojiMatches[0]) + } + } + when { layout.isGestureBatch || (layout.emojiMatches.isEmpty() && layout.presentableSuggestions.size <= 1) -> @@ -508,11 +536,11 @@ fun RowScope.SuggestionItems(words: SuggestedWords, onClick: (i: Int) -> Unit, o layout.autocorrectMatch != null -> { var supplementalSuggestionIndex = 0 - if(layout.emojiMatches.isEmpty()) { - suggestionItem(layout.sortedMatches.getOrNull(supplementalSuggestionIndex++)) - } else { - suggestionItem(layout.emojiMatches[0]) - } + + emojiSlots( + if(layout.emojiMatches.isEmpty()) layout.sortedMatches.getOrNull(supplementalSuggestionIndex++) else null + ) + SuggestionSeparator() suggestionItem(layout.autocorrectMatch) SuggestionSeparator() @@ -526,11 +554,11 @@ fun RowScope.SuggestionItems(words: SuggestedWords, onClick: (i: Int) -> Unit, o else -> { var supplementalSuggestionIndex = 1 - if(layout.emojiMatches.isEmpty()) { - suggestionItem(layout.sortedMatches.getOrNull(supplementalSuggestionIndex++)) - } else { - suggestionItem(layout.emojiMatches[0]) - } + + emojiSlots( + if(layout.emojiMatches.isEmpty()) layout.sortedMatches.getOrNull(supplementalSuggestionIndex++) else null + ) + SuggestionSeparator() suggestionItem(layout.sortedMatches.getOrNull(0)) SuggestionSeparator() diff --git a/java/src/org/futo/inputmethod/latin/uix/actions/EmojiAction.kt b/java/src/org/futo/inputmethod/latin/uix/actions/EmojiAction.kt index a841cb57ac..ecc2f32712 100644 --- a/java/src/org/futo/inputmethod/latin/uix/actions/EmojiAction.kt +++ b/java/src/org/futo/inputmethod/latin/uix/actions/EmojiAction.kt @@ -923,12 +923,16 @@ data class LooseShortcut(val emoji: String, val shortcut: String, val score: Int class PersistentEmojiState : PersistentActionState { companion object { + const val MAX_EMOJI_SUGGESTIONS = 2 + var emojis: MutableState?> = mutableStateOf(null) var emojiMap: HashMap = HashMap() // Language name to translations private val loadedTranslations: HashMap = hashMapOf() - private val loadedTranslatedShortcuts: HashMap> = hashMapOf() + + // word → ranked emoji list (up to 2), per language + private val loadedTranslatedShortcuts: HashMap>> = hashMapOf() @JvmStatic fun getTranslationForLocale(locale: Locale): EmojiTranslations? { @@ -962,8 +966,8 @@ class PersistentEmojiState : PersistentActionState { @JvmStatic - fun getShortcut(locale: Locale, text: String): String? { - return loadedTranslatedShortcuts[locale.language]?.get(text) + fun getShortcuts(locale: Locale, text: String): List { + return loadedTranslatedShortcuts[locale.language]?.get(text) ?: emptyList() } @JvmStatic @@ -1014,13 +1018,18 @@ class PersistentEmojiState : PersistentActionState { val ttsName = entry.value.names.last() val names = entry.value.names.flatMap { it.split(" ") } - names.filter { wordCounts[it] == 1 && it.length > 1 }.map { it.lowercase() to entry.key } + + + // score: ttsName (1) ranks above word fragments (0) + names.filter { wordCounts[it] == 1 && it.length > 1 }.map { Triple(it.lowercase(), entry.key, 0) } + if(!ttsName.contains(' ')) { - listOf(ttsName.lowercase() to entry.key) + listOf(Triple(ttsName.lowercase(), entry.key, 1)) } else { emptyList() } - }.reversed().toMap() + } + .sortedByDescending { it.third } + .groupBy({ it.first }, { it.second }) + .mapValues { (_, v) -> v.distinct().take(MAX_EMOJI_SUGGESTIONS) } if(language != "en") loadedTranslatedShortcuts.put(language, aliases) } @@ -1125,10 +1134,11 @@ class PersistentEmojiState : PersistentActionState { } } + // Dedup by emoji before take: same emoji at multiple scores collapses both strip slots. loadedTranslatedShortcuts["en"] = englishShortcuts .sortedByDescending { it.score } - .distinctBy { it.shortcut } - .associate { it.shortcut to it.emoji } + .groupBy { it.shortcut } + .mapValues { (_, v) -> v.map { it.emoji }.distinct().take(MAX_EMOJI_SUGGESTIONS) } loadedTranslations["en_gemoji"] = EmojiTranslations(englishTranslations) }