Introduce PickerPolicy and Popup.Keyboard.Explicit for decoupling emoji picker

This commit is contained in:
Rocka 2026-06-13 14:53:19 +08:00
parent 4dda210837
commit 2e36b2bf1b
No known key found for this signature in database
GPG Key ID: 28031158FFDD6853
10 changed files with 213 additions and 135 deletions

View File

@ -1,6 +1,6 @@
/*
* SPDX-License-Identifier: LGPL-2.1-or-later
* SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors
* SPDX-FileCopyrightText: Copyright 2021-2026 Fcitx5 for Android Contributors
*/
package org.fcitx.fcitx5.android.input.keyboard
@ -117,7 +117,12 @@ open class KeyDef(
class AltPreview(content: String, val alternative: String) : Preview(content)
class Keyboard(val label: String) : Popup()
sealed class Keyboard : Popup() {
data class Preset(val label: String, val transformPunctuation: Boolean = true) :
Keyboard()
class Explicit(val items: Array<String>) : Keyboard()
}
class Menu(val items: Array<Item>) : Popup() {
class Item(val label: String, @DrawableRes val icon: Int, val action: KeyAction)

View File

@ -1,6 +1,6 @@
/*
* SPDX-License-Identifier: LGPL-2.1-or-later
* SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors
* SPDX-FileCopyrightText: Copyright 2021-2026 Fcitx5 for Android Contributors
*/
package org.fcitx.fcitx5.android.input.keyboard
@ -35,7 +35,7 @@ class SymbolKey(
),
popup ?: arrayOf(
Popup.Preview(symbol),
Popup.Keyboard(symbol)
Popup.Keyboard.Preset(symbol)
)
)
@ -57,7 +57,7 @@ class AlphabetKey(
),
popup ?: arrayOf(
Popup.AltPreview(character, punctuation),
Popup.Keyboard(character)
Popup.Keyboard.Preset(character)
)
)
@ -78,7 +78,7 @@ class AlphabetDigitKey(
),
popup ?: arrayOf(
Popup.AltPreview(character, altText),
Popup.Keyboard(character)
Popup.Keyboard.Preset(character)
)
) {
constructor(

View File

@ -1,6 +1,6 @@
/*
* SPDX-License-Identifier: LGPL-2.1-or-later
* SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors
* SPDX-FileCopyrightText: Copyright 2021-2026 Fcitx5 for Android Contributors
*/
package org.fcitx.fcitx5.android.input.keyboard
@ -185,10 +185,17 @@ class TextKeyboard(
is PopupAction.PreviewAction -> action.copy(content = transformPopupPreview(action.content))
is PopupAction.PreviewUpdateAction -> action.copy(content = transformPopupPreview(action.content))
is PopupAction.ShowKeyboardAction -> {
val label = action.keyboard.label
if (label.length == 1 && label[0].isLetter())
action.copy(keyboard = KeyDef.Popup.Keyboard(transformAlphabet(label)))
else action
when (action.keyboard) {
is KeyDef.Popup.Keyboard.Preset -> {
val label = action.keyboard.label
if (label.length == 1 && label[0].isLetter())
action.copy(
keyboard = action.keyboard.copy(label = transformAlphabet(label))
)
else action
}
is KeyDef.Popup.Keyboard.Explicit -> action
}
}
else -> action
}

View File

@ -1,6 +1,6 @@
/*
* SPDX-License-Identifier: LGPL-2.1-or-later
* SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors
* SPDX-FileCopyrightText: Copyright 2021-2026 Fcitx5 for Android Contributors
*/
package org.fcitx.fcitx5.android.input.picker
@ -29,7 +29,6 @@ import org.fcitx.fcitx5.android.input.keyboard.KeyDef.Appearance.Border
import org.fcitx.fcitx5.android.input.keyboard.KeyDef.Appearance.Variant
import org.fcitx.fcitx5.android.input.keyboard.KeyView
import org.fcitx.fcitx5.android.input.keyboard.TextKeyView
import org.fcitx.fcitx5.android.input.popup.EmojiModifier
import org.fcitx.fcitx5.android.input.popup.PopupAction
import org.fcitx.fcitx5.android.input.popup.PopupActionListener
import splitties.views.dsl.constraintlayout.below
@ -72,10 +71,7 @@ class PickerPageUi(
Low(12, 4, 3, 19f, true, false)
}
companion object {
val BackspaceAction = SymAction(KeySym(FcitxKeyMapping.FcitxKey_BackSpace))
private var popupOnKeyPress by AppPrefs.getInstance().keyboard.popupOnKeyPress
}
private val popupOnKeyPress by AppPrefs.getInstance().keyboard.popupOnKeyPress
var keyActionListener: KeyActionListener? = null
var popupActionListener: PopupActionListener? = null
@ -106,8 +102,9 @@ class PickerPageUi(
)
private val backspaceKey by lazy {
val backspaceAction = SymAction(KeySym(FcitxKeyMapping.FcitxKey_BackSpace))
val action: (View) -> Unit = {
keyActionListener?.onKeyAction(BackspaceAction, Source.Keyboard)
keyActionListener?.onKeyAction(backspaceAction, Source.Keyboard)
}
val listener = View.OnClickListener { action.invoke(it) }
ImageKeyView(ctx, theme, backspaceAppearance).apply {
@ -175,11 +172,26 @@ class PickerPageUi(
layoutParams = ViewGroup.LayoutParams(matchParent, matchParent)
}
private fun onSymbolClick(str: String) {
keyActionListener?.onKeyAction(CommitAction(str), Source.Keyboard)
fun setItems(items: List<String>) {
keyViews.forEachIndexed { i, keyView ->
keyView.apply {
if (i >= items.size) {
isEnabled = false
mainText.text = ""
setOnClickListener(null)
} else {
isEnabled = true
mainText.text = items[i]
setOnClickListener { onItemClick(items[i]) }
}
swipeEnabled = false
onGestureListener = null
setOnLongClickListener(null)
}
}
}
fun setItems(items: List<String>, withSkinTone: Boolean = false) {
fun setItems(items: List<String>, policy: PickerPolicy) {
keyViews.forEachIndexed { i, keyView ->
keyView.apply {
if (i >= items.size) {
@ -191,28 +203,15 @@ class PickerPageUi(
onGestureListener = null
} else {
isEnabled = true
val label = items[i]
val commitString =
if (withSkinTone) EmojiModifier.getPreferredTone(label) else label
mainText.text = commitString
val item = items[i]
mainText.text = item
setOnClickListener {
onSymbolClick(commitString)
onItemClick(item)
}
setOnLongClickListener { view ->
view as KeyView
if (!popupOnKeyPress) {
// in case "popup on keypress" is disabled, popup keyboard need to know
// the actual bounds on press. see [^1] as well
view.updateBounds()
}
// TODO: maybe popup keyboard should just accept String as label?
onPopupAction(
PopupAction.ShowKeyboardAction(
view.id,
KeyDef.Popup.Keyboard(label),
bounds
)
)
setOnLongClickListener longClick@{ view ->
if (view !is KeyView) return@longClick false
val popup = policy.popup(item) ?: return@longClick false
onItemLongClick(view, popup)
false
}
swipeEnabled = true
@ -220,23 +219,13 @@ class PickerPageUi(
view as KeyView
when (event.type) {
CustomGestureView.GestureType.Down -> {
if (popupOnKeyPress) {
// [^1]: bounds is first calculated in KeyView's onLayout(), it
// not in screen viewport at the time of layout.
// eg. it's inside the next page of ViewPager
// so update bounds when it's pressed
view.updateBounds()
onPopupAction(
PopupAction.PreviewAction(view.id, label, view.bounds)
)
}
onPopupShow(view, item)
// never "consume" the gesture on touch down
false
}
CustomGestureView.GestureType.Move -> {
onPopupChangeFocus(view.id, event.x, event.y)
}
CustomGestureView.GestureType.Up -> {
onPopupTrigger(view.id).also {
onPopupAction(PopupAction.DismissAction(view.id))
@ -249,22 +238,45 @@ class PickerPageUi(
}
}
private fun onItemClick(item: String) {
keyActionListener?.onKeyAction(CommitAction(item), Source.Keyboard)
}
private fun onPopupAction(action: PopupAction) {
popupActionListener?.onPopupAction(action)
}
private fun onItemLongClick(view: KeyView, popup: KeyDef.Popup.Keyboard) {
if (!popupOnKeyPress) {
// in case "popup on keypress" is disabled, popup keyboard need to know
// the actual bounds on press. see [^1] as well
view.updateBounds()
}
onPopupAction(PopupAction.ShowKeyboardAction(view.id, popup, view.bounds))
}
private fun onPopupShow(view: KeyView, item: String) {
if (!popupOnKeyPress) return
// [^1]: bounds is first calculated in KeyView's onLayout(), it
// not in screen viewport at the time of layout.
// e.g. it's inside the next page of ViewPager
// so update bounds when it's pressed
view.updateBounds()
onPopupAction(PopupAction.PreviewAction(view.id, item, view.bounds))
return
}
private fun onPopupChangeFocus(viewId: Int, x: Float, y: Float): Boolean {
val changeFocusAction = PopupAction.ChangeFocusAction(viewId, x, y)
popupActionListener?.onPopupAction(changeFocusAction)
onPopupAction(changeFocusAction)
return changeFocusAction.outResult
}
private fun onPopupTrigger(viewId: Int): Boolean {
val triggerAction = PopupAction.TriggerAction(viewId)
// TODO: maybe popup keyboard should just yield String value?
onPopupAction(triggerAction)
val action = triggerAction.outAction as? FcitxKeyAction ?: return false
onSymbolClick(action.act)
onItemClick(action.act)
onPopupAction(PopupAction.DismissAction(viewId))
return true
}

View File

@ -1,14 +1,13 @@
/*
* SPDX-License-Identifier: LGPL-2.1-or-later
* SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors
* SPDX-FileCopyrightText: Copyright 2021-2026 Fcitx5 for Android Contributors
*/
package org.fcitx.fcitx5.android.input.picker
import android.text.TextPaint
import android.annotation.SuppressLint
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import org.fcitx.fcitx5.android.data.RecentlyUsed
import org.fcitx.fcitx5.android.data.prefs.AppPrefs
import org.fcitx.fcitx5.android.data.theme.Theme
import org.fcitx.fcitx5.android.input.keyboard.KeyActionListener
import org.fcitx.fcitx5.android.input.popup.PopupActionListener
@ -20,8 +19,8 @@ class PickerPagesAdapter(
private val rawData: List<Pair<PickerData.Category, Array<String>>>,
private val density: PickerPageUi.Density,
recentlyUsedFileName: String,
private val bordered: Boolean = false,
private val isEmoji: Boolean = false
private val bordered: Boolean,
private val policy: PickerPolicy
) : RecyclerView.Adapter<PickerPagesAdapter.ViewHolder>() {
class ViewHolder(val ui: PickerPageUi) : RecyclerView.ViewHolder(ui.root)
@ -38,17 +37,9 @@ class PickerPagesAdapter(
*/
private val pages: MutableList<List<String>> = mutableListOf(listOf())
private fun buildCategories(
data: List<Pair<PickerData.Category, Array<String>>>,
knowGraphOnly: Boolean = false
) {
val textPaint = if (knowGraphOnly) TextPaint() else null
private fun buildCategories(data: List<Pair<PickerData.Category, Array<String>>>) {
data.forEach { (cat, arr) ->
val list = if (textPaint != null) {
arr.filter { textPaint.hasGlyph(it) }
} else {
arr.asList()
}
val list = arr.filter(policy::filter).map(policy::transform)
val chunks = list.chunked(density.pageSize)
categories.add(cat to IntRange(pages.size, pages.size + chunks.size - 1))
pages.addAll(chunks)
@ -56,20 +47,29 @@ class PickerPagesAdapter(
}
init {
buildCategories(
rawData,
isEmoji && AppPrefs.getInstance().symbols.hideUnsupportedEmojis.getValue()
)
buildCategories(rawData)
}
fun rebuildCategories(knowGraphOnly: Boolean = false) {
private fun rebuildCategories() {
categories.clear()
// empty "RecentlyUsed" category
categories.add(PickerData.RecentlyUsedCategory to IntRange(0, 0))
pages.clear()
// empty "RecentlyUsed" page
pages.add(emptyList())
buildCategories(rawData, knowGraphOnly)
buildCategories(rawData)
}
private var lastInvalidateKey = policy.invalidateKey()
@SuppressLint("NotifyDataSetChanged")
fun refreshIfNeeded() {
val newKey = policy.invalidateKey()
if (lastInvalidateKey != newKey) {
lastInvalidateKey = newKey
rebuildCategories()
notifyDataSetChanged()
}
}
private val recentlyUsed = RecentlyUsed(recentlyUsedFileName, density.pageSize)
@ -102,18 +102,21 @@ class PickerPagesAdapter(
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.ui.setItems(pages[position], isEmoji)
if (position == 0) {
// RecentlyUsed content should be displayed as-is, without popups
holder.ui.setItems(recentlyUsed.items)
} else {
holder.ui.setItems(pages[position], policy)
}
}
override fun onViewAttachedToWindow(holder: ViewHolder) {
holder.ui.keyActionListener = keyActionListener
if (holder.bindingAdapterPosition == 0) {
holder.ui.popupActionListener = if (holder.bindingAdapterPosition == 0) {
// prevent popup on RecentlyUsed page
holder.ui.popupActionListener = null
// RecentlyUsed content are already modified with skin tones
holder.ui.setItems(recentlyUsed.items, withSkinTone = false)
null
} else {
holder.ui.popupActionListener = popupActionListener
popupActionListener
}
}

View File

@ -0,0 +1,76 @@
/*
* SPDX-License-Identifier: LGPL-2.1-or-later
* SPDX-FileCopyrightText: Copyright 2026 Fcitx5 for Android Contributors
*/
package org.fcitx.fcitx5.android.input.picker
import android.text.TextPaint
import org.fcitx.fcitx5.android.data.prefs.AppPrefs
import org.fcitx.fcitx5.android.input.keyboard.KeyDef
import org.fcitx.fcitx5.android.input.popup.EmojiModifier
interface PickerPolicy {
/**
* Whether Picker should include this symbol
*/
fun filter(raw: String): Boolean
/**
* Actual string to commit when this symbol was selected
*/
fun transform(raw: String): String
/**
* Long press options for this symbol
*/
fun popup(raw: String): KeyDef.Popup.Keyboard?
/**
* Return different value to make the Picker rebuild all pages on attach
*/
fun invalidateKey(): Any
}
/**
* Default policy: show all symbols as provided and never need to rebuild
*/
class DefaultPickerPolicy : PickerPolicy {
override fun filter(raw: String) = true
override fun transform(raw: String) = raw
override fun popup(raw: String) =
KeyDef.Popup.Keyboard.Preset(label = raw, transformPunctuation = false)
override fun invalidateKey() = Unit
}
/**
* Emoji policy: filter unsupported glyph, transform skintone, rebuild on preference changes
*/
class EmojiPickerPolicy : PickerPolicy {
data class Prefs(
val hideUnsupportedEmojis: Boolean,
val defaultSkinTone: EmojiModifier.SkinTone
)
private val symbolPrefs = AppPrefs.getInstance().symbols
private val hideUnsupportedEmojis by symbolPrefs.hideUnsupportedEmojis
private val defaultSkinTone by symbolPrefs.defaultEmojiSkinTone
private val prefs get() = Prefs(hideUnsupportedEmojis, defaultSkinTone)
override fun filter(raw: String): Boolean {
return if (hideUnsupportedEmojis) TextPaint().hasGlyph(raw) else true
}
override fun transform(raw: String): String {
return EmojiModifier.getPreferredTone(raw, defaultSkinTone)
}
override fun popup(raw: String): KeyDef.Popup.Keyboard? {
val items = EmojiModifier.produceSkinTones(raw, defaultSkinTone) ?: return null
return KeyDef.Popup.Keyboard.Explicit(items)
}
override fun invalidateKey(): Prefs = prefs
}

View File

@ -1,15 +1,12 @@
/*
* SPDX-License-Identifier: LGPL-2.1-or-later
* SPDX-FileCopyrightText: Copyright 2021-2025 Fcitx5 for Android Contributors
* SPDX-FileCopyrightText: Copyright 2021-2026 Fcitx5 for Android Contributors
*/
package org.fcitx.fcitx5.android.input.picker
import android.annotation.SuppressLint
import androidx.core.content.ContextCompat
import androidx.transition.Transition
import androidx.viewpager2.widget.ViewPager2
import org.fcitx.fcitx5.android.data.prefs.AppPrefs
import org.fcitx.fcitx5.android.data.prefs.ManagedPreference
import org.fcitx.fcitx5.android.data.theme.ThemeManager
import org.fcitx.fcitx5.android.input.broadcast.ReturnKeyDrawableComponent
import org.fcitx.fcitx5.android.input.dependency.theme
@ -32,7 +29,8 @@ class PickerWindow(
private val density: PickerPageUi.Density,
private val switchKey: KeyDef,
private val popupPreview: Boolean = true,
private val followKeyBorder: Boolean = true
private val followKeyBorder: Boolean = true,
private val policy: PickerPolicy = DefaultPickerPolicy()
) : InputWindow.ExtendedInputWindow<PickerWindow>(), EssentialWindow {
enum class Key : EssentialWindow.Key {
@ -104,14 +102,12 @@ class PickerWindow(
}
}
private val isEmoji = key === Key.Emoji
override fun onCreateView() = PickerLayout(context, theme, switchKey).apply {
pickerLayout = this
val bordered = followKeyBorder && keyBorder
pickerPagesAdapter = PickerPagesAdapter(
theme, keyActionListener, popupActionListener, data,
density, key.name, bordered, isEmoji
density, key.name, bordered, policy
)
tabsUi.apply {
setTabs(pickerPagesAdapter.getCategoryList())
@ -149,39 +145,17 @@ class PickerWindow(
override fun onCreateBarExtension() = pickerLayout.tabsUi.root
val symbolPrefs = AppPrefs.getInstance().symbols
private val hideUnsupportedEmojisPrefs = symbolPrefs.hideUnsupportedEmojis
private val defaultEmojiSkinTonePrefs = symbolPrefs.defaultEmojiSkinTone
@SuppressLint("NotifyDataSetChanged")
private val initDataListener = ManagedPreference.OnChangeListener<Any> { _, _ ->
pickerPagesAdapter.rebuildCategories(hideUnsupportedEmojisPrefs.getValue())
pickerPagesAdapter.notifyDataSetChanged()
}.takeIf { isEmoji }
@SuppressLint("NotifyDataSetChanged")
private val refreshPagesListener = ManagedPreference.OnChangeListener<Any> { _, _ ->
pickerPagesAdapter.notifyDataSetChanged()
}
override fun onAttached() {
pickerLayout.embeddedKeyboard.also {
pickerPagesAdapter.refreshIfNeeded()
it.onReturnDrawableUpdate(returnKeyDrawable.resourceId)
it.keyActionListener = keyActionListener
}
if (isEmoji) {
hideUnsupportedEmojisPrefs.registerOnChangeListener(initDataListener!!)
defaultEmojiSkinTonePrefs.registerOnChangeListener(refreshPagesListener)
}
}
override fun onDetached() {
popup.dismissAll()
pickerLayout.embeddedKeyboard.keyActionListener = null
if (isEmoji) {
hideUnsupportedEmojisPrefs.unregisterOnChangeListener(initDataListener!!)
defaultEmojiSkinTonePrefs.unregisterOnChangeListener(refreshPagesListener)
}
}
override val showTitle = false

View File

@ -1,6 +1,6 @@
/*
* SPDX-License-Identifier: LGPL-2.1-or-later
* SPDX-FileCopyrightText: Copyright 2021-2023 Fcitx5 for Android Contributors
* SPDX-FileCopyrightText: Copyright 2021-2026 Fcitx5 for Android Contributors
*/
package org.fcitx.fcitx5.android.input.picker
@ -23,7 +23,8 @@ fun emojiPicker(): PickerWindow = PickerWindow(
density = PickerPageUi.Density.Medium,
switchKey = TextPickerSwitchKey(":-)", PickerWindow.Key.Emoticon),
popupPreview = false,
followKeyBorder = false
followKeyBorder = false,
policy = EmojiPickerPolicy()
)
fun emoticonPicker(): PickerWindow = PickerWindow(

View File

@ -1,6 +1,6 @@
/*
* SPDX-License-Identifier: LGPL-2.1-or-later
* SPDX-FileCopyrightText: Copyright 2025 Fcitx5 for Android Contributors
* SPDX-FileCopyrightText: Copyright 2025-2026 Fcitx5 for Android Contributors
*/
package org.fcitx.fcitx5.android.input.popup
@ -13,7 +13,6 @@ import android.os.Build
import android.text.TextPaint
import androidx.annotation.RequiresApi
import org.fcitx.fcitx5.android.R
import org.fcitx.fcitx5.android.data.prefs.AppPrefs
import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceEnum
object EmojiModifier {
@ -45,8 +44,6 @@ object EmojiModifier {
0x1F9D1, 0x200D, 0x1F91D, 0x200D, 0x1F9D1,
)
private val defaultSkinTone by AppPrefs.getInstance().symbols.defaultEmojiSkinTone
fun isSupported(): Boolean {
// UProperty.EMOJI_MODIFIER_BASE requires API 28
return Build.VERSION.SDK_INT >= Build.VERSION_CODES.P
@ -107,22 +104,20 @@ object EmojiModifier {
return DefaultTextPaint.hasGlyph(emoji)
}
fun getPreferredTone(emoji: String): String {
fun getPreferredTone(emoji: String, tone: SkinTone): String {
if (!isSupported()) return emoji
val (codePoints, modifiable) = getCodePoints(emoji)
val tone = defaultSkinTone
if (tone == SkinTone.Default || !isModifiable(modifiable)) return emoji
val candidate = buildEmoji(codePoints, modifiable, tone)
return if (isValidEmoji(candidate)) candidate else emoji
}
fun produceSkinTones(emoji: String): Array<String>? {
fun produceSkinTones(emoji: String, excludeTone: SkinTone): Array<String>? {
if (!isSupported()) return null
val (codePoints, modifiable) = getCodePoints(emoji)
val tone = defaultSkinTone
if (!isModifiable(modifiable)) return null
val candidates = SkinTone.entries
.filter { it != tone }
.filter { it != excludeTone }
.map { buildEmoji(codePoints, modifiable, it) }
.filter { isValidEmoji(it) }
return if (candidates.isEmpty()) null else candidates.toTypedArray()

View File

@ -116,18 +116,23 @@ class PopupComponent :
}
private fun showKeyboard(viewId: Int, keyboard: KeyDef.Popup.Keyboard, bounds: Rect) {
val keys = PopupPreset[keyboard.label]
?: EmojiModifier.produceSkinTones(keyboard.label)
?: return
var keys: Array<String>
var labels: Array<String>
when (keyboard) {
is KeyDef.Popup.Keyboard.Preset -> {
val preset = PopupPreset[keyboard.label] ?: return
keys = preset
labels = if (keyboard.transformPunctuation && punctuation.enabled) {
Array(keys.size) { punctuation.transform(keys[it]) }
} else keys
}
is KeyDef.Popup.Keyboard.Explicit -> {
keys = keyboard.items
labels = keyboard.items
}
}
// clear popup preview text OR create empty popup preview
showingEntryUi[viewId]?.setText("") ?: showPopup(viewId, "", bounds)
reallyShowKeyboard(viewId, keys, bounds)
}
private fun reallyShowKeyboard(viewId: Int, keys: Array<String>, bounds: Rect) {
val labels = if (punctuation.enabled) {
Array(keys.size) { punctuation.transform(keys[it]) }
} else keys
val keyboardUi = PopupKeyboardUi(
context,
theme,