diff --git a/.idea/gradle.xml b/.idea/gradle.xml
deleted file mode 100644
index 0e63f5976..000000000
--- a/.idea/gradle.xml
+++ /dev/null
@@ -1,27 +0,0 @@
-
-
-
-
-
-
-
\ No newline at end of file
diff --git a/README.md b/README.md
index 818c35864..39cb582ed 100644
--- a/README.md
+++ b/README.md
@@ -97,6 +97,16 @@ Sumireは、**プライバシーを絶対に妥協しない**
| カスタムキーボード | ユーザーが作成したキーボード配列を読み込むモードです。複数レイアウトの切り替え、フリック、ローマ字変換、定型文字列の入力に対応します。 |
| フローティング | キーボードを画面下部に固定せず、独立したフローティングウィンドウとして表示できます。位置は保存され、通常の候補表示や入力処理と組み合わせて使えます。 |
+### ∑ 数式候補
+
+「設定 → 計算・単位換算候補 → 数式候補を表示」を有効にすると、入力中の簡易記法や LaTeX 数式を候補欄で組版できます。通常の候補の後、全角候補の前に同じ数式の `[文字]` と `[TeX]` を表示し、タップするとそれぞれ Unicode 文字列または `$` のない正規化 LaTeX ソースを確定します。自動確定や自動置換は行いません。
+
+| 入力例 | `[文字]` | `[TeX]` |
+|:--|:--|:--|
+| `25^2` | `25²` | `25^{2}` |
+| `1/2` | `½` | `\frac{1}{2}` |
+| `sqrt(x^2+y^2)` | `√(x²+y²)` | `\sqrt{x^{2}+y^{2}}` |
+
### ⚙️ ユーザー設定
以下は `pref_*.xml` に定義されている設定項目です。保存値の初期値は `AppPreference.kt` を優先し、値を保存しない画面遷移や実行系の項目は「画面/操作」としています。`Zenz` と `Gemma` の設定は、これらを含む Full 版で表示されます。
@@ -425,6 +435,12 @@ customizable keyboard layouts**, aiming to make typing a truly personal experien
Developed in Kotlin from the ground up and optimized with Jetpack libraries for a smooth, fast,
and responsive UI. It's also optimized for tablets and foldables.
+* ∑ **Formula Candidates**
+ Recognizes shorthand and a supported LaTeX subset, renders formulas in the candidate strip, and
+ offers separate Unicode (`[Text]`) and normalized bare LaTeX (`[TeX]`) candidates. Formula
+ candidates appear after ordinary candidates and before full-width candidates when present.
+ They are opt-in through the calculator and unit-conversion settings and never commit automatically.
+
### 🚀 Quick Start
1. Install from the **Google Play** badge above,
diff --git a/app/src/debug/java/com/kazumaproject/markdownhelperkeyboard/autofill/DebugInlineAutofillService.kt b/app/src/debug/java/com/kazumaproject/markdownhelperkeyboard/autofill/DebugInlineAutofillService.kt
index 8ac305d18..09c076d52 100644
--- a/app/src/debug/java/com/kazumaproject/markdownhelperkeyboard/autofill/DebugInlineAutofillService.kt
+++ b/app/src/debug/java/com/kazumaproject/markdownhelperkeyboard/autofill/DebugInlineAutofillService.kt
@@ -220,6 +220,16 @@ class DebugInlineAutofillService : AutofillService() {
username = "work@example.test",
password = "sumire-work-password",
),
+ QaDataset(
+ label = "🔐 開発用",
+ username = "development@example.test",
+ password = "sumire-development-password",
+ ),
+ QaDataset(
+ label = "🔐 検証用",
+ username = "staging@example.test",
+ password = "sumire-staging-password",
+ ),
)
}
}
diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/candidate/Candidate.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/candidate/Candidate.kt
index 1ee8c1e41..5c2d290cd 100644
--- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/candidate/Candidate.kt
+++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/candidate/Candidate.kt
@@ -1,5 +1,7 @@
package com.kazumaproject.markdownhelperkeyboard.converter.candidate
+import com.kazumaproject.markdownhelperkeyboard.converter.utility.FormulaCandidatePresentation
+
/**
* @see 1:NBest 2:Part of letters 3:Hirakana 4:Katakana 5:Combine part of letter 6. Single Kanji
**/
@@ -13,4 +15,8 @@ data class Candidate(
val rightId: Short? = null,
/** Stable source identity for action candidates whose display string must never be committed. */
val sourceId: Long? = null,
+ /** Text sent to InputConnection. Defaults to the legacy candidate string. */
+ val commitText: String = string,
+ /** Optional non-text presentation, currently used by formula candidates. */
+ val presentation: FormulaCandidatePresentation? = null,
)
diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/candidate/CandidateTypes.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/candidate/CandidateTypes.kt
index 64e1c2f2d..da9539bc7 100644
--- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/candidate/CandidateTypes.kt
+++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/candidate/CandidateTypes.kt
@@ -10,3 +10,5 @@ const val CANDIDATE_TYPE_CALCULATION: Byte = 49
const val CANDIDATE_TYPE_UNIT_CONVERSION: Byte = 50
const val CANDIDATE_TYPE_UTILITY_LITERAL: Byte = 51
const val CANDIDATE_TYPE_TEXT_MACRO: Byte = 52
+const val CANDIDATE_TYPE_FORMULA_UNICODE: Byte = 53
+const val CANDIDATE_TYPE_FORMULA_TEX: Byte = 54
diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/utility/FormulaLayout.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/utility/FormulaLayout.kt
new file mode 100644
index 000000000..3196e48e1
--- /dev/null
+++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/utility/FormulaLayout.kt
@@ -0,0 +1,442 @@
+package com.kazumaproject.markdownhelperkeyboard.converter.utility
+
+import kotlin.math.max
+
+/**
+ * Font-independent drawing instructions for a formula. Keeping these instructions free of
+ * Android classes makes the important placement rules testable on the JVM.
+ */
+sealed interface FormulaDrawOperation {
+ data class Text(
+ val value: String,
+ val x: Float,
+ val baseline: Float,
+ val fontSize: Float,
+ val bold: Boolean = false,
+ ) : FormulaDrawOperation
+
+ data class Line(
+ val startX: Float,
+ val startY: Float,
+ val endX: Float,
+ val endY: Float,
+ val strokeWidth: Float,
+ ) : FormulaDrawOperation
+}
+
+data class FormulaLayout(
+ val width: Float,
+ val ascent: Float,
+ val descent: Float,
+ val operations: List,
+) {
+ val height: Float get() = ascent + descent
+}
+
+fun interface FormulaTextMeasurer {
+ fun measure(value: String, fontSize: Float): Float
+}
+
+data class FormulaLayoutConfig(
+ val fontSize: Float = 16f,
+ val scriptScale: Float = 0.72f,
+ val fractionScale: Float = 0.82f,
+ val ascentRatio: Float = 0.80f,
+ val descentRatio: Float = 0.22f,
+ val horizontalGapRatio: Float = 0.12f,
+ val verticalGapRatio: Float = 0.10f,
+ val ruleThicknessRatio: Float = 0.06f,
+) {
+ internal fun metrics(size: Float): Pair =
+ size * ascentRatio to size * descentRatio
+
+ internal fun horizontalGap(size: Float): Float = size * horizontalGapRatio
+
+ internal fun verticalGap(size: Float): Float = size * verticalGapRatio
+
+ internal fun ruleThickness(size: Float): Float = max(1f, size * ruleThicknessRatio)
+}
+
+/**
+ * Measures a FormulaNode around a baseline at y=0 and returns operations in that coordinate
+ * system. A caller can translate every operation by the final baseline when drawing.
+ */
+object FormulaLayoutEngine {
+ fun layout(
+ node: FormulaNode,
+ config: FormulaLayoutConfig = FormulaLayoutConfig(),
+ measureText: FormulaTextMeasurer = FormulaTextMeasurer { value, size ->
+ value.length * size * 0.58f
+ },
+ ): FormulaLayout = layoutNode(node, config.fontSize, config, measureText)
+
+ private fun layoutNode(
+ node: FormulaNode,
+ size: Float,
+ config: FormulaLayoutConfig,
+ measureText: FormulaTextMeasurer,
+ ): FormulaLayout = when (node) {
+ is FormulaNode.Number -> textLayout(node.value, size, config, measureText)
+ is FormulaNode.Symbol -> textLayout(node.value, size, config, measureText)
+ is FormulaNode.Row -> rowLayout(node.children, size, config, measureText)
+ is FormulaNode.Fraction -> fractionLayout(node, size, config, measureText)
+ is FormulaNode.Script -> scriptLayout(node, size, config, measureText)
+ is FormulaNode.Radical -> radicalLayout(node, size, config, measureText)
+ is FormulaNode.Delimited -> delimitedLayout(node, size, config, measureText)
+ is FormulaNode.LargeOperator -> largeOperatorLayout(node, size, config, measureText)
+ is FormulaNode.Accent -> accentLayout(node, size, config, measureText)
+ }
+
+ private fun textLayout(
+ value: String,
+ size: Float,
+ config: FormulaLayoutConfig,
+ measureText: FormulaTextMeasurer,
+ bold: Boolean = false,
+ ): FormulaLayout {
+ val (ascent, descent) = config.metrics(size)
+ return FormulaLayout(
+ width = measureText.measure(value, size).coerceAtLeast(0f),
+ ascent = ascent,
+ descent = descent,
+ operations = if (value.isEmpty()) {
+ emptyList()
+ } else {
+ listOf(FormulaDrawOperation.Text(value, 0f, 0f, size, bold))
+ },
+ )
+ }
+
+ private fun rowLayout(
+ children: List,
+ size: Float,
+ config: FormulaLayoutConfig,
+ measureText: FormulaTextMeasurer,
+ ): FormulaLayout {
+ if (children.isEmpty()) return textLayout("", size, config, measureText)
+ val layouts = children.map { layoutNode(it, size, config, measureText) }
+ var x = 0f
+ var ascent = 0f
+ var descent = 0f
+ val operations = buildList {
+ layouts.forEachIndexed { index, child ->
+ addAll(child.operations.map { it.translate(x, 0f) })
+ x += child.width
+ val sourceNode = children[index]
+ val isLimitedOperator = sourceNode is FormulaNode.LargeOperator &&
+ (sourceNode.lower != null || sourceNode.upper != null)
+ val nextNode = children.getOrNull(index + 1)
+ if (index < layouts.lastIndex &&
+ (isLimitedOperator ||
+ (nextNode !is FormulaNode.Delimited && sourceNode.needsOperandGap()))
+ ) {
+ x += config.horizontalGap(size)
+ }
+ ascent = max(ascent, child.ascent)
+ descent = max(descent, child.descent)
+ }
+ }
+ return FormulaLayout(x, ascent, descent, operations)
+ }
+
+ private fun FormulaNode.needsOperandGap(): Boolean = when (this) {
+ is FormulaNode.Symbol -> value in setOf(
+ "sin", "cos", "tan", "arcsin", "arccos", "arctan",
+ "sinh", "cosh", "tanh", "sec", "csc", "cot",
+ "arcsinh", "arccosh", "arctanh", "ln", "log", "exp",
+ "lim", "min", "max", "sup", "inf", "mod",
+ )
+ is FormulaNode.Script -> base.needsOperandGap()
+ else -> false
+ }
+
+ private fun fractionLayout(
+ node: FormulaNode.Fraction,
+ size: Float,
+ config: FormulaLayoutConfig,
+ measureText: FormulaTextMeasurer,
+ ): FormulaLayout {
+ val childSize = size * config.fractionScale
+ val numerator = layoutNode(node.numerator, childSize, config, measureText)
+ val denominator = layoutNode(node.denominator, childSize, config, measureText)
+ val horizontalGap = config.horizontalGap(size)
+ val verticalGap = config.verticalGap(size)
+ val rule = config.ruleThickness(size)
+ val contentWidth = max(numerator.width, denominator.width)
+ val width = contentWidth + horizontalGap * 2f
+ val numeratorBaseline = -(rule / 2f + verticalGap + numerator.descent)
+ val denominatorBaseline = rule / 2f + verticalGap + denominator.ascent
+ val numeratorX = (width - numerator.width) / 2f
+ val denominatorX = (width - denominator.width) / 2f
+ val top = numeratorBaseline - numerator.ascent
+ val bottom = denominatorBaseline + denominator.descent
+ return FormulaLayout(
+ width = width,
+ ascent = max(0f, -top),
+ descent = max(0f, bottom),
+ operations = buildList {
+ addAll(numerator.operations.map { it.translate(numeratorX, numeratorBaseline) })
+ add(
+ FormulaDrawOperation.Line(
+ startX = horizontalGap / 2f,
+ startY = 0f,
+ endX = width - horizontalGap / 2f,
+ endY = 0f,
+ strokeWidth = rule,
+ )
+ )
+ addAll(denominator.operations.map { it.translate(denominatorX, denominatorBaseline) })
+ },
+ )
+ }
+
+ private fun scriptLayout(
+ node: FormulaNode.Script,
+ size: Float,
+ config: FormulaLayoutConfig,
+ measureText: FormulaTextMeasurer,
+ ): FormulaLayout {
+ val base = layoutNode(node.base, size, config, measureText)
+ val scriptSize = size * config.scriptScale
+ val superscript = node.superscript?.let { layoutNode(it, scriptSize, config, measureText) }
+ val subscript = node.subscript?.let { layoutNode(it, scriptSize, config, measureText) }
+ if (superscript == null && subscript == null) return base
+
+ val scriptX = base.width + max(1f, size * 0.04f)
+ val scriptsArePaired = superscript != null && subscript != null
+ val superscriptBaseline = if (scriptsArePaired) {
+ -(config.verticalGap(size) + (superscript?.descent ?: 0f))
+ } else {
+ -base.ascent * 0.56f
+ }
+ val subscriptBaseline = if (scriptsArePaired) {
+ config.verticalGap(size) + (subscript?.ascent ?: 0f)
+ } else {
+ base.descent * 0.72f
+ }
+ val scriptWidth = max(superscript?.width ?: 0f, subscript?.width ?: 0f)
+ val ascent = max(
+ base.ascent,
+ superscript?.let { -superscriptBaseline + it.ascent } ?: 0f,
+ )
+ val descent = max(
+ base.descent,
+ subscript?.let { subscriptBaseline + it.descent } ?: 0f,
+ )
+ return FormulaLayout(
+ width = scriptX + scriptWidth,
+ ascent = ascent,
+ descent = descent,
+ operations = buildList {
+ addAll(base.operations)
+ superscript?.let {
+ addAll(it.operations.map { operation -> operation.translate(scriptX, superscriptBaseline) })
+ }
+ subscript?.let {
+ addAll(it.operations.map { operation -> operation.translate(scriptX, subscriptBaseline) })
+ }
+ },
+ )
+ }
+
+ private fun radicalLayout(
+ node: FormulaNode.Radical,
+ size: Float,
+ config: FormulaLayoutConfig,
+ measureText: FormulaTextMeasurer,
+ ): FormulaLayout {
+ val radicandNode = if (node.radicandWasParenthesized) {
+ FormulaNode.Delimited("(", node.radicand, ")")
+ } else {
+ node.radicand
+ }
+ val radicand = layoutNode(radicandNode, size, config, measureText)
+ val root = textLayout("√", size, config, measureText)
+ val gap = config.horizontalGap(size)
+ val radicandX = root.width + gap
+ val overlineY = -radicand.ascent - config.verticalGap(size) / 2f
+ val index = node.index?.let { layoutNode(it, size * config.scriptScale, config, measureText) }
+ val indexBaseline = -root.ascent * 0.58f
+ val width = radicandX + radicand.width
+ val ascent = max(
+ root.ascent,
+ max(
+ -overlineY + config.ruleThickness(size) / 2f,
+ index?.let { -indexBaseline + it.ascent } ?: 0f,
+ ),
+ )
+ val descent = max(root.descent, radicand.descent)
+ return FormulaLayout(
+ width = width,
+ ascent = ascent,
+ descent = descent,
+ operations = buildList {
+ addAll(root.operations)
+ addAll(radicand.operations.map { it.translate(radicandX, 0f) })
+ add(
+ FormulaDrawOperation.Line(
+ startX = radicandX,
+ startY = overlineY,
+ endX = width,
+ endY = overlineY,
+ strokeWidth = config.ruleThickness(size),
+ )
+ )
+ index?.let {
+ addAll(it.operations.map { operation -> operation.translate(0f, indexBaseline) })
+ }
+ },
+ )
+ }
+
+ private fun delimitedLayout(
+ node: FormulaNode.Delimited,
+ size: Float,
+ config: FormulaLayoutConfig,
+ measureText: FormulaTextMeasurer,
+ ): FormulaLayout {
+ val content = layoutNode(node.content, size, config, measureText)
+ val baseDelimiterHeight = config.metrics(size).let { (ascent, descent) -> ascent + descent }
+ val delimiterScale = if (baseDelimiterHeight > 0f) {
+ (content.height / baseDelimiterHeight).coerceAtLeast(1f)
+ } else {
+ 1f
+ }
+ val delimiterSize = size * delimiterScale
+ val left = textLayout(
+ node.left,
+ if (node.left.isEmpty()) size else delimiterSize,
+ config,
+ measureText,
+ )
+ val right = textLayout(
+ node.right,
+ if (node.right.isEmpty()) size else delimiterSize,
+ config,
+ measureText,
+ )
+ val rightX = left.width + content.width
+ return FormulaLayout(
+ width = left.width + content.width + right.width,
+ ascent = maxOf(left.ascent, content.ascent, right.ascent),
+ descent = maxOf(left.descent, content.descent, right.descent),
+ operations = buildList {
+ addAll(left.operations)
+ addAll(content.operations.map { it.translate(left.width, 0f) })
+ addAll(right.operations.map { it.translate(rightX, 0f) })
+ },
+ )
+ }
+
+ private fun largeOperatorLayout(
+ node: FormulaNode.LargeOperator,
+ size: Float,
+ config: FormulaLayoutConfig,
+ measureText: FormulaTextMeasurer,
+ ): FormulaLayout {
+ val operator = textLayout(
+ FormulaFormatter.largeOperatorDisplay(node.name),
+ size,
+ config,
+ measureText,
+ )
+ val limitSize = size * config.scriptScale
+ val lower = node.lower?.let { layoutNode(it, limitSize, config, measureText) }
+ val upper = node.upper?.let { layoutNode(it, limitSize, config, measureText) }
+ val operatorRegionWidth = maxOf(operator.width, lower?.width ?: 0f, upper?.width ?: 0f)
+ val operatorX = (operatorRegionWidth - operator.width) / 2f
+ val limitGap = config.verticalGap(size)
+ val upperBaseline = upper?.let { -(operator.ascent + limitGap + it.descent) }
+ val lowerBaseline = lower?.let { operator.descent + limitGap + it.ascent }
+ val operand = node.operand?.let { layoutNode(it, size, config, measureText) }
+ val operandX = if (operand == null) {
+ operatorRegionWidth
+ } else {
+ operatorRegionWidth + config.horizontalGap(size)
+ }
+ return FormulaLayout(
+ width = operandX + (operand?.width ?: 0f),
+ ascent = maxOf(
+ operator.ascent,
+ upper?.let { -(upperBaseline ?: 0f) + it.ascent } ?: 0f,
+ operand?.ascent ?: 0f,
+ ),
+ descent = maxOf(
+ operator.descent,
+ lower?.let { (lowerBaseline ?: 0f) + it.descent } ?: 0f,
+ operand?.descent ?: 0f,
+ ),
+ operations = buildList {
+ addAll(operator.operations.map { it.translate(operatorX, 0f) })
+ upper?.let {
+ addAll(it.operations.map { operation -> operation.translate((operatorRegionWidth - it.width) / 2f, upperBaseline ?: 0f) })
+ }
+ lower?.let {
+ addAll(it.operations.map { operation -> operation.translate((operatorRegionWidth - it.width) / 2f, lowerBaseline ?: 0f) })
+ }
+ operand?.let {
+ addAll(it.operations.map { operation -> operation.translate(operandX, 0f) })
+ }
+ },
+ )
+ }
+
+ private fun accentLayout(
+ node: FormulaNode.Accent,
+ size: Float,
+ config: FormulaLayoutConfig,
+ measureText: FormulaTextMeasurer,
+ ): FormulaLayout {
+ val base = layoutNode(node.base, size, config, measureText)
+ if (node.kind == AccentKind.BOLD) {
+ return base.copy(operations = base.operations.map { it.bold() })
+ }
+ val accentValue = when (node.kind) {
+ AccentKind.HAT -> "⌃"
+ AccentKind.BAR,
+ AccentKind.OVERLINE -> "¯"
+ AccentKind.DOT -> "˙"
+ AccentKind.DDOT -> "¨"
+ AccentKind.TILDE -> "˜"
+ AccentKind.VEC -> "→"
+ AccentKind.UNDERLINE -> ""
+ AccentKind.BOLD -> ""
+ }
+ if (node.kind == AccentKind.UNDERLINE) {
+ val underlineY = base.descent + config.verticalGap(size)
+ return base.copy(
+ descent = underlineY + config.ruleThickness(size),
+ operations = base.operations + FormulaDrawOperation.Line(
+ startX = 0f,
+ startY = underlineY,
+ endX = base.width,
+ endY = underlineY,
+ strokeWidth = config.ruleThickness(size),
+ ),
+ )
+ }
+ val accent = textLayout(accentValue, size * 0.9f, config, measureText)
+ val accentBaseline = -base.ascent - config.verticalGap(size) / 2f
+ return base.copy(
+ ascent = max(base.ascent, -accentBaseline + accent.ascent),
+ operations = base.operations + accent.operations.map {
+ it.translate((base.width - accent.width) / 2f, accentBaseline)
+ },
+ )
+ }
+
+ private fun FormulaDrawOperation.translate(dx: Float, dy: Float): FormulaDrawOperation = when (this) {
+ is FormulaDrawOperation.Text -> copy(x = x + dx, baseline = baseline + dy)
+ is FormulaDrawOperation.Line -> copy(
+ startX = startX + dx,
+ startY = startY + dy,
+ endX = endX + dx,
+ endY = endY + dy,
+ )
+ }
+
+ private fun FormulaDrawOperation.bold(): FormulaDrawOperation = when (this) {
+ is FormulaDrawOperation.Text -> copy(bold = true)
+ is FormulaDrawOperation.Line -> this
+ }
+}
diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/utility/FormulaModels.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/utility/FormulaModels.kt
new file mode 100644
index 000000000..22356c975
--- /dev/null
+++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/utility/FormulaModels.kt
@@ -0,0 +1,498 @@
+package com.kazumaproject.markdownhelperkeyboard.converter.utility
+
+/**
+ * A deliberately small, renderer-independent representation of a mathematical expression.
+ *
+ * The tree is shared by the shorthand parser, the supported LaTeX parser, the text exporters,
+ * and the Android canvas renderer. It is intentionally not an evaluation tree: a formula is
+ * allowed to contain variables and symbols which cannot be calculated.
+ */
+sealed interface FormulaNode {
+ data class Number(val value: String) : FormulaNode
+
+ class Symbol(
+ val value: String,
+ val texName: String? = null,
+ ) : FormulaNode {
+ // texName is an output hint, not part of the mathematical structure.
+ override fun equals(other: Any?): Boolean = other is Symbol && value == other.value
+
+ override fun hashCode(): Int = value.hashCode()
+
+ override fun toString(): String = "Symbol(value=$value, texName=$texName)"
+ }
+
+ data class Row(val children: List) : FormulaNode
+
+ data class Fraction(
+ val numerator: FormulaNode,
+ val denominator: FormulaNode,
+ ) : FormulaNode
+
+ class Script(
+ val base: FormulaNode,
+ val subscript: FormulaNode? = null,
+ val superscript: FormulaNode? = null,
+ val subscriptWasParenthesized: Boolean = false,
+ val superscriptWasParenthesized: Boolean = false,
+ ) : FormulaNode {
+ // Parentheses flags only describe how a linear Unicode fallback is written.
+ override fun equals(other: Any?): Boolean = other is Script &&
+ base == other.base &&
+ subscript == other.subscript &&
+ superscript == other.superscript
+
+ override fun hashCode(): Int = 31 * (31 * base.hashCode() + (subscript?.hashCode() ?: 0)) +
+ (superscript?.hashCode() ?: 0)
+
+ override fun toString(): String = "Script(base=$base, subscript=$subscript, superscript=$superscript)"
+ }
+
+ class Radical(
+ val radicand: FormulaNode,
+ val index: FormulaNode? = null,
+ val radicandWasParenthesized: Boolean = false,
+ ) : FormulaNode {
+ override fun equals(other: Any?): Boolean = other is Radical &&
+ radicand == other.radicand && index == other.index
+
+ override fun hashCode(): Int = 31 * radicand.hashCode() + (index?.hashCode() ?: 0)
+
+ override fun toString(): String = "Radical(radicand=$radicand, index=$index)"
+ }
+
+ class Delimited(
+ val left: String,
+ val content: FormulaNode,
+ val right: String,
+ val latexSized: Boolean = false,
+ ) : FormulaNode {
+ // \left/\right is a layout spelling; the delimiters and their content are the AST.
+ override fun equals(other: Any?): Boolean = other is Delimited &&
+ left == other.left && content == other.content && right == other.right
+
+ override fun hashCode(): Int = ((31 * left.hashCode() + content.hashCode()) * 31) + right.hashCode()
+
+ override fun toString(): String = "Delimited(left=$left, content=$content, right=$right)"
+ }
+
+ data class LargeOperator(
+ val name: String,
+ val lower: FormulaNode? = null,
+ val upper: FormulaNode? = null,
+ val operand: FormulaNode? = null,
+ ) : FormulaNode
+
+ data class Accent(
+ val kind: AccentKind,
+ val base: FormulaNode,
+ ) : FormulaNode
+}
+
+enum class AccentKind(
+ val unicodeMark: String?,
+ val texCommand: String,
+) {
+ HAT("̂", "hat"),
+ BAR("̄", "bar"),
+ OVERLINE("̄", "overline"),
+ DOT("̇", "dot"),
+ DDOT("̈", "ddot"),
+ TILDE("̃", "tilde"),
+ VEC("⃗", "vec"),
+ UNDERLINE("̲", "underline"),
+ BOLD(null, "mathbf"),
+}
+
+enum class FormulaCandidateType {
+ UNICODE,
+ TEX,
+}
+
+/** The complete, reusable result of parsing one formula source. */
+data class ParsedFormula(
+ val ast: FormulaNode,
+ val unicodeText: String,
+ val normalizedTex: String,
+ val sourceText: String,
+ val sourceWasNormalizedTex: Boolean,
+) {
+ fun presentation(type: FormulaCandidateType): FormulaCandidatePresentation =
+ FormulaCandidatePresentation(
+ ast = ast,
+ unicodeText = unicodeText,
+ normalizedTex = normalizedTex,
+ type = type,
+ )
+}
+
+/** Data carried by a Candidate independently of the string sent to InputConnection. */
+data class FormulaCandidatePresentation(
+ val ast: FormulaNode,
+ val unicodeText: String,
+ val normalizedTex: String,
+ val type: FormulaCandidateType,
+) {
+ val commitText: String
+ get() = if (type == FormulaCandidateType.UNICODE) unicodeText else normalizedTex
+
+ val fallbackText: String
+ get() = commitText
+}
+
+internal fun formulaRow(children: Iterable): FormulaNode {
+ val flattened = buildList {
+ children.forEach { child ->
+ when {
+ child is FormulaNode.Row -> addAll(child.children)
+ child is FormulaNode.Symbol && child.value.isEmpty() -> Unit
+ else -> add(child)
+ }
+ }
+ }
+ return when (flattened.size) {
+ 0 -> FormulaNode.Symbol("")
+ 1 -> flattened.single()
+ else -> FormulaNode.Row(flattened)
+ }
+}
+
+/** Creates a symbol with the canonical TeX spelling for direct Unicode input when available. */
+internal fun formulaSymbol(value: String, texName: String? = null): FormulaNode.Symbol =
+ FormulaNode.Symbol(value, texName ?: FormulaFormatter.texNameForUnicode(value))
+
+internal fun FormulaNode.asRowChildren(): List = when (this) {
+ is FormulaNode.Row -> children
+ else -> listOf(this)
+}
+
+internal fun shouldParenthesizeScriptForUnicode(node: FormulaNode): Boolean = when (node) {
+ is FormulaNode.Row -> node.children.size > 1
+ is FormulaNode.Fraction,
+ is FormulaNode.Radical,
+ is FormulaNode.LargeOperator,
+ is FormulaNode.Accent -> true
+ is FormulaNode.Delimited,
+ is FormulaNode.Number,
+ is FormulaNode.Symbol,
+ is FormulaNode.Script -> false
+}
+
+internal fun FormulaNode.isEmptyFormula(): Boolean =
+ this is FormulaNode.Symbol && value.isEmpty()
+
+object FormulaFormatter {
+ private val superscriptCharacters = mapOf(
+ '0' to '⁰', '1' to '¹', '2' to '²', '3' to '³', '4' to '⁴',
+ '5' to '⁵', '6' to '⁶', '7' to '⁷', '8' to '⁸', '9' to '⁹',
+ '+' to '⁺', '-' to '⁻', '−' to '⁻', '=' to '⁼',
+ '(' to '⁽', ')' to '⁾', 'a' to 'ᵃ', 'b' to 'ᵇ', 'c' to 'ᶜ',
+ 'd' to 'ᵈ', 'e' to 'ᵉ', 'f' to 'ᶠ', 'g' to 'ᵍ', 'h' to 'ʰ',
+ 'i' to 'ⁱ', 'j' to 'ʲ', 'k' to 'ᵏ', 'l' to 'ˡ', 'm' to 'ᵐ',
+ 'n' to 'ⁿ', 'o' to 'ᵒ', 'p' to 'ᵖ', 'r' to 'ʳ', 's' to 'ˢ',
+ 't' to 'ᵗ', 'u' to 'ᵘ', 'v' to 'ᵛ', 'w' to 'ʷ', 'x' to 'ˣ',
+ 'y' to 'ʸ', 'z' to 'ᶻ',
+ )
+ private val subscriptCharacters = mapOf(
+ '0' to '₀', '1' to '₁', '2' to '₂', '3' to '₃', '4' to '₄',
+ '5' to '₅', '6' to '₆', '7' to '₇', '8' to '₈', '9' to '₉',
+ '+' to '₊', '-' to '₋', '−' to '₋', '=' to '₌',
+ '(' to '₍', ')' to '₎', 'a' to 'ₐ', 'e' to 'ₑ', 'h' to 'ₕ',
+ 'i' to 'ᵢ', 'j' to 'ⱼ', 'k' to 'ₖ', 'l' to 'ₗ', 'm' to 'ₘ',
+ 'n' to 'ₙ', 'o' to 'ₒ', 'p' to 'ₚ', 'r' to 'ᵣ', 's' to 'ₛ',
+ 't' to 'ₜ', 'u' to 'ᵤ', 'v' to 'ᵥ', 'x' to 'ₓ',
+ )
+
+ private val vulgarFractions = mapOf(
+ "1/2" to "½",
+ "1/3" to "⅓",
+ "2/3" to "⅔",
+ "1/4" to "¼",
+ "3/4" to "¾",
+ "1/5" to "⅕",
+ "2/5" to "⅖",
+ "3/5" to "⅗",
+ "4/5" to "⅘",
+ "1/6" to "⅙",
+ "5/6" to "⅚",
+ "1/8" to "⅛",
+ "3/8" to "⅜",
+ "5/8" to "⅝",
+ "7/8" to "⅞",
+ )
+
+ private val unicodeToTex = mapOf(
+ "Π" to "\\Pi", "∞" to "\\infty",
+ "∑" to "\\sum", "∏" to "\\prod", "∫" to "\\int", "∬" to "\\iint",
+ "∭" to "\\iiint", "∮" to "\\oint", "√" to "\\sqrt",
+ "≤" to "\\leq", "≥" to "\\geq", "≠" to "\\neq", "≈" to "\\approx",
+ "≃" to "\\simeq", "≡" to "\\equiv", "≢" to "\\not\\equiv",
+ "≅" to "\\cong", "≍" to "\\asymp", "≰" to "\\nleq", "≱" to "\\ngeq",
+ "∼" to "\\sim", "∝" to "\\propto", "→" to "\\to",
+ "⇒" to "\\Rightarrow", "↔" to "\\leftrightarrow", "⇔" to "\\Leftrightarrow",
+ "←" to "\\leftarrow", "↦" to "\\mapsto", "⟶" to "\\longrightarrow",
+ "⟹" to "\\Longrightarrow", "⟷" to "\\longleftrightarrow",
+ "⟺" to "\\Longleftrightarrow", "⟵" to "\\longleftarrow", "⟼" to "\\longmapsto",
+ "∈" to "\\in",
+ "∉" to "\\notin", "∋" to "\\ni", "⊂" to "\\subset", "⊆" to "\\subseteq",
+ "⊈" to "\\nsubseteq", "⊃" to "\\supset", "⊇" to "\\supseteq",
+ "⊉" to "\\nsupseteq", "⊄" to "\\nsubset", "⊅" to "\\nsupset",
+ "∪" to "\\cup", "∩" to "\\cap",
+ "∀" to "\\forall", "∃" to "\\exists", "∧" to "\\land", "∨" to "\\lor",
+ "¬" to "\\neg", "∅" to "\\emptyset", "ℕ" to "\\mathbb{N}",
+ "ℤ" to "\\mathbb{Z}", "ℚ" to "\\mathbb{Q}", "ℝ" to "\\mathbb{R}",
+ "ℂ" to "\\mathbb{C}", "±" to "\\pm", "∓" to "\\mp", "×" to "\\times",
+ "⋅" to "\\cdot", "÷" to "\\div", "∂" to "\\partial", "∇" to "\\nabla",
+ "‖" to "\\Vert", "∥" to "\\parallel", "∣" to "\\mid",
+ "mod" to "\\bmod",
+ "α" to "\\alpha", "β" to "\\beta", "γ" to "\\gamma", "δ" to "\\delta",
+ "ϵ" to "\\epsilon", "ε" to "\\varepsilon", "ζ" to "\\zeta", "η" to "\\eta",
+ "θ" to "\\theta", "ϑ" to "\\vartheta", "ι" to "\\iota", "κ" to "\\kappa",
+ "λ" to "\\lambda", "μ" to "\\mu", "ν" to "\\nu", "ξ" to "\\xi",
+ "ο" to "\\omicron", "π" to "\\pi", "ϖ" to "\\varpi", "ρ" to "\\rho",
+ "ϱ" to "\\varrho", "σ" to "\\sigma", "ς" to "\\varsigma", "τ" to "\\tau",
+ "υ" to "\\upsilon", "ϕ" to "\\phi", "φ" to "\\varphi", "χ" to "\\chi",
+ "ψ" to "\\psi", "ω" to "\\omega", "Γ" to "\\Gamma", "Δ" to "\\Delta",
+ "Θ" to "\\Theta", "Λ" to "\\Lambda", "Ξ" to "\\Xi",
+ "Σ" to "\\Sigma", "Υ" to "\\Upsilon", "Φ" to "\\Phi", "Ψ" to "\\Psi",
+ "Ω" to "\\Omega",
+ )
+
+ fun unicode(node: FormulaNode): String = when (node) {
+ is FormulaNode.Number -> node.value
+ is FormulaNode.Symbol -> node.value
+ is FormulaNode.Row -> unicodeRow(node.children)
+ is FormulaNode.Fraction -> unicodeFraction(node)
+ is FormulaNode.Script -> buildString {
+ append(unicode(node.base))
+ node.subscript?.let {
+ append(scriptText(it, subscriptCharacters, node.subscriptWasParenthesized))
+ }
+ node.superscript?.let {
+ append(scriptText(it, superscriptCharacters, node.superscriptWasParenthesized))
+ }
+ }
+ is FormulaNode.Radical -> buildString {
+ append("√")
+ if (node.index != null) {
+ append(scriptText(node.index, superscriptCharacters, false))
+ append("⟨")
+ append(unicode(node.radicand))
+ append("⟩")
+ } else if (node.radicandWasParenthesized) {
+ append('(')
+ append(unicode(node.radicand))
+ append(')')
+ } else {
+ append(unicode(node.radicand))
+ }
+ }
+ is FormulaNode.Delimited -> node.left + unicode(node.content) + node.right
+ is FormulaNode.LargeOperator -> buildString {
+ append(largeOperatorDisplay(node.name))
+ node.lower?.let { append(scriptText(it, subscriptCharacters, false)) }
+ node.upper?.let { append(scriptText(it, superscriptCharacters, false)) }
+ node.operand?.let {
+ append(' ')
+ append(unicode(it))
+ }
+ }
+ is FormulaNode.Accent -> unicodeAccent(node)
+ }
+
+ fun tex(node: FormulaNode): String = when (node) {
+ is FormulaNode.Number -> node.value
+ is FormulaNode.Symbol -> node.texName ?: unicodeToTex[node.value] ?: texSymbol(node.value)
+ is FormulaNode.Row -> texRow(node.children)
+ is FormulaNode.Fraction -> "\\frac{" + tex(node.numerator) + "}{" +
+ tex(node.denominator) + "}"
+ is FormulaNode.Script -> buildString {
+ append(tex(node.base))
+ node.subscript?.let { append("_{${tex(it)}}") }
+ node.superscript?.let { append("^{${tex(it)}}") }
+ }
+ is FormulaNode.Radical -> {
+ if (node.index == null) {
+ "\\sqrt{" + tex(node.radicand) + "}"
+ } else {
+ "\\sqrt[" + tex(node.index) + "]{" + tex(node.radicand) + "}"
+ }
+ }
+ is FormulaNode.Delimited -> if (node.latexSized) {
+ "\\left${texDelimiter(node.left, followedByContent = true)}${tex(node.content)}" +
+ "\\right${texDelimiter(node.right)}"
+ } else {
+ texDelimiter(node.left, followedByContent = true) +
+ tex(node.content) + texDelimiter(node.right)
+ }
+ is FormulaNode.LargeOperator -> buildString {
+ append("\\")
+ append(node.name)
+ node.lower?.let { append("_{${tex(it)}}") }
+ node.upper?.let { append("^{${tex(it)}}") }
+ node.operand?.let {
+ if (node.lower == null && node.upper == null) append(' ')
+ append(tex(it))
+ }
+ }
+ is FormulaNode.Accent -> "\\${node.kind.texCommand}{${tex(node.base)}}"
+ }
+
+ fun render(node: FormulaNode): Pair = unicode(node) to tex(node)
+
+ internal fun texNameForUnicode(value: String): String? = unicodeToTex[value]
+
+ private fun unicodeRow(children: List): String = buildString {
+ children.forEachIndexed { index, child ->
+ val previous = children.getOrNull(index - 1)
+ if (index > 0 &&
+ (child is FormulaNode.LargeOperator ||
+ (child !is FormulaNode.Delimited && previous?.needsUnicodeOperandGap() == true))
+ ) {
+ append(' ')
+ }
+ append(unicode(child))
+ }
+ }
+
+ private fun FormulaNode.needsUnicodeOperandGap(): Boolean = when (this) {
+ is FormulaNode.LargeOperator -> true
+ is FormulaNode.Symbol -> value in setOf(
+ "sin", "cos", "tan", "arcsin", "arccos", "arctan",
+ "sinh", "cosh", "tanh", "sec", "csc", "cot",
+ "arcsinh", "arccosh", "arctanh", "ln", "log", "exp",
+ "lim", "min", "max", "sup", "inf", "mod",
+ )
+ is FormulaNode.Script -> this.base.needsUnicodeOperandGap()
+ else -> false
+ }
+
+ private fun texRow(children: List): String = buildString {
+ var previous: String? = null
+ children.forEach { child ->
+ val current = tex(child)
+ if (previous?.endsWithBareControlWord() == true && current.firstOrNull()?.isLetter() == true) {
+ append(' ')
+ }
+ append(current)
+ previous = current
+ }
+ }
+
+ private fun String.endsWithBareControlWord(): Boolean {
+ val slash = lastIndexOf('\\')
+ return slash >= 0 && substring(slash + 1).isNotEmpty() &&
+ substring(slash + 1).all(Char::isLetter)
+ }
+
+ private fun unicodeFraction(node: FormulaNode.Fraction): String {
+ val numerator = unicode(node.numerator)
+ val denominator = unicode(node.denominator)
+ vulgarFractions["$numerator/$denominator"]?.let { return it }
+
+ if (numerator.all(Char::isDigit) && denominator.all(Char::isDigit)) {
+ return toSuperscript(numerator) + "⁄" + toSubscript(denominator)
+ }
+
+ val renderedNumerator = if (needsFractionParentheses(node.numerator)) {
+ "($numerator)"
+ } else {
+ numerator
+ }
+ val renderedDenominator = if (needsFractionParentheses(node.denominator)) {
+ "($denominator)"
+ } else {
+ denominator
+ }
+ return "$renderedNumerator⁄$renderedDenominator"
+ }
+
+ private fun needsFractionParentheses(node: FormulaNode): Boolean = when (node) {
+ is FormulaNode.Row -> node.children.size > 1
+ is FormulaNode.Delimited -> false
+ is FormulaNode.Number,
+ is FormulaNode.Symbol,
+ is FormulaNode.Script,
+ is FormulaNode.Radical,
+ is FormulaNode.LargeOperator,
+ is FormulaNode.Accent,
+ is FormulaNode.Fraction -> true
+ }
+
+ private fun scriptText(
+ node: FormulaNode,
+ mapping: Map,
+ wasParenthesized: Boolean,
+ ): String {
+ val source = unicode(node)
+ val mapped = buildString(source.length) {
+ source.forEach { char -> append(mapping[char] ?: char) }
+ }
+ return if (wasParenthesized) {
+ val left = mapping['('] ?: '('
+ val right = mapping[')'] ?: ')'
+ "$left$mapped$right"
+ } else {
+ mapped
+ }
+ }
+
+ private fun toSuperscript(source: String): String = source.map { superscriptCharacters[it] ?: it }.joinToString("")
+
+ private fun toSubscript(source: String): String = source.map { subscriptCharacters[it] ?: it }.joinToString("")
+
+ internal fun largeOperatorDisplay(name: String): String = when (name) {
+ "sum" -> "∑"
+ "prod" -> "∏"
+ "int" -> "∫"
+ "iint" -> "∬"
+ "iiint" -> "∭"
+ "oint" -> "∮"
+ "lim" -> "lim"
+ "min" -> "min"
+ "max" -> "max"
+ "sup" -> "sup"
+ "inf" -> "inf"
+ else -> name
+ }
+
+ private fun unicodeAccent(node: FormulaNode.Accent): String {
+ val base = unicode(node.base)
+ return when (node.kind) {
+ AccentKind.BOLD -> base
+ else -> base + node.kind.unicodeMark.orEmpty()
+ }
+ }
+
+ private fun texDelimiter(delimiter: String, followedByContent: Boolean = false): String {
+ val value = when (delimiter) {
+ "{" -> "\\{"
+ "}" -> "\\}"
+ "⟨" -> "\\langle"
+ "⟩" -> "\\rangle"
+ "⌊" -> "\\lfloor"
+ "⌋" -> "\\rfloor"
+ "⌈" -> "\\lceil"
+ "⌉" -> "\\rceil"
+ "|" -> "|"
+ "‖" -> "\\Vert"
+ "∥" -> "\\parallel"
+ else -> delimiter
+ }
+ return if (followedByContent && value.lastOrNull()?.isLetter() == true) {
+ "$value "
+ } else {
+ value
+ }
+ }
+
+ private fun texSymbol(value: String): String = when (value) {
+ "#" -> "\\#"
+ "%" -> "\\%"
+ "&" -> "\\&"
+ "_" -> "\\_"
+ "{" -> "\\{"
+ "}" -> "\\}"
+ else -> value
+ }
+}
diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/utility/FormulaParser.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/utility/FormulaParser.kt
new file mode 100644
index 000000000..28477af0b
--- /dev/null
+++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/utility/FormulaParser.kt
@@ -0,0 +1,1441 @@
+package com.kazumaproject.markdownhelperkeyboard.converter.utility
+
+/**
+ * Parses the keyboard's formula shorthand and the intentionally finite LaTeX subset.
+ *
+ * This parser is kept independent of Android so it can run on the candidate worker thread and
+ * be exercised with ordinary JVM tests. It is conservative by design: an unsupported command,
+ * an unfinished group, or an input that looks like prose is rejected instead of producing a
+ * surprising candidate.
+ */
+class FormulaParser(
+ private val maxCodePoints: Int = MAX_CODE_POINTS,
+ private val maxTokens: Int = MAX_TOKENS,
+ private val maxDepth: Int = MAX_DEPTH,
+) {
+ fun parse(input: String): ParsedFormula? {
+ if (!isWithinLimits(input)) return null
+ val trimmed = input.trim()
+ if (trimmed.isEmpty() || looksLikeNonFormula(trimmed)) return null
+
+ val (source, hadMathDelimiters) = stripMathDelimiters(trimmed) ?: return null
+ if (source.isEmpty()) return null
+ if (source.firstOrNull() == '^' || source.firstOrNull() == '_') return null
+
+ val ast = if ('\\' in source) {
+ LatexFormulaParser(source, maxTokens, maxDepth).parse()
+ } else {
+ SimpleFormulaParser(source, maxTokens, maxDepth).parse()
+ } ?: return null
+
+ if (ast.isEmptyFormula() || !isMathLike(source, ast)) return null
+ val (unicodeText, normalizedTex) = FormulaFormatter.render(ast)
+ if (unicodeText.isBlank() || normalizedTex.isBlank()) return null
+
+ return ParsedFormula(
+ ast = ast,
+ unicodeText = unicodeText,
+ normalizedTex = normalizedTex,
+ sourceText = trimmed,
+ sourceWasNormalizedTex = !hadMathDelimiters && '\\' in source &&
+ source == normalizedTex,
+ )
+ }
+
+ private fun isWithinLimits(input: String): Boolean =
+ input.codePointCount(0, input.length) <= maxCodePoints && input.length <= MAX_UTF16_LENGTH
+
+ private fun stripMathDelimiters(input: String): Pair? {
+ if (input.startsWith("$$") && input.endsWith("$$") && input.length > 4) {
+ return input.substring(2, input.length - 2).trim() to true
+ }
+ if (input.startsWith('$') && input.endsWith('$') && input.length > 2) {
+ return input.substring(1, input.length - 1).trim() to true
+ }
+ if ('$' in input) return null
+ return input to false
+ }
+
+ private fun looksLikeNonFormula(input: String): Boolean {
+ if ('\n' in input || '\r' in input || '\t' in input) return true
+ if (input.contains("```") || input.contains('`')) return true
+ if (
+ input.contains(';') ||
+ input.contains("//") ||
+ input.contains("/*") ||
+ input.contains("*/") ||
+ input.any { it == '"' || it == '\'' }
+ ) return true
+ if (Regex("(?i)^(?:https?|ftp)://|^www\\.").containsMatchIn(input)) return true
+ if (input.contains("://")) return true
+ if (Regex("^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$").matches(input)) return true
+ if (input.startsWith("<") && input.endsWith(">")) return true
+ if (Regex("(?i)^(?:fun|val|var|const|let|return|class|object|function|def|public|private|protected|new|if|else|for|while|switch|case|try|catch|throw|interface|package|import|select|insert|update|delete)\\b")
+ .containsMatchIn(input)
+ ) return true
+ return false
+ }
+
+ private fun isMathLike(source: String, ast: FormulaNode): Boolean {
+ if ('\\' in source) return true
+ if (source.any {
+ it in "^_=/+*%!<>≤≥≠≈≃≡≢≅≍≰≱∼∝→⇒↔⇔←↦⟶⟹⟷⟺⟵⟼√|‖∥∣×÷±∓⋅⊄⊅⊈⊉∈∉∋⊂⊆⊃⊇∪∩∧∨¬⌊⌋⌈⌉⟨⟩"
+ }) return true
+ if (source.any {
+ it in "πΠ∞∑∏∫∬∭∮∂∇∀∃∈∉⊂⊆⊃⊇∪∩ℕℤℚℝℂℵ" ||
+ it in "αβγδεζηθικλμνξοπρστυφχψωϵεϑϕφςϖϱΓΔΘΛΞΠΣΥΦΨΩ"
+ }
+ ) return true
+ if (source == "e") return true
+ if (Regex("(?i)(?:^|[^a-z])(?:sqrt|root|nroot|sum|sigma|prod|product|int|integral|iint|iiint|oint|lim|limit|abs|absolute|norm|floor|ceil|ceiling|vec|vector|hat|bar|overline|dot|ddot|tilde|underline|bold)\\s*[({]")
+ .containsMatchIn(source)
+ ) return true
+ return containsMathStructure(ast) || containsMathSymbol(ast)
+ }
+
+ private fun containsMathStructure(node: FormulaNode): Boolean = when (node) {
+ is FormulaNode.Number,
+ is FormulaNode.Symbol -> false
+ is FormulaNode.Row -> node.children.any(::containsMathStructure)
+ is FormulaNode.Fraction,
+ is FormulaNode.Script,
+ is FormulaNode.Radical,
+ is FormulaNode.LargeOperator,
+ is FormulaNode.Accent -> true
+ is FormulaNode.Delimited -> containsMathStructure(node.content)
+ }
+
+ private fun containsMathSymbol(node: FormulaNode): Boolean = when (node) {
+ is FormulaNode.Symbol -> node.texName != null
+ is FormulaNode.Row -> node.children.any(::containsMathSymbol)
+ is FormulaNode.Delimited -> containsMathSymbol(node.content)
+ else -> false
+ }
+
+ private companion object {
+ const val MAX_CODE_POINTS = 200
+ const val MAX_UTF16_LENGTH = 255
+ const val MAX_TOKENS = 128
+ const val MAX_DEPTH = 32
+ }
+}
+
+private class SimpleFormulaParser(
+ private val source: String,
+ private val maxTokens: Int,
+ private val maxDepth: Int,
+) {
+ private var index = 0
+ private var tokenCount = 0
+ private var depth = 0
+ private var failed = false
+
+ fun parse(): FormulaNode? {
+ val result = parseRelation(emptySet()) ?: return null
+ skipSpaces()
+ return result.takeIf { !failed && index == source.length }
+ }
+
+ private fun parseRelation(stop: Set): FormulaNode? {
+ var left = parseAdditive(stop) ?: return null
+ while (!failed && !atStop(stop)) {
+ skipSpaces()
+ val relation = readRelationOperator() ?: break
+ val right = parseAdditive(stop) ?: return null
+ left = formulaRow(listOf(left, formulaSymbol(relation), right))
+ }
+ return left
+ }
+
+ private fun parseAdditive(stop: Set): FormulaNode? {
+ var left = parseMultiplicative(stop) ?: return null
+ while (!failed && !atStop(stop)) {
+ skipSpaces()
+ val operator = when {
+ consume('+') -> "+"
+ peek() == '-' && !source.startsWith("->", index) -> {
+ consume('-')
+ "-"
+ }
+ consume('−') -> "−"
+ else -> null
+ } ?: break
+ val right = parseMultiplicative(stop) ?: return null
+ left = formulaRow(listOf(left, formulaSymbol(operator), right))
+ }
+ return left
+ }
+
+ private fun parseMultiplicative(stop: Set): FormulaNode? {
+ var left = parseUnary(stop) ?: return null
+ while (!failed && !atStop(stop)) {
+ skipSpaces()
+ when {
+ consume('*') -> {
+ val right = parseUnary(stop) ?: return null
+ left = formulaRow(listOf(left, FormulaNode.Symbol("×", "\\times"), right))
+ }
+
+ consume('·') || consume('×') || consume('⋅') -> {
+ val right = parseUnary(stop) ?: return null
+ val symbol = if (source[index - 1] == '×') "×" else "⋅"
+ val texName = if (symbol == "×") "\\times" else "\\cdot"
+ left = formulaRow(listOf(left, FormulaNode.Symbol(symbol, texName), right))
+ }
+
+ consume('/') || consume('÷') -> {
+ val right = parseUnary(stop) ?: return null
+ left = FormulaNode.Fraction(left, right)
+ }
+
+ peekIdentifier("mod") -> {
+ readIdentifier()
+ val right = parseUnary(stop) ?: return null
+ left = formulaRow(listOf(left, formulaSymbol("mod"), right))
+ }
+
+ startsPrimary() -> {
+ val right = parseUnary(stop) ?: return null
+ left = attachLargeOperatorOperand(left, right)
+ ?: formulaRow(listOf(left, right))
+ }
+
+ else -> return left
+ }
+ }
+ return left
+ }
+
+ private fun attachLargeOperatorOperand(
+ operator: FormulaNode,
+ operand: FormulaNode,
+ ): FormulaNode? {
+ if (operator !is FormulaNode.LargeOperator) return null
+ if (operator.lower == null && operator.upper == null) return null
+ if (operator.operand == null) return operator.copy(operand = operand)
+ if (operator.operand is FormulaNode.Symbol && operand is FormulaNode.Delimited) {
+ return operator.copy(
+ operand = formulaRow(listOf(operator.operand, operand)),
+ )
+ }
+ return null
+ }
+
+ private fun parseUnary(stop: Set): FormulaNode? {
+ skipSpaces()
+ return when {
+ consume('+') -> formulaRow(listOf(formulaSymbol("+"), parseUnary(stop) ?: return null))
+ consume('-') -> formulaRow(listOf(formulaSymbol("-"), parseUnary(stop) ?: return null))
+ consume('−') -> formulaRow(listOf(formulaSymbol("−"), parseUnary(stop) ?: return null))
+ else -> parsePower(stop)
+ }
+ }
+
+ private fun parsePower(stop: Set): FormulaNode? {
+ return parsePostfix(stop)
+ }
+
+ private fun parsePostfix(stop: Set): FormulaNode? {
+ var value = parsePrimary(stop) ?: return null
+ var subscript: FormulaNode? = null
+ var superscript: FormulaNode? = null
+ var subscriptWasParenthesized = false
+ var superscriptWasParenthesized = false
+ while (!failed) {
+ skipSpaces()
+ when {
+ consume('_') -> {
+ val (operand, parenthesized) = parseScriptOperand(stop) ?: return null
+ if (subscript != null) return null
+ subscript = operand
+ subscriptWasParenthesized = parenthesized
+ }
+
+ consume('^') -> {
+ val (operand, parenthesized) = parseScriptOperand(stop) ?: return null
+ if (superscript != null) return null
+ superscript = operand
+ superscriptWasParenthesized = parenthesized
+ }
+
+ consume('!') -> value = formulaRow(listOf(value, formulaSymbol("!")))
+ consume('%') -> value = formulaRow(listOf(value, formulaSymbol("%")))
+ else -> break
+ }
+ }
+ return if (subscript == null && superscript == null) {
+ value
+ } else if (value is FormulaNode.Symbol) {
+ simpleLargeOperatorName(value.value)?.let { name ->
+ FormulaNode.LargeOperator(
+ name = name,
+ lower = subscript,
+ upper = superscript,
+ )
+ } ?: FormulaNode.Script(
+ base = value,
+ subscript = subscript,
+ superscript = superscript,
+ subscriptWasParenthesized = subscriptWasParenthesized,
+ superscriptWasParenthesized = superscriptWasParenthesized,
+ )
+ } else {
+ FormulaNode.Script(
+ base = value,
+ subscript = subscript,
+ superscript = superscript,
+ subscriptWasParenthesized = subscriptWasParenthesized,
+ superscriptWasParenthesized = superscriptWasParenthesized,
+ )
+ }
+ }
+
+ private fun parsePrimary(stop: Set): FormulaNode? {
+ skipSpaces()
+ if (atEnd() || atStop(stop)) return null
+ if (consume('(')) {
+ if (!enterDepth()) return null
+ val content = parseRelation(setOf(')')) ?: return null
+ skipSpaces()
+ if (!consume(')')) return null
+ leaveDepth()
+ return FormulaNode.Delimited("(", content, ")")
+ }
+ if (consume('[')) {
+ if (!enterDepth()) return null
+ val content = parseRelation(setOf(']')) ?: return null
+ skipSpaces()
+ if (!consume(']')) return null
+ leaveDepth()
+ return FormulaNode.Delimited("[", content, "]")
+ }
+ if (peek() == '⌊') return parseSimpleDelimited('⌊', '⌋')
+ if (peek() == '⌈') return parseSimpleDelimited('⌈', '⌉')
+ if (consume('{')) {
+ if (!enterDepth()) return null
+ val content = parseRelation(setOf('}')) ?: return null
+ skipSpaces()
+ if (!consume('}')) return null
+ leaveDepth()
+ return content
+ }
+ if (source.startsWith("||", index)) return parseAsciiNorm()
+ if (peek() == '|') return parseAbsoluteValue()
+ if (peek() == '‖') return parseDoubleBarValue()
+ if (peek() == '√') {
+ consume('√')
+ val radicand = parseFunctionArgument(stop) ?: return null
+ return FormulaNode.Radical(
+ radicand = radicand,
+ radicandWasParenthesized = shouldParenthesizeScriptForUnicode(radicand),
+ )
+ }
+ if (peek()?.isDigit() == true || (peek() == '.' && source.getOrNull(index + 1)?.isDigit() == true)) {
+ return FormulaNode.Number(readNumber())
+ }
+ if (isIdentifierStart(peek())) {
+ val identifier = readIdentifier()
+ return parseIdentifier(identifier, stop)
+ }
+ val symbol = readSimpleSymbol() ?: return null
+ return formulaSymbol(symbol)
+ }
+
+ private fun parseIdentifier(identifier: String, stop: Set): FormulaNode? {
+ val lower = identifier.lowercase()
+ when (lower) {
+ "sqrt" -> {
+ val radicand = parseFunctionArgument(stop) ?: return null
+ return FormulaNode.Radical(
+ radicand = radicand,
+ radicandWasParenthesized = shouldParenthesizeScriptForUnicode(radicand),
+ )
+ }
+
+ "root", "nroot" -> {
+ val arguments = parseCommaArguments(expectedAtLeast = 2) ?: return null
+ return FormulaNode.Radical(arguments[1], index = arguments[0])
+ }
+
+ "sum", "sigma", "prod", "product", "int", "integral", "iint", "iiint", "oint" -> {
+ skipSpaces()
+ if (peek() != '(') return formulaSymbol(identifier)
+ val arguments = parseCommaArguments(expectedAtLeast = 3) ?: return null
+ val name = when (lower) {
+ "sigma" -> "sum"
+ "product" -> "prod"
+ "integral" -> "int"
+ else -> lower
+ }
+ return FormulaNode.LargeOperator(
+ name = name,
+ lower = arguments[0],
+ upper = arguments[1],
+ operand = formulaRow(arguments.drop(2)),
+ )
+ }
+
+ "lim", "limit" -> {
+ skipSpaces()
+ if (peek() != '(') return formulaSymbol(identifier)
+ val arguments = parseCommaArguments(expectedAtLeast = 2) ?: return null
+ return FormulaNode.LargeOperator(
+ name = "lim",
+ lower = arguments[0],
+ operand = formulaRow(arguments.drop(1)),
+ )
+ }
+
+ "min", "max" -> {
+ val arguments = parseCommaArguments(expectedAtLeast = 2)
+ ?: return null
+ return formulaRow(
+ listOf(
+ FormulaNode.Symbol(lower, "\\$lower"),
+ FormulaNode.Delimited("(", formulaRow(arguments), ")"),
+ )
+ )
+ }
+
+ "abs", "absolute" -> {
+ val argument = parseFunctionArgument(stop) ?: return null
+ return FormulaNode.Delimited("|", argument, "|")
+ }
+
+ "norm" -> {
+ val argument = parseFunctionArgument(stop) ?: return null
+ return FormulaNode.Delimited("‖", argument, "‖")
+ }
+
+ "floor", "ceil", "ceiling" -> {
+ val argument = parseFunctionArgument(stop) ?: return null
+ return FormulaNode.Delimited(
+ left = if (lower == "floor") "⌊" else "⌈",
+ content = argument,
+ right = if (lower == "floor") "⌋" else "⌉",
+ )
+ }
+
+ "vec", "vector" -> {
+ val argument = parseFunctionArgument(stop) ?: return null
+ return FormulaNode.Accent(AccentKind.VEC, argument)
+ }
+
+ "hat" -> {
+ val argument = parseFunctionArgument(stop) ?: return null
+ return FormulaNode.Accent(AccentKind.HAT, argument)
+ }
+
+ "bar", "overline" -> {
+ val argument = parseFunctionArgument(stop) ?: return null
+ return FormulaNode.Accent(AccentKind.BAR, argument)
+ }
+
+ "dot" -> {
+ val argument = parseFunctionArgument(stop) ?: return null
+ return FormulaNode.Accent(AccentKind.DOT, argument)
+ }
+
+ "ddot" -> {
+ val argument = parseFunctionArgument(stop) ?: return null
+ return FormulaNode.Accent(AccentKind.DDOT, argument)
+ }
+
+ "tilde" -> {
+ val argument = parseFunctionArgument(stop) ?: return null
+ return FormulaNode.Accent(AccentKind.TILDE, argument)
+ }
+
+ "underline" -> {
+ val argument = parseFunctionArgument(stop) ?: return null
+ return FormulaNode.Accent(AccentKind.UNDERLINE, argument)
+ }
+
+ "bold" -> {
+ val argument = parseFunctionArgument(stop) ?: return null
+ return FormulaNode.Accent(AccentKind.BOLD, argument)
+ }
+ }
+
+ val knownSymbol = simpleIdentifierSymbol(identifier)
+ if (knownSymbol != null) return knownSymbol
+
+ return formulaSymbol(identifier)
+ }
+
+ private fun parseFunctionArgument(stop: Set): FormulaNode? {
+ skipSpaces()
+ return when {
+ consume('(') -> {
+ if (!enterDepth()) return null
+ val content = parseRelation(setOf(')')) ?: return null
+ skipSpaces()
+ if (!consume(')')) return null
+ leaveDepth()
+ content
+ }
+
+ consume('{') -> {
+ if (!enterDepth()) return null
+ val content = parseRelation(setOf('}')) ?: return null
+ skipSpaces()
+ if (!consume('}')) return null
+ leaveDepth()
+ content
+ }
+
+ else -> {
+ if (!enterDepth()) return null
+ val value = parsePrimary(stop)
+ leaveDepth()
+ value
+ }
+ }
+ }
+
+ private fun parseSimpleDelimited(left: Char, right: Char): FormulaNode? {
+ if (!consume(left)) return null
+ if (!enterDepth()) return null
+ val content = parseRelation(setOf(right)) ?: return null
+ skipSpaces()
+ if (!consume(right)) return null
+ leaveDepth()
+ return FormulaNode.Delimited(left.toString(), content, right.toString())
+ }
+
+ private fun parseCommaArguments(expectedAtLeast: Int): List? {
+ skipSpaces()
+ if (!consume('(')) return null
+ if (!enterDepth()) return null
+ val arguments = mutableListOf()
+ while (true) {
+ val argument = parseRelation(setOf(',', ')')) ?: return null
+ arguments += argument
+ skipSpaces()
+ when {
+ consume(',') -> Unit
+ consume(')') -> break
+ else -> return null
+ }
+ if (arguments.size > maxTokens) return null
+ }
+ leaveDepth()
+ return arguments.takeIf { it.size >= expectedAtLeast }
+ }
+
+ private fun parseAbsoluteValue(): FormulaNode? {
+ consume('|')
+ if (!enterDepth()) return null
+ val content = parseRelation(setOf('|')) ?: return null
+ skipSpaces()
+ if (!consume('|')) return null
+ leaveDepth()
+ return FormulaNode.Delimited("|", content, "|")
+ }
+
+ private fun parseDoubleBarValue(): FormulaNode? {
+ consume('‖')
+ if (!enterDepth()) return null
+ val content = parseRelation(setOf('‖')) ?: return null
+ skipSpaces()
+ if (!consume('‖')) return null
+ leaveDepth()
+ return FormulaNode.Delimited("‖", content, "‖")
+ }
+
+ private fun parseAsciiNorm(): FormulaNode? {
+ if (!consume('|') || !consume('|')) return null
+ if (!enterDepth()) return null
+ val content = parseRelation(setOf('|')) ?: return null
+ skipSpaces()
+ if (!consume('|') || !consume('|')) return null
+ leaveDepth()
+ return FormulaNode.Delimited("‖", content, "‖")
+ }
+
+ private fun readNumber(): String {
+ countToken()
+ val start = index
+ var digits = 0
+ while (source.getOrNull(index)?.isDigit() == true) {
+ index++
+ digits++
+ }
+ if (source.getOrNull(index) == '.') {
+ index++
+ while (source.getOrNull(index)?.isDigit() == true) {
+ index++
+ digits++
+ }
+ }
+ if (digits == 0) failed = true
+ return source.substring(start, index)
+ }
+
+ private fun readIdentifier(): String {
+ countToken()
+ val start = index
+ while (isIdentifierPart(source.getOrNull(index))) index++
+ return source.substring(start, index)
+ }
+
+ private fun readSimpleSymbol(): String? {
+ val twoCharacter = source.substring(index).take(2)
+ val symbol = when {
+ twoCharacter == "<=" -> "≤"
+ twoCharacter == ">=" -> "≥"
+ twoCharacter == "!=" -> "≠"
+ twoCharacter == "==" -> "="
+ twoCharacter == "->" -> "→"
+ twoCharacter == "=>" -> "⇒"
+ else -> null
+ }
+ if (symbol != null) {
+ countToken()
+ index += 2
+ return symbol
+ }
+ val char = peek() ?: return null
+ if (char in "+-*/^_=<>%,!()[]{}|·×÷≤≥≠≈≃≡≢≅≍≰≱∼∝→⇒↔⇔←↦⟶⟹⟷⟺⟵⟼±∓⋅⊄⊅⊈⊉∈∉∋⊂⊆⊃⊇∪∩∀∃∧∨¬∅ℕℤℚℝℂ∞πΠ∑∏∫∬∭∮∂∇‖∥∣:;" ||
+ char.isLetter() || char == '√'
+ ) {
+ countToken()
+ index++
+ return when (char) {
+ '*' -> "×"
+ '|' -> "|"
+ else -> char.toString()
+ }
+ }
+ failed = true
+ return null
+ }
+
+ private fun readRelationOperator(): String? {
+ skipSpaces()
+ val remaining = source.substring(index)
+ val operator = when {
+ remaining.startsWith("<->") -> "↔"
+ remaining.startsWith("=>") -> "⇒"
+ remaining.startsWith("->") -> "→"
+ remaining.startsWith("<=") -> "≤"
+ remaining.startsWith(">=") -> "≥"
+ remaining.startsWith("!=") -> "≠"
+ remaining.startsWith("==") -> "="
+ peek() == '=' -> "="
+ peek() == '<' -> "<"
+ peek() == '>' -> ">"
+ peek() == '≤' -> "≤"
+ peek() == '≥' -> "≥"
+ peek() == '≠' -> "≠"
+ peek() == '≈' -> "≈"
+ peek() == '≃' -> "≃"
+ peek() == '≡' -> "≡"
+ peek() == '≢' -> "≢"
+ peek() == '≅' -> "≅"
+ peek() == '≍' -> "≍"
+ peek() == '≰' -> "≰"
+ peek() == '≱' -> "≱"
+ peek() == '∼' -> "∼"
+ peek() == '∝' -> "∝"
+ peek() == '→' -> "→"
+ peek() == '⇒' -> "⇒"
+ peek() == '↔' -> "↔"
+ peek() == '↦' -> "↦"
+ peek() == '⟶' -> "⟶"
+ peek() == '⟹' -> "⟹"
+ peek() == '⟷' -> "⟷"
+ peek() == '⟺' -> "⟺"
+ peek() == '⟵' -> "⟵"
+ peek() == '⟼' -> "⟼"
+ peek() == '⇔' -> "⇔"
+ peek() == '←' -> "←"
+ peek() == '∈' -> "∈"
+ peek() == '∉' -> "∉"
+ peek() == '∋' -> "∋"
+ peek() == '⊂' -> "⊂"
+ peek() == '⊆' -> "⊆"
+ peek() == '⊃' -> "⊃"
+ peek() == '⊇' -> "⊇"
+ peek() == '⊄' -> "⊄"
+ peek() == '⊅' -> "⊅"
+ peek() == '⊈' -> "⊈"
+ peek() == '⊉' -> "⊉"
+ peek() == '∪' -> "∪"
+ peek() == '∩' -> "∩"
+ peek() == '∧' -> "∧"
+ peek() == '∨' -> "∨"
+ peek() == '∥' -> "∥"
+ peek() == '∣' -> "∣"
+ peek() == '±' -> "±"
+ peek() == '∓' -> "∓"
+ else -> null
+ } ?: return null
+ countToken()
+ index += when {
+ remaining.startsWith("<->") -> 3
+ remaining.startsWith("=>") ||
+ remaining.startsWith("->") -> 2
+ remaining.startsWith("<=") || remaining.startsWith(">=") ||
+ remaining.startsWith("!=") || remaining.startsWith("==") -> 2
+ else -> 1
+ }
+ return operator
+ }
+
+ private fun simpleIdentifierSymbol(identifier: String): FormulaNode? {
+ val lower = identifier.lowercase()
+ val greek = GREEK_SYMBOLS[identifier] ?: GREEK_SYMBOLS[lower]
+ if (greek == null) return when (lower) {
+ "pi" -> FormulaNode.Symbol("π", "\\pi")
+ "infty", "infinity" -> FormulaNode.Symbol("∞", "\\infty")
+ "aleph" -> FormulaNode.Symbol("ℵ", "\\aleph")
+ "forall" -> FormulaNode.Symbol("∀", "\\forall")
+ "exists" -> FormulaNode.Symbol("∃", "\\exists")
+ "in" -> FormulaNode.Symbol("∈", "\\in")
+ "notin" -> FormulaNode.Symbol("∉", "\\notin")
+ "ni", "owns" -> FormulaNode.Symbol("∋", "\\ni")
+ "subset" -> FormulaNode.Symbol("⊂", "\\subset")
+ "subseteq" -> FormulaNode.Symbol("⊆", "\\subseteq")
+ "nsubset" -> FormulaNode.Symbol("⊄", "\\nsubset")
+ "nsubseteq" -> FormulaNode.Symbol("⊈", "\\nsubseteq")
+ "supset" -> FormulaNode.Symbol("⊃", "\\supset")
+ "supseteq" -> FormulaNode.Symbol("⊇", "\\supseteq")
+ "nsupset" -> FormulaNode.Symbol("⊅", "\\nsupset")
+ "nsupseteq" -> FormulaNode.Symbol("⊉", "\\nsupseteq")
+ "union", "cup" -> FormulaNode.Symbol("∪", "\\cup")
+ "intersect", "intersection", "cap" -> FormulaNode.Symbol("∩", "\\cap")
+ "and", "land", "wedge" -> FormulaNode.Symbol("∧", "\\land")
+ "or", "lor", "vee" -> FormulaNode.Symbol("∨", "\\lor")
+ "not", "neg", "lnot" -> FormulaNode.Symbol("¬", "\\neg")
+ "iff" -> FormulaNode.Symbol("⇔", "\\Leftrightarrow")
+ "implies" -> FormulaNode.Symbol("⇒", "\\Rightarrow")
+ "parallel" -> FormulaNode.Symbol("∥", "\\parallel")
+ "mid" -> FormulaNode.Symbol("∣", "\\mid")
+ "equiv" -> FormulaNode.Symbol("≡", "\\equiv")
+ "nequiv" -> FormulaNode.Symbol("≢", "\\not\\equiv")
+ "cong" -> FormulaNode.Symbol("≅", "\\cong")
+ "approx" -> FormulaNode.Symbol("≈", "\\approx")
+ "simeq" -> FormulaNode.Symbol("≃", "\\simeq")
+ "sim" -> FormulaNode.Symbol("∼", "\\sim")
+ "propto" -> FormulaNode.Symbol("∝", "\\propto")
+ "to", "rightarrow" -> FormulaNode.Symbol("→", "\\to")
+ "leftarrow" -> FormulaNode.Symbol("←", "\\leftarrow")
+ "mapsto" -> FormulaNode.Symbol("↦", "\\mapsto")
+ "emptyset", "empty" -> FormulaNode.Symbol("∅", "\\varnothing")
+ "natural", "naturals" -> FormulaNode.Symbol("ℕ", "\\mathbb{N}")
+ "integer", "integers" -> FormulaNode.Symbol("ℤ", "\\mathbb{Z}")
+ "rational", "rationals" -> FormulaNode.Symbol("ℚ", "\\mathbb{Q}")
+ "real", "reals" -> FormulaNode.Symbol("ℝ", "\\mathbb{R}")
+ "complex", "complexes" -> FormulaNode.Symbol("ℂ", "\\mathbb{C}")
+ "sin" -> FormulaNode.Symbol("sin", "\\sin")
+ "cos" -> FormulaNode.Symbol("cos", "\\cos")
+ "tan" -> FormulaNode.Symbol("tan", "\\tan")
+ "arcsin", "asin" -> FormulaNode.Symbol("arcsin", "\\arcsin")
+ "arccos", "acos" -> FormulaNode.Symbol("arccos", "\\arccos")
+ "arctan", "atan" -> FormulaNode.Symbol("arctan", "\\arctan")
+ "sinh" -> FormulaNode.Symbol("sinh", "\\sinh")
+ "cosh" -> FormulaNode.Symbol("cosh", "\\cosh")
+ "tanh" -> FormulaNode.Symbol("tanh", "\\tanh")
+ "sec" -> FormulaNode.Symbol("sec", "\\sec")
+ "csc" -> FormulaNode.Symbol("csc", "\\csc")
+ "cot" -> FormulaNode.Symbol("cot", "\\cot")
+ "arcsinh", "asinh" -> FormulaNode.Symbol("arcsinh", "\\operatorname{arcsinh}")
+ "arccosh", "acosh" -> FormulaNode.Symbol("arccosh", "\\operatorname{arccosh}")
+ "arctanh", "atanh" -> FormulaNode.Symbol("arctanh", "\\operatorname{arctanh}")
+ "ln" -> FormulaNode.Symbol("ln", "\\ln")
+ "log" -> FormulaNode.Symbol("log", "\\log")
+ "exp" -> FormulaNode.Symbol("exp", "\\exp")
+ "min" -> FormulaNode.Symbol("min", "\\min")
+ "max" -> FormulaNode.Symbol("max", "\\max")
+ "mod" -> FormulaNode.Symbol("mod", "\\bmod")
+ else -> null
+ }
+ val texIdentifier = if (GREEK_SYMBOLS.containsKey(identifier)) identifier else lower
+ return FormulaNode.Symbol(greek, "\\$texIdentifier")
+ }
+
+ private fun simpleLargeOperatorName(value: String): String? = when (value.lowercase()) {
+ "∑", "sum", "sigma" -> "sum"
+ "∏", "prod", "product" -> "prod"
+ "∫", "int", "integral" -> "int"
+ "∬", "iint" -> "iint"
+ "∭", "iiint" -> "iiint"
+ "∮", "oint" -> "oint"
+ "lim", "limit" -> "lim"
+ "min" -> "min"
+ "max" -> "max"
+ "sup" -> "sup"
+ "inf" -> "inf"
+ else -> null
+ }
+
+ private fun peekIdentifier(identifier: String): Boolean {
+ val start = index
+ if (!source.regionMatches(start, identifier, 0, identifier.length, ignoreCase = true)) {
+ return false
+ }
+ return !isIdentifierPart(source.getOrNull(start + identifier.length))
+ }
+
+ private fun startsPrimary(): Boolean {
+ skipSpaces()
+ val char = peek() ?: return false
+ return char == '(' || char == '[' || char == '{' || char == '|' || char == '‖' ||
+ char == '√' || char.isDigit() || char == '.' && source.getOrNull(index + 1)?.isDigit() == true ||
+ isIdentifierStart(char) || char in "πΠ∞∑∏∫∬∭∮∂∇∀∃∅ℕℤℚℝℂ"
+ }
+
+ private fun isIdentifierStart(char: Char?): Boolean =
+ char?.isLetter() == true || char?.let { it in "πΠ∞" } == true
+
+ private fun isIdentifierPart(char: Char?): Boolean =
+ char?.isLetterOrDigit() == true || char == 'π' || char == 'Π' || char == '∞'
+
+ private fun atStop(stop: Set): Boolean = peek()?.let(stop::contains) == true
+
+ private fun peek(): Char? = source.getOrNull(index)
+
+ private fun consume(expected: Char): Boolean {
+ if (peek() != expected) return false
+ countToken()
+ index++
+ return true
+ }
+
+ private fun skipSpaces() {
+ while (source.getOrNull(index)?.isWhitespace() == true) index++
+ }
+
+ private fun atEnd(): Boolean = index >= source.length
+
+ private fun countToken() {
+ tokenCount++
+ if (tokenCount > maxTokens) failed = true
+ }
+
+ private fun enterDepth(): Boolean {
+ depth++
+ if (depth > maxDepth) {
+ failed = true
+ return false
+ }
+ return true
+ }
+
+ private fun leaveDepth() {
+ depth = (depth - 1).coerceAtLeast(0)
+ }
+
+ private fun parseScriptOperand(stop: Set): Pair? {
+ skipSpaces()
+ return when {
+ consume('(') -> {
+ if (!enterDepth()) return null
+ val value = parseRelation(setOf(')')) ?: return null
+ skipSpaces()
+ if (!consume(')')) return null
+ leaveDepth()
+ value to shouldParenthesizeScriptForUnicode(value)
+ }
+
+ consume('{') -> {
+ if (!enterDepth()) return null
+ val value = parseRelation(setOf('}')) ?: return null
+ skipSpaces()
+ if (!consume('}')) return null
+ leaveDepth()
+ value to shouldParenthesizeScriptForUnicode(value)
+ }
+
+ // An unbraced script is one primary atom. Calling parsePostfix here would absorb
+ // the next ^/_ into the script itself (x_1^2 -> x_{1^2}).
+ else -> parsePrimary(stop)?.let { it to false }
+ }
+ }
+
+ private companion object {
+ val GREEK_SYMBOLS = mapOf(
+ "alpha" to "α", "beta" to "β", "gamma" to "γ", "delta" to "δ",
+ "epsilon" to "ϵ", "varepsilon" to "ε", "zeta" to "ζ", "eta" to "η",
+ "theta" to "θ", "vartheta" to "ϑ", "iota" to "ι", "kappa" to "κ",
+ "lambda" to "λ", "mu" to "μ", "nu" to "ν", "xi" to "ξ",
+ "omicron" to "ο", "pi" to "π", "varpi" to "ϖ", "rho" to "ρ",
+ "varrho" to "ϱ", "sigma" to "σ", "varsigma" to "ς", "tau" to "τ",
+ "upsilon" to "υ", "phi" to "ϕ", "varphi" to "φ", "chi" to "χ",
+ "psi" to "ψ", "omega" to "ω", "Gamma" to "Γ", "Delta" to "Δ",
+ "Theta" to "Θ", "Lambda" to "Λ", "Xi" to "Ξ", "Pi" to "Π",
+ "Sigma" to "Σ", "Upsilon" to "Υ", "Phi" to "Φ", "Psi" to "Ψ",
+ "Omega" to "Ω",
+ )
+ }
+}
+
+private class LatexFormulaParser(
+ private val source: String,
+ private val maxTokens: Int,
+ private val maxDepth: Int,
+) {
+ private var index = 0
+ private var tokenCount = 0
+ private var depth = 0
+ private var failed = false
+
+ fun parse(): FormulaNode? {
+ val result = parseRow(Stop.End) ?: return null
+ skipWhitespace()
+ return result.takeIf { !failed && index == source.length }
+ }
+
+ private fun parseRow(stop: Stop): FormulaNode? {
+ val children = mutableListOf()
+ while (!failed) {
+ skipWhitespace()
+ if (isStop(stop)) break
+ if (atEnd()) {
+ if (stop != Stop.End) failed = true
+ break
+ }
+ val atom = parseAtom() ?: return null
+ val scriptedAtom = applyScripts(atom) ?: return null
+ val previous = children.lastOrNull()
+ if (previous is FormulaNode.LargeOperator) {
+ val updatedOperator = attachLargeOperatorOperand(previous, scriptedAtom)
+ if (updatedOperator != null) {
+ children[children.lastIndex] = updatedOperator
+ continue
+ }
+ }
+ children += scriptedAtom
+ }
+ if (children.isEmpty()) return null
+ return formulaRow(children)
+ }
+
+ private fun attachLargeOperatorOperand(
+ operator: FormulaNode.LargeOperator,
+ operand: FormulaNode,
+ ): FormulaNode? {
+ if (operator.lower == null && operator.upper == null) return null
+ if (operator.operand == null) return operator.copy(operand = operand)
+ if (operator.operand is FormulaNode.Symbol && operand is FormulaNode.Delimited) {
+ return operator.copy(
+ operand = formulaRow(listOf(operator.operand, operand)),
+ )
+ }
+ return null
+ }
+
+ private fun parseAtom(): FormulaNode? {
+ if (atEnd()) return null
+ return when {
+ consume('{') -> parseBraceGroup()
+ peek() == '\\' -> parseCommand()
+ peek()?.isDigit() == true || (peek() == '.' && source.getOrNull(index + 1)?.isDigit() == true) ->
+ FormulaNode.Number(readNumber())
+ peek()?.isLetter() == true -> formulaSymbol(readIdentifier())
+ peek() == '(' -> parsePlainDelimited('(', ')')
+ peek() == '[' -> parsePlainDelimited('[', ']')
+ peek() == '|' -> {
+ consume('|')
+ FormulaNode.Symbol("|")
+ }
+ peek() == '‖' -> {
+ consume('‖')
+ FormulaNode.Symbol("‖", "\\Vert")
+ }
+ else -> readLatexSymbol()?.let(::formulaSymbol)
+ }
+ }
+
+ private fun parseCommand(): FormulaNode? {
+ if (!consume('\\')) return null
+ val command = readCommandName() ?: return null
+ return when (command) {
+ "frac", "dfrac", "tfrac" -> {
+ val numerator = parseRequiredGroup() ?: return null
+ val denominator = parseRequiredGroup() ?: return null
+ FormulaNode.Fraction(numerator, denominator)
+ }
+
+ "sqrt" -> {
+ val index = parseOptionalBracket()
+ val radicand = parseRequiredGroup() ?: return null
+ FormulaNode.Radical(
+ radicand = radicand,
+ index = index,
+ radicandWasParenthesized = shouldParenthesizeScriptForUnicode(radicand),
+ )
+ }
+
+ "left" -> parseSizedDelimited()
+ "lfloor" -> parsePairedCommandDelimited("⌊", "rfloor", "⌋")
+ "lceil" -> parsePairedCommandDelimited("⌈", "rceil", "⌉")
+ "lvert" -> parsePairedCommandDelimited("|", "rvert", "|")
+ "lVert" -> parsePairedCommandDelimited("‖", "rVert", "‖")
+ "langle" -> parsePairedCommandDelimited("⟨", "rangle", "⟩")
+ "lbrace" -> parsePairedCommandDelimited("{", "rbrace", "}")
+ "lbrack" -> parsePairedCommandDelimited("[", "rbrack", "]")
+ "lparen" -> parsePairedCommandDelimited("(", "rparen", ")")
+ "right", "middle", "begin", "end" -> null
+
+ "operatorname" -> {
+ val content = parseRequiredGroup() ?: return null
+ val text = FormulaFormatter.unicode(content)
+ FormulaNode.Symbol(text, "\\operatorname{$text}")
+ }
+
+ "not" -> {
+ val next = parseAtom() ?: return null
+ val nextUnicode = FormulaFormatter.unicode(next)
+ val unicode = when (nextUnicode) {
+ "=" -> "≠"
+ "∈" -> "∉"
+ "≡" -> "≢"
+ "⊂" -> "⊄"
+ "⊃" -> "⊅"
+ "⊆" -> "⊈"
+ "⊇" -> "⊉"
+ else -> "¬$nextUnicode"
+ }
+ FormulaNode.Symbol(unicode, "\\not${FormulaFormatter.tex(next)}")
+ }
+
+ "mathbb", "Bbb" -> parseNumberSet()
+ "mathbf", "boldsymbol" -> {
+ val content = parseRequiredGroup() ?: return null
+ FormulaNode.Accent(AccentKind.BOLD, content)
+ }
+
+ "mathcal", "mathsf", "mathtt", "mathit", "mathnormal" -> {
+ val content = parseRequiredGroup() ?: return null
+ val tex = FormulaFormatter.tex(content)
+ FormulaNode.Symbol(
+ FormulaFormatter.unicode(content),
+ "\\$command{$tex}",
+ )
+ }
+
+ "text", "mathrm" -> {
+ val content = parseRequiredGroup() ?: return null
+ val text = FormulaFormatter.unicode(content)
+ FormulaNode.Symbol(text, "\\$command{$text}")
+ }
+
+ in SPACING_COMMANDS -> FormulaNode.Symbol("")
+
+ in ACCENT_COMMANDS -> {
+ val content = parseRequiredGroup() ?: return null
+ FormulaNode.Accent(ACCENT_COMMANDS.getValue(command), content)
+ }
+
+ "{" -> FormulaNode.Symbol("{", "\\{")
+ "}" -> FormulaNode.Symbol("}", "\\}")
+ in LARGE_OPERATOR_COMMANDS -> FormulaNode.LargeOperator(command)
+ else -> LATEX_SYMBOLS[command]?.let { (unicode, tex) -> FormulaNode.Symbol(unicode, tex) }
+ }
+ }
+
+ private fun parseSizedDelimited(): FormulaNode? {
+ val left = readDelimiter() ?: return null
+ val content = parseRow(Stop.RightCommand) ?: return null
+ if (!peekCommand("right")) return null
+ consumeCommand("right")
+ val right = readDelimiter() ?: return null
+ return FormulaNode.Delimited(left, content, right, latexSized = true)
+ }
+
+ private fun parsePairedCommandDelimited(
+ left: String,
+ rightCommand: String,
+ right: String,
+ ): FormulaNode? {
+ val content = parseRow(Stop.CommandStop(rightCommand)) ?: return null
+ if (!peekCommand(rightCommand)) return null
+ consumeCommand(rightCommand)
+ return FormulaNode.Delimited(left, content, right)
+ }
+
+ private fun parseNumberSet(): FormulaNode? {
+ val content = parseRequiredGroup() ?: return null
+ val (symbol, asciiName) = when (FormulaFormatter.unicode(content)) {
+ "N", "ℕ" -> "ℕ" to "N"
+ "Z", "ℤ" -> "ℤ" to "Z"
+ "Q", "ℚ" -> "ℚ" to "Q"
+ "R", "ℝ" -> "ℝ" to "R"
+ "C", "ℂ" -> "ℂ" to "C"
+ else -> return FormulaNode.Accent(AccentKind.BOLD, content)
+ }
+ return FormulaNode.Symbol(symbol, "\\mathbb{$asciiName}")
+ }
+
+ private fun parsePlainDelimited(left: Char, right: Char): FormulaNode? {
+ if (!consume(left)) return null
+ if (!enterDepth()) return null
+ val content = parseRow(Stop.CharStop(right)) ?: return null
+ skipWhitespace()
+ if (!consume(right)) return null
+ leaveDepth()
+ return FormulaNode.Delimited(left.toString(), content, right.toString())
+ }
+
+ private fun parseBraceGroup(): FormulaNode? {
+ if (!enterDepth()) return null
+ val content = parseRow(Stop.CharStop('}')) ?: return null
+ skipWhitespace()
+ if (!consume('}')) return null
+ leaveDepth()
+ return content
+ }
+
+ private fun parseRequiredGroup(): FormulaNode? {
+ skipWhitespace()
+ if (!consume('{')) return null
+ return parseBraceGroup()
+ }
+
+ private fun parseOptionalBracket(): FormulaNode? {
+ skipWhitespace()
+ if (!consume('[')) return null
+ if (!enterDepth()) return null
+ val content = parseRow(Stop.CharStop(']')) ?: return null
+ skipWhitespace()
+ if (!consume(']')) return null
+ leaveDepth()
+ return content
+ }
+
+ private fun applyScripts(base: FormulaNode): FormulaNode? {
+ var value = base
+ var subscript: FormulaNode? = null
+ var superscript: FormulaNode? = null
+ var subscriptWasParenthesized = false
+ var superscriptWasParenthesized = false
+ while (!failed) {
+ skipWhitespace()
+ when {
+ consume('_') -> {
+ val (operand, parenthesized) = parseScriptArgument() ?: return null
+ if (subscript != null) return null
+ subscript = operand
+ subscriptWasParenthesized = parenthesized
+ }
+
+ consume('^') -> {
+ val (operand, parenthesized) = parseScriptArgument() ?: return null
+ if (superscript != null) return null
+ superscript = operand
+ superscriptWasParenthesized = parenthesized
+ }
+
+ else -> break
+ }
+ }
+ if (subscript == null && superscript == null) return value
+ if (value is FormulaNode.LargeOperator) {
+ return value.copy(lower = subscript, upper = superscript)
+ }
+ return FormulaNode.Script(
+ value,
+ subscript = subscript,
+ superscript = superscript,
+ subscriptWasParenthesized = subscriptWasParenthesized,
+ superscriptWasParenthesized = superscriptWasParenthesized,
+ )
+ }
+
+ private fun parseScriptArgument(): Pair? {
+ skipWhitespace()
+ return if (consume('{')) {
+ parseBraceGroup()?.let { it to shouldParenthesizeScriptForUnicode(it) }
+ } else {
+ parseAtom()?.let { atom ->
+ applyScripts(atom)?.let { it to false }
+ }
+ }
+ }
+
+ private fun readDelimiter(): String? {
+ skipWhitespace()
+ if (peek() == '\\') {
+ if (!consume('\\')) return null
+ val command = readCommandName() ?: return null
+ return when (command) {
+ "langle" -> "⟨"
+ "rangle" -> "⟩"
+ "lfloor" -> "⌊"
+ "rfloor" -> "⌋"
+ "lceil" -> "⌈"
+ "rceil" -> "⌉"
+ "lvert", "rvert", "vert" -> "|"
+ "Vert", "lVert", "rVert", "|" -> "‖"
+ "lbrace" -> "{"
+ "rbrace" -> "}"
+ "lbrack" -> "["
+ "rbrack" -> "]"
+ "lparen" -> "("
+ "rparen" -> ")"
+ "{", "}" -> command
+ else -> null
+ }
+ }
+ val char = peek() ?: return null
+ if (char in "([{|⟨⟩)]}‖⌊⌋⌈⌉") {
+ consume(char)
+ return char.toString()
+ }
+ if (char == '.') {
+ consume('.')
+ return ""
+ }
+ return null
+ }
+
+ private fun readLatexSymbol(): String? {
+ val char = peek() ?: return null
+ val symbol = when (char) {
+ '+', '-', '*', '/', '=', '<', '>', ',', '.', ':', ';', '!', '(', ')', '[', ']',
+ '|', '%', '&', '#', '~' -> char.toString()
+ '×', '÷', '≤', '≥', '≠', '≈', '∞', 'π', 'Π', '∂', '∇', '√',
+ '∑', '∏', '∫', '∬', '∭', '∮', '∼', '∝', '→', '⇒', '↔', '←', '↦',
+ '±', '∓', '⋅', '∈', '∉', '⊂', '⊆', '⊃', '⊇', '∪', '∩', '∀', '∃',
+ '∧', '∨', '¬', '∅', 'ℕ', 'ℤ', 'ℚ', 'ℝ', 'ℂ', 'ℵ', '‖', '∥', '∣' -> char.toString()
+ else -> return null.also { failed = true }
+ }
+ consume(char)
+ return symbol
+ }
+
+ private fun readNumber(): String {
+ countToken()
+ val start = index
+ while (source.getOrNull(index)?.isDigit() == true) index++
+ if (source.getOrNull(index) == '.') {
+ index++
+ while (source.getOrNull(index)?.isDigit() == true) index++
+ }
+ return source.substring(start, index)
+ }
+
+ private fun readIdentifier(): String {
+ countToken()
+ val start = index
+ while (source.getOrNull(index)?.isLetterOrDigit() == true) index++
+ return source.substring(start, index)
+ }
+
+ private fun readCommandName(): String? {
+ if (atEnd()) return null
+ val start = index
+ if (source[index].isLetter()) {
+ while (source.getOrNull(index)?.isLetter() == true) index++
+ } else {
+ index++
+ }
+ return source.substring(start, index).takeIf { it.isNotEmpty() }
+ }
+
+ private fun peekCommand(command: String): Boolean {
+ if (peek() != '\\') return false
+ var cursor = index + 1
+ while (source.getOrNull(cursor)?.isLetter() == true) cursor++
+ return source.substring(index + 1, cursor) == command
+ }
+
+ private fun consumeCommand(command: String): Boolean {
+ if (!peekCommand(command)) return false
+ consume('\\')
+ readCommandName()
+ return true
+ }
+
+ private fun skipWhitespace() {
+ while (source.getOrNull(index)?.isWhitespace() == true) index++
+ }
+
+ private fun isStop(stop: Stop): Boolean = when (stop) {
+ Stop.End -> false
+ is Stop.CharStop -> peek() == stop.char
+ Stop.RightCommand -> peekCommand("right")
+ is Stop.CommandStop -> peekCommand(stop.command)
+ }
+
+ private fun atEnd(): Boolean = index >= source.length
+
+ private fun peek(): Char? = source.getOrNull(index)
+
+ private fun consume(expected: Char): Boolean {
+ if (peek() != expected) return false
+ countToken()
+ index++
+ return true
+ }
+
+ private fun countToken() {
+ tokenCount++
+ if (tokenCount > maxTokens) failed = true
+ }
+
+ private fun enterDepth(): Boolean {
+ depth++
+ if (depth > maxDepth) {
+ failed = true
+ return false
+ }
+ return true
+ }
+
+ private fun leaveDepth() {
+ depth = (depth - 1).coerceAtLeast(0)
+ }
+
+ private sealed interface Stop {
+ data object End : Stop
+ data class CharStop(val char: Char) : Stop
+ data object RightCommand : Stop
+ data class CommandStop(val command: String) : Stop
+ }
+
+ private companion object {
+ val ACCENT_COMMANDS = mapOf(
+ "hat" to AccentKind.HAT,
+ "widehat" to AccentKind.HAT,
+ "bar" to AccentKind.BAR,
+ "overline" to AccentKind.OVERLINE,
+ "dot" to AccentKind.DOT,
+ "ddot" to AccentKind.DDOT,
+ "tilde" to AccentKind.TILDE,
+ "widetilde" to AccentKind.TILDE,
+ "vec" to AccentKind.VEC,
+ "underline" to AccentKind.UNDERLINE,
+ )
+
+ val LARGE_OPERATOR_COMMANDS = setOf(
+ "sum", "prod", "int", "iint", "iiint", "oint", "lim", "min", "max", "sup", "inf",
+ )
+
+ val LATEX_SYMBOLS = mapOf(
+ "pi" to ("π" to "\\pi"),
+ "Pi" to ("Π" to "\\Pi"),
+ "infty" to ("∞" to "\\infty"),
+ "emptyset" to ("∅" to "\\emptyset"),
+ "varnothing" to ("∅" to "\\varnothing"),
+ "pm" to ("±" to "\\pm"),
+ "mp" to ("∓" to "\\mp"),
+ "times" to ("×" to "\\times"),
+ "cdot" to ("⋅" to "\\cdot"),
+ "div" to ("÷" to "\\div"),
+ "le" to ("≤" to "\\leq"),
+ "leq" to ("≤" to "\\leq"),
+ "ge" to ("≥" to "\\geq"),
+ "geq" to ("≥" to "\\geq"),
+ "ne" to ("≠" to "\\neq"),
+ "neq" to ("≠" to "\\neq"),
+ "nleq" to ("≰" to "\\nleq"),
+ "ngeq" to ("≱" to "\\ngeq"),
+ "approx" to ("≈" to "\\approx"),
+ "simeq" to ("≃" to "\\simeq"),
+ "sim" to ("∼" to "\\sim"),
+ "propto" to ("∝" to "\\propto"),
+ "equiv" to ("≡" to "\\equiv"),
+ "nequiv" to ("≢" to "\\not\\equiv"),
+ "cong" to ("≅" to "\\cong"),
+ "asymp" to ("≍" to "\\asymp"),
+ "to" to ("→" to "\\to"),
+ "rightarrow" to ("→" to "\\rightarrow"),
+ "longrightarrow" to ("⟶" to "\\longrightarrow"),
+ "Rightarrow" to ("⇒" to "\\Rightarrow"),
+ "Longrightarrow" to ("⟹" to "\\Longrightarrow"),
+ "leftrightarrow" to ("↔" to "\\leftrightarrow"),
+ "Leftrightarrow" to ("⇔" to "\\Leftrightarrow"),
+ "iff" to ("⇔" to "\\Leftrightarrow"),
+ "longleftrightarrow" to ("⟷" to "\\longleftrightarrow"),
+ "Longleftrightarrow" to ("⟺" to "\\Longleftrightarrow"),
+ "leftarrow" to ("←" to "\\leftarrow"),
+ "longleftarrow" to ("⟵" to "\\longleftarrow"),
+ "mapsto" to ("↦" to "\\mapsto"),
+ "longmapsto" to ("⟼" to "\\longmapsto"),
+ "in" to ("∈" to "\\in"),
+ "notin" to ("∉" to "\\notin"),
+ "ni" to ("∋" to "\\ni"),
+ "owns" to ("∋" to "\\owns"),
+ "subset" to ("⊂" to "\\subset"),
+ "subseteq" to ("⊆" to "\\subseteq"),
+ "nsubseteq" to ("⊈" to "\\nsubseteq"),
+ "supset" to ("⊃" to "\\supset"),
+ "supseteq" to ("⊇" to "\\supseteq"),
+ "nsupseteq" to ("⊉" to "\\nsupseteq"),
+ "nsubset" to ("⊄" to "\\nsubset"),
+ "nsupset" to ("⊅" to "\\nsupset"),
+ "cup" to ("∪" to "\\cup"),
+ "cap" to ("∩" to "\\cap"),
+ "forall" to ("∀" to "\\forall"),
+ "exists" to ("∃" to "\\exists"),
+ "land" to ("∧" to "\\land"),
+ "wedge" to ("∧" to "\\wedge"),
+ "lor" to ("∨" to "\\lor"),
+ "vee" to ("∨" to "\\vee"),
+ "neg" to ("¬" to "\\neg"),
+ "lnot" to ("¬" to "\\lnot"),
+ "partial" to ("∂" to "\\partial"),
+ "nabla" to ("∇" to "\\nabla"),
+ "parallel" to ("∥" to "\\parallel"),
+ "mid" to ("∣" to "\\mid"),
+ "Vert" to ("‖" to "\\Vert"),
+ "vert" to ("|" to "|"),
+ "|" to ("‖" to "\\Vert"),
+ "sin" to ("sin" to "\\sin"),
+ "cos" to ("cos" to "\\cos"),
+ "tan" to ("tan" to "\\tan"),
+ "arcsin" to ("arcsin" to "\\arcsin"),
+ "arccos" to ("arccos" to "\\arccos"),
+ "arctan" to ("arctan" to "\\arctan"),
+ "asin" to ("arcsin" to "\\arcsin"),
+ "acos" to ("arccos" to "\\arccos"),
+ "atan" to ("arctan" to "\\arctan"),
+ "sec" to ("sec" to "\\sec"),
+ "csc" to ("csc" to "\\csc"),
+ "cot" to ("cot" to "\\cot"),
+ "sinh" to ("sinh" to "\\sinh"),
+ "cosh" to ("cosh" to "\\cosh"),
+ "tanh" to ("tanh" to "\\tanh"),
+ "log" to ("log" to "\\log"),
+ "ln" to ("ln" to "\\ln"),
+ "exp" to ("exp" to "\\exp"),
+ "min" to ("min" to "\\min"),
+ "max" to ("max" to "\\max"),
+ "bmod" to ("mod" to "\\bmod"),
+ "colon" to (":" to "\\colon"),
+ "aleph" to ("ℵ" to "\\aleph"),
+ "%" to ("%" to "\\%"),
+ "#" to ("#" to "\\#"),
+ ) + greekLatexSymbols()
+
+ val SPACING_COMMANDS = setOf(
+ " ", "!", ",", ":", ";", "quad", "qquad", "enspace", "thinspace",
+ "medspace", "thickspace",
+ )
+
+ private fun greekLatexSymbols(): Map> = mapOf(
+ "alpha" to ("α" to "\\alpha"), "beta" to ("β" to "\\beta"),
+ "gamma" to ("γ" to "\\gamma"), "delta" to ("δ" to "\\delta"),
+ "epsilon" to ("ϵ" to "\\epsilon"), "varepsilon" to ("ε" to "\\varepsilon"),
+ "zeta" to ("ζ" to "\\zeta"), "eta" to ("η" to "\\eta"),
+ "theta" to ("θ" to "\\theta"), "vartheta" to ("ϑ" to "\\vartheta"),
+ "iota" to ("ι" to "\\iota"), "kappa" to ("κ" to "\\kappa"),
+ "lambda" to ("λ" to "\\lambda"), "mu" to ("μ" to "\\mu"),
+ "nu" to ("ν" to "\\nu"), "xi" to ("ξ" to "\\xi"),
+ "omicron" to ("ο" to "\\omicron"), "rho" to ("ρ" to "\\rho"),
+ "varrho" to ("ϱ" to "\\varrho"), "sigma" to ("σ" to "\\sigma"),
+ "varsigma" to ("ς" to "\\varsigma"), "tau" to ("τ" to "\\tau"),
+ "upsilon" to ("υ" to "\\upsilon"), "phi" to ("ϕ" to "\\phi"),
+ "varphi" to ("φ" to "\\varphi"), "chi" to ("χ" to "\\chi"),
+ "psi" to ("ψ" to "\\psi"), "omega" to ("ω" to "\\omega"),
+ "Gamma" to ("Γ" to "\\Gamma"), "Delta" to ("Δ" to "\\Delta"),
+ "Theta" to ("Θ" to "\\Theta"), "Lambda" to ("Λ" to "\\Lambda"),
+ "Xi" to ("Ξ" to "\\Xi"), "Sigma" to ("Σ" to "\\Sigma"),
+ "Upsilon" to ("Υ" to "\\Upsilon"), "Phi" to ("Φ" to "\\Phi"),
+ "Psi" to ("Ψ" to "\\Psi"), "Omega" to ("Ω" to "\\Omega"),
+ )
+ }
+}
diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/utility/UtilityCandidateComposer.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/utility/UtilityCandidateComposer.kt
index 649c055d3..d647caff8 100644
--- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/utility/UtilityCandidateComposer.kt
+++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/utility/UtilityCandidateComposer.kt
@@ -1,6 +1,9 @@
package com.kazumaproject.markdownhelperkeyboard.converter.utility
+import com.kazumaproject.core.domain.extensions.isAllFullWidthNumericSymbol
import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_CALCULATION
+import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_FORMULA_TEX
+import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_FORMULA_UNICODE
import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_UNIT_CONVERSION
import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_UTILITY_LITERAL
import com.kazumaproject.markdownhelperkeyboard.converter.candidate.Candidate
@@ -19,15 +22,65 @@ object UtilityCandidateComposer {
UtilityCandidateKind.CALCULATION -> CANDIDATE_TYPE_CALCULATION
UtilityCandidateKind.UNIT_CONVERSION -> CANDIDATE_TYPE_UNIT_CONVERSION
UtilityCandidateKind.LITERAL -> CANDIDATE_TYPE_UTILITY_LITERAL
+ UtilityCandidateKind.FORMULA_UNICODE -> CANDIDATE_TYPE_FORMULA_UNICODE
+ UtilityCandidateKind.FORMULA_TEX -> CANDIDATE_TYPE_FORMULA_TEX
},
length = input.length.coerceAtMost(UByte.MAX_VALUE.toInt()).toUByte(),
score = 0,
yomi = input,
+ commitText = candidate.text,
+ presentation = candidate.formulaPresentation,
)
}
+ val formulaCandidates = utilityCandidates.filter { candidate ->
+ candidate.isFormulaCandidate()
+ }
+ val nonFormulaUtilityCandidates = utilityCandidates.filterNot { candidate ->
+ candidate.isFormulaCandidate()
+ }
val ordered = when (result.trigger) {
+ UtilityTrigger.FORMULA -> {
+ val source = input.trim()
+ val sourceIsCanonicalTex = formulaCandidates.any {
+ it.presentation?.normalizedTex == source
+ }
+ val originalInput = if (sourceIsCanonicalTex) {
+ null
+ } else {
+ existingCandidates.firstOrNull {
+ it.string == input || it.commitText == input ||
+ it.string == source || it.commitText == source
+ } ?: utilityLiteral(input, input)
+ }
+ val remainingCandidates = existingCandidates.filterNot { existing ->
+ existing === originalInput ||
+ (sourceIsCanonicalTex &&
+ (existing.string == input || existing.commitText == input ||
+ existing.string == source || existing.commitText == source))
+ }
+ val unitCandidates = nonFormulaUtilityCandidates.filter {
+ it.type == CANDIDATE_TYPE_UNIT_CONVERSION
+ } + remainingCandidates.filter {
+ it.type == CANDIDATE_TYPE_UNIT_CONVERSION
+ }
+ val otherUtilityCandidates = nonFormulaUtilityCandidates.filterNot {
+ it.type == CANDIDATE_TYPE_UNIT_CONVERSION
+ }
+ val normalCandidates = remainingCandidates.filterNot {
+ it.type == CANDIDATE_TYPE_UNIT_CONVERSION
+ }
+ val candidatesBeforeFormula = otherUtilityCandidates +
+ unitCandidates + normalCandidates + listOfNotNull(originalInput)
+ val fullWidthCandidates = candidatesBeforeFormula.filter {
+ it.isFullWidthCandidate()
+ }
+ candidatesBeforeFormula.filterNot { it.isFullWidthCandidate() } +
+ formulaCandidates + fullWidthCandidates
+ }
+
UtilityTrigger.EXPLICIT_CALCULATION,
- UtilityTrigger.EXPLICIT_UNIT_CONVERSION -> utilityCandidates + existingCandidates
+ UtilityTrigger.EXPLICIT_UNIT_CONVERSION ->
+ utilityCandidates + existingCandidates
UtilityTrigger.AUTOMATIC_UNIT_CONVERSION -> {
val inputLiteral = existingCandidates.firstOrNull { it.string == input }
@@ -39,10 +92,10 @@ object UtilityCandidateComposer {
?: utilityLiteral(text, input)
}
if (preferredSource == null) {
- listOf(inputLiteral) + utilityCandidates +
+ listOf(inputLiteral) + nonFormulaUtilityCandidates +
existingCandidates.filterNot { it === inputLiteral }
} else {
- listOf(preferredSource) + utilityCandidates + inputLiteral +
+ listOf(preferredSource) + nonFormulaUtilityCandidates + inputLiteral +
existingCandidates.filterNot {
it === preferredSource || it === inputLiteral
}
@@ -51,7 +104,42 @@ object UtilityCandidateComposer {
UtilityTrigger.NONE -> existingCandidates
}
- return ordered.distinctBy(Candidate::string)
+ return if (result.trigger == UtilityTrigger.FORMULA) {
+ distinctFormulaCandidates(ordered)
+ } else {
+ ordered.distinctBy(Candidate::string)
+ }
+ }
+
+ private fun Candidate.isFormulaCandidate(): Boolean =
+ type == CANDIDATE_TYPE_FORMULA_UNICODE || type == CANDIDATE_TYPE_FORMULA_TEX
+
+ /**
+ * Keep the engine's candidates carrying the visible [全] label after formula candidates.
+ * Type 21 is shared by symbol candidates, so only move it when its value is actually a
+ * full-width numeric/symbol string; this leaves superscript and other type-21 candidates in
+ * the ordinary group.
+ */
+ private fun Candidate.isFullWidthCandidate(): Boolean =
+ type == 22.toByte() ||
+ type == 30.toByte() ||
+ (type == 21.toByte() && string.isAllFullWidthNumericSymbol())
+
+ private fun distinctFormulaCandidates(candidates: List): List {
+ val emittedTexts = mutableSetOf()
+ val emittedFormulaTypes = mutableSetOf>()
+ return candidates.filter { candidate ->
+ if (candidate.isFormulaCandidate()) {
+ if (!emittedFormulaTypes.add(candidate.string to candidate.type)) {
+ false
+ } else {
+ emittedTexts.add(candidate.string)
+ true
+ }
+ } else {
+ emittedTexts.add(candidate.string)
+ }
+ }
}
private fun utilityLiteral(text: String, input: String) = Candidate(
@@ -66,6 +154,8 @@ object UtilityCandidateComposer {
CANDIDATE_TYPE_CALCULATION,
CANDIDATE_TYPE_UNIT_CONVERSION,
CANDIDATE_TYPE_UTILITY_LITERAL -> true
+ CANDIDATE_TYPE_FORMULA_UNICODE,
+ CANDIDATE_TYPE_FORMULA_TEX -> true
else -> false
}
diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/utility/UtilityCandidateProvider.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/utility/UtilityCandidateProvider.kt
index ec55f0c8d..fb96cb9fd 100644
--- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/utility/UtilityCandidateProvider.kt
+++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/utility/UtilityCandidateProvider.kt
@@ -5,6 +5,7 @@ import java.math.RoundingMode
class UtilityCandidateProvider(
private val calculationParser: CalculationParser = CalculationParser(),
+ private val formulaParser: FormulaParser = FormulaParser(),
private val unitRegistry: UnitRegistry = UnitRegistry.Default,
private val unitExpressionParser: UnitExpressionParser = UnitExpressionParser(unitRegistry),
) {
@@ -23,6 +24,17 @@ class UtilityCandidateProvider(
if (!config.calculationEnabled) return UtilityCandidateResult.Empty
return provideCalculation(calculation, config)
}
+ if (config.formulaCandidateEnabled && !looksLikeUnitExpression(normalized, config)) {
+ val formulaInput = UtilityInputNormalizer.normalizeForFormula(input)
+ formulaParser.parse(formulaInput)
+ ?.takeIf {
+ it.unicodeText != formulaInput.trim() ||
+ it.normalizedTex != formulaInput.trim()
+ }
+ ?.let { formula ->
+ return provideFormula(formula)
+ }
+ }
if (containsBoundaryEquals(normalized)) return UtilityCandidateResult.Empty
if (!config.unitConversionEnabled) return UtilityCandidateResult.Empty
return provideUnitConversion(normalized, config)
@@ -46,6 +58,49 @@ class UtilityCandidateProvider(
return trimmed.startsWith('=') || trimmed.endsWith('=') || '=' in trimmed
}
+ /** Keep quantities and conversion syntax on the existing unit-conversion path. */
+ private fun looksLikeUnitExpression(
+ input: String,
+ config: UtilityCandidateConfig,
+ ): Boolean {
+ val explicit = splitExplicitConversion(input)
+ if (explicit != null) {
+ val parsed = unitExpressionParser.parse(explicit.source, config.regionalUnitProfile)
+ if (parsed != null && unitRegistry.findExact(
+ explicit.target,
+ parsed.category,
+ config.regionalUnitProfile,
+ ) != null
+ ) {
+ return true
+ }
+ }
+
+ var offset = 0
+ while (offset < input.length) {
+ if (!input[offset].isDigit() && input[offset] != '.') {
+ offset++
+ continue
+ }
+ while (input.getOrNull(offset)?.isDigit() == true ||
+ input.getOrNull(offset)?.let { it == '.' || it == ',' } == true
+ ) {
+ offset++
+ }
+ while (input.getOrNull(offset)?.isWhitespace() == true) offset++
+ if (unitRegistry.matchAt(input, offset, profile = config.regionalUnitProfile) != null) {
+ return true
+ }
+ }
+ return false
+ }
+
+ private fun shouldUseFormattedCalculationExpression(expression: String): Boolean =
+ expression.any { it == '^' || it == '_' || it == '√' || it == '|' || it == '‖' } ||
+ expression.contains('\\') ||
+ Regex("(?i)\\b(?:sqrt|root|nroot|sum|sigma|prod|product|int|integral|lim|abs|norm|floor|ceil|hat|bar|vec)\\b")
+ .containsMatchIn(expression)
+
private fun provideCalculation(
trigger: CalculationTrigger,
config: UtilityCandidateConfig,
@@ -56,22 +111,85 @@ class UtilityCandidateProvider(
val candidates = buildList {
add(UtilityCandidate(result, UtilityCandidateKind.CALCULATION))
if (config.includeExpressionCandidate) {
- val displayExpression = trigger.expression
- .replace("*", "×")
- .replace("/", "÷")
+ val parsedFormula = if (config.formulaCandidateEnabled) {
+ formulaParser.parse(trigger.expression)
+ } else {
+ null
+ }
+ val useFormattedExpression = parsedFormula != null &&
+ shouldUseFormattedCalculationExpression(trigger.expression)
+ val displayExpression = parsedFormula
+ ?.takeIf { useFormattedExpression }
+ ?.unicodeText
+ ?: trigger.expression
+ .replace("*", "×")
+ .replace("/", "÷")
val expressionCandidate = if (trigger.prefix) {
"$result=$displayExpression"
} else {
"$displayExpression=$result"
}
if (expressionCandidate != result) {
- add(UtilityCandidate(expressionCandidate, UtilityCandidateKind.CALCULATION))
+ val formulaPresentation = parsedFormula
+ ?.takeIf { useFormattedExpression }
+ ?.let { formula ->
+ val expressionUnicode = if (trigger.prefix) {
+ "$result=${formula.unicodeText}"
+ } else {
+ "${formula.unicodeText}=$result"
+ }
+ val expressionTex = if (trigger.prefix) {
+ "$result=${formula.normalizedTex}"
+ } else {
+ "${formula.normalizedTex}=$result"
+ }
+ FormulaCandidatePresentation(
+ ast = formulaRow(
+ listOf(
+ FormulaNode.Number(result),
+ FormulaNode.Symbol("="),
+ formula.ast,
+ ).let { parts ->
+ if (trigger.prefix) parts else listOf(formula.ast, FormulaNode.Symbol("="), FormulaNode.Number(result))
+ }
+ ),
+ unicodeText = expressionUnicode,
+ normalizedTex = expressionTex,
+ type = FormulaCandidateType.UNICODE,
+ )
+ }
+ add(
+ UtilityCandidate(
+ text = expressionCandidate,
+ kind = UtilityCandidateKind.CALCULATION,
+ formulaPresentation = formulaPresentation,
+ )
+ )
}
}
}
return UtilityCandidateResult(candidates, UtilityTrigger.EXPLICIT_CALCULATION)
}
+ private fun provideFormula(formula: ParsedFormula): UtilityCandidateResult {
+ val candidates = listOf(
+ UtilityCandidate(
+ text = formula.unicodeText,
+ kind = UtilityCandidateKind.FORMULA_UNICODE,
+ formulaPresentation = formula.presentation(FormulaCandidateType.UNICODE),
+ ),
+ UtilityCandidate(
+ text = formula.normalizedTex,
+ kind = UtilityCandidateKind.FORMULA_TEX,
+ formulaPresentation = formula.presentation(FormulaCandidateType.TEX),
+ ),
+ )
+ return UtilityCandidateResult(
+ candidates = candidates,
+ trigger = UtilityTrigger.FORMULA,
+ )
+ }
+
private fun provideUnitConversion(
normalizedInput: String,
config: UtilityCandidateConfig,
diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/utility/UtilityInputNormalizer.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/utility/UtilityInputNormalizer.kt
index 1686fcac9..3f0871c10 100644
--- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/utility/UtilityInputNormalizer.kt
+++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/utility/UtilityInputNormalizer.kt
@@ -23,4 +23,23 @@ object UtilityInputNormalizer {
}
}
}
+
+ /**
+ * Normalizes full-width ASCII input for formula parsing without applying NFKC to mathematical
+ * compatibility characters such as ℕ, ℝ, and superscript glyphs. Those characters carry
+ * mathematical meaning and must reach the formula parser unchanged.
+ */
+ fun normalizeForFormula(input: String): String {
+ return buildString(input.length) {
+ input.forEach { char ->
+ when {
+ char in '\uFF01'..'\uFF5E' -> append((char.code - 0xFEE0).toChar())
+ char == '\u3000' || char == '\u00a0' ||
+ char in '\u2000'..'\u200a' || char == '\u202f' || char == '\u205f' ->
+ append(' ')
+ else -> append(char)
+ }
+ }
+ }
+ }
}
diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/utility/UtilityModels.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/utility/UtilityModels.kt
index a1303eb6b..cc785acce 100644
--- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/utility/UtilityModels.kt
+++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/converter/utility/UtilityModels.kt
@@ -62,6 +62,7 @@ data class UtilityCandidateConfig(
val calculationEnabled: Boolean = true,
val unitConversionEnabled: Boolean = true,
val includeExpressionCandidate: Boolean = true,
+ val formulaCandidateEnabled: Boolean = true,
val angleMode: AngleMode = AngleMode.DEGREES,
val calculationPrecision: Precision = Precision.Auto,
val regionalUnitProfile: RegionalUnitProfile = RegionalUnitProfile.JAPAN,
@@ -96,16 +97,24 @@ data class UtilityCandidateConfig(
enum class UtilityTrigger {
NONE,
+ FORMULA,
EXPLICIT_CALCULATION,
EXPLICIT_UNIT_CONVERSION,
AUTOMATIC_UNIT_CONVERSION,
}
-enum class UtilityCandidateKind { CALCULATION, UNIT_CONVERSION, LITERAL }
+enum class UtilityCandidateKind {
+ CALCULATION,
+ UNIT_CONVERSION,
+ LITERAL,
+ FORMULA_UNICODE,
+ FORMULA_TEX,
+}
data class UtilityCandidate(
val text: String,
val kind: UtilityCandidateKind,
+ val formulaPresentation: FormulaCandidatePresentation? = null,
)
data class UtilityCandidateResult(
diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/CandidateStripPresentationPolicy.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/CandidateStripPresentationPolicy.kt
index 57514e736..d2c778a58 100644
--- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/CandidateStripPresentationPolicy.kt
+++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/CandidateStripPresentationPolicy.kt
@@ -73,9 +73,11 @@ object CandidateStripPresentationPolicy {
reserveIndependentShortcutToolbarSpace =
shortcutPresentation.showIndependentToolbar && hideShortcutForCandidates,
showIntegratedShortcutItems =
- shortcutPresentation.showIntegratedShortcutItems && !hideShortcutForCandidates,
+ shortcutPresentation.showIntegratedShortcutItems &&
+ !hideShortcutForCandidates,
showIntegratedShortcutEntry =
- shortcutPresentation.showIntegratedShortcutEntry && !hideShortcutForCandidates
+ shortcutPresentation.showIntegratedShortcutEntry &&
+ !hideShortcutForCandidates
)
}
}
diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt
index d77cb5cd6..1eabb2b4b 100644
--- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt
+++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt
@@ -182,6 +182,8 @@ import com.kazumaproject.markdownhelperkeyboard.clipboard_history.database.ItemT
import com.kazumaproject.markdownhelperkeyboard.converter.candidate.BunsetsuCandidateResult
import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_ERA
import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_CALCULATION
+import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_FORMULA_TEX
+import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_FORMULA_UNICODE
import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_LEARNED_DICTIONARY
import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_TIME
import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_UNIT_CONVERSION
@@ -228,15 +230,18 @@ import com.kazumaproject.markdownhelperkeyboard.gemma.media.GemmaImagePickerActi
import com.kazumaproject.markdownhelperkeyboard.gemma.media.GemmaImeMediaPanelController
import com.kazumaproject.markdownhelperkeyboard.ime_service.adapters.FloatingCandidateListAdapter
import com.kazumaproject.markdownhelperkeyboard.ime_service.adapters.GridSpacingItemDecoration
+import com.kazumaproject.markdownhelperkeyboard.ime_service.adapters.InlineSuggestionStripState
import com.kazumaproject.markdownhelperkeyboard.ime_service.adapters.ShortcutAdapter
import com.kazumaproject.markdownhelperkeyboard.ime_service.adapters.SuggestionAdapter
import com.kazumaproject.markdownhelperkeyboard.ime_service.adapters.resolveCandidateEmptyPopupThemeColors
import com.kazumaproject.markdownhelperkeyboard.ime_service.autofill.InlineAutofillController
-import com.kazumaproject.markdownhelperkeyboard.ime_service.autofill.InlineSuggestionClipView
+import com.kazumaproject.markdownhelperkeyboard.ime_service.autofill.InlineSuggestionDisplayState
+import com.kazumaproject.markdownhelperkeyboard.ime_service.autofill.InlineSuggestionSurface
import com.kazumaproject.markdownhelperkeyboard.ime_service.autofill.InlineSuggestionsRequestFactory
import com.kazumaproject.markdownhelperkeyboard.ime_service.candidate.CandidateStripContent
import com.kazumaproject.markdownhelperkeyboard.ime_service.candidate.CandidateStripContentResolver
import com.kazumaproject.markdownhelperkeyboard.ime_service.candidate.CandidateStripInputState
+import com.kazumaproject.markdownhelperkeyboard.ime_service.candidate.InlineSuggestionToggle
import com.kazumaproject.markdownhelperkeyboard.ime_service.candidate.CandidateQueryModeResolver
import com.kazumaproject.markdownhelperkeyboard.ime_service.candidate.CandidateRefreshCoordinator
import com.kazumaproject.markdownhelperkeyboard.ime_service.candidate.CandidateRefreshRequest
@@ -403,7 +408,6 @@ import java.text.BreakIterator
import java.text.SimpleDateFormat
import java.util.ArrayDeque
import java.util.Calendar
-import java.util.IdentityHashMap
import java.util.Locale
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicLong
@@ -750,8 +754,9 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
private var suggestionAdapter: SuggestionAdapter? = null
private var suggestionAdapterFull: SuggestionAdapter? = null
private var inlineAutofillController: InlineAutofillController? = null
- private var inlineSuggestionsDisplayed = false
- private val inlineHostPreviousVisibility = IdentityHashMap>()
+ private var inlineSuggestionEnabled: Boolean = true
+ private val inlineSuggestionDisplayState = InlineSuggestionDisplayState()
+ private var currentInlineSuggestionViews: List = emptyList()
private var currentCandidateStripCandidates: List = emptyList()
private var currentCandidateStripFullCandidates: List = emptyList()
private var currentCandidateStripContent: CandidateStripContent = CandidateStripContent.Empty
@@ -777,13 +782,6 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
private var lastSuggestionLayoutKey: SuggestionLayoutKey? = null
private var mainSuggestionGridSpacingDecoration: RecyclerView.ItemDecoration? = null
- private data class InlineSuggestionHost(
- val clipView: InlineSuggestionClipView,
- val container: LinearLayout,
- val suggestionRecyclerView: RecyclerView,
- val suggestionVisibility: View,
- )
-
private data class ClipboardPreviewSnapshot(
val text: String,
val bitmap: Bitmap?,
@@ -824,6 +822,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
private lateinit var runtimeInputSharedPreferences: SharedPreferences
private var runtimeInputPreferenceListenerRegistered = false
private val runtimeInputPreferenceKeys = setOf(
+ AppPreference.INLINE_SUGGESTION_ENABLED_KEY,
AppPreference.FLICK_SENSITIVITY_KEY,
AppPreference.FLICK_THRESHOLD_SHAPE_KEY,
AppPreference.FLICK_EDITOR_PREVIEW_KEY,
@@ -845,6 +844,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
AppPreference.UTILITY_CALCULATION_ENABLED_KEY,
AppPreference.UTILITY_UNIT_CONVERSION_ENABLED_KEY,
AppPreference.UTILITY_EXPRESSION_CANDIDATE_ENABLED_KEY,
+ AppPreference.UTILITY_FORMULA_CANDIDATE_ENABLED_KEY,
AppPreference.UTILITY_ANGLE_MODE_KEY,
AppPreference.UTILITY_CALCULATION_PRECISION_KEY,
AppPreference.UTILITY_REGIONAL_PROFILE_KEY,
@@ -1002,15 +1002,25 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
val fullContent = resolveCandidateStripContent(
candidates = currentCandidateStripFullCandidates,
candidatesShown = effectiveCandidatesShown,
- includeZeroQuery = false
+ includeZeroQuery = false,
+ includeInlineSuggestionToggle = false,
)
currentCandidateStripContent = content
+ val inlineSuggestionState = InlineSuggestionStripState(
+ views = currentInlineSuggestionViews,
+ showInlineSuggestions =
+ inlineSuggestionDisplayState.surface == InlineSuggestionSurface.Inline &&
+ currentInlineSuggestionViews.isNotEmpty(),
+ toggle = inlineSuggestionToggleForCandidateStrip(),
+ )
+ suggestionAdapter?.submitContent(content, inlineSuggestionState)
if (isKeyboardFloatingMode != true) {
mainLayoutBinding?.let { binding ->
setMainSuggestionColumn(binding)
}
+ } else {
+ setFloatingSuggestionColumn()
}
- suggestionAdapter?.submitContent(content)
// The full candidate view is hidden during normal composing. Submitting to its
// AsyncListDiffer on every keystroke still calculates a complete DiffUtil diff even
// though the user cannot see it. Keep the state current, but submit only when that
@@ -1024,7 +1034,6 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
content = content
)
applyCandidateStripPresentation(presentation)
- enforceInlineSuggestionVisibility()
}
private fun isFullCandidateViewVisible(): Boolean {
@@ -1038,14 +1047,47 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
private fun resolveCandidateStripContent(
candidates: List,
candidatesShown: Boolean,
- includeZeroQuery: Boolean
+ includeZeroQuery: Boolean,
+ includeInlineSuggestionToggle: Boolean = true,
): CandidateStripContent {
val state = buildCandidateStripInputState(
candidates = candidates,
candidatesShown = candidatesShown,
- includeZeroQuery = includeZeroQuery
+ includeZeroQuery = includeZeroQuery,
+ )
+ return CandidateStripContentResolver.resolve(
+ if (includeInlineSuggestionToggle) {
+ state
+ } else {
+ state.copy(inlineSuggestionToggle = null)
+ }
+ )
+ }
+
+ private fun inlineSuggestionToggleForCandidateStrip(): InlineSuggestionToggle? {
+ if (!inlineSuggestionEnabled || !inlineSuggestionDisplayState.hasSuggestions) {
+ return null
+ }
+ val contentDescription = when (inlineSuggestionDisplayState.surface) {
+ InlineSuggestionSurface.Inline ->
+ R.string.inline_suggestion_show_normal_candidates_content_description
+
+ InlineSuggestionSurface.NormalCandidates ->
+ R.string.inline_suggestion_show_inline_candidates_content_description
+ }
+ return InlineSuggestionToggle(
+ contentDescription = getString(contentDescription),
+ badge = null,
+ iconResId = when (inlineSuggestionDisplayState.surface) {
+ InlineSuggestionSurface.Inline -> R.drawable.more_horiz_24px
+ InlineSuggestionSurface.NormalCandidates -> R.drawable.inline_suggestion_key_24
+ },
+ iconBackgroundResId = when (inlineSuggestionDisplayState.surface) {
+ InlineSuggestionSurface.Inline -> null
+ InlineSuggestionSurface.NormalCandidates ->
+ com.kazumaproject.core.R.drawable.suggestion_icon_bg
+ },
)
- return CandidateStripContentResolver.resolve(state)
}
private fun buildCandidateStripInputState(
@@ -1097,6 +1139,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
shortcutToolbarIntegratedInSuggestion = shortcutToolbarIntegratedInSuggestion == true,
integratedShortcutEntryExpanded = integratedShortcutEntryExpanded,
shortcutItems = currentShortcutItems,
+ inlineSuggestionToggle = inlineSuggestionToggleForCandidateStrip(),
)
}
@@ -1357,7 +1400,13 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
isContinuousTapInputEnabled.set(true)
lastFlickConvertedNextHiragana.set(true)
if (!hasConvertedKatakana) {
- if (candidate != null && candidate.type != CANDIDATE_TYPE_TEXT_MACRO) {
+ if (
+ candidate != null &&
+ candidate.type != CANDIDATE_TYPE_TEXT_MACRO &&
+ candidate.type != CANDIDATE_TYPE_FORMULA_UNICODE &&
+ candidate.type != CANDIDATE_TYPE_FORMULA_TEX &&
+ candidate.presentation == null
+ ) {
applyFirstSuggestion(candidate)
} else {
applyRawComposingFallback(insertString)
@@ -2328,9 +2377,10 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
originalInput = inputString.value,
selectedCandidateLength = suggestion.length.toInt()
)
+ val commitWord = suggestion.formulaFallbackText ?: suggestion.word
stringInTail.set(tail)
if (tail.isNotEmpty()) {
- commitText(suggestion.word, 1)
+ commitText(commitWord, 1)
finishComposingText()
updateSuggestionsForFloatingCandidate(emptyList())
_inputString.update { tail }
@@ -2341,10 +2391,10 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
floatingCandidateNextItem(insertString = tail)
}
} else {
- if (suggestion.word.isNotBlank()) {
- rememberZeroQueryKeyAfterCommit(suggestion.word)
+ if (commitWord.isNotBlank()) {
+ rememberZeroQueryKeyAfterCommit(commitWord)
}
- commitText(suggestion.word, 1)
+ commitText(commitWord, 1)
finishComposingText()
updateSuggestionsForFloatingCandidate(emptyList())
_inputString.update { "" }
@@ -2499,19 +2549,18 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
startScope(mainView)
}
}
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
- inlineAutofillController?.onHostChanged()
- }
return keyboardContainer
}
@RequiresApi(Build.VERSION_CODES.R)
- override fun onCreateInlineSuggestionsRequest(uiExtras: Bundle): InlineSuggestionsRequest {
+ override fun onCreateInlineSuggestionsRequest(uiExtras: Bundle): InlineSuggestionsRequest? {
+ if (!inlineSuggestionEnabled) return null
return InlineSuggestionsRequestFactory.create(this)
}
@RequiresApi(Build.VERSION_CODES.R)
override fun onInlineSuggestionsResponse(response: InlineSuggestionsResponse): Boolean {
+ if (!inlineSuggestionEnabled) return false
return inlineAutofillController?.handleResponse(response) ?: false
}
@@ -2601,7 +2650,21 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
private fun syncRuntimeInputPreferences() {
assertMainThread("syncRuntimeInputPreferences")
+ val previousInlineSuggestionEnabled = inlineSuggestionEnabled
+ applyInlineSuggestionEnabled(appPreference.inline_suggestion_enabled_preference)
+ if (previousInlineSuggestionEnabled != inlineSuggestionEnabled) {
+ refreshShortcutAvailability()
+ }
+
+ val previousUtilityCandidateConfig = utilityCandidateConfig
utilityCandidateConfig = appPreference.utility_candidate_config
+ if (
+ isInputViewActive &&
+ previousUtilityCandidateConfig != utilityCandidateConfig &&
+ inputString.value.isNotEmpty()
+ ) {
+ requestCandidateRefresh(CandidateShowFlag.Updating)
+ }
val sensitivity = (appPreference.flick_sensitivity_preference ?: 100).coerceIn(1, 200)
val thresholdShape = FlickThresholdShape.fromPreferenceValue(
@@ -2783,6 +2846,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
qwertyRomajiHankakuSymbolPreference = preferences.qwertyRomajiHankakuSymbolPreference
qwertyShowKutoutenButtonsPreference = preferences.qwertyShowKutoutenButtonsPreference
showCandidateInPasswordPreference = preferences.showCandidateInPasswordPreference
+ applyInlineSuggestionEnabled(preferences.inlineSuggestionEnabled)
qwertyShowKeymapSymbolsPreference = preferences.qwertyShowKeymapSymbolsPreference
qwertyRomajiShiftConversionPreference = preferences.qwertyRomajiShiftConversionPreference
isNgWordEnable = preferences.isNgWordEnable
@@ -4154,117 +4218,74 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
floatingView?.suggestionRecyclerView?.adapter = null
floatingView?.candidatesRowView?.adapter = null
}
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
- inlineAutofillController?.onHostChanged()
- }
- }
-
- private fun inlineSuggestionHosts(): List = buildList {
- mainLayoutBinding?.let { binding ->
- add(
- InlineSuggestionHost(
- clipView = binding.inlineSuggestionsClip,
- container = binding.inlineSuggestionsContainer,
- suggestionRecyclerView = binding.suggestionRecyclerView,
- suggestionVisibility = binding.suggestionVisibility,
- )
- )
- }
- floatingKeyboardBinding?.let { binding ->
- add(
- InlineSuggestionHost(
- clipView = binding.inlineSuggestionsClip,
- container = binding.inlineSuggestionsContainer,
- suggestionRecyclerView = binding.suggestionRecyclerView,
- suggestionVisibility = binding.suggestionVisibility,
- )
- )
+ lastSuggestionLayoutKey = null
+ if (isFloatingMode) {
+ setFloatingSuggestionColumn()
}
}
- private fun activeInlineSuggestionHost(): InlineSuggestionHost? {
- return if (isKeyboardFloatingMode == true) {
- floatingKeyboardBinding?.let { binding ->
- InlineSuggestionHost(
- clipView = binding.inlineSuggestionsClip,
- container = binding.inlineSuggestionsContainer,
- suggestionRecyclerView = binding.suggestionRecyclerView,
- suggestionVisibility = binding.suggestionVisibility,
- )
- }
- } else {
- mainLayoutBinding?.let { binding ->
- InlineSuggestionHost(
- clipView = binding.inlineSuggestionsClip,
- container = binding.inlineSuggestionsContainer,
- suggestionRecyclerView = binding.suggestionRecyclerView,
- suggestionVisibility = binding.suggestionVisibility,
- )
+ private fun applyInlineSuggestionEnabled(enabled: Boolean) {
+ if (inlineSuggestionEnabled == enabled) return
+ inlineSuggestionEnabled = enabled
+ if (!enabled) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
+ inlineAutofillController?.clear()
}
+ currentInlineSuggestionViews = emptyList()
+ inlineSuggestionDisplayState.updateAvailability(false)
+ updateShortcutActiveStates()
+ refreshCandidateStripContent()
}
}
@RequiresApi(Build.VERSION_CODES.R)
private fun renderInlineSuggestionViews(views: List) {
assertMainThread("renderInlineSuggestionViews")
- clearInlineSuggestionHosts(restoreNativeVisibility = true)
- if (views.isEmpty()) return
-
- val host = activeInlineSuggestionHost() ?: return
- Timber.d("Rendering ${views.size} inline suggestion views")
- host.clipView.setBackgroundColor(
- ContextCompat.getColor(this, com.kazumaproject.core.R.color.keyboard_bg)
- )
- inlineHostPreviousVisibility[host.clipView] =
- host.suggestionRecyclerView.isVisible to host.suggestionVisibility.isVisible
+ if (!inlineSuggestionEnabled) {
+ currentInlineSuggestionViews = emptyList()
+ inlineSuggestionDisplayState.updateAvailability(false)
+ updateShortcutActiveStates()
+ refreshCandidateStripContent()
+ return
+ }
+ currentInlineSuggestionViews = views
views.forEach { view ->
- (view.parent as? ViewGroup)?.removeView(view)
view.setZOrderedOnTop(true)
- val frameworkWidth = view.layoutParams?.width
- ?.takeIf { it > 0 }
- ?: ViewGroup.LayoutParams.WRAP_CONTENT
- val frameworkHeight = view.layoutParams?.height
- ?.takeIf { it > 0 }
- ?: ViewGroup.LayoutParams.MATCH_PARENT
- host.container.addView(
- view,
- LinearLayout.LayoutParams(
- frameworkWidth,
- frameworkHeight,
- ).apply {
- val margin = (4 * resources.displayMetrics.density).toInt()
- marginStart = margin
- marginEnd = margin
- }
- )
+ view.clipBounds = null
}
- inlineSuggestionsDisplayed = true
- enforceInlineSuggestionVisibility()
- Timber.d(
- "Inline suggestion host visible=${host.clipView.isVisible} " +
- "children=${host.container.childCount}"
- )
+ inlineSuggestionDisplayState.updateAvailability(views.isNotEmpty())
+ updateShortcutActiveStates()
+ Timber.d("Rendering ${views.size} inline suggestion views")
+ refreshCandidateStripContent()
}
- private fun clearInlineSuggestionHosts(restoreNativeVisibility: Boolean) {
- inlineSuggestionHosts().forEach { host ->
- host.container.removeAllViews()
- host.clipView.isVisible = false
- val previous = inlineHostPreviousVisibility.remove(host.clipView)
- if (restoreNativeVisibility && previous != null) {
- host.suggestionRecyclerView.isVisible = previous.first
- host.suggestionVisibility.isVisible = previous.second
- }
+ private fun toggleInlineSuggestionSurface() {
+ if (!inlineSuggestionEnabled) return
+ if (!inlineSuggestionDisplayState.toggleSurface()) return
+ refreshCandidateStripContent()
+ if (isKeyboardFloatingMode == true) {
+ floatingKeyboardBinding?.suggestionRecyclerView?.scrollToPosition(0)
+ } else {
+ mainLayoutBinding?.suggestionRecyclerView?.scrollToPosition(0)
}
- inlineSuggestionsDisplayed = false
+ updateShortcutActiveStates()
}
- private fun enforceInlineSuggestionVisibility() {
- if (!inlineSuggestionsDisplayed) return
- val activeHost = activeInlineSuggestionHost() ?: return
- activeHost.clipView.isVisible = true
- activeHost.suggestionRecyclerView.isVisible = false
- activeHost.suggestionVisibility.isVisible = false
+ private fun setFloatingSuggestionColumn() {
+ val recyclerView = floatingKeyboardBinding?.suggestionRecyclerView ?: return
+ if (suggestionAdapter?.isInlineSuggestionStripShown() == true) {
+ val layoutManager = recyclerView.layoutManager as? LinearLayoutManager
+ if (layoutManager?.orientation != LinearLayoutManager.HORIZONTAL) {
+ recyclerView.layoutManager =
+ LinearLayoutManager(this, LinearLayoutManager.HORIZONTAL, false)
+ recyclerView.scrollToPosition(0)
+ }
+ } else if (recyclerView.layoutManager !is FlexboxLayoutManager) {
+ recyclerView.layoutManager = FlexboxLayoutManager(applicationContext).apply {
+ flexDirection = FlexDirection.COLUMN
+ }
+ recyclerView.scrollToPosition(0)
+ }
}
private fun updateFloatingKeyboardBackgroundBounds(
@@ -4652,8 +4673,11 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
}
clearZenzLiveSlot("onStartInputView")
setSuggestionAdapterSuggestionsOnMain(emptyList())
- suggestionAdapter?.setCandidateTextSize(appPreference.candidate_letter_size ?: 14.0f)
- suggestionAdapterFull?.setCandidateTextSize(appPreference.candidate_letter_size ?: 14.0f)
+ val candidateTextSize = appPreference.candidate_letter_size ?: 14.0f
+ suggestionAdapter?.setCandidateTextSize(candidateTextSize)
+ suggestionAdapterFull?.setCandidateTextSize(candidateTextSize)
+ listAdapter.setCandidateTextSize(candidateTextSize)
+ listAdapter.setCandidateTextColor(resolveFloatingCandidateTextColor())
suggestionClickNum = 0
setCurrentInputType(editorInfo)
suggestionAdapter?.setClipboardDescriptionTextVisibility(
@@ -5857,6 +5881,17 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
}
}
+ private fun resolveFloatingCandidateTextColor(): Int? {
+ return if (keyboardThemeMode == "custom") {
+ customThemeCandidateTextColor ?: Color.BLACK
+ } else {
+ // Let FormulaViewHolder resolve the color from its popup context. The popup is
+ // themed separately from the service and therefore has the correct night-mode
+ // resource even when the service's base context does not.
+ null
+ }
+ }
+
private fun setupKeyboardView() {
Timber.d("setupKeyboardView: Called")
val isDynamicColorsEnable = DynamicColors.isDynamicColorAvailable()
@@ -6057,10 +6092,9 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
?: ContextCompat.getColor(
this@IMEService,
com.kazumaproject.core.R.color.qwety_key_bg_color
- )
+ )
)
}
-
root.setDrawableSolidColor(customThemeBgColor ?: Color.WHITE)
suggestionViewParent.setDrawableSolidColor(
customThemeBgColor ?: Color.WHITE
@@ -6107,6 +6141,10 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
}
}
}
+ // The physical-keyboard candidate popup is rendered in a separate
+ // PopupWindow, so it does not inherit the candidate-strip TextView color.
+ // Keep its formula renderer in sync with the active keyboard theme.
+ listAdapter.setCandidateTextColor(resolveFloatingCandidateTextColor())
applyCandidateEmptyPopupThemeToAdapters()
mainView.root.outlineProvider = ViewOutlineProvider.BACKGROUND
mainView.root.clipToOutline = isKeyboardRounded == true
@@ -7364,11 +7402,12 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
originalInput = insertString,
selectedCandidateLength = selectedSuggestion.length.toInt()
)
+ val commitWord = selectedSuggestion.formulaFallbackText ?: selectedSuggestion.word
stringInTail.set(tail)
- Timber.d("displayComposingTextInHardwareKeyboardConnected: ${selectedSuggestion.word} ${selectedSuggestion.length} $insertString $tail ${insertString.length} ${selectedSuggestion.length.toInt()}")
- val spannableString = SpannableString(selectedSuggestion.word + tail)
+ Timber.d("displayComposingTextInHardwareKeyboardConnected: $commitWord ${selectedSuggestion.length} $insertString $tail ${insertString.length} ${selectedSuggestion.length.toInt()}")
+ val spannableString = SpannableString(commitWord + tail)
setComposingTextAfterEdit(
- inputString = selectedSuggestion.word,
+ inputString = commitWord,
spannableString = spannableString,
backgroundColor = if (customComposingTextPreference == true) {
inputCompositionAfterBackgroundColor ?: getColor(
@@ -7395,8 +7434,9 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
return
}
val subString = stringInTail.get()
+ val commitWord = selectedSuggestion.formulaFallbackText ?: selectedSuggestion.word
if (subString.isNotEmpty()) {
- commitText(selectedSuggestion.word, 1)
+ commitText(commitWord, 1)
updateSuggestionsForFloatingCandidate(emptyList())
_inputString.update { subString }
listAdapter.updateHighlightPosition(-1)
@@ -7406,10 +7446,10 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
floatingCandidateNextItem(insertString = subString)
}
} else {
- if (selectedSuggestion.word.isNotBlank()) {
- rememberZeroQueryKeyAfterCommit(selectedSuggestion.word)
+ if (commitWord.isNotBlank()) {
+ rememberZeroQueryKeyAfterCommit(commitWord)
}
- commitText(selectedSuggestion.word, 1)
+ commitText(commitWord, 1)
updateSuggestionsForFloatingCandidate(emptyList())
_inputString.update { "" }
listAdapter.updateHighlightPosition(-1)
@@ -16167,12 +16207,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
if (!suppressSuggestions) {
updateFloatingCandidatesOnMain(
candidates = displayedCandidates.map {
- CandidateItem(
- word = it.string,
- length = it.length,
- candidateType = it.type,
- sourceId = it.sourceId,
- )
+ it.toFloatingCandidateItem()
},
insertString = insertString
)
@@ -16203,6 +16238,17 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
result = utilityCandidateProvider.provide(input, utilityCandidateConfig),
)
+ private fun Candidate.toFloatingCandidateItem(
+ displayWord: String = string,
+ ): CandidateItem = CandidateItem(
+ word = displayWord,
+ length = length,
+ candidateType = type,
+ sourceId = sourceId,
+ formulaSource = presentation?.normalizedTex,
+ formulaFallbackText = commitText,
+ )
+
private fun candidateForAutomaticApplication(
input: String,
candidate: Candidate?,
@@ -17414,7 +17460,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
return if (candidate.type == (15).toByte()) {
candidate.string.correctReading().first
} else {
- candidate.string
+ candidate.commitText
}
}
@@ -17647,7 +17693,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
return if (candidate.type == (15).toByte()) {
candidate.string.correctReading().first
} else {
- candidate.string
+ candidate.presentation?.unicodeText ?: candidate.string
}
}
@@ -17893,12 +17939,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
physicalKeyboardEnable.replayCache.first()
) {
updateSuggestionsForFloatingCandidate(segment.candidates.map {
- CandidateItem(
- word = displayTextFromCandidate(it),
- length = it.length,
- candidateType = it.type,
- sourceId = it.sourceId,
- )
+ it.toFloatingCandidateItem(displayTextFromCandidate(it))
}, highlightedAbsoluteIndex = segmentHighlightIndex)
}
}
@@ -18889,6 +18930,9 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
integratedShortcutEntryExpanded = !integratedShortcutEntryExpanded
refreshCandidateStripContent()
}
+ adapter.setOnInlineSuggestionToggleClickListener {
+ toggleInlineSuggestionSurface()
+ }
adapter.setOnZeroQueryCandidateClickListener { candidate ->
vibrate()
commitZeroQueryCandidate(candidate)
@@ -19061,11 +19105,14 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
lastSuggestionLayoutKey = null
return@measureDebugSection
}
+ val inlineSuggestionStripShown =
+ (adapter as? SuggestionAdapter)?.isInlineSuggestionStripShown() == true
val key = SuggestionLayoutKey(
isPortrait = isPortrait,
columnNum = columnNum,
layoutKind = if (
+ inlineSuggestionStripShown ||
columnNum == "1" ||
CandidateStripLayoutPolicy.shouldUseLinearHorizontalLayout(
currentCandidateStripContent
@@ -19113,7 +19160,9 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
SuggestionAdapter.VIEW_TYPE_CLIPBOARD_PREVIEW,
SuggestionAdapter.VIEW_TYPE_SHORTCUT_ENTRY,
SuggestionAdapter.VIEW_TYPE_CUSTOM_LAYOUT_PICKER,
- SuggestionAdapter.VIEW_TYPE_SHORTCUT -> spanCount
+ SuggestionAdapter.VIEW_TYPE_SHORTCUT,
+ SuggestionAdapter.VIEW_TYPE_INLINE_TOGGLE,
+ SuggestionAdapter.VIEW_TYPE_INLINE_SUGGESTION -> spanCount
else -> 1
}
}
@@ -19314,7 +19363,10 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
val handwritingAvailable =
gemmaTranslationManager.imageInputCapability() is GemmaImageCapability.Available
val visibleItems = configuredShortcutItems.filter { type ->
- type != ShortcutType.GEMMA_HANDWRITING || handwritingAvailable
+ when (type) {
+ ShortcutType.GEMMA_HANDWRITING -> handwritingAvailable
+ else -> true
+ }
}
currentShortcutItems = visibleItems
shortcutAdapter?.submitList(visibleItems) {
@@ -19829,7 +19881,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
content !is CandidateStripContent.ZeroQuerySuggestions,
customLayoutPickerShown = content is CandidateStripContent.CustomLayoutPicker,
symbolKeyboardShown = keyboardSymbolViewState.value.isShown,
- shortcutToolbarHiddenForCandidates = shortcutToolbarHiddenForCandidates
+ shortcutToolbarHiddenForCandidates = shortcutToolbarHiddenForCandidates,
)
)
}
@@ -21011,14 +21063,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
setSuggestionAdaptersOnMain(candidates)
if (physicalKeyboardEnable.replayCache.firstOrNull() == true) {
updateSuggestionsForFloatingCandidate(
- candidates.map {
- CandidateItem(
- word = it.string,
- length = it.length,
- candidateType = it.type,
- sourceId = it.sourceId,
- )
- }
+ candidates.map { it.toFloatingCandidateItem() }
)
}
if (applyFirstCandidate) {
@@ -22027,7 +22072,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
position: Int,
) {
val candidateLength = candidate.length.toInt()
- val candidateString = candidate.string
+ val candidateString = candidate.commitText
if (insertString.length > candidateLength) {
recordCandidateLearning(
currentInputMode = currentInputMode,
@@ -22051,7 +22096,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
try {
setComposingText("", 0)
finishComposingText()
- commitText(candidate.string, 1)
+ commitText(candidate.commitText, 1)
} finally {
endBatchEdit()
}
@@ -22076,11 +22121,16 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
when (candidate.type.toInt()) {
CANDIDATE_TYPE_CALCULATION.toInt(),
CANDIDATE_TYPE_UNIT_CONVERSION.toInt() -> {
- commitUtilityCandidate(candidate.string)
+ commitUtilityCandidate(candidate.commitText)
}
CANDIDATE_TYPE_UTILITY_LITERAL.toInt() -> {
- commitUtilityCandidate(candidate.string)
+ commitUtilityCandidate(candidate.commitText)
+ }
+
+ CANDIDATE_TYPE_FORMULA_UNICODE.toInt(),
+ CANDIDATE_TYPE_FORMULA_TEX.toInt() -> {
+ commitUtilityCandidate(candidate.commitText)
}
15 -> {
@@ -23186,12 +23236,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
if (!suppressSuggestions) {
updateFloatingCandidatesOnMain(
candidates = displayedCandidates.map {
- CandidateItem(
- word = it.string,
- length = it.length,
- candidateType = it.type,
- sourceId = it.sourceId,
- )
+ it.toFloatingCandidateItem()
},
insertString = insertString
)
@@ -23268,12 +23313,7 @@ class IMEService : InputMethodService(), LifecycleOwner, InputConnection,
if (!suppressSuggestions) {
updateFloatingCandidatesOnMain(
candidates = displayedCandidates.map {
- CandidateItem(
- word = it.string,
- length = it.length,
- candidateType = it.type,
- sourceId = it.sourceId,
- )
+ it.toFloatingCandidateItem()
},
insertString = insertString
)
diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshot.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshot.kt
index 5a4b7de11..52b36dbc7 100644
--- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshot.kt
+++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/ImePreferencesSnapshot.kt
@@ -68,6 +68,7 @@ data class ImePreferencesSnapshot(
val qwertyRomajiHankakuSymbolPreference: Boolean,
val qwertyShowKutoutenButtonsPreference: Boolean,
val showCandidateInPasswordPreference: Boolean,
+ val inlineSuggestionEnabled: Boolean,
val qwertyShowKeymapSymbolsPreference: Boolean,
val qwertyRomajiShiftConversionPreference: Boolean,
val isNgWordEnable: Boolean,
@@ -400,6 +401,7 @@ data class ImePreferencesSnapshot(
qwertyShowKutoutenButtonsPreference =
appPreference.qwerty_show_kutouten_buttons ?: false,
showCandidateInPasswordPreference = appPreference.show_candidates_password ?: true,
+ inlineSuggestionEnabled = appPreference.inline_suggestion_enabled_preference,
qwertyShowKeymapSymbolsPreference =
appPreference.qwerty_show_keymap_symbols ?: false,
qwertyRomajiShiftConversionPreference =
diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/FloatingCandidateListAdapter.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/FloatingCandidateListAdapter.kt
index 65b55fcb0..7efddea7b 100644
--- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/FloatingCandidateListAdapter.kt
+++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/FloatingCandidateListAdapter.kt
@@ -4,19 +4,30 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
+import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.kazumaproject.core.data.floating_candidate.CandidateItem
import com.kazumaproject.markdownhelperkeyboard.R
+import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_CALCULATION
+import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_FORMULA_TEX
+import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_FORMULA_UNICODE
+import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_UNIT_CONVERSION
+import com.kazumaproject.markdownhelperkeyboard.converter.utility.FormulaCandidateType
+import com.kazumaproject.markdownhelperkeyboard.converter.utility.FormulaParser
import timber.log.Timber
private const val VIEW_TYPE_SUGGESTION = 1
private const val VIEW_TYPE_PAGER = 2
+private const val VIEW_TYPE_FORMULA = 3
class FloatingCandidateListAdapter(
private val pageSize: Int,
) : ListAdapter(DiffCallback()) {
+ private val formulaParser = FormulaParser()
+ private var candidateTextSizeSp: Float = 14f
+ private var candidateTextColor: Int? = null
// --- Public Callbacks ---
var onSuggestionClicked: ((suggestion: CandidateItem) -> Unit)? = null
@@ -48,6 +59,19 @@ class FloatingCandidateListAdapter(
}
}
+ fun setCandidateTextSize(size: Float) {
+ val sanitized = size.coerceIn(8f, 48f)
+ if (candidateTextSizeSp == sanitized) return
+ candidateTextSizeSp = sanitized
+ notifyItemRangeChanged(0, itemCount)
+ }
+
+ fun setCandidateTextColor(color: Int?) {
+ if (candidateTextColor == color) return
+ candidateTextColor = color
+ notifyItemRangeChanged(0, itemCount)
+ }
+
// --- Suggestion ViewHolder ---
inner class SuggestionViewHolder(view: View) : RecyclerView.ViewHolder(view) {
private val textView: TextView = view.findViewById(R.id.text_view_item)
@@ -65,6 +89,43 @@ class FloatingCandidateListAdapter(
}
}
+ inner class FormulaViewHolder(view: View) : RecyclerView.ViewHolder(view) {
+ private val formulaView: FormulaView = view.findViewById(R.id.floating_formula_view)
+ private val badgeView: TextView = view.findViewById(R.id.floating_formula_badge)
+
+ init {
+ itemView.setOnClickListener {
+ if (absoluteAdapterPosition != RecyclerView.NO_POSITION) {
+ onSuggestionClicked?.invoke(getItem(absoluteAdapterPosition))
+ }
+ }
+ }
+
+ fun bind(item: CandidateItem) {
+ val parsed = item.formulaSource?.let(formulaParser::parse)
+ val type = when (item.candidateType) {
+ CANDIDATE_TYPE_FORMULA_TEX -> FormulaCandidateType.TEX
+ else -> FormulaCandidateType.UNICODE
+ }
+ formulaView.setPresentation(parsed?.presentation(type))
+ formulaView.setFallbackText(
+ if (parsed == null) item.formulaFallbackText ?: item.word else null
+ )
+ formulaView.setFormulaTextSizeSp(candidateTextSizeSp)
+ val textColor = candidateTextColor
+ ?: ContextCompat.getColor(itemView.context, com.kazumaproject.core.R.color.keyboard_icon_color)
+ formulaView.setFormulaTextColor(textColor)
+ badgeView.text = when (item.candidateType) {
+ CANDIDATE_TYPE_FORMULA_TEX -> itemView.context.getString(R.string.candidate_badge_formula_tex)
+ CANDIDATE_TYPE_FORMULA_UNICODE -> itemView.context.getString(R.string.candidate_badge_formula_unicode)
+ CANDIDATE_TYPE_CALCULATION -> itemView.context.getString(R.string.candidate_badge_calculation)
+ CANDIDATE_TYPE_UNIT_CONVERSION -> itemView.context.getString(R.string.candidate_badge_unit_conversion)
+ else -> ""
+ }
+ badgeView.setTextColor(textColor)
+ }
+ }
+
// --- Pager ViewHolder ---
inner class PagerViewHolder(view: View) : RecyclerView.ViewHolder(view) {
private val textView: TextView = view.findViewById(R.id.text_view_item)
@@ -80,7 +141,11 @@ class FloatingCandidateListAdapter(
// --- Adapter Overrides ---
override fun getItemViewType(position: Int): Int {
- return if (position == pageSize) VIEW_TYPE_PAGER else VIEW_TYPE_SUGGESTION
+ return when {
+ position == pageSize -> VIEW_TYPE_PAGER
+ getItem(position).formulaSource != null -> VIEW_TYPE_FORMULA
+ else -> VIEW_TYPE_SUGGESTION
+ }
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
@@ -94,6 +159,14 @@ class FloatingCandidateListAdapter(
)
)
+ VIEW_TYPE_FORMULA -> FormulaViewHolder(
+ inflater.inflate(
+ R.layout.floating_candidate_list_item_formula,
+ parent,
+ false,
+ )
+ )
+
VIEW_TYPE_PAGER -> PagerViewHolder(
inflater.inflate(
R.layout.floating_candidate_list_item_pager,
@@ -113,6 +186,7 @@ class FloatingCandidateListAdapter(
val currentItem = getItem(position)
when (holder) {
is SuggestionViewHolder -> holder.bind(currentItem.word)
+ is FormulaViewHolder -> holder.bind(currentItem)
is PagerViewHolder -> holder.bind(currentItem.word)
}
}
@@ -120,7 +194,7 @@ class FloatingCandidateListAdapter(
// --- DiffUtil Callback ---
private class DiffCallback : DiffUtil.ItemCallback() {
override fun areItemsTheSame(oldItem: CandidateItem, newItem: CandidateItem): Boolean =
- oldItem.word == newItem.word
+ oldItem.word == newItem.word && oldItem.candidateType == newItem.candidateType
override fun areContentsTheSame(oldItem: CandidateItem, newItem: CandidateItem): Boolean =
oldItem == newItem
@@ -128,7 +202,7 @@ class FloatingCandidateListAdapter(
/**
* ハイライトされているアイテムを選択し、対応するクリックイベントをトリガーします。
- * アイテムが通常の候補(Suggestion)の場合にのみ onSuggestionClicked を呼び出します。
+ * ページャー以外の候補(組版数式を含む)で onSuggestionClicked を呼び出します。
*/
fun selectHighlightedItem() {
// highlightedPosition が有効な範囲にあるか確認
@@ -138,7 +212,7 @@ class FloatingCandidateListAdapter(
}
// ハイライトされているアイテムがページャー(VIEW_TYPE_PAGER)でないことを確認
- if (getItemViewType(highlightedPosition) == VIEW_TYPE_SUGGESTION) {
+ if (getItemViewType(highlightedPosition) != VIEW_TYPE_PAGER) {
getHighlightedItem()?.let { item ->
Timber.d("Programmatically selecting item: ${item.word}")
onSuggestionClicked?.invoke(item)
diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/FormulaView.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/FormulaView.kt
new file mode 100644
index 000000000..fdcacc120
--- /dev/null
+++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/FormulaView.kt
@@ -0,0 +1,206 @@
+package com.kazumaproject.markdownhelperkeyboard.ime_service.adapters
+
+import android.content.Context
+import android.graphics.Canvas
+import android.graphics.Color
+import android.graphics.Paint
+import android.util.AttributeSet
+import android.view.View
+import com.kazumaproject.markdownhelperkeyboard.converter.utility.FormulaCandidatePresentation
+import com.kazumaproject.markdownhelperkeyboard.converter.utility.FormulaDrawOperation
+import com.kazumaproject.markdownhelperkeyboard.converter.utility.FormulaLayout
+import com.kazumaproject.markdownhelperkeyboard.converter.utility.FormulaLayoutConfig
+import com.kazumaproject.markdownhelperkeyboard.converter.utility.FormulaLayoutEngine
+import com.kazumaproject.markdownhelperkeyboard.converter.utility.FormulaTextMeasurer
+import kotlin.math.ceil
+
+/**
+ * Small Canvas renderer used only inside candidate cells. The input field receives the
+ * presentation's commitText; this view only draws the AST and never participates in committing.
+ */
+class FormulaView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0,
+) : View(context, attrs, defStyleAttr) {
+ private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
+ color = Color.BLACK
+ typeface = PaintTypeface.default
+ }
+
+ private var presentation: FormulaCandidatePresentation? = null
+ private var formulaTextSizeSp: Float = 16f
+ private var formulaTextColor: Int = Color.BLACK
+ private var measuredFormula: FormulaLayout? = null
+ private var drawLinearFallback: Boolean = false
+ private var fallbackText: String? = null
+
+ init {
+ importantForAccessibility = IMPORTANT_FOR_ACCESSIBILITY_YES
+ setWillNotDraw(false)
+ }
+
+ fun setPresentation(value: FormulaCandidatePresentation?) {
+ if (presentation == value) return
+ presentation = value
+ contentDescription = value?.commitText ?: fallbackText
+ requestLayout()
+ invalidate()
+ }
+
+ fun setFallbackText(value: String?) {
+ if (fallbackText == value) return
+ fallbackText = value
+ if (presentation == null) contentDescription = value
+ requestLayout()
+ invalidate()
+ }
+
+ fun setFormulaTextSizeSp(size: Float) {
+ val sanitized = size.coerceIn(8f, 48f)
+ if (formulaTextSizeSp == sanitized) return
+ formulaTextSizeSp = sanitized
+ requestLayout()
+ invalidate()
+ }
+
+ fun setFormulaTextColor(color: Int) {
+ if (formulaTextColor == color) return
+ formulaTextColor = color
+ paint.color = color
+ invalidate()
+ }
+
+ override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
+ val currentPresentation = presentation
+ if (currentPresentation == null) {
+ measuredFormula = null
+ drawLinearFallback = false
+ val linearText = fallbackText
+ if (linearText == null) {
+ setMeasuredDimension(0, 0)
+ return
+ }
+ val fontSizePx = formulaTextSizeSp * resources.displayMetrics.scaledDensity
+ paint.textSize = fontSizePx
+ setMeasuredDimension(
+ resolveSize(ceil(paint.measureText(linearText)).toInt(), widthMeasureSpec),
+ resolveSize(
+ ceil(-paint.fontMetrics.ascent + paint.fontMetrics.descent).toInt(),
+ heightMeasureSpec,
+ ),
+ )
+ return
+ }
+
+ val fontSizePx = formulaTextSizeSp * resources.displayMetrics.scaledDensity
+ val config = layoutConfig(fontSizePx)
+ val layout = FormulaLayoutEngine.layout(
+ node = currentPresentation.ast,
+ config = config,
+ measureText = FormulaTextMeasurer { value, size ->
+ paint.textSize = size
+ paint.measureText(value)
+ },
+ )
+ paint.textSize = fontSizePx
+ val widthLimit = measureSpecContentSize(widthMeasureSpec)
+ val heightLimit = measureSpecContentSize(heightMeasureSpec)
+ val desiredWidth = ceil(layout.width).toInt()
+ val desiredHeight = ceil(layout.height).toInt()
+ val maxUnboundedHeight = ceil(fontSizePx * 4.8f).toInt()
+ drawLinearFallback =
+ (widthLimit != null && desiredWidth > widthLimit) ||
+ (heightLimit != null && desiredHeight > heightLimit) ||
+ desiredHeight > maxUnboundedHeight
+
+ measuredFormula = if (drawLinearFallback) null else layout
+ val fallbackWidth = ceil(paint.measureText(currentPresentation.fallbackText)).toInt()
+ val fallbackHeight = ceil(-paint.fontMetrics.ascent + paint.fontMetrics.descent).toInt()
+ val contentWidth = if (drawLinearFallback) fallbackWidth else desiredWidth
+ val contentHeight = if (drawLinearFallback) fallbackHeight else desiredHeight
+ setMeasuredDimension(
+ resolveSize(contentWidth, widthMeasureSpec),
+ resolveSize(contentHeight, heightMeasureSpec),
+ )
+ }
+
+ override fun onDraw(canvas: Canvas) {
+ super.onDraw(canvas)
+ val currentPresentation = presentation
+ if (currentPresentation == null) {
+ val linearText = fallbackText ?: return
+ paint.color = formulaTextColor
+ paint.textSize = formulaTextSizeSp * resources.displayMetrics.scaledDensity
+ paint.typeface = PaintTypeface.default
+ paint.isFakeBoldText = false
+ canvas.drawText(linearText, 0f, -paint.fontMetrics.ascent, paint)
+ return
+ }
+ paint.color = formulaTextColor
+ val baseline = if (drawLinearFallback || measuredFormula == null) {
+ paint.textSize = formulaTextSizeSp * resources.displayMetrics.scaledDensity
+ -paint.fontMetrics.ascent
+ } else {
+ val layout = measuredFormula ?: return
+ layout.ascent
+ }
+
+ if (drawLinearFallback || measuredFormula == null) {
+ paint.typeface = PaintTypeface.default
+ paint.isFakeBoldText = false
+ canvas.drawText(currentPresentation.fallbackText, 0f, baseline, paint)
+ return
+ }
+
+ val layout = measuredFormula ?: return
+ layout.operations.forEach { operation ->
+ when (operation) {
+ is FormulaDrawOperation.Text -> {
+ paint.textSize = operation.fontSize
+ paint.isFakeBoldText = operation.bold
+ canvas.drawText(
+ operation.value,
+ operation.x,
+ baseline + operation.baseline,
+ paint,
+ )
+ }
+
+ is FormulaDrawOperation.Line -> {
+ paint.isFakeBoldText = false
+ paint.strokeWidth = operation.strokeWidth
+ canvas.drawLine(
+ operation.startX,
+ baseline + operation.startY,
+ operation.endX,
+ baseline + operation.endY,
+ paint,
+ )
+ }
+ }
+ }
+ paint.isFakeBoldText = false
+ }
+
+ private fun layoutConfig(fontSizePx: Float): FormulaLayoutConfig {
+ paint.textSize = fontSizePx
+ val fontMetrics = paint.fontMetrics
+ return FormulaLayoutConfig(
+ fontSize = fontSizePx,
+ ascentRatio = (-fontMetrics.ascent / fontSizePx).coerceAtLeast(0.6f),
+ descentRatio = (fontMetrics.descent / fontSizePx).coerceAtLeast(0.16f),
+ )
+ }
+
+ private fun measureSpecContentSize(spec: Int): Int? = when (MeasureSpec.getMode(spec)) {
+ MeasureSpec.AT_MOST,
+ MeasureSpec.EXACTLY -> MeasureSpec.getSize(spec)
+ else -> null
+ }
+
+ /** Android's default typeface is intentionally kept in one place for consistent fallback. */
+ private object PaintTypeface {
+ val default = android.graphics.Typeface.DEFAULT
+ }
+}
diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/InlineSuggestionItemDecoration.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/InlineSuggestionItemDecoration.kt
new file mode 100644
index 000000000..caa4b4ff6
--- /dev/null
+++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/InlineSuggestionItemDecoration.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright 2026 KazumaProject
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package com.kazumaproject.markdownhelperkeyboard.ime_service.adapters
+
+import android.graphics.Rect
+import android.view.View
+import androidx.recyclerview.widget.RecyclerView
+
+/** Applies the only spacing used by the inline suggestion strip. */
+internal class InlineSuggestionItemDecoration(
+ private val edgeSpacing: Int,
+ private val itemSpacing: Int,
+) : RecyclerView.ItemDecoration() {
+
+ override fun getItemOffsets(
+ outRect: Rect,
+ view: View,
+ parent: RecyclerView,
+ state: RecyclerView.State,
+ ) {
+ val position = parent.getChildAdapterPosition(view)
+ outRect.set(offsetsForPosition(position, state.itemCount))
+ }
+
+ internal fun offsetsForPosition(position: Int, itemCount: Int): Rect {
+ if (position == RecyclerView.NO_POSITION || itemCount <= 0) {
+ return Rect()
+ }
+ return Rect(
+ if (position == 0) edgeSpacing else itemSpacing,
+ 0,
+ if (position == itemCount - 1) edgeSpacing else 0,
+ 0,
+ )
+ }
+}
diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapter.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapter.kt
index bc25fa9b5..9a42b2552 100644
--- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapter.kt
+++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapter.kt
@@ -3,14 +3,17 @@ package com.kazumaproject.markdownhelperkeyboard.ime_service.adapters
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.PorterDuff
+import android.graphics.Rect
import android.graphics.drawable.GradientDrawable
import android.graphics.drawable.StateListDrawable
import android.text.SpannableString
import android.text.Spanned
import android.text.style.RelativeSizeSpan
+import android.view.Gravity
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
+import android.widget.FrameLayout
import android.widget.ImageView
import androidx.appcompat.widget.AppCompatImageButton
import androidx.constraintlayout.widget.ConstraintLayout
@@ -31,6 +34,8 @@ import com.kazumaproject.core.domain.state.TenKeyQWERTYMode
import com.kazumaproject.markdownhelperkeyboard.R
import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_ERA
import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_CALCULATION
+import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_FORMULA_TEX
+import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_FORMULA_UNICODE
import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_LEARNED_DICTIONARY
import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_TIME
import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_UNIT_CONVERSION
@@ -43,6 +48,7 @@ import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.CustomKeybo
import com.kazumaproject.markdownhelperkeyboard.gemma.GemmaTranslationManager
import com.kazumaproject.markdownhelperkeyboard.ime_service.CandidateStripLayoutPolicy
import com.kazumaproject.markdownhelperkeyboard.ime_service.candidate.CandidateStripContent
+import com.kazumaproject.markdownhelperkeyboard.ime_service.candidate.InlineSuggestionToggle
import com.kazumaproject.markdownhelperkeyboard.ime_service.extensions.correctReading
import com.kazumaproject.markdownhelperkeyboard.ime_service.extensions.debugPrintCodePoints
import com.kazumaproject.markdownhelperkeyboard.ime_service.measureDebugSection
@@ -53,6 +59,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import timber.log.Timber
+import java.util.IdentityHashMap
import java.util.concurrent.Executor
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicInteger
@@ -94,6 +101,18 @@ internal data class CandidateYomiPresentation(
val textSize: Float
)
+/**
+ * Framework-owned inline views shown by the compact candidate strip.
+ *
+ * The list deliberately uses View rather than InlineContentView so this adapter can still be
+ * loaded on Android versions earlier than API 30.
+ */
+internal data class InlineSuggestionStripState(
+ val views: List = emptyList(),
+ val showInlineSuggestions: Boolean = false,
+ val toggle: InlineSuggestionToggle? = null,
+)
+
internal fun resolveCandidateYomiPresentation(
showCandidateYomiForLiveConversion: Boolean,
isFirstCandidate: Boolean,
@@ -127,6 +146,8 @@ class SuggestionAdapter internal constructor(
const val VIEW_TYPE_SHORTCUT_ENTRY = 6
const val VIEW_TYPE_ZERO_QUERY_CLOSE = 7
const val VIEW_TYPE_ZERO_QUERY_CANDIDATE = 8
+ const val VIEW_TYPE_INLINE_TOGGLE = 9
+ const val VIEW_TYPE_INLINE_SUGGESTION = 10
private val diffThreadIndex = AtomicInteger(0)
private val diffExecutor: Executor = Executors.newFixedThreadPool(2) { runnable ->
@@ -143,6 +164,8 @@ class SuggestionAdapter internal constructor(
internal enum class SuggestionDisplayItemKind {
CandidateItem,
SelectionActionItem,
+ InlineSuggestionToggleItem,
+ InlineSuggestionItem,
ZeroQueryCloseItem,
ZeroQueryCandidateItem,
QuickActionsItem,
@@ -155,7 +178,8 @@ class SuggestionAdapter internal constructor(
internal enum class StartAnchorRole {
QuickActions,
ShortcutItems,
- ShortcutEntry
+ ShortcutEntry,
+ InlineSuggestionToggle,
}
internal data class QuickActionsVisibilitySignature(
@@ -181,6 +205,15 @@ class SuggestionAdapter internal constructor(
val candidateIndex: Int,
) : SuggestionDisplayItem()
+ data class InlineSuggestionToggleItem(
+ val toggle: InlineSuggestionToggle,
+ ) : SuggestionDisplayItem()
+
+ data class InlineSuggestionItem(
+ val view: View,
+ val index: Int,
+ ) : SuggestionDisplayItem()
+
object ZeroQueryCloseItem : SuggestionDisplayItem()
data class ZeroQueryCandidateItem(
@@ -244,6 +277,7 @@ class SuggestionAdapter internal constructor(
private var onCustomLayoutItemClickListener: ((Int) -> Unit)? = null
private var onShortcutItemClickListener: ((ShortcutType) -> Unit)? = null
private var onShortcutEntryClickListener: ((View) -> Unit)? = null
+ private var onInlineSuggestionToggleClickListener: (() -> Unit)? = null
private var onZeroQueryCandidateClickListener: ((Candidate) -> Unit)? = null
private var onZeroQueryCloseClickListener: (() -> Unit)? = null
private var onShowSoftKeyboardClick: (() -> Unit)? = null
@@ -268,6 +302,15 @@ class SuggestionAdapter internal constructor(
private var currentMode: TenKeyQWERTYMode = TenKeyQWERTYMode.Default
private var customLayouts: List = emptyList()
+ private var inlineSuggestionStripState = InlineSuggestionStripState()
+ private val attachedRecyclerViews = IdentityHashMap()
+ private val inlineScrollListeners = IdentityHashMap()
+ private val recyclerViewLayoutListeners =
+ IdentityHashMap()
+ private val originalClipToPadding = IdentityHashMap()
+ private val originalRecyclerViewPadding = IdentityHashMap()
+ private val inlineViewLayoutListeners = IdentityHashMap()
+
private var showCustomTab: Boolean = true
private var shortcutItems: List = emptyList()
@@ -321,6 +364,10 @@ class SuggestionAdapter internal constructor(
this.onShortcutEntryClickListener = listener
}
+ fun setOnInlineSuggestionToggleClickListener(listener: () -> Unit) {
+ this.onInlineSuggestionToggleClickListener = listener
+ }
+
fun setOnZeroQueryCandidateClickListener(listener: (Candidate) -> Unit) {
this.onZeroQueryCandidateClickListener = listener
}
@@ -334,6 +381,32 @@ class SuggestionAdapter internal constructor(
}
fun release() {
+ attachedRecyclerViews.keys.toList().forEach { recyclerView ->
+ detachInlineSuggestionViews(recyclerView)
+ attachedRecyclerViews.remove(recyclerView)?.let(recyclerView::removeItemDecoration)
+ inlineScrollListeners.remove(recyclerView)?.let(recyclerView::removeOnScrollListener)
+ recyclerViewLayoutListeners.remove(recyclerView)?.let {
+ recyclerView.removeOnLayoutChangeListener(it)
+ }
+ originalClipToPadding.remove(recyclerView)?.let {
+ recyclerView.clipToPadding = it
+ }
+ originalRecyclerViewPadding.remove(recyclerView)?.let { padding ->
+ recyclerView.setPadding(padding.left, padding.top, padding.right, padding.bottom)
+ }
+ }
+ attachedRecyclerViews.clear()
+ inlineScrollListeners.clear()
+ recyclerViewLayoutListeners.clear()
+ originalClipToPadding.clear()
+ originalRecyclerViewPadding.clear()
+ inlineSuggestionStripState.views.forEach { view ->
+ inlineViewLayoutListeners.remove(view)?.let(view::removeOnLayoutChangeListener)
+ view.clipBounds = null
+ (view.parent as? ViewGroup)?.removeView(view)
+ }
+ inlineViewLayoutListeners.clear()
+ inlineSuggestionStripState = InlineSuggestionStripState()
released = true
onItemClickListener = null
onItemLongClickListener = null
@@ -342,6 +415,7 @@ class SuggestionAdapter internal constructor(
onCustomLayoutItemClickListener = null
onShortcutItemClickListener = null
onShortcutEntryClickListener = null
+ onInlineSuggestionToggleClickListener = null
onZeroQueryCandidateClickListener = null
onZeroQueryCloseClickListener = null
onShowSoftKeyboardClick = null
@@ -560,6 +634,13 @@ class SuggestionAdapter internal constructor(
newItem is SuggestionDisplayItem.ShortcutItem ->
oldItem.shortcutType == newItem.shortcutType
+ oldItem is SuggestionDisplayItem.InlineSuggestionToggleItem &&
+ newItem is SuggestionDisplayItem.InlineSuggestionToggleItem -> true
+
+ oldItem is SuggestionDisplayItem.InlineSuggestionItem &&
+ newItem is SuggestionDisplayItem.InlineSuggestionItem ->
+ oldItem.view === newItem.view
+
oldItem is SuggestionDisplayItem.CustomLayoutItem &&
newItem is SuggestionDisplayItem.CustomLayoutItem ->
oldItem.layout.stableId == newItem.layout.stableId
@@ -643,12 +724,22 @@ class SuggestionAdapter internal constructor(
}
fun submitContent(content: CandidateStripContent) {
+ submitContent(content, inlineSuggestionStripState)
+ }
+
+ internal fun submitContent(
+ content: CandidateStripContent,
+ inlineSuggestionState: InlineSuggestionStripState,
+ ) {
val layoutModeChanged =
CandidateStripLayoutPolicy.shouldUseLinearHorizontalLayout(currentContent) !=
- CandidateStripLayoutPolicy.shouldUseLinearHorizontalLayout(content)
+ CandidateStripLayoutPolicy.shouldUseLinearHorizontalLayout(content) ||
+ inlineSuggestionStripState.showInlineSuggestions !=
+ inlineSuggestionState.showInlineSuggestions
val nextCandidates = content.candidatesForClicks()
submitContent(
content = content,
+ inlineSuggestionState = inlineSuggestionState,
onCommitted = if (layoutModeChanged || candidateSuggestions != nextCandidates) {
{ onListUpdated?.invoke() }
} else {
@@ -659,12 +750,18 @@ class SuggestionAdapter internal constructor(
private fun submitContent(
content: CandidateStripContent,
+ inlineSuggestionState: InlineSuggestionStripState = this.inlineSuggestionStripState,
onCommitted: (() -> Unit)?,
) {
traceDebugSection("SuggestionAdapter.submitContent") {
- if (currentContent == content) return
+ if (
+ currentContent == content &&
+ inlineSuggestionStripState == inlineSuggestionState
+ ) return
currentContent = content
+ inlineSuggestionStripState = inlineSuggestionState
candidateSuggestions = content.candidatesForClicks()
+ updateInlineSuggestionRecyclerViews()
rebuildDisplayItems(onCommitted)
}
}
@@ -699,6 +796,12 @@ class SuggestionAdapter internal constructor(
}
private fun buildDisplayItems(): List {
+ if (
+ inlineSuggestionStripState.showInlineSuggestions &&
+ inlineSuggestionStripState.views.isNotEmpty()
+ ) {
+ return buildInlineSuggestionItems()
+ }
return when (val content = currentContent) {
is CandidateStripContent.Candidates -> buildCandidateItems(content)
is CandidateStripContent.SelectionActions -> buildSelectionActionItems(content)
@@ -713,10 +816,22 @@ class SuggestionAdapter internal constructor(
}
}
+ private fun buildInlineSuggestionItems(): List = buildList {
+ inlineSuggestionStripState.toggle?.let { toggle ->
+ add(SuggestionDisplayItem.InlineSuggestionToggleItem(toggle))
+ }
+ inlineSuggestionStripState.views.forEachIndexed { index, view ->
+ add(SuggestionDisplayItem.InlineSuggestionItem(view, index))
+ }
+ }
+
private fun buildCandidateItems(
content: CandidateStripContent.Candidates
): List =
buildList {
+ content.inlineSuggestionToggle?.let { toggle ->
+ add(SuggestionDisplayItem.InlineSuggestionToggleItem(toggle))
+ }
content.candidates.forEachIndexed { index, candidate ->
add(SuggestionDisplayItem.CandidateItem(candidate, index))
}
@@ -726,6 +841,9 @@ class SuggestionAdapter internal constructor(
content: CandidateStripContent.ZeroQuerySuggestions
): List =
buildList {
+ content.inlineSuggestionToggle?.let { toggle ->
+ add(SuggestionDisplayItem.InlineSuggestionToggleItem(toggle))
+ }
add(SuggestionDisplayItem.ZeroQueryCloseItem)
content.candidates.forEachIndexed { index, candidate ->
add(SuggestionDisplayItem.ZeroQueryCandidateItem(candidate, index))
@@ -736,6 +854,9 @@ class SuggestionAdapter internal constructor(
content: CandidateStripContent.SelectionActions
): List =
buildList {
+ content.inlineSuggestionToggle?.let { toggle ->
+ add(SuggestionDisplayItem.InlineSuggestionToggleItem(toggle))
+ }
if (content.showShortcutEntry) {
add(SuggestionDisplayItem.ShortcutEntryItem)
}
@@ -746,15 +867,22 @@ class SuggestionAdapter internal constructor(
private fun buildCustomLayoutItems(
content: CandidateStripContent.CustomLayoutPicker
- ): List =
- content.layouts.mapIndexed { index, layout ->
- SuggestionDisplayItem.CustomLayoutItem(layout, index)
+ ): List = buildList {
+ content.inlineSuggestionToggle?.let { toggle ->
+ add(SuggestionDisplayItem.InlineSuggestionToggleItem(toggle))
}
+ content.layouts.forEachIndexed { index, layout ->
+ add(SuggestionDisplayItem.CustomLayoutItem(layout, index))
+ }
+ }
private fun buildEmptyStateItems(
content: CandidateStripContent.EmptyState
): List =
buildList {
+ content.inlineSuggestionToggle?.let { toggle ->
+ add(SuggestionDisplayItem.InlineSuggestionToggleItem(toggle))
+ }
if (content.showZeroQueryToggle) {
add(SuggestionDisplayItem.ZeroQueryCloseItem)
}
@@ -801,6 +929,9 @@ class SuggestionAdapter internal constructor(
content: CandidateStripContent.ExpandedShortcutEntry
): List =
buildList {
+ content.inlineSuggestionToggle?.let { toggle ->
+ add(SuggestionDisplayItem.InlineSuggestionToggleItem(toggle))
+ }
add(SuggestionDisplayItem.ShortcutEntryItem)
content.shortcutItems.forEach { shortcutType ->
add(SuggestionDisplayItem.ShortcutItem(shortcutType))
@@ -819,6 +950,11 @@ class SuggestionAdapter internal constructor(
return buildDisplayItems().map { it.kind() }
}
+ internal fun isInlineSuggestionStripShown(): Boolean {
+ return inlineSuggestionStripState.showInlineSuggestions &&
+ inlineSuggestionStripState.views.isNotEmpty()
+ }
+
internal fun buildZeroQueryDisplayTextsForTesting(): List {
return buildDisplayItems().mapNotNull { item ->
when (item) {
@@ -890,6 +1026,12 @@ class SuggestionAdapter internal constructor(
is SuggestionDisplayItem.SelectionActionItem ->
SuggestionDisplayItemKind.SelectionActionItem
+ is SuggestionDisplayItem.InlineSuggestionToggleItem ->
+ SuggestionDisplayItemKind.InlineSuggestionToggleItem
+
+ is SuggestionDisplayItem.InlineSuggestionItem ->
+ SuggestionDisplayItemKind.InlineSuggestionItem
+
SuggestionDisplayItem.ZeroQueryCloseItem ->
SuggestionDisplayItemKind.ZeroQueryCloseItem
@@ -935,11 +1077,15 @@ class SuggestionAdapter internal constructor(
is SuggestionDisplayItem.ShortcutItem ->
StartAnchorSignature(role = StartAnchorRole.ShortcutItems)
+ is SuggestionDisplayItem.InlineSuggestionToggleItem ->
+ StartAnchorSignature(role = StartAnchorRole.InlineSuggestionToggle)
+
else -> null
}
}
inner class SuggestionViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+ val formulaView: FormulaView = itemView.findViewById(R.id.suggestion_item_formula_view)
val text: MaterialTextView = itemView.findViewById(R.id.suggestion_item_text_view)
val yomiText: MaterialTextView = itemView.findViewById(R.id.suggestion_item_yomi_text_view)
val typeText: MaterialTextView = itemView.findViewById(R.id.suggestion_item_type_text_view)
@@ -950,6 +1096,18 @@ class SuggestionAdapter internal constructor(
val actionText: MaterialTextView = itemView.findViewById(R.id.suggestion_gemma_action_text)
}
+ inner class InlineSuggestionToggleViewHolder(itemView: View) :
+ RecyclerView.ViewHolder(itemView) {
+ val badgeText: MaterialTextView =
+ itemView.findViewById(R.id.suggestion_inline_toggle_badge)
+ val badgeIcon: ImageView = itemView.findViewById(R.id.suggestion_inline_toggle_icon)
+ }
+
+ inner class InlineSuggestionViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+ val container: FrameLayout = itemView as FrameLayout
+ var inlineView: View? = null
+ }
+
inner class ZeroQueryViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
val text: MaterialTextView = itemView.findViewById(R.id.zero_query_item_text_view)
}
@@ -999,6 +1157,8 @@ class SuggestionAdapter internal constructor(
SuggestionDisplayItem.ZeroQueryCloseItem -> VIEW_TYPE_ZERO_QUERY_CLOSE
is SuggestionDisplayItem.ZeroQueryCandidateItem -> VIEW_TYPE_ZERO_QUERY_CANDIDATE
is SuggestionDisplayItem.SelectionActionItem -> VIEW_TYPE_SELECTION_ACTION
+ is SuggestionDisplayItem.InlineSuggestionToggleItem -> VIEW_TYPE_INLINE_TOGGLE
+ is SuggestionDisplayItem.InlineSuggestionItem -> VIEW_TYPE_INLINE_SUGGESTION
is SuggestionDisplayItem.QuickActionsItem -> VIEW_TYPE_EMPTY
is SuggestionDisplayItem.ClipboardPreviewItem -> VIEW_TYPE_CLIPBOARD_PREVIEW
SuggestionDisplayItem.ShortcutEntryItem -> VIEW_TYPE_SHORTCUT_ENTRY
@@ -1060,6 +1220,26 @@ class SuggestionAdapter internal constructor(
SelectionActionViewHolder(itemView)
}
+ VIEW_TYPE_INLINE_TOGGLE -> {
+ val itemView = LayoutInflater.from(parent.context)
+ .inflate(R.layout.suggestion_inline_toggle_item, parent, false)
+ InlineSuggestionToggleViewHolder(itemView)
+ }
+
+ VIEW_TYPE_INLINE_SUGGESTION -> {
+ val density = parent.resources.displayMetrics.density
+ val itemView = FrameLayout(parent.context).apply {
+ layoutParams = RecyclerView.LayoutParams(
+ ViewGroup.LayoutParams.WRAP_CONTENT,
+ (58 * density).toInt(),
+ )
+ clipChildren = true
+ clipToPadding = true
+ importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO
+ }
+ InlineSuggestionViewHolder(itemView)
+ }
+
VIEW_TYPE_SHORTCUT -> {
val itemView = LayoutInflater.from(parent.context)
.inflate(R.layout.item_shortcut, parent, false)
@@ -1110,9 +1290,21 @@ class SuggestionAdapter internal constructor(
item as SuggestionDisplayItem.ZeroQueryCandidateItem,
)
- VIEW_TYPE_SELECTION_ACTION -> onBindSelectionActionViewHolder(
- holder as SelectionActionViewHolder,
- item as SuggestionDisplayItem.SelectionActionItem,
+ VIEW_TYPE_SELECTION_ACTION -> {
+ onBindSelectionActionViewHolder(
+ holder as SelectionActionViewHolder,
+ item as SuggestionDisplayItem.SelectionActionItem,
+ )
+ }
+
+ VIEW_TYPE_INLINE_TOGGLE -> onBindInlineSuggestionToggleViewHolder(
+ holder as InlineSuggestionToggleViewHolder,
+ item as SuggestionDisplayItem.InlineSuggestionToggleItem,
+ )
+
+ VIEW_TYPE_INLINE_SUGGESTION -> onBindInlineSuggestionViewHolder(
+ holder as InlineSuggestionViewHolder,
+ item as SuggestionDisplayItem.InlineSuggestionItem,
)
VIEW_TYPE_SHORTCUT -> onBindShortcutViewHolder(
@@ -1131,6 +1323,178 @@ class SuggestionAdapter internal constructor(
}
}
+ override fun onViewAttachedToWindow(holder: RecyclerView.ViewHolder) {
+ super.onViewAttachedToWindow(holder)
+ if (holder is InlineSuggestionViewHolder) {
+ holder.inlineView?.post { updateInlineSuggestionClipBounds(holder) }
+ }
+ }
+
+ override fun onViewDetachedFromWindow(holder: RecyclerView.ViewHolder) {
+ if (holder is InlineSuggestionViewHolder) {
+ holder.inlineView
+ ?.takeIf { it.parent === holder.container }
+ ?.clipBounds = null
+ }
+ super.onViewDetachedFromWindow(holder)
+ }
+
+ override fun onViewRecycled(holder: RecyclerView.ViewHolder) {
+ if (holder is InlineSuggestionViewHolder) {
+ detachInlineSuggestionView(holder)
+ }
+ super.onViewRecycled(holder)
+ }
+
+ override fun onAttachedToRecyclerView(recyclerView: RecyclerView) {
+ super.onAttachedToRecyclerView(recyclerView)
+ attachedRecyclerViews[recyclerView] = InlineSuggestionItemDecoration(
+ edgeSpacing = (4 * recyclerView.resources.displayMetrics.density).toInt(),
+ itemSpacing = (4 * recyclerView.resources.displayMetrics.density).toInt(),
+ )
+ inlineScrollListeners[recyclerView] = object : RecyclerView.OnScrollListener() {
+ override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
+ updateInlineSuggestionClipBounds(recyclerView)
+ }
+ }.also(recyclerView::addOnScrollListener)
+ originalClipToPadding[recyclerView] = recyclerView.clipToPadding
+ originalRecyclerViewPadding[recyclerView] = Rect(
+ recyclerView.paddingLeft,
+ recyclerView.paddingTop,
+ recyclerView.paddingRight,
+ recyclerView.paddingBottom,
+ )
+ val layoutListener = View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
+ updateInlineSuggestionClipBounds(recyclerView)
+ }
+ recyclerViewLayoutListeners[recyclerView] = layoutListener
+ recyclerView.addOnLayoutChangeListener(layoutListener)
+ updateInlineSuggestionRecyclerViews()
+ }
+
+ override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
+ detachInlineSuggestionViews(recyclerView)
+ attachedRecyclerViews.remove(recyclerView)?.let(recyclerView::removeItemDecoration)
+ inlineScrollListeners.remove(recyclerView)?.let(recyclerView::removeOnScrollListener)
+ recyclerViewLayoutListeners.remove(recyclerView)?.let {
+ recyclerView.removeOnLayoutChangeListener(it)
+ }
+ originalClipToPadding.remove(recyclerView)?.let { recyclerView.clipToPadding = it }
+ originalRecyclerViewPadding.remove(recyclerView)?.let { padding ->
+ recyclerView.setPadding(padding.left, padding.top, padding.right, padding.bottom)
+ }
+ super.onDetachedFromRecyclerView(recyclerView)
+ }
+
+ private fun updateInlineSuggestionRecyclerViews() {
+ val showInline =
+ inlineSuggestionStripState.showInlineSuggestions &&
+ inlineSuggestionStripState.views.isNotEmpty()
+ attachedRecyclerViews.forEach { (recyclerView, decoration) ->
+ recyclerView.removeItemDecoration(decoration)
+ if (showInline) {
+ recyclerView.addItemDecoration(decoration)
+ recyclerView.setPadding(0, 0, 0, 0)
+ recyclerView.clipToPadding = true
+ recyclerView.post { updateInlineSuggestionClipBounds(recyclerView) }
+ } else {
+ originalRecyclerViewPadding[recyclerView]?.let { padding ->
+ recyclerView.setPadding(
+ padding.left,
+ padding.top,
+ padding.right,
+ padding.bottom,
+ )
+ }
+ recyclerView.clipToPadding = originalClipToPadding[recyclerView] ?: false
+ }
+ }
+ }
+
+ private fun onBindInlineSuggestionViewHolder(
+ holder: InlineSuggestionViewHolder,
+ item: SuggestionDisplayItem.InlineSuggestionItem,
+ ) {
+ detachInlineSuggestionView(holder)
+ val view = item.view
+ inlineViewLayoutListeners.remove(view)?.let(view::removeOnLayoutChangeListener)
+ (view.parent as? ViewGroup)?.removeView(view)
+ val width = view.layoutParams?.width?.takeIf { it > 0 }
+ ?: ViewGroup.LayoutParams.WRAP_CONTENT
+ val height = view.layoutParams?.height?.takeIf { it > 0 }
+ ?: ViewGroup.LayoutParams.WRAP_CONTENT
+ holder.container.addView(
+ view,
+ FrameLayout.LayoutParams(width, height).apply {
+ gravity = Gravity.CENTER_VERTICAL or Gravity.START
+ }
+ )
+ holder.inlineView = view
+ val layoutListener = View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ ->
+ holder.itemView.post { updateInlineSuggestionClipBounds(holder) }
+ }
+ inlineViewLayoutListeners[view] = layoutListener
+ view.addOnLayoutChangeListener(layoutListener)
+ holder.itemView.contentDescription = null
+ view.clipBounds = null
+ holder.itemView.post { updateInlineSuggestionClipBounds(holder) }
+ }
+
+ private fun detachInlineSuggestionViews(recyclerView: RecyclerView) {
+ for (index in 0 until recyclerView.childCount) {
+ val holder = recyclerView.getChildViewHolder(recyclerView.getChildAt(index))
+ if (holder is InlineSuggestionViewHolder) {
+ detachInlineSuggestionView(holder)
+ }
+ }
+ }
+
+ private fun detachInlineSuggestionView(holder: InlineSuggestionViewHolder) {
+ holder.inlineView?.let { view ->
+ if (view.parent === holder.container) {
+ inlineViewLayoutListeners.remove(view)?.let(view::removeOnLayoutChangeListener)
+ view.clipBounds = null
+ }
+ }
+ holder.container.removeAllViews()
+ holder.inlineView = null
+ }
+
+ private fun updateInlineSuggestionClipBounds(holder: InlineSuggestionViewHolder) {
+ val view = holder.inlineView ?: return
+ if (view.parent !== holder.container) return
+ val recyclerView = holder.itemView.parent as? RecyclerView ?: return
+ val recyclerLocation = IntArray(2)
+ val viewLocation = IntArray(2)
+ recyclerView.getLocationInWindow(recyclerLocation)
+ view.getLocationInWindow(viewLocation)
+
+ val viewportLeft = recyclerLocation[0] + recyclerView.paddingLeft
+ val viewportTop = recyclerLocation[1] + recyclerView.paddingTop
+ val viewportRight = recyclerLocation[0] + recyclerView.width - recyclerView.paddingRight
+ val viewportBottom = recyclerLocation[1] + recyclerView.height - recyclerView.paddingBottom
+ val clip = Rect(
+ (viewportLeft - viewLocation[0]).coerceAtLeast(0),
+ (viewportTop - viewLocation[1]).coerceAtLeast(0),
+ (viewportRight - viewLocation[0]).coerceAtMost(view.width),
+ (viewportBottom - viewLocation[1]).coerceAtMost(view.height),
+ )
+ if (clip.left >= clip.right || clip.top >= clip.bottom) {
+ view.clipBounds = Rect(0, 0, 0, 0)
+ } else {
+ view.clipBounds = clip
+ }
+ }
+
+ private fun updateInlineSuggestionClipBounds(recyclerView: RecyclerView) {
+ for (index in 0 until recyclerView.childCount) {
+ val child = recyclerView.getChildAt(index)
+ (recyclerView.getChildViewHolder(child) as? InlineSuggestionViewHolder)?.let {
+ updateInlineSuggestionClipBounds(it)
+ }
+ }
+ }
+
private fun onBindQuickActionsViewHolder(
holder: QuickActionsViewHolder,
state: QuickActionsState
@@ -1503,6 +1867,8 @@ class SuggestionAdapter internal constructor(
applyCandidateItemBackground(holder.itemView)
val suggestion = item.candidate
val position = item.candidateIndex
+ val formulaPresentation = suggestion.presentation
+ val isFormula = formulaPresentation != null
val paddingLength = when {
position == 0 -> 4
suggestion.string.length == 1 -> 4
@@ -1520,6 +1886,10 @@ class SuggestionAdapter internal constructor(
suggestion.string.padStart(suggestion.string.length + paddingLength)
.plus(" ".repeat(paddingLength))
}
+ holder.formulaView.isVisible = isFormula
+ holder.formulaView.setPresentation(formulaPresentation)
+ holder.formulaView.setFormulaTextSizeSp(candidateTextSize)
+ holder.text.isVisible = !isFormula
holder.text.textSize = candidateTextSize
val yomiPresentation = resolveCandidateYomiPresentation(
@@ -1528,7 +1898,7 @@ class SuggestionAdapter internal constructor(
suggestion = suggestion,
candidateTextSize = candidateTextSize
)
- holder.yomiText.isVisible = yomiPresentation.isVisible
+ holder.yomiText.isVisible = yomiPresentation.isVisible && !isFormula
holder.yomiText.text = yomiPresentation.text
holder.yomiText.textSize = yomiPresentation.textSize
holder.yomiText.translationX = if (yomiPresentation.isVisible) {
@@ -1543,6 +1913,9 @@ class SuggestionAdapter internal constructor(
holder.typeText.setTextColor(color)
holder.yomiText.setTextColor(color)
}
+ holder.formulaView.setFormulaTextColor(
+ candidateTextColor ?: holder.text.currentTextColor
+ )
holder.typeText.text = when (suggestion.type) {
(1).toByte() -> ""
@@ -1614,6 +1987,10 @@ class SuggestionAdapter internal constructor(
holder.itemView.context.getString(R.string.candidate_badge_calculation)
CANDIDATE_TYPE_UNIT_CONVERSION ->
holder.itemView.context.getString(R.string.candidate_badge_unit_conversion)
+ CANDIDATE_TYPE_FORMULA_UNICODE ->
+ holder.itemView.context.getString(R.string.candidate_badge_formula_unicode)
+ CANDIDATE_TYPE_FORMULA_TEX ->
+ holder.itemView.context.getString(R.string.candidate_badge_formula_tex)
CANDIDATE_TYPE_USER_TEMPLATE ->
if (showDictionaryCandidateLabels) "[定型]" else ""
CANDIDATE_TYPE_TEXT_MACRO -> "[マクロ]"
@@ -1718,6 +2095,8 @@ class SuggestionAdapter internal constructor(
applyCandidateItemBackground(holder.itemView)
val suggestion = item.candidate
val position = item.candidateIndex
+ holder.actionText.visibility = View.VISIBLE
+ holder.itemView.contentDescription = null
holder.actionText.text = suggestion.string
holder.actionText.textSize = candidateTextSize
holder.badgeText.text = when (suggestion.type) {
@@ -1741,6 +2120,30 @@ class SuggestionAdapter internal constructor(
}
}
+ private fun onBindInlineSuggestionToggleViewHolder(
+ holder: InlineSuggestionToggleViewHolder,
+ item: SuggestionDisplayItem.InlineSuggestionToggleItem,
+ ) {
+ applyCandidateItemBackground(holder.itemView)
+ holder.badgeText.text = item.toggle.badge.orEmpty()
+ holder.badgeText.isVisible = !item.toggle.badge.isNullOrEmpty()
+ holder.badgeIcon.isVisible = item.toggle.iconResId != null
+ holder.badgeIcon.background = item.toggle.iconBackgroundResId?.let { backgroundResId ->
+ ContextCompat.getDrawable(holder.itemView.context, backgroundResId)
+ }
+ item.toggle.iconResId?.let(holder.badgeIcon::setImageResource)
+ holder.itemView.contentDescription = item.toggle.contentDescription
+ candidateTextColor?.let { color ->
+ holder.badgeText.setTextColor(color)
+ holder.badgeIcon.imageTintList = android.content.res.ColorStateList.valueOf(color)
+ }
+ holder.itemView.isPressed = false
+ holder.itemView.setOnClickListener {
+ onInlineSuggestionToggleClickListener?.invoke()
+ }
+ holder.itemView.setOnLongClickListener { true }
+ }
+
private fun onBindCustomLayoutViewHolder(
holder: CustomLayoutViewHolder,
item: SuggestionDisplayItem.CustomLayoutItem,
diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/autofill/InlineAutofillController.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/autofill/InlineAutofillController.kt
index 1c7a2cebf..611416984 100644
--- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/autofill/InlineAutofillController.kt
+++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/autofill/InlineAutofillController.kt
@@ -57,15 +57,6 @@ internal class InlineAutofillController(
return true
}
- /** Reparents the already inflated views when the active normal or floating host changes. */
- fun onHostChanged() {
- val (token, views) = synchronized(stateLock) {
- if (destroyed || inflatedViews.isEmpty()) return
- (activeToken ?: return) to inflatedViews
- }
- publishIfCurrent(token, views)
- }
-
fun clear() {
val token = synchronized(stateLock) {
inflatedViews = emptyList()
diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/autofill/InlineSuggestionClipView.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/autofill/InlineSuggestionClipView.kt
deleted file mode 100644
index d3064fa0a..000000000
--- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/autofill/InlineSuggestionClipView.kt
+++ /dev/null
@@ -1,129 +0,0 @@
-/*
- * Copyright 2026 KazumaProject
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-package com.kazumaproject.markdownhelperkeyboard.ime_service.autofill
-
-import android.content.Context
-import android.graphics.Canvas
-import android.graphics.Color
-import android.graphics.PixelFormat
-import android.graphics.Rect
-import android.util.AttributeSet
-import android.view.Choreographer
-import android.view.Surface
-import android.view.SurfaceHolder
-import android.view.SurfaceView
-import android.view.View
-import android.view.ViewGroup
-import android.view.ViewTreeObserver
-import android.widget.FrameLayout
-
-/**
- * Clips embedded inline-suggestion surfaces to the visible candidate-strip area.
- *
- * The implementation deliberately avoids a static reference to InlineContentView so this layout
- * class can still be inflated on Android versions earlier than API 30.
- */
-class InlineSuggestionClipView @JvmOverloads constructor(
- context: Context,
- attrs: AttributeSet? = null,
- defStyleAttr: Int = 0,
-) : FrameLayout(context, attrs, defStyleAttr) {
-
- private val parentBounds = Rect()
- private val contentBounds = Rect()
- private val backgroundView = SurfaceView(context).apply {
- setZOrderOnTop(true)
- holder.setFormat(PixelFormat.TRANSPARENT)
- layoutParams = ViewGroup.LayoutParams(
- ViewGroup.LayoutParams.MATCH_PARENT,
- ViewGroup.LayoutParams.MATCH_PARENT,
- )
- holder.addCallback(object : SurfaceHolder.Callback {
- override fun surfaceCreated(holder: SurfaceHolder) {
- drawBackgroundColorIfReady()
- }
-
- override fun surfaceChanged(
- holder: SurfaceHolder,
- format: Int,
- width: Int,
- height: Int,
- ) = Unit
-
- override fun surfaceDestroyed(holder: SurfaceHolder) = Unit
- })
- }
- private var surfaceBackgroundColor = Color.TRANSPARENT
- private val onDrawListener = ViewTreeObserver.OnDrawListener {
- clipInlineContentDescendants(this)
- }
-
- init {
- // A SurfaceView background keeps the remote suggestion surfaces composited with the IME
- // strip. This follows AOSP's AutofillKeyboard InlineContentClipView implementation.
- addView(backgroundView)
- }
-
- override fun onAttachedToWindow() {
- super.onAttachedToWindow()
- viewTreeObserver.addOnDrawListener(onDrawListener)
- }
-
- override fun onDetachedFromWindow() {
- super.onDetachedFromWindow()
- if (viewTreeObserver.isAlive) {
- viewTreeObserver.removeOnDrawListener(onDrawListener)
- }
- }
-
- override fun setBackgroundColor(color: Int) {
- surfaceBackgroundColor = color
- Choreographer.getInstance().postFrameCallback {
- drawBackgroundColorIfReady()
- }
- }
-
- private fun drawBackgroundColorIfReady() {
- val surface: Surface = backgroundView.holder.surface
- if (!surface.isValid) return
- val canvas: Canvas = surface.lockCanvas(null)
- try {
- canvas.drawColor(surfaceBackgroundColor)
- } finally {
- surface.unlockCanvasAndPost(canvas)
- }
- }
-
- private fun clipInlineContentDescendants(root: View?) {
- if (root == null || width <= 0 || height <= 0) return
- if (root.javaClass.name == INLINE_CONTENT_VIEW_CLASS_NAME) {
- parentBounds.set(0, 0, width, height)
- contentBounds.set(parentBounds)
- offsetRectIntoDescendantCoords(root, contentBounds)
- root.clipBounds = contentBounds
- return
- }
- if (root is ViewGroup) {
- for (index in 0 until root.childCount) {
- clipInlineContentDescendants(root.getChildAt(index))
- }
- }
- }
-
- private companion object {
- const val INLINE_CONTENT_VIEW_CLASS_NAME = "android.widget.inline.InlineContentView"
- }
-}
diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/autofill/InlineSuggestionDisplayState.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/autofill/InlineSuggestionDisplayState.kt
new file mode 100644
index 000000000..988cd388b
--- /dev/null
+++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/autofill/InlineSuggestionDisplayState.kt
@@ -0,0 +1,40 @@
+package com.kazumaproject.markdownhelperkeyboard.ime_service.autofill
+
+internal enum class InlineSuggestionSurface {
+ Inline,
+ NormalCandidates,
+}
+
+/** Keeps the chosen candidate-strip surface while the current inline response is available. */
+internal class InlineSuggestionDisplayState {
+ var hasSuggestions: Boolean = false
+ private set
+
+ var surface: InlineSuggestionSurface = InlineSuggestionSurface.Inline
+ private set
+
+ fun updateAvailability(available: Boolean) {
+ if (!available) {
+ hasSuggestions = false
+ surface = InlineSuggestionSurface.Inline
+ return
+ }
+
+ // A newly available response keeps the existing inline-first behavior. Re-publishing the
+ // same response for a different host must not undo a user-selected keyboard-candidate
+ // surface.
+ if (!hasSuggestions) {
+ surface = InlineSuggestionSurface.Inline
+ }
+ hasSuggestions = true
+ }
+
+ fun toggleSurface(): Boolean {
+ if (!hasSuggestions) return false
+ surface = when (surface) {
+ InlineSuggestionSurface.Inline -> InlineSuggestionSurface.NormalCandidates
+ InlineSuggestionSurface.NormalCandidates -> InlineSuggestionSurface.Inline
+ }
+ return true
+ }
+}
diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/candidate/CandidateStripContent.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/candidate/CandidateStripContent.kt
index c2e02e688..a8291e0a1 100644
--- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/candidate/CandidateStripContent.kt
+++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/candidate/CandidateStripContent.kt
@@ -1,6 +1,7 @@
package com.kazumaproject.markdownhelperkeyboard.ime_service.candidate
import android.graphics.Bitmap
+import androidx.annotation.DrawableRes
import com.kazumaproject.markdownhelperkeyboard.converter.candidate.Candidate
import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.CustomKeyboardLayout
import com.kazumaproject.markdownhelperkeyboard.short_cut.ShortcutType
@@ -8,24 +9,29 @@ import com.kazumaproject.markdownhelperkeyboard.short_cut.ShortcutType
sealed interface CandidateStripContent {
data class Candidates(
val candidates: List,
+ val inlineSuggestionToggle: InlineSuggestionToggle? = null,
) : CandidateStripContent
/** Explicit actions for selected text: local macros first, then translation and prompts. */
data class SelectionActions(
val actions: List,
val showShortcutEntry: Boolean,
+ val inlineSuggestionToggle: InlineSuggestionToggle? = null,
) : CandidateStripContent
data class ZeroQuerySuggestions(
val candidates: List,
+ val inlineSuggestionToggle: InlineSuggestionToggle? = null,
) : CandidateStripContent
data class CustomLayoutPicker(
val layouts: List,
+ val inlineSuggestionToggle: InlineSuggestionToggle? = null,
) : CandidateStripContent
data class ExpandedShortcutEntry(
val shortcutItems: List,
+ val inlineSuggestionToggle: InlineSuggestionToggle? = null,
) : CandidateStripContent
data class EmptyState(
@@ -35,11 +41,20 @@ sealed interface CandidateStripContent {
val shortcutItems: List,
val showIntegratedShortcuts: Boolean,
val showZeroQueryToggle: Boolean = false,
+ val inlineSuggestionToggle: InlineSuggestionToggle? = null,
) : CandidateStripContent
data object Empty : CandidateStripContent
}
+/** A candidate-strip action that switches between inline autofill and keyboard candidates. */
+data class InlineSuggestionToggle(
+ val contentDescription: String,
+ val badge: String? = null,
+ @DrawableRes val iconResId: Int? = null,
+ @DrawableRes val iconBackgroundResId: Int? = null,
+)
+
data class QuickActionsState(
val incognitoVisible: Boolean,
val undoEnabled: Boolean,
diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/candidate/CandidateStripContentResolver.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/candidate/CandidateStripContentResolver.kt
index c0d5ccbcb..d06e3a497 100644
--- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/candidate/CandidateStripContentResolver.kt
+++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/candidate/CandidateStripContentResolver.kt
@@ -5,28 +5,33 @@ object CandidateStripContentResolver {
fun resolve(state: CandidateStripInputState): CandidateStripContent {
if (shouldShowExpandedShortcutEntry(state)) {
return CandidateStripContent.ExpandedShortcutEntry(
- shortcutItems = state.shortcutItems
+ shortcutItems = state.shortcutItems,
+ inlineSuggestionToggle = state.inlineSuggestionToggle,
)
}
if (state.candidates.isNotEmpty()) {
if (state.selectionActionsShown) {
return CandidateStripContent.SelectionActions(
actions = state.candidates,
- showShortcutEntry = shouldShowShortcutEntryWithSelectionActions(state)
+ showShortcutEntry = shouldShowShortcutEntryWithSelectionActions(state),
+ inlineSuggestionToggle = state.inlineSuggestionToggle,
)
}
return CandidateStripContent.Candidates(
- candidates = state.candidates
+ candidates = state.candidates,
+ inlineSuggestionToggle = state.inlineSuggestionToggle,
)
}
if (shouldShowZeroQuerySuggestions(state)) {
return CandidateStripContent.ZeroQuerySuggestions(
- candidates = state.zeroQueryCandidates
+ candidates = state.zeroQueryCandidates,
+ inlineSuggestionToggle = state.inlineSuggestionToggle,
)
}
if (state.customLayoutPickerShown) {
return CandidateStripContent.CustomLayoutPicker(
- layouts = state.customLayouts
+ layouts = state.customLayouts,
+ inlineSuggestionToggle = state.inlineSuggestionToggle,
)
}
val clipboardPreview = resolveClipboardPreviewOrNull(state)
@@ -45,7 +50,8 @@ object CandidateStripContentResolver {
quickActions.hasAnyAction ||
showShortcutEntry ||
showIntegratedShortcuts ||
- showZeroQueryToggle
+ showZeroQueryToggle ||
+ state.inlineSuggestionToggle != null
) {
return CandidateStripContent.EmptyState(
showShortcutEntry = showShortcutEntry,
@@ -53,7 +59,8 @@ object CandidateStripContentResolver {
clipboardPreview = clipboardPreview,
shortcutItems = state.shortcutItems,
showIntegratedShortcuts = showIntegratedShortcuts,
- showZeroQueryToggle = showZeroQueryToggle
+ showZeroQueryToggle = showZeroQueryToggle,
+ inlineSuggestionToggle = state.inlineSuggestionToggle,
)
}
return CandidateStripContent.Empty
diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/candidate/CandidateStripInputState.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/candidate/CandidateStripInputState.kt
index ee6353f5b..67215866a 100644
--- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/candidate/CandidateStripInputState.kt
+++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/candidate/CandidateStripInputState.kt
@@ -34,4 +34,5 @@ data class CandidateStripInputState(
val shortcutToolbarIntegratedInSuggestion: Boolean,
val integratedShortcutEntryExpanded: Boolean,
val shortcutItems: List,
+ val inlineSuggestionToggle: InlineSuggestionToggle? = null,
)
diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreference.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreference.kt
index a5a6744af..502254693 100644
--- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreference.kt
+++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/AppPreference.kt
@@ -51,6 +51,7 @@ object AppPreference {
const val UTILITY_CALCULATION_ENABLED_KEY = "utility_calculation_enabled"
const val UTILITY_UNIT_CONVERSION_ENABLED_KEY = "utility_unit_conversion_enabled"
const val UTILITY_EXPRESSION_CANDIDATE_ENABLED_KEY = "utility_expression_candidate_enabled"
+ const val UTILITY_FORMULA_CANDIDATE_ENABLED_KEY = "utility_formula_candidate_enabled"
const val UTILITY_ANGLE_MODE_KEY = "utility_angle_mode"
const val UTILITY_CALCULATION_PRECISION_KEY = "utility_calculation_precision"
const val UTILITY_REGIONAL_PROFILE_KEY = "utility_regional_profile"
@@ -106,6 +107,7 @@ object AppPreference {
const val KEY_SOUND_KEY = "key_sound_preference"
const val KEY_SOUND_VOLUME_PERCENT_KEY = "key_sound_volume_percent_preference"
const val ALLOW_FULLSCREEN_MODE_KEY = "allow_fullscreen_mode_preference"
+ const val INLINE_SUGGESTION_ENABLED_KEY = "inline_suggestion_enabled_preference"
private const val MIN_CANDIDATE_VISIBLE_HEIGHT_DP = 30
private const val MAX_CANDIDATE_VISIBLE_HEIGHT_DP = 300
@@ -137,6 +139,8 @@ object AppPreference {
private val KEY_SOUND_PREFERENCE = Pair(KEY_SOUND_KEY, false)
private val KEY_SOUND_VOLUME_PERCENT_PREFERENCE =
Pair(KEY_SOUND_VOLUME_PERCENT_KEY, 0)
+ private val INLINE_SUGGESTION_ENABLED_PREFERENCE =
+ Pair(INLINE_SUGGESTION_ENABLED_KEY, true)
private val LEARN_DICTIONARY_PREFERENCE = Pair("learn_dictionary_preference", true)
private val INCOGNITO_MODE_DETECTION_PREFERENCE =
Pair("incognito_mode_detection_preference", true)
@@ -1447,6 +1451,15 @@ object AppPreference {
fun isFullscreenModeAllowed(defaultValue: Boolean): Boolean =
preferences.getBoolean(ALLOW_FULLSCREEN_MODE_KEY, defaultValue)
+ var inline_suggestion_enabled_preference: Boolean
+ get() = preferences.getBoolean(
+ INLINE_SUGGESTION_ENABLED_PREFERENCE.first,
+ INLINE_SUGGESTION_ENABLED_PREFERENCE.second,
+ )
+ set(value) = preferences.edit {
+ it.putBoolean(INLINE_SUGGESTION_ENABLED_PREFERENCE.first, value)
+ }
+
var landscape_force_qwerty_romaji_preference: Boolean
get() = preferences.getBoolean(
LANDSCAPE_FORCE_QWERTY_ROMAJI_PREFERENCE.first,
@@ -1794,6 +1807,10 @@ object AppPreference {
UTILITY_EXPRESSION_CANDIDATE_ENABLED_KEY,
true,
),
+ formulaCandidateEnabled = preferences.getBoolean(
+ UTILITY_FORMULA_CANDIDATE_ENABLED_KEY,
+ true,
+ ),
angleMode = preferences.getString(UTILITY_ANGLE_MODE_KEY, "degrees")
.toAngleMode(),
calculationPrecision = calculationPrecision,
@@ -1813,6 +1830,10 @@ object AppPreference {
UTILITY_EXPRESSION_CANDIDATE_ENABLED_KEY,
value.includeExpressionCandidate,
)
+ editor.putBoolean(
+ UTILITY_FORMULA_CANDIDATE_ENABLED_KEY,
+ value.formulaCandidateEnabled,
+ )
editor.putString(
UTILITY_ANGLE_MODE_KEY,
if (value.angleMode == AngleMode.DEGREES) "degrees" else "radians",
@@ -1840,6 +1861,7 @@ object AppPreference {
editor.remove(UTILITY_CALCULATION_ENABLED_KEY)
editor.remove(UTILITY_UNIT_CONVERSION_ENABLED_KEY)
editor.remove(UTILITY_EXPRESSION_CANDIDATE_ENABLED_KEY)
+ editor.remove(UTILITY_FORMULA_CANDIDATE_ENABLED_KEY)
editor.remove(UTILITY_ANGLE_MODE_KEY)
editor.remove(UTILITY_CALCULATION_PRECISION_KEY)
editor.remove(UTILITY_REGIONAL_PROFILE_KEY)
diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/candidate_view_height_setting/SuggestionAdapter2.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/candidate_view_height_setting/SuggestionAdapter2.kt
index 33dcdd7da..49a816f6e 100644
--- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/candidate_view_height_setting/SuggestionAdapter2.kt
+++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/candidate_view_height_setting/SuggestionAdapter2.kt
@@ -31,6 +31,8 @@ import com.kazumaproject.core.domain.state.TenKeyQWERTYMode
import com.kazumaproject.markdownhelperkeyboard.R
import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_ERA
import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_CALCULATION
+import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_FORMULA_TEX
+import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_FORMULA_UNICODE
import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_LEARNED_DICTIONARY
import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_TIME
import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_UNIT_CONVERSION
@@ -43,6 +45,7 @@ import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.CustomKeybo
import com.kazumaproject.markdownhelperkeyboard.gemma.GemmaTranslationManager
import com.kazumaproject.markdownhelperkeyboard.ime_service.extensions.correctReading
import com.kazumaproject.markdownhelperkeyboard.ime_service.extensions.debugPrintCodePoints
+import com.kazumaproject.markdownhelperkeyboard.ime_service.adapters.FormulaView
import com.kazumaproject.markdownhelperkeyboard.short_cut.ShortcutType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -517,6 +520,7 @@ class SuggestionAdapter2 : RecyclerView.Adapter() {
)
inner class SuggestionViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
+ val formulaView: FormulaView = itemView.findViewById(R.id.suggestion_item_formula_view)
val text: MaterialTextView = itemView.findViewById(R.id.suggestion_item_text_view)
val yomiText: MaterialTextView = itemView.findViewById(R.id.suggestion_item_yomi_text_view)
val typeText: MaterialTextView = itemView.findViewById(R.id.suggestion_item_type_text_view)
@@ -930,6 +934,8 @@ class SuggestionAdapter2 : RecyclerView.Adapter() {
applyCandidateItemBackground(holder.itemView)
val suggestion = item.candidate
val position = item.candidateIndex
+ val formulaPresentation = suggestion.presentation
+ val isFormula = formulaPresentation != null
val paddingLength = when {
position == 0 -> 4
suggestion.string.length == 1 -> 4
@@ -947,6 +953,10 @@ class SuggestionAdapter2 : RecyclerView.Adapter() {
suggestion.string.padStart(suggestion.string.length + paddingLength)
.plus(" ".repeat(paddingLength))
}
+ holder.formulaView.isVisible = isFormula
+ holder.formulaView.setPresentation(formulaPresentation)
+ holder.formulaView.setFormulaTextSizeSp(candidateTextSize)
+ holder.text.isVisible = !isFormula
holder.text.textSize = candidateTextSize
val yomiPresentation = resolvePreviewCandidateYomiPresentation(
@@ -955,7 +965,7 @@ class SuggestionAdapter2 : RecyclerView.Adapter() {
suggestion = suggestion,
candidateTextSize = candidateTextSize
)
- holder.yomiText.isVisible = yomiPresentation.isVisible
+ holder.yomiText.isVisible = yomiPresentation.isVisible && !isFormula
holder.yomiText.text = yomiPresentation.text
holder.yomiText.textSize = yomiPresentation.textSize
holder.yomiText.translationX = if (yomiPresentation.isVisible) {
@@ -969,6 +979,9 @@ class SuggestionAdapter2 : RecyclerView.Adapter() {
holder.typeText.setTextColor(color)
holder.yomiText.setTextColor(color)
}
+ holder.formulaView.setFormulaTextColor(
+ candidateTextColor ?: holder.text.currentTextColor
+ )
holder.typeText.text = when (suggestion.type) {
(1).toByte() -> ""
@@ -1019,6 +1032,10 @@ class SuggestionAdapter2 : RecyclerView.Adapter() {
holder.itemView.context.getString(R.string.candidate_badge_calculation)
CANDIDATE_TYPE_UNIT_CONVERSION ->
holder.itemView.context.getString(R.string.candidate_badge_unit_conversion)
+ CANDIDATE_TYPE_FORMULA_UNICODE ->
+ holder.itemView.context.getString(R.string.candidate_badge_formula_unicode)
+ CANDIDATE_TYPE_FORMULA_TEX ->
+ holder.itemView.context.getString(R.string.candidate_badge_formula_tex)
CANDIDATE_TYPE_USER_TEMPLATE ->
if (showDictionaryCandidateLabels) "定型" else ""
CANDIDATE_TYPE_TEXT_MACRO -> "マクロ"
diff --git a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/CommonPreferenceFragment.kt b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/CommonPreferenceFragment.kt
index 69925504c..2d6df7455 100644
--- a/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/CommonPreferenceFragment.kt
+++ b/app/src/main/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/CommonPreferenceFragment.kt
@@ -385,6 +385,13 @@ open class CommonPreferenceFragment : PreferenceFragmentCompat() {
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
setPreferencesFromResource(preferencesXmlRes, rootKey)
+ findPreference(AppPreference.INLINE_SUGGESTION_ENABLED_KEY)?.let {
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
+ it.isEnabled = false
+ it.summary = getString(R.string.inline_suggestion_unsupported_summary)
+ }
+ }
+
val packageInfo = requireContext().packageManager.getPackageInfo(
requireContext().packageName, 0
)
diff --git a/app/src/main/res/drawable/inline_suggestion_key_24.xml b/app/src/main/res/drawable/inline_suggestion_key_24.xml
new file mode 100644
index 000000000..ad52efddd
--- /dev/null
+++ b/app/src/main/res/drawable/inline_suggestion_key_24.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/app/src/main/res/drawable/more_horiz_24px.xml b/app/src/main/res/drawable/more_horiz_24px.xml
index 702796560..e6c268b7e 100644
--- a/app/src/main/res/drawable/more_horiz_24px.xml
+++ b/app/src/main/res/drawable/more_horiz_24px.xml
@@ -1,10 +1,10 @@
diff --git a/app/src/main/res/layout-land/main_layout.xml b/app/src/main/res/layout-land/main_layout.xml
index e9e0cd5c7..942f7ea46 100644
--- a/app/src/main/res/layout-land/main_layout.xml
+++ b/app/src/main/res/layout-land/main_layout.xml
@@ -116,35 +116,6 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/floating_keyboard_layout.xml b/app/src/main/res/layout/floating_keyboard_layout.xml
index 412a9071a..11cf835a5 100644
--- a/app/src/main/res/layout/floating_keyboard_layout.xml
+++ b/app/src/main/res/layout/floating_keyboard_layout.xml
@@ -117,35 +117,6 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/suggestion_item.xml b/app/src/main/res/layout/suggestion_item.xml
index 78474461e..b69f6f43c 100644
--- a/app/src/main/res/layout/suggestion_item.xml
+++ b/app/src/main/res/layout/suggestion_item.xml
@@ -16,6 +16,13 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
+
+
[計算]
[換算]
+ [文字]
+ [TeX]
計算・単位換算候補
計算結果と単位換算候補を設定します
計算
計算候補を表示
式付き候補も表示
+ 数式候補を表示
角度単位
計算精度
単位換算
@@ -28,9 +31,9 @@
有効数字 %1$d 桁
自動候補は無効
すべての設定をリセット
- 計算・単位換算候補を既定値へ戻します
+ 計算・単位換算・数式候補を既定値へ戻します
ユーティリティ候補設定をリセットしますか?
- 計算・単位換算候補のすべての設定を既定値へ戻します。
+ 計算・単位換算・数式候補のすべての設定を既定値へ戻します。
変換先を追加
変換先
削除
@@ -190,6 +193,13 @@
パスワード入力時の変換候補
パスワード入力中は変換候補を表示しません
パスワード入力中に変換候補を表示します
+ インライン候補
+ インライン候補(自動入力)を使用
+ Android 11(API 30)以降の Inline Suggestions API を使い、自動入力サービスから提供された候補をキーボード上部に表示します。自動入力サービスと対象の入力欄もこの API に対応している必要があります。
+ インライン自動入力候補を表示せず、このキーボードの通常の変換候補を使用します。Inline Suggestions は Android 11(API 30)以降で利用できます。
+ Inline Suggestions は Android 11(API 30)以降で、自動入力サービスと対象の入力欄が対応している場合に利用できます。
+ 通常の変換候補を表示
+ インライン自動入力候補を表示
パスワード入力中の文字列の設定
パスワード入力中に未確定文字として扱います。
パスワード入力中に確定文字として扱います。
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 85a2c1d3f..f03435380 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -2,11 +2,14 @@
[Calc]
[Convert]
+ [Text]
+ [TeX]
Calculations and unit conversions
Configure calculator and conversion candidates
Calculator
Show calculation candidates
Also show result with expression
+ Show formula candidates
Angle unit
Calculation precision
Unit conversions
@@ -28,9 +31,9 @@
%1$d significant digits
Automatic candidates off
Reset all settings
- Restore calculator and unit conversion defaults
+ Restore calculator, conversion, and formula defaults
Reset utility candidate settings?
- All calculator and unit conversion settings will be restored to their defaults.
+ All calculator, conversion, and formula settings will be restored to their defaults.
Add conversion target
Conversion targets
Remove
@@ -189,6 +192,13 @@
Conversion candidates for passwords
Don\'t show conversion candidates while typing a password
Show conversion candidates while typing a password
+ Inline Suggestions
+ Use inline autofill suggestions
+ Use Android Inline Suggestions API on Android 11 (API 30) or later to show suggestions supplied by an autofill service above the keyboard. The autofill service and the target input field must also support the API.
+ Do not show inline autofill suggestions. Use this keyboard\'s normal conversion candidates instead. Inline Suggestions requires Android 11 (API 30) or later.
+ Inline Suggestions requires Android 11 (API 30) or later and support from the autofill service and target input field.
+ Show normal conversion candidates
+ Show inline autofill suggestions
Text settings during password input
Treat as pre-edit text during password input.
Treat as committed text during password input.
diff --git a/app/src/main/res/xml/pref_common.xml b/app/src/main/res/xml/pref_common.xml
index c7eac0289..4ec6ee4e6 100644
--- a/app/src/main/res/xml/pref_common.xml
+++ b/app/src/main/res/xml/pref_common.xml
@@ -66,4 +66,13 @@
+
+
+
+
diff --git a/app/src/main/res/xml/pref_common_legacy.xml b/app/src/main/res/xml/pref_common_legacy.xml
index c3380e72a..70dea62a1 100644
--- a/app/src/main/res/xml/pref_common_legacy.xml
+++ b/app/src/main/res/xml/pref_common_legacy.xml
@@ -328,6 +328,13 @@
android:title="@string/reconversion_preference_title"
app:summary="@string/reconversion_preference_summary" />
+
+
+
value.length * size * 0.6f }
+
+ @Test
+ fun fractionNumeratorIsAboveRuleAndDenominatorIsBelowRule() {
+ val layout = FormulaLayoutEngine.layout(
+ FormulaNode.Fraction(FormulaNode.Number("12"), FormulaNode.Number("34")),
+ FormulaLayoutConfig(fontSize = 20f),
+ measure,
+ )
+ val text = layout.operations.filterIsInstance()
+ val rule = layout.operations.filterIsInstance().single()
+ val numerator = text.first { it.value == "12" }
+ val denominator = text.first { it.value == "34" }
+
+ assertTrue(numerator.baseline < rule.startY)
+ assertTrue(denominator.baseline > rule.startY)
+ assertTrue(numerator.x > 0f)
+ assertTrue(denominator.x > 0f)
+ assertTrue(layout.width >= numerator.x + 2f * 20f * 0.82f * 0.6f)
+ }
+
+ @Test
+ fun nestedScriptsAndLargeOperatorHaveFiniteBounds() {
+ val parsed = FormulaParser().parse("sum(i=1,n,i^2)") ?: error("formula did not parse")
+ val layout = FormulaLayoutEngine.layout(parsed.ast, FormulaLayoutConfig(fontSize = 18f), measure)
+ assertTrue(layout.width > 0f)
+ assertTrue(layout.height > 0f)
+ assertTrue(layout.operations.all { operation ->
+ when (operation) {
+ is FormulaDrawOperation.Text -> operation.x >= 0f && operation.baseline.isFinite()
+ is FormulaDrawOperation.Line -> operation.startX >= 0f && operation.endX >= operation.startX
+ }
+ })
+ }
+
+ @Test
+ fun upperOperationsStayInsideTheReportedAscent() {
+ val parsed = FormulaParser().parse("\\hat{x_1^2}+\\sum_{i=1}^{n}i^2")
+ ?: error("formula did not parse")
+ val config = FormulaLayoutConfig(fontSize = 18f)
+ val layout = FormulaLayoutEngine.layout(parsed.ast, config, measure)
+
+ assertTrue(layout.operations.all { operation ->
+ when (operation) {
+ is FormulaDrawOperation.Text -> {
+ val ascent = operation.fontSize * config.ascentRatio
+ val descent = operation.fontSize * config.descentRatio
+ operation.baseline - ascent >= -layout.ascent - 0.001f &&
+ operation.baseline + descent <= layout.descent + 0.001f
+ }
+
+ is FormulaDrawOperation.Line -> {
+ val halfStroke = operation.strokeWidth / 2f
+ operation.startY - halfStroke >= -layout.ascent - 0.001f &&
+ operation.endY + halfStroke <= layout.descent + 0.001f
+ }
+ }
+ })
+ }
+}
diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/utility/FormulaParserTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/utility/FormulaParserTest.kt
new file mode 100644
index 000000000..07b01db74
--- /dev/null
+++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/converter/utility/FormulaParserTest.kt
@@ -0,0 +1,227 @@
+package com.kazumaproject.markdownhelperkeyboard.converter.utility
+
+import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_FORMULA_TEX
+import com.kazumaproject.markdownhelperkeyboard.converter.candidate.CANDIDATE_TYPE_FORMULA_UNICODE
+import com.kazumaproject.markdownhelperkeyboard.converter.candidate.Candidate
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class FormulaParserTest {
+ private val parser = FormulaParser()
+ private val provider = UtilityCandidateProvider()
+
+ @Test
+ fun requiredShorthandAndLatexExamplesHaveExactOutputs() {
+ val expected = mapOf(
+ "25^2" to ("25²" to "25^{2}"),
+ "x_1^(n+1)" to ("x₁⁽ⁿ⁺¹⁾" to "x_{1}^{n+1}"),
+ "1/2" to ("½" to "\\frac{1}{2}"),
+ "12/34" to ("¹²⁄₃₄" to "\\frac{12}{34}"),
+ "\\frac{a+b}{c+d}" to ("(a+b)⁄(c+d)" to "\\frac{a+b}{c+d}"),
+ "sqrt(x^2+y^2)" to ("√(x²+y²)" to "\\sqrt{x^{2}+y^{2}}"),
+ "sum(i=1,n,i^2)" to ("∑ᵢ₌₁ⁿ i²" to "\\sum_{i=1}^{n}i^{2}"),
+ )
+
+ expected.forEach { (source, output) ->
+ val parsed = parser.parse(source)
+ assertTrue(source, parsed != null)
+ assertEquals(source, output.first, parsed?.unicodeText)
+ assertEquals(source, output.second, parsed?.normalizedTex)
+ }
+ }
+
+ @Test
+ fun formulaCandidatesKeepCommitTextSeparateFromCanvasPresentation() {
+ val utilityCandidates = provider.provide("25^2").candidates
+ val candidates = UtilityCandidateComposer.compose(
+ input = "25^2",
+ existingCandidates = emptyList(),
+ result = provider.provide("25^2"),
+ )
+ assertEquals(listOf("25²", "25^{2}"), utilityCandidates.map { it.text })
+ assertEquals(CANDIDATE_TYPE_FORMULA_UNICODE, candidateType(candidates[1]))
+ assertEquals(CANDIDATE_TYPE_FORMULA_TEX, candidateType(candidates[2]))
+ assertEquals("25²", candidates[1].presentation?.unicodeText)
+ assertEquals("25^{2}", candidates[2].presentation?.normalizedTex)
+ assertEquals(listOf("25^2", "25²", "25^{2}"), candidates.map { it.commitText })
+ }
+
+ @Test
+ fun formulaCandidatesFollowOrdinaryCandidatesAndPrecedeFullWidthCandidates() {
+ val composed = UtilityCandidateComposer.compose(
+ input = "25^2",
+ existingCandidates = listOf(
+ candidate("通常候補"),
+ candidate("25", type = 22),
+ candidate("!", type = 21),
+ candidate("A", type = 30),
+ ),
+ result = provider.provide("25^2"),
+ )
+
+ assertEquals(
+ listOf("通常候補", "25^2", "25²", "25^{2}", "25", "!", "A"),
+ composed.map { it.commitText },
+ )
+ }
+
+ @Test
+ fun canonicalLatexDoesNotAddItsOwnSourceAgain() {
+ val existing = listOf(candidate("\\frac{a+b}{c+d}"), candidate("別候補"))
+ val result = provider.provide("\\frac{a+b}{c+d}")
+ val composed = UtilityCandidateComposer.compose("\\frac{a+b}{c+d}", existing, result)
+
+ assertEquals(
+ listOf("別候補", "(a+b)⁄(c+d)", "\\frac{a+b}{c+d}"),
+ composed.map { it.commitText },
+ )
+ }
+
+ @Test
+ fun disabledFormulaCandidatesDoNotChangeExistingUtilityBehavior() {
+ val config = UtilityCandidateConfig(formulaCandidateEnabled = false)
+ assertFalse(provider.provide("25^2", config).hasCandidates)
+ assertEquals(
+ listOf("625", "25^2=625"),
+ provider.provide("25^2=", config).candidates.map { it.text },
+ )
+ }
+
+ @Test
+ fun calculatedPowerKeepsResultFirstAndFormatsOnlyTheExpressionCandidate() {
+ val candidates = provider.provide("25^2=").candidates
+ assertEquals(listOf("625", "25²=625"), candidates.map { it.text })
+ assertEquals("25²=625", candidates[1].formulaPresentation?.unicodeText)
+ assertEquals("25^{2}=625", candidates[1].formulaPresentation?.normalizedTex)
+ }
+
+ @Test
+ fun unsupportedInputsAreRejectedWithoutThrowing() {
+ listOf(
+ "",
+ "ordinary text",
+ "https://example.com/a^2",
+ "mail@example.com",
+ "\\unknown{a}",
+ "\\begin{matrix}a&b\\end{matrix}",
+ "\\frac{a+b",
+ "_2",
+ "^2",
+ "1".repeat(201),
+ "a\n+b",
+ ).forEach { source ->
+ assertEquals(source, null, parser.parse(source))
+ assertFalse(source, provider.provide(source).hasCandidates)
+ }
+ }
+
+ @Test
+ fun formulasAndLatexUseTheSameNodeFamilies() {
+ val shorthand = parser.parse("a/b")?.ast
+ val latex = parser.parse("\\frac{a}{b}")?.ast
+ assertTrue(shorthand is FormulaNode.Fraction)
+ assertTrue(latex is FormulaNode.Fraction)
+ assertEquals(
+ parser.parse("x_1^(n+1)")?.ast,
+ parser.parse("x_{1}^{n+1}")?.ast,
+ )
+ assertEquals(parser.parse("sqrt(x)")?.ast, parser.parse("\\sqrt{x}")?.ast)
+ assertEquals(parser.parse("a*b")?.ast, parser.parse("a\\times b")?.ast)
+ assertEquals(parser.parse("a<=b")?.ast, parser.parse("a\\leq b")?.ast)
+ assertEquals(parser.parse("π")?.ast, parser.parse("\\pi")?.ast)
+ assertEquals(
+ parser.parse("sum(i=1,n,i^2)")?.ast,
+ parser.parse("\\sum_{i=1}^{n}i^2")?.ast,
+ )
+ assertEquals(
+ parser.parse("lim(x->0,f(x))")?.ast,
+ parser.parse("\\lim_{x\\to0}f(x)")?.ast,
+ )
+ assertTrue(parser.parse("e") != null)
+ }
+
+ @Test
+ fun supportedFormulaFamiliesParseWithoutAndroidOrExternalLibraries() {
+ listOf(
+ "a*b", "a<=b", "a≈b", "a∝b", "x->y", "5!", "10%",
+ "sin(x)", "asin(x)", "sinh(x)", "log(x)", "exp(x)", "min(x,y)",
+ "floor(x)", "ceil(x)", "⌊x⌋", "root(3,x)", "abs(x)", "norm(v)", "||v||",
+ "alpha", "Gamma", "π", "∞", "prod(i=1,n,i)", "int(a,b,f)",
+ "lim(x->0,f(x))", "forall x in A", "vec(v)", "dot(x)", "underline(x)",
+ "\\frac{a}{\\frac{b}{c}}", "\\sqrt[3]{x}", "\\left|x+y\\right|",
+ "\\lfloor x\\rfloor", "\\left\\lfloor x\\right\\rfloor",
+ "\\langle x,y\\rangle", "\\sum_{i=1}^{n}i^2",
+ "\\prod_{i=1}^{n}i", "\\int_0^1x\\,dx", "\\iint_D f", "\\iiint_V f",
+ "\\oint_C f", "\\lim_{x\\to0}f(x)", "\\partial_x f", "\\nabla f",
+ "\\mathbb{R}", "\\forall x\\in A", "\\vec{v}", "\\hat{x}",
+ "\\bar{y}", "\\dot{z}", "\\ddot{z}", "\\tilde{x}", "\\mathcal{F}",
+ ).forEach { source ->
+ assertTrue(source, parser.parse(source) != null)
+ }
+ }
+
+ @Test
+ fun texControlWordsAreSeparatedWhenFollowedByLetters() {
+ assertEquals("a\\times b", parser.parse("a*b")?.normalizedTex)
+ assertEquals("\\sin x", parser.parse("\\sin x")?.normalizedTex)
+ assertEquals("\\leq", parser.parse("≤")?.normalizedTex)
+ assertEquals("sin(x)", parser.parse("sin(x)")?.unicodeText)
+ assertEquals("sin x", parser.parse("\\sin x")?.unicodeText)
+ }
+
+ @Test
+ fun ordinaryFunctionLookingTextIsNotTreatedAsFormula() {
+ assertEquals(null, parser.parse("hello(world)"))
+ }
+
+ @Test
+ fun directUnicodeOperatorsAndCodeFragmentsAreHandledConservatively() {
+ assertEquals("a\\cup b", parser.parse("a∪b")?.normalizedTex)
+ assertEquals("a\\in b", parser.parse("a∈b")?.normalizedTex)
+ assertEquals("\\frac{2}{3}", parser.parse("2÷3")?.normalizedTex)
+ assertEquals("a\\pm b", parser.parse("a±b")?.normalizedTex)
+ assertEquals("a\\equiv b", parser.parse("a≡b")?.normalizedTex)
+ assertEquals("a\\not\\equiv b", parser.parse("a≢b")?.normalizedTex)
+ assertEquals("a\\not\\equiv b", parser.parse("a\\not\\equiv b")?.normalizedTex)
+ assertEquals("\\mathbb{R}", parser.parse("\\mathbb{R}")?.normalizedTex)
+ assertEquals("\\left\\langle x\\right\\rangle", parser.parse("\\left\\langle x\\right\\rangle")?.normalizedTex)
+ assertEquals(
+ "\\sum_{i=1}^{n}i^{2}",
+ parser.parse("∑_{i=1}^{n}i^2")?.normalizedTex,
+ )
+ assertEquals(
+ parser.parse("sqrt(x^2+y^2)")?.ast,
+ parser.parse("√(x^2+y^2)")?.ast,
+ )
+
+ listOf(
+ "const x = a^2;",
+ "if (x^2) { y++; }",
+ "value = \"x^2\"",
+ "for(i=0;i
+ assertEquals(source, null, parser.parse(source))
+ }
+
+ assertEquals(
+ listOf("a→b", "a\\to b"),
+ provider.provide("a→b").candidates.map { it.text },
+ )
+ assertEquals(
+ listOf("ℕ", "\\mathbb{N}"),
+ provider.provide("ℕ").candidates.map { it.text },
+ )
+ assertEquals("25^2", UtilityInputNormalizer.normalizeForFormula("25^2"))
+ }
+
+ private fun candidateType(candidate: Candidate): Byte = candidate.type
+
+ private fun candidate(text: String, type: Int = 1) = Candidate(
+ string = text,
+ type = type.toByte(),
+ length = text.length.toUByte(),
+ score = 0,
+ )
+}
diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEServiceInlineSuggestionRequestContractTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEServiceInlineSuggestionRequestContractTest.kt
new file mode 100644
index 000000000..c6f6da0b1
--- /dev/null
+++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEServiceInlineSuggestionRequestContractTest.kt
@@ -0,0 +1,32 @@
+package com.kazumaproject.markdownhelperkeyboard.ime_service
+
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.io.File
+
+class IMEServiceInlineSuggestionRequestContractTest {
+
+ @Test
+ fun disabledInlineSuggestionsDoNotCreateAnAutofillRequest() {
+ val source = imeServiceSource()
+ val start = source.indexOf("override fun onCreateInlineSuggestionsRequest")
+ val end = source.indexOf("override fun onInlineSuggestionsResponse", start)
+
+ assertTrue("Missing inline suggestions request callback", start >= 0)
+ assertTrue("Missing inline suggestions response callback", end > start)
+
+ val function = source.substring(start, end)
+ val disabledGuard = "if (!inlineSuggestionEnabled) return null"
+ val factoryCall = "return InlineSuggestionsRequestFactory.create(this)"
+
+ assertTrue(function.contains("): InlineSuggestionsRequest?"))
+ assertTrue(function.contains(disabledGuard))
+ assertTrue(function.indexOf(disabledGuard) < function.indexOf(factoryCall))
+ }
+
+ private fun imeServiceSource(): String =
+ listOf(
+ File("app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt"),
+ File("src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt"),
+ ).first { it.isFile }.readText()
+}
diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEServiceInlineSuggestionToggleIconContractTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEServiceInlineSuggestionToggleIconContractTest.kt
new file mode 100644
index 000000000..d83ddb63d
--- /dev/null
+++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEServiceInlineSuggestionToggleIconContractTest.kt
@@ -0,0 +1,70 @@
+package com.kazumaproject.markdownhelperkeyboard.ime_service
+
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.io.File
+
+class IMEServiceInlineSuggestionToggleIconContractTest {
+
+ @Test
+ fun toggleUsesMoreHorizForInlineAndKeyForNormalCandidates() {
+ val function = imeServiceSource().functionBody(
+ start = "private fun inlineSuggestionToggleForCandidateStrip",
+ end = "private fun buildCandidateStripInputState",
+ )
+
+ assertTrue(
+ function.contains(
+ "InlineSuggestionSurface.Inline -> R.drawable.more_horiz_24px"
+ )
+ )
+ assertTrue(
+ function.contains(
+ "InlineSuggestionSurface.NormalCandidates -> R.drawable.inline_suggestion_key_24"
+ )
+ )
+ assertTrue(
+ function.contains(
+ "InlineSuggestionSurface.Inline -> null"
+ )
+ )
+ assertTrue(
+ function.contains(
+ "com.kazumaproject.core.R.drawable.suggestion_icon_bg"
+ )
+ )
+ assertFalse(function.contains("swap_horiz_24px"))
+ assertFalse(function.contains("keyboard_24px"))
+ assertFalse(function.contains("henkan"))
+ assertFalse(function.contains("arrows_output"))
+ }
+
+ @Test
+ fun toggleUsesOnlyTheOuterCandidateBackground() {
+ val layout = inlineToggleLayoutSource()
+
+ assertTrue(layout.contains("android:background=\"@drawable/recyclerview_item_bg\""))
+ assertFalse(layout.contains("android:background=\"@drawable/suggestion_icon_bg\""))
+ }
+
+ private fun imeServiceSource(): String =
+ listOf(
+ File("app/src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt"),
+ File("src/main/java/com/kazumaproject/markdownhelperkeyboard/ime_service/IMEService.kt"),
+ ).first { it.isFile }.readText()
+
+ private fun inlineToggleLayoutSource(): String =
+ listOf(
+ File("app/src/main/res/layout/suggestion_inline_toggle_item.xml"),
+ File("src/main/res/layout/suggestion_inline_toggle_item.xml"),
+ ).first { it.isFile }.readText()
+
+ private fun String.functionBody(start: String, end: String): String {
+ val startIndex = indexOf(start)
+ require(startIndex >= 0) { "Missing start marker: $start" }
+ val endIndex = indexOf(end, startIndex + start.length)
+ require(endIndex >= 0) { "Missing end marker: $end" }
+ return substring(startIndex, endIndex)
+ }
+}
diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapterDisplayItemTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapterDisplayItemTest.kt
index 624e60ba4..7289d4a91 100644
--- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapterDisplayItemTest.kt
+++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapterDisplayItemTest.kt
@@ -2,11 +2,13 @@ package com.kazumaproject.markdownhelperkeyboard.ime_service.adapters
import android.graphics.Color
import android.graphics.drawable.ColorDrawable
+import android.view.View
import com.kazumaproject.markdownhelperkeyboard.converter.candidate.Candidate
import com.kazumaproject.markdownhelperkeyboard.custom_keyboard.data.CustomKeyboardLayout
import com.kazumaproject.markdownhelperkeyboard.gemma.GemmaTranslationManager
import com.kazumaproject.markdownhelperkeyboard.ime_service.candidate.CandidateStripContent
import com.kazumaproject.markdownhelperkeyboard.ime_service.candidate.ClipboardPreviewState
+import com.kazumaproject.markdownhelperkeyboard.ime_service.candidate.InlineSuggestionToggle
import com.kazumaproject.markdownhelperkeyboard.ime_service.candidate.QuickActionsState
import com.kazumaproject.markdownhelperkeyboard.short_cut.ShortcutType
import org.junit.Assert.assertEquals
@@ -403,6 +405,121 @@ class SuggestionAdapterDisplayItemTest {
adapter.release()
}
+ @Test
+ fun inlineSuggestionToggleUsesSelectionActionPresentationBeforeNormalCandidates() {
+ val adapter = SuggestionAdapter()
+ adapter.submitContent(
+ CandidateStripContent.Candidates(
+ candidates = listOf(candidate("通常候補")),
+ inlineSuggestionToggle = InlineSuggestionToggle(
+ contentDescription = "インライン自動入力候補を表示",
+ badge = "⇄",
+ ),
+ )
+ )
+
+ assertEquals(
+ listOf(
+ SuggestionAdapter.SuggestionDisplayItemKind.InlineSuggestionToggleItem,
+ SuggestionAdapter.SuggestionDisplayItemKind.CandidateItem,
+ ),
+ adapter.buildDisplayItemKindsForTesting()
+ )
+ assertEquals(
+ listOf(candidate("通常候補")),
+ adapter.buildClickCandidatesForTesting()
+ )
+ assertEquals(
+ SuggestionAdapter.StartAnchorSignature(
+ role = SuggestionAdapter.StartAnchorRole.InlineSuggestionToggle
+ ),
+ adapter.buildStartAnchorSignatureForTesting()
+ )
+ assertTrue(adapter.isStartAnchoredContentExpected())
+ adapter.release()
+ }
+
+ @Test
+ fun inlineSuggestionsAreDisplayedAsToggleThenInlineViewsAndCanReturnToNormalCandidates() {
+ val adapter = SuggestionAdapter()
+ val application = org.robolectric.RuntimeEnvironment.getApplication()
+ val firstInlineView = View(application)
+ val secondInlineView = View(application)
+ val toggle = InlineSuggestionToggle(
+ contentDescription = "通常候補を表示",
+ badge = "⇄",
+ )
+ val content = CandidateStripContent.Candidates(
+ candidates = listOf(candidate("通常候補")),
+ inlineSuggestionToggle = toggle,
+ )
+
+ adapter.submitContent(
+ content,
+ InlineSuggestionStripState(
+ views = listOf(firstInlineView, secondInlineView),
+ showInlineSuggestions = true,
+ toggle = toggle,
+ ),
+ )
+
+ assertEquals(
+ listOf(
+ SuggestionAdapter.SuggestionDisplayItemKind.InlineSuggestionToggleItem,
+ SuggestionAdapter.SuggestionDisplayItemKind.InlineSuggestionItem,
+ SuggestionAdapter.SuggestionDisplayItemKind.InlineSuggestionItem,
+ ),
+ adapter.buildDisplayItemKindsForTesting(),
+ )
+ assertTrue(adapter.isInlineSuggestionStripShown())
+
+ adapter.submitContent(
+ content,
+ InlineSuggestionStripState(
+ views = listOf(firstInlineView, secondInlineView),
+ showInlineSuggestions = false,
+ toggle = toggle,
+ ),
+ )
+
+ assertEquals(
+ listOf(
+ SuggestionAdapter.SuggestionDisplayItemKind.InlineSuggestionToggleItem,
+ SuggestionAdapter.SuggestionDisplayItemKind.CandidateItem,
+ ),
+ adapter.buildDisplayItemKindsForTesting(),
+ )
+ assertFalse(adapter.isInlineSuggestionStripShown())
+
+ adapter.submitContent(
+ content,
+ InlineSuggestionStripState(
+ views = listOf(firstInlineView, secondInlineView),
+ showInlineSuggestions = true,
+ toggle = toggle,
+ ),
+ )
+
+ assertEquals(
+ listOf(
+ SuggestionAdapter.SuggestionDisplayItemKind.InlineSuggestionToggleItem,
+ SuggestionAdapter.SuggestionDisplayItemKind.InlineSuggestionItem,
+ SuggestionAdapter.SuggestionDisplayItemKind.InlineSuggestionItem,
+ ),
+ adapter.buildDisplayItemKindsForTesting(),
+ )
+ adapter.release()
+ }
+
+ @Test
+ fun inlineSuggestionSpacingHasOnlyFourDpEdgesAndGaps() {
+ val decoration = InlineSuggestionItemDecoration(edgeSpacing = 4, itemSpacing = 4)
+
+ assertEquals(android.graphics.Rect(4, 0, 0, 0), decoration.offsetsForPosition(0, 3))
+ assertEquals(android.graphics.Rect(4, 0, 0, 0), decoration.offsetsForPosition(1, 3))
+ assertEquals(android.graphics.Rect(4, 0, 4, 0), decoration.offsetsForPosition(2, 3))
+ }
+
@Test
fun zeroQuerySuggestionsBuildCloseThenCandidates() {
val adapter = SuggestionAdapter()
diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapterShortcutEntryClickTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapterShortcutEntryClickTest.kt
index 4a9b0a89a..19655edf0 100644
--- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapterShortcutEntryClickTest.kt
+++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/adapters/SuggestionAdapterShortcutEntryClickTest.kt
@@ -3,12 +3,14 @@ package com.kazumaproject.markdownhelperkeyboard.ime_service.adapters
import android.content.Context
import android.os.Looper
import android.view.Gravity
+import android.view.View
import android.widget.FrameLayout
import android.widget.LinearLayout
import androidx.test.core.app.ApplicationProvider
import com.kazumaproject.markdownhelperkeyboard.converter.candidate.Candidate
import com.kazumaproject.markdownhelperkeyboard.ime_service.candidate.CandidateStripContent
import com.kazumaproject.markdownhelperkeyboard.ime_service.candidate.ClipboardPreviewState
+import com.kazumaproject.markdownhelperkeyboard.ime_service.candidate.InlineSuggestionToggle
import com.kazumaproject.markdownhelperkeyboard.ime_service.candidate.QuickActionsState
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
@@ -210,6 +212,46 @@ class SuggestionAdapterShortcutEntryClickTest {
adapter.release()
}
+ @Test
+ fun inlineViewRebindingDetachesPreviousParentBeforeReuse() {
+ val adapter = SuggestionAdapter()
+ val context = ApplicationProvider.getApplicationContext()
+ val firstInlineView = View(context)
+ val secondInlineView = View(context)
+ val toggle = InlineSuggestionToggle(
+ contentDescription = "通常候補を表示",
+ badge = "⇄",
+ )
+ val content = CandidateStripContent.Candidates(
+ candidates = listOf(candidate("通常候補")),
+ inlineSuggestionToggle = toggle,
+ )
+ adapter.submitContent(
+ content,
+ InlineSuggestionStripState(
+ views = listOf(firstInlineView, secondInlineView),
+ showInlineSuggestions = true,
+ toggle = toggle,
+ ),
+ )
+ drainMainUntilItemCount(adapter, expectedItemCount = 3)
+
+ val holder = adapter.onCreateViewHolder(
+ FrameLayout(context),
+ SuggestionAdapter.VIEW_TYPE_INLINE_SUGGESTION,
+ ) as SuggestionAdapter.InlineSuggestionViewHolder
+ adapter.onBindViewHolder(holder, 1)
+ assertTrue(firstInlineView.parent === holder.container)
+
+ adapter.onBindViewHolder(holder, 2)
+ assertTrue(firstInlineView.parent == null)
+ assertTrue(secondInlineView.parent === holder.container)
+
+ adapter.onViewRecycled(holder)
+ assertTrue(secondInlineView.parent == null)
+ adapter.release()
+ }
+
@Test
fun zeroQueryCandidateLongPressDoesNothing() {
val adapter = SuggestionAdapter()
diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/autofill/InlineSuggestionDisplayStateTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/autofill/InlineSuggestionDisplayStateTest.kt
new file mode 100644
index 000000000..ffffc898f
--- /dev/null
+++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/autofill/InlineSuggestionDisplayStateTest.kt
@@ -0,0 +1,58 @@
+package com.kazumaproject.markdownhelperkeyboard.ime_service.autofill
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class InlineSuggestionDisplayStateTest {
+
+ @Test
+ fun newlyAvailableSuggestionsUseInlineSurface() {
+ val state = InlineSuggestionDisplayState()
+
+ state.updateAvailability(true)
+
+ assertTrue(state.hasSuggestions)
+ assertEquals(InlineSuggestionSurface.Inline, state.surface)
+ }
+
+ @Test
+ fun availableSuggestionsCanToggleBetweenNormalCandidatesAndInlineSurfaces() {
+ val state = InlineSuggestionDisplayState().apply {
+ updateAvailability(true)
+ }
+
+ assertTrue(state.toggleSurface())
+ assertEquals(InlineSuggestionSurface.NormalCandidates, state.surface)
+ assertTrue(state.toggleSurface())
+ assertEquals(InlineSuggestionSurface.Inline, state.surface)
+ }
+
+ @Test
+ fun republishingAvailableSuggestionsPreservesTheSelectedSurface() {
+ val state = InlineSuggestionDisplayState().apply {
+ updateAvailability(true)
+ toggleSurface()
+ }
+
+ state.updateAvailability(true)
+
+ assertEquals(InlineSuggestionSurface.NormalCandidates, state.surface)
+ }
+
+ @Test
+ fun clearingSuggestionsResetsTheNextResponseToInlineSurface() {
+ val state = InlineSuggestionDisplayState().apply {
+ updateAvailability(true)
+ toggleSurface()
+ }
+
+ state.updateAvailability(false)
+
+ assertFalse(state.hasSuggestions)
+ assertFalse(state.toggleSurface())
+ state.updateAvailability(true)
+ assertEquals(InlineSuggestionSurface.Inline, state.surface)
+ }
+}
diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/autofill/InlineSuggestionsRequestFactoryTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/autofill/InlineSuggestionsRequestFactoryTest.kt
index 2edbd4fc8..b2f0cd816 100644
--- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/autofill/InlineSuggestionsRequestFactoryTest.kt
+++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/autofill/InlineSuggestionsRequestFactoryTest.kt
@@ -3,7 +3,6 @@ package com.kazumaproject.markdownhelperkeyboard.ime_service.autofill
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert.assertEquals
-import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@@ -32,11 +31,4 @@ class InlineSuggestionsRequestFactoryTest {
}
}
- @Test
- @Config(sdk = [29])
- fun inlineHostClassCanBeCreatedBeforeAndroidEleven() {
- val context = ApplicationProvider.getApplicationContext()
-
- assertNotNull(InlineSuggestionClipView(context))
- }
}
diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/candidate/CandidateStripContentResolverTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/candidate/CandidateStripContentResolverTest.kt
index 3235d663a..d7063acf4 100644
--- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/candidate/CandidateStripContentResolverTest.kt
+++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/ime_service/candidate/CandidateStripContentResolverTest.kt
@@ -196,6 +196,61 @@ class CandidateStripContentResolverTest {
assertTrue((content as CandidateStripContent.SelectionActions).actions == actions)
}
+ @Test
+ fun inlineSuggestionToggleIsIncludedWithNormalCandidates() {
+ val toggle = InlineSuggestionToggle(
+ contentDescription = "インライン自動入力候補を表示",
+ badge = "⇄",
+ )
+ val state = baseState(
+ candidates = listOf(candidate("通常候補")),
+ candidatesShown = true,
+ inputStringEmpty = false,
+ inlineSuggestionToggle = toggle,
+ )
+
+ val content = CandidateStripContentResolver.resolve(state)
+
+ assertTrue(content is CandidateStripContent.Candidates)
+ assertEquals(toggle, (content as CandidateStripContent.Candidates).inlineSuggestionToggle)
+ }
+
+ @Test
+ fun inlineSuggestionToggleKeepsAnOtherwiseEmptyCandidateStripVisible() {
+ val toggle = InlineSuggestionToggle(
+ contentDescription = "インライン自動入力候補を表示",
+ badge = "⇄",
+ )
+
+ val content = CandidateStripContentResolver.resolve(
+ baseState(inlineSuggestionToggle = toggle)
+ )
+
+ assertTrue(content is CandidateStripContent.EmptyState)
+ assertEquals(toggle, (content as CandidateStripContent.EmptyState).inlineSuggestionToggle)
+ }
+
+ @Test
+ fun inlineSuggestionToggleIsIncludedWithCustomLayoutPicker() {
+ val toggle = InlineSuggestionToggle(
+ contentDescription = "インライン自動入力候補を表示",
+ badge = "⇄",
+ )
+
+ val content = CandidateStripContentResolver.resolve(
+ baseState(
+ customLayoutPickerShown = true,
+ customLayouts = listOf(customLayout("カスタム")),
+ inlineSuggestionToggle = toggle,
+ )
+ )
+
+ assertEquals(
+ toggle,
+ (content as CandidateStripContent.CustomLayoutPicker).inlineSuggestionToggle
+ )
+ }
+
@Test
fun clipboardPreviewShown_whenClipboardTextIsLastPastedAndTapToDeleteDisabled() {
val state = baseState(
@@ -602,6 +657,7 @@ class CandidateStripContentResolverTest {
shortcutToolbarIntegratedInSuggestion: Boolean = false,
integratedShortcutEntryExpanded: Boolean = false,
shortcutItems: List = emptyList(),
+ inlineSuggestionToggle: InlineSuggestionToggle? = null,
): CandidateStripInputState =
CandidateStripInputState(
candidates = candidates,
@@ -632,6 +688,7 @@ class CandidateStripContentResolverTest {
shortcutToolbarIntegratedInSuggestion = shortcutToolbarIntegratedInSuggestion,
integratedShortcutEntryExpanded = integratedShortcutEntryExpanded,
shortcutItems = shortcutItems,
+ inlineSuggestionToggle = inlineSuggestionToggle,
)
private fun candidate(text: String): Candidate =
diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/InlineSuggestionPreferenceTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/InlineSuggestionPreferenceTest.kt
new file mode 100644
index 000000000..4edec4e7d
--- /dev/null
+++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/InlineSuggestionPreferenceTest.kt
@@ -0,0 +1,100 @@
+package com.kazumaproject.markdownhelperkeyboard.setting_activity.ui.setting
+
+import android.content.Context
+import androidx.preference.PreferenceManager
+import androidx.test.core.app.ApplicationProvider
+import com.kazumaproject.markdownhelperkeyboard.R
+import com.kazumaproject.markdownhelperkeyboard.setting_activity.AppPreference
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.xmlpull.v1.XmlPullParser
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [35])
+class InlineSuggestionPreferenceTest {
+
+ private val context = ApplicationProvider.getApplicationContext()
+
+ @After
+ fun tearDown() {
+ PreferenceManager.getDefaultSharedPreferences(context).edit().clear().commit()
+ }
+
+ @Test
+ fun settingExistsInNewAndLegacyCommonSettings() {
+ assertTrue(AppPreference.INLINE_SUGGESTION_ENABLED_KEY in preferenceKeys(R.xml.pref_common))
+ assertTrue(
+ AppPreference.INLINE_SUGGESTION_ENABLED_KEY in
+ preferenceKeys(R.xml.pref_common_legacy)
+ )
+ assertEquals(
+ "true",
+ preferenceDefaultValue(R.xml.pref_common, AppPreference.INLINE_SUGGESTION_ENABLED_KEY)
+ )
+ assertEquals(
+ "true",
+ preferenceDefaultValue(
+ R.xml.pref_common_legacy,
+ AppPreference.INLINE_SUGGESTION_ENABLED_KEY,
+ )
+ )
+ }
+
+ @Test
+ fun appPreferenceDefaultsToEnabledAndPersistsDisabledState() {
+ AppPreference.init(context)
+ assertTrue(AppPreference.inline_suggestion_enabled_preference)
+
+ AppPreference.inline_suggestion_enabled_preference = false
+
+ assertFalse(AppPreference.inline_suggestion_enabled_preference)
+ }
+
+ @Test
+ fun descriptionExplainsAndroidApiAndSupportRequirements() {
+ val summary = context.getString(R.string.inline_suggestion_enabled_summary_on)
+
+ assertTrue(summary.contains("Android 11"))
+ assertTrue(summary.contains("API 30"))
+ assertTrue(summary.contains("autofill service"))
+ assertTrue(summary.contains("target input field"))
+ }
+
+ private fun preferenceKeys(xmlRes: Int): Set {
+ val parser = context.resources.getXml(xmlRes)
+ return try {
+ buildSet {
+ while (parser.next() != XmlPullParser.END_DOCUMENT) {
+ if (parser.eventType != XmlPullParser.START_TAG) continue
+ parser.getAttributeValue(ANDROID_NS, "key")?.let(::add)
+ }
+ }
+ } finally {
+ parser.close()
+ }
+ }
+
+ private fun preferenceDefaultValue(xmlRes: Int, key: String): String? {
+ val parser = context.resources.getXml(xmlRes)
+ return try {
+ while (parser.next() != XmlPullParser.END_DOCUMENT) {
+ if (parser.eventType != XmlPullParser.START_TAG) continue
+ if (parser.getAttributeValue(ANDROID_NS, "key") != key) continue
+ return parser.getAttributeValue(ANDROID_NS, "defaultValue")
+ }
+ null
+ } finally {
+ parser.close()
+ }
+ }
+
+ private companion object {
+ private const val ANDROID_NS = "http://schemas.android.com/apk/res/android"
+ }
+}
diff --git a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/UtilityCandidatePreferenceTest.kt b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/UtilityCandidatePreferenceTest.kt
index 081ee0a17..c9b16a568 100644
--- a/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/UtilityCandidatePreferenceTest.kt
+++ b/app/src/test/java/com/kazumaproject/markdownhelperkeyboard/setting_activity/ui/setting/UtilityCandidatePreferenceTest.kt
@@ -23,6 +23,7 @@ import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.xmlpull.v1.XmlPullParser
import java.util.Locale
+import org.junit.Assert.assertFalse
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
@@ -43,6 +44,7 @@ class UtilityCandidatePreferenceTest {
assertTrue(config.calculationEnabled)
assertTrue(config.unitConversionEnabled)
assertTrue(config.includeExpressionCandidate)
+ assertTrue(config.formulaCandidateEnabled)
assertEquals(AngleMode.DEGREES, config.angleMode)
assertEquals(Precision.Auto, config.calculationPrecision)
assertEquals(RegionalUnitProfile.JAPAN, config.regionalUnitProfile)
@@ -62,6 +64,7 @@ class UtilityCandidatePreferenceTest {
val updated = AppPreference.utility_candidate_config.copy(
calculationEnabled = false,
includeExpressionCandidate = false,
+ formulaCandidateEnabled = false,
angleMode = AngleMode.RADIANS,
calculationPrecision = Precision.SignificantDigits(9),
regionalUnitProfile = RegionalUnitProfile.UNITED_KINGDOM,
@@ -71,6 +74,20 @@ class UtilityCandidatePreferenceTest {
assertEquals(updated, ImePreferencesSnapshot.from(AppPreference).utilityCandidateConfig)
}
+ @Test
+ fun resetRestoresFormulaCandidatesToTheDefaultOnState() {
+ AppPreference.utility_candidate_config = AppPreference.utility_candidate_config.copy(
+ formulaCandidateEnabled = false,
+ )
+ AppPreference.resetUtilityCandidateConfig()
+
+ assertTrue(AppPreference.utility_candidate_config.formulaCandidateEnabled)
+ assertFalse(
+ PreferenceManager.getDefaultSharedPreferences(context)
+ .contains(AppPreference.UTILITY_FORMULA_CANDIDATE_ENABLED_KEY),
+ )
+ }
+
@Test
fun decimalPrecisionPersistsForCalculationAndUnitTargets() {
val defaults = AppPreference.utility_candidate_config
@@ -151,6 +168,8 @@ class UtilityCandidatePreferenceTest {
assertEquals("小数点以下 2 桁", japanese.utilityPrecisionLabel(Precision.DecimalPlaces(2)))
assertEquals("有効数字 1 桁", japanese.utilityPrecisionLabel(Precision.SignificantDigits(1)))
assertEquals("有効数字 2 桁", japanese.utilityPrecisionLabel(Precision.SignificantDigits(2)))
+ assertEquals("[Text]", english.getString(R.string.candidate_badge_formula_unicode))
+ assertEquals("[文字]", japanese.getString(R.string.candidate_badge_formula_unicode))
}
@Test
@@ -161,6 +180,7 @@ class UtilityCandidatePreferenceTest {
null,
)
assertTrue(screen.findPreference(CALCULATION_KEY) != null)
+ assertTrue(screen.findPreference(FORMULA_KEY) != null)
assertTrue(screen.findPreference(PRECISION_KEY) != null)
TARGET_KEYS.forEach { key ->
assertTrue(key, screen.findPreference(key) != null)
@@ -189,6 +209,13 @@ class UtilityCandidatePreferenceTest {
PRECISION_KEY,
SettingDestinations.highlightPreferenceKey(precisionResult.destination),
)
+
+ val formulaResult = SettingSearchIndex.searchable(context, SettingSearchScope.NEW_HOME)
+ .first { it.key == FORMULA_KEY }
+ assertEquals(
+ R.id.utilityCandidatePreferenceFragment,
+ SettingDestinations.destinationId(formulaResult.destination),
+ )
}
@Test
@@ -264,6 +291,7 @@ class UtilityCandidatePreferenceTest {
private companion object {
const val CALCULATION_KEY = "utility_calculation_enabled"
+ const val FORMULA_KEY = "utility_formula_candidate_enabled"
const val PRECISION_KEY = "utility_calculation_precision"
const val ROUTE_KEY = "setting_route_utility_candidates"
val TARGET_KEYS = listOf(
diff --git a/core/src/main/java/com/kazumaproject/core/data/floating_candidate/CandidateItem.kt b/core/src/main/java/com/kazumaproject/core/data/floating_candidate/CandidateItem.kt
index a8f87798b..06b9ec13d 100644
--- a/core/src/main/java/com/kazumaproject/core/data/floating_candidate/CandidateItem.kt
+++ b/core/src/main/java/com/kazumaproject/core/data/floating_candidate/CandidateItem.kt
@@ -5,4 +5,8 @@ data class CandidateItem(
val length: UByte,
val candidateType: Byte = 0,
val sourceId: Long? = null,
+ /** Normalized bare TeX used by the app-side Canvas renderer, when this is a formula item. */
+ val formulaSource: String? = null,
+ /** Commit/display fallback kept so a renderer failure never loses the candidate text. */
+ val formulaFallbackText: String? = null,
)
diff --git a/docs/inline-autofill-qa.md b/docs/inline-autofill-qa.md
new file mode 100644
index 000000000..13c150f36
--- /dev/null
+++ b/docs/inline-autofill-qa.md
@@ -0,0 +1,175 @@
+# Inline Autofill QA 実行手順
+
+この手順では、Android の Inline Suggestions と Sumire の候補欄を実機で確認します。
+`fullStandardDebug` の本体 APK に含まれるデバッグ専用 AutofillService と、
+`fullStandardDebugAndroidTest` の QA 用ログイン画面を使用します。
+
+> この QA は Android 11(API 30)以降で実行してください。表示されるアカウント情報はすべて架空のテストデータです。
+
+## 1. 前提条件
+
+- リポジトリのルートでコマンドを実行する
+- Android SDK、JDK、Gradle Wrapper が利用できる
+- USB デバッグを有効にした Android 11 以降の実機、またはエミュレーターが接続されている
+- `adb devices -l` に確認対象の端末が表示される
+
+```bash
+adb devices -l
+```
+
+複数の端末が表示される場合は、以降のコマンドに `-s ` を追加して対象端末を固定してください。
+
+## 2. APK をビルドしてインストールする
+
+本体 APK と QA 用 test APK をビルドします。
+
+```bash
+./gradlew \
+ :app:assembleFullStandardDebug \
+ :app:assembleFullStandardDebugAndroidTest
+```
+
+生成された 2 つの APK を端末へインストールします。
+
+```bash
+adb install -r app/build/outputs/apk/fullStandard/debug/app-full-standard-debug.apk
+adb install -r app/build/outputs/apk/androidTest/fullStandard/debug/app-full-standard-debug-androidTest.apk
+```
+
+役割は次のとおりです。
+
+| APK | 役割 |
+|:--|:--|
+| `app-full-standard-debug.apk` | Sumire 本体、IME、デバッグ用 `DebugInlineAutofillService` |
+| `app-full-standard-debug-androidTest.apk` | QA 用 `InlineAutofillLoginActivity` |
+
+デバッグ用 AutofillService は `src/debug` にのみ存在するため、Release APK ではこの手順を実行できません。
+
+## 3. Sumire とデバッグ用 AutofillService を選択する
+
+Sumire を有効化して、現在の IME に設定します。
+
+```bash
+adb shell ime enable com.kazumaproject.markdownhelperkeyboard/.ime_service.IMEService
+adb shell ime set com.kazumaproject.markdownhelperkeyboard/.ime_service.IMEService
+```
+
+デバッグ用 AutofillService を選択します。
+
+```bash
+adb shell settings put secure autofill_service com.kazumaproject.markdownhelperkeyboard/.autofill.DebugInlineAutofillService
+```
+
+設定結果を確認します。
+
+```bash
+adb shell ime list -s
+adb shell settings get secure autofill_service
+```
+
+`settings get` の結果が次の値になっていれば、デバッグ用 AutofillService が選択されています。
+
+```text
+com.kazumaproject.markdownhelperkeyboard/.autofill.DebugInlineAutofillService
+```
+
+また、Sumire の設定画面で **共通設定 → インライン候補 → インライン候補(自動入力)を使用** が ON になっていることも確認してください。
+
+## 4. QA 画面を起動する
+
+端末がスリープしている場合は先に起こします。
+
+```bash
+adb shell input keyevent KEYCODE_WAKEUP
+```
+
+QA 用ログイン画面を起動します。
+
+```bash
+adb shell am force-stop com.kazumaproject.markdownhelperkeyboard.test
+adb shell am start -n com.kazumaproject.markdownhelperkeyboard.test/com.kazumaproject.markdownhelperkeyboard.qa.InlineAutofillLoginActivity
+```
+
+画面には「ユーザー名」と「パスワード」の入力欄が表示されます。起動後、次の動作を確認します。
+
+1. 3 秒ほど待つとパスワード欄が選択され、Sumire が表示される。
+2. inline 候補欄に `🔐 個人アカウント`、`🔐 仕事用` などの架空候補が表示される。
+3. 候補欄左端の切り替えアイコンをタップすると、inline 候補が通常の変換候補欄へ切り替わる。
+4. 同じアイコンをもう一度タップすると、inline 候補欄へ戻る。
+
+候補が表示されない場合は、ユーザー名またはパスワードの入力欄を一度タップしてから数秒待ってください。
+
+## 5. スクリーンショットとログを取得する
+
+表示確認用のスクリーンショットをホスト側へ保存します。
+
+```bash
+adb exec-out screencap -p > /tmp/inline-autofill-qa.png
+```
+
+デバッグ用 AutofillService と IME のログを確認します。
+
+```bash
+adb logcat -d -t 300 | rg 'SumireInlineAutofillQA|IMEService|Inline'
+```
+
+特定タグをリアルタイムで見る場合は、別のターミナルで次を実行します。
+
+```bash
+adb logcat -s SumireInlineAutofillQA:D IMEService:D '*:S'
+```
+
+正常時は、デバッグ用サービスが 4 件の Dataset を返し、IME が inline suggestion view を描画したログが確認できます。
+
+## 6. トラブルシューティング
+
+### inline 候補が表示されない
+
+次を順番に確認します。
+
+```bash
+adb shell settings get secure autofill_service
+adb shell ime list -s
+adb shell cmd autofill reset
+```
+
+- 端末が Android 11 以降であること
+- `DebugInlineAutofillService` が選択されていること
+- Sumire が現在の IME であること
+- QA 画面の入力欄をタップしてフォーカスを移していること
+- Sumire の「インライン候補(自動入力)」設定が ON であること
+
+設定を変更した後は、QA 画面を再起動してください。
+
+### スクリーンショットが黒い
+
+端末がスリープ中の可能性があります。
+
+```bash
+adb shell input keyevent KEYCODE_WAKEUP
+```
+
+その後、QA 画面を再起動して撮影します。
+
+### インストール先のパッケージが合わない
+
+この手順は `fullStandardDebug` 専用です。`lite` や `fdroid` をビルドした場合は、APK の出力先とパッケージ名に suffix が付くため、上記の component 名はそのまま使用できません。
+
+## 7. 端末を元に戻す
+
+QA 終了後、デバッグ用 AutofillService と Sumire の選択を解除します。
+
+```bash
+adb shell cmd autofill reset
+adb shell settings delete secure autofill_service
+adb shell ime reset
+adb shell am force-stop com.kazumaproject.markdownhelperkeyboard.test
+```
+
+`settings put secure autofill_service` の実行前に別の AutofillService を使っていた場合は、削除する代わりに元の component 名を再設定してください。
+
+## 関連ファイル
+
+- `app/src/debug/java/com/kazumaproject/markdownhelperkeyboard/autofill/DebugInlineAutofillService.kt`
+- `app/src/androidTest/java/com/kazumaproject/markdownhelperkeyboard/qa/InlineAutofillLoginActivity.java`
+- `app/src/debug/res/xml/debug_inline_autofill_service.xml`