Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.kazumaproject.markdownhelperkeyboard.ime_service.editor

import android.text.InputType
import android.view.KeyEvent
import android.view.inputmethod.EditorInfo
import com.kazumaproject.markdownhelperkeyboard.ime_service.input_behavior.TypeNullInputBehaviorSetting

/** Only the editor-facing Enter. Composition/segment confirmation is owned by the caller. */
sealed interface EditorEnterAction {
data object Enter : EditorEnterAction
data object Newline : EditorEnterAction
data class Action(val id: Int) : EditorEnterAction
}

object EditorEnterPolicy {
fun resolve(editorInfo: EditorInfo?): EditorEnterAction {
if (editorInfo == null) return EditorEnterAction.Enter
val action = editorInfo.imeOptions and EditorInfo.IME_MASK_ACTION
if (editorInfo.imeOptions and EditorInfo.IME_FLAG_NO_ENTER_ACTION == 0 &&
action in EditorInfo.IME_ACTION_GO..EditorInfo.IME_ACTION_PREVIOUS
) {
return EditorEnterAction.Action(action)
}
// Gboard raw captures use commitText("\n") for SHORT_MESSAGE, independent of
// multiline flags; other observed text variations send an Enter key pair.
return if (editorInfo.inputType and InputType.TYPE_MASK_CLASS == InputType.TYPE_CLASS_TEXT &&
editorInfo.inputType and InputType.TYPE_MASK_VARIATION == InputType.TYPE_TEXT_VARIATION_SHORT_MESSAGE
) EditorEnterAction.Newline else EditorEnterAction.Enter
}

fun usesEditorActionForDefaultTypeNull(
inputType: Int?,
setting: TypeNullInputBehaviorSetting,
hasExplicitDirectOverride: Boolean,
): Boolean = inputType == InputType.TYPE_NULL &&
setting == TypeNullInputBehaviorSetting.DEFAULT && !hasExplicitDirectOverride

// Existing built-in key states 0..5 keep their meanings; 6..8 are editor actions.
fun keyStateIndex(action: EditorEnterAction): Int = when (action) {
EditorEnterAction.Enter, EditorEnterAction.Newline -> 0
is EditorEnterAction.Action -> when (action.id) {
EditorInfo.IME_ACTION_GO -> 7
EditorInfo.IME_ACTION_SEARCH -> 3
EditorInfo.IME_ACTION_SEND -> 8
EditorInfo.IME_ACTION_NEXT -> 4
EditorInfo.IME_ACTION_PREVIOUS -> 6
else -> 5
}
}

fun label(action: EditorEnterAction, japanese: Boolean): String = when (action) {
EditorEnterAction.Enter, EditorEnterAction.Newline -> if (japanese) "改行" else "return"
is EditorEnterAction.Action -> when (action.id) {
EditorInfo.IME_ACTION_GO -> if (japanese) "実行" else "go"
EditorInfo.IME_ACTION_SEARCH -> if (japanese) "検索" else "search"
EditorInfo.IME_ACTION_SEND -> if (japanese) "送信" else "send"
EditorInfo.IME_ACTION_NEXT -> if (japanese) "次へ" else "next"
EditorInfo.IME_ACTION_PREVIOUS -> if (japanese) "前へ" else "previous"
else -> if (japanese) "完了" else "done"
}
}

/** Keep the service's existing dispatch wrappers (and mutation bookkeeping) in the path. */
fun dispatch(action: EditorEnterAction, sendKey: (Int) -> Unit, performAction: (Int) -> Unit, commitNewline: () -> Unit) {
when (action) {
EditorEnterAction.Enter -> sendKey(KeyEvent.KEYCODE_ENTER)
EditorEnterAction.Newline -> commitNewline()
is EditorEnterAction.Action -> performAction(action.id)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package com.kazumaproject.markdownhelperkeyboard.ime_service.editor

import android.view.KeyEvent
import android.view.inputmethod.EditorInfo
import com.kazumaproject.markdownhelperkeyboard.ime_service.input_behavior.TypeNullInputBehaviorSetting
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class EditorEnterPolicyTest {
@Test fun recordedGboardMatrix() {
val stream = checkNotNull(javaClass.getResourceAsStream("/enter_parity/gboard.tsv"))
val rows = stream.bufferedReader().use { it.readLines() }.drop(1)
// 22 input types × 8 actions × 4 multiline states × 2 suppression states × 4 custom actions,
// plus 16 action/suppression combinations × 33 independent probes and one field regression.
assertEquals("Incomplete Gboard reference", 22 * 8 * 4 * 2 * 4 + 16 * 33 + 1, rows.size)
assertEquals("Duplicate case IDs", rows.size, rows.map { it.substringBefore('\t') }.toSet().size)
rows.forEach { line ->
val parts = line.split('\t')
val info = EditorInfo().apply {
inputType = parts[1].toInt()
imeOptions = parts[2].toLong().toInt()
actionLabel = parts[3].takeUnless { it == "\\N" }
actionId = parts[4].toInt()
hintText = parts.getOrNull(6)?.takeUnless { it == "\\N" }
fieldName = parts.getOrNull(7)?.takeUnless { it == "\\N" }
privateImeOptions = parts.getOrNull(8)?.takeUnless { it == "\\N" }
}
val expected = when (parts[5]) {
"enter" -> EditorEnterAction.Enter
"newline" -> EditorEnterAction.Newline
else -> EditorEnterAction.Action(parts[5].toInt())
}
assertEquals(parts[0], expected, EditorEnterPolicy.resolve(info))
}
}

@Test fun mapsNoEnterActionDoesNotDispatchDone() {
val info = EditorInfo().apply { inputType = 0x264001; imeOptions = 0x40000006 }
val keys = mutableListOf<Int>()
val actions = mutableListOf<Int>()
EditorEnterPolicy.dispatch(EditorEnterPolicy.resolve(info), { keys += it }, { actions += it }, { fail("Unexpected newline commit") })
assertEquals(listOf(KeyEvent.KEYCODE_ENTER), keys)
assertTrue(actions.isEmpty())
assertEquals("改行", EditorEnterPolicy.label(EditorEnterPolicy.resolve(info), true))
}

@Test fun fieldSwitchesDoNotRetainPreviousActionOrSendTwice() {
val actions = mutableListOf<Int>()
val keys = mutableListOf<Int>()
listOf(3, 0x40000006, 5, 6).forEach { options ->
EditorEnterPolicy.dispatch(EditorEnterPolicy.resolve(EditorInfo().apply { imeOptions = options }),
{ keys += it }, { actions += it }, { fail("Unexpected newline commit") })
}
assertEquals(listOf(3, 5, 6), actions)
assertEquals(listOf(KeyEvent.KEYCODE_ENTER), keys)
}

@Test fun builtInDynamicKeyStatesDescribeTheDispatchedAction() {
val states = checkNotNull(com.kazumaproject.custom_keyboard.layout.KeyboardDefaultLayouts
.createNumberLayout().keys.single { it.keyId == "enter_key" }.dynamicStates)
val labels = mapOf(2 to "実行", 3 to "検索", 4 to "送信", 5 to "次", 6 to "確定", 7 to "前へ")
labels.forEach { (id, label) ->
val action = EditorEnterAction.Action(id)
assertEquals(label, states[EditorEnterPolicy.keyStateIndex(action)].label)
}
assertEquals("改行", states[EditorEnterPolicy.keyStateIndex(EditorEnterAction.Enter)].label)
}

@Test fun shortMessageCommitsOnlyNewlineWhenActionIsSuppressed() {
val commits = mutableListOf<String>()
val info = EditorInfo().apply { inputType = 0x41; imeOptions = 0x40000004 }
EditorEnterPolicy.dispatch(EditorEnterPolicy.resolve(info),
{ fail("Must not send key events") }, { fail("Must not send SEND") }, { commits += "\n" })
assertEquals(listOf("\n"), commits)
}

@Test fun typeNullDefaultEnterIsSeparateFromExplicitDirectSettings() {
assertTrue(EditorEnterPolicy.usesEditorActionForDefaultTypeNull(0, TypeNullInputBehaviorSetting.DEFAULT, false))
assertFalse(EditorEnterPolicy.usesEditorActionForDefaultTypeNull(1, TypeNullInputBehaviorSetting.DEFAULT, false))
assertFalse(EditorEnterPolicy.usesEditorActionForDefaultTypeNull(null, TypeNullInputBehaviorSetting.DEFAULT, false))
assertFalse(EditorEnterPolicy.usesEditorActionForDefaultTypeNull(0, TypeNullInputBehaviorSetting.DIRECT_COMMIT, false))
assertFalse(EditorEnterPolicy.usesEditorActionForDefaultTypeNull(0, TypeNullInputBehaviorSetting.DEFAULT, true))
assertFalse(EditorEnterPolicy.usesEditorActionForDefaultTypeNull(0, TypeNullInputBehaviorSetting.COMPOSING_TEXT, false))
}

@Test fun absentEditorAndUnknownActionDoNotGuessAnAction() {
assertEquals(EditorEnterAction.Enter, EditorEnterPolicy.resolve(null))
assertEquals(EditorEnterAction.Enter, EditorEnterPolicy.resolve(EditorInfo().apply { imeOptions = 255 }))
}
}
Loading
Loading