mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 19:41:10 +00:00
feat: Steam's touch icons, labels and colours on the touch controls; pick and save them like Steam
This commit is contained in:
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 })
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user