refactor: split key view and simplify keyboard layout and gesture architecture

- introduce KeyView as a dedicated key component for rendering, event handling, and popup interaction
- remove KeyboardGestureFrame and merge gesture handling into GestureFrame
- remove KeyboardSizeCalculator and KeyboardSize; move size calculation into Keyboard
- remove proximity_correction and vertical_correction from theme configuration
- simplify KeyboardActionListener.onPress(): remove isSound parameter and vibration handling
- centralize vibration feedback management in GestureFrame
- update KeyboardWindow to receive keyboardActionListener via constructor
This commit is contained in:
ENOA-ANSUL-EDEN 2026-03-11 18:51:01 +08:00
parent 6cf035b316
commit 91622f9307
19 changed files with 918 additions and 1482 deletions

View File

@ -17,11 +17,11 @@ height:
4: &jpgd4 48 #主键盘
5: &jpgd5 39.5 #符号键盘
6: &hgap 4 #键盘横缝大小
7: &sgap 8 #键盘竖缝大小
7: &sgap 12 #键盘竖缝大小
#键盘高度
round_corner:
1: &round1 12 #按键圆角半径
1: &round1 6 #按键圆角半径
2: &round2 0 #候选窗口圆角
3: &round3 12 #回车键圆角
@ -55,14 +55,12 @@ style:
popup_bottom_margin: 68 #按键提示底边距
popup_key_height: 48 #按键提示内键高度
popup_text_size: 23 #按键提示字号
proximity_correction: true #按键纠错
reset_ascii_mode: false #显示键盘时重置为中文状态
round_corner: *round1 #按键圆角半径
shadow_radius: 0.0 #按键阴影半径
symbol_font: symbol.ttf #符号字体
symbol_text_size: 10 #符号字号
text_font: latin.ttf #编码字体
vertical_correction: -5
vertical_gap: *sgap #键盘行距
enter_label_mode: 0 #是否使用App提供的ActionLabel内容作为Enter键的文本(由于多数App没有适配ActionLable,实际影响不大)。0不使用,1只使用actionlabel,2优先使用,3当其他方式没有获得label时才读取actionlabel
enter_labels: # 定义Enter键的文本

View File

@ -43,14 +43,12 @@ style:
preview_height: 60 #按鍵提示高度
preview_offset: -12 #按鍵提示縱向偏移
preview_text_size: 40 #按鍵提示字號
proximity_correction: true #按鍵糾錯
reset_ascii_mode: false #顯示鍵盤時重置爲中文狀態
round_corner: 8 #按鍵圓角半徑
shadow_radius: 0.0 #按鍵陰影半徑
symbol_font: symbol.ttf #符號字型
symbol_text_size: 10 #符號字號
text_font: latin.ttf #編碼字型
vertical_correction: -10
vertical_gap: 1 #鍵盤行距
#background_folder: #背景图保存在background目录下的哪个子目录
enter_label_mode: 0 #是否使用App提供的ActionLabel内容作为Enter键的文本(由于多数App没有适配ActionLable,实际影响不大)。0不使用,1只使用actionlabel,2优先使用,3当其他方式没有获得label时才读取actionlabel

View File

@ -45,8 +45,8 @@ class GeneralStyleMapper(
keySymbolOffsetY = getFloat("key_symbol_offset_y"),
keyHintOffsetX = getFloat("key_hint_offset_x"),
keyHintOffsetY = getFloat("key_hint_offset_y"),
keyPressOffsetX = getInt("key_press_offset_x"),
keyPressOffsetY = getInt("key_press_offset_y"),
keyPressOffsetX = getFloat("key_press_offset_x"),
keyPressOffsetY = getFloat("key_press_offset_y"),
keyWidth = getFloat("key_width"),
labelTextSize = getFloat("label_text_size"),
labelFont = getStringList("label_font"),
@ -59,14 +59,12 @@ class GeneralStyleMapper(
popupKeyHeight = getInt("popup_key_height"),
popupFont = getStringList("popup_font"),
popupTextSize = getFloat("popup_text_size"),
proximityCorrection = getBoolean("proximity_correction"),
resetASCIIMode = getBoolean("reset_ascii_mode"),
roundCorner = getFloat("round_corner"),
shadowRadius = getFloat("shadow_radius"),
symbolFont = getStringList("symbol_font"),
symbolTextSize = getFloat("symbol_text_size"),
textFont = getStringList("text_font"),
verticalCorrection = getInt("vertical_correction"),
verticalGap = getInt("vertical_gap"),
backgroundFolder = getString("background_folder"),
enterLabelMode = getInt("enter_label_mode"),

View File

@ -42,8 +42,8 @@ class TextKeyboardMapper(
keySymbolOffsetY = it.getFloat("key_symbol_offset_y"),
keyHintOffsetX = it.getFloat("key_hint_offset_x"),
keyHintOffsetY = it.getFloat("key_hint_offset_y"),
keyPressOffsetX = it.getInt("key_press_offset_x"),
keyPressOffsetY = it.getInt("key_press_offset_y"),
keyPressOffsetX = it.getFloat("key_press_offset_x"),
keyPressOffsetY = it.getFloat("key_press_offset_y"),
keyTextColor = it.getString("key_text_color"),
keyBackColor = it.getString("key_back_color"),
keySymbolColor = it.getString("key_symbol_color"),
@ -92,8 +92,8 @@ class TextKeyboardMapper(
keySymbolOffsetY = getFloat("key_symbol_offset_y"),
keyHintOffsetX = getFloat("key_hint_offset_x"),
keyHintOffsetY = getFloat("key_hint_offset_y"),
keyPressOffsetX = getInt("key_press_offset_x"),
keyPressOffsetY = getInt("key_press_offset_y"),
keyPressOffsetX = getFloat("key_press_offset_x"),
keyPressOffsetY = getFloat("key_press_offset_y"),
importPreset = getString("import_preset"),
keys = keys,
)

View File

@ -44,8 +44,8 @@ data class GeneralStyle(
val keySymbolOffsetY: Float = 0f,
val keyHintOffsetX: Float = 0f,
val keyHintOffsetY: Float = 0f,
val keyPressOffsetX: Int = 0,
val keyPressOffsetY: Int = 0,
val keyPressOffsetX: Float = 0f,
val keyPressOffsetY: Float = 0f,
val keyWidth: Float = 0f,
val labelTextSize: Float = 0f,
val labelFont: List<String> = emptyList(),
@ -58,14 +58,12 @@ data class GeneralStyle(
val popupKeyHeight: Int = 0,
val popupFont: List<String> = emptyList(),
val popupTextSize: Float = 0f,
val proximityCorrection: Boolean = false,
val resetASCIIMode: Boolean = false,
val roundCorner: Float = 0f,
val shadowRadius: Float = 0f,
val symbolFont: List<String> = emptyList(),
val symbolTextSize: Float = 0f,
val textFont: List<String> = emptyList(),
val verticalCorrection: Int = 0,
val verticalGap: Int = 0,
val backgroundFolder: String = "backgrounds",
val enterLabelMode: Int = 0,

View File

@ -36,8 +36,8 @@ data class TextKeyboard(
val keySymbolOffsetY: Float,
val keyHintOffsetX: Float,
val keyHintOffsetY: Float,
val keyPressOffsetX: Int,
val keyPressOffsetY: Int,
val keyPressOffsetX: Float,
val keyPressOffsetY: Float,
val importPreset: String,
val keys: List<TextKey>,
) : Parcelable {
@ -65,8 +65,8 @@ data class TextKeyboard(
val keySymbolOffsetY: Float,
val keyHintOffsetX: Float,
val keyHintOffsetY: Float,
val keyPressOffsetX: Int,
val keyPressOffsetY: Int,
val keyPressOffsetX: Float,
val keyPressOffsetY: Float,
val keyTextColor: String,
val keyBackColor: String,
val keySymbolColor: String,

View File

@ -35,6 +35,7 @@ import com.osfans.trime.ime.core.TrimeInputMethodService
import com.osfans.trime.ime.dependency.InputDependencyManager
import com.osfans.trime.ime.keyboard.CommonKeyboardActionListener
import com.osfans.trime.ime.keyboard.GestureFrame
import com.osfans.trime.ime.keyboard.KeyBehavior
import com.osfans.trime.ime.keyboard.KeyboardWindow
import com.osfans.trime.ime.switches.SwitchOptionWindow
import com.osfans.trime.ime.window.BoardWindow
@ -117,8 +118,8 @@ class InputBarDelegate : InputBroadcastReceiver {
alwaysUi.updateState(newState)
}
private val swipeDownHideKeyboardCallback: ((GestureFrame.SwipeDirection) -> Unit) = { d ->
if (d == GestureFrame.SwipeDirection.Down) {
private val swipeDownHideKeyboardCallback: ((KeyBehavior) -> Unit) = { d ->
if (d == KeyBehavior.SWIPE_DOWN) {
service.requestHideSelf(0)
}
}
@ -133,7 +134,7 @@ class InputBarDelegate : InputBroadcastReceiver {
}.apply {
hideKeyboardButton.apply {
setOnClickListener { service.requestHideSelf(0) }
onSwipeListener = swipeDownHideKeyboardCallback
onSwipe = swipeDownHideKeyboardCallback
}
clipboardUi.suggestionView.apply {
setOnClickListener {
@ -157,7 +158,7 @@ class InputBarDelegate : InputBroadcastReceiver {
private val candidateUi by lazy {
CandidateUi(context, candidate.view).apply {
unrollButton.apply {
onSwipeListener = swipeDownHideKeyboardCallback
onSwipe = swipeDownHideKeyboardCallback
}
}
}

View File

@ -114,8 +114,6 @@ class CandidateItemUi(
/**
* candidate long press feedback is handled by `showCandidateActionMenu`
*/
longPressFeedbackEnabled = false
add(
content,
lParams(matchParent, matchParent) {

View File

@ -115,10 +115,9 @@ class CommonKeyboardActionListener {
val listener by lazy {
object : KeyboardActionListener {
override fun onPress(keyEventCode: Int, isSound: Boolean) {
override fun onPress(keyEventCode: Int) {
InputFeedbackManager.run {
keyPressVibrate(service.window.window!!.decorView)
if (isSound) keyPressSound(keyEventCode)
keyPressSound(keyEventCode)
keyPressSpeak(keyEventCode)
}
}

View File

@ -1,5 +1,5 @@
/*
* SPDX-FileCopyrightText: 2015 - 2025 Rime community
* SPDX-FileCopyrightText: 2015 - 2026 Rime community
* SPDX-License-Identifier: GPL-3.0-or-later
*/
@ -7,8 +7,8 @@ package com.osfans.trime.ime.keyboard
import android.annotation.SuppressLint
import android.content.Context
import android.os.SystemClock
import android.view.MotionEvent
import android.view.ViewConfiguration
import android.widget.FrameLayout
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.lifecycleScope
@ -20,43 +20,53 @@ import kotlin.math.abs
import kotlin.math.floor
open class GestureFrame(context: Context) : FrameLayout(context) {
enum class SwipeDirection {
Up,
Down,
Left,
Right,
}
private var startX = 0f
private var startY = 0f
private var lastX = 0f
private var startTime = 0L
private var isLongPressed = false
private var slideActivated = false
private var shouldPerformSwipe = false
private var longPressJob: Job? = null
private var repeatJob: Job? = null
private var doubleTapJob: Job? = null
private var lastTapTime = 0L
private var lastSwipeBehavior: KeyBehavior = KeyBehavior.CLICK
private val lifecycleScope by lazy {
findViewTreeLifecycleOwner()?.lifecycleScope!!
}
private var touchId = 0
var onClick: (() -> Unit)? = null
var onDoubleClick: (() -> Unit)? = null
var onLazyDoubleClick: (() -> Unit)? = null
var onLongClick: (() -> Unit)? = null
@Volatile
private var longPressTriggered = false
private var longPressJob: Job? = null
var onSwipeLeft: (() -> Unit)? = null
var onSwipeRight: (() -> Unit)? = null
var onSwipeUp: (() -> Unit)? = null
var onSwipeDown: (() -> Unit)? = null
@Volatile
var longPressFeedbackEnabled = true
var onSlide: ((delta: Int, x: Float, y: Float) -> Unit)? = null
@Volatile
private var swipeTriggered = false
private var shouldPerformSwipe = false
private var swipeStartX = -1f
private var swipeStartY = -1f
private var swipeLastX = -1f
var onPress: (() -> Unit)? = null
var onRelease: ((behavior: KeyBehavior, longPress: Boolean) -> Unit)? = null
var onCancel: (() -> Unit)? = null
var onMove: ((x: Float, y: Float, longPress: Boolean) -> Unit)? = null
var onSwipe: ((behavior: KeyBehavior) -> Unit)? = null
@Volatile
private var slideActivated = false
var isRepeatable = false
var isSlideCursor = false
var isSlideDelete = false
private var lastTapTime = 0L
private var maybeDoubleTap = false
var onDoubleTapListener: (() -> Unit)? = null
var onSwipeListener: ((SwipeDirection) -> Unit)? = null
var onSlideListener: ((Int) -> Unit)? = null
var onReleaseListener: (() -> Unit)? = null
var hasLongPress = false
var hasDouble = false
var hasLazyDouble = false
var hasPopup = false
init {
// disable system sound effect and haptic feedback
@ -67,149 +77,245 @@ open class GestureFrame(context: Context) : FrameLayout(context) {
isFocusableInTouchMode = false
}
fun getSwipeDirection(dx: Float, dy: Float): SwipeDirection = if (abs(dx) > abs(dy)) {
if (dx > 0) SwipeDirection.Right else SwipeDirection.Left
} else {
if (dy > 0) SwipeDirection.Down else SwipeDirection.Up
}
fun getNStep(start: Float, end: Float, step: Float): Int = (if (start < end) 1 else -1) * floor(abs(end - start) / step).toInt()
private fun resetState() {
onReleaseListener?.invoke()
swipeStartX = -1f
swipeStartY = -1f
swipeLastX = -1f
longPressTriggered = false
longPressJob?.cancel()
longPressJob = null
shouldPerformSwipe = false
swipeTriggered = false
slideActivated = false
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
val x = event.x
val y = event.y
when (event.actionMasked) {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
if (!isEnabled) return false
touchId = (touchId) and 0xFFFF
val currentTouchId = touchId
drawableHotspotChanged(x, y)
isPressed = true
InputFeedbackManager.keyPressVibrate(this)
longPressJob?.cancel()
longPressJob = lifecycleScope.launch {
delay(longPressTimeout.toLong())
if (touchId != currentTouchId) return@launch
if (!(swipeTriggered || longPressTriggered || shouldPerformSwipe)) {
longPressTriggered = performLongClick()
if (longPressFeedbackEnabled && longPressTriggered) {
InputFeedbackManager.keyPressVibrate(this@GestureFrame, true)
}
}
}
swipeStartX = x
swipeStartY = y
swipeLastX = x
return true
}
MotionEvent.ACTION_MOVE -> {
if (!isEnabled) return false
drawableHotspotChanged(x, y)
val dx = x - swipeStartX
val dy = y - swipeStartY
startX = event.x
startY = event.y
lastX = startX
startTime = SystemClock.elapsedRealtime()
if (!longPressTriggered) {
if (!shouldPerformSwipe && (abs(dx) > SWIPE_THRESHOLD || abs(dy) > SWIPE_THRESHOLD)) {
shouldPerformSwipe = true
swipeTriggered = true
}
}
val onSlide = onSlideListener
if (onSlide != null) {
if (!slideActivated) {
if (abs(dx) >= SWIPE_THRESHOLD) {
val startX = swipeStartX
slideActivated = true
swipeLastX = startX + (if (dx > 0) SWIPE_THRESHOLD else -SWIPE_THRESHOLD)
}
}
if (slideActivated) {
val startX = swipeStartX
val lastX = swipeLastX
val totalPast = getNStep(startX, lastX, SLIDE_STEP_SIZE)
val totalNow = getNStep(startX, x, SLIDE_STEP_SIZE)
val delta = totalNow - totalPast
if (delta != 0) {
onSlide(delta)
}
swipeLastX = x
}
} else if (longPressTriggered) {
val lastX = swipeLastX
val delta = getNStep(lastX, x, SWIPE_MOVE_SIZE)
if (delta != 0) {
swipeLastX += delta.toFloat() * SWIPE_MOVE_SIZE
}
isLongPressed = false
slideActivated = false
shouldPerformSwipe = false
lastSwipeBehavior = KeyBehavior.CLICK
if (vibrateOnKeyPress) InputFeedbackManager.keyPressVibrate(this)
onPress?.invoke()
if (hasLongPress || isRepeatable || hasPopup) {
startLongPressJob()
}
return true
}
MotionEvent.ACTION_MOVE -> {
val x = event.x
val y = event.y
val dx = x - startX
val dy = y - startY
if (!isLongPressed) {
val behavior = detectSwipe(dx, dy)
if (behavior != lastSwipeBehavior) {
lastSwipeBehavior = behavior
if (behavior != KeyBehavior.CLICK) {
onSwipe?.invoke(behavior)
}
}
}
onMove?.invoke(x, y, isLongPressed)
if ((isSlideCursor || isSlideDelete) && onSlide != null && !isLongPressed) {
if (!slideActivated) {
if (abs(dx) >= swipeTravel) {
slideActivated = true
lastX = startX
}
}
if (slideActivated) {
val step = getNStep(lastX, x, slideStepSize.toFloat())
if (step != 0) {
onSlide?.invoke(step, x, y)
lastX = x
}
}
}
return true
}
MotionEvent.ACTION_UP -> {
isPressed = false
val dx = x - swipeStartX
val dy = y - swipeStartY
val x = event.x
val y = event.y
if (vibrateOnKeyRelease) InputFeedbackManager.keyPressVibrate(this)
cancelJobs()
if (slideActivated) {
val onSlide = onSlideListener
if (onSlide != null) {
onSlide(0)
resetState()
return true
}
onSlide?.invoke(0, x, y)
onCancel?.invoke()
return true
}
if (isLongPressed) {
dispatchBehavior(KeyBehavior.LONG_CLICK, true)
return true
}
if (shouldPerformSwipe) {
if (!longPressTriggered) {
onSwipeListener?.invoke(getSwipeDirection(dx, dy))
dispatchBehavior(lastSwipeBehavior, false)
return true
}
if (!hasDouble && !hasLazyDouble) {
dispatchBehavior(KeyBehavior.CLICK, false)
return true
}
val now = SystemClock.elapsedRealtime()
val delta = now - lastTapTime
if (delta <= doubleTapTimeout) {
lastTapTime = 0
doubleTapJob?.cancel()
if (hasDouble) {
dispatchBehavior(KeyBehavior.DOUBLE_CLICK, false)
} else {
dispatchBehavior(KeyBehavior.LAZY_DOUBLE_CLICK, false)
}
} else {
if (!longPressTriggered) {
val onDoubleTap = onDoubleTapListener
if (onDoubleTap != null) {
val now = System.currentTimeMillis()
if (maybeDoubleTap && now - lastTapTime <= longPressTimeout) {
maybeDoubleTap = false
onDoubleTap()
} else {
maybeDoubleTap = true
performClick()
lastTapTime = now
if (hasLazyDouble && !hasDouble) {
doubleTapJob = lifecycleScope.launch {
delay(doubleTapTimeout.toLong())
if (lastTapTime == now) {
lastTapTime = 0
dispatchBehavior(KeyBehavior.CLICK, false)
}
lastTapTime = now
} else {
performClick()
}
} else {
dispatchBehavior(KeyBehavior.CLICK, false)
}
}
resetState()
return true
}
MotionEvent.ACTION_CANCEL -> {
isPressed = false
resetState()
cancelJobs()
isLongPressed = false
slideActivated = false
shouldPerformSwipe = false
onCancel?.invoke()
return true
}
}
return true
}
companion object {
private const val SWIPE_THRESHOLD = 24f
private const val SWIPE_MOVE_SIZE = 24f
private const val SLIDE_STEP_SIZE = 12f
private fun startLongPressJob() {
longPressJob = lifecycleScope.launch {
delay(longPressTimeout.toLong())
if (shouldPerformSwipe || slideActivated) return@launch
isLongPressed = true
if (vibrateOnKeyPress) InputFeedbackManager.keyPressVibrate(this@GestureFrame, true)
if (isRepeatable) {
startRepeatJob()
} else {
performLongClick()
}
}
}
private fun startRepeatJob() {
repeatJob = lifecycleScope.launch {
try {
while (true) {
if (vibrateOnKeyRepeat) InputFeedbackManager.keyPressVibrate(this@GestureFrame)
dispatchBehavior(KeyBehavior.CLICK, true)
delay(repeatInterval.toLong())
}
} finally {
onCancel?.invoke()
}
}
}
private fun detectSwipe(dx: Float, dy: Float): KeyBehavior {
val absDx = abs(dx)
val absDy = abs(dy)
val distance = if (absDx > absDy) absDx else absDy
val elapsed = SystemClock.elapsedRealtime() - startTime
val velocity = if (elapsed > 0) {
(distance / elapsed) * 1000f
} else {
0f
}
val isSwipe = distance >= swipeTravel || velocity >= swipeVelocity
shouldPerformSwipe = isSwipe
if (!isSwipe) return KeyBehavior.CLICK
return if (absDx > absDy) {
if (dx > 0) KeyBehavior.SWIPE_RIGHT else KeyBehavior.SWIPE_LEFT
} else {
if (dy > 0) KeyBehavior.SWIPE_DOWN else KeyBehavior.SWIPE_UP
}
}
private fun dispatchBehavior(
behavior: KeyBehavior,
longPress: Boolean,
) {
onRelease?.invoke(behavior, longPress)
when (behavior) {
KeyBehavior.CLICK -> performClick()
KeyBehavior.DOUBLE_CLICK -> onDoubleClick?.invoke()
KeyBehavior.LAZY_DOUBLE_CLICK -> onLazyDoubleClick?.invoke()
KeyBehavior.SWIPE_LEFT -> onSwipeLeft?.invoke()
KeyBehavior.SWIPE_RIGHT -> onSwipeRight?.invoke()
KeyBehavior.SWIPE_UP -> onSwipeUp?.invoke()
KeyBehavior.SWIPE_DOWN -> onSwipeDown?.invoke()
else -> {}
}
}
private fun cancelJobs() {
longPressJob?.cancel()
repeatJob?.cancel()
doubleTapJob?.cancel()
}
fun getNStep(start: Float, end: Float, step: Float): Int = (if (start < end) 1 else -1) *
floor(abs(end - start) / step).toInt()
override fun setOnLongClickListener(l: OnLongClickListener?) {
hasLongPress = l != null
super.setOnLongClickListener(l)
}
override fun performClick(): Boolean {
super.performClick()
onClick?.invoke()
return true
}
override fun performLongClick(): Boolean {
val handled = super.performLongClick()
onLongClick?.invoke()
return handled || onLongClick != null
}
companion object {
private val swipeTravel by AppPrefs.defaultInstance().keyboard.swipeTravel
private val swipeVelocity by AppPrefs.defaultInstance().keyboard.swipeVelocity
private val longPressTimeout by AppPrefs.defaultInstance().keyboard.longPressTimeout
private val repeatInterval by AppPrefs.defaultInstance().keyboard.repeatInterval
private val doubleTapTimeout by AppPrefs.defaultInstance().keyboard.doubleTapTimeout
private val slideStepSize by AppPrefs.defaultInstance().keyboard.slideStepSize
private val vibrateOnKeyPress by AppPrefs.defaultInstance().keyboard.vibrateOnKeyPress
private val vibrateOnKeyRelease by AppPrefs.defaultInstance().keyboard.vibrateOnKeyRelease
private val vibrateOnKeyRepeat by AppPrefs.defaultInstance().keyboard.vibrateOnKeyRepeat
}
}

View File

@ -64,8 +64,8 @@ class Key(
get() = field + keyOffsetX
var keyHintOffsetY = 0f
get() = field + keyOffsetY
var keyPressOffsetX = 0
var keyPressOffsetY = 0
var keyPressOffsetX = 0f
var keyPressOffsetY = 0f
// get color from key customization or just fallback to specified color
private fun getColor(
@ -85,8 +85,7 @@ class Key(
src: TextKeyboard.TextKey.() -> String,
fallback: String,
) = selfConfig?.let {
if (src(it).isEmpty()) null
ColorManager.getDrawable(src(it))
if (src(it).isEmpty()) null else ColorManager.getDrawable(src(it))
} ?: ColorManager.getDrawable(fallback)
private val keyBackground by lazy { getDrawable({ keyBackColor }, "key_back_color") }
@ -125,10 +124,10 @@ class Key(
return isOn
}
private val keyOffsetX: Int
get() = if (isPressed) keyPressOffsetX else 0
private val keyOffsetY: Int
get() = if (isPressed) keyPressOffsetY else 0
private val keyOffsetX: Float
get() = if (isPressed) keyPressOffsetX else 0f
private val keyOffsetY: Float
get() = if (isPressed) keyPressOffsetY else 0f
/**
* Informs the key that it has been pressed, in case it needs to change its appearance or state.

View File

@ -0,0 +1,401 @@
/*
* SPDX-FileCopyrightText: 2015 - 2026 Rime community
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.osfans.trime.ime.keyboard
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.Rect
import android.graphics.drawable.GradientDrawable
import android.view.KeyEvent
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.utils.sizeDp
import com.osfans.trime.daemon.RimeDaemon
import com.osfans.trime.data.prefs.AppPrefs
import com.osfans.trime.data.theme.ColorManager
import com.osfans.trime.data.theme.FontManager
import com.osfans.trime.ime.core.TrimeInputMethodService
import com.osfans.trime.ime.popup.PopupAction
import com.osfans.trime.ime.popup.PopupDelegate
import com.osfans.trime.util.sp
import splitties.dimensions.dp
import timber.log.Timber
@SuppressLint("ClickableViewAccessibility", "ViewConstructor")
class KeyView(
context: Context,
private val key: Key,
private val keyboard: Keyboard,
private val keyboardView: KeyboardView,
private val keyboardActionListener: KeyboardActionListener,
) : GestureFrame(context) {
private val service: TrimeInputMethodService
get() = keyboardView.service
private val popup: PopupDelegate
get() = keyboardView.popup
private val rime get() = RimeDaemon.getFirstSessionOrNull()!!
private val hookShiftArrow: Boolean by lazy {
AppPrefs.defaultInstance().keyboard.hookShiftArrow.getValue()
}
private val deletedTextBuffer = ArrayDeque<String>()
private var keyPressed = false
override fun isPressed(): Boolean = keyPressed
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
}
private val symbolPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textAlign = Paint.Align.CENTER
}
private var cachedIcon: IconicsDrawable? = null
private var cachedIconName: String? = null
private val cachedLocation = intArrayOf(0, 0)
private val cachedBounds = Rect()
private var boundsValid = false
val bounds: Rect
get() = cachedBounds.also {
if (!boundsValid) updateBounds()
}
fun updateBounds() {
val (x, y) = cachedLocation.also { getLocationInWindow(it) }
cachedBounds.set(x, y, x + width, y + height)
boundsValid = true
}
init {
setWillNotDraw(false)
isRepeatable = key.click?.isRepeatable ?: false
isSlideCursor = key.click?.isSlideCursor ?: false
isSlideDelete = key.click?.isSlideDelete ?: false
hasLongPress = key.hasAction(KeyBehavior.LONG_CLICK)
hasDouble = key.hasAction(KeyBehavior.DOUBLE_CLICK)
hasLazyDouble = key.hasAction(KeyBehavior.LAZY_DOUBLE_CLICK)
hasPopup = key.popup.isNotEmpty()
onPress = {
if (keyboard.firstPressedKeyIndex == -1) keyboard.firstPressedKeyIndex = id
setPressedState(true)
key.getCode(KeyBehavior.CLICK).let { keyboardActionListener.onPress(it) }
showPopupPreview()
}
onRelease = { behavior, isFromLongPress ->
Timber.d("KeyView release: label=${key.getLabel()}, behavior=$behavior, fromLongPress=$isFromLongPress")
if (isFromLongPress) {
if (hasPopup) {
val triggerAction = PopupAction.TriggerAction(id)
popup.listener.onPopupAction(triggerAction)
triggerAction.outAction?.let { action ->
keyboardActionListener.onAction(KeyAction(action))
setPressedState(false)
dismissPopupPreview()
}
} else if (isRepeatable) {
key.getAction(KeyBehavior.CLICK)?.let { processKeyAction(it, KeyBehavior.CLICK) }
}
} else {
when (behavior) {
KeyBehavior.CLICK -> {
val pressedIdx = keyboard.firstPressedKeyIndex
val actionBehavior = if (pressedIdx != -1 && pressedIdx != id) KeyBehavior.COMBO else behavior
key.getAction(actionBehavior)?.let { processKeyAction(it, actionBehavior) }
}
KeyBehavior.DOUBLE_CLICK, KeyBehavior.LAZY_DOUBLE_CLICK,
KeyBehavior.SWIPE_UP, KeyBehavior.SWIPE_DOWN, KeyBehavior.SWIPE_LEFT, KeyBehavior.SWIPE_RIGHT,
->
key.getAction(behavior)?.let { processKeyAction(it, behavior) }
else -> {}
}
setPressedState(false)
dismissPopupPreview()
}
if (keyboard.firstPressedKeyIndex == id) keyboard.firstPressedKeyIndex = -1
}
onSwipe = { direction ->
setPressedState(true)
showPopupPreview(direction)
}
onSlide = { delta, _, _ ->
service.let { svc ->
if (isSlideCursor) {
repeat(kotlin.math.abs(delta)) {
val keyCode = if (delta > 0) KeyEvent.KEYCODE_DPAD_RIGHT else KeyEvent.KEYCODE_DPAD_LEFT
svc.sendDownUpKeyEvent(keyCode)
}
} else if (isSlideDelete) {
val ic = svc.currentInputConnection
when {
delta < 0 -> {
val beforeText = ic.getTextBeforeCursor(1, 0) ?: ""
if (beforeText.isNotEmpty()) {
deletedTextBuffer.addFirst(beforeText.toString())
ic.deleteSurroundingText(1, 0)
}
}
delta > 0 -> {
if (deletedTextBuffer.isNotEmpty()) {
ic.commitText(deletedTextBuffer.removeFirst(), 1)
}
}
}
}
}
}
onLongClick = {
if (key.popup.isNotEmpty()) {
dismissPopupPreview()
showPopupKeyboard()
} else if (hasLongPress) {
key.getAction(KeyBehavior.LONG_CLICK)?.let {
processKeyAction(it, KeyBehavior.LONG_CLICK)
setPressedState(false)
dismissPopupPreview()
}
}
}
onMove = { x, y, isLongPress ->
if (isLongPress && hasPopup) {
popup.listener.onPopupAction(PopupAction.ChangeFocusAction(id, x, y))
}
}
onCancel = {
deletedTextBuffer.clear()
setPressedState(false)
dismissPopupPreview()
}
}
fun setPressedState(pressed: Boolean) {
if (keyPressed != pressed) {
keyPressed = pressed
if (pressed) {
key.onPressed()
} else {
key.onReleased()
}
invalidate()
}
}
private fun processKeyAction(action: KeyAction, behavior: KeyBehavior) {
Timber.d("processKeyAction: label=${key.getLabel()}, code=${action.code}, type=$behavior")
if (action.isModifierKey) {
keyboard.clickModifierKey(
action.isShiftLock xor (behavior == KeyBehavior.LONG_CLICK),
action.modifierKeyOnMask,
)
keyboardView.invalidateAllKeys()
return
}
keyboardActionListener.onAction(action)
val hookArrow = if (hookShiftArrow) {
when (action.code) {
in KeyEvent.KEYCODE_DPAD_UP..KeyEvent.KEYCODE_DPAD_RIGHT -> true
KeyEvent.KEYCODE_MOVE_HOME, KeyEvent.KEYCODE_MOVE_END -> true
else -> false
}
} else {
false
}
if (!hookArrow) {
if (keyboard.refreshModifier()) {
keyboardView.invalidateAllKeys()
}
}
}
private fun showPopupKeyboard() {
val popupKeys = key.popup
if (popupKeys.isEmpty()) return
popup.listener.onPopupAction(
PopupAction.ShowKeyboardAction(id, popupKeys, bounds),
)
}
private fun showPopupPreview(behavior: KeyBehavior = KeyBehavior.CLICK) {
key.getPreviewText(behavior).takeIf { it.isNotEmpty() }?.let { previewText ->
val context = if (previewText.isIconFont) {
previewText
} else {
String(Character.toChars(previewText.codePointAt(0)))
}
popup.listener.onPopupAction(PopupAction.PreviewAction(id, context, bounds))
}
}
private fun dismissPopupPreview() {
popup.listener.onPopupAction(
PopupAction.DismissAction(id),
)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val desiredWidth = key.width + paddingLeft + paddingRight
val desiredHeight = key.height + paddingTop + paddingBottom
setMeasuredDimension(
resolveSize(desiredWidth, widthMeasureSpec),
resolveSize(desiredHeight, heightMeasureSpec),
)
}
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
boundsValid = false
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
drawBackground(canvas, key)
val label = if (key.code == KeyEvent.KEYCODE_ENTER && keyboardView.labelEnter.isNotEmpty()) {
keyboardView.labelEnter
} else {
key.getLabel()
}
if (label.isNotEmpty()) {
drawLabel(canvas, label, key)
}
val symbol = key.symbolLabel
if (symbol.isNotEmpty()) {
drawSymbol(canvas, symbol, key)
}
val hint = key.hint
if (hint.isNotEmpty()) {
drawSymbol(canvas, hint, key, isTop = false)
}
}
private fun drawBackground(canvas: Canvas, k: Key) {
val bg = k.getBackgroundDrawable() ?: return
if (bg is GradientDrawable) {
(k.roundCorner ?: keyboard.roundCorner).takeIf { it > 0f }?.let { bg.cornerRadius = dp(it) }
(k.keyBorder ?: keyboard.keyBorder).takeIf { it > 0 }?.let { bg.setStroke(dp(it), ColorManager.getColor("key_border_color")) }
}
bg.setBounds(
paddingLeft,
paddingTop,
width - paddingRight,
height - paddingBottom,
)
bg.draw(canvas)
}
private fun drawLabel(canvas: Canvas, label: String, k: Key) {
val textColor = k.getTextColor()
val textSize = if (k.keyTextSize > 0) sp(k.keyTextSize) else sp(if (label.length > 1) keyboardView.labelTextSize else keyboardView.keyTextSize)
if (label.isIconFont) {
drawIcon(canvas, label, textSize, textColor)
} else {
textPaint.apply {
color = textColor
this.textSize = textSize
typeface = FontManager.getTypeface("key_font")
clearShadowLayer()
}
val centerX = (width - paddingLeft - paddingRight) / 2f + paddingLeft
val centerY = (height - paddingTop - paddingBottom) / 2f + paddingTop
val adjustmentY = (textPaint.textSize - textPaint.descent()) / 2f
val offsetX = k.keyTextOffsetX
val offsetY = k.keyTextOffsetY
canvas.drawText(label, centerX + sp(offsetX), centerY + adjustmentY + sp(offsetY), textPaint)
}
}
private fun drawIcon(canvas: Canvas, iconName: String, size: Float, color: Int) {
val iconSize = (size / resources.displayMetrics.density).toInt()
val cmdName = iconName.toIconName()
val icon = if (cachedIconName == cmdName) {
cachedIcon!!
} else {
IconicsDrawable(context, cmdName).apply {
sizeDp = iconSize
cachedIcon = this
cachedIconName = cmdName
}
}
icon.colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
val centerX = (width - paddingLeft - paddingRight) / 2f + paddingLeft + sp(key.keyTextOffsetX)
val centerY = (height - paddingTop - paddingBottom) / 2f + paddingTop
icon.setBounds(
(centerX - icon.intrinsicWidth / 2).toInt(),
(centerY - icon.intrinsicHeight / 2).toInt(),
(centerX + icon.intrinsicWidth / 2).toInt(),
(centerY + icon.intrinsicHeight / 2).toInt(),
)
icon.draw(canvas)
}
private fun drawSymbol(canvas: Canvas, text: String, k: Key, isTop: Boolean = true) {
val showSymbol = rime.run { !getRuntimeOption("_hide_key_symbol") }
val showHint = rime.run { !getRuntimeOption("_hide_key_hint") }
if (isTop && !showSymbol) return
if (!isTop && !showHint) return
val textColor = k.getSymbolColor()
val textSize = if (k.symbolTextSize > 0) sp(k.symbolTextSize) else sp(12f)
if (text.isIconFont) {
drawIcon(canvas, text, textSize, textColor)
} else {
symbolPaint.apply {
color = textColor
this.textSize = textSize
typeface = FontManager.getTypeface("symbol_font")
}
val fontMetrics = symbolPaint.fontMetrics
val offsetX = if (isTop) k.keySymbolOffsetX else k.keyHintOffsetX
val offsetY = if (isTop) k.keySymbolOffsetY else k.keyHintOffsetY
val centerX = (width - paddingLeft - paddingRight) / 2f + paddingLeft + sp(offsetX)
val centerY = if (isTop) {
paddingTop - fontMetrics.top + sp(offsetY)
} else {
height - paddingBottom - fontMetrics.bottom + sp(offsetY)
}
canvas.drawText(text, centerX, centerY, symbolPaint)
}
}
}

View File

@ -17,7 +17,6 @@ import com.osfans.trime.util.appContext
import splitties.bitflags.hasFlag
import splitties.dimensions.dp
import splitties.systemservices.windowManager
import timber.log.Timber
import kotlin.math.abs
import kotlin.math.pow
@ -27,18 +26,18 @@ class Keyboard(
private val theme: Theme,
selfConfig: TextKeyboard? = null,
) {
// TODO: Apply proper dp unit conversion for keyHeight, verticalGap, and horizontalGap in a future breaking change
/** 按鍵默認水平間距 */
private val horizontalGap: Int =
internal val horizontalGap: Int =
intArrayOf(
selfConfig?.horizontalGap ?: 0,
theme.generalStyle.horizontalGap,
).firstOrNull { it > 0 } ?: 0
).firstOrNull { it > 0 }?.let { appContext.dp(it) } ?: 0
/** 默認鍵寬 */
private val keyWidth: Int = (allowedWidth * theme.generalStyle.keyWidth / 100).toInt()
/** 默認鍵高 */
/** 默認鍵高 (NOTE: 无需 dp 转换,会被 keyboardHeight 等比例缩放) */
private val keyHeight: Int =
intArrayOf(
selfConfig?.height?.toInt() ?: 0,
@ -46,11 +45,11 @@ class Keyboard(
).firstOrNull { it > 0 } ?: 0
/** 默認行距 */
private val verticalGap: Int =
internal val verticalGap: Int =
intArrayOf(
selfConfig?.verticalGap ?: 0,
theme.generalStyle.verticalGap,
).firstOrNull { it > 0 } ?: 0
).firstOrNull { it > 0 }?.let { appContext.dp(it) } ?: 0
/** 默認按鍵圓角半徑 */
val roundCorner: Float =
@ -88,6 +87,8 @@ class Keyboard(
var modifier = 0
private set
var firstPressedKeyIndex: Int = -1
/** Width of the screen available to fit the keyboard */
private val allowedWidth: Int
get() {
@ -135,7 +136,6 @@ class Keyboard(
val isLock = selfConfig?.lock ?: false // 切換程序時記憶鍵盤
val asciiKeyboard: String? = selfConfig?.asciiKeyboard // 英文鍵盤
// todo 把按下按键弹出的内容改为单独设计的view,而不是keyboard
val keyboardHeight: Int =
intArrayOf(
selfConfig?.let { getKeyboardHeightFromKeyboardConfig(it) } ?: 0,
@ -143,167 +143,187 @@ class Keyboard(
).firstOrNull { it > 0 } ?: 0
init {
if (selfConfig != null) {
val columns = selfConfig.columns
// 按键高度取值顺序: keys > keyboard/height > style/key_height
// 考虑到key设置height_land需要对皮肤做大量修改,而当部分key设置height而部分没有设时会造成按键高度异常,故取消普通按键的height_land参数
var rowHeight = keyHeight
// 定义 新的键盘尺寸计算方式, 避免尺寸计算不恰当,导致切换键盘时键盘高度发生变化,UI闪烁的问题。同时可以快速调整整个键盘的尺寸
// 1. default键盘的高度 = 其他键盘的高度
// 2. 当键盘高度(不含padding)与keyboard_height不一致时,每行按键等比例缩放按键高度高度,行之间的间距向上取整数、padding不缩放;
// 3. 由于高度只能取整数,缩放后仍然存在余数的,由 auto_height_index 指定的行吸收(遵循四舍五入)
// 特别的,当值为负数时,为倒序序号(-1即倒数第一个);当值大于按键行数时,为最后一行
val autoHeightIndex = selfConfig.autoHeightIndex
fun firstNonZero(a: Float, b: Float, c: Float): Float = if (a != 0f) {
a
} else if (b != 0f) {
b
} else {
c
}
val keys = selfConfig.keys
val keyboardKeyWidth = selfConfig.width
val maxColumns = if (columns == -1) Int.MAX_VALUE else columns
val isSplit = appContext.isLandscapeMode() && landscapePercent > 0
val (rowWidthTotalWeight, oneWeightWidthPx, multiplier, scaledHeight, scaledVerticalGap) =
KeyboardSizeCalculator(
isSplit,
landscapePercent,
maxColumns,
allowedWidth,
keyboardHeight,
keyboardKeyWidth,
keyHeight,
horizontalGap,
verticalGap,
autoHeightIndex,
).calc(keys)
var x = this.horizontalGap / 2
var y = scaledVerticalGap
var row = 0
val maxColumns = if (selfConfig.columns == -1) Int.MAX_VALUE else selfConfig.columns
val isSplit = appContext.isLandscapeMode() && landscapePercent > 0
val splitRatio = if (isSplit) landscapePercent / 100f else 0f
val oneWeightWidthPx =
allowedWidth.toFloat() / (MAX_TOTAL_WEIGHT * (1 + splitRatio))
// total width weight for each row.
val rowWidthTotalWeight = mutableListOf<Float>()
// raw height of each row before scaling
val rowRawHeight = mutableListOf<Int>()
var x = 0
var column = 0
var rowHeight = keyHeight
var totalKeyWidth = 0f
// determine row count, row heights, total row weights; does not create Key objects
for (key in keys) {
// determine the width weight of this key
val keyWidthWeight =
if (key.width == 0f && key.click.isNotEmpty()) keyboardKeyWidth else key.width
val widthPx = (keyWidthWeight * allowedWidth / MAX_TOTAL_WEIGHT).toInt()
// wrap to next row if column or width limit is reached
if (column >= maxColumns || x + widthPx > allowedWidth) {
rowWidthTotalWeight.add(totalKeyWidth)
rowRawHeight.add(rowHeight)
x = 0
column = 0
totalKeyWidth = 0f
}
// first key of a row defines the row height
if (column == 0) {
rowHeight = if (key.height > 0) key.height.toInt() else keyHeight
}
totalKeyWidth += keyWidthWeight
// only clickable keys count toward column count
if (key.click.isNotEmpty()) {
column++
}
x += widthPx
}
rowWidthTotalWeight.add(totalKeyWidth)
rowRawHeight.add(rowHeight)
val rows = rowRawHeight.size
val rawHeightSum = rowRawHeight.sum()
// scaled row heights after fitting into keyboardHeight
val rowHeightScaled = MutableList(rows) { 0 }
var remainHeight = keyboardHeight
val scale = keyboardHeight.toFloat() / rawHeightSum
// scale row heights to keyboardHeight; last row absorbs rounding errors
for (i in 0 until rows - 1) {
val h = (rowRawHeight[i] * scale).toInt()
rowHeightScaled[i] = h
remainHeight -= h
}
rowHeightScaled[rows - 1] = remainHeight
var xPos = 0
var yPos = 0
var row = 0
column = 0
var rowWeightAccumulo = 0f
var currentRowHeight = rowHeightScaled[0]
// indicates whether the split gap has been inserted in the current row
var splitInserted = false
minWidth = 0
try {
var rowWidthWeight = 0f
for (textKey in keys) {
val gap = this.horizontalGap
val keyWidth =
if (textKey.width == 0f && textKey.click.isNotEmpty()) {
keyboardKeyWidth
} else {
textKey.width
}
var widthPx = (keyWidth * oneWeightWidthPx).toInt()
widthPx -= gap
if (column >= maxColumns || x + widthPx > allowedWidth) {
// new row
rowWidthWeight = 0f
x = gap / 2
y += scaledVerticalGap + rowHeight
column = 0
row++
if (mKeys.isNotEmpty()) {
mKeys[mKeys.size - 1].edgeFlags =
mKeys[mKeys.size - 1].edgeFlags or EDGE_RIGHT
}
}
rowWidthWeight += keyWidth
val totalWeightOfThisRow = rowWidthTotalWeight[row] ?: 0f
if (isSplit && rowWidthWeight >= totalWeightOfThisRow / 2 + 1) {
rowWidthWeight = Int.MIN_VALUE.toFloat()
val weight = (totalWeightOfThisRow * multiplier * oneWeightWidthPx).toInt()
if (keyWidth > 20) {
// enlarge the key if this key is a long key
widthPx += weight
} else {
x += weight // (10 * (defaultWidth));
}
}
if (column == 0) {
rowHeight =
if (keyboardHeight > 0) {
scaledHeight[row]
} else {
if (textKey.height > 0) textKey.height.toInt() else keyHeight
}
}
if (textKey.click.isEmpty()) { // 無按鍵事件
x += widthPx + gap
continue // 縮進
}
val key = Key(this, textKey)
key.keyTextOffsetX =
floatArrayOf(
textKey.keyTextOffsetX,
selfConfig.keyTextOffsetX,
theme.generalStyle.keyTextOffsetX,
).firstOrNull { it != 0f } ?: 0f
key.keyTextOffsetY =
floatArrayOf(
textKey.keyTextOffsetY,
selfConfig.keyTextOffsetY,
theme.generalStyle.keyTextOffsetY,
).firstOrNull { it != 0f } ?: 0f
key.keySymbolOffsetX =
floatArrayOf(
textKey.keySymbolOffsetX,
selfConfig.keySymbolOffsetX,
theme.generalStyle.keySymbolOffsetX,
).firstOrNull { it != 0f } ?: 0f
key.keySymbolOffsetY =
floatArrayOf(
textKey.keySymbolOffsetY,
selfConfig.keySymbolOffsetY,
theme.generalStyle.keySymbolOffsetY,
).firstOrNull { it != 0f } ?: 0f
key.keyHintOffsetX =
floatArrayOf(
textKey.keyHintOffsetX,
selfConfig.keyHintOffsetX,
theme.generalStyle.keyHintOffsetX,
).firstOrNull { it != 0f } ?: 0f
key.keyHintOffsetY =
floatArrayOf(
textKey.keyHintOffsetY,
selfConfig.keyHintOffsetY,
theme.generalStyle.keyHintOffsetY,
).firstOrNull { it != 0f } ?: 0f
key.keyPressOffsetX =
intArrayOf(
textKey.keyPressOffsetX,
selfConfig.keyPressOffsetX,
theme.generalStyle.keyPressOffsetX,
).firstOrNull { it != 0 } ?: 0
key.keyPressOffsetY =
intArrayOf(
textKey.keyPressOffsetY,
selfConfig.keyPressOffsetY,
theme.generalStyle.keyPressOffsetY,
).firstOrNull { it != 0 } ?: 0
key.x = x
key.y = y
val rightGap = abs(allowedWidth - x - widthPx - gap / 2)
// 右側不留白
key.width =
if (rightGap <= allowedWidth / 100) allowedWidth - x - gap / 2 else widthPx
key.height = rowHeight
key.gap = gap
key.row = row
key.column = column
column++
x += key.width + key.gap
mKeys.add(key)
if (x > minWidth) {
minWidth = x
// create Key objects, assign position, size, offsets
for (textKey in keys) {
val keyWidthWeight =
if (textKey.width == 0f && textKey.click.isNotEmpty()) keyboardKeyWidth else textKey.width
var widthPx = (keyWidthWeight * oneWeightWidthPx).toInt()
// wrap to next row if limits are exceeded
if (column >= maxColumns || xPos + widthPx > allowedWidth) {
xPos = 0
yPos += currentRowHeight
row++
column = 0
rowWeightAccumulo = 0f
splitInserted = false
currentRowHeight = rowHeightScaled[row]
}
rowWeightAccumulo += keyWidthWeight
val totalWeight = rowWidthTotalWeight[row]
// if split keyboard layout is enabled, insert split gap at row middle when cumulative width ≥ 50%
if (isSplit && !splitInserted && rowWeightAccumulo >= totalWeight * 0.5f) {
splitInserted = true
val gap = (totalWeight * splitRatio * oneWeightWidthPx).toInt()
// large keys absorb the gap; small keys shift right
if (keyWidthWeight > 20f) {
widthPx += gap
} else {
xPos += gap
}
}
if (mKeys.isNotEmpty()) {
mKeys[mKeys.size - 1].edgeFlags =
mKeys[mKeys.size - 1].edgeFlags or EDGE_RIGHT
// spacer keys only move the cursor; no Key object is created
if (textKey.click.isEmpty()) {
xPos += widthPx
continue
}
this.height = y + rowHeight + scaledVerticalGap
for (key in mKeys) {
if (key.column == 0) key.edgeFlags = key.edgeFlags or EDGE_LEFT
if (key.row == 0) key.edgeFlags = key.edgeFlags or EDGE_TOP
if (key.row == row) key.edgeFlags = key.edgeFlags or EDGE_BOTTOM
val key = Key(this, textKey)
key.keyTextOffsetX = firstNonZero(textKey.keyTextOffsetX, selfConfig.keyTextOffsetX, theme.generalStyle.keyTextOffsetX)
key.keyTextOffsetY = firstNonZero(textKey.keyTextOffsetY, selfConfig.keyTextOffsetY, theme.generalStyle.keyTextOffsetY)
key.keySymbolOffsetX = firstNonZero(textKey.keySymbolOffsetX, selfConfig.keySymbolOffsetX, theme.generalStyle.keySymbolOffsetX)
key.keySymbolOffsetY = firstNonZero(textKey.keySymbolOffsetY, selfConfig.keySymbolOffsetY, theme.generalStyle.keySymbolOffsetY)
key.keyHintOffsetX = firstNonZero(textKey.keyHintOffsetX, selfConfig.keyHintOffsetX, theme.generalStyle.keyHintOffsetX)
key.keyHintOffsetY = firstNonZero(textKey.keyHintOffsetY, selfConfig.keyHintOffsetY, theme.generalStyle.keyHintOffsetY)
key.x = xPos
key.y = yPos
// correct minor rounding errors on the right edge
val rightGap = abs(allowedWidth - xPos - widthPx)
key.width = if (rightGap <= allowedWidth / 100) allowedWidth - xPos else widthPx
key.height = currentRowHeight
key.row = row
key.column = column
column++
xPos += key.width
mKeys.add(key)
if (xPos > minWidth) {
minWidth = xPos
}
} catch (e: Exception) {
Timber.e(e, "Failed to create keyboard")
}
mKeys.lastOrNull()?.edgeFlags = mKeys.lastOrNull()?.edgeFlags?.or(EDGE_RIGHT) ?: 0
height = yPos + currentRowHeight
for (key in mKeys) {
if (key.column == 0) key.edgeFlags = key.edgeFlags or EDGE_LEFT
if (key.row == 0) key.edgeFlags = key.edgeFlags or EDGE_TOP
if (key.row == row) key.edgeFlags = key.edgeFlags or EDGE_BOTTOM
}
}
}
@ -492,6 +512,7 @@ class Keyboard(
private const val GRID_WIDTH = 10
private const val GRID_HEIGHT = 5
private const val GRID_SIZE = GRID_WIDTH * GRID_HEIGHT
private const val MAX_TOTAL_WEIGHT = 100
/** Number of key widths from current touch point to search for nearest keys. */
const val SEARCH_DISTANCE = 1.4f

View File

@ -13,7 +13,7 @@ interface KeyboardActionListener {
* @param keyEventCode the unicode of the key being pressed. If the touch is not on a valid key,
* the value will be zero.
*/
fun onPress(keyEventCode: Int, isSound: Boolean = true)
fun onPress(keyEventCode: Int)
/**
* Called when the user releases a key. This is sent after the [.onKey] is called. For

View File

@ -1,380 +0,0 @@
/*
* SPDX-FileCopyrightText: 2015 - 2025 Rime community
* SPDX-License-Identifier: GPL-3.0-or-later
*/
package com.osfans.trime.ime.keyboard
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.os.SystemClock
import android.util.SparseArray
import android.view.MotionEvent
import android.widget.FrameLayout
import androidx.core.util.isNotEmpty
import androidx.core.util.size
import androidx.lifecycle.findViewTreeLifecycleOwner
import androidx.lifecycle.lifecycleScope
import com.osfans.trime.data.prefs.AppPrefs
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlin.math.abs
import kotlin.math.floor
import kotlin.math.max
open class KeyboardGestureFrame(context: Context) : FrameLayout(context) {
private data class PointerState(
var keyIndex: Int = -1,
var startX: Float = 0f,
var startY: Float = 0f,
var lastX: Float = 0f,
var startTime: Long = 0L,
var isLongPressed: Boolean = false,
var shouldPerformSwipe: Boolean = false,
var slideActivated: Boolean = false,
var longPressJob: Job? = null,
var repeatJob: Job? = null,
var lastSwipeBehavior: KeyBehavior? = null,
var currentX: Float = 0f,
var currentY: Float = 0f,
)
private val lifecycleScope by lazy {
findViewTreeLifecycleOwner()?.lifecycleScope!!
}
private val activePointers = SparseArray<PointerState>()
private val lastTapTimes = SparseArray<Long>()
var onKeyActionListener: ((keyIndex: Int, behavior: KeyBehavior) -> Boolean)? = null
var onKeySlideListener: ((keyIndex: Int, deltaX: Int, x: Float, y: Float) -> Boolean)? = null
var onKeyStateListener: ((keyIndex: Int, behavior: KeyBehavior, isVisible: Boolean, isPressed: Boolean, isRepeating: Boolean) -> Unit)? = null
var onKeyReleaseListener: ((keyIndex: Int) -> Unit)? = null
var onPopupChangeFocus: ((keyIndex: Int, x: Float, y: Float) -> Unit)? = null
var onPopupSelected: ((keyIndex: Int) -> Unit)? = null
open fun getKeyIndex(x: Float, y: Float): Int = -1
open fun isKeyRepeatable(keyIndex: Int): Boolean = false
open fun isKeySlideCursor(keyIndex: Int): Boolean = false
open fun isKeySlideDelete(keyIndex: Int): Boolean = false
open fun hasAction(keyIndex: Int, behavior: KeyBehavior): Boolean = false
open fun hasPopupKeys(keyIndex: Int): Boolean = false
open val verticalCorrection: Int = 0
init {
setWillNotDraw(false)
}
@SuppressLint("ClickableViewAccessibility")
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN, MotionEvent.ACTION_POINTER_DOWN -> {
val pointerIndex = event.actionIndex
val pointerId = event.getPointerId(pointerIndex)
val x = event.getX(pointerIndex)
val y = event.getY(pointerIndex)
val adjustedY = if (y >= -verticalCorrection) y + verticalCorrection else y
val keyIndex = getKeyIndex(x, adjustedY)
if (keyIndex == -1) return true
val state = PointerState(
keyIndex = keyIndex,
startX = x,
startY = y,
lastX = x,
currentX = x,
currentY = y,
startTime = SystemClock.elapsedRealtime(),
)
activePointers.put(pointerId, state)
onKeyStateListener?.invoke(keyIndex, KeyBehavior.CLICK, true, true, false)
val hasLongClick = hasAction(keyIndex, KeyBehavior.LONG_CLICK)
val hasPopup = hasPopupKeys(keyIndex)
if (hasLongClick || hasPopup || isKeyRepeatable(keyIndex)) launchLongPressJob(pointerId, state)
}
MotionEvent.ACTION_MOVE -> {
for (i in 0 until event.pointerCount) {
val pointerId = event.getPointerId(i)
val state = activePointers.get(pointerId) ?: continue
val keyIndex = state.keyIndex
if (keyIndex == -1) continue
val x = event.getX(i)
val y = event.getY(i)
val dx = x - state.startX
val dy = y - state.startY
state.currentX = x
state.currentY = y
if (!swipeEnabled) {
val currentKeyIndex = getKeyIndex(x, y)
if (currentKeyIndex != -1 && currentKeyIndex != state.keyIndex) {
activateKeyFeedback(currentKeyIndex)
deactivateKeyFeedback(state.keyIndex)
state.keyIndex = currentKeyIndex
}
state.longPressJob?.cancel()
continue
}
if (!state.isLongPressed && swipeEnabled) {
val behavior = getSwipeBehavior(state, dx, dy)
if (state.lastSwipeBehavior != behavior) {
state.lastSwipeBehavior = behavior
activateKeyFeedback(keyIndex, behavior)
}
}
if ((isKeySlideCursor(keyIndex) || isKeySlideDelete(keyIndex)) && onKeySlideListener != null && !state.isLongPressed) {
if (!state.slideActivated) {
if (abs(dx) >= swipeTravel) {
state.slideActivated = true
state.lastX = state.startX + (if (dx > 0) swipeTravel else -swipeTravel)
}
}
if (state.slideActivated) {
val startX = state.startX
val lastX = state.lastX
val totalPast = getNStep(startX, lastX, slideStepSize.toFloat())
val totalNow = getNStep(startX, x, slideStepSize.toFloat())
val delta = totalNow - totalPast
if (delta != 0) {
onKeySlideListener?.invoke(keyIndex, delta, x, y)
state.lastX = x
}
}
}
if (state.isLongPressed) {
onPopupChangeFocus?.invoke(keyIndex, x, y)
}
}
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_POINTER_UP -> {
val pointerIndex = event.actionIndex
val pointerId = event.getPointerId(pointerIndex)
val state = activePointers.get(pointerId) ?: return true
activePointers.delete(pointerId)
val keyIndex = state.keyIndex
if (keyIndex == -1) return true
val x = event.getX(pointerIndex)
val y = event.getY(pointerIndex)
if (state.slideActivated) onKeySlideListener?.invoke(keyIndex, 0, x, y)
state.longPressJob?.cancel()
state.repeatJob?.cancel()
onKeyReleaseListener?.invoke(keyIndex)
val hasLazy = hasAction(keyIndex, KeyBehavior.LAZY_DOUBLE_CLICK)
val hasDouble = hasAction(keyIndex, KeyBehavior.DOUBLE_CLICK)
if (state.isLongPressed) {
onPopupSelected?.invoke(keyIndex)
deactivateKeyFeedback(keyIndex, KeyBehavior.LONG_CLICK)
return true
}
if (state.shouldPerformSwipe && !state.slideActivated) {
onKeyActionListener?.invoke(keyIndex, state.lastSwipeBehavior!!)
deactivateKeyFeedback(keyIndex)
return true
}
if (activePointers.isNotEmpty()) {
onKeyActionListener?.invoke(keyIndex, KeyBehavior.COMBO)
deactivateKeyFeedback(keyIndex)
return true
}
if (!hasLazy && !hasDouble) {
if (!state.slideActivated) {
onKeyActionListener?.invoke(keyIndex, KeyBehavior.CLICK)
}
deactivateKeyFeedback(keyIndex)
return true
}
val now = SystemClock.elapsedRealtime()
val lastTime = lastTapTimes.get(keyIndex, 0L)
val timeDelta = now - lastTime
if (hasLazy && !hasDouble) {
if (timeDelta <= doubleTapTimeout) {
lastTapTimes.delete(keyIndex)
onKeyActionListener?.invoke(keyIndex, KeyBehavior.LAZY_DOUBLE_CLICK)
} else {
val scheduledAt = now
lastTapTimes.put(keyIndex, scheduledAt)
lifecycleScope.launch {
delay(doubleTapTimeout.toLong())
if (lastTapTimes.get(keyIndex, -1L) == scheduledAt) {
lastTapTimes.delete(keyIndex)
onKeyActionListener?.invoke(keyIndex, KeyBehavior.CLICK)
}
}
}
} else if (timeDelta <= doubleTapTimeout) {
lastTapTimes.delete(keyIndex)
onKeyActionListener?.invoke(keyIndex, KeyBehavior.DOUBLE_CLICK)
} else if (!state.slideActivated) {
lastTapTimes.put(keyIndex, now)
onKeyActionListener?.invoke(keyIndex, KeyBehavior.CLICK)
}
deactivateKeyFeedback(keyIndex)
}
MotionEvent.ACTION_CANCEL -> {
onKeyReleaseListener?.invoke(-1)
for (i in 0 until activePointers.size) {
val state = activePointers.valueAt(i)
state.longPressJob?.cancel()
state.repeatJob?.cancel()
deactivateKeyFeedback(state.keyIndex)
}
activePointers.clear()
lastTapTimes.clear()
}
}
return true
}
private fun launchLongPressJob(pointerId: Int, state: PointerState) {
val keyIndex = state.keyIndex
state.longPressJob = lifecycleScope.launch {
delay(longPressTimeout.toLong())
if (activePointers.get(pointerId) != state || state.shouldPerformSwipe || state.slideActivated) return@launch
state.isLongPressed = true
if (isKeyRepeatable(keyIndex)) {
onKeyActionListener?.invoke(keyIndex, KeyBehavior.CLICK)
launchRepeatClickJob(pointerId, state)
} else {
if (hasPopupKeys(keyIndex)) onKeyStateListener?.invoke(keyIndex, KeyBehavior.LONG_CLICK, true, true, false)
if (onKeyActionListener?.invoke(keyIndex, KeyBehavior.LONG_CLICK) == true) {
deactivateKeyFeedback(keyIndex, KeyBehavior.LONG_CLICK)
activePointers.delete(pointerId)
}
}
}
}
private fun launchRepeatClickJob(pointerId: Int, state: PointerState) {
val keyIndex = state.keyIndex
state.repeatJob = lifecycleScope.launch {
try {
delay(repeatInterval.toLong())
while (activePointers.get(pointerId) == state) {
onKeyActionListener?.invoke(keyIndex, KeyBehavior.CLICK)
onKeyStateListener?.invoke(keyIndex, KeyBehavior.CLICK, false, false, true)
delay(repeatInterval.toLong())
}
} finally {
deactivateKeyFeedback(keyIndex)
}
}
}
private fun getSwipeBehavior(state: PointerState, dx: Float, dy: Float): KeyBehavior {
val absDx = abs(dx)
val absDy = abs(dy)
val distance = max(absDx, absDy)
val elapsed = SystemClock.elapsedRealtime() - state.startTime
val velocity = if (elapsed > 0) (distance / elapsed) * 1000f else 0f
val isSwipe = distance >= swipeTravel || velocity >= swipeVelocity
val behavior = if (isSwipe) {
if (absDx > absDy) {
if (dx > 0) KeyBehavior.SWIPE_RIGHT else KeyBehavior.SWIPE_LEFT
} else {
if (dy > 0) KeyBehavior.SWIPE_DOWN else KeyBehavior.SWIPE_UP
}
} else {
KeyBehavior.CLICK
}
state.shouldPerformSwipe = isSwipe
return behavior
}
fun getNStep(start: Float, end: Float, step: Float): Int = (if (start < end) 1 else -1) * floor(abs(end - start) / step).toInt()
private fun activateKeyFeedback(keyIndex: Int, behavior: KeyBehavior = KeyBehavior.CLICK) {
onKeyStateListener?.invoke(keyIndex, behavior, true, false, false)
}
private fun deactivateKeyFeedback(keyIndex: Int, behavior: KeyBehavior = KeyBehavior.CLICK) {
onKeyStateListener?.invoke(keyIndex, behavior, false, false, false)
}
private val touchPaint by lazy {
if (DEBUG_SHOW_TOUCH_PATH) {
Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
strokeWidth = 2f
strokeCap = Paint.Cap.ROUND
}
} else {
null
}
}
override fun onDrawForeground(canvas: Canvas) {
super.onDrawForeground(canvas)
if (touchPaint == null) return
for (i in 0 until activePointers.size) {
val state = activePointers.valueAt(i)
drawTouchPath(canvas, state.startX, state.startY, state.currentX, state.currentY)
}
}
private fun drawTouchPath(canvas: Canvas, startX: Float, startY: Float, endX: Float, endY: Float) {
val p = touchPaint ?: return
p.color = 0xFF90CAF9.toInt()
p.style = Paint.Style.FILL
canvas.drawCircle(startX, startY, 6f, p)
p.color = 0xFF000000.toInt()
p.style = Paint.Style.STROKE
canvas.drawLine(startX, startY, endX, endY, p)
p.color = 0xFFFFCC80.toInt()
p.style = Paint.Style.FILL
canvas.drawCircle(endX, endY, 6f, p)
p.textSize = 28f
p.style = Paint.Style.FILL
p.alpha = 255
p.color = 0xFF000000.toInt()
val startText = "x:${startX.toInt()} y:${startY.toInt()}"
val endText = "x:${endX.toInt()} y:${endY.toInt()}"
canvas.drawText(startText, startX + 10f, startY + 30f, p)
canvas.drawText(endText, endX + 10f, endY + 30f, p)
invalidate()
}
companion object {
private const val DEBUG_SHOW_TOUCH_PATH = false
private val swipeEnabled by AppPrefs.defaultInstance().keyboard.swipeEnabled
private val swipeTravel by AppPrefs.defaultInstance().keyboard.swipeTravel
private val swipeVelocity by AppPrefs.defaultInstance().keyboard.swipeVelocity
private val longPressTimeout by AppPrefs.defaultInstance().keyboard.longPressTimeout
private val repeatInterval by AppPrefs.defaultInstance().keyboard.repeatInterval
private val doubleTapTimeout by AppPrefs.defaultInstance().keyboard.doubleTapTimeout
private val slideStepSize by AppPrefs.defaultInstance().keyboard.slideStepSize
}
}

View File

@ -1,13 +0,0 @@
// SPDX-FileCopyrightText: 2015 - 2024 Rime community
//
// SPDX-License-Identifier: GPL-3.0-or-later
package com.osfans.trime.ime.keyboard
data class KeyboardSize(
val rowWidthTotalWeight: Map<Int, Float>,
val defaultWidth: Float,
val multiplier: Float,
val scaledHeight: List<Int>,
val scaledVerticalGap: Int,
)

View File

@ -1,145 +0,0 @@
// SPDX-FileCopyrightText: 2015 - 2024 Rime community
//
// SPDX-License-Identifier: GPL-3.0-or-later
package com.osfans.trime.ime.keyboard
import com.osfans.trime.data.theme.model.TextKeyboard
import com.osfans.trime.util.appContext
import kotlin.math.abs
import kotlin.math.ceil
class KeyboardSizeCalculator(
isSplit: Boolean,
splitPercent: Int,
private val maxColumns: Int,
private val mAllowedWidth: Int,
private val keyboardHeight: Int,
private val keyboardKeyWidth: Float,
private val keyHeight: Int,
private val mDefaultHorizontalGap: Int,
private val mDefaultVerticalGap: Int,
private val autoHeightIndex: Int,
) {
private val splitSpaceRatio: Float = if (isSplit) (splitPercent / 100f) else 0f
fun calc(keys: List<TextKeyboard.TextKey>): KeyboardSize {
var x = mDefaultHorizontalGap / 2
var y = 0
var column = 0
var row = 0
var rowHeight = keyHeight
var rawSumHeight = 0
val rawHeight = ArrayList<Int>()
var totalKeyWidth = 0f
var maxColumn = 0
val rowTotalWeight = HashMap<Int, Float>()
for (key in keys) {
val keyWidthWeight =
if (key.width == 0f && key.click.isNotEmpty()) {
keyboardKeyWidth
} else {
key.width
}
val widthPx =
(keyWidthWeight * mAllowedWidth / MAX_TOTAL_WEIGHT).toInt() - mDefaultHorizontalGap
if (column >= maxColumns || x + widthPx > mAllowedWidth) {
maxColumn = maxOf(maxColumn, column)
rowTotalWeight[row] = totalKeyWidth
x = mDefaultHorizontalGap / 2
y += mDefaultVerticalGap + rowHeight
totalKeyWidth = 0f
column = 0
row++
rawSumHeight += rowHeight
rawHeight.add(rowHeight)
}
if (column == 0) {
rowHeight = if (key.height > 0) key.height.toInt() else keyHeight
}
totalKeyWidth += keyWidthWeight
if (key.click.isEmpty()) { // 無按鍵事件
x += widthPx + mDefaultHorizontalGap
continue // 縮進
}
column++
val rightGap = abs(mAllowedWidth - x - widthPx - mDefaultHorizontalGap / 2)
x += (
if (rightGap <= mAllowedWidth / MAX_TOTAL_WEIGHT) {
mAllowedWidth - x -
mDefaultHorizontalGap / 2
} else {
widthPx
}
) + mDefaultHorizontalGap
}
rowTotalWeight[row] = totalKeyWidth
rawSumHeight += rowHeight
rawHeight.add(rowHeight)
val scaledVerticalGap = calculateScaledVerticalGap(rawSumHeight, rawHeight)
return KeyboardSize(
rowTotalWeight,
calculateOneWeightWidthPx(),
splitSpaceRatio,
calculateAdjustedHeight(rawSumHeight, rawHeight, scaledVerticalGap),
scaledVerticalGap.toInt(),
)
}
private fun calculateOneWeightWidthPx(): Float = (mAllowedWidth / (MAX_TOTAL_WEIGHT * (1 + splitSpaceRatio)))
private fun calculateScaledVerticalGap(
rawSumHeight: Int,
rawHeight: List<Int>,
): Double {
val scale: Double =
keyboardHeight.toDouble() / (rawSumHeight + mDefaultVerticalGap * (rawHeight.size + 1))
return ceil((mDefaultVerticalGap * scale))
}
private fun calculateAdjustedHeight(
rawSumHeight: Int,
rawHeight: List<Int>,
scaledVerticalGap: Double,
): List<Int> {
var remainHeight = keyboardHeight - scaledVerticalGap * (rawHeight.size + 1)
val scale = remainHeight / rawSumHeight
var finalAutoHeightIndex = -100
if (autoHeightIndex < 0) {
finalAutoHeightIndex = rawHeight.size + autoHeightIndex
if (finalAutoHeightIndex < 0) finalAutoHeightIndex = 0
} else if (autoHeightIndex >= rawHeight.size) {
finalAutoHeightIndex = rawHeight.size - 1
}
val newHeight = rawHeight.toMutableList()
for (i in rawHeight.indices) {
if (i != finalAutoHeightIndex) {
val h: Int = (rawHeight[i] * scale).toInt()
newHeight[i] = h
remainHeight -= h
}
}
if (remainHeight < 1) {
if (rawHeight[finalAutoHeightIndex] > 0) remainHeight = 1.0
}
newHeight[finalAutoHeightIndex] = remainHeight.toInt()
return newHeight
}
companion object {
private const val MAX_TOTAL_WEIGHT = 100
}
}

View File

@ -7,626 +7,89 @@ package com.osfans.trime.ime.keyboard
import android.annotation.SuppressLint
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.graphics.drawable.GradientDrawable
import android.view.KeyEvent
import androidx.core.graphics.createBitmap
import androidx.core.graphics.withClip
import com.mikepenz.iconics.IconicsDrawable
import com.mikepenz.iconics.utils.sizeDp
import com.osfans.trime.daemon.RimeDaemon
import com.osfans.trime.data.prefs.AppPrefs
import com.osfans.trime.data.theme.ColorManager
import com.osfans.trime.data.theme.FontManager
import android.widget.FrameLayout
import androidx.core.view.children
import com.osfans.trime.data.theme.Theme
import com.osfans.trime.ime.core.TrimeInputMethodService
import com.osfans.trime.ime.popup.PopupAction
import com.osfans.trime.ime.popup.PopupActionListener
import com.osfans.trime.ime.popup.PopupDelegate
import com.osfans.trime.util.sp
import splitties.dimensions.dp
import timber.log.Timber
import kotlin.math.min
import kotlin.math.pow
/** 顯示[鍵盤][Keyboard]及[按鍵][Key] */
// TODO: move layout calculation responsibilities from Keyboard to KeyboardView using ConstraintLayout
@SuppressLint("ViewConstructor")
class KeyboardView(
context: Context,
private val theme: Theme,
private val keyboard: Keyboard,
private val popup: PopupDelegate,
private val service: TrimeInputMethodService,
) : KeyboardGestureFrame(context) {
val popup: PopupDelegate,
val service: TrimeInputMethodService,
private val keyboardActionListener: KeyboardActionListener,
) : FrameLayout(context) {
private val rime get() = RimeDaemon.getFirstSessionOrNull()!!
private val keyTextSize = theme.generalStyle.keyTextSize
private val labelTextSize =
theme.generalStyle.keyLongTextSize
.takeIf { it > 0 } ?: keyTextSize
private val keys get() = keyboard.keys
private val symbolTextSize = theme.generalStyle.symbolTextSize
private val mShadowRadius = theme.generalStyle.shadowRadius
private val mShadowColor = ColorManager.getColor("shadow_color")
private val keyBorderColor = ColorManager.getColor("key_border_color")
internal var labelEnter: String = theme.generalStyle.enterLabel.default
internal val keyTextSize = theme.generalStyle.keyTextSize
internal val labelTextSize = theme.generalStyle.keyLongTextSize.takeIf { it > 0 } ?: keyTextSize
// Working variable
private val originCoords = intArrayOf(0, 0)
private val mKeys get() = keyboard.keys
init {
setWillNotDraw(false)
buildKeyViews()
}
var keyboardActionListener: KeyboardActionListener? = null
override val verticalCorrection: Int
get() = theme.generalStyle.verticalCorrection
private var mProximityThreshold = 0
private fun buildKeyViews() {
removeAllViews()
/**
* Enables or disables the key feedback popup. This is a popup that shows a magnified version of
* the depressed key. By default the preview is enabled.
*/
private val showPreview by AppPrefs.defaultInstance().keyboard.popupOnKeyPress
private val vibrateOnKeyRelease by AppPrefs.defaultInstance().keyboard.vibrateOnKeyRelease
private val vibrateOnKeyRepeat by AppPrefs.defaultInstance().keyboard.vibrateOnKeyRepeat
private val deletedTextBuffer = ArrayDeque<String>()
/**
* 是否允許距離校正 When enabled, calls to [KeyboardActionListener.onKey] will include key codes for
* adjacent keys. When disabled, only the primary key code will be reported.
*/
private val enableProximityCorrection = theme.generalStyle.proximityCorrection
/** True if all keys should be drawn */
private var invalidateAllKeys = false
/** The keys that should be drawn */
private val invalidatedKeys = hashSetOf<Key>()
/** The dirty region in the keyboard bitmap */
private val dirtyRect = Rect()
/** The keyboard bitmap for faster updates */
private var drawingBuffer: Bitmap? = null
/** The canvas for the above mutable keyboard bitmap */
private val drawingCanvas = Canvas()
private val basePaint =
Paint().apply {
isAntiAlias = true
textAlign = Paint.Align.CENTER
keys.forEachIndexed { index, key ->
val keyView = createKeyView(index, key)
addView(keyView)
}
}
private var labelEnter: String = theme.generalStyle.enterLabel.default
private fun createKeyView(index: Int, key: Key): KeyView = KeyView(context, key = key, keyboard = keyboard, keyboardView = this, keyboardActionListener = keyboardActionListener).apply {
id = index
layoutParams = LayoutParams(key.width, key.height)
translationX = key.x.toFloat()
translationY = key.y.toFloat()
setPadding(
keyboard.horizontalGap / 2,
keyboard.verticalGap / 2,
keyboard.horizontalGap / 2,
keyboard.verticalGap / 2,
)
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val fullWidth = keyboard.minWidth + paddingLeft + paddingRight
val fullHeight = keyboard.height + paddingTop + paddingBottom
val measuredWidth = minOf(
MeasureSpec.getSize(widthMeasureSpec),
fullWidth,
)
measureChildren(widthMeasureSpec, heightMeasureSpec)
setMeasuredDimension(measuredWidth, fullHeight)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
}
fun invalidateAllKeys() {
children.forEach { it.invalidate() }
}
fun onEnterKeyLabelUpdate(label: String) {
labelEnter = label
}
private val popupActionListener: PopupActionListener by lazy { popup.listener }
init {
computeProximityThreshold(keyboard)
invalidateAllKeys()
onKeyActionListener = { keyIndex, behavior ->
if (behavior == KeyBehavior.LONG_CLICK && hasPopupKeys(keyIndex)) {
val popupKeys = mKeys.get(keyIndex).popup
val bounds = getKeyBounds(keyIndex)
popupActionListener.onPopupAction(
PopupAction.ShowKeyboardAction(keyIndex, popupKeys, bounds),
)
false
} else {
detectAndSendKey(keyIndex, behavior)
true
}
}
onKeySlideListener = { keyIndex, deltaX, x, y ->
val key = mKeys.getOrNull(keyIndex)
val ic = service.currentInputConnection
when {
key?.click?.isSlideCursor == true -> {
when {
deltaX > 0 -> keyboardActionListener?.onAction(KeyAction("Right"))
deltaX < 0 -> keyboardActionListener?.onAction(KeyAction("Left"))
}
true
}
key?.click?.isSlideDelete == true -> {
when {
deltaX < 0 -> {
val beforeText = ic.getTextBeforeCursor(1, 0) ?: ""
if (beforeText.isNotEmpty()) {
deletedTextBuffer.addFirst(beforeText.toString())
ic?.deleteSurroundingText(1, 0)
}
true
}
deltaX > 0 -> {
if (deletedTextBuffer.isNotEmpty()) {
ic?.commitText(deletedTextBuffer.removeFirst(), 1)
}
true
}
}
}
}
false
}
onKeyStateListener = { keyIndex, behavior, isVisible, isPressed, isRepeating ->
val key = mKeys.getOrNull(keyIndex)
if (isPressed || (isRepeating && vibrateOnKeyRepeat)) keyboardActionListener?.onPress(key?.getCode(behavior) ?: 0, !isRepeating)
if (!isRepeating) {
if (isVisible) {
key?.onPressed()
invalidateKey(key)
if (showPreview) showPopup(keyIndex, behavior)
} else {
key?.onReleased()
invalidateKey(key)
hidePopup(keyIndex)
if (behavior == KeyBehavior.LONG_CLICK || vibrateOnKeyRelease) keyboardActionListener?.onPress(key?.getCode(behavior) ?: 0, false)
}
}
}
onKeyReleaseListener = { keyIndex ->
deletedTextBuffer.clear()
}
onPopupSelected = { keyIndex ->
val triggerAction = PopupAction.TriggerAction(keyIndex)
popupActionListener.onPopupAction(triggerAction)
triggerAction.outAction?.let { action ->
keyboardActionListener?.onAction(KeyAction(action))
}
}
onPopupChangeFocus = { keyIndex, x, y ->
val key = mKeys.getOrNull(keyIndex)
if (key != null) {
val relativeX = x - key.x.toFloat()
val relativeY = y - key.y.toFloat()
val changeFocusAction = PopupAction.ChangeFocusAction(keyIndex, relativeX, relativeY)
popupActionListener.onPopupAction(changeFocusAction)
}
}
}
override fun getKeyIndex(x: Float, y: Float): Int = getKeyIndices(x.toInt(), y.toInt())
override fun isKeyRepeatable(keyIndex: Int): Boolean = mKeys.getOrNull(keyIndex)?.click?.isRepeatable ?: false
override fun isKeySlideCursor(keyIndex: Int): Boolean = mKeys.getOrNull(keyIndex)?.click?.isSlideCursor ?: false
override fun isKeySlideDelete(keyIndex: Int): Boolean = mKeys.getOrNull(keyIndex)?.click?.isSlideDelete ?: false
override fun hasAction(keyIndex: Int, behavior: KeyBehavior): Boolean = mKeys.getOrNull(keyIndex)?.hasAction(behavior) ?: false
override fun hasPopupKeys(keyIndex: Int): Boolean = mKeys.getOrNull(keyIndex)?.popup?.isNotEmpty() == true
private fun showPopup(keyIndex: Int, behavior: KeyBehavior = KeyBehavior.CLICK) {
val key = mKeys.getOrNull(keyIndex) ?: return
val bounds = getKeyBounds(keyIndex)
val previewText = key.getPreviewText(behavior)
// 当preview显式为空时,不显示气泡提示
if (previewText.isEmpty()) return
val context = if (previewText.isIconFont) previewText else String(Character.toChars(previewText.codePointAt(0)))
popupActionListener.onPopupAction(
PopupAction.PreviewAction(keyIndex, context, bounds),
)
}
private fun hidePopup(keyIndex: Int) {
popupActionListener.onPopupAction(PopupAction.DismissAction(keyIndex))
}
private fun getKeyBounds(keyIndex: Int): Rect {
val key = mKeys.getOrNull(keyIndex) ?: return Rect()
val location = intArrayOf(0, 0)
getLocationInWindow(location)
return Rect(
key.x + location[0],
key.y + location[1],
key.x + key.width + location[0],
key.y + key.height + location[1],
)
}
private fun setModifier(
action: KeyAction,
behavior: KeyBehavior,
): Boolean = setModifier(action.isShiftLock xor (behavior == KeyBehavior.LONG_CLICK), action.modifierKeyOnMask)
private fun setModifier(
on: Boolean,
code: Int,
): Boolean = keyboard.clickModifierKey(on, code).also { if (it) invalidateAllKeys() }
// 重置全部修饰键的状态(如果有锁定则不重置)
private fun refreshModifier() {
if (keyboard.refreshModifier()) {
invalidateAllKeys()
}
}
/**
* 返回鍵盤是否爲大寫狀態
*/
val isCapsOn: Boolean
get() = keyboard.mShiftKey?.isOn ?: false
public override fun onMeasure(
widthMeasureSpec: Int,
heightMeasureSpec: Int,
) {
// Round up a little
val fullWidth = keyboard.minWidth + paddingLeft + paddingRight
val fullHeight = keyboard.height + paddingTop + paddingBottom
val measuredWidth =
if (MeasureSpec.getSize(widthMeasureSpec) < fullWidth + 10) {
MeasureSpec.getSize(widthMeasureSpec)
} else {
fullWidth
}
setMeasuredDimension(measuredWidth, fullHeight)
}
/**
* 計算水平和豎直方向的相鄰按鍵中心的平均距離的平方,這樣不需要做開方運算
*
* @param keyboard 鍵盤
*/
private fun computeProximityThreshold(keyboard: Keyboard?) {
if (keyboard == null && mKeys.isEmpty()) return
val dimensionSum = mKeys.sumOf { key -> min(key.width, key.height) + key.gap }
if (dimensionSum < 0) return
mProximityThreshold = (dimensionSum * Keyboard.SEARCH_DISTANCE / mKeys.size).pow(2).toInt() // Square it
}
public override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (canvas.isHardwareAccelerated) {
onDrawKeyboard(canvas)
return
}
val bufferNeedsUpdates = invalidateAllKeys || invalidatedKeys.isNotEmpty()
if (bufferNeedsUpdates || drawingBuffer == null) {
if (maybeAllocateDrawingBuffer()) {
invalidateAllKeys = true
drawingCanvas.setBitmap(drawingBuffer)
}
onDrawKeyboard(drawingCanvas)
}
canvas.drawBitmap(drawingBuffer!!, 0.0f, 0.0f, null)
}
private fun maybeAllocateDrawingBuffer(): Boolean {
if (width == 0 || height == 0) {
return false
}
if (drawingBuffer != null && drawingBuffer!!.width == width && drawingBuffer!!.height == height) {
return false
}
freeDrawingBuffer()
drawingBuffer = createBitmap(width, height)
return true
}
private fun freeDrawingBuffer() {
drawingCanvas.setBitmap(null)
drawingCanvas.setMatrix(null)
if (drawingBuffer != null) {
drawingBuffer!!.recycle()
drawingBuffer = null
}
}
private fun onDrawKeyboard(canvas: Canvas) {
val paint = basePaint
val drawAllKeys = invalidateAllKeys || invalidatedKeys.isEmpty()
val isHardwareAccelerated = canvas.isHardwareAccelerated
if (drawAllKeys || isHardwareAccelerated) {
if (!isHardwareAccelerated && background != null) {
canvas.drawColor(Color.BLACK, PorterDuff.Mode.CLEAR)
background.draw(canvas)
}
for (key in mKeys) {
onDrawKey(key, canvas, paint)
}
} else {
for (key in invalidatedKeys) {
if (!mKeys.contains(key)) continue
if (background != null) {
val x = key.x + paddingLeft
val y = key.y + paddingTop
dirtyRect.set(x, y, x + key.width, y + key.height)
canvas.withClip(dirtyRect) {
drawColor(Color.BLACK, PorterDuff.Mode.CLEAR)
background.draw(this)
}
}
onDrawKey(key, canvas, paint)
}
}
invalidatedKeys.clear()
invalidateAllKeys = false
}
private fun onDrawKey(
key: Key,
canvas: Canvas,
paint: Paint,
) {
val keyDrawX = (key.x + paddingLeft).toFloat()
val keyDrawY = (key.y + paddingTop).toFloat()
canvas.translate(keyDrawX, keyDrawY)
drawKeyBackground(key, canvas)
drawKeyLabel(key, canvas, paint)
drawKeySymbol(key, canvas, paint)
canvas.translate(-keyDrawX, -keyDrawY)
}
private fun drawKeyBackground(
key: Key,
canvas: Canvas,
) {
val keyBackground = key.getBackgroundDrawable()
if (keyBackground != null) {
if (keyBackground is GradientDrawable) {
(key.roundCorner ?: keyboard.roundCorner).takeIf { it > 0f }?.let {
keyBackground.cornerRadius = dp(it)
}
(key.keyBorder ?: keyboard.keyBorder).takeIf { it > 0 }?.let {
keyBackground.setStroke(dp(it), keyBorderColor)
}
}
onDrawKeyBackground(key, canvas, keyBackground)
}
}
private fun drawKeyLabel(
key: Key,
canvas: Canvas,
paint: Paint,
) {
val keyLabel = key.getLabel().let {
if (it == "enter_labels") labelEnter else it
}
if (keyLabel.isEmpty()) return
if (keyLabel.isIconFont) {
val size = if (key.keyTextSize > 0) sp(key.keyTextSize) else sp(if (keyLabel.length > 1) labelTextSize else keyTextSize)
drawIconLabel(key, keyLabel, canvas, size, centered = true)
} else {
drawTextLabel(key, keyLabel, canvas, paint)
}
}
private fun drawIconLabel(
key: Key,
label: String,
canvas: Canvas,
size: Float,
color: Int = key.getTextColor(),
offsetX: Float = key.keyTextOffsetX,
offsetY: Float = key.keyTextOffsetY,
centered: Boolean = true,
) {
val iconSize = size / resources.displayMetrics.density
val icon = IconicsDrawable(context, label.toIconName()).apply {
sizeDp = iconSize.toInt()
colorFilter = PorterDuffColorFilter(color, PorterDuff.Mode.SRC_IN)
}
val centerX = key.width / 2f - icon.intrinsicWidth / 2f
val centerY = if (centered) key.height / 2f else key.height.toFloat()
val x = (centerX + sp(offsetX)).toInt()
val y = (centerY - icon.intrinsicHeight / 2f + sp(offsetY)).toInt()
icon.setBounds(x, y, x + icon.intrinsicWidth, y + icon.intrinsicHeight)
icon.draw(canvas)
}
private fun drawTextLabel(
key: Key,
label: String,
canvas: Canvas,
paint: Paint,
) {
paint.typeface = FontManager.getTypeface("key_font")
paint.textSize = if (key.keyTextSize > 0) sp(key.keyTextSize) else sp(if (label.length > 1) labelTextSize else keyTextSize)
paint.color = key.getTextColor()
val centerX = key.width * 0.5f
val centerY = key.height * 0.5f
val labelX = centerX + sp(key.keyTextOffsetX)
val labelBaseline = centerY + sp(key.keyTextOffsetY)
if (mShadowRadius > 0f) {
paint.setShadowLayer(mShadowRadius, 0f, 0f, mShadowColor)
}
val adjustmentY = (paint.textSize - paint.descent()) / 2f
canvas.drawText(label, labelX, labelBaseline + adjustmentY, paint)
paint.clearShadowLayer()
}
private fun drawKeySymbol(
key: Key,
canvas: Canvas,
paint: Paint,
) {
val showSymbol = rime.run { !getRuntimeOption("_hide_key_symbol") }
val showHint = rime.run { !getRuntimeOption("_hide_key_hint") }
if (!showSymbol && !showHint) return
val centerX = key.width / 2f
val symbolSize = listOf(key.symbolTextSize, symbolTextSize).firstOrNull { it > 0f }?.let { sp(it) } ?: sp(symbolTextSize)
fun drawSymbolText(label: String, offsetX: Float, offsetY: Float, isTop: Boolean) {
paint.apply {
typeface = FontManager.getTypeface("symbol_font")
textSize = symbolSize
color = key.getSymbolColor()
}
val fontMetrics = paint.fontMetrics
val y = if (isTop) -fontMetrics.top + sp(offsetY) else key.height - fontMetrics.bottom + sp(offsetY)
canvas.drawText(label, centerX + sp(offsetX), y, paint)
}
if (showSymbol && key.symbolLabel.isNotEmpty()) {
val label = key.symbolLabel
if (label.isIconFont) {
drawIconLabel(key, label, canvas, symbolSize, key.getSymbolColor(), key.keySymbolOffsetX, key.keySymbolOffsetY, centered = false)
} else {
drawSymbolText(label, key.keySymbolOffsetX, key.keySymbolOffsetY, isTop = true)
}
}
if (showHint && key.hint.isNotEmpty()) {
val label = key.hint
if (label.isIconFont) {
drawIconLabel(key, label, canvas, symbolSize, key.getSymbolColor(), key.keyHintOffsetX, key.keyHintOffsetY, centered = false)
} else {
drawSymbolText(label, key.keyHintOffsetX, key.keyHintOffsetY, isTop = false)
}
}
}
private fun onDrawKeyBackground(
key: Key,
canvas: Canvas,
background: Drawable,
) {
val padding = Rect().also { background.getPadding(it) }
val bgWidth = key.width + padding.left + padding.right
val bgHeight = key.height + padding.top + padding.bottom
val bgX = -padding.left.toFloat()
val bgY = -padding.top.toFloat()
background.setBounds(0, 0, bgWidth, bgHeight)
canvas.translate(bgX, bgY)
background.draw(canvas)
canvas.translate(-bgX, -bgY)
}
private fun getKeyIndices(
x: Int,
y: Int,
): Int {
var primaryIndex = -1
var closestKey = -1
var closestKeyDist = mProximityThreshold + 1
IntArray(MAX_NEARBY_KEYS).fill(Int.MAX_VALUE)
val nearestKeyIndices = keyboard.getNearestKeys(x, y)
for (nearestKeyIndex in nearestKeyIndices!!) {
val key = mKeys[nearestKeyIndex]
val isInside = key.isInside(x, y)
if (isInside) {
primaryIndex = nearestKeyIndex
}
val dist = key.squaredDistanceFrom(x, y)
if (enableProximityCorrection && dist < mProximityThreshold || isInside) {
// Find insertion point
if (dist < closestKeyDist) {
closestKeyDist = dist
closestKey = nearestKeyIndex
}
}
}
if (primaryIndex == -1) {
primaryIndex = closestKey
}
return primaryIndex
}
private fun releaseKey(code: Int) {
Timber.d("releaseKey: keyCode=$code")
keyboardActionListener?.onRelease(code)
}
private val hookShiftArrow by AppPrefs.defaultInstance().keyboard.hookShiftArrow
fun isHookShiftArrow(keyCode: Int): Boolean {
if (!hookShiftArrow) return false
return when (keyCode) {
in KeyEvent.KEYCODE_DPAD_UP..KeyEvent.KEYCODE_DPAD_RIGHT -> true
KeyEvent.KEYCODE_MOVE_HOME, KeyEvent.KEYCODE_MOVE_END -> true
else -> false
}
}
private fun detectAndSendKey(
index: Int,
behavior: KeyBehavior = KeyBehavior.CLICK,
) {
val key = mKeys.getOrNull(index) ?: return
val action = key.getAction(behavior) ?: return
Timber.d("detectAndSendKey: label=${key.getLabel()}, code=${action.code}, type=$behavior, modifier=${action.isModifierKey}")
if (action.isModifierKey) {
setModifier(action, behavior)
return
}
keyboardActionListener?.onAction(action)
releaseKey(action.code)
if (!isHookShiftArrow(action.code)) refreshModifier()
return
}
/**
* Requests a redraw of the entire keyboard. Calling [invalidate] is not sufficient because
* the keyboard renders the keys to an off-screen buffer and an invalidate() only draws the cached
* buffer.
*
* @see invalidateKey
*/
fun invalidateAllKeys() {
Timber.d("invalidateAllKeys")
invalidatedKeys.clear()
invalidateAllKeys = true
invalidate()
}
/**
* Invalidates a key so that it will be redrawn on the next repaint. Use this method if only one
* key is changing it's content. Any changes that affect the position or size of the key may not
* be honored.
*
* @param key the key in the attached [Keyboard].
* @see invalidateAllKeys
*/
private fun invalidateKey(key: Key?) {
if (invalidateAllKeys || key == null) return
invalidatedKeys.add(key)
invalidate()
}
get() = keyboard.mShiftKey?.isOn == true
fun onDetach() {
popup.dismissAll()
freeDrawingBuffer()
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
freeDrawingBuffer()
}
companion object {
private const val MAX_NEARBY_KEYS = 12
}
}

View File

@ -100,7 +100,6 @@ class KeyboardWindow :
currentKeyboardView?.also {
it.onDetach()
keyboardView.removeView(it)
it.keyboardActionListener = null
}
currentKeyboard?.lastAsciiMode = rime.run { statusCached }.isAsciiMode
}
@ -120,7 +119,7 @@ class KeyboardWindow :
val config = selectKeyboardConfig(target)
val keyboard = currentKeyboard ?: Keyboard(theme, config)
val view = currentKeyboardView ?: KeyboardView(context, theme, keyboard, popup, service)
val view = currentKeyboardView ?: KeyboardView(context, theme, keyboard, popup, service, keyboardActionListener)
if (currentKeyboard == null) {
cachedKeyboards[target] = keyboard to view
@ -147,7 +146,6 @@ class KeyboardWindow :
}
view.let {
it.keyboardActionListener = keyboardActionListener
keyboardView.apply {
(it.parent as? android.view.ViewGroup)?.removeView(it)
add(it, lParams(matchParent, matchParent))
@ -322,13 +320,9 @@ class KeyboardWindow :
}
override fun onAttached() {
currentKeyboardView?.keyboardActionListener = keyboardActionListener
}
override fun onDetached() {
currentKeyboardView?.let {
it.onDetach()
it.keyboardActionListener = null
}
currentKeyboardView?.onDetach()
}
}