mirror of
https://github.com/fcitx5-android/fcitx5-android.git
synced 2026-08-02 20:16:06 +08:00
Show floating CandidatesView for hardware keyboard
This commit is contained in:
parent
ab747e35ae
commit
d7d6343267
3
.idea/dictionaries/project.xml
generated
3
.idea/dictionaries/project.xml
generated
@ -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>
|
||||
|
||||
@ -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)}]"
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user