package dev.stave.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Backspace
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Mic
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.tv.material3.Icon
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import dev.stave.ui.theme.AccentAmber
import dev.stave.ui.theme.OnMuted
import dev.stave.ui.theme.OnPrimary
import dev.stave.ui.theme.SurfaceRaised
import dev.stave.ui.theme.loadFraunces
import dev.stave.ui.theme.staveInterFamily
import dev.stave.ui.theme.withInterDigits
/**
* On-screen D-pad-navigable keyboard. Designed as a drop-in replacement for the
* Android system IME on TV, inspired by Kodi's Copacetic skin.
*
* Why a custom keyboard rather than the system IME:
* - The Shield's system keyboard takes over the screen and is hard to
* dismiss; it's a usability cliff in a D-pad flow.
* - Every serious TV media app (Kodi, Plex, Jellyfin) rolls its own for
* this reason.
* - Having our own means we can do live narrowing next to the keys, style
* it to match the app, and never have IME-reappears bugs.
*
* Layout principles:
* - QWERTY (not alphabetical) — people find letters via muscle memory.
* - Flat buttons. Focused key shows an amber background tint; no borders.
* - 3 alphabet rows (10 / 9 / 7) + 1 function row with Shift, Space,
* Backspace, Mic (conditional), Done.
* - Single view with a Shift key that cycles: lower → Upper → 123 → lower.
* A small indicator under Shift shows the current layer.
*
* Focus policy:
* - On first composition the keyboard grabs focus on the top-left key (Q
* in the lowercase layout).
* - Compose's spatial focus handles all internal navigation.
* - Keys in column 0 of each row wrap a [FocusRequester] that the parent
* can observe via [onLeftFromColumn0]; when the user presses Left there,
* we invoke the callback so the parent can route focus out of the
* keyboard (to the Now Playing bar or the Earhorn button).
*
* Voice: the Mic key invokes [onVoice] which the parent wires to
* [VoiceSearchDialog]. The dialog delegates to the system's RecognizerIntent
* — the same path the system keyboard's mic button uses — because trying to
* run the in-process SpeechRecognizer on TV builds was unreliable: Shield
* TV in particular doesn't ship with an on-device recognition model, and
* the third-party RecognitionService binding fails silently during audio
* capture there. The system intent always works wherever the system
* keyboard's mic does, which is the user's working baseline.
* [voiceAvailable] controls whether the key renders at all.
*/
@Composable
fun OnScreenKeyboard(
query: String,
onKey: (String) -> Unit,
onBackspace: () -> Unit,
/** Invoked when the user taps the Clear key. Parent wipes the
* entire query in one go (vs. Backspace which removes one char).
* Separate callback rather than onBackspace-with-length so the
* parent can decide what "clear" means for its search state
* (e.g. also reset live-search, dismiss debounced pending calls). */
onClear: () -> Unit,
onDone: () -> Unit,
/** Invoked when the user taps the mic key. Parent opens its own voice
* dialog in response (we don't own the dialog here — keyboard stays
* pure UI, dialog lifecycle belongs to the owning screen so search
* state mutation / navigation still happen in the right place). */
onVoice: () -> Unit,
/** When false, the mic key is hidden from the function row. Device
* has no on-device recognizer available. */
voiceAvailable: Boolean,
/** Invoked when the user presses D-pad Up from a key in the TOP row
* (number row). This is the natural "exit the keyboard" gesture:
* parent closes the search session and parks focus back where search
* was launched from (typically the Earhorn button). Inner rows pass Up
* through to spatial focus for normal row-to-row navigation. */
onUpEscape: () -> Unit,
/** Attached to the Q key (top-left of the letter rows). Hoisted out
* of the keyboard so the parent can route focus TO Q from elsewhere
* — e.g. Left-from-first-recent-search should land on Q as if the
* user had just walked in. Passed by the parent rather than created
* inside; parent's lifetime outlives individual keyboard compositions. */
firstKeyFocus: FocusRequester,
/** When true (default), the keyboard requests focus on Q on first
* composition. Parent sets this false when it wants initial focus
* elsewhere (e.g. on the recents list, so the user walks INTO the
* keyboard from the right rather than the keyboard grabbing focus
* on open). */
autoFocusOnEnter: Boolean = true,
modifier: Modifier = Modifier,
) {
val ctx = LocalContext.current
// Conditionally claim focus on first composition. When the parent has
// chosen to seat focus elsewhere (typically the recents list), skip so
// we don't fight the parent's own focus request.
LaunchedEffect(Unit) {
if (autoFocusOnEnter) {
runCatching { firstKeyFocus.requestFocus() }
}
}
// Hardware-remote shortcuts caught at the top-level column so they fire
// regardless of focused key.
// Play/Pause = Space
// Rewind / SkipBackward = Backspace
val hardwareKeys = Modifier.onPreviewKeyEvent { ev ->
if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when (ev.key) {
Key.MediaPlayPause, Key.MediaPlay, Key.MediaPause -> {
onKey(" "); true
}
Key.MediaRewind, Key.MediaSkipBackward -> {
onBackspace(); true
}
else -> false
}
}
Column(
modifier = modifier.then(hardwareKeys),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
// Query display — typed text with an amber caret. Not focusable.
Box(
Modifier
.fillMaxWidth()
.background(SurfaceRaised)
.padding(horizontal = 16.dp, vertical = 14.dp),
) {
if (query.isEmpty()) {
// Just the amber caret — the pill in the header bar
// already shows "Search…", so repeating it here doubles up.
Box(
Modifier
.padding(start = 2.dp)
.size(width = 2.dp, height = 18.dp)
.background(AccentAmber)
)
} else {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
withInterDigits(query),
style = MaterialTheme.typography.bodyLarge,
color = OnPrimary,
)
Box(
Modifier
.padding(start = 2.dp)
.size(width = 2.dp, height = 18.dp)
.background(AccentAmber)
)
}
}
}
// Four character rows (QWERTY letters, home row, bottom row, digits),
// each a simple weighted Row (not LazyRow). Rows are 12 keys wide and
// fit the keyboard pane without horizontal scrolling. Wrap-around on
// both ends of each row.
//
// Fonts are resolved once here and passed into each key: Fraunces
// (opsz=144) for letters and symbols, Inter for digits. Per-key font
// selection is a single branch on isDigit inside CharacterKey.
val frauncesFamily = remember(ctx) { loadFraunces(ctx) }
val interFamily = staveInterFamily()
val rows = allRows()
val rowFocusRequesters: List<List<FocusRequester>> = remember(rows.size, rows.map { it.keys.size }) {
rows.map { r -> List(r.keys.size) { FocusRequester() } }
}
// Every function key has its own FocusRequester. Down from the
// bottom character row routes to whichever key spatially covers
// that column (proportional mapping based on the function row's
// actual weights), so the user lands where their eye was — no
// surprise jumps from a digit on the right to Space on the far
// left just because Space is wider.
val spaceKeyFocus = remember { FocusRequester() }
val backspaceKeyFocus = remember { FocusRequester() }
val clearKeyFocus = remember { FocusRequester() }
val micKeyFocus = remember { FocusRequester() }
val doneKeyFocus = remember { FocusRequester() }
// Weight order matches the render order in the function row. The
// mic slot disappears when voiceAvailable is false.
val functionKeyTargets: List<Pair<FocusRequester, Double>> = remember(voiceAvailable) {
buildList {
add(spaceKeyFocus to if (voiceAvailable) 5.0 else 6.0)
add(backspaceKeyFocus to 2.0)
add(clearKeyFocus to 2.0)
if (voiceAvailable) add(micKeyFocus to 1.2)
add(doneKeyFocus to 1.2)
}
}
val functionKeyTotalWeight = remember(functionKeyTargets) {
functionKeyTargets.sumOf { it.second }
}
rows.forEachIndexed { rowIdx, row ->
CharacterRow(
keys = row.keys,
firstKeyFocus = if (rowIdx == 0) firstKeyFocus else null,
focusRequesters = rowFocusRequesters[rowIdx],
frauncesFamily = frauncesFamily,
interFamily = interFamily,
onKey = onKey,
onUpFromCol = { col ->
if (rowIdx == 0) {
onUpEscape()
} else {
val target = rowFocusRequesters[rowIdx - 1]
runCatching {
target[col.coerceIn(0, target.lastIndex)].requestFocus()
}
}
},
onDownFromCol = if (rowIdx < rows.lastIndex) {
{ col ->
val target = rowFocusRequesters[rowIdx + 1]
runCatching {
target[col.coerceIn(0, target.lastIndex)].requestFocus()
}
}
} else {
// Bottom char row → pick the function key whose x-range
// covers the column. Midpoint of the column (in 0..1)
// multiplied by total weight gives a cumulative weight
// value; iterate keys accumulating weights and stop at
// the first whose running total exceeds it.
{ col ->
val colMidpoint = (col + 0.5) / row.keys.size
val weightAtCol = colMidpoint * functionKeyTotalWeight
var accum = 0.0
var target = functionKeyTargets.last().first
for ((fr, w) in functionKeyTargets) {
accum += w
if (weightAtCol < accum) {
target = fr
break
}
}
runCatching { target.requestFocus() }
}
},
)
}
// Function row: Space · Backspace · Clear · [Mic] · Done. Clear
// wipes the entire query in one press (Backspace is one-char);
// placed next to Backspace because they're both deletion actions,
// ordered smaller-to-larger scope.
// Function row wrapped in a Box that swallows D-pad Down so focus
// doesn't escape to whatever is below the keyboard (typically the
// Now Playing bar). Exits from the keyboard are deliberate: Up
// (top row → close), Back, or Done.
Box(
Modifier.onPreviewKeyEvent { ev ->
if (ev.type == KeyEventType.KeyDown && ev.key == Key.DirectionDown) {
true // consume
} else false
}
) {
KeyRow {
FunctionKey(
label = "space",
onClick = { onKey(" ") },
onLeftEscape = null,
weight = if (voiceAvailable) 5.0f else 6.0f,
focusRequester = spaceKeyFocus,
)
FunctionKey(
icon = Icons.Filled.Backspace,
contentDescription = "Backspace",
onClick = onBackspace,
onLeftEscape = null,
weight = 2.0f,
focusRequester = backspaceKeyFocus,
)
FunctionKey(
label = "clear",
onClick = onClear,
onLeftEscape = null,
weight = 2.0f,
focusRequester = clearKeyFocus,
)
if (voiceAvailable) {
FunctionKey(
icon = Icons.Filled.Mic,
contentDescription = "Voice search",
onClick = onVoice,
onLeftEscape = null,
weight = 1.2f,
focusRequester = micKeyFocus,
)
}
FunctionKey(
icon = Icons.Filled.Check,
contentDescription = "Search",
onClick = onDone,
onLeftEscape = null,
weight = 1.2f,
focusRequester = doneKeyFocus,
)
}
}
}
}
// -----------------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------------
/** One of the four character rows. Font-family is chosen per-character
* inside CharacterKey — digits (0-9) render in Inter because Fraunces
* digits at key size are hard to scan, everything else (including & @
* and all punctuation) stays on Fraunces alongside the letters. */
private data class CharRow(val keys: List<String>)
private fun allRows(): List<CharRow> = listOf(
CharRow(listOf("q","w","e","r","t","y","u","i","o","p","'","\"")),
CharRow(listOf("a","s","d","f","g","h","j","k","l","-","_",".")),
CharRow(listOf("z","x","c","v","b","n","m","?","!",",","/",":")),
CharRow(listOf("1","2","3","4","5","6","7","8","9","0","&","@")),
)
/** Row container — horizontal flow with consistent spacing between keys and
* a fixed height so rows align even when content differs.
*
* content lambda takes RowScope as its receiver so the child Key/Function
* composables (which are RowScope extension functions, using .weight()) can
* resolve their receiver. Without the receiver type here, the children
* would appear unresolved at the call site. */
@Composable
private fun KeyRow(content: @Composable androidx.compose.foundation.layout.RowScope.() -> Unit) {
Row(
Modifier
.fillMaxWidth()
.height(54.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
content()
}
}
/**
* Row of character keys in a uniform weighted Row. Every key has weight=1
* so the row fills the available width with 12 equal cells. All keys are
* always composed — this is the critical reliability property: explicit
* cross-row Up/Down focus routing depends on every target FocusRequester
* being attached to a real focus node, which LazyRow's virtualization
* would break for off-screen items (causing "Up does nothing" and
* "sometimes jumps 2 keys over" symptoms).
*
* Navigation out of the row:
* - Left on first key → wrap to last key in this row
* - Right on last key → wraps to first key of same row (speed-typing
* shortcut — symmetric with the Left-from-first → last wrap)
* - Up/Down → invoke [onUpFromCol]/[onDownFromCol] with the key's column
* index; parent routes focus to the column-aligned key in adjacent row.
*/
@Composable
private fun CharacterRow(
keys: List<String>,
firstKeyFocus: FocusRequester?,
focusRequesters: List<FocusRequester>,
frauncesFamily: androidx.compose.ui.text.font.FontFamily,
interFamily: androidx.compose.ui.text.font.FontFamily,
onKey: (String) -> Unit,
onUpFromCol: (Int) -> Unit,
onDownFromCol: ((Int) -> Unit)?,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(54.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
keys.forEachIndexed { colIdx, label ->
val isFirst = colIdx == 0
val isLast = colIdx == keys.lastIndex
CharacterKey(
label = label,
frauncesFamily = frauncesFamily,
interFamily = interFamily,
onClick = { onKey(label) },
focusRequester = if (isFirst && firstKeyFocus != null)
firstKeyFocus else focusRequesters[colIdx],
// When firstKeyFocus is active on Q (row 0, col 0), also
// attach focusRequesters[0] to the same key so cross-row
// routing (e.g. Up-from-A) and within-row wrap
// (e.g. Right-from-") — which use focusRequesters[0] —
// find an attached handle.
secondaryFocusRequester = if (isFirst && firstKeyFocus != null)
focusRequesters[0] else null,
onLeftWrap = if (isFirst) {
{
runCatching { focusRequesters[keys.lastIndex].requestFocus() }
}
} else null,
onRightWrap = if (isLast) {
{
runCatching { focusRequesters[0].requestFocus() }
}
} else null,
onUp = { onUpFromCol(colIdx) },
onDown = if (onDownFromCol != null) {
{ onDownFromCol(colIdx) }
} else null,
)
}
}
}
/** Single character key. weight=1 in the parent Row so every row's 12
* cells divide the width uniformly. Column alignment is geometric, which
* keeps spatial focus and explicit column routing in lockstep.
*
* Font: non-digit keys use Fraunces (opsz=144), digit keys use Inter.
* Families are resolved once in the parent keyboard composable and passed
* in — applying fontFamily directly rather than leaning on the theme style
* because titleLarge's Fraunces assignment drops off symbol glyphs
* (' " - _ . ? ! , / : & @) on some TV Compose builds. */
@Composable
private fun androidx.compose.foundation.layout.RowScope.CharacterKey(
label: String,
frauncesFamily: androidx.compose.ui.text.font.FontFamily,
interFamily: androidx.compose.ui.text.font.FontFamily,
onClick: () -> Unit,
focusRequester: FocusRequester,
/** Optional second FocusRequester attached to the same key. Used on
* the Q key (row 0, col 0) where BOTH the screen-wide firstKeyFocus
* handle AND the row's own focusRequesters[0] need to point at that
* key — otherwise the unused one is left unattached and
* requestFocus on it is a silent no-op. */
secondaryFocusRequester: FocusRequester? = null,
onLeftWrap: (() -> Unit)?,
onRightWrap: (() -> Unit)?,
onUp: () -> Unit,
onDown: (() -> Unit)?,
) {
val interaction = remember { MutableInteractionSource() }
val focused by interaction.collectIsFocusedAsState()
val shape = RoundedCornerShape(4.dp)
val isDigit = label.length == 1 && label[0].isDigit()
val family = if (isDigit) interFamily else frauncesFamily
val textStyle = MaterialTheme.typography.titleLarge.copy(fontFamily = family)
Box(
Modifier
.weight(1f)
.fillMaxHeight()
.clip(shape)
.background(SurfaceRaised)
.then(
if (focused) Modifier.border(1.5.dp, AccentAmber, shape)
else Modifier
)
.focusRequester(focusRequester)
.then(
if (secondaryFocusRequester != null)
Modifier.focusRequester(secondaryFocusRequester)
else Modifier
)
.onPreviewKeyEvent { ev ->
if (ev.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when (ev.key) {
Key.DirectionLeft -> {
if (onLeftWrap != null) { onLeftWrap(); true }
else false
}
Key.DirectionRight -> {
if (onRightWrap != null) { onRightWrap(); true }
else false
}
Key.DirectionUp -> {
onUp(); true
}
Key.DirectionDown -> {
if (onDown != null) { onDown(); true }
else false
}
else -> false
}
}
.clickable(interaction, indication = null) { onClick() },
contentAlignment = Alignment.Center,
) {
Text(
label,
style = textStyle,
color = OnPrimary,
)
}
}
/** Function key — wider, can carry either a text label OR an icon, with an
* optional secondary hint line (e.g. "⇧" under Shift). */
@Composable
private fun androidx.compose.foundation.layout.RowScope.FunctionKey(
label: String? = null,
hint: String? = null,
icon: androidx.compose.ui.graphics.vector.ImageVector? = null,
contentDescription: String? = null,
onClick: () -> Unit,
onLeftEscape: (() -> Unit)?,
weight: Float,
focusRequester: FocusRequester? = null,
) {
val interaction = remember { MutableInteractionSource() }
val focused by interaction.collectIsFocusedAsState()
val shape = RoundedCornerShape(4.dp)
Box(
Modifier
.weight(weight)
.fillMaxHeight()
.clip(shape)
.background(SurfaceRaised)
.then(
if (focused) Modifier.border(1.5.dp, AccentAmber, shape)
else Modifier
)
.then(
if (focusRequester != null) Modifier.focusRequester(focusRequester)
else Modifier
)
.then(
if (onLeftEscape != null) Modifier.onPreviewKeyEvent { ev ->
if (ev.type == KeyEventType.KeyDown && ev.key == Key.DirectionLeft) {
onLeftEscape()
true
} else false
} else Modifier
)
.clickable(interaction, indication = null) { onClick() },
contentAlignment = Alignment.Center,
) {
when {
icon != null -> Icon(
icon,
contentDescription = contentDescription,
tint = OnPrimary,
modifier = Modifier.size(22.dp),
)
label != null -> Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
label,
style = MaterialTheme.typography.labelLarge,
color = OnPrimary,
)
if (hint != null) {
Text(
hint,
style = MaterialTheme.typography.labelSmall,
color = OnMuted,
)
}
}
}
}
}