feat: Steam's touch icons, labels and colours on the touch controls; pick and save them like Steam

This commit is contained in:
xXJsonDeruloXx committed 2026-10-08 08:05:07 -04:00
1 parent e637a2e0bd
commit c2dd90d1b6
6 files changed
+805 -30

No files matched your search

@@ -148,7 +148,6 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
private var steamTouchControls: com.droiddeck.launcher.input.SteamTouchControls? = null
private var oscStyle by mutableStateOf(SessionPrefs.OSC_STYLE_DROIDDECK)
private var steamTouchAvailable by mutableStateOf(false)
private var steamTouchRetries = 0
private val steamTouchRetry = Runnable { updateOnScreenControls() }
private var keyboard: KeyboardHost? = null
private var controllerSettings by mutableStateOf<ControllerPrefs.Settings?>(null)
@@ -1866,7 +1865,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
val steam = if (wantsSteam) steamTouch() else null
// The service makes the device while the session starts, after this activity: the app's
// own pad until then, and another look in a moment.
if (wantsSteam && steam == null && steamTouchRetries++ < 120) {
if (wantsSteam && steam == null && !isFinishing && !isDestroyed) {
uiHandler.removeCallbacks(steamTouchRetry)
uiHandler.postDelayed(steamTouchRetry, 1000)
}
@@ -2111,6 +2110,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback {
}
override fun onDestroy() {
uiHandler.removeCallbacks(steamTouchRetry)
if (runtimeRemovalBlocked) { super.onDestroy(); return }
// Deliberately does NOT end the session: this activity can be destroyed while the user is
// in another app, and the whole point of the service is that Steam survives that.
@@ -0,0 +1,285 @@
package com.droiddeck.launcher.input
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.LruCache
import java.io.File
/**
* What each touch control is bound to, and the icon it shows - Steam's, as its configurator keeps
* them.
*
* A Steam Input binding is one string: `<action>, <label>, <icon>, <#foreground #background>`, e.g.
* `xinput_button A, Jump, ghost_045_move_0400.png, #232323 #E4E4E4`. The icon is a file name looked
* up as Steam does (steamui.so): the game's own `TouchMenuIcons` folder, then the binding icon
* library `tenfoot/resource/images/library/controller/binding_icons`. Steam draws it filled with
* the foreground colour through its own alpha (mask-image) on a button of the background colour.
* Steam Link is sent these per control by the client it streams from; here they are read from the
* game's touch config, and editing one writes the binding string back, as the configurator does.
*/
object SteamTouchBindings {
/** Steam's binding icon colours (the configurator's palette, steamui sp.js), and its defaults. */
val PALETTE = listOf(
"#FFFFFF", "#E4E4E4", "#AAAAAA", "#787878", "#434343", "#222222", "#0000AD", "#0045AD", "#0074AD",
"#00ADAD", "#33AD69", "#00AD3D", "#00AD00", "#48B119", "#74AD00", "#96AD00", "#ADA200", "#AD5D00",
"#AD3A00", "#AD0000", "#AD0051", "#AD007F", "#AD00AD", "#6800AD", "#4800AD",
)
const val DEFAULT_FOREGROUND = "#232323"
const val DEFAULT_BACKGROUND = "#E4E4E4"
/** A parsed binding string. Empty strings for absent fields. */
data class Binding(val action: String, val label: String, val icon: String, val foreground: String, val background: String) {
fun compose(): String =
if (icon.isEmpty() && foreground.isEmpty() && background.isEmpty()) "$action, $label, "
else "$action, $label, $icon, ${listOf(foreground, background).filter { it.isNotEmpty() }.joinToString(" ")}"
val hasIcon get() = icon.isNotEmpty()
}
fun parse(binding: String): Binding {
val parts = binding.split(',').map { it.trim() }
val colors = parts.getOrNull(3).orEmpty().split(' ').filter { it.startsWith("#") }
return Binding(parts.getOrNull(0).orEmpty(), parts.getOrNull(1).orEmpty(), parts.getOrNull(2).orEmpty(),
colors.getOrNull(0).orEmpty(), colors.getOrNull(1).orEmpty())
}
/** Where a control's binding lives: its input in the group the preset puts on its source. */
data class Ref(val groupId: String, val input: String)
/** The source and input names a control reads (D-pad: its four directions). */
private fun inputsOf(type: Int): List<Pair<String, String>> = when (type) {
SteamTouchConfig.A -> listOf("button_diamond" to "button_a")
SteamTouchConfig.B -> listOf("button_diamond" to "button_b")
SteamTouchConfig.X -> listOf("button_diamond" to "button_x")
SteamTouchConfig.Y -> listOf("button_diamond" to "button_y")
SteamTouchConfig.SELECT -> listOf("switch" to "button_menu")
SteamTouchConfig.START -> listOf("switch" to "button_escape")
SteamTouchConfig.BUMPER_LEFT -> listOf("switch" to "left_bumper")
SteamTouchConfig.BUMPER_RIGHT -> listOf("switch" to "right_bumper")
SteamTouchConfig.TRIGGER_LEFT -> listOf("left_trigger" to "click")
SteamTouchConfig.TRIGGER_RIGHT -> listOf("right_trigger" to "click")
SteamTouchConfig.JOYSTICK_LEFT_BUTTON -> listOf("joystick" to "click")
SteamTouchConfig.JOYSTICK_RIGHT_BUTTON -> listOf("right_joystick" to "click")
SteamTouchConfig.MACRO_1_FINGER -> listOf("switch" to "button_macro_1finger")
SteamTouchConfig.MACRO_2_FINGER -> listOf("switch" to "button_macro_2finger")
SteamTouchConfig.DPAD -> DPAD_INPUTS.map { "dpad" to it }
in SteamTouchConfig.MACRO_0..SteamTouchConfig.MACRO_0 + 7 -> listOf("switch" to "button_macro${type - SteamTouchConfig.MACRO_0}")
else -> emptyList()
}
/** D-pad directions, in the order [refsFor] returns them: up, down, left, right. */
val DPAD_INPUTS = listOf("dpad_north", "dpad_south", "dpad_west", "dpad_east")
/**
* Where control [type]'s bindings are for preset [preset] (the action set less one) and its
* active [layers]: a layer's group wins over the base's for the same source.
*/
fun refsFor(mappings: KeyValues.Node, type: Int, preset: Int, layers: List<Int> = emptyList()): List<Ref?> {
val presets = mappings.children("preset").associateBy { it.string("id")?.toIntOrNull() ?: -1 }
val stack = layers.reversed().mapNotNull { presets[it - 1] } + listOfNotNull(presets[preset] ?: presets.values.firstOrNull())
return inputsOf(type).map { (source, input) ->
stack.firstNotNullOfOrNull { p -> groupFor(p, source)?.let { Ref(it, input) } }
}
}
private fun groupFor(preset: KeyValues.Node, source: String): String? =
preset.child("group_source_bindings")?.entries?.firstOrNull { (_, v) ->
val parts = (v as? String)?.split(' ') ?: return@firstOrNull false
parts.getOrNull(0) == source && parts.getOrNull(1) == "active" && !parts.contains("modeshift")
}?.first
/** The binding at [ref]: its first activator's first binding (Full_Press when there is one). */
fun bindingAt(mappings: KeyValues.Node, ref: Ref): Binding? {
val group = mappings.children("group").firstOrNull { it.string("id") == ref.groupId } ?: return null
val activators = group.child("inputs")?.child(ref.input)?.child("activators") ?: return null
val activator = activators.child("Full_Press") ?: activators.entries.firstNotNullOfOrNull { it.second as? KeyValues.Node }
return activator?.child("bindings")?.string("binding")?.let(::parse)
}
/** A short glyph for a binding with no icon or label: what it presses. */
fun glyph(binding: Binding?): String? {
val words = binding?.action?.split(' ') ?: return null
val arg = words.getOrNull(1)?.uppercase() ?: return null
return when (words[0]) {
"xinput_button" -> XINPUT_GLYPHS[arg] ?: arg
"key_press" -> KEY_GLYPHS[arg] ?: arg.take(4)
"mouse_button" -> when (arg) { "LEFT" -> "LMB"; "RIGHT" -> "RMB"; "MIDDLE" -> "MMB"; else -> "M" + arg.take(2) }
"mouse_wheel" -> if (arg.contains("UP")) "▲" else "▼"
else -> null
}
}
private val XINPUT_GLYPHS = mapOf(
"SHOULDER_LEFT" to "LB", "SHOULDER_RIGHT" to "RB", "TRIGGER_LEFT" to "LT", "TRIGGER_RIGHT" to "RT",
"JOYSTICK_LEFT" to "L3", "JOYSTICK_RIGHT" to "R3", "START" to "☰", "SELECT" to "⧉", "BACK" to "⧉",
"DPAD_UP" to "▲", "DPAD_DOWN" to "▼", "DPAD_LEFT" to "◀", "DPAD_RIGHT" to "▶",
)
private val KEY_GLYPHS = mapOf(
"RETURN" to "⏎", "SPACE" to "␣", "ESCAPE" to "Esc", "TAB" to "Tab", "BACKSPACE" to "⌫",
"LEFT_SHIFT" to "⇧", "RIGHT_SHIFT" to "⇧", "LEFT_CONTROL" to "Ctrl", "RIGHT_CONTROL" to "Ctrl",
"LEFT_ALT" to "Alt", "RIGHT_ALT" to "Alt", "UP_ARROW" to "↑", "DOWN_ARROW" to "↓",
"LEFT_ARROW" to "←", "RIGHT_ARROW" to "→",
)
// ---- Writing a binding back ----
/**
* Rewrites every binding of [ref]'s input in config [text] (VDF) with [change], keeping the rest
* of the file byte for byte. Returns the new text, or null when the input is not there.
*/
fun rewrite(text: String, ref: Ref, change: (Binding) -> Binding): String? {
val tokens = Vdf.tokens(text)
val group = Vdf.blocks(tokens, 0, tokens.size, "group").firstOrNull { (s, e) ->
Vdf.value(tokens, s, e, "id") == ref.groupId
} ?: return null
val inputs = Vdf.blocks(tokens, group.first, group.second, "inputs").firstOrNull() ?: return null
val input = Vdf.blocks(tokens, inputs.first, inputs.second, ref.input).firstOrNull() ?: return null
val targets = (input.first until input.second).filter { i ->
tokens[i].text.equals("binding", true) && tokens[i].quoted && i + 1 < input.second && tokens[i + 1].quoted
}.map { tokens[it + 1] }
if (targets.isEmpty()) return null
val out = StringBuilder(text)
for (t in targets.sortedByDescending { it.start }) out.replace(t.start, t.end, change(parse(t.text)).compose())
return out.toString()
}
/** A VDF tokenizer that keeps where each token sits, for edits that leave the rest untouched. */
private object Vdf {
class Token(val text: String, val quoted: Boolean, val start: Int, val end: Int)
fun tokens(text: String): List<Token> {
val out = mutableListOf<Token>()
var i = 0
while (i < text.length) {
val c = text[i]
when {
c.isWhitespace() -> i++
c == '/' && i + 1 < text.length && text[i + 1] == '/' -> while (i < text.length && text[i] != '\n') i++
c == '{' || c == '}' -> { out += Token(c.toString(), false, i, i + 1); i++ }
c == '"' -> {
val start = i + 1
i++
while (i < text.length && text[i] != '"') { if (text[i] == '\\') i++; i++ }
out += Token(text.substring(start, minOf(i, text.length)), true, start, minOf(i, text.length))
i++
}
else -> {
val start = i
while (i < text.length && !text[i].isWhitespace() && text[i] != '{' && text[i] != '}' && text[i] != '"') i++
out += Token(text.substring(start, i), false, start, i)
}
}
}
return out
}
/** The bodies (token index ranges inside the braces) of direct children named [key] of the
* block spanning [from, to). */
fun blocks(tokens: List<Token>, from: Int, to: Int, key: String): List<Pair<Int, Int>> {
val found = mutableListOf<Pair<Int, Int>>()
var i = from
var depth = 0
while (i < to) {
val t = tokens[i]
if (t.text == "{" && !t.quoted) depth++
else if (t.text == "}" && !t.quoted) depth--
else if (depth == 0 && t.quoted && t.text.equals(key, true) && i + 1 < to && tokens[i + 1].text == "{" && !tokens[i + 1].quoted) {
val open = i + 1
var d = 0
var j = open
while (j < to) {
if (tokens[j].text == "{" && !tokens[j].quoted) d++
if (tokens[j].text == "}" && !tokens[j].quoted) { d--; if (d == 0) break }
j++
}
found += (open + 1) to j
i = j + 1
continue
}
i++
}
// The whole file is one top-level block ("controller_mappings"): look inside it too.
if (found.isEmpty() && from == 0) {
val top = tokens.indexOfFirst { it.text == "{" && !it.quoted }
if (top > 0) {
var d = 0
var j = top
while (j < to) {
if (tokens[j].text == "{" && !tokens[j].quoted) d++
if (tokens[j].text == "}" && !tokens[j].quoted) { d--; if (d == 0) break }
j++
}
return blocks(tokens, top + 1, j, key)
}
}
return found
}
/** A direct key-value of the block spanning [from, to). */
fun value(tokens: List<Token>, from: Int, to: Int, key: String): String? {
var depth = 0
var i = from
while (i < to) {
val t = tokens[i]
if (t.text == "{" && !t.quoted) depth++
else if (t.text == "}" && !t.quoted) depth--
else if (depth == 0 && t.quoted && t.text.equals(key, true) && i + 1 < to && tokens[i + 1].quoted) return tokens[i + 1].text
i++
}
return null
}
}
// ---- Icons ----
private val cache = object : LruCache<String, Bitmap>(8 * 1024 * 1024) {
override fun sizeOf(key: String, value: Bitmap) = value.byteCount
}
private val missing = HashSet<String>()
fun libraryDir(context: Context) = File(SteamTouchConfig.steamRoot(context), "tenfoot/resource/images/library/controller/binding_icons")
/** The game's own icons folder (`TouchMenuIcons` in its install directory), when it has one. */
fun gameIconsDir(context: Context, appId: Int): File? = try {
val manifest = File(SteamTouchConfig.steamRoot(context), "steamapps/appmanifest_$appId.acf")
val installDir = KeyValues.parse(manifest.readText()).let { it.child("AppState") ?: it }.string("installdir")
installDir?.let { File(SteamTouchConfig.steamRoot(context), "steamapps/common/$it/TouchMenuIcons") }?.takeIf { it.isDirectory }
} catch (_: Exception) {
null
}
fun iconFile(context: Context, appId: Int, name: String): File? {
if (name.isEmpty() || name.contains('/')) return null
gameIconsDir(context, appId)?.let { File(it, name) }?.takeIf { it.isFile }?.let { return it }
File(libraryDir(context), name).takeIf { it.isFile }?.let { return it }
return File(SteamTouchConfig.steamRoot(context), "steamui/images/controller/$name").takeIf { it.isFile }
}
/** An icon, decoded at about [size] px. Blocking (reads a file); cached. */
fun icon(context: Context, appId: Int, name: String, size: Int = 128): Bitmap? {
val key = "$appId/$name/$size"
cache.get(key)?.let { return it }
if (key in missing) return null
val file = iconFile(context, appId, name)
val bitmap = file?.let {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(it.path, bounds)
var sample = 1
while (bounds.outWidth / (sample * 2) >= size) sample *= 2
BitmapFactory.decodeFile(it.path, BitmapFactory.Options().apply { inSampleSize = sample })
}
if (bitmap == null) missing += key else cache.put(key, bitmap)
return bitmap
}
fun cachedIcon(appId: Int, name: String, size: Int = 128): Bitmap? = cache.get("$appId/$name/$size")
/** Icon names for the picker: the game's own first, then Steam's library by category. */
fun iconNames(context: Context, appId: Int): List<String> {
val own = gameIconsDir(context, appId)?.list()?.filter { it.endsWith(".png", true) }?.sorted().orEmpty()
val library = libraryDir(context).list()?.filter { it.endsWith(".png", true) && it != "special_blank.png" }?.sorted().orEmpty()
return own + library.filter { it !in own }
}
}
@@ -68,6 +68,8 @@ object SteamTouchConfig {
val layouts: Layouts?,
/** Bound controls per preset (action set id - 1). */
val available: Map<Int, Set<Int>>,
/** The parsed config, for its bindings (SteamTouchBindings). */
val mappings: KeyValues.Node? = null,
) {
/** The controls to show for [actionSet] (as the device is told it) and [layers]. */
fun availableFor(actionSet: Int, layers: List<Int>): Set<Int> {
@@ -126,7 +128,7 @@ object SteamTouchConfig {
val mappings = kv.child("controller_mappings") ?: kv
val layouts = mappings.string("touch_layout")?.let { hex -> decodeLayouts(hexToBytes(hex)) }
Log.i(TAG, "steam touch: app $appId uses ${file.path} (${layouts?.layouts?.size ?: 0} layouts)")
Config(file, text, layouts, availability(mappings))
Config(file, text, layouts, availability(mappings), mappings)
} catch (e: Exception) {
Log.w(TAG, "steam touch: ${file.path} unreadable: $e")
Config(file, null, null, emptyMap())
@@ -293,15 +295,27 @@ object SteamTouchConfig {
* app has none) and selected for the app in the configset, as the client saves a layout Steam
* Link sends it. Returns the file written, or null.
*/
fun saveLayout(context: Context, appId: Int, config: Config, actionSet: Int, elements: List<Element>): File? {
fun saveLayout(
context: Context,
appId: Int,
config: Config,
actionSet: Int,
elements: List<Element>,
color: FloatArray? = null,
bindings: Map<SteamTouchBindings.Ref, (SteamTouchBindings.Binding) -> SteamTouchBindings.Binding> = emptyMap(),
): File? {
val dir = configDir(context) ?: return null
val source = config.text ?: return null
// Icons, labels and colours live in the binding strings (SteamTouchBindings), as the
// configurator writes them.
val source = bindings.entries.fold(config.text ?: return null) { text, (ref, change) ->
SteamTouchBindings.rewrite(text, ref, change) ?: text
}
val target = File(dir, "$appId/controller_mobile_touch.vdf")
val id = layoutIdOf(actionSet)
val old = config.layouts
val keep = old?.layouts?.filter { it.actionSet != id }.orEmpty()
val color = old?.forActionSet(id)?.color ?: floatArrayOf(1f, 1f, 1f, 0.4f)
val layouts = Layouts(keep + Layout(id, elements, color, old?.forActionSet(id)?.version),
val layoutColor = color ?: old?.forActionSet(id)?.color ?: floatArrayOf(1f, 1f, 1f, 0.4f)
val layouts = Layouts(keep + Layout(id, elements, layoutColor, old?.forActionSet(id)?.version),
old?.rest ?: byteArrayOf(0x10, 0x02)) // input_mode = controller
val hex = bytesToHex(encodeLayouts(layouts))
val line = "\t\"touch_layout\"\t\t\"$hex\""
@@ -28,8 +28,10 @@ import kotlin.math.min
* (SteamTouchDevice.State, SteamTouchConfig), placed where the config's layout puts each control or
* where Steam Link would by default, and only the controls the config binds. Touches become the
* touch controller's input report; the client does the rest - bindings, action sets, the virtual
* pad the game reads. [startEditing] moves, resizes and hides controls and saves the layout into
* the game's touch config, as Steam Link does.
* pad the game reads. Each control shows its binding's icon, label and colours, as Steam keeps them
* (SteamTouchBindings). [startEditing] moves, resizes and hides controls, picks their icons and the
* layout's colour, and saves all of it into the game's touch config, as Steam Link and Steam's
* configurator do.
*/
@SuppressLint("ViewConstructor")
class SteamTouchControls(
@@ -53,6 +55,10 @@ class SteamTouchControls(
private var tint = Color.WHITE
private var alphaScale = 0.45f
// What each control is bound to (its icon, label, colours), per element type; the D-pad has four.
private class Visual(val ref: SteamTouchBindings.Ref?, val binding: SteamTouchBindings.Binding?)
private var visuals: Map<Int, List<Visual>> = emptyMap()
// Touch state.
private class Finger(val element: Element?, var x: Float, var y: Float, val toolbar: Int = -1)
private val fingers = HashMap<Int, Finger>()
@@ -66,6 +72,10 @@ class SteamTouchControls(
private var pinchStart = 0f
private var pinchScale = 1f
private var saving = false
// Icon and colour edits not saved yet.
private val pendingBindings = HashMap<SteamTouchBindings.Ref, SteamTouchBindings.Binding>()
private var pendingColor: FloatArray? = null
private var selectedArm = 0
private val fill = Paint(Paint.ANTI_ALIAS_FLAG)
private val stroke = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE }
@@ -130,9 +140,8 @@ class SteamTouchControls(
private fun rebuild() {
elements = SteamTouchConfig.elementsFor(config, actionSet, layers)
val color = SteamTouchConfig.layoutColor(config, actionSet)
tint = if (color != null) Color.rgb((color[0] * 255).toInt(), (color[1] * 255).toInt(), (color[2] * 255).toInt()) else Color.WHITE
alphaScale = color?.get(3)?.coerceIn(0.15f, 1f) ?: 0.45f
applyColor(pendingColor ?: SteamTouchConfig.layoutColor(config, actionSet))
rebuildVisuals()
if (!editing) {
Log.i(TAG, "steam touch: app $appId, action set $actionSet, layers $layers: ${elements.size} controls")
releaseAll()
@@ -140,6 +149,30 @@ class SteamTouchControls(
invalidate()
}
private fun applyColor(color: FloatArray?) {
tint = if (color != null) Color.rgb((color[0] * 255).toInt(), (color[1] * 255).toInt(), (color[2] * 255).toInt()) else Color.WHITE
alphaScale = color?.get(3)?.coerceIn(0.15f, 1f) ?: 0.45f
}
/** Each control's bindings for the action set and layers in use, with edits not saved yet. */
private fun rebuildVisuals() {
val mappings = config.mappings
val preset = SteamTouchConfig.layoutIdOf(actionSet) - 1
val types = (elements + editElements).map { it.type }.toSet()
visuals = if (mappings == null) emptyMap() else types.associateWith { type ->
SteamTouchBindings.refsFor(mappings, type, preset, layers).map { ref ->
Visual(ref, ref?.let { pendingBindings[it] ?: SteamTouchBindings.bindingAt(mappings, it) })
}
}
val icons = visuals.values.flatten().mapNotNull { it.binding?.icon?.takeIf(String::isNotEmpty) }.toSet()
if (icons.isEmpty()) return
val app = appId
loader.execute {
icons.forEach { SteamTouchBindings.icon(context, app, it, ICON_PX) }
handler.post { invalidate() }
}
}
// ---- Geometry ----
private fun unit() = min(width / 1280f, height / 720f)
@@ -206,32 +239,86 @@ class SteamTouchControls(
canvas.drawRoundRect(rect, r * 0.25f, r * 0.25f, fill)
canvas.drawRoundRect(rect, r * 0.25f, r * 0.25f, stroke)
}
else -> {
canvas.drawCircle(x, y, r, fill)
canvas.drawCircle(x, y, r, stroke)
val label = label(e.type)
if (label.length > 3) text.textSize = r * 0.36f
LABEL_COLORS[e.type]?.let { text.color = it }
canvas.drawText(label, x, y - (text.descent() + text.ascent()) / 2, text)
}
else -> drawFace(canvas, e.type, visuals[e.type]?.firstOrNull()?.binding, x, y, r, baseAlpha, pressed)
}
}
/**
* A button: its binding's icon on a disc of its background colour, as Steam draws binding icons;
* else its label; else a glyph for what it presses; else the button's own name.
*/
private fun drawFace(canvas: Canvas, type: Int, binding: SteamTouchBindings.Binding?, x: Float, y: Float, r: Float,
baseAlpha: Int, pressed: Boolean) {
val icon = binding?.icon?.takeIf { it.isNotEmpty() }?.let { SteamTouchBindings.cachedIcon(appId, it, ICON_PX) }
if (icon != null) {
val alpha = min(255, baseAlpha + if (pressed) 150 else 90)
val disc = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = parseColor(binding.background, SteamTouchBindings.DEFAULT_BACKGROUND)
this.alpha = alpha
}
canvas.drawCircle(x, y, r, disc)
val s = r * 0.62f
SteamTouchIconPicker.drawSteamIcon(canvas, icon, RectF(x - s, y - s, x + s, y + s),
parseColor(binding.foreground, SteamTouchBindings.DEFAULT_FOREGROUND),
Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply { this.alpha = alpha })
canvas.drawCircle(x, y, r, stroke)
return
}
canvas.drawCircle(x, y, r, fill)
canvas.drawCircle(x, y, r, stroke)
val own = label(type)
val shown = binding?.label?.takeIf { it.isNotEmpty() } ?: SteamTouchBindings.glyph(binding) ?: own
text.textSize = when {
shown.length <= 2 -> r * 0.7f
shown.length <= 4 -> r * 0.42f
else -> r * 0.3f
}
if (shown == own) LABEL_COLORS[type]?.let { text.color = it }
val line = if (shown.length > 10) shown.take(9) + "…" else shown
canvas.drawText(line, x, y - (text.descent() + text.ascent()) / 2, text)
}
private fun parseColor(hex: String, fallback: String) =
try { Color.parseColor(hex.ifEmpty { fallback }) } catch (_: IllegalArgumentException) { Color.parseColor(fallback) }
private fun drawDpad(canvas: Canvas, e: Element, x: Float, y: Float, r: Float) {
val arm = r * 0.36f
val dirs = if (editing) 0 else dpadBits(e)
val arms = listOf(DPAD_UP to (0f to -1f), DPAD_DOWN to (0f to 1f), DPAD_LEFT to (-1f to 0f), DPAD_RIGHT to (1f to 0f))
for ((bit, d) in arms) {
val armVisuals = visuals[SteamTouchConfig.DPAD].orEmpty()
val isSelected = editing && editElements.indexOf(e) == selected
for ((armIndex, armEntry) in arms.withIndex()) {
val (bit, d) = armEntry
val (dx, dy) = d
val cxArm = x + dx * r * 0.6f
val cyArm = y + dy * r * 0.6f
val rect = RectF(cxArm - if (dx == 0f) arm else r * 0.4f, cyArm - if (dy == 0f) arm else r * 0.4f,
cxArm + if (dx == 0f) arm else r * 0.4f, cyArm + if (dy == 0f) arm else r * 0.4f)
val old = fill.color
val oldStroke = stroke.color
if (dirs and bit != 0L) fill.color = Color.argb(200, 20, 24, 30)
if (isSelected && armIndex == selectedArm) stroke.color = Color.rgb(255, 200, 60)
val binding = armVisuals.getOrNull(armIndex)?.binding
val icon = binding?.icon?.takeIf { it.isNotEmpty() }?.let { SteamTouchBindings.cachedIcon(appId, it, ICON_PX) }
if (icon != null) {
val disc = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = parseColor(binding.background, SteamTouchBindings.DEFAULT_BACKGROUND)
alpha = min(255, (255 * alphaScale).toInt() + if (dirs and bit != 0L) 150 else 90)
}
canvas.drawRoundRect(rect, arm * 0.4f, arm * 0.4f, disc)
val s = min(rect.width(), rect.height()) * 0.42f
SteamTouchIconPicker.drawSteamIcon(canvas, icon, RectF(rect.centerX() - s, rect.centerY() - s, rect.centerX() + s, rect.centerY() + s),
parseColor(binding.foreground, SteamTouchBindings.DEFAULT_FOREGROUND),
Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG).apply { alpha = disc.alpha })
canvas.drawRoundRect(rect, arm * 0.4f, arm * 0.4f, stroke)
fill.color = old
stroke.color = oldStroke
continue
}
canvas.drawRoundRect(rect, arm * 0.4f, arm * 0.4f, fill)
canvas.drawRoundRect(rect, arm * 0.4f, arm * 0.4f, stroke)
fill.color = old
stroke.color = oldStroke
val tri = Path()
val s = arm * 0.5f
val tx = cxArm + dx * r * 0.08f
@@ -390,20 +477,25 @@ class SteamTouchControls(
hidden = available.filter { type -> editElements.none { it.type == type } }
.mapNotNull { SteamTouchConfig.defaultElement(it, available)?.copy(visible = false) }.toMutableList()
selected = -1
pendingBindings.clear()
pendingColor = null
rebuildVisuals()
invalidate()
}
private fun stopEditing() {
editing = false
selected = -1
pendingBindings.clear()
pendingColor = null
rebuild()
}
private val toolbarLabels = listOf("−", "+", "Hide", "Reset", "Cancel", "Save")
private val toolbarLabels = listOf("−", "+", "Hide", "Icon", "Colour", "Reset", "Cancel", "Save")
private fun toolbarRects(): List<RectF> {
val u = max(unit(), 0.6f)
val w = 120f * u
val w = 112f * u
val h = 56f * u
val gap = 12f * u
val total = toolbarLabels.size * w + (toolbarLabels.size - 1) * gap
@@ -416,12 +508,12 @@ class SteamTouchControls(
val bar = Paint(Paint.ANTI_ALIAS_FLAG)
val rects = toolbarRects()
val title = Paint(text).apply { color = Color.WHITE; textSize = 26f * max(unit(), 0.6f) }
canvas.drawText(if (saving) "Saving…" else "Editing touch layout for ${if (appId == BIG_PICTURE) "Steam" else "app $appId"} - drag, pinch or use −/+",
canvas.drawText(if (saving) "Saving…" else "Editing touch layout for ${if (appId == BIG_PICTURE) "Steam" else "app $appId"} - drag, pinch, or pick a control's icon",
width / 2f, rects[0].top - 18f * unit(), title)
rects.forEachIndexed { i, r ->
val enabled = i > 2 || selected >= 0
val enabled = i > 3 || selected >= 0
bar.color = when (i) {
5 -> Color.rgb(26, 159, 255)
7 -> Color.rgb(26, 159, 255)
else -> Color.argb(if (enabled) 230 else 120, 50, 56, 66)
}
canvas.drawRoundRect(r, 10f, 10f, bar)
@@ -444,6 +536,12 @@ class SteamTouchControls(
val e = hit(editElements, x, y)
selected = if (e != null) editElements.indexOf(e) else -1
if (e != null) dragOffset = (x - cx(e)) to (y - cy(e))
// The D-pad's icons are per direction: a tap picks the one under the finger.
if (e?.type == SteamTouchConfig.DPAD) {
val dx = x - cx(e)
val dy = y - cy(e)
selectedArm = if (abs(dx) > abs(dy)) (if (dx < 0) 2 else 3) else (if (dy < 0) 0 else 1)
}
invalidate()
}
MotionEvent.ACTION_POINTER_DOWN -> if (selected >= 0 && event.pointerCount == 2) {
@@ -481,18 +579,81 @@ class SteamTouchControls(
hidden += editElements.removeAt(selected).copy(visible = false)
selected = -1
}
3 -> {
3 -> if (selected >= 0) pickIcon(editElements[selected])
4 -> pickColor()
5 -> {
val available = config.availableFor(actionSet, layers)
editElements = available.mapNotNull { SteamTouchConfig.defaultElement(it, available) }.sortedBy { it.type }.toMutableList()
hidden.clear()
selected = -1
}
4 -> stopEditing()
5 -> save()
6 -> stopEditing()
7 -> save()
}
invalidate()
}
/** The selected control's icon, label and colours (a D-pad direction: the one tapped). */
private fun pickIcon(e: Element) {
val list = visuals[e.type].orEmpty()
val visual = list.getOrNull(if (e.type == SteamTouchConfig.DPAD) selectedArm else 0)
val ref = visual?.ref
val binding = visual?.binding
if (ref == null || binding == null) {
Toast.makeText(context, "This control has no binding in Steam to put an icon on", Toast.LENGTH_SHORT).show()
return
}
val name = if (e.type == SteamTouchConfig.DPAD) "D-pad " + listOf("up", "down", "left", "right")[selectedArm] else label(e.type)
SteamTouchIconPicker(context, appId, "Icon for $name", binding) { picked ->
pendingBindings[ref] = picked
rebuildVisuals()
invalidate()
}.show()
}
/** The layout's colour and opacity, as Steam Link's colour picker sets them. */
private fun pickColor() {
val current = pendingColor ?: SteamTouchConfig.layoutColor(config, actionSet) ?: floatArrayOf(1f, 1f, 1f, 0.45f)
val density = resources.displayMetrics.density
fun dp(v: Int) = (v * density).toInt()
var chosen = current.copyOf()
val root = android.widget.LinearLayout(context).apply { orientation = android.widget.LinearLayout.VERTICAL; setPadding(dp(16), dp(8), dp(16), 0) }
val swatchRow = android.widget.LinearLayout(context)
SteamTouchBindings.PALETTE.forEach { hex ->
val c = Color.parseColor(hex)
swatchRow.addView(View(context).apply {
background = android.graphics.drawable.GradientDrawable().apply { shape = android.graphics.drawable.GradientDrawable.OVAL; setColor(c); setStroke(dp(1), Color.GRAY) }
setOnClickListener {
chosen = floatArrayOf(Color.red(c) / 255f, Color.green(c) / 255f, Color.blue(c) / 255f, chosen[3])
pendingColor = chosen
applyColor(chosen)
invalidate()
}
}, android.widget.LinearLayout.LayoutParams(dp(30), dp(30)).apply { marginEnd = dp(6) })
}
root.addView(android.widget.TextView(context).apply { text = "Colour" })
root.addView(android.widget.HorizontalScrollView(context).apply { addView(swatchRow) })
root.addView(android.widget.TextView(context).apply { text = "Opacity"; setPadding(0, dp(12), 0, 0) })
val opacityRow = android.widget.LinearLayout(context)
listOf(20, 35, 50, 65, 80, 100).forEach { percent ->
opacityRow.addView(android.widget.Button(context).apply {
text = "$percent%"
setOnClickListener {
chosen = floatArrayOf(chosen[0], chosen[1], chosen[2], percent / 100f)
pendingColor = chosen
applyColor(chosen)
invalidate()
}
})
}
root.addView(android.widget.HorizontalScrollView(context).apply { addView(opacityRow) })
android.app.AlertDialog.Builder(context, android.R.style.Theme_DeviceDefault_Dialog_Alert)
.setTitle("Touch layout colour")
.setView(root)
.setPositiveButton(android.R.string.ok, null)
.show()
}
private fun save() {
saving = true
invalidate()
@@ -500,9 +661,17 @@ class SteamTouchControls(
val set = actionSet
val cfg = config
val layout = editElements.map { it.copy(visible = true) } + hidden.filter { h -> editElements.none { it.type == h.type } }
val color = pendingColor
// A picked icon replaces the label, icon and colours of every binding of that input; what
// the binding does stays as it is.
val bindings = pendingBindings.mapValues { (_, picked) ->
{ old: SteamTouchBindings.Binding ->
old.copy(label = picked.label, icon = picked.icon, foreground = picked.foreground, background = picked.background)
}
}
loader.execute {
val file = try {
SteamTouchConfig.saveLayout(context, app, cfg, set, layout)
SteamTouchConfig.saveLayout(context, app, cfg, set, layout, color, bindings)
} catch (e: Exception) {
Log.w(TAG, "steam touch: saving failed: $e")
null
@@ -529,6 +698,7 @@ class SteamTouchControls(
private const val TAG = "SteamTouch"
private const val POLL_MS = 150L
const val BIG_PICTURE = 769
private const val ICON_PX = 128
private const val DPAD_UP = 1L shl 8
private const val DPAD_RIGHT = 1L shl 9
@@ -0,0 +1,188 @@
package com.droiddeck.launcher.input
import android.app.AlertDialog
import android.content.Context
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter
import android.graphics.drawable.GradientDrawable
import android.os.Handler
import android.os.Looper
import android.text.InputType
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.BaseAdapter
import android.widget.EditText
import android.widget.GridView
import android.widget.HorizontalScrollView
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.TextView
import java.util.concurrent.Executors
/**
* Picks a touch control's icon, label and colours the way Steam's configurator does: an icon from
* the game's own `TouchMenuIcons` and Steam's binding icon library, a foreground and a background
* from Steam's palette. [onPicked] gets the binding with the new fields (the action untouched).
*/
class SteamTouchIconPicker(
private val context: Context,
private val appId: Int,
private val title: String,
private val current: SteamTouchBindings.Binding,
private val onPicked: (SteamTouchBindings.Binding) -> Unit,
) {
private val main = Handler(Looper.getMainLooper())
private val loader = Executors.newFixedThreadPool(2)
private var icon = current.icon
private var foreground = current.foreground.ifEmpty { SteamTouchBindings.DEFAULT_FOREGROUND }
private var background = current.background.ifEmpty { SteamTouchBindings.DEFAULT_BACKGROUND }
private val density = context.resources.displayMetrics.density
private fun dp(v: Int) = (v * density).toInt()
fun show() {
val root = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL; setPadding(dp(16), dp(8), dp(16), 0) }
val label = EditText(context).apply {
hint = "Label (optional)"
setText(current.label)
inputType = InputType.TYPE_CLASS_TEXT
setSingleLine()
}
root.addView(label)
val preview = ImageView(context)
val names = SteamTouchBindings.iconNames(context, appId)
val grid = GridView(context).apply {
numColumns = GridView.AUTO_FIT
columnWidth = dp(56)
stretchMode = GridView.STRETCH_COLUMN_WIDTH
verticalSpacing = dp(6)
horizontalSpacing = dp(6)
}
val adapter = IconAdapter(names)
grid.adapter = adapter
grid.setOnItemClickListener { _, _, position, _ ->
icon = names[position]
adapter.notifyDataSetChanged()
updatePreview(preview)
}
root.addView(LinearLayout(context).apply {
gravity = Gravity.CENTER_VERTICAL
addView(preview, LinearLayout.LayoutParams(dp(56), dp(56)))
addView(TextView(context).apply {
text = if (names.isEmpty()) "Steam's icons were not found" else "${names.size} icons from Steam"
setPadding(dp(12), 0, 0, 0)
})
})
root.addView(grid, LinearLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(220)))
root.addView(paletteRow("Icon colour", { foreground }) { foreground = it; adapter.notifyDataSetChanged(); updatePreview(preview) })
root.addView(paletteRow("Button colour", { background }) { background = it; adapter.notifyDataSetChanged(); updatePreview(preview) })
updatePreview(preview)
AlertDialog.Builder(context, android.R.style.Theme_DeviceDefault_Dialog_Alert)
.setTitle(title)
.setView(root)
.setPositiveButton("Use") { _, _ ->
onPicked(current.copy(label = label.text.toString().replace(",", " ").trim(), icon = icon,
foreground = if (icon.isEmpty()) "" else foreground, background = if (icon.isEmpty()) "" else background))
}
.setNeutralButton("No icon") { _, _ ->
onPicked(current.copy(label = label.text.toString().replace(",", " ").trim(), icon = "", foreground = "", background = ""))
}
.setNegativeButton(android.R.string.cancel, null)
.setOnDismissListener { loader.shutdown() }
.show()
}
private fun paletteRow(name: String, value: () -> String, onPick: (String) -> Unit): View {
val row = LinearLayout(context).apply { orientation = LinearLayout.HORIZONTAL; gravity = Gravity.CENTER_VERTICAL }
val swatches = mutableListOf<View>()
fun refresh() = swatches.forEachIndexed { i, v ->
(v.background as GradientDrawable).setStroke(dp(if (SteamTouchBindings.PALETTE[i].equals(value(), true)) 3 else 1),
if (SteamTouchBindings.PALETTE[i].equals(value(), true)) Color.rgb(102, 192, 244) else Color.GRAY)
}
SteamTouchBindings.PALETTE.forEach { hex ->
val swatch = View(context).apply {
background = GradientDrawable().apply { shape = GradientDrawable.OVAL; setColor(Color.parseColor(hex)) }
setOnClickListener { onPick(hex); refresh() }
}
swatches += swatch
row.addView(swatch, LinearLayout.LayoutParams(dp(30), dp(30)).apply { marginEnd = dp(6) })
}
refresh()
return LinearLayout(context).apply {
orientation = LinearLayout.VERTICAL
setPadding(0, dp(8), 0, 0)
addView(TextView(context).apply { text = name })
addView(HorizontalScrollView(context).apply { addView(row) })
}
}
private fun updatePreview(view: ImageView) {
if (icon.isEmpty()) { view.setImageDrawable(null); view.background = null; return }
loader.execute {
val bitmap = SteamTouchBindings.icon(context, appId, icon, 128)?.let { render(it, dp(56)) }
main.post { view.setImageBitmap(bitmap) }
}
}
/** The icon as Steam draws it: on a disc of the background colour, filled with the foreground
* through its own alpha. */
private fun render(icon: Bitmap, size: Int): Bitmap {
val out = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
val canvas = Canvas(out)
val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
paint.color = Color.parseColor(background)
canvas.drawCircle(size / 2f, size / 2f, size / 2f, paint)
drawSteamIcon(canvas, icon, android.graphics.RectF(size * 0.18f, size * 0.18f, size * 0.82f, size * 0.82f), Color.parseColor(foreground), paint)
return out
}
private inner class IconAdapter(val names: List<String>) : BaseAdapter() {
override fun getCount() = names.size
override fun getItem(position: Int) = names[position]
override fun getItemId(position: Int) = position.toLong()
override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
val view = (convertView as? ImageView) ?: ImageView(context).apply {
layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, dp(56))
setPadding(dp(8), dp(8), dp(8), dp(8))
}
val name = names[position]
view.tag = name
view.background = GradientDrawable().apply {
cornerRadius = dp(8).toFloat()
setColor(Color.parseColor(background))
if (name == icon) setStroke(dp(3), Color.rgb(102, 192, 244))
}
view.colorFilter = null
val cached = SteamTouchBindings.cachedIcon(appId, name, 96)
if (cached != null) view.setImageBitmap(tinted(cached)) else {
view.setImageDrawable(null)
loader.execute {
val bitmap = SteamTouchBindings.icon(context, appId, name, 96)
main.post { if (view.tag == name && bitmap != null) view.setImageBitmap(tinted(bitmap)) }
}
}
return view
}
private fun tinted(icon: Bitmap): Bitmap {
val out = Bitmap.createBitmap(icon.width, icon.height, Bitmap.Config.ARGB_8888)
drawSteamIcon(Canvas(out), icon, android.graphics.RectF(0f, 0f, icon.width.toFloat(), icon.height.toFloat()),
Color.parseColor(foreground), Paint(Paint.FILTER_BITMAP_FLAG))
return out
}
}
companion object {
/** Steam's binding icon look (steamui: the icon multiplied with its foreground colour,
* background-blend-mode: multiply, keeping the icon's own alpha). */
fun drawSteamIcon(canvas: Canvas, icon: Bitmap, into: android.graphics.RectF, foreground: Int, paint: Paint) {
val tint = Paint(paint).apply { colorFilter = PorterDuffColorFilter(foreground, PorterDuff.Mode.MULTIPLY) }
canvas.drawBitmap(icon, null, into, tint)
}
}
}
@@ -0,0 +1,118 @@
package com.droiddeck.launcher.input
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
class SteamTouchBindingsTest {
private val config = """
"controller_mappings"
{
"version" "3"
"controller_type" "controller_mobile_touch"
"group"
{
"id" "0"
"mode" "four_buttons"
"inputs"
{
"button_a"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button A, , "
}
}
}
}
"button_b"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "key_press ESCAPE, Pause, ghost_050_menu_0020.png, #FFFFFF #AD0000"
}
}
}
}
}
}
"group"
{
"id" "9"
"mode" "dpad"
"inputs"
{
"dpad_north"
{
"activators"
{
"Full_Press"
{
"bindings"
{
"binding" "xinput_button DPAD_UP, , "
}
}
}
}
}
}
"preset"
{
"id" "0"
"name" "Default"
"group_source_bindings"
{
"0" "button_diamond active"
"9" "dpad active"
}
}
}
"""
private val mappings = KeyValues.parse(config).child("controller_mappings")!!
@Test
fun parsesAndComposesSteamsFormat() {
val b = SteamTouchBindings.parse("key_press ESCAPE, Pause, ghost_050_menu_0020.png, #FFFFFF #AD0000")
assertEquals("key_press ESCAPE", b.action)
assertEquals("Pause", b.label)
assertEquals("ghost_050_menu_0020.png", b.icon)
assertEquals("#FFFFFF", b.foreground)
assertEquals("#AD0000", b.background)
assertEquals("key_press ESCAPE, Pause, ghost_050_menu_0020.png, #FFFFFF #AD0000", b.compose())
assertEquals("xinput_button A, , ", SteamTouchBindings.parse("xinput_button A, , ").compose())
}
@Test
fun findsEachControlsBinding() {
val b = SteamTouchBindings.refsFor(mappings, SteamTouchConfig.B, 0).single()!!
assertEquals(SteamTouchBindings.Ref("0", "button_b"), b)
assertEquals("ghost_050_menu_0020.png", SteamTouchBindings.bindingAt(mappings, b)!!.icon)
val dpad = SteamTouchBindings.refsFor(mappings, SteamTouchConfig.DPAD, 0)
assertEquals(4, dpad.size)
assertEquals("xinput_button DPAD_UP", SteamTouchBindings.bindingAt(mappings, dpad[0]!!)!!.action)
assertNull(SteamTouchBindings.refsFor(mappings, SteamTouchConfig.BUMPER_LEFT, 0).single())
assertEquals("Esc", SteamTouchBindings.glyph(SteamTouchBindings.parse("key_press ESCAPE, , ")))
}
@Test
fun rewritesOnlyThatBinding() {
val ref = SteamTouchBindings.Ref("0", "button_a")
val out = SteamTouchBindings.rewrite(config, ref) { it.copy(label = "Jump", icon = "ghost_045_move_0400.png", foreground = "#232323", background = "#E4E4E4") }
assertNotNull(out)
assertEquals(config.replace("\"xinput_button A, , \"", "\"xinput_button A, Jump, ghost_045_move_0400.png, #232323 #E4E4E4\""), out)
val reparsed = KeyValues.parse(out!!).child("controller_mappings")!!
assertEquals("Jump", SteamTouchBindings.bindingAt(reparsed, ref)!!.label)
assertNull(SteamTouchBindings.rewrite(config, SteamTouchBindings.Ref("5", "button_a")) { it })
}
}