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 de87d63f6..d23637fda 100644 --- a/java/src/org/futo/inputmethod/latin/uix/actions/EmojiAction.kt +++ b/java/src/org/futo/inputmethod/latin/uix/actions/EmojiAction.kt @@ -1,6 +1,13 @@ package org.futo.inputmethod.latin.uix.actions +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.Paint +import android.text.TextPaint +import android.util.TypedValue import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -21,16 +28,28 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Constraints import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.core.graphics.applyCanvas +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.yield import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject @@ -38,6 +57,9 @@ import kotlinx.serialization.json.jsonPrimitive import org.futo.inputmethod.latin.R import org.futo.inputmethod.latin.uix.Action import org.futo.inputmethod.latin.uix.ActionWindow +import org.futo.inputmethod.latin.uix.PersistentActionState +import kotlin.math.ceil +import kotlin.math.roundToInt data class EmojiItem( val emoji: String, @@ -45,23 +67,102 @@ data class EmojiItem( val category: String ) -@Composable -fun EmojiGrid(onClick: (EmojiItem) -> Unit, onExit: () -> Unit, onBackspace: () -> Unit, onSpace: () -> Unit) { - val context = LocalContext.current +const val EMOJI_HEIGHT = 30.0f //sp - val emojis = remember { - val stream = context.resources.openRawResource(R.raw.gemoji) - val text = stream.bufferedReader().readText() - val emojidata = Json.parseToJsonElement(text) - emojidata.jsonArray.map { - EmojiItem( - emoji = it.jsonObject["emoji"]!!.jsonPrimitive.content, - description = it.jsonObject["description"]!!.jsonPrimitive.content, - category = it.jsonObject["category"]!!.jsonPrimitive.content, - ) +data class BitmapRecycler( + private val freeBitmaps: MutableList = mutableListOf() +) { + var textPaint: TextPaint? = null + var total = 0 + fun getTextPaint(context: Context): TextPaint { + return textPaint ?: run { + this.textPaint = TextPaint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply { + textSize = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_SP, + EMOJI_HEIGHT, + context.resources.displayMetrics + ) + } + + this.textPaint!! } } + fun getBitmap(): Bitmap { + return freeBitmaps.removeFirstOrNull()?.apply { + eraseColor(Color.TRANSPARENT) + } ?: with(textPaint!!.fontMetricsInt) { + println("creating new bitmap, total $total") + total += 1 + val size = bottom - top + Bitmap.createBitmap(size, size, android.graphics.Bitmap.Config.ARGB_8888) + } + } + + fun freeBitmap(bitmap: Bitmap) { + if(freeBitmaps.size > 60) { + println("Recycling bitmap, new total $total") + total -= 1 + bitmap.recycle() + } else { + freeBitmaps.add(bitmap) + } + } + + fun freeAllBitmaps() { + freeBitmaps.forEach { + println("Recycling bitmap due to freeAllBitmaps, new total $total") + total -= 1 + it.recycle() + } + freeBitmaps.clear() + } +} + +@Composable fun EmojiIcon(emoji: String, bitmaps: BitmapRecycler) { + var rendering by remember { mutableStateOf(true) } + val offscreenCanvasBitmap: Bitmap = remember { bitmaps.getBitmap() } + val imageBitmap = remember { offscreenCanvasBitmap.asImageBitmap() } + + DisposableEffect(offscreenCanvasBitmap) { + onDispose { + bitmaps.freeBitmap(bitmap = offscreenCanvasBitmap) + } + } + + LaunchedEffect(emoji) { + withContext(Dispatchers.Unconfined) { + val textPaint = bitmaps.textPaint!! + yield() + offscreenCanvasBitmap.applyCanvas { + yield() + val textWidth = textPaint.measureText(emoji, 0, emoji.length) + yield() + drawText( + emoji, + /* start = */ 0, + /* end = */ emoji.length, + /* x = */ (width - textWidth) / 2, + /* y = */ -textPaint.fontMetrics.top, + textPaint, + ) + yield() + } + yield() + rendering = false + } + } + + Image( + bitmap = imageBitmap, + contentDescription = emoji, + modifier = Modifier.fillMaxSize() + ) +} + +@Composable +fun EmojiGrid(onClick: (EmojiItem) -> Unit, onExit: () -> Unit, onBackspace: () -> Unit, onSpace: () -> Unit, bitmaps: BitmapRecycler, emojis: List) { + val context = LocalContext.current val spToDp = context.resources.displayMetrics.scaledDensity / context.resources.displayMetrics.density Column { @@ -70,25 +171,27 @@ fun EmojiGrid(onClick: (EmojiItem) -> Unit, onExit: () -> Unit, onBackspace: () contentPadding = PaddingValues(10.dp), modifier = Modifier.weight(1.0f) ) { - items(emojis) { emoji -> - Box(modifier = Modifier.fillMaxSize().clickable { - onClick(emoji) - }) { - Text( - text = emoji.emoji, - fontSize = 24.sp, - modifier = Modifier.align(Alignment.Center) - ) + items(emojis, key = { it.emoji }) { emoji -> + Box(modifier = Modifier + .fillMaxSize() + .clickable { + onClick(emoji) + }) { + EmojiIcon(emoji.emoji, bitmaps) } } } - Surface(color = MaterialTheme.colorScheme.background, modifier = Modifier.fillMaxWidth().height(48.dp)) { + Surface(color = MaterialTheme.colorScheme.background, modifier = Modifier + .fillMaxWidth() + .height(48.dp)) { Row(modifier = Modifier.padding(2.dp, 8.dp, 2.dp, 0.dp)) { IconButton(onClick = { onExit() }) { Text("ABC", fontSize = 14.sp) } - Button(onClick = { onSpace() }, modifier = Modifier.weight(1.0f).padding(8.dp, 2.dp), colors = ButtonDefaults.buttonColors( + Button(onClick = { onSpace() }, modifier = Modifier + .weight(1.0f) + .padding(8.dp, 2.dp), colors = ButtonDefaults.buttonColors( containerColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.33f), contentColor = MaterialTheme.colorScheme.onBackground, disabledContainerColor = MaterialTheme.colorScheme.outline, @@ -122,6 +225,7 @@ fun EmojiGrid(onClick: (EmojiItem) -> Unit, onExit: () -> Unit, onBackspace: () } } +/* @Preview(showBackground = true) @Composable fun EmojiGridPreview() { @@ -132,15 +236,49 @@ fun EmojiGridPreview() { onSpace = {} ) } +*/ +class PersistentEmojiState: PersistentActionState { + val bitmaps: BitmapRecycler = BitmapRecycler() + var emojis: MutableState?> = mutableStateOf(null) + suspend fun loadEmojis(context: Context) = withContext(Dispatchers.IO) { + val stream = context.resources.openRawResource(R.raw.gemoji) + val text = stream.bufferedReader().readText() + + withContext(Dispatchers.Default) { + val emojiData = Json.parseToJsonElement(text) + emojis.value = emojiData.jsonArray.map { + EmojiItem( + emoji = it.jsonObject["emoji"]!!.jsonPrimitive.content, + description = it.jsonObject["description"]!!.jsonPrimitive.content, + category = it.jsonObject["category"]!!.jsonPrimitive.content, + ) + } + } + } + + override suspend fun cleanUp() { + bitmaps.freeAllBitmaps() + } +} val EmojiAction = Action( icon = R.drawable.smile, name = R.string.title_emojis, simplePressImpl = null, - windowImpl = { manager, _ -> + persistentState = { manager -> + val state = PersistentEmojiState() + state.bitmaps.getTextPaint(manager.getContext()) + manager.getLifecycleScope().launch { + state.loadEmojis(manager.getContext()) + } + + state + }, + windowImpl = { manager, persistentState -> + val state = persistentState as PersistentEmojiState object : ActionWindow { @Composable override fun windowName(): String { @@ -149,19 +287,21 @@ val EmojiAction = Action( @Composable override fun WindowContents() { - EmojiGrid(onClick = { - manager.typeText(it.emoji) - }, onExit = { - manager.closeActionWindow() - }, onSpace = { - manager.typeText(" ") - }, onBackspace = { - manager.backspace(1) - }) + state.emojis.value?.let { emojis -> + EmojiGrid(onClick = { + manager.typeText(it.emoji) + }, onExit = { + manager.closeActionWindow() + }, onSpace = { + manager.typeText(" ") + }, onBackspace = { + manager.backspace(1) + }, bitmaps = state.bitmaps, emojis = emojis) + } } override fun close() { - + state.bitmaps.freeAllBitmaps() } } }