Show floating CandidatesView for hardware keyboard

This commit is contained in:
Rocka 2024-08-18 22:08:39 +08:00
parent ab747e35ae
commit d7d6343267
No known key found for this signature in database
GPG Key ID: 28031158FFDD6853
7 changed files with 288 additions and 26 deletions

View File

@ -13,6 +13,7 @@
<w>fcitx</w>
<w>fmtlib</w>
<w>icuuid</w>
<w>inputmethodservice</w>
<w>iostreams</w>
<w>iter</w>
<w>jbytes</w>
@ -21,6 +22,7 @@
<w>jstring</w>
<w>jyutping</w>
<w>kawaii</w>
<w>keypress</w>
<w>lgpl</w>
<w>libevent</w>
<w>libime</w>
@ -37,6 +39,7 @@
<w>shijienihao</w>
<w>shuangpin</w>
<w>sinhala</w>
<w>snackbar</w>
<w>spdx</w>
<w>stringutils</w>
<w>unfocus</w>

View File

@ -13,7 +13,7 @@ sealed class FcitxEvent<T>(open val data: T) {
override val eventType = EventType.Candidate
data class Data(val total: Int, val candidates: Array<String>) {
data class Data(val total: Int = -1, val candidates: Array<String> = emptyArray()) {
override fun toString(): String =
"total=$total, candidates=[${candidates.joinToString(limit = 5)}]"

View File

@ -0,0 +1,160 @@
/*
* SPDX-License-Identifier: LGPL-2.1-or-later
* SPDX-FileCopyrightText: Copyright 2024 Fcitx5 for Android Contributors
*/
package org.fcitx.fcitx5.android.input
import android.annotation.SuppressLint
import android.graphics.Color
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.annotation.Size
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import org.fcitx.fcitx5.android.R
import org.fcitx.fcitx5.android.core.FcitxEvent
import org.fcitx.fcitx5.android.daemon.FcitxConnection
import org.fcitx.fcitx5.android.data.theme.Theme
import org.fcitx.fcitx5.android.input.candidates.CandidateItemUi
import org.fcitx.fcitx5.android.input.preedit.PreeditUi
import splitties.dimensions.dp
import splitties.views.backgroundColor
import splitties.views.dsl.constraintlayout.below
import splitties.views.dsl.constraintlayout.bottomOfParent
import splitties.views.dsl.constraintlayout.lParams
import splitties.views.dsl.constraintlayout.startOfParent
import splitties.views.dsl.constraintlayout.topOfParent
import splitties.views.dsl.core.add
import splitties.views.dsl.core.endMargin
import splitties.views.dsl.core.horizontalLayout
import splitties.views.dsl.core.lParams
import splitties.views.dsl.core.withTheme
import splitties.views.dsl.core.wrapContent
import splitties.views.horizontalPadding
import splitties.views.verticalPadding
import kotlin.math.min
@SuppressLint("ViewConstructor")
class CandidatesView(
val service: FcitxInputMethodService,
val fcitx: FcitxConnection,
val theme: Theme
) : ConstraintLayout(service) {
var handleEvents = false
set(value) {
field = value
visibility = View.GONE
if (field) {
setupFcitxEventHandler()
} else {
eventHandlerJob?.cancel()
eventHandlerJob = null
}
}
private val ctx = context.withTheme(R.style.Theme_InputViewTheme)
private var eventHandlerJob: Job? = null
private var inputPanel = FcitxEvent.InputPanelEvent.Data()
private var candidates = FcitxEvent.CandidateListEvent.Data()
private val preeditUi = object : PreeditUi(ctx, theme) {
override val bkgColor = Color.TRANSPARENT
}
// TODO ui orientation from fcitx
private val candidatesUi = horizontalLayout {
horizontalPadding = dp(4)
}
private fun handleFcitxEvent(it: FcitxEvent<*>) {
when (it) {
// TODO make a new candidate page event
is FcitxEvent.CandidateListEvent -> {
candidates = it.data
updateUI()
}
is FcitxEvent.InputPanelEvent -> {
inputPanel = it.data
updateUI()
}
else -> {}
}
}
private fun evaluateVisibility(): Boolean {
return inputPanel.preedit.isNotEmpty() ||
candidates.total > 0 ||
inputPanel.auxUp.isNotEmpty() ||
inputPanel.auxDown.isNotEmpty()
}
private fun updateUI() {
if (evaluateVisibility()) {
visibility = View.VISIBLE
preeditUi.update(inputPanel)
preeditUi.root.isVisible = preeditUi.visible
updateCandidates()
} else {
visibility = View.GONE
}
}
private fun updateCandidates() {
candidatesUi.apply {
removeAllViews()
val limit = min(candidates.candidates.size, 5)
for (i in 0..<limit) {
val item = CandidateItemUi(ctx, theme).apply {
text.textSize = 16f
text.text = "${i + 1}. ${candidates.candidates[i]}"
}
addView(item.root, lParams { endMargin = if (i == limit - 1) 0 else dp(4) })
}
}
}
fun updatePosition(@Size(2) pos: FloatArray) {
updateLayoutParams<FrameLayout.LayoutParams> {
leftMargin = pos[0].toInt()
topMargin = pos[1].toInt()
}
}
private fun setupFcitxEventHandler() {
eventHandlerJob = service.lifecycleScope.launch {
fcitx.runImmediately { eventFlow }.collect {
handleFcitxEvent(it)
}
}
}
init {
verticalPadding = dp(8)
backgroundColor = theme.backgroundColor
add(preeditUi.root, lParams(wrapContent, wrapContent) {
topOfParent()
startOfParent()
})
add(candidatesUi, lParams(wrapContent, wrapContent) {
below(preeditUi.root)
startOfParent()
bottomOfParent()
})
layoutParams = ViewGroup.LayoutParams(wrapContent, wrapContent)
}
override fun onDetachedFromWindow() {
handleEvents = false
super.onDetachedFromWindow()
}
}

View File

@ -63,6 +63,7 @@ import org.fcitx.fcitx5.android.input.cursor.CursorTracker
import org.fcitx.fcitx5.android.utils.InputMethodUtil
import org.fcitx.fcitx5.android.utils.alpha
import org.fcitx.fcitx5.android.utils.inputMethodManager
import org.fcitx.fcitx5.android.utils.monitorCursorAnchor
import org.fcitx.fcitx5.android.utils.withBatchEdit
import splitties.bitflags.hasFlag
import splitties.dimensions.dp
@ -83,6 +84,7 @@ class FcitxInputMethodService : LifecycleInputMethodService() {
private lateinit var pkgNameCache: PackageNameCache
private var inputView: InputView? = null
private var candidatesView: CandidatesView? = null
private var capabilityFlags = CapabilityFlags.DefaultFlags
@ -414,6 +416,7 @@ class FcitxInputMethodService : LifecycleInputMethodService() {
}
override fun onCreateInputView(): View {
candidatesView = CandidatesView(this, fcitx, ThemeManager.activeTheme)
// onCreateInputView will be called once, when the input area is first displayed,
// during each onConfigurationChanged period.
// That is, onCreateInputView would be called again, after system dark mode changes,
@ -429,12 +432,21 @@ class FcitxInputMethodService : LifecycleInputMethodService() {
} catch (e: Exception) {
Timber.w("Device does not support android.R.attr.colorAccent which it should have.")
}
window.window!!.decorView
.findViewById<FrameLayout>(android.R.id.inputArea)
val decor = window.window?.decorView ?: return
// input method layout has not changed in 11 years:
// https://android.googlesource.com/platform/frameworks/base/+/ae3349e1c34f7aceddc526cd11d9ac44951e97b6/core/res/res/layout/input_method.xml
// put CandidatesView directly under parentPanel
decor.findViewById<FrameLayout>(android.R.id.content).addView(candidatesView!!)
super.setInputView(view)
// expand inputArea to fullscreen
decor.findViewById<FrameLayout>(android.R.id.inputArea)
.updateLayoutParams<ViewGroup.LayoutParams> {
height = ViewGroup.LayoutParams.MATCH_PARENT
}
super.setInputView(view)
/**
* expand InputView to fullscreen, since [android.inputmethodservice.InputMethodService.setInputView]
* would set InputView's height to [ViewGroup.LayoutParams.WRAP_CONTENT]
*/
view.updateLayoutParams<ViewGroup.LayoutParams> {
height = ViewGroup.LayoutParams.MATCH_PARENT
}
@ -444,18 +456,28 @@ class FcitxInputMethodService : LifecycleInputMethodService() {
win.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)
}
private var inputViewLocation = intArrayOf(0, 0)
override fun onComputeInsets(outInsets: Insets) {
val (_, y) = intArrayOf(0, 0).also { inputView?.keyboardView?.getLocationInWindow(it) }
Timber.d("onComputeInsets")
if (candidatesView?.handleEvents == true) {
val h = window.window!!.decorView.height
outInsets.apply {
contentTopInsets = h
visibleTopInsets = h
touchableInsets = Insets.TOUCHABLE_INSETS_VISIBLE
}
return
}
inputView?.keyboardView?.getLocationInWindow(inputViewLocation)
outInsets.apply {
contentTopInsets = y
touchableInsets = Insets.TOUCHABLE_INSETS_CONTENT
touchableRegion.setEmpty()
visibleTopInsets = y
contentTopInsets = inputViewLocation[1]
visibleTopInsets = inputViewLocation[1]
touchableInsets = Insets.TOUCHABLE_INSETS_VISIBLE
}
}
// TODO: candidate view for physical keyboard input
// always show InputView since we do not support physical keyboard input without it yet
// always show InputView since we delegate CandidatesView's visibility to it
@SuppressLint("MissingSuperCall")
override fun onEvaluateInputViewShown() = true
@ -576,12 +598,26 @@ class FcitxInputMethodService : LifecycleInputMethodService() {
override fun onStartInputView(info: EditorInfo, restarting: Boolean) {
Timber.d("onStartInputView: restarting=$restarting")
// monitor cursor anchor only when needed, ie
// InputView just becomes visible && using floating CandidatesView
if (!restarting && !super.onEvaluateInputViewShown()) {
currentInputConnection?.monitorCursorAnchor()
}
postFcitxJob {
focus(true)
}
// because onStartInputView will always be called after onStartInput,
// editorInfo and capFlags should be up-to-date
inputView?.startInput(info, capabilityFlags, restarting)
if (super.onEvaluateInputViewShown()) {
candidatesView?.handleEvents = false
inputView?.handleEvents = true
inputView?.visibility = View.VISIBLE
// because onStartInputView will always be called after onStartInput,
// editorInfo and capFlags should be up-to-date
inputView?.startInput(info, capabilityFlags, restarting)
} else {
candidatesView?.handleEvents = true
inputView?.handleEvents = false
inputView?.visibility = View.GONE
}
}
override fun onUpdateSelection(
@ -599,8 +635,30 @@ class FcitxInputMethodService : LifecycleInputMethodService() {
inputView?.updateSelection(newSelStart, newSelEnd)
}
private val decorLocation = floatArrayOf(0f, 0f)
private val decorLocationInt = intArrayOf(0, 0)
private var decorLocationUpdated = false
private fun updateDecorLocation() {
window.window!!.decorView.getLocationOnScreen(decorLocationInt)
decorLocation[0] = decorLocationInt[0].toFloat()
decorLocation[1] = decorLocationInt[1].toFloat()
decorLocationUpdated = true
}
private val anchorPosition = floatArrayOf(0f, 0f)
override fun onUpdateCursorAnchorInfo(info: CursorAnchorInfo) {
// CursorAnchorInfo focus more on screen coordinates rather than selection
anchorPosition[0] = info.insertionMarkerHorizontal
anchorPosition[1] = info.insertionMarkerBottom
info.matrix.mapPoints(anchorPosition)
// avoid calling `decorView.getLocationOnScreen` repeatedly
if (!decorLocationUpdated) {
updateDecorLocation()
}
anchorPosition[0] -= decorLocation[0]
anchorPosition[1] -= decorLocation[1]
candidatesView?.updatePosition(anchorPosition)
}
private fun handleCursorUpdate(newSelStart: Int, newSelEnd: Int, updateIndex: Int) {
@ -794,7 +852,11 @@ class FcitxInputMethodService : LifecycleInputMethodService() {
override fun onFinishInputView(finishingInput: Boolean) {
Timber.d("onFinishInputView: finishingInput=$finishingInput")
currentInputConnection?.finishComposingText()
currentInputConnection?.run {
finishComposingText()
monitorCursorAnchor(false)
decorLocationUpdated = false
}
resetComposingState()
postFcitxJob {
focus(false)
@ -830,6 +892,7 @@ class FcitxInputMethodService : LifecycleInputMethodService() {
FcitxDaemon.disconnect(javaClass.name)
}
@Suppress("ConstPropertyName")
companion object {
const val DeleteSurroundingFlag = "org.fcitx.fcitx5.android.DELETE_SURROUNDING"
}

View File

@ -82,6 +82,17 @@ class InputView(
val theme: Theme
) : ConstraintLayout(service) {
var handleEvents = true
set(value) {
field = value
if (field) {
setupFcitxEventHandler()
} else {
eventHandlerJob?.cancel()
eventHandlerJob = null
}
}
private var shouldUpdateNavbarForeground = false
private var shouldUpdateNavbarBackground = false
@ -107,7 +118,15 @@ class InputView(
setOnClickListener(placeholderOnClickListener)
}
private val eventHandlerJob: Job
private var eventHandlerJob: Job? = null
private fun setupFcitxEventHandler() {
eventHandlerJob = service.lifecycleScope.launch {
fcitx.runImmediately { eventFlow }.collect {
handleFcitxEvent(it)
}
}
}
private val scope = DynamicScope()
private val themedContext = context.withTheme(R.style.Theme_InputViewTheme)
@ -203,11 +222,7 @@ class InputView(
// MUST call before any operation
setupScope()
eventHandlerJob = service.lifecycleScope.launch {
fcitx.runImmediately { eventFlow }.collect {
handleFcitxEvent(it)
}
}
setupFcitxEventHandler()
// restore punctuation mapping in case of InputView recreation
fcitx.launchOnReady {
@ -226,6 +241,7 @@ class InputView(
broadcaster.onImeUpdate(fcitx.runImmediately { inputMethodEntryCached })
// TODO should be moved outside of InputView
service.window.window!!.also {
when (navbarBackground) {
NavbarBackground.None -> {
@ -454,7 +470,7 @@ class InputView(
showingDialog?.dismiss()
// cancel eventHandlerJob and then clear DynamicScope,
// implies that InputView should not be attached again after detached.
eventHandlerJob.cancel()
handleEvents = false
scope.clear()
super.onDetachedFromWindow()
}

View File

@ -27,7 +27,7 @@ import splitties.views.dsl.core.textView
import splitties.views.dsl.core.verticalLayout
import splitties.views.horizontalPadding
class PreeditUi(override val ctx: Context, private val theme: Theme) : Ui {
open class PreeditUi(override val ctx: Context, private val theme: Theme) : Ui {
class CursorSpan(ctx: Context, @ColorInt color: Int, metrics: Paint.FontMetricsInt) :
DynamicDrawableSpan() {
@ -45,13 +45,13 @@ class PreeditUi(override val ctx: Context, private val theme: Theme) : Ui {
private val keyBorder by ThemeManager.prefs.keyBorder
private val barBackground = when (theme) {
open val bkgColor = when (theme) {
is Theme.Builtin -> if (keyBorder) theme.backgroundColor else theme.barColor
is Theme.Custom -> theme.backgroundColor
}
private fun createTextView() = textView {
backgroundColor = barBackground
backgroundColor = bkgColor
horizontalPadding = dp(8)
setTextColor(theme.keyTextColor)
textSize = 16f

View File

@ -5,6 +5,7 @@
package org.fcitx.fcitx5.android.utils
import android.os.Build
import android.view.inputmethod.InputConnection
fun InputConnection.withBatchEdit(block: InputConnection.() -> Unit) {
@ -12,3 +13,22 @@ fun InputConnection.withBatchEdit(block: InputConnection.() -> Unit) {
block.invoke(this)
endBatchEdit()
}
fun InputConnection.monitorCursorAnchor(enable: Boolean = true): Boolean {
if (!enable) {
requestCursorUpdates(0)
return false
}
var scheduled = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
scheduled = requestCursorUpdates(
InputConnection.CURSOR_UPDATE_MONITOR,
InputConnection.CURSOR_UPDATE_FILTER_CHARACTER_BOUNDS or InputConnection.CURSOR_UPDATE_FILTER_INSERTION_MARKER
)
}
if (!scheduled) {
scheduled =
requestCursorUpdates(InputConnection.CURSOR_UPDATE_MONITOR)
}
return scheduled
}