From 92df14bb6e1768bc51f6ea3ed357fcaef66cf68d Mon Sep 17 00:00:00 2001 From: The412Banner Date: Wed, 23 Sep 2026 13:18:15 -0400 Subject: [PATCH] Custom resolution per mode, as in Bannerlator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolution gains Custom…: a width × height box (320×240 to 3840×2160, evened) with one-tap presets for 4:3 (960×720, 1024×768, 1280×960), 16:10 (1280×800) and 16:9. A custom size replaces the cap and the shape for that mode - the Shape row greys out and says so - and the compositor fits it to the panel with bars where the shapes differ. Choosing any cap clears it. The device report lists it for both modes. --- .../com/steamdeck/launcher/MainActivity.kt | 5 +- .../com/steamdeck/launcher/SessionActivity.kt | 2 + .../steamdeck/launcher/core/DeviceReport.kt | 2 + .../launcher/session/SessionPrefs.kt | 21 +++++ .../launcher/ui/ModeSettingsDialog.kt | 83 +++++++++++++++++-- 5 files changed, 107 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/steamdeck/launcher/MainActivity.kt b/app/src/main/java/com/steamdeck/launcher/MainActivity.kt index 74cfd98f0..29f791b33 100644 --- a/app/src/main/java/com/steamdeck/launcher/MainActivity.kt +++ b/app/src/main/java/com/steamdeck/launcher/MainActivity.kt @@ -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(null) private var resolutionCap by mutableStateOf(1080) + private var customResolution by mutableStateOf?>(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) diff --git a/app/src/main/java/com/steamdeck/launcher/SessionActivity.kt b/app/src/main/java/com/steamdeck/launcher/SessionActivity.kt index bf8af866a..c7adb4dd7 100644 --- a/app/src/main/java/com/steamdeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/steamdeck/launcher/SessionActivity.kt @@ -456,6 +456,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { // 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() diff --git a/app/src/main/java/com/steamdeck/launcher/core/DeviceReport.kt b/app/src/main/java/com/steamdeck/launcher/core/DeviceReport.kt index 9c6c08300..50b1edacb 100644 --- a/app/src/main/java/com/steamdeck/launcher/core/DeviceReport.kt +++ b/app/src/main/java/com/steamdeck/launcher/core/DeviceReport.kt @@ -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") diff --git a/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt index d04fdc225..17a399e93 100644 --- a/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/steamdeck/launcher/session/SessionPrefs.kt @@ -308,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? = + parseResolution(prefs(context).getString("customRes.$mode", null)) + + fun setCustomResolution(context: Context, mode: String, size: Pair?) { + 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? { + 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 diff --git a/app/src/main/java/com/steamdeck/launcher/ui/ModeSettingsDialog.kt b/app/src/main/java/com/steamdeck/launcher/ui/ModeSettingsDialog.kt index 0aa6e0786..2a3e60ab2 100644 --- a/app/src/main/java/com/steamdeck/launcher/ui/ModeSettingsDialog.kt +++ b/app/src/main/java/com/steamdeck/launcher/ui/ModeSettingsDialog.kt @@ -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? = 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?) -> 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", "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, 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?, onSave: (Pair) -> 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") } }, + ) +}