Merge pull request #6 from Droid-Deck/feat/in-session-sidebar-menu-review

Paged in-session drawer with controller navigation
This commit is contained in:
Kurt Himebauch authored and GitHub committed 2026-09-24 21:26:01 -04:00
commit 643d82e2fd
5 files changed
+282 -166

No files matched your search

@@ -24,6 +24,7 @@ import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.OnBackPressedCallback
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@@ -120,6 +121,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// Compose reads these; the activity writes them.
private var drawerOpen by mutableStateOf(false)
private var drawerPage by mutableIntStateOf(0)
private var drawerControllerActive by mutableStateOf(false)
private var backActionsInverted by mutableStateOf(false)
/** The on-screen PC keyboard (ui/PcKeyboard): real key presses, Esc and F1 included. */
private var pcKeyboardOpen by mutableStateOf(false)
@@ -234,7 +237,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
onAndroidKeyboard = { pcKeyboardOpen = false; keyboard?.toggle() },
onClose = { pcKeyboardOpen = false },
)
SessionDrawer(drawerOpen, DrawerActions(
SessionDrawer(drawerOpen, drawerPage, drawerControllerActive, onPageChange = { drawerPage = it }, a = DrawerActions(
steam = SessionState.mode == SessionService.MODE_STEAM,
title = if (SessionState.mode == SessionService.MODE_RUN)
com.droiddeck.launcher.frontend.Library.nameForProgram(SessionState.program) ?: "Game" else null,
@@ -565,7 +568,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
// Wider than 16:9 is fine - games and the client cope with a phone's 20:9 - so the
// panel's aspect is kept above that, unless the user pinned 16:9 for a foldable, and the
// compositor letterboxes onto a squarer panel.
// "Exactly this panel" drops that floor, for a 4:3 or 3:2 handheld whose games should
// "Match screen" drops that floor, for a 4:3 or 3:2 handheld whose games should
// fill it.
val aspect = when (SessionPrefs.shapeMode(this)) {
SessionPrefs.SHAPE_WIDE -> 16f / 9f
@@ -704,7 +707,19 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
KeyEvent.KEYCODE_VOLUME_UP, KeyEvent.KEYCODE_VOLUME_DOWN, KeyEvent.KEYCODE_VOLUME_MUTE ->
return super.dispatchKeyEvent(event)
}
val fromController = event.device != null && PadBridge.isFromController(event.device)
if (fromController && event.action == KeyEvent.ACTION_DOWN) {
if (drawerOpen && !drawerControllerActive) sessionOverlay.requestFocus()
drawerControllerActive = true
}
if (drawerOpen) {
if (fromController && (event.keyCode == KeyEvent.KEYCODE_BUTTON_L1 || event.keyCode == KeyEvent.KEYCODE_BUTTON_R1)) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) {
drawerPage = (drawerPage + if (event.keyCode == KeyEvent.KEYCODE_BUTTON_R1) 1 else 2) % 3
releaseDrawerDirection()
}
return true
}
val handled = super.dispatchKeyEvent(event)
if (event.action == KeyEvent.ACTION_DOWN) {
Log.i(TAG, "drawer key=${KeyEvent.keyCodeToString(event.keyCode)} handled=$handled viewFocused=${sessionOverlay.hasFocus()}")
@@ -715,7 +730,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
return true
}
val fromController = PadBridge.isFromController(event.device)
val resumeKey = event.keyCode == KeyEvent.KEYCODE_BUTTON_A || event.keyCode == KeyEvent.KEYCODE_BUTTON_START
val resumeKeyId = event.deviceId to event.keyCode
if (fromController && resumeKey && (SessionState.suspended || resumeKeyId in resumeKeysDown)) {
@@ -727,14 +741,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
return true
}
if (drawerOpen) {
if (event.keyCode == KeyEvent.KEYCODE_BACK || event.keyCode == KeyEvent.KEYCODE_BUTTON_B) {
if (event.action == KeyEvent.ACTION_DOWN && event.repeatCount == 0) drawerOpen = false
releaseDrawerDirection()
return true
}
return super.dispatchKeyEvent(event)
}
if (pcKeyboardOpen && event.device != null && PadBridge.isFromController(event.device)) return super.dispatchKeyEvent(event)
if (event.keyCode != KeyEvent.KEYCODE_BACK && padBridge?.onKeyEvent(event) == true) return true
// A hardware keyboard, forwarded to the compositor's wl_keyboard. Back is left to the
@@ -778,6 +784,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
override fun dispatchGenericMotionEvent(event: MotionEvent): Boolean {
if (drawerOpen && event.device != null && PadBridge.isFromController(event.device)) {
if (!drawerControllerActive) sessionOverlay.requestFocus()
drawerControllerActive = true
if (event.actionMasked == MotionEvent.ACTION_MOVE &&
event.isFromSource(InputDevice.SOURCE_JOYSTICK)
) {
@@ -792,6 +800,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
return super.dispatchGenericMotionEvent(event)
}
override fun dispatchTouchEvent(event: MotionEvent): Boolean {
if (event.actionMasked == MotionEvent.ACTION_DOWN && event.isFromSource(InputDevice.SOURCE_TOUCHSCREEN)) {
drawerControllerActive = false
}
return super.dispatchTouchEvent(event)
}
private fun dispatchDrawerDirection(event: MotionEvent) {
val hatX = event.getAxisValue(MotionEvent.AXIS_HAT_X)
val hatY = event.getAxisValue(MotionEvent.AXIS_HAT_Y)
@@ -52,9 +52,9 @@ object SessionPrefs {
/** The choices the settings offer, in order. */
val shapeChoices = listOf(
SHAPE_AUTO to "The panel's shape (16:9 or wider)",
SHAPE_EXACT to "Exactly this panel (4:3, 3:2…)",
SHAPE_WIDE to "16:9 with bars",
SHAPE_AUTO to "Auto (16:9+)",
SHAPE_EXACT to "Match screen",
SHAPE_WIDE to "Always 16:9",
)
/**
@@ -183,8 +183,8 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
onPick = { v -> if (v == CUSTOM) editCustom = true else { a.onCustomResolution(null); a.onResolution(v) } },
)
ChoiceRow(
host, "shape", "Shape",
if (custom != null) "Set by the custom resolution." else "The panel's shape stays at 16:9 or wider; pick Exactly this panel for a 4:3 or 3:2 screen.",
host, "shape", "Screen ratio",
if (custom != null) "Set by the custom resolution." else "Auto uses at least 16:9.",
com.droiddeck.launcher.session.SessionPrefs.shapeChoices, s.shapeMode, enabled = custom == null, onPick = a.onShape,
)
if (editCustom) CustomResolutionDialog(
@@ -7,11 +7,11 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.ui.focus.focusRequester
import androidx.compose.material3.ButtonDefaults
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -23,19 +23,19 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Switch
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.res.painterResource
@@ -56,7 +56,6 @@ import androidx.compose.animation.slideOutHorizontally
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.border
import androidx.compose.foundation.hoverable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.layout.Arrangement
@@ -75,9 +74,11 @@ import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import com.droiddeck.launcher.core.FexPreset
import com.droiddeck.launcher.gpu.FrameGen
import com.droiddeck.launcher.session.SessionPrefs
@@ -85,6 +86,29 @@ import com.droiddeck.launcher.input.SecondScreenDisplay
import com.droiddeck.launcher.input.SecondScreenMode
import kotlinx.coroutines.flow.collect
private val drawerPageTitles = listOf("Now", "Controls", "Session")
private val drawerPageEntries = listOf("hud", "touch", "shape")
private class DrawerFocus {
private val requesters = HashMap<String, FocusRequester>()
private val last = arrayOfNulls<String>(3)
var focused by mutableStateOf<String?>(null)
private set
private fun requester(key: String) = requesters.getOrPut(key) { FocusRequester() }
fun track(page: Int, key: String): Modifier = Modifier.focusRequester(requester(key)).onFocusChanged {
if (it.isFocused) {
last[page] = key
focused = key
} else if (focused == key) focused = null
}
fun target(page: Int) = last[page] ?: drawerPageEntries[page]
fun request(key: String) = runCatching { requester(key).requestFocus() }
fun forget(page: Int) { last[page] = null }
}
@Composable
fun HudText(text: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.TopEnd) {
@@ -204,32 +228,41 @@ class DrawerActions(
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun SessionDrawer(open: Boolean, a: DrawerActions) {
fun SessionDrawer(open: Boolean, page: Int, controllerActive: Boolean, onPageChange: (Int) -> Unit, a: DrawerActions) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val host = rememberMenuHost()
var androidAppsExpanded by rememberSaveable { mutableStateOf(false) }
var appToChooseDisplay by remember { mutableStateOf<HomeApp.LaunchableApp?>(null) }
var confirmStop by remember { mutableStateOf(false) }
val pageScroll = remember { List(3) { ScrollState(0) } }
val veil by animateFloatAsState(if (open) 1f else 0f, Motion.tw(260), label = "veil")
// A controller starts on the drawer's first control, highlighted, as on the app's main screen:
// the STEAM button, or in a desktop session the first setting.
val firstFocus = remember { androidx.compose.ui.focus.FocusRequester() }
var firstControlFocused by remember { mutableStateOf(false) }
val focus = remember { DrawerFocus() }
val inputModeManager = LocalInputModeManager.current
val steamButtons = a.onSteamMenu != null && a.onQam != null
val focusManager = LocalFocusManager.current
BackHandler(enabled = open) {
if (host.open != null) host.open = null else a.onClose()
}
LaunchedEffect(open) {
if (open) {
BackHandler(enabled = open && confirmStop) { confirmStop = false }
LaunchedEffect(page) { host.open = null; appToChooseDisplay = null }
LaunchedEffect(open, controllerActive) {
if (open && !controllerActive) focusManager.clearFocus(force = true)
}
LaunchedEffect(open, page, controllerActive, host.open, appToChooseDisplay, confirmStop) {
if (!open) {
host.open = null
confirmStop = false
} else if (controllerActive) {
inputModeManager.requestInputMode(InputMode.Keyboard)
var attempts = 0
while (attempts < 24 && !firstControlFocused) {
firstFocus.requestFocus()
if (host.open != null || appToChooseDisplay != null || confirmStop) return@LaunchedEffect
val target = focus.target(page)
repeat(24) {
androidx.compose.runtime.withFrameNanos { }
attempts++
focus.request(target)
if (focus.focused == target) return@LaunchedEffect
}
if (!firstControlFocused) android.util.Log.w("SessionDrawer", "first controller focus request did not focus a control")
focus.forget(page)
focus.request(drawerPageEntries[page])
}
}
if (open || veil > 0.01f) Box(
@@ -252,13 +285,14 @@ fun SessionDrawer(open: Boolean, a: DrawerActions) {
.controllerBack {
if (host.open != null) host.open = null else a.onClose()
}
.verticalScroll(rememberScrollState())
.padding(horizontal = 14.dp, vertical = 16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(start = 4.dp, bottom = 6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(start = 4.dp, bottom = 6.dp)) {
Box(modifier = Modifier.size(10.dp).clip(CircleShape).background(Brush.linearGradient(listOf(colors.primary, pal.primary2))))
Spacer(Modifier.width(10.dp))
Text(a.title ?: if (a.steam) "Steam" else "Desktop", fontSize = 17.sp, fontWeight = FontWeight.Bold, color = colors.onBackground)
Text(a.title ?: if (a.steam) "Steam" else "Desktop", fontSize = 17.sp, fontWeight = FontWeight.Bold,
color = colors.onBackground, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
StopSessionButton(modifier = focus.track(page, "stop")) { host.open = null; confirmStop = true }
}
if (a.onSteamMenu != null && a.onQam != null) {
val qamInteraction = remember { MutableInteractionSource() }
@@ -282,11 +316,7 @@ fun SessionDrawer(open: Boolean, a: DrawerActions) {
OutlinedButton(
onClick = { host.open = null; a.onSteamMenu.invoke() },
interactionSource = steamSrc,
modifier = Modifier.weight(1f).height(48.dp).focusRequester(firstFocus)
.onFocusChanged {
firstControlFocused = it.isFocused
android.util.Log.i("SessionDrawer", "first control focused=${it.isFocused}")
}.controllerConfirm {
modifier = Modifier.weight(1f).height(48.dp).then(focus.track(page, "steam")).controllerConfirm {
host.open = null
a.onSteamMenu.invoke()
},
@@ -303,7 +333,8 @@ fun SessionDrawer(open: Boolean, a: DrawerActions) {
qamStartedOnPress = false
},
interactionSource = qamInteraction,
modifier = Modifier.weight(1f).height(48.dp).semantics { contentDescription = "Open Quick Access Menu" }.controllerConfirm {
modifier = Modifier.weight(1f).height(48.dp).then(focus.track(page, "qam"))
.semantics { contentDescription = "Open Quick Access Menu" }.controllerConfirm {
host.open = null
a.onQam.invoke()
},
@@ -311,132 +342,154 @@ fun SessionDrawer(open: Boolean, a: DrawerActions) {
border = BorderStroke(if (qamHot) 2.dp else 1.dp, if (qamHot) pal.signal else colors.outline),
colors = ButtonDefaults.outlinedButtonColors(containerColor = if (qamHot) pal.signal.copy(alpha = 0.16f) else Color.Transparent),
) {
Text("…", fontSize = 22.sp, fontWeight = FontWeight.SemiBold)
Text("QAM", fontSize = 13.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.5.sp)
}
}
}
if (a.isHomeApp) SettingsGroup("Android apps") {
ActionRow("Launch an app", null, if (androidAppsExpanded) "Hide" else "Show") {
androidAppsExpanded = !androidAppsExpanded
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
) {
DrawerOutlineButton("LB ‹", modifier = Modifier.height(48.dp).then(focus.track(page, "prev"))) {
host.open = null; onPageChange((page + 2) % 3)
}
if (androidAppsExpanded) {
if (a.androidApps.isEmpty()) {
Text("No launchable apps found", fontSize = 12.sp, color = colors.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp))
} else {
for (app in a.androidApps) {
MenuItem(
app.label,
checked = false,
leading = {
app.icon?.let { icon ->
Image(
bitmap = icon.asImageBitmap(),
contentDescription = null,
modifier = Modifier.size(26.dp).clip(RoundedCornerShape(6.dp)),
)
}
},
) {
host.open = null
if (a.secondScreenDisplays.isEmpty()) a.onLaunchAndroidApp(app, null)
else appToChooseDisplay = app
Row(
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f),
) {
drawerPageTitles.forEachIndexed { index, title ->
val dotSource = remember { MutableInteractionSource() }
val dotFocused = dotSource.collectIsFocusedAsState().value
val select = { host.open = null; onPageChange(index) }
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.size(38.dp)
.semantics { contentDescription = "$title page" }
.hoverable(dotSource)
.clickable(interactionSource = dotSource, indication = LocalIndication.current, onClick = select)
.controllerConfirm(onClick = select),
) {
Box(
Modifier.size(if (index == page) 16.dp else 10.dp)
.clip(CircleShape)
.background(if (index == page) pal.signal else colors.onSurfaceVariant)
.border(if (dotFocused) 2.dp else 0.dp, colors.onBackground, CircleShape),
)
}
}
}
DrawerOutlineButton("› RB", modifier = Modifier.height(48.dp).then(focus.track(page, "next"))) {
host.open = null; onPageChange((page + 1) % 3)
}
}
Column(
modifier = Modifier.weight(1f).fillMaxWidth().verticalScroll(pageScroll[page]),
) {
when (page) {
0 -> SettingsGroup("Now") {
ToggleRow(host, "hud", "Performance HUD", null, a.hudOn,
chipModifier = focus.track(page, "hud"), onChange = a.onHud)
if (a.fillScreen != null) ToggleRow(
host, "fill", "Stretch games to fill", null, a.fillScreen,
chipModifier = focus.track(page, "fill"), onChange = a.onFillScreen,
)
val fgOpen = host.open == "fg"
val fgLabel = when (a.frameGenEngine) {
FrameGen.ENGINE_WINFG -> "Win-FG ${a.frameGenMultiplier}×"
FrameGen.ENGINE_LSFG -> "LSFG ${a.frameGenMultiplier}×"
else -> "Off"
}
SettingsRow("Frame generation", null, highlighted = fgOpen) {
Box {
ValueChip(fgLabel, fgOpen, modifier = focus.track(page, "fg")) { host.open = if (fgOpen) null else "fg" }
AnchoredMenu(fgOpen, onDismiss = { if (host.open == "fg") host.open = null }, title = "Frame generation") { firstItemFocus ->
val need = if (a.lsfgReady) null else "Requires Lossless Scaling"
MenuItem("Off", checked = a.frameGenEngine == FrameGen.ENGINE_OFF, focusRequester = firstItemFocus) { a.onFrameGenPick(FrameGen.ENGINE_OFF, 2); host.open = null }
for (m in 2..4) MenuItem("Win-FG ${m}×", checked = a.frameGenEngine == FrameGen.ENGINE_WINFG && a.frameGenMultiplier == m) { a.onFrameGenPick(FrameGen.ENGINE_WINFG, m); host.open = null }
for (m in 2..4) MenuItem("LSFG ${m}×", checked = a.frameGenEngine == FrameGen.ENGINE_LSFG && a.frameGenMultiplier == m, enabled = a.lsfgReady, detail = need) { a.onFrameGenPick(FrameGen.ENGINE_LSFG, m); host.open = null }
}
}
}
}
}
}
SettingsGroup("Now") {
ToggleRow(
host, "hud", "Performance HUD", null, a.hudOn,
chipModifier = if (steamButtons) Modifier else Modifier.focusRequester(firstFocus)
.onFocusChanged { firstControlFocused = it.isFocused }, onChange = a.onHud,
)
if (a.fillScreen != null) ToggleRow(
host, "fill", "Stretch games to fill the screen",
"Off for a game that shows up small in a corner (Quake 3).", a.fillScreen, onChange = a.onFillScreen,
)
val fgOpen = host.open == "fg"
val fgLabel = when (a.frameGenEngine) {
FrameGen.ENGINE_WINFG -> "Win-FG ${a.frameGenMultiplier}×"
FrameGen.ENGINE_LSFG -> "LSFG ${a.frameGenMultiplier}×"
else -> "Off"
}
SettingsRow("Frame generation", null, highlighted = fgOpen) {
Box {
ValueChip(fgLabel, fgOpen) { host.open = if (fgOpen) null else "fg" }
AnchoredMenu(fgOpen, onDismiss = { if (host.open == "fg") host.open = null }, title = "Frame generation") { firstItemFocus ->
val need = if (a.lsfgReady) null else "install Lossless Scaling in Steam"
MenuItem("Off", checked = a.frameGenEngine == FrameGen.ENGINE_OFF, focusRequester = firstItemFocus) { a.onFrameGenPick(FrameGen.ENGINE_OFF, 2); host.open = null }
for (m in 2..4) MenuItem("Win-FG ${m}×", checked = a.frameGenEngine == FrameGen.ENGINE_WINFG && a.frameGenMultiplier == m) { a.onFrameGenPick(FrameGen.ENGINE_WINFG, m); host.open = null }
for (m in 2..4) MenuItem("LSFG ${m}×", checked = a.frameGenEngine == FrameGen.ENGINE_LSFG && a.frameGenMultiplier == m, enabled = a.lsfgReady, detail = need) { a.onFrameGenPick(FrameGen.ENGINE_LSFG, m); host.open = null }
1 -> {
SettingsGroup("Controls") {
ChoiceRow(host, "touch", "Touch", null,
listOf(SessionPrefs.TOUCH_AUTO to "Auto (${a.touchAuto})", SessionPrefs.TOUCH_PAD to "Touchpad", SessionPrefs.TOUCH_DIRECT to "Direct"),
a.touchMode, chipModifier = focus.track(page, "touch"), onPick = a.onTouch)
ChoiceRow(host, "osc", "On-screen controls", null,
if (a.steam) listOf(SessionPrefs.OSC_AUTO to "Auto", SessionPrefs.OSC_ALWAYS to "Always", SessionPrefs.OSC_STEAM_QAM to "Steam + QAM", SessionPrefs.OSC_NEVER to "Never")
else listOf(SessionPrefs.OSC_AUTO to "Auto", SessionPrefs.OSC_ALWAYS to "Always", SessionPrefs.OSC_NEVER to "Never"),
a.oscMode, chipModifier = focus.track(page, "osc"), onPick = a.onOsc)
if (a.steam) ChoiceRow(host, "back-actions", "Back", null,
listOf(false to SessionPrefs.BACK_MENU_THEN_QAM, true to SessionPrefs.BACK_QAM_THEN_MENU),
a.backActionsInverted, chipModifier = focus.track(page, "back-actions"), onPick = a.onBackActionsInverted)
}
SettingsGroup("Keyboard") {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth().padding(8.dp)) {
DrawerOutlineButton("Hardware", modifier = Modifier.weight(1f).height(42.dp).then(focus.track(page, "hardware"))) {
host.open = null; a.onHardwareKeyboard()
}
DrawerOutlineButton("Android", modifier = Modifier.weight(1f).height(42.dp).then(focus.track(page, "android"))) {
host.open = null; a.onKeyboard()
}
}
}
if (a.steam && a.secondScreenDisplays.isNotEmpty()) SettingsGroup("Second screen") {
ChoiceRow(host, "second-screen-mode", "Controls", null,
listOf(SecondScreenMode.NONE, SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.TERMINAL).map { it to it.label },
a.secondScreenMode, chipModifier = focus.track(page, "second-screen-mode"), onPick = a.onSecondScreenMode)
if (a.secondScreenDisplays.size > 1) ChoiceRow(host, "second-screen-display", "Display", null,
a.secondScreenDisplays.map { it.id to it.label }, a.selectedSecondScreenDisplay,
chipModifier = focus.track(page, "second-screen-display"), onPick = a.onSecondScreenDisplay)
}
}
else -> {
if (a.isHomeApp) SettingsGroup("Android apps") {
SettingsRow("Launch an app", null) {
DrawerOutlineButton(if (androidAppsExpanded) "Hide" else "Show", modifier = focus.track(page, "apps")) {
androidAppsExpanded = !androidAppsExpanded
}
}
if (androidAppsExpanded) {
if (a.androidApps.isEmpty()) {
Text("No launchable apps", fontSize = 12.sp, color = colors.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp))
} else for (app in a.androidApps) {
MenuItem(
app.label, checked = false,
modifier = focus.track(page, "app:${app.packageName}/${app.className}"),
leading = {
app.icon?.let { icon ->
Image(bitmap = icon.asImageBitmap(), contentDescription = null,
modifier = Modifier.size(26.dp).clip(RoundedCornerShape(6.dp)))
}
},
) {
host.open = null
if (a.secondScreenDisplays.isEmpty()) a.onLaunchAndroidApp(app, null)
else appToChooseDisplay = app
}
}
}
}
SettingsGroup("Next session") {
ChoiceRow(host, "shape", "Screen ratio", null,
SessionPrefs.shapeChoices, a.shapeMode,
chipModifier = focus.track(page, "shape"), onPick = a.onShape)
if (a.steam) ChoiceRow(host, "fex", "FEX preset", null,
FexPreset.all.map { it.id to it.label }, a.fexPreset,
chipModifier = focus.track(page, "fex"), onPick = a.onFexPreset)
}
Spacer(Modifier.height(18.dp))
if (!a.isHomeApp) DrawerOutlineButton("Background", modifier = focus.track(page, "background")) {
host.open = null; a.onBackground()
}
}
}
ChoiceRow(
host, "touch", "Touch", null,
listOf(SessionPrefs.TOUCH_AUTO to "Auto (${a.touchAuto})", SessionPrefs.TOUCH_PAD to "Touchpad", SessionPrefs.TOUCH_DIRECT to "Direct"), a.touchMode,
onPick = a.onTouch,
)
ChoiceRow(
host, "osc", "On-screen controls", null,
if (a.steam) listOf(SessionPrefs.OSC_AUTO to "Auto", SessionPrefs.OSC_ALWAYS to "Always", SessionPrefs.OSC_STEAM_QAM to "Steam + QAM", SessionPrefs.OSC_NEVER to "Never")
else listOf(SessionPrefs.OSC_AUTO to "Auto", SessionPrefs.OSC_ALWAYS to "Always", SessionPrefs.OSC_NEVER to "Never"),
a.oscMode,
onPick = a.onOsc,
)
if (a.steam) ChoiceRow(
host, "back-actions", "Back", SessionPrefs.backActionsOrder(a.backActionsInverted),
listOf(
false to SessionPrefs.BACK_MENU_THEN_QAM,
true to SessionPrefs.BACK_QAM_THEN_MENU,
), a.backActionsInverted, onPick = a.onBackActionsInverted,
)
SettingsRow("Keyboard", null) {
Row(verticalAlignment = Alignment.CenterVertically) {
SecondaryButton("Hardware") { host.open = null; a.onHardwareKeyboard() }
Spacer(Modifier.width(8.dp))
SecondaryButton("Android") { host.open = null; a.onKeyboard() }
}
}
}
if (a.steam && a.secondScreenDisplays.isNotEmpty()) SettingsGroup("Second screen") {
ChoiceRow(
host, "second-screen-mode", "Controls", null,
listOf(SecondScreenMode.NONE, SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.TERMINAL)
.map { it to it.label }, a.secondScreenMode,
onPick = a.onSecondScreenMode,
)
if (a.secondScreenDisplays.size > 1) ChoiceRow(
host, "second-screen-display", "Display", null,
a.secondScreenDisplays.map { it.id to it.label }, a.selectedSecondScreenDisplay,
onPick = a.onSecondScreenDisplay,
)
}
SettingsGroup("Next session") {
ChoiceRow(
host, "shape", "Display shape", null,
SessionPrefs.shapeChoices, a.shapeMode, onPick = a.onShape,
)
if (a.steam) ChoiceRow(
host, "fex", "FEX preset", null,
FexPreset.all.map { it.id to it.label }, a.fexPreset, note = FexPreset.byId(a.fexPreset).detail, onPick = a.onFexPreset,
)
}
Spacer(Modifier.height(18.dp))
Row(
horizontalArrangement = if (a.isHomeApp) Arrangement.End else Arrangement.spacedBy(10.dp),
modifier = Modifier.fillMaxWidth(),
) {
if (!a.isHomeApp) SecondaryButton("Send to background") { host.open = null; a.onBackground() }
DangerButton("Stop session") { host.open = null; a.onStop() }
}
Spacer(Modifier.height(12.dp))
}
}
}
@@ -458,18 +511,63 @@ fun SessionDrawer(open: Boolean, a: DrawerActions) {
onDismiss = { appToChooseDisplay = null },
)
}
if (confirmStop) {
val cancelFocus = remember { FocusRequester() }
val cancel = { confirmStop = false }
val stop = { confirmStop = false; a.onStop() }
LaunchedEffect(controllerActive) {
if (controllerActive) {
androidx.compose.runtime.withFrameNanos { }
runCatching { cancelFocus.requestFocus() }
}
}
AlertDialog(
onDismissRequest = cancel,
modifier = Modifier.controllerBack(onBack = cancel),
title = { Text("Stop session?") },
confirmButton = {
TextButton(
onClick = stop,
modifier = Modifier.controllerConfirm(onClick = stop),
colors = ButtonDefaults.textButtonColors(contentColor = colors.error),
) { Text("Stop") }
},
dismissButton = {
TextButton(onClick = cancel, modifier = Modifier.focusRequester(cancelFocus)
.controllerConfirm(onClick = cancel)) { Text("Cancel") }
},
)
}
}
@Composable
private fun DangerButton(text: String, onClick: () -> Unit) {
private fun DrawerOutlineButton(text: String, modifier: Modifier = Modifier, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
val source = remember { MutableInteractionSource() }
val hot = source.collectIsFocusedAsState().value || source.collectIsHoveredAsState().value
OutlinedButton(
onClick = onClick,
interactionSource = source,
modifier = modifier.controllerConfirm(onClick = onClick),
shape = RoundedCornerShape(12.dp),
contentPadding = PaddingValues(horizontal = 12.dp),
border = BorderStroke(if (hot) 2.dp else 1.dp, if (hot) pal.signal else colors.outline),
colors = ButtonDefaults.outlinedButtonColors(containerColor = if (hot) pal.signal.copy(alpha = 0.16f) else Color.Transparent),
) { Text(text, fontSize = 13.sp, fontWeight = FontWeight.SemiBold, maxLines = 1) }
}
@Composable
private fun StopSessionButton(modifier: Modifier = Modifier, onClick: () -> Unit) {
val colors = MaterialTheme.colorScheme
val src = remember { MutableInteractionSource() }
val hot = src.collectIsFocusedAsState().value || src.collectIsHoveredAsState().value
val fill by animateColorAsState(if (hot) colors.error.copy(alpha = 0.18f) else Color.Transparent, Motion.tw(220), label = "dangerFill")
Box(
modifier = Modifier.clip(RoundedCornerShape(12.dp)).background(fill).border(1.dp, colors.error.copy(alpha = if (hot) 0.9f else 0.5f), RoundedCornerShape(12.dp))
contentAlignment = Alignment.Center,
modifier = modifier.size(42.dp).semantics { contentDescription = "Stop session" }
.clip(RoundedCornerShape(12.dp)).background(fill).border(1.dp, colors.error.copy(alpha = if (hot) 0.9f else 0.5f), RoundedCornerShape(12.dp))
.hoverable(src).clickable(interactionSource = src, indication = LocalIndication.current, onClick = onClick)
.controllerConfirm(onClick = onClick)
.padding(horizontal = 16.dp, vertical = 11.dp),
) { Text(text, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.5.sp, color = colors.error, maxLines = 1) }
.controllerConfirm(onClick = onClick),
) { Text("×", fontSize = 25.sp, fontWeight = FontWeight.Medium, color = colors.error) }
}
@@ -57,7 +57,9 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.InputMode
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalInputModeManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.IntOffset
@@ -118,8 +120,9 @@ fun AnchoredMenu(open: Boolean, onDismiss: () -> Unit, title: String? = null, no
state.targetState = open
if (!state.currentState && !state.targetState && state.isIdle) return
val firstItemFocus = remember { FocusRequester() }
LaunchedEffect(open) {
if (open) {
val inputMode = LocalInputModeManager.current.inputMode
LaunchedEffect(open, inputMode) {
if (open && inputMode == InputMode.Keyboard) {
repeat(2) { androidx.compose.runtime.withFrameNanos { } }
runCatching { firstItemFocus.requestFocus() }
android.util.Log.i("AnchoredMenu", "requested first option focus")
@@ -169,7 +172,7 @@ fun AnchoredMenu(open: Boolean, onDismiss: () -> Unit, title: String? = null, no
fun MenuItem(
label: String, checked: Boolean, enabled: Boolean = true, detail: String? = null,
trailing: (@Composable () -> Unit)? = null, leading: (@Composable () -> Unit)? = null,
focusRequester: FocusRequester? = null, onClick: () -> Unit,
focusRequester: FocusRequester? = null, modifier: Modifier = Modifier, onClick: () -> Unit,
) {
val colors = MaterialTheme.colorScheme
val pal = LocalPalette.current
@@ -178,7 +181,7 @@ fun MenuItem(
val shift by animateFloatAsState(if (hot) 2f else 0f, Motion.sp(0.5f), label = "miShift")
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth()
modifier = modifier.fillMaxWidth()
.graphicsLayer { translationX = shift.dp.toPx() }
.then(focusRequester?.let { Modifier.focusRequester(it) } ?: Modifier)
.clip(RoundedCornerShape(8.dp))