Skip to content
4 changes: 4 additions & 0 deletions app/src/keyboards/java/be/scri/helpers/KeyHandler.kt
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,10 @@ class KeyHandler(
handleCurrencyKey(language)
true
}
KeyboardBase.KEYCODE_EMOJI -> {
ime.openEmojiKeyboard()
true
}
else -> {
handleDefaultKey(code)
true
Expand Down
180 changes: 180 additions & 0 deletions app/src/keyboards/java/be/scri/helpers/ui/KeyboardUIManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,15 @@ import androidx.appcompat.content.res.AppCompatResources
import androidx.core.content.ContextCompat
import androidx.core.content.edit
import androidx.core.graphics.toColorInt
import androidx.core.view.updateLayoutParams
import androidx.recyclerview.widget.GridLayoutManager
import be.scri.R
import be.scri.R.color.white
import be.scri.databinding.InputMethodViewBinding
import be.scri.helpers.AutoGridLayoutManager
import be.scri.helpers.EMOJI_SPEC_FILE_PATH
import be.scri.helpers.EmojiAdapter
import be.scri.helpers.EmojiData
import be.scri.helpers.KeyboardBase
import be.scri.helpers.KeyboardLanguageMappingConstants.conjugatePlaceholder
import be.scri.helpers.KeyboardLanguageMappingConstants.pluralPlaceholder
Expand All @@ -29,6 +35,8 @@ import be.scri.helpers.LanguageMappingConstants.getLanguageAlias
import be.scri.helpers.PreferencesHelper
import be.scri.helpers.PreferencesHelper.getIsDarkModeOrNot
import be.scri.helpers.english.ENInterfaceVariables.ALREADY_PLURAL_MSG
import be.scri.helpers.getCategoryIconRes
import be.scri.helpers.parseRawEmojiSpecsFile
import be.scri.models.ScribeState
import be.scri.services.GeneralKeyboardIME
import be.scri.views.KeyboardView
Expand Down Expand Up @@ -754,6 +762,178 @@ class KeyboardUIManager(
binding.separator6.visibility = View.GONE
}

/**
* Displays the emoji palette and hides the keyboard view.
* Loads emojis from the emoji spec file on a background thread and populates the grid.
*/
fun showEmojiPalette() {
binding.keyboardView.post {
val keyboardHeight = binding.keyboardView.measuredHeight
val toolbarHeight =
binding.commandOptionsBar.measuredHeight.takeIf { it > 0 }
?: context.resources.getDimensionPixelSize(R.dimen.toolbar_height)

binding.emojiPaletteHolder.updateLayoutParams {
height = keyboardHeight + toolbarHeight
}
binding.emojiPaletteHolder.requestLayout()
}

binding.emojiPaletteHolder.visibility = View.VISIBLE

binding.keyboardView.visibility = View.GONE
binding.commandOptionsBar.visibility = View.GONE

binding.emojiPaletteClose.setOnClickListener {
hideEmojiPalette()
}

binding.emojiPaletteModeChange.setOnClickListener {
hideEmojiPalette()
}
binding.emojiPaletteModeChange.text = "ABC"
binding.emojiPaletteModeChange.setTextColor(
ContextCompat.getColor(context, R.color.emoji_palette_icons),
)

binding.emojiPaletteBackspace.setOnClickListener {
listener.onKeyboardActionListener().onKey(KeyboardBase.KEYCODE_DELETE)
}

Thread {
val fullEmojiList = parseRawEmojiSpecsFile(context, EMOJI_SPEC_FILE_PATH)
val systemFontPaint =
android.graphics.Paint().apply {
typeface = android.graphics.Typeface.DEFAULT
}
val emojis =
fullEmojiList.filter { emoji ->
systemFontPaint.hasGlyph(emoji.emoji)
}

android.os.Handler(android.os.Looper.getMainLooper()).post {
setupEmojiAdapter(emojis)
}
}.start()
}

/**
* Sets up the emoji RecyclerView adapter and category strip.
*
* @param emojis The filtered list of emojis the device can render.
*/
private fun setupEmojiAdapter(emojis: List<EmojiData>) {
val emojiCategories = prepareEmojiCategories(emojis)
val emojiItems = prepareEmojiItems(emojiCategories)

val emojiItemSize = context.resources.getDimensionPixelSize(R.dimen.emoji_item_size)
val emojiLayoutManager = AutoGridLayoutManager(context, emojiItemSize)

emojiLayoutManager.spanSizeLookup =
object : GridLayoutManager.SpanSizeLookup() {
override fun getSpanSize(position: Int): Int =
if (emojiItems[position] is EmojiAdapter.Item.Category) {
emojiLayoutManager.spanCount
} else {
1
}
}

binding.emojisList.layoutManager = emojiLayoutManager
binding.emojisList.adapter =
EmojiAdapter(context, emojiItems) { emojiData ->
listener.onEmojiSelected(emojiData.emoji)
}

setupEmojiCategoryStrip(emojiCategories, emojiItems, emojiLayoutManager)
}

/**
* Groups emojis by category.
*
* @param emojis The full list of emojis.
* @return A map of category name to list of emojis in corresponding category.
*/
private fun prepareEmojiCategories(emojis: List<EmojiData>): Map<String, List<EmojiData>> = emojis.groupBy { it.category }

/**
* Builds a list of category headers and emoji items for the RecyclerView.
*
* @param categories The map of categories to their emojis.
* @return A flat list of [EmojiAdapter.Item] objects.
*/
private fun prepareEmojiItems(categories: Map<String, List<EmojiData>>): List<EmojiAdapter.Item> {
val emojiItems = mutableListOf<EmojiAdapter.Item>()
categories.entries.forEach { (category, emojis) ->
emojiItems.add(EmojiAdapter.Item.Category(category))
emojis.forEach { emojiItems.add(EmojiAdapter.Item.Emoji(it)) }
}
return emojiItems
}

/**
* Populates the emoji category strip at the bottom of the palette.
* Tapping a category icon scrolls the emoji list to that category.
*
* @param categories The map of category names to their emojis.
* @param emojiItems The full flat list used to find category positions.
* @param layoutManager The AutoGridLayoutManager used to scroll to positions.
*/
private fun setupEmojiCategoryStrip(
categories: Map<String, List<EmojiData>>,
emojiItems: List<EmojiAdapter.Item>,
layoutManager: AutoGridLayoutManager,
) {
binding.emojiCategoriesStrip.removeAllViews()

var activeButton: android.widget.ImageButton? = null

categories.keys.forEachIndexed { index, category ->
val button =
android.widget.ImageButton(context).apply {
setImageResource(getCategoryIconRes(category))
background = null
imageAlpha = if (index == 0) 255 else 128 // 50% opacity for inactive
imageTintList = ContextCompat.getColorStateList(context, R.color.emoji_palette_icons)
layoutParams =
android.widget.LinearLayout.LayoutParams(
0,
android.widget.LinearLayout.LayoutParams.MATCH_PARENT,
1f,
)
setOnClickListener {
activeButton?.imageAlpha = 128 // dim previous.
imageAlpha = 255 // full opacity for active.
activeButton = this

val position =
emojiItems.indexOfFirst {
it is EmojiAdapter.Item.Category && it.value == category
}
if (position != -1) {
(layoutManager as androidx.recyclerview.widget.LinearLayoutManager)
.scrollToPositionWithOffset(position, 0)
}
}
}

if (index == 0) activeButton = button
binding.emojiCategoriesStrip.addView(button)
}
}

/**
* Hides the emoji palette and restores the normal keyboard view and command options bar.
* Called when the user taps the close button, the ABC button, or finishes emoji selection.
*/
fun hideEmojiPalette() {
binding.emojiPaletteHolder.visibility = View.GONE
binding.keyboardView.visibility = View.VISIBLE
binding.commandOptionsBar.visibility = View.VISIBLE
binding.emojisList.scrollToPosition(0)
binding.emojiCategoriesStrip.removeAllViews()
}

/**
* Updates the text of the suggestion buttons, primarily for displaying emoji suggestions.
*
Expand Down
37 changes: 35 additions & 2 deletions app/src/keyboards/java/be/scri/services/GeneralKeyboardIME.kt
Original file line number Diff line number Diff line change
Expand Up @@ -492,6 +492,7 @@ abstract class GeneralKeyboardIME(
val inputConnection = currentInputConnection
if (inputConnection != null) {
when (code) {
KeyboardBase.KEYCODE_EMOJI -> openEmojiKeyboard()
KeyboardBase.KEYCODE_DELETE -> handleDelete()
KeyboardBase.KEYCODE_SHIFT -> {
if (keyboardMode == keyboardLetters) {
Expand Down Expand Up @@ -527,6 +528,10 @@ abstract class GeneralKeyboardIME(

// MARK: Helper Methods

fun openEmojiKeyboard() {
uiManager.showEmojiPalette()
}

protected fun isPeriodAndCommaEnabled(): Boolean {
val isPreferenceEnabled = PreferencesHelper.getEnablePeriodAndCommaABC(this, language)
val isInSearchBar = isSearchBar()
Expand Down Expand Up @@ -1006,15 +1011,43 @@ abstract class GeneralKeyboardIME(
// MARK: Deletion Logic

/**
* Handles the logic for the Delete/Backspace key. It deletes characters from either
* Handles the logic for the Delete key. It deletes characters from either
* the main input field or the command bar, depending on the context.
* Delegated to BackspaceHandler.
*
* @param isCommandBar true` if the deletion should happen in the command bar.
* @param isLongPress true` if this is a long press/repeat action, false for single tap.
*/
fun handleDelete(isLongPress: Boolean = false) {
val effectiveIsCommandBar = currentState != ScribeState.IDLE && currentState != ScribeState.SELECT_COMMAND
val inputConnection = currentInputConnection ?: return
val effectiveIsCommandBar =
currentState != ScribeState.IDLE &&
currentState != ScribeState.SELECT_COMMAND

if (!effectiveIsCommandBar) {
val selectedText = inputConnection.getSelectedText(0)
if (selectedText.isNullOrEmpty()) {
// Use BreakIterator to delete full emoji characters.
val prevText = inputConnection.getTextBeforeCursor(8, 0)
if (!prevText.isNullOrEmpty()) {
val breakIterator =
android.icu.text.BreakIterator
.getCharacterInstance()
breakIterator.setText(prevText.toString())
val end = breakIterator.last()
val start = breakIterator.previous()
val count =
if (start == android.icu.text.BreakIterator.DONE) {
1
} else {
(end - start).coerceAtLeast(1)
}
inputConnection.deleteSurroundingText(count, 0)
return
}
}
}

backspaceHandler.handleBackspace(effectiveIsCommandBar, isLongPress)
}

Expand Down
Loading
Loading