feat: Calculator candidates

This commit is contained in:
igugyj 2026-06-04 18:44:23 +08:00
parent e205195f38
commit 8cdd708c3f
15 changed files with 386 additions and 8 deletions

View File

@ -144,6 +144,12 @@ class AppPrefs(private val sharedPreferences: SharedPreferences) {
val showVoiceInputButton =
switch(R.string.show_voice_input_button, "show_voice_input_button", false)
val calculatorCandidates = switch(
R.string.calculator_candidates,
"calculator_candidates",
true,
R.string.calculator_candidates_summary
)
val preferredVoiceInput = voiceInputPreference(
R.string.preferred_voice_input, "preferred_voice_input", ""
) { showVoiceInputButton.getValue() }

View File

@ -17,17 +17,21 @@ import androidx.annotation.Keep
import androidx.annotation.RequiresApi
import androidx.core.view.updateLayoutParams
import org.fcitx.fcitx5.android.R
import org.fcitx.fcitx5.android.core.CandidateWord
import org.fcitx.fcitx5.android.core.CapabilityFlags
import org.fcitx.fcitx5.android.core.FcitxEvent
import org.fcitx.fcitx5.android.daemon.FcitxConnection
import org.fcitx.fcitx5.android.daemon.launchOnReady
import org.fcitx.fcitx5.android.data.prefs.AppPrefs
import org.fcitx.fcitx5.android.data.prefs.ManagedPreference
import org.fcitx.fcitx5.android.data.prefs.ManagedPreferenceProvider
import org.fcitx.fcitx5.android.data.theme.Theme
import org.fcitx.fcitx5.android.data.theme.ThemeManager
import org.fcitx.fcitx5.android.input.bar.KawaiiBarComponent
import org.fcitx.fcitx5.android.input.broadcast.InputBroadcaster
import org.fcitx.fcitx5.android.input.calculator.CalculatorEngine
import org.fcitx.fcitx5.android.input.broadcast.PreeditEmptyStateComponent
import timber.log.Timber
import org.fcitx.fcitx5.android.input.broadcast.PunctuationComponent
import org.fcitx.fcitx5.android.input.broadcast.ReturnKeyDrawableComponent
import org.fcitx.fcitx5.android.input.candidates.horizontal.HorizontalCandidateComponent
@ -105,6 +109,7 @@ class InputView(
private val kawaiiBar = KawaiiBarComponent()
private val horizontalCandidate = HorizontalCandidateComponent()
private val keyboardWindow = KeyboardWindow()
private val calculatorEngine = CalculatorEngine()
private val symbolPicker = symbolPicker()
private val emojiPicker = emojiPicker()
private val emoticonPicker = emoticonPicker()
@ -131,6 +136,21 @@ class InputView(
private val keyboardPrefs = AppPrefs.getInstance().keyboard
private val focusChangeResetKeyboard by keyboardPrefs.focusChangeResetKeyboard
private var calculatorCandidates by keyboardPrefs.calculatorCandidates
@Keep
private val onCalculatorCandidatesChangeListener =
ManagedPreference.OnChangeListener<Boolean> { _, value ->
if (!value && calculatorEngine.hasExpression) {
calculatorEngine.reset()
horizontalCandidate.calculatorMode = false
horizontalCandidate.adapter.updateCandidates(emptyArray(), 0)
broadcaster.onPreeditEmptyStateUpdate(true)
broadcaster.onCandidateUpdate(
FcitxEvent.CandidateListEvent.Data(0, emptyArray())
)
}
}
private val keyboardHeightPercent = keyboardPrefs.keyboardHeightPercent
private val keyboardHeightPercentLandscape = keyboardPrefs.keyboardHeightPercentLandscape
@ -188,6 +208,62 @@ class InputView(
// MUST call before any operation
setupScope()
commonKeyActionListener.onSymAction = fun(sym: Int): Boolean {
if (!calculatorCandidates) return false
Timber.d("Calculator: onSymAction called sym=0x%04x, isNumberKeyboard=%s, hasExpression=%s",
sym, keyboardWindow.isNumberKeyboardActive, calculatorEngine.hasExpression)
if (!keyboardWindow.isNumberKeyboardActive) {
if (calculatorEngine.hasExpression) {
Timber.d("Calculator: resetting stale expression")
calculatorEngine.reset()
}
return false
}
val canHandle = calculatorEngine.canHandleSym(sym)
val result = canHandle && calculatorEngine.handleSym(sym)
Timber.d("Calculator: canHandle=%s, handleSym result=%s, expression=%s",
canHandle, result, calculatorEngine.currentExpression)
return result
}
calculatorEngine.onCandidatesChanged = { candidates, expression ->
Timber.d("Calculator: onCandidatesChanged candidates=%d expression=%s mode=%s",
candidates.size, expression, horizontalCandidate.calculatorMode)
if (expression.isNotEmpty()) {
service.currentInputConnection?.setComposingText(expression, 1)
} else {
service.currentInputConnection?.finishComposingText()
}
if (candidates.isNotEmpty()) {
horizontalCandidate.calculatorMode = true
horizontalCandidate.onCalculatorCandidateClick = { text ->
Timber.d("Calculator: candidate clicked text=%s", text)
service.currentInputConnection?.commitText(text, 1)
calculatorEngine.reset()
}
horizontalCandidate.adapter.updateCandidates(candidates.toTypedArray(), candidates.size)
} else {
if (horizontalCandidate.calculatorMode) {
horizontalCandidate.calculatorMode = false
horizontalCandidate.adapter.updateCandidates(emptyArray(), 0)
}
}
// trigger bar state machine transition to show/hide candidate view
broadcaster.onPreeditEmptyStateUpdate(expression.isEmpty())
broadcaster.onCandidateUpdate(
FcitxEvent.CandidateListEvent.Data(candidates.size, candidates.toTypedArray())
)
}
calculatorEngine.onCommitResult = { result ->
Timber.d("Calculator: onCommitResult result=%s", result)
service.currentInputConnection?.commitText(result, 1)
horizontalCandidate.calculatorMode = false
horizontalCandidate.adapter.updateCandidates(emptyArray(), 0)
broadcaster.onPreeditEmptyStateUpdate(true)
broadcaster.onCandidateUpdate(FcitxEvent.CandidateListEvent.Data(0, emptyArray()))
}
// restore punctuation mapping in case of InputView recreation
fcitx.launchOnReady {
punctuation.updatePunctuationMapping(it.statusAreaActionsCached)
@ -258,6 +334,7 @@ class InputView(
})
keyboardPrefs.registerOnChangeListener(onKeyboardSizeChangeListener)
keyboardPrefs.calculatorCandidates.registerOnChangeListener(onCalculatorCandidatesChangeListener)
}
private fun updateKeyboardSize() {
@ -309,6 +386,11 @@ class InputView(
* called when [InputView] is about to show, or restart
*/
fun startInput(info: EditorInfo, capFlags: CapabilityFlags, restarting: Boolean = false) {
Timber.d("Calculator: startInput inputType=0x%08x", info.inputType)
calculatorEngine.reset()
horizontalCandidate.calculatorMode = false
broadcaster.onPreeditEmptyStateUpdate(true)
broadcaster.onCandidateUpdate(FcitxEvent.CandidateListEvent.Data(0, emptyArray()))
broadcaster.onStartInput(info, capFlags)
returnKeyDrawable.updateDrawableOnEditorInfo(info)
if (focusChangeResetKeyboard || !restarting) {
@ -330,6 +412,12 @@ class InputView(
}
override fun handleFcitxEvent(it: FcitxEvent<*>) {
if (calculatorEngine.hasExpression && !keyboardWindow.isNumberKeyboardActive) {
Timber.d("Calculator: stale expression reset on fcitx event")
calculatorEngine.reset()
broadcaster.onPreeditEmptyStateUpdate(true)
broadcaster.onCandidateUpdate(FcitxEvent.CandidateListEvent.Data(0, emptyArray()))
}
when (it) {
is FcitxEvent.CandidateListEvent -> {
broadcaster.onCandidateUpdate(it.data)
@ -364,6 +452,7 @@ class InputView(
override fun onDetachedFromWindow() {
keyboardPrefs.unregisterOnChangeListener(onKeyboardSizeChangeListener)
keyboardPrefs.calculatorCandidates.unregisterOnChangeListener(onCalculatorCandidatesChangeListener)
// clear DynamicScope, implies that InputView should not be attached again after detached.
scope.clear()
super.onDetachedFromWindow()

View File

@ -0,0 +1,130 @@
package org.fcitx.fcitx5.android.input.calculator
import org.fcitx.fcitx5.android.core.CandidateWord
import timber.log.Timber
class CalculatorEngine {
companion object {
const val KP_0 = 0xffb0
const val KP_1 = 0xffb1
const val KP_2 = 0xffb2
const val KP_3 = 0xffb3
const val KP_4 = 0xffb4
const val KP_5 = 0xffb5
const val KP_6 = 0xffb6
const val KP_7 = 0xffb7
const val KP_8 = 0xffb8
const val KP_9 = 0xffb9
const val KP_Add = 0xffab
const val KP_Subtract = 0xffad
const val KP_Multiply = 0xffaa
const val KP_Divide = 0xffaf
const val KP_Decimal = 0xffae
const val KP_Equal = 0xffbd
const val Key_BackSpace = 0xff08
val calculatorSyms = setOf(
KP_0, KP_1, KP_2, KP_3, KP_4, KP_5, KP_6, KP_7, KP_8, KP_9,
KP_Add, KP_Subtract, KP_Multiply, KP_Divide, KP_Decimal, KP_Equal,
Key_BackSpace
)
}
private val expression = StringBuilder()
var onCandidatesChanged: ((List<CandidateWord>, String) -> Unit)? = null
var onCommitResult: ((String) -> Unit)? = null
val hasExpression: Boolean get() = expression.isNotEmpty()
val currentExpression: String get() = expression.toString()
fun canHandleSym(sym: Int): Boolean = sym in calculatorSyms
fun handleSym(sym: Int): Boolean {
if (sym !in calculatorSyms) return false
Timber.d("Calculator: handleSym sym=0x%04x expr=\"%s\"", sym, expression)
when (sym) {
KP_0, KP_1, KP_2, KP_3, KP_4, KP_5, KP_6, KP_7, KP_8, KP_9 -> {
val digit = (sym - KP_0).toString()[0]
expression.append(digit)
update()
}
KP_Add -> {
appendOperator('+')
}
KP_Subtract -> {
appendOperator('-')
}
KP_Multiply -> {
appendOperator('*')
}
KP_Divide -> {
appendOperator('/')
}
KP_Decimal -> {
if (expression.isEmpty() || expression.last() !in "0123456789.") {
expression.append("0")
}
val lastNumStart = expression.lastIndexOfAny(
charArrayOf('+', '-', '*', '/')
) + 1
val lastNum = expression.substring(lastNumStart)
if ('.' !in lastNum) {
expression.append('.')
}
update()
}
KP_Equal -> {
reset()
return false
}
Key_BackSpace -> {
if (expression.isNotEmpty()) {
expression.deleteCharAt(expression.length - 1)
update()
return true
}
return false
}
}
return true
}
private fun appendOperator(op: Char) {
if (expression.isEmpty()) return
val last = expression.last()
if (last in "+-*/") {
expression.setCharAt(expression.length - 1, op)
} else {
expression.append(op)
}
update()
}
private fun update() {
val expr = expression.toString()
val candidates = mutableListOf<CandidateWord>()
val result = ExpressionEvaluator.evaluate(expr)
Timber.d("Calculator: update expr=\"%s\" evaluateResult=%s", expr, result)
if (result != null) {
val resultStr = formatResult(result)
candidates.add(CandidateWord("", resultStr, ""))
candidates.add(CandidateWord("", "${expr}=${resultStr}", ""))
}
onCandidatesChanged?.invoke(candidates, expr)
}
private fun formatResult(value: Double): String {
return if (value == value.toLong().toDouble()) {
value.toLong().toString()
} else {
value.toString()
}
}
fun reset() {
expression.clear()
onCandidatesChanged?.invoke(emptyList(), "")
}
}

View File

@ -0,0 +1,104 @@
package org.fcitx.fcitx5.android.input.calculator
object ExpressionEvaluator {
fun evaluate(expression: String): Double? {
if (expression.isBlank()) return null
val tokens = tokenize(expression) ?: return null
if (tokens.isEmpty()) return null
return try {
val parser = Parser(tokens)
val result = parser.parseExpression()
if (parser.pos != tokens.size) null else result
} catch (_: Exception) {
null
}
}
private sealed class Token {
data class Number(val value: Double) : Token()
data class Op(val op: Char) : Token()
}
private fun tokenize(expr: String): List<Token>? {
val tokens = mutableListOf<Token>()
var i = 0
while (i < expr.length) {
val c = expr[i]
when {
c.isWhitespace() -> i++
c in "0123456789." -> {
val start = i
var dotCount = 0
while (i < expr.length && (expr[i].isDigit() || (expr[i] == '.' && dotCount++ == 0))) i++
val numStr = expr.substring(start, i)
if (numStr == "." || numStr.endsWith(".") || numStr.count { it == '.' } > 1) return null
val value = numStr.toDoubleOrNull() ?: return null
tokens.add(Token.Number(value))
}
c in "+-*/" -> {
tokens.add(Token.Op(c))
i++
}
else -> return null
}
}
return tokens
}
private class Parser(private val tokens: List<Token>) {
var pos = 0
fun parseExpression(): Double {
var left = parseTerm()
while (pos < tokens.size) {
when (val token = tokens[pos]) {
is Token.Op -> when (token.op) {
'+', '-' -> {
pos++
val right = parseTerm()
left = if (token.op == '+') left + right else left - right
}
else -> break
}
else -> break
}
}
return left
}
private fun parseTerm(): Double {
var left = parseFactor()
while (pos < tokens.size) {
when (val token = tokens[pos]) {
is Token.Op -> when (token.op) {
'*', '/' -> {
pos++
val right = parseFactor()
left = if (token.op == '*') {
left * right
} else {
if (right == 0.0) throw ArithmeticException("Division by zero")
left / right
}
}
else -> break
}
else -> break
}
}
return left
}
private fun parseFactor(): Double {
val token = tokens.getOrNull(pos) ?: throw IllegalArgumentException("Unexpected end")
return when (token) {
is Token.Number -> {
pos++
token.value
}
else -> throw IllegalArgumentException("Unexpected token: $token")
}
}
}
}

View File

@ -84,6 +84,16 @@ class HorizontalCandidateComponent :
)
}
var calculatorMode: Boolean = false
set(value) {
field = value
if (!value) {
onCalculatorCandidateClick = null
}
}
var onCalculatorCandidateClick: ((String) -> Unit)? = null
val adapter: HorizontalCandidateViewAdapter by lazy {
object : HorizontalCandidateViewAdapter(theme) {
override fun onBindViewHolder(holder: CandidateViewHolder, position: Int) {
@ -92,12 +102,19 @@ class HorizontalCandidateComponent :
minWidth = layoutMinWidth
flexGrow = layoutFlexGrow
}
holder.itemView.setOnClickListener {
fcitx.launchOnReady { it.select(holder.idx) }
}
holder.itemView.setOnLongClickListener {
inputView.showCandidateActionMenu(holder.idx, holder.candidate.text, holder.ui.root)
true
if (calculatorMode) {
holder.itemView.setOnClickListener {
onCalculatorCandidateClick?.invoke(holder.candidate.text)
}
holder.itemView.setOnLongClickListener(null)
} else {
holder.itemView.setOnClickListener {
fcitx.launchOnReady { it.select(holder.idx) }
}
holder.itemView.setOnLongClickListener {
inputView.showCandidateActionMenu(holder.idx, holder.candidate.text, holder.ui.root)
true
}
}
}
@ -167,6 +184,7 @@ class HorizontalCandidateComponent :
}
override fun onCandidateUpdate(data: FcitxEvent.CandidateListEvent.Data) {
if (calculatorMode) return
val candidates = data.candidates
val total = data.total
val maxSpanCount = maxSpanCountPref.getValue()

View File

@ -64,6 +64,12 @@ class CommonKeyActionListener :
private var backspaceSwipeState = Stopped
/**
* Called when a [SymAction] is received before forwarding to fcitx.
* Return true to consume the action (prevents forwarding).
*/
var onSymAction: ((sym: Int) -> Boolean)? = null
// there should be a new fcitx API for this
private suspend fun FcitxAPI.commitAndReset() {
if (inputMethodEntryCached.languageCode.startsWith("zh")) {
@ -93,8 +99,14 @@ class CommonKeyActionListener :
is FcitxKeyAction -> service.postFcitxJob {
sendKey(action.act, action.states.states, action.code)
}
is SymAction -> service.postFcitxJob {
sendKey(action.sym, action.states)
is SymAction -> {
if (onSymAction?.invoke(action.sym.sym) == true) {
// consumed by calculator engine, do nothing
} else {
service.postFcitxJob {
sendKey(action.sym, action.states)
}
}
}
is CommitAction -> service.postFcitxJob {
commitAndReset()

View File

@ -76,6 +76,9 @@ class KeyboardWindow : InputWindow.SimpleInputWindow<KeyboardWindow>(), Essentia
private val currentKeyboard: BaseKeyboard? get() = keyboards[currentKeyboardName]
val isNumberKeyboardActive: Boolean
get() = currentKeyboardName == NumberKeyboard.Name
private val keyActionListener = KeyActionListener { it, source ->
if (it is KeyAction.LayoutSwitchAction) {
switchLayout(it.act)

View File

@ -154,6 +154,8 @@
<string name="num_items_deleted">%1$d Element(e) gelöscht</string>
<string name="plugins">Plugins</string>
<string name="show_voice_input_button">Taste Spracheingabe zeigen</string>
<string name="calculator_candidates">Taschenrechner-Kandidaten</string>
<string name="calculator_candidates_summary">Ergebnisse als Kandidaten auf der Nummerntastatur anzeigen</string>
<string name="fcitx_daemon">Fcitx Daemon</string>
<string name="removed_n_items"> %1$d Element(e) entfernt</string>
<string name="whether_reset_switch_preference">Wollen Sie diese Einstellung auf den Standard zurücksetzen?</string>

View File

@ -110,6 +110,8 @@
<string name="main_program">programa principal</string>
<string name="edit_theme">Editar tema</string>
<string name="show_voice_input_button">Mostrar botón de entrada por voz</string>
<string name="calculator_candidates">Candidatos de calculadora</string>
<string name="calculator_candidates_summary">Mostrar resultados de cálculos como candidatos en el teclado numérico</string>
<string name="no_plugins">No hay ningún complemento disponible</string>
<string name="whether_reset_switch_preference">¿Quiere restablecer la preferencia a su valor predeterminado?</string>
<string name="enabled">Activado</string>

View File

@ -220,6 +220,8 @@
<string name="main_program">メインプログラム</string>
<string name="edit_theme">テーマを編集</string>
<string name="show_voice_input_button">音声入力ボタンを表示する</string>
<string name="calculator_candidates">計算機の候補</string>
<string name="calculator_candidates_summary">数字キーボードで式を入力すると計算結果が候補として表示されます</string>
<string name="fcitx_daemon">Fcitxデーモン</string>
<string name="restarting_fcitx">Fcitxを再起動中</string>
<string name="plugin_needs_reload">プラグインの変更を除去しました。ここをクリックして再読み込みします</string>

View File

@ -91,4 +91,6 @@
<string name="quickphrase_phrase">구문</string>
<string name="reload_config">구성 새로고침</string>
<string name="input_method_options">입력기 옵션</string>
<string name="calculator_candidates">계산기 후보</string>
<string name="calculator_candidates_summary">숫자 키보드에서 표현식을 입력할 때 계산 결과를 후보로 표시</string>
</resources>

View File

@ -225,6 +225,8 @@
<string name="main_program">основная программа</string>
<string name="edit_theme">Изменить тему</string>
<string name="show_voice_input_button">Показывать кнопку голосового ввода</string>
<string name="calculator_candidates">Кандидаты калькулятора</string>
<string name="calculator_candidates_summary">Показывать результаты вычислений в виде кандидатов на цифровой клавиатуре</string>
<string name="fcitx_daemon">Демон Fcitx</string>
<string name="restarting_fcitx">Перезапуск Fcitx</string>
<string name="plugin_needs_reload">Обнаружены изменения плагина, нажмите здесь, чтобы перезагрузить</string>

View File

@ -225,6 +225,8 @@
<string name="main_program">主程序</string>
<string name="edit_theme">编辑主题</string>
<string name="show_voice_input_button">显示语音输入按钮</string>
<string name="calculator_candidates">计算器候选词</string>
<string name="calculator_candidates_summary">在数字键盘上输入表达式时显示计算结果候选词</string>
<string name="fcitx_daemon">Fcitx 守护程序</string>
<string name="restarting_fcitx">正在重新启动 Fcitx</string>
<string name="plugin_needs_reload">检测到插件更改,点此重新加载</string>

View File

@ -225,6 +225,8 @@
<string name="main_program">主程式</string>
<string name="edit_theme">編輯主題</string>
<string name="show_voice_input_button">顯示語音輸入鍵</string>
<string name="calculator_candidates">計算機候選詞</string>
<string name="calculator_candidates_summary">在數字鍵盤上輸入表達式時顯示計算結果候選詞</string>
<string name="fcitx_daemon">Fcitx 常駐程式</string>
<string name="restarting_fcitx">重新啟動 Fcitx</string>
<string name="plugin_needs_reload">偵測到有外掛程式變化,點此重新載入</string>

View File

@ -225,6 +225,8 @@
<string name="main_program">main program</string>
<string name="edit_theme">Edit Theme</string>
<string name="show_voice_input_button">Show voice input button</string>
<string name="calculator_candidates">Calculator candidates</string>
<string name="calculator_candidates_summary">Show calculation results as candidates on the number keyboard</string>
<string name="fcitx_daemon">Fcitx Daemon</string>
<string name="restarting_fcitx">Restarting Fcitx</string>
<string name="plugin_needs_reload">Plugin changes detected, click here to reload</string>