Merge pull request #14 from The412Banner/fix/exact-panel-shape

Display: Exactly this panel shape + custom resolution per mode
This commit is contained in:
The412Banner authored and GitHub committed 2026-09-23 13:27:13 -04:00
commit 90c7574e28
6 files changed
+125 -11

No files matched your search

@@ -149,6 +149,7 @@ class MainActivity : ComponentActivity() {
// The mode whose settings dialog is open, with what it shows; refreshed by openModeSettings().
private var settingsMode by mutableStateOf<String?>(null)
private var resolutionCap by mutableStateOf(1080)
private var customResolution by mutableStateOf<Pair<Int, Int>?>(null)
private var fexPreset by mutableStateOf("")
private var steamChannel by mutableStateOf("publicbeta")
private var theme by mutableStateOf("paper")
@@ -414,7 +415,7 @@ class MainActivity : ComponentActivity() {
private fun ModeSettingsHost(mode: String) {
ModeSettingsPage(
ModeSettings(
mode = mode, resolutionCap = resolutionCap, shapeMode = shapeMode,
mode = mode, resolutionCap = resolutionCap, customResolution = customResolution, shapeMode = shapeMode,
hdr = hdrOn, hdrReason = hdrReason,
linuxRows = linuxRows,
linuxSelected = if (mode == SessionService.MODE_STEAM) linuxSteam else linuxDesktop,
@@ -436,6 +437,7 @@ class MainActivity : ComponentActivity() {
),
ModeSettingsActions(
onResolution = { cap -> SessionPrefs.setResolutionCap(this, mode, cap); resolutionCap = cap },
onCustomResolution = { size -> SessionPrefs.setCustomResolution(this, mode, size); customResolution = size },
onShape = { shape -> SessionPrefs.setShapeMode(this, shape); shapeMode = shape },
onHdr = { on -> SessionPrefs.setHdr(this, mode, on); hdrOn = on },
onSelectLinux = { id -> SessionPrefs.setLinuxDriver(this, mode, id); refreshDrivers() },
@@ -530,6 +532,7 @@ class MainActivity : ComponentActivity() {
showApps = false
refreshDrivers()
resolutionCap = SessionPrefs.resolutionCap(this, mode)
customResolution = SessionPrefs.customResolution(this, mode)
fexPreset = SessionPrefs.fexPreset(this)
steamChannel = SessionPrefs.steamChannel(this)
addedGamesDirs = SessionPrefs.addedGamesDirs(this)
@@ -444,13 +444,20 @@ 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.
val aspect = if (SessionPrefs.shapeMode(this) == SessionPrefs.SHAPE_WIDE) 16f / 9f
else maxOf(panelW / panelH, 16f / 9f)
// "Exactly this panel" 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
SessionPrefs.SHAPE_EXACT -> panelW / panelH
else -> maxOf(panelW / panelH, 16f / 9f)
}
// 720 tall at most by default, client and desktop alike: the client's CEF is the heaviest
// thing in the session, and pixels above that cost frames for nothing anyone can see on a
// handheld panel. The mode's settings (the cog beside Play / Desktop) can change
// the cap or lift it to the panel.
val mode = SessionPrefs.prefMode(intent.getStringExtra(SessionService.EXTRA_MODE) ?: SessionService.MODE_STEAM)
// A custom resolution is taken as given; the compositor fits it to the panel.
SessionPrefs.customResolution(this, mode)?.let { return it }
val cap = SessionPrefs.resolutionCap(this, mode)
val height = (if (cap <= 0) panelH else minOf(panelH, cap.toFloat())).toInt()
val width = (height * aspect).toInt()
@@ -109,6 +109,8 @@ object DeviceReport {
k("Session output", SessionState.outputSize?.let { "${it.first}x${it.second}" })
k("Session refresh", String.format(Locale.US, "%.2f Hz", SessionState.refreshHz))
k("Shape setting", SessionPrefs.shapeMode(context))
k("Custom resolution (Steam)", SessionPrefs.customResolution(context, SessionService.MODE_STEAM)?.let { "${it.first}x${it.second}" } ?: "off")
k("Custom resolution (Desktop)", SessionPrefs.customResolution(context, SessionService.MODE_DESKTOP)?.let { "${it.first}x${it.second}" } ?: "off")
k("Foldable", context.packageManager.hasSystemFeature("android.hardware.sensor.hinge_angle"))
h("Drivers")
@@ -35,10 +35,18 @@ object SessionPrefs {
const val SHAPE_AUTO = "auto"
const val SHAPE_WIDE = "16:9"
const val SHAPE_EXACT = "exact"
/** 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",
)
/**
* The shape of the display the session presents: the panel's own (never narrower than 16:9)
* or a fixed 16:9. A foldable defaults to 16:9, which sits with modest bars on either of its
* The shape of the display the session presents: the panel's own (never narrower than 16:9),
* exactly the panel's (a 4:3 or 3:2 handheld, drawn edge to edge), or a fixed 16:9. A foldable defaults to 16:9, which sits with modest bars on either of its
* panels; the panel's own shape would fit one and leave a strip on the other, and gamescope's
* display cannot change size once the session is up.
*/
@@ -300,6 +308,27 @@ object SessionPrefs {
prefs(context).edit().putInt("resolutionCap.$mode", cap).apply()
}
/**
* A fixed size for the session's display, per mode, or null. When set it replaces both the
* cap and the shape: the compositor fits it to the panel with bars where the shapes differ.
*/
fun customResolution(context: Context, mode: String): Pair<Int, Int>? =
parseResolution(prefs(context).getString("customRes.$mode", null))
fun setCustomResolution(context: Context, mode: String, size: Pair<Int, Int>?) {
prefs(context).edit().putString("customRes.$mode", size?.let { "${it.first}x${it.second}" }).apply()
}
/** "1024x768" (or ×, or *) to an even size inside 320x240..3840x2160; anything else is null. */
fun parseResolution(text: String?): Pair<Int, Int>? {
val parts = text?.trim()?.split('x', 'X', '×', '*')?.map { it.trim() } ?: return null
if (parts.size != 2) return null
val w = parts[0].toIntOrNull() ?: return null
val h = parts[1].toIntOrNull() ?: return null
if (w !in 320..3840 || h !in 240..2160) return null
return Pair(w and 1.inv(), h and 1.inv())
}
/**
* What the desktop shell composites with: pixman (software, the default - the Adreno stand-in
* is not a DRM render node, so labwc's gbm allocator cannot use it), or gles2 / vulkan for a
@@ -6,6 +6,10 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
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.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@@ -18,6 +22,8 @@ class DriverRow(val id: String, val name: String, val detail: String, val remova
class ModeSettings(
val mode: String,
val resolutionCap: Int,
/** A fixed session size, or null for the cap and shape. */
val customResolution: Pair<Int, Int>? = null,
val shapeMode: String,
val hdr: Boolean,
val hdrReason: String?,
@@ -50,6 +56,8 @@ class AddedGameRow(val folderPath: String, val folderName: String, val exePath:
class ModeSettingsActions(
val onResolution: (Int) -> Unit,
/** Null clears it. */
val onCustomResolution: (Pair<Int, Int>?) -> Unit = {},
val onShape: (String) -> Unit,
val onHdr: (Boolean) -> Unit,
val onSelectLinux: (String) -> Unit,
@@ -88,16 +96,25 @@ fun ModeSettingsPage(s: ModeSettings, a: ModeSettingsActions) {
) {
SettingsGroup("Display") {
val default = SessionPrefs.defaultResolutionCap(s.mode)
var editCustom by remember { mutableStateOf(false) }
val custom = s.customResolution
ChoiceRow(
host, "res", "Resolution", "Applies next session.",
listOf(720 to "Up to 720p", 900 to "Up to 900p", 1080 to "Up to 1080p", 0 to "The panel's own")
.map { (cap, label) -> cap to (if (cap == default) "$label - the default" else label) },
s.resolutionCap, note = "720p can improve menu responsiveness.",
onPick = a.onResolution,
.map { (cap, label) -> cap to (if (cap == default) "$label - the default" else label) } +
(CUSTOM to (custom?.let { "Custom · ${it.first}×${it.second}" } ?: "Custom…")),
if (custom != null) CUSTOM else s.resolutionCap, note = "720p can improve menu responsiveness.",
onPick = { v -> if (v == CUSTOM) editCustom = true else { a.onCustomResolution(null); a.onResolution(v) } },
)
ChoiceRow(
host, "shape", "Shape", "Adds bars to preserve 16:9.",
listOf("auto" to "The panel's shape", "16:9" to "16:9 with bars"), s.shapeMode, onPick = a.onShape,
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.",
com.steamdeck.launcher.session.SessionPrefs.shapeChoices, s.shapeMode, enabled = custom == null, onPick = a.onShape,
)
if (editCustom) CustomResolutionDialog(
initial = custom,
onSave = { size -> editCustom = false; a.onCustomResolution(size) },
onDismiss = { editCustom = false },
)
}
SettingsGroup("HDR") {
@@ -267,3 +284,59 @@ private fun DriverRowMenu(
}
}
}
/** The Resolution menu's "Custom…" entry. */
private const val CUSTOM = -1
/** Width × height for the session, with the common handheld shapes one tap away. */
@OptIn(androidx.compose.foundation.layout.ExperimentalLayoutApi::class)
@Composable
private fun CustomResolutionDialog(initial: Pair<Int, Int>?, onSave: (Pair<Int, Int>) -> Unit, onDismiss: () -> Unit) {
var w by remember { mutableStateOf(initial?.first?.toString() ?: "") }
var h by remember { mutableStateOf(initial?.second?.toString() ?: "") }
val parsed = SessionPrefs.parseResolution("${w}x$h")
androidx.compose.material3.AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Custom resolution") },
text = {
androidx.compose.foundation.layout.Column {
Text(
"The session's display size. It replaces the cap and the shape; a size that does not match the panel's shape gets bars.",
fontSize = 13.sp, color = MaterialTheme.colorScheme.onSurfaceVariant,
)
androidx.compose.foundation.layout.Row(
verticalAlignment = androidx.compose.ui.Alignment.CenterVertically,
modifier = Modifier.padding(top = 12.dp),
) {
val numbers = androidx.compose.foundation.text.KeyboardOptions(keyboardType = androidx.compose.ui.text.input.KeyboardType.Number)
androidx.compose.material3.OutlinedTextField(
w, { v -> w = v.filter(Char::isDigit).take(4) }, label = { Text("Width") },
singleLine = true, keyboardOptions = numbers, modifier = Modifier.weight(1f),
)
Text("×", fontSize = 18.sp, modifier = Modifier.padding(horizontal = 10.dp))
androidx.compose.material3.OutlinedTextField(
h, { v -> h = v.filter(Char::isDigit).take(4) }, label = { Text("Height") },
singleLine = true, keyboardOptions = numbers, modifier = Modifier.weight(1f),
)
}
androidx.compose.foundation.layout.FlowRow(
horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(6.dp),
modifier = Modifier.padding(top = 10.dp),
) {
for ((pw, ph, tag) in listOf(Triple(960, 720, "4:3"), Triple(1024, 768, "4:3"), Triple(1280, 960, "4:3"), Triple(1280, 800, "16:10"), Triple(1152, 648, "16:9"), Triple(1280, 720, "16:9"))) {
androidx.compose.material3.AssistChip(
onClick = { w = pw.toString(); h = ph.toString() },
label = { Text("$pw×$ph · $tag", fontSize = 12.sp) },
)
}
}
if (parsed == null && (w.isNotEmpty() || h.isNotEmpty())) Text(
"Between 320×240 and 3840×2160.", fontSize = 12.sp, color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 6.dp),
)
}
},
confirmButton = { androidx.compose.material3.TextButton(enabled = parsed != null, onClick = { parsed?.let(onSave) }) { Text("Use") } },
dismissButton = { androidx.compose.material3.TextButton(onClick = onDismiss) { Text("Cancel") } },
)
}
@@ -261,7 +261,7 @@ fun SessionDrawer(open: Boolean, a: DrawerActions) {
SettingsGroup("Next session") {
ChoiceRow(
host, "shape", "Display shape", null,
listOf(SessionPrefs.SHAPE_AUTO to "The panel's shape", SessionPrefs.SHAPE_WIDE to "16:9 with bars"), a.shapeMode, onPick = a.onShape,
SessionPrefs.shapeChoices, a.shapeMode, onPick = a.onShape,
)
if (a.steam) ChoiceRow(
host, "fex", "FEX preset", null,