Keyboard: add the keys the Android IME does not have

The emulated keyboard reaches games now, and the first thing it was used for was
a game's debug menu -- which opened on Space and then could not be navigated,
because a soft keyboard is built for typing text and has no arrows, no Escape and
no function row. For that job those are not optional extras, they are the whole
interaction.

A row of them floats above the IME whenever the keyboard is up: Esc, Tab, the
four arrows, Enter, and an Fn toggle for F1-F12. It scrolls sideways so nothing
is cut off on a narrow screen, and it appears and disappears with the keyboard,
so there is nothing to place in the touch layout and nothing to find.

This adds to the IME rather than replacing it. Prediction, swipe and non-Latin
input still come from whichever keyboard the user chose; only the keys that
keyboard cannot express come from here.

Taps go through SoftKeyboard.tap, the same paced queue the IME's own keys use, so
a press is held long enough for the guest to sample it. The keys use tap gesture
detection rather than clickable(): this sits beside a focused IME sink, and
anything focusable here can take focus off it and drop the keyboard mid-use.

The arrows are KEYCODE_DPAD_*, which the handler already maps to Qt's arrow
codes, so nothing was needed on the native side.
This commit is contained in:
jpolo1224
2026-08-19 13:17:38 -04:00
parent da4169148f
commit ef63026354
2 changed files with 135 additions and 0 deletions
@@ -0,0 +1,128 @@
package com.armsx2.input
import android.view.KeyEvent
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.sizeIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.foundation.gestures.detectTapGestures
/**
* The keys the Android IME does not have, floated above it.
*
* The emulated keyboard works, but a soft keyboard is built for typing text and has no
* arrows, no Escape and no function row -- so the first thing it was used for, a game's
* debug menu, opened and then could not be navigated. Those keys are not optional extras
* for that job; they are the whole interaction.
*
* This does not replace the IME. Prediction, swipe and non-Latin input all still come from
* whichever keyboard the user has chosen, and this only adds what that keyboard cannot
* express. It appears and disappears with [SoftKeyboard.visible], so there is nothing to
* place in the touch layout and nothing to discover.
*
* Taps go through [SoftKeyboard.tap], the same paced queue the IME's own keys use, so a
* press is held long enough for the guest to sample it (see KEY_STEP_MS -- a press and
* release issued back to back can land entirely between two guest polls and be missed).
*
* Gestures rather than clickable(): this sits next to a focused IME sink, and anything
* focusable here can take focus off it and drop the keyboard mid-use. detectTapGestures
* never touches focus.
*/
private data class ExtraKey(val label: String, val code: Int, val wide: Boolean = false)
private val BASE_KEYS = listOf(
ExtraKey("Esc", KeyEvent.KEYCODE_ESCAPE),
ExtraKey("Tab", KeyEvent.KEYCODE_TAB),
ExtraKey("", KeyEvent.KEYCODE_DPAD_LEFT),
ExtraKey("", KeyEvent.KEYCODE_DPAD_UP),
ExtraKey("", KeyEvent.KEYCODE_DPAD_DOWN),
ExtraKey("", KeyEvent.KEYCODE_DPAD_RIGHT),
ExtraKey("", KeyEvent.KEYCODE_ENTER),
)
// KEYCODE_F1..F12 are contiguous, the same way the handler's qt mapping assumes.
private val FN_KEYS = (0..11).map { ExtraKey("F${it + 1}", KeyEvent.KEYCODE_F1 + it) }
@Composable
fun BoxScope.KeyboardExtraKeys() {
if (!SoftKeyboard.visible.value) return
var showFn by remember { mutableStateOf(false) }
Surface(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.92f),
shape = RoundedCornerShape(topStart = 12.dp, topEnd = 12.dp),
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
// imePadding lifts it clear of the keyboard; navigationBarsPadding keeps it off
// the gesture bar on the frames where the IME is animating out.
.imePadding()
.navigationBarsPadding(),
) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 6.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
for (key in if (showFn) FN_KEYS else BASE_KEYS) {
KeyCap(key.label) { SoftKeyboard.tap(key.code) }
}
KeyCap(if (showFn) "abc" else "Fn", accent = true) { showFn = !showFn }
}
}
}
@Composable
private fun KeyCap(label: String, accent: Boolean = false, onTap: () -> Unit) {
Box(
modifier = Modifier
.sizeIn(minWidth = 44.dp)
.heightIn(min = 40.dp)
.clip(RoundedCornerShape(8.dp))
.background(
if (accent) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceVariant
)
.pointerInput(label) { detectTapGestures { onTap() } }
.padding(horizontal = 10.dp, vertical = 8.dp),
contentAlignment = Alignment.Center,
) {
Text(
label,
color = if (accent) MaterialTheme.colorScheme.onPrimaryContainer
else MaterialTheme.colorScheme.onSurfaceVariant,
fontSize = 15.sp,
fontWeight = FontWeight.Medium,
)
}
}
@@ -1,5 +1,6 @@
package com.armsx2.ui
import com.armsx2.input.KeyboardExtraKeys
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
@@ -130,6 +131,12 @@ object WindowImpl {
com.armsx2.ui.touch.TouchControlsOverlay()
}
// The keys the IME does not have (arrows, Esc, Tab, function row), shown only
// while the emulated keyboard is up. Outside the density override above: this
// is normal UI and should scale with the UI scale setting, unlike the touch
// controls, whose size comes from the user's own layout.
KeyboardExtraKeys()
if (showLibrary.value && MainActivityRuntime.eState.value == EmuState.RUNNING && !overlayVisible.value) {
Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.56f))) {
com.armsx2.navigation.AppNavigation()