Add basic emoji search using string matching

This commit is contained in:
Aleksandras Kostarevas 2024-07-13 12:41:42 +03:00
parent b6206e3059
commit e181717692
7 changed files with 225 additions and 55 deletions

View File

@ -678,11 +678,40 @@ class LatinIME : InputMethodService(), LifecycleOwner, ViewModelStoreOwner, Save
}
}
var overrideInputConnection: InputConnection? = null
private var overrideInputConnection: InputConnection? = null
private var overrideEditorInfo: EditorInfo? = null
fun overrideInputConnection(to: InputConnection?, editorInfo: EditorInfo?) {
this.overrideInputConnection = to
this.overrideEditorInfo = editorInfo
latinIMELegacy.loadSettings()
inputLogic.finishInput()
inputLogic.startInput(RichInputMethodManager.getInstance().combiningRulesExtraValueOfCurrentSubtype, latinIMELegacy.mSettings.current)
val currentIC = currentInputConnection
currentIC?.requestCursorUpdates(InputConnection.CURSOR_UPDATE_IMMEDIATE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
super.getCurrentInputConnection()?.setImeConsumesInput(to != null)
}
}
val isInputConnectionOverridden
get() = overrideInputConnection != null
override fun getCurrentInputConnection(): InputConnection? {
return overrideInputConnection ?: super.getCurrentInputConnection()
}
override fun getCurrentInputEditorInfo(): EditorInfo? {
return overrideEditorInfo ?: super.getCurrentInputEditorInfo()
}
fun getBaseInputConnection(): InputConnection? {
return super.getCurrentInputConnection()
}
override val lifecycle: Lifecycle
get() = mLifecycleRegistry
override val savedStateRegistry: SavedStateRegistry

View File

@ -3,6 +3,7 @@ package org.futo.inputmethod.latin.uix
import android.content.Context
import android.net.Uri
import android.view.View
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
@ -56,7 +57,7 @@ interface KeyboardManagerForAction {
fun announce(s: String)
fun getActiveLocale(): Locale
fun overrideInputConnection(inputConnection: InputConnection)
fun overrideInputConnection(inputConnection: InputConnection, editorInfo: EditorInfo)
fun unsetInputConnection()
fun requestDialog(text: String, options: List<DialogRequestItem>, onCancel: () -> Unit)

View File

@ -1,22 +1,21 @@
package org.futo.inputmethod.latin.uix
import android.content.Context
import android.text.InputType
import android.util.AttributeSet
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
class ActionEditText(context: Context, val textChanged: (String) -> Unit) :
class ActionEditText(context: Context) :
androidx.appcompat.widget.AppCompatEditText(context) {
var inputConnection: InputConnection? = null
private set
@ -26,6 +25,13 @@ class ActionEditText(context: Context, val textChanged: (String) -> Unit) :
return inputConnection
}
private var textChanged: (String) -> Unit = { }
fun setTextChangeCallback(
textChanged: (String) -> Unit
) {
this.textChanged = textChanged
}
override fun onTextChanged(
text: CharSequence?,
start: Int,
@ -33,7 +39,12 @@ class ActionEditText(context: Context, val textChanged: (String) -> Unit) :
lengthAfter: Int
) {
super.onTextChanged(text, start, lengthBefore, lengthAfter)
textChanged(text?.toString() ?: "")
// For some strange reason this IS null sometimes, even though it
// shouldn't be
if(textChanged != null) {
textChanged(text?.toString() ?: "")
}
}
}
@ -43,26 +54,41 @@ fun ActionTextEditor(text: MutableState<String>) {
val context = LocalContext.current
val manager = LocalManager.current
val height = with(LocalDensity.current) {
48.dp.toPx()
}
val inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_FLAG_AUTO_COMPLETE or EditorInfo.TYPE_TEXT_FLAG_NO_SUGGESTIONS
AndroidView(
factory = {
ActionEditText(context) {
text.value = it
}.apply {
onCreateInputConnection(
EditorInfo()
)
ActionEditText(context).apply {
this.inputType = inputType
setTextChangeCallback { text.value = it }
setText(text.value)
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT
)
inputType = InputType.TYPE_CLASS_TEXT
manager.overrideInputConnection(inputConnection!!)
setHeight(height.toInt())
val editorInfo = EditorInfo().apply {
this.inputType = inputType
}
onCreateInputConnection(editorInfo)
manager.overrideInputConnection(inputConnection!!, editorInfo)
requestFocus()
}
},
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
.fillMaxHeight(),
onRelease = {
manager.unsetInputConnection()
}

View File

@ -36,4 +36,8 @@ object EmojiTracker {
.filter { it.isNotBlank() }
.distinct()
}
suspend fun Context.resetRecentEmojis() {
setSetting(lastUsedEmoji, "")
}
}

View File

@ -11,6 +11,7 @@ import android.os.Vibrator
import android.util.Log
import android.view.View
import android.view.WindowManager
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InlineSuggestionsResponse
import android.view.inputmethod.InputConnection
import android.view.inputmethod.InputContentInfo
@ -146,7 +147,11 @@ class UixActionKeyboardManager(val uixManager: UixManager, val latinIME: LatinIM
}
override fun typeText(v: String) {
latinIME.latinIMELegacy.onTextInput(v)
if(latinIME.isInputConnectionOverridden) {
latinIME.getBaseInputConnection()?.commitText(v, 1)
} else {
latinIME.latinIMELegacy.onTextInput(v)
}
}
override fun typeUri(uri: Uri, mimeTypes: List<String>): Boolean {
@ -224,16 +229,13 @@ class UixActionKeyboardManager(val uixManager: UixManager, val latinIME: LatinIM
return latinIME.latinIMELegacy.locale
}
override fun overrideInputConnection(inputConnection: InputConnection) {
latinIME.overrideInputConnection = inputConnection
latinIME.inputLogic.startInput(RichInputMethodManager.getInstance().combiningRulesExtraValueOfCurrentSubtype,
latinIME.latinIMELegacy.mSettings.current)
override fun overrideInputConnection(inputConnection: InputConnection, editorInfo: EditorInfo) {
latinIME.overrideInputConnection(inputConnection, editorInfo)
uixManager.toggleExpandAction(true)
}
override fun unsetInputConnection() {
latinIME.overrideInputConnection = null
latinIME.inputLogic.startInput(RichInputMethodManager.getInstance().combiningRulesExtraValueOfCurrentSubtype,
latinIME.latinIMELegacy.mSettings.current)
latinIME.overrideInputConnection(null, null)
}
override fun requestDialog(text: String, options: List<DialogRequestItem>, onCancel: () -> Unit) {
@ -361,8 +363,8 @@ class UixManager(private val latinIME: LatinIME) {
keyboardManagerForAction.announce("$name closed")
}
private fun toggleExpandAction() {
mainKeyboardHidden = !mainKeyboardHidden
fun toggleExpandAction(to: Boolean? = null) {
mainKeyboardHidden = !(to ?: mainKeyboardHidden)
if(!mainKeyboardHidden) {
latinIME.onKeyboardShown()
}
@ -378,7 +380,7 @@ class UixManager(private val latinIME: LatinIME) {
1.5
}
Column {
if(mainKeyboardHidden) {
if(mainKeyboardHidden || latinIME.isInputConnectionOverridden) {
ActionWindowBar(
onBack = { returnBackToMainKeyboardViewFromAction() },
canExpand = currWindowAction!!.canShowKeyboard,
@ -398,7 +400,7 @@ class UixManager(private val latinIME: LatinIME) {
windowImpl.WindowContents(keyboardShown = !isMainKeyboardHidden)
}
if(!mainKeyboardHidden) {
if(!mainKeyboardHidden && !latinIME.isInputConnectionOverridden) {
val suggestedWordsOrNull = if (shouldShowSuggestionStrip) {
suggestedWords
} else {

View File

@ -11,9 +11,11 @@ import androidx.annotation.UiThread
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.absoluteOffset
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@ -25,6 +27,8 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LocalContentColor
@ -43,6 +47,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
@ -73,8 +78,10 @@ import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.GridLayoutManager.SpanSizeLookup
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ -88,9 +95,12 @@ import kotlinx.serialization.json.jsonPrimitive
import org.futo.inputmethod.latin.R
import org.futo.inputmethod.latin.common.Constants
import org.futo.inputmethod.latin.uix.Action
import org.futo.inputmethod.latin.uix.ActionTextEditor
import org.futo.inputmethod.latin.uix.ActionWindow
import org.futo.inputmethod.latin.uix.AutoFitText
import org.futo.inputmethod.latin.uix.DialogRequestItem
import org.futo.inputmethod.latin.uix.EmojiTracker.getRecentEmojis
import org.futo.inputmethod.latin.uix.EmojiTracker.resetRecentEmojis
import org.futo.inputmethod.latin.uix.EmojiTracker.useEmoji
import org.futo.inputmethod.latin.uix.PersistentActionState
import org.futo.inputmethod.latin.uix.actions.emoji.EmojiItem
@ -127,14 +137,24 @@ class EmojiItemItem(val emoji: EmojiItem) : EmojiViewItem() {
const val VIEW_EMOJI = 0
const val VIEW_CATEGORY = 1
private object EmojiViewItemDiffCallback : DiffUtil.ItemCallback<EmojiViewItem>() {
override fun areItemsTheSame(oldItem: EmojiViewItem, newItem: EmojiViewItem): Boolean {
return oldItem == newItem
}
override fun areContentsTheSame(oldItem: EmojiViewItem, newItem: EmojiViewItem): Boolean {
return oldItem == newItem
}
}
// Note: Using traditional View here, because Android Compose leaves a lot of performance to be desired
class EmojiGridAdapter(
private val data: List<EmojiViewItem>,
private val onClick: (EmojiItem) -> Unit,
private val onSelectSkinTone: (PopupInfo) -> Unit,
private val emojiCellWidth: Int,
private val contentColor: Color
) : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
) : ListAdapter<EmojiViewItem, RecyclerView.ViewHolder>(EmojiViewItemDiffCallback) {
class EmojiViewHolder(
context: Context,
@ -189,7 +209,7 @@ class EmojiGridAdapter(
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val item = data[position]
val item = getItem(position)
if(item is EmojiItemItem && holder is EmojiViewHolder) {
holder.bindEmoji(item.emoji, onClick, onSelectSkinTone, contentColor.toArgb())
}else if(item is CategoryItem && holder is CategoryViewHolder) {
@ -197,10 +217,8 @@ class EmojiGridAdapter(
}
}
override fun getItemCount() = data.size
override fun getItemViewType(position: Int): Int {
return when(data[position]) {
return when(getItem(position)) {
is CategoryItem -> VIEW_CATEGORY
is EmojiItemItem -> VIEW_EMOJI
}
@ -259,7 +277,6 @@ fun Emojis(
val emojiAdapter = remember {
EmojiGridAdapter(
emojis,
onClick,
onSelectSkinTone = {
activePopup = it
@ -270,6 +287,10 @@ fun Emojis(
)
}
LaunchedEffect(emojis) {
emojiAdapter.submitList(emojis)
}
var viewWidth by remember { mutableIntStateOf(0) }
var viewHeight by remember { mutableIntStateOf(0) }
var popupSize by remember { mutableStateOf(IntSize(0, 0)) }
@ -281,13 +302,14 @@ fun Emojis(
layoutManager = GridLayoutManager(context, 8).apply {
spanSizeLookup = object : SpanSizeLookup() {
override fun getSpanSize(position: Int): Int {
return when(emojis[position]) {
return when(emojiAdapter.currentList[position]) {
is EmojiItemItem -> 1
is CategoryItem -> spanCount
}
}
}
}
adapter = emojiAdapter
addOnScrollListener(object : RecyclerView.OnScrollListener() {
@ -566,7 +588,9 @@ fun EmojiGrid(
emojis: List<EmojiItem>,
keyboardShown: Boolean,
emojiMap: Map<String, EmojiItem>,
keyBackground: Drawable
keyBackground: Drawable,
isSearching: Boolean,
searchFilter: String
) {
val context = LocalContext.current
val recentEmojis = remember {
@ -596,6 +620,23 @@ fun EmojiGrid(
val jumpCategory: MutableState<CategoryItem?> = remember { mutableStateOf(null) }
var emojiList = listOf(CategoryItem("Recent")) + recentEmojis.map { EmojiItemItem(it) } + categorizedEmojis
if(isSearching) {
emojiList = emojiList.filter {
(it is EmojiItemItem) &&
(it.emoji.description.contains(searchFilter)
|| it.emoji.aliases.joinToString().contains(searchFilter)
|| it.emoji.tags.joinToString().contains(searchFilter))
}.take(48).map {
EmojiItemItem((it as EmojiItemItem).emoji.copy(category = "Search Results"))
}
if(emojiList.isEmpty()) {
emojiList = emojiList + listOf(CategoryItem("No results found"))
}
}
Column {
Emojis(
modifier = Modifier
@ -612,25 +653,27 @@ fun EmojiGrid(
keyBackground.state = intArrayOf()
keyBackground.draw(this.drawContext.canvas.nativeCanvas)
},
emojis = listOf(CategoryItem("Recent")) + recentEmojis.map { EmojiItemItem(it) } + categorizedEmojis,
emojis = emojiList,
onClick = onClick,
emojiMap = emojiMap,
currentCategory = currentCategory,
jumpCategory = jumpCategory
)
EmojiNavigation(
showKeys = !keyboardShown,
onExit = onExit,
onBackspace = onBackspace,
categories = listOf(
CategoryItem("Recent")
) + categorizedEmojis.filterIsInstance<CategoryItem>(),
activeCategoryItem = currentCategory.value,
goToCategory = {
jumpCategory.value = it
}
)
if(!isSearching) {
EmojiNavigation(
showKeys = !keyboardShown,
onExit = onExit,
onBackspace = onBackspace,
categories = listOf(
CategoryItem("Recent")
) + categorizedEmojis.filterIsInstance<CategoryItem>(),
activeCategoryItem = currentCategory.value,
goToCategory = {
jumpCategory.value = it
}
)
}
}
}
@ -719,6 +762,9 @@ val EmojiAction = Action(
windowImpl = { manager, persistentState ->
val state = persistentState as PersistentEmojiState
object : ActionWindow {
private val searchText = mutableStateOf("")
private val searching = mutableStateOf(false)
@Composable
override fun windowName(): String {
return stringResource(R.string.emoji_action_title)
@ -745,7 +791,67 @@ val EmojiAction = Action(
if(!isRepeated) {
manager.performHapticAndAudioFeedback(Constants.CODE_DELETE, view)
}
}, emojis = emojis, keyboardShown = keyboardShown, emojiMap = state.emojiMap, keyBackground = manager.getThemeProvider().keyBackground)
}, emojis = emojis, keyboardShown = keyboardShown, emojiMap = state.emojiMap, keyBackground = manager.getThemeProvider().keyBackground,
isSearching = searching.value, searchFilter = searchText.value)
}
}
@Composable
override fun WindowTitleBar(rowScope: RowScope) {
if(searching.value) {
with(rowScope) {
Surface(
color = MaterialTheme.colorScheme.surfaceBright,
shape = RoundedCornerShape(24.dp),
modifier = Modifier
.minimumInteractiveComponentSize()
.padding(2.dp)
.weight(1.0f)
) {
Box(
modifier = Modifier.padding(8.dp),
contentAlignment = Alignment.CenterStart
) {
ActionTextEditor(text = searchText)
}
}
}
} else {
super.WindowTitleBar(rowScope)
Surface(color = MaterialTheme.colorScheme.surfaceBright, shape = RoundedCornerShape(24.dp), modifier = Modifier
.minimumInteractiveComponentSize()
.padding(2.dp)
.width(128.dp)
.clickable { searching.value = true }) {
Box(modifier = Modifier.padding(8.dp), contentAlignment = Alignment.CenterStart) {
Row {
Icon(Icons.Default.Search, contentDescription = null)
Text("Search", style = Typography.bodySmall, modifier = Modifier
.alpha(0.75f)
.align(Alignment.CenterVertically))
}
}
}
IconButton(onClick = {
manager.requestDialog(
"Clear recent emojis?",
listOf(
DialogRequestItem("Cancel") {},
DialogRequestItem("Clear") {
runBlocking {
manager.getContext().resetRecentEmojis()
}
manager.closeActionWindow()
},
),
{}
)
}) {
Icon(painterResource(id = R.drawable.close), contentDescription = "Clear recent emojis")
}
}
}
@ -772,6 +878,8 @@ fun EmojiGridPreview() {
},
keyboardShown = false,
emojiMap = hashMapOf(),
keyBackground = context.getDrawable(R.drawable.btn_keyboard_spacebar_lxx_dark)!!
keyBackground = context.getDrawable(R.drawable.btn_keyboard_spacebar_lxx_dark)!!,
isSearching = false,
searchFilter = ""
)
}

View File

@ -49,7 +49,7 @@ class EmojiView @JvmOverloads constructor(
View(context, attrs) {
companion object {
private const val EMOJI_DRAW_TEXT_SIZE_SP = 32
private const val EMOJI_DRAW_TEXT_SIZE_DP = 42
}
init {
@ -61,8 +61,8 @@ class EmojiView @JvmOverloads constructor(
private val textPaint = TextPaint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply {
textSize = TypedValue.applyDimension(
TypedValue.COMPLEX_UNIT_SP,
EMOJI_DRAW_TEXT_SIZE_SP.toFloat(),
TypedValue.COMPLEX_UNIT_DIP,
EMOJI_DRAW_TEXT_SIZE_DP.toFloat(),
context.resources.displayMetrics
)
}