diff --git a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt index fc9fa91f1..4d6406ce7 100644 --- a/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt +++ b/app/src/main/java/com/droiddeck/launcher/SessionActivity.kt @@ -79,6 +79,7 @@ import com.droiddeck.launcher.input.OnScreenControls import com.droiddeck.launcher.input.InputRouter import com.droiddeck.launcher.input.InputOutput import com.droiddeck.launcher.input.InputCommand +import com.droiddeck.launcher.input.quickButton import com.droiddeck.launcher.input.InputProfiles import com.droiddeck.launcher.input.InputProfile import com.droiddeck.launcher.input.InputAction @@ -160,6 +161,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private var unfoldedControls by mutableStateOf(false) private var unfoldedControlsWithController by mutableStateOf(false) private var adaptiveSticks by mutableStateOf(null) + private var quickButtons by mutableStateOf>(emptyMap()) private var picturePosition by mutableStateOf("center") private fun applyGameAlignment() { @@ -173,7 +175,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } WaylandCompositor.nativeSetScaleMode(SCALE_FIT, alignment) } - private var unfoldedPanel by mutableStateOf(null) + private var unfoldedPanel by mutableStateOf(null) + private var unfoldedColumn: android.widget.LinearLayout? = null private lateinit var sessionRoot: FrameLayout private var inputRouter: InputRouter? = null private var physicalInputBindings: com.droiddeck.launcher.input.PhysicalInputBindings? = null @@ -1416,6 +1419,14 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { primaryInputLayout?.let { com.droiddeck.launcher.input.InputLibraries.setAdaptiveSticks(this@SessionActivity, it.id, on) } refreshInputProfile() }, + quickButtons = quickButtons.takeIf { + unfoldedPanel != null || oscMode != SessionPrefs.OSC_NEVER && !steamTouchInUse + }.orEmpty(), + onQuickButton = { button, on -> + primaryInputLayout?.let { com.droiddeck.launcher.input.InputLibraries.setQuickButton(this@SessionActivity, it.id, button, on) } + refreshInputProfile() + updateOnScreenControls() + }, unfoldedControlsWithController = unfoldedControlsWithController, onUnfoldedControlsWithController = ::chooseUnfoldedControlsWithController, onBackground = { drawerOpen = false; pip.background(); moveTaskToBack(true) }, @@ -2813,7 +2824,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } return onScreenControls?.let { takes(it, it::takesTouchAt) } == true || steamTouchControls?.let { takes(it, it::takesTouchAt) } == true || - unfoldedPanel?.let { takes(it, it::takesTouchAt) } == true + unfoldedPanel?.let { takes(it, it::takesTouchAt) } == true || + unfoldedColumn?.getChildAt(0)?.let { takes(it) { _, _ -> true } } == true } override fun dispatchTouchEvent(event: MotionEvent): Boolean { @@ -3401,6 +3413,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { DroidDeckTheme { SessionMenu(docked = true) } }, onSwap = if (bottomSwapAvailable && com.droiddeck.launcher.input.DualScreen.builtIn(target)) ({ putGameOnBottom(true) }) else null, + onDeckPanel = ::selectDeckPanel, dashboard = if (bottomPages().isEmpty()) null else ({ DroidDeckTheme { BottomDashboard( @@ -3511,10 +3524,11 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { private fun refreshInputProfile() { val library = com.droiddeck.launcher.input.InputLibraries.read(this) val desktop = loadingMode() == SessionService.MODE_DESKTOP - inputProfile = library.compileActive(desktop, secondScreenMode.bindings) + inputProfile = library.compileActive(desktop, secondScreenMode.panel(this)) primaryInputLayout = library.activeLayout(desktop, InputRole.PRIMARY) adaptiveSticks = primaryInputLayout?.adaptiveSticks - secondaryInputLayout = library.panelLayout(desktop, secondScreenMode.bindings) + quickButtons = primaryInputLayout?.takeIf { it.editable }?.let { l -> l.quickButtons.associateWith { it !in l.style.hidden } }.orEmpty() + secondaryInputLayout = library.panelLayout(desktop, secondScreenMode.panel(this)) inputUsesLocalActions = InputControl.padControls.any { inputProfile.action(InputRole.PHYSICAL, it) !is InputAction.Native } inputUsesPointer = inputProfile.bindings.values.any { bindings -> bindings.values.any { it is InputAction.Pointer || it is InputAction.MouseButton || it is InputAction.Scroll } @@ -3527,8 +3541,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { DeckControls.refresh() onScreenControls?.updateLayout(primaryInputLayout) bottomInputControls?.updateLayout(primaryInputLayout) - unfoldedPanel?.updateCustomLayout(primaryInputLayout?.elements.orEmpty()) - unfoldedPanel?.updateStyle(primaryInputLayout?.style ?: com.droiddeck.launcher.input.InputSurfaceStyle()) + unfoldedPanel?.updateLayout(primaryInputLayout) updateOnScreenPad() secondScreenPresentation?.refreshControls() } @@ -3537,6 +3550,13 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { if (SessionState.mode == SessionService.MODE_STEAM) padBridge?.triggerQam() } + private fun selectDeckPanel(panel: com.droiddeck.launcher.input.DeckPanel) { + if (SessionPrefs.deckPanel(this) == panel) return + releasePads() + SessionPrefs.setDeckPanel(this, panel) + refreshInputProfile() + } + private fun chooseUnfoldedControls(on: Boolean) { SessionPrefs.setUnfoldedControls(this, on) unfoldedControls = on @@ -3573,19 +3593,35 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { Log.i(TAG, if (wanted) "unfolded controls: game ${width}x$gameHeight, controls ${width}x${height - gameHeight}" else "unfolded controls: game full screen") } + val dock = com.droiddeck.launcher.input.OnScreenLayout.Dock(!com.droiddeck.launcher.session.SessionDisplay.foldable(this), SessionState.deckPad) var panel = unfoldedPanel - if (!wanted) { - if (panel != null) { sessionRoot.removeView(panel); unfoldedPanel = null; updateOnScreenControls() } - return + if (panel != null && (!wanted || panel.dock != dock)) { + sessionRoot.removeView(unfoldedColumn) + unfoldedColumn = null + unfoldedPanel = null + panel = null + if (!wanted) updateOnScreenControls() } + if (!wanted) return if (panel == null) { - panel = com.droiddeck.launcher.ui.InputSurfaceView(this, padBridge, primaryInputLayout?.elements.orEmpty(), InputRole.PRIMARY, - primaryInputLayout?.style ?: com.droiddeck.launcher.input.InputSurfaceStyle()) - sessionRoot.addView(panel, sessionRoot.indexOfChild(surfaceView) + 1) + panel = OnScreenControls(this, padBridge, dock).also { it.updateLayout(primaryInputLayout) } + val column = android.widget.LinearLayout(this).apply { + orientation = android.widget.LinearLayout.VERTICAL + addView(unfoldedBar(), android.widget.LinearLayout.LayoutParams(android.widget.LinearLayout.LayoutParams.MATCH_PARENT, + (com.droiddeck.launcher.ui.BAR_HEIGHT.value * resources.displayMetrics.density + 0.5f).toInt())) + addView(panel, android.widget.LinearLayout.LayoutParams(android.widget.LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f)) + } + sessionRoot.addView(column, sessionRoot.indexOfChild(surfaceView) + 1) + unfoldedColumn = column unfoldedPanel = panel updateOnScreenControls() } - panel.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, height - gameHeight, Gravity.BOTTOM) + unfoldedColumn?.layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, height - gameHeight, Gravity.BOTTOM) + } + + private fun unfoldedBar(): View = com.droiddeck.launcher.ui.bottomScreenBarView(this) { + com.droiddeck.launcher.ui.sessionBarModel(getString(R.string.deck_tab_gamepad), { chooseUnfoldedControls(false) }, + { drawerOpen = true }, ::tapSteamButton, ::tapQam) } private fun bottomKeyboardPresentation(): SecondScreenPresentation? { @@ -3725,7 +3761,7 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { } private fun panelMode(mode: SecondScreenMode) { - val rebind = secondScreenMode.bindings != mode.bindings + val rebind = secondScreenMode.panel(this) != mode.panel(this) secondScreenMode = mode SessionState.secondScreenMode = mode if (rebind) refreshInputProfile() @@ -4210,7 +4246,8 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { glassesSlot = when { c == null || c.visibility != View.VISIBLE -> 0 c.isButtonsOnly -> -1 - controllerSettings?.keyboardButton != false -> 2 + primaryInputLayout?.let { l -> l.elements.any { it.quickButton == InputCommand.KEYBOARD && !l.style.hides(it) } } + ?: (controllerSettings?.keyboardButton != false) -> 2 else -> 1 } } @@ -4996,10 +5033,15 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { val swapBack = { putGameOnBottom(false) } val steam = SessionState.mode == SessionService.MODE_STEAM val openMenu = { drawerOpen = true } - fun bar(title: String?, extras: List = emptyList()) = com.droiddeck.launcher.ui.BottomBarModel( - title, openMenu, openMenu, if (steam) ({ tapSteamButton() }) else null, if (steam) ({ tapQam() }) else null, - extras + com.droiddeck.launcher.ui.BarExtra(getString(R.string.swap_screens), icon = R.drawable.ic_bottom_swap, onClick = swapBack), - ) + fun bar(title: String?, extras: List = emptyList(), tabs: com.droiddeck.launcher.ui.BarTabs? = null) = + com.droiddeck.launcher.ui.BottomBarModel( + title, openMenu, openMenu, if (steam) ({ tapSteamButton() }) else null, if (steam) ({ tapQam() }) else null, + extras + com.droiddeck.launcher.ui.BarExtra(getString(R.string.swap_screens), icon = R.drawable.ic_bottom_swap, onClick = swapBack), + tabs = tabs, + ) + val keyboardExtra = com.droiddeck.launcher.ui.BarExtra(getString(R.string.second_show_keyboard), icon = R.drawable.ic_bottom_keyboard) { + keyboard?.toggle() + } val profileLayout = secondaryInputLayout androidx.compose.foundation.layout.Box( Modifier.fillMaxSize().background(androidx.compose.ui.graphics.Color.Black) @@ -5014,16 +5056,34 @@ class SessionActivity : ComponentActivity(), SurfaceHolder.Callback { }, modifier = Modifier.fillMaxSize(), ) - secondScreenMode.controls && (secondScreenMode != SecondScreenMode.DECK_CONTROLS || SessionState.deckPad) -> { - androidx.compose.runtime.key(profileLayout) { - androidx.compose.ui.viewinterop.AndroidView( - factory = { context -> com.droiddeck.launcher.ui.InputSurfaceView(context, padBridge, profileLayout?.elements.orEmpty(), - surfaceStyle = profileLayout?.style ?: com.droiddeck.launcher.input.InputSurfaceStyle()) }, - modifier = Modifier.fillMaxSize(), - ) + secondScreenMode.controls && (secondScreenMode != SecondScreenMode.DECK_CONTROLS || SessionState.deckPad) -> + androidx.compose.foundation.layout.Column(Modifier.fillMaxSize()) { + when (secondScreenMode) { + SecondScreenMode.KEYBOARD_TRACKPAD -> + com.droiddeck.launcher.ui.BottomScreenBar(bar(getString(SecondScreenMode.KEYBOARD_TRACKPAD.label), listOf(keyboardExtra))) + SecondScreenMode.DECK_CONTROLS -> com.droiddeck.launcher.ui.BottomScreenBar(bar(getString(SecondScreenMode.DECK_CONTROLS.label), + tabs = com.droiddeck.launcher.ui.deckPanelTabs(this@SessionActivity, SessionPrefs.deckPanel(this@SessionActivity), ::selectDeckPanel))) + else -> Unit + } + androidx.compose.runtime.key(profileLayout) { + androidx.compose.ui.viewinterop.AndroidView( + factory = { context -> com.droiddeck.launcher.ui.InputSurfaceView(context, padBridge, profileLayout?.elements.orEmpty(), + surfaceStyle = profileLayout?.style ?: com.droiddeck.launcher.input.InputSurfaceStyle()) }, + modifier = Modifier.fillMaxWidth().weight(1f), + ) + } } + secondScreenMode == SecondScreenMode.TERMINAL -> androidx.compose.foundation.layout.Column(Modifier.fillMaxSize()) { + var terminal by androidx.compose.runtime.remember { androidx.compose.runtime.mutableStateOf(null) } + com.droiddeck.launcher.ui.BottomScreenBar(bar(getString(SecondScreenMode.TERMINAL.label), listOf( + com.droiddeck.launcher.ui.BarExtra(getString(R.string.drawer_keyboard), icon = R.drawable.ic_bottom_keyboard) { terminal?.showKeyboard() }, + ))) + androidx.compose.ui.viewinterop.AndroidView( + factory = { context -> com.droiddeck.launcher.ui.SessionTerminalPanel(context).also { terminal = it } }, + modifier = Modifier.fillMaxWidth().weight(1f), + ) } - + pages.isEmpty() -> com.droiddeck.launcher.ui.BottomScreenBar(bar(null)) else -> BottomDashboard( pages, com.droiddeck.launcher.ui.SessionBottomTab.picked(SessionState.sessionId), gameStarts = gameStarts, diff --git a/app/src/main/java/com/droiddeck/launcher/input/InputButtonImages.kt b/app/src/main/java/com/droiddeck/launcher/input/InputButtonImages.kt new file mode 100644 index 000000000..3ca2053fa --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/InputButtonImages.kt @@ -0,0 +1,43 @@ +package com.droiddeck.launcher.input + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.net.Uri +import java.io.File +import java.util.UUID + +internal object InputButtonImages { + const val DIR = "screenkeys" + private const val MAX_SIZE_DP = 140 + + fun file(context: Context, name: String): File? = + name.takeIf { it.matches(Regex("[A-Za-z0-9_.-]{1,128}")) && it != "." && it != ".." }?.let { File(File(context.filesDir, DIR), it) } + + fun import(context: Context, uri: Uri): String? { + val dir = File(context.filesDir, DIR).apply { mkdirs() } + val name = "key_" + UUID.randomUUID() + ".png" + val longest = (MAX_SIZE_DP * context.resources.displayMetrics.density).toInt() + return runCatching { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + context.contentResolver.openInputStream(uri)?.use { BitmapFactory.decodeStream(it, null, bounds) } + val source = maxOf(bounds.outWidth, bounds.outHeight) + if (source <= 0) return@runCatching null + var sample = 1 + while (source / (sample * 2) >= longest) sample *= 2 + val bitmap = context.contentResolver.openInputStream(uri)?.use { + BitmapFactory.decodeStream(it, null, BitmapFactory.Options().apply { inSampleSize = sample }) + } ?: return@runCatching null + val scale = longest.toFloat() / maxOf(bitmap.width, bitmap.height) + val scaled = if (scale < 1f) Bitmap.createScaledBitmap(bitmap, (bitmap.width * scale).toInt().coerceAtLeast(1), + (bitmap.height * scale).toInt().coerceAtLeast(1), true) else bitmap + try { + val saved = File(dir, name).outputStream().use { scaled.compress(Bitmap.CompressFormat.PNG, 100, it) } + if (saved) name else null + } finally { + if (scaled !== bitmap) bitmap.recycle() + scaled.recycle() + } + }.getOrNull().also { if (it == null) File(dir, name).delete() } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/InputLayoutFile.kt b/app/src/main/java/com/droiddeck/launcher/input/InputLayoutFile.kt new file mode 100644 index 000000000..b47f9bf92 --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/InputLayoutFile.kt @@ -0,0 +1,123 @@ +package com.droiddeck.launcher.input + +import android.content.Context +import org.json.JSONArray +import org.json.JSONObject +import kotlin.math.roundToInt + +internal object InputLayoutFile { + private val NAMES: Map = mapOf( + 103 to "KEY_UP", 106 to "KEY_RIGHT", 108 to "KEY_DOWN", 105 to "KEY_LEFT", + 28 to "KEY_ENTER", 1 to "KEY_ESC", 14 to "KEY_BKSP", 111 to "KEY_DEL", 15 to "KEY_TAB", + 57 to "KEY_SPACE", 29 to "KEY_CTRL_L", 97 to "KEY_CTRL_R", 110 to "KEY_INSERT", + 42 to "KEY_SHIFT_L", 54 to "KEY_SHIFT_R", 56 to "KEY_ALT_L", 100 to "KEY_ALT_R", + 102 to "KEY_HOME", 107 to "KEY_END", 99 to "KEY_PRTSCN", 104 to "KEY_PG_UP", 109 to "KEY_PG_DOWN", + 58 to "KEY_CAPS_LOCK", 69 to "KEY_NUM_LOCK", 41 to "KEY_GRAVE", 12 to "KEY_MINUS", + 11 to "KEY_0", 2 to "KEY_1", 3 to "KEY_2", 4 to "KEY_3", 5 to "KEY_4", 6 to "KEY_5", + 7 to "KEY_6", 8 to "KEY_7", 9 to "KEY_8", 10 to "KEY_9", + 30 to "KEY_A", 48 to "KEY_B", 46 to "KEY_C", 32 to "KEY_D", 18 to "KEY_E", 33 to "KEY_F", + 34 to "KEY_G", 35 to "KEY_H", 23 to "KEY_I", 36 to "KEY_J", 37 to "KEY_K", 38 to "KEY_L", + 50 to "KEY_M", 49 to "KEY_N", 24 to "KEY_O", 25 to "KEY_P", 16 to "KEY_Q", 19 to "KEY_R", + 31 to "KEY_S", 20 to "KEY_T", 22 to "KEY_U", 47 to "KEY_V", 17 to "KEY_W", 45 to "KEY_X", + 21 to "KEY_Y", 44 to "KEY_Z", + 26 to "KEY_BRACKET_LEFT", 27 to "KEY_BRACKET_RIGHT", 43 to "KEY_BACKSLASH", 53 to "KEY_SLASH", + 39 to "KEY_SEMICOLON", 51 to "KEY_COMMA", 52 to "KEY_PERIOD", 40 to "KEY_APOSTROPHE", + 59 to "KEY_F1", 60 to "KEY_F2", 61 to "KEY_F3", 62 to "KEY_F4", 63 to "KEY_F5", 64 to "KEY_F6", + 65 to "KEY_F7", 66 to "KEY_F8", 67 to "KEY_F9", 68 to "KEY_F10", 87 to "KEY_F11", 88 to "KEY_F12", + 79 to "KEY_KP_1", 80 to "KEY_KP_2", 81 to "KEY_KP_3", 75 to "KEY_KP_4", 76 to "KEY_KP_5", + 77 to "KEY_KP_6", 71 to "KEY_KP_7", 72 to "KEY_KP_8", 73 to "KEY_KP_9", 82 to "KEY_KP_0", + ) + private val CODES: Map = NAMES.entries.associate { (code, name) -> name to code } + + fun fileName(layout: SavedInputLayout) = layout.name.replace(Regex("[^A-Za-z0-9 _.-]"), "_").trim().ifEmpty { "droiddeck-layout" } + ".icp" + + fun export(layout: SavedInputLayout, bindings: SavedInputBindings?, width: Int, height: Int): String? { + val snap = width.coerceAtLeast(1) / 100f + val elements = JSONArray() + for (e in layout.elements) { + val action = bindings?.action(e) ?: InputAction.Disabled + val directional = e.kind == InputElementKind.DPAD || e.kind == InputElementKind.STICK + val codes = when { + directional && action is InputAction.Directions -> listOf(action.up, action.right, action.down, action.left) + !directional && action is InputAction.Keys -> action.codes + else -> continue + } + val binds = JSONArray() + for (code in codes) { + val name = NAMES[code] + if (name != null) binds.put(name) else if (directional) binds.put("") + } + if ((0 until binds.length()).all { binds.getString(it).isEmpty() }) continue + val shape = when { + directional -> "ROUND_RECT" + e.buttonShape == InputButtonShape.CIRCLE -> "CIRCLE" + e.buttonShape == InputButtonShape.RECTANGLE -> "RECT" + else -> "ROUND_RECT" + } + val sizePx = e.height * height + val scale = when { + directional -> sizePx * 2.3f / (14f * snap) + shape == "CIRCLE" -> sizePx / (6f * snap) + else -> sizePx / (4f * snap) + } + elements.put(JSONObject() + .put("type", when (e.kind) { InputElementKind.DPAD -> "D_PAD"; InputElementKind.STICK -> "STICK"; else -> "BUTTON" }) + .put("shape", shape).put("customColor", 0).put("bindings", binds).put("scale", scale.toDouble()) + .put("x", (e.x + e.width / 2).toDouble()).put("y", (e.y + e.height / 2).toDouble()) + .put("toggleSwitch", e.toggle).put("swipeable", false) + .put("text", if (e.icon != 0 || e.image.isNotEmpty()) "" else e.name).put("iconId", 0) + .apply { if (e.opacity < 1f) put("opacity", e.opacity.toDouble()) }) + } + if (elements.length() == 0) return null + return JSONObject().put("id", 0).put("name", layout.name).put("cursorSpeed", 1.5).put("elements", elements).toString() + } + + fun parse(raw: String, width: Int, density: Float): List? { + val root = runCatching { JSONObject(raw) }.getOrNull() ?: return null + val elements = root.optJSONArray("elements") ?: return null + val snap = width.coerceAtLeast(1) / 100f + val keys = mutableListOf() + for (i in 0 until elements.length()) { + val element = elements.optJSONObject(i) ?: continue + val type = element.optString("type", "BUTTON") + if (type != "BUTTON" && type != "D_PAD" && type != "STICK") continue + val binds = element.optJSONArray("bindings") ?: continue + val raw = (0 until binds.length()).map { CODES[binds.optString(it)] ?: 0 } + val directional = type != "BUTTON" + val codes = when { + !directional -> raw.filter { it != 0 }.distinct().take(8) + type == "STICK" && raw.size <= 4 -> listOf(raw.getOrElse(0) { 0 }, 0, raw.getOrElse(1) { 0 }, 0, raw.getOrElse(2) { 0 }, 0, raw.getOrElse(3) { 0 }, 0) + else -> raw.take(if (type == "STICK") 8 else 4) + } + if (codes.none { it != 0 }) continue + val shape = when { + type == "D_PAD" -> 5 + type == "STICK" -> 6 + else -> when (element.optString("shape")) { "CIRCLE" -> 2; "RECT" -> 3; "SQUARE" -> 1; else -> 0 } + } + val scale = element.optDouble("scale", 1.0).toFloat().takeIf { it.isFinite() && it > 0f } ?: 1f + val sizePx = when { + directional -> 14f * snap * scale / 2.3f + shape == 2 -> 6f * snap * scale + else -> 4f * snap * scale + } + fun coordinate(name: String) = element.optDouble(name, .5).toFloat().takeIf { it.isFinite() }?.coerceIn(0f, 1f) ?: .5f + keys += LegacyScreenKey(codes, coordinate("x"), coordinate("y"), (sizePx / density).roundToInt().coerceIn(24, 140).toFloat(), + text = element.optString("text").trim().take(24), + opacity = element.optDouble("opacity", 1.0).toFloat().takeIf { it.isFinite() }?.coerceIn(.2f, 1f) ?: 1f, + shape = shape, toggle = element.optBoolean("toggleSwitch")) + if (keys.size == 64) break + } + return keys + } + + fun import(context: Context, raw: String, fallbackName: String, screen: InputLayoutScreen, style: InputSurfaceStyle, + width: Int, height: Int, density: Float, id: String = newInputId()): Pair? { + val keys = parse(raw, width, density)?.takeIf { it.isNotEmpty() } ?: return null + val converted = runCatching { LegacyScreenKeyMigration.convert(context, keys, width, height, density) }.getOrNull() ?: return null + val name = runCatching { JSONObject(raw).optString("name") }.getOrNull().orEmpty().trim().take(80).ifEmpty { fallbackName.trim().take(80) } + val layout = SavedInputLayout(id, name, converted.map { it.first }, style = style, screen = screen, + aspect = (width.coerceAtLeast(1).toFloat() / height.coerceAtLeast(1)).coerceIn(.05f, 20f)) + return layout to SavedInputBindings(newInputId(), name, false, id, elements = converted.associate { it.first.id to it.second }) + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/InputLibraries.kt b/app/src/main/java/com/droiddeck/launcher/input/InputLibraries.kt index 52a41324b..074fd834a 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/InputLibraries.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/InputLibraries.kt @@ -9,7 +9,7 @@ import org.json.JSONObject import java.util.UUID object InputLibraries { - private const val VERSION = 11 + private const val VERSION = 12 private const val KEY = "library" private const val RECOVERY = "library-recovery." const val OVERLAY = "builtin.overlay" @@ -29,25 +29,41 @@ object InputLibraries { if (version < 5) library = scopeLayouts(upgradeStockLayouts(upgradeOverlay(context, library))) if (version < 11) library = neutralStockLayouts(library) if (version < 10) library = restyleGamepads(context, library) + if (version < 12) library = barStockLayouts(library) if (version < 6 || !p.contains(LegacyScreenKeyMigration.BACKUP)) { val legacy = LegacyScreenKeyMigration.snapshot(context) if (legacy != null) { - val upgraded = runCatching { LegacyScreenKeyMigration.migrate(context, library, legacy) }.getOrElse { + val migrated = runCatching { LegacyScreenKeyMigration.migrate(context, library, legacy) }.getOrElse { android.util.Log.e("InputLibraries", "Could not migrate screen keys; keeping the original settings", it) return library } + val merged = mergeScreenKeys(migrated, legacy) + val upgraded = merged ?: migrated val encoded = encode(upgraded) require(encoded.length <= 2_000_000) - if (!p.edit().putString(KEY, encoded).putString(LegacyScreenKeyMigration.BACKUP, legacy).commit()) return library + val edit = p.edit().putString(KEY, encoded).putString(LegacyScreenKeyMigration.BACKUP, legacy) + if (merged != null) edit.putBoolean(LegacyScreenKeyMigration.MERGED, true) + if (!edit.commit()) return library LegacyScreenKeyMigration.clean(context) return upgraded } } + val backup = p.getString(LegacyScreenKeyMigration.BACKUP, null) + if (backup != null && !p.contains(LegacyScreenKeyMigration.MERGED)) { + val merged = mergeScreenKeys(library, backup) + if (merged != null) { + val encoded = encode(merged) + if (encoded.length <= 2_000_000 && p.edit().putString(KEY, encoded).putBoolean(LegacyScreenKeyMigration.MERGED, true).commit()) library = merged + } + } if (raw == null || version < VERSION) write(context, library) if (p.contains(LegacyScreenKeyMigration.BACKUP)) LegacyScreenKeyMigration.clean(context) return library } + private fun mergeScreenKeys(library: InputLibrary, legacy: String): InputLibrary? = runCatching { LegacyScreenKeyMigration.merge(library, legacy) } + .onFailure { android.util.Log.e("InputLibraries", "Could not show the imported screen keys with the gamepad", it) }.getOrNull() + fun hasPendingScreenKeyMigration(context: Context) = !prefs(context).contains(LegacyScreenKeyMigration.BACKUP) && LegacyScreenKeyMigration.snapshot(context) != null fun hasLibraryRecovery(context: Context) = prefs(context).all.keys.any { it.startsWith(RECOVERY) } @@ -115,8 +131,24 @@ object InputLibraries { } }.onSuccess { ControllerPrefs.setAdaptiveSticks(context, on) }.isSuccess + fun setQuickButton(context: Context, layoutId: String, button: InputCommand, shown: Boolean): Boolean = runCatching { + update(context) { library -> library.layouts.firstOrNull { it.id == layoutId && it.editable }?.let { library.withLayout(it.withQuickButton(button, shown)) } ?: library } + }.isSuccess + + fun defaultPositions(context: Context, library: InputLibrary, layout: SavedInputLayout): List? { + val origin = layout.origin ?: layout.id + InputStockLayouts.layouts().firstOrNull { it.id == origin }?.let { return it.elements } + if (origin == OVERLAY || origin.startsWith("migrated.layout.")) { + val (surfaceContext, metrics) = primaryDisplay(context) + val size = if (origin == OVERLAY) listOf(metrics.widthPixels, metrics.heightPixels) + else origin.removePrefix("migrated.layout.").split('x').mapNotNull { it.toIntOrNull() } + return size.takeIf { it.size == 2 && it.all { side -> side > 0 } }?.let { LegacyGamepadLayout(surfaceContext, it[0], it[1]).elements() } + } + return library.layouts.firstOrNull { it.id == origin && it.id != layout.id }?.elements + } + @Suppress("DEPRECATION") - private fun primaryDisplay(context: Context): Pair { + internal fun primaryDisplay(context: Context): Pair { val display = context.getSystemService(DisplayManager::class.java)?.getDisplay(Display.DEFAULT_DISPLAY) val surfaceContext = display?.let(context::createDisplayContext) ?: context return surfaceContext to DisplayMetrics().also { if (display != null) display.getRealMetrics(it) else it.setTo(context.resources.displayMetrics) } @@ -143,7 +175,8 @@ object InputLibraries { val elements = LegacyGamepadLayout(surfaceContext, metrics.widthPixels.coerceAtLeast(1), metrics.heightPixels.coerceAtLeast(1)).elements() val style = InputSurfaceStyle(settings.tint, settings.opacity) val replacements = library.layouts.filter { it.template == InputLayoutTemplate.OVERLAY }.associate { old -> - old.id to old.copy(name = "Gamepad", elements = elements, template = InputLayoutTemplate.CUSTOM, style = style) + old.id to old.copy(name = "Gamepad", elements = elements, template = InputLayoutTemplate.CUSTOM, style = style, + aspect = (metrics.widthPixels.coerceAtLeast(1).toFloat() / metrics.heightPixels.coerceAtLeast(1)).coerceIn(.05f, 20f)) } var result = library.copy(layouts = library.layouts.map { replacements[it.id] ?: it }) val migrated = mutableMapOf, String>() @@ -172,7 +205,7 @@ object InputLibraries { if (size[0] == metrics.widthPixels && size[1] == metrics.heightPixels) continue val id = "migrated.$key" if (result.layouts.none { it.id == id }) result = result.withLayout(SavedInputLayout(id, "Gamepad ${size[0]} × ${size[1]}", - LegacyGamepadLayout(surfaceContext, size[0], size[1]).elements(), style = style)) + LegacyGamepadLayout(surfaceContext, size[0], size[1]).elements(), style = style, aspect = (size[0].toFloat() / size[1]).coerceIn(.05f, 20f))) } return result.validate() } @@ -206,11 +239,30 @@ object InputLibraries { } val oldBindings = migrated.keys.map { it.first }.toSet() result = result.copy(bindings = result.bindings.filter { it.id !in oldBindings || result.usersOfBindings(it.id).isNotEmpty() }) + return addStockLayouts(result) + } + + private fun addStockLayouts(library: InputLibrary): InputLibrary { + val stock = InputStockLayouts.layouts() + var result = library for (layout in stock) if (result.layouts.none { it.id == layout.id }) result = result.withLayout(layout) for (preset in InputStockLayouts.presets(stock)) if (result.bindings.none { it.id == preset.id }) result = result.withPreset(preset) return result.validate() } + private fun barStockLayouts(library: InputLibrary): InputLibrary { + var result = library + for (stock in InputStockLayouts.layouts().filter { it.id == DECK || it.id == TRACKPAD }) { + val saved = result.layouts.firstOrNull { it.id == stock.id } ?: continue + val (commands, rest) = saved.elements.partition { it.id.startsWith("command:") } + val untouched = commands.isNotEmpty() && commands.all { it.control == InputControl.A && it.kind == InputElementKind.BUTTON } && + rest.size == stock.elements.size && rest.all { e -> stock.elements.any { s -> s.id == e.id && s.copy(y = e.y, height = e.height) == e && + kotlin.math.abs(s.y + s.height - e.y - e.height) < 1e-3f } } + if (untouched) result = result.withLayout(saved.copy(elements = stock.elements)) + } + return addStockLayouts(result) + } + private fun migrationBindingId(library: InputLibrary, binding: String, layout: String): String { val seed = JSONArray(listOf(binding, layout)).toString().toByteArray(Charsets.UTF_8) val base = "migrated.bindings." + UUID.nameUUIDFromBytes(seed) @@ -242,7 +294,8 @@ object InputLibraries { .put("width", e.width.toDouble()).put("height", e.height.toDouble()).put("floating", e.floating).put("hideWhenIdle", e.hideWhenIdle).put("mouseGestures", e.mouseGestures).put("toggle", e.toggle).put("icon", e.icon).put("image", e.image) .put("elementOpacity", e.opacity.toDouble()).put("buttonShape", e.buttonShape.name).put("showLabel", e.showLabel)) layouts.put(JSONObject().put("id", layout.id).put("name", layout.name).put("template", layout.template.name).put("elements", elements) - .put("tint", layout.style.tint ?: JSONObject.NULL).put("opacity", layout.style.opacity).put("screen", layout.screen.name)) + .put("tint", layout.style.tint ?: JSONObject.NULL).put("opacity", layout.style.opacity).put("screen", layout.screen.name).put("aspect", layout.aspect.toDouble()).put("scale", layout.scale.toDouble()) + .put("hidden", JSONArray(layout.style.hidden.map { it.name }.sorted())).put("origin", layout.origin ?: JSONObject.NULL)) } val bindings = JSONArray() for (b in library.bindings) { @@ -283,8 +336,10 @@ object InputLibraries { e.optDouble("elementOpacity", 1.0).toFloat(), InputButtonShape.valueOf(e.optString("buttonShape", InputButtonShape.ROUNDED.name)), e.optBoolean("showLabel", true)) } SavedInputLayout(layout.getString("id"), layout.getString("name"), elements, InputLayoutTemplate.valueOf(layout.getString("template")), - InputSurfaceStyle(if (!layout.has("tint")) ControllerPrefs.STEAM_BLUE else if (layout.isNull("tint")) null else layout.getInt("tint"), layout.optInt("opacity", 100)), - InputLayoutScreen.valueOf(layout.optString("screen", InputLayoutScreen.BOTH.name))) + InputSurfaceStyle(if (!layout.has("tint")) ControllerPrefs.STEAM_BLUE else if (layout.isNull("tint")) null else layout.getInt("tint"), layout.optInt("opacity", 100), + layout.optJSONArray("hidden")?.let { hidden -> (0 until hidden.length()).map { InputCommand.valueOf(hidden.getString(it)) }.toSet() }.orEmpty()), + InputLayoutScreen.valueOf(layout.optString("screen", InputLayoutScreen.BOTH.name)), layout.optDouble("aspect", 0.0).toFloat(), layout.optionalString("origin"), + layout.optDouble("scale", 1.0).toFloat()) } val bindings = doc.getJSONArray("bindings").objects(512).map { b -> val controls = b.getJSONObject("controls").actions(28).mapKeys { requireNotNull(InputControl.fromId(it.key)) } diff --git a/app/src/main/java/com/droiddeck/launcher/input/InputLibrary.kt b/app/src/main/java/com/droiddeck/launcher/input/InputLibrary.kt index 03bc353cb..cc6313dc3 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/InputLibrary.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/InputLibrary.kt @@ -9,15 +9,25 @@ enum class InputLayoutScreen { MAIN, SECONDARY, BOTH; if (role == InputRole.PRIMARY) this == MAIN else this == SECONDARY) } -data class InputSurfaceStyle(val tint: Int? = ControllerPrefs.STEAM_BLUE, val opacity: Int = 100) +data class InputSurfaceStyle(val tint: Int? = ControllerPrefs.STEAM_BLUE, val opacity: Int = 100, val hidden: Set = emptySet()) { + fun hides(element: InputSurfaceElement) = element.quickButton?.let { it in hidden } == true + fun shown(elements: List) = if (hidden.isEmpty()) elements else elements.filterNot(::hides) +} data class SavedInputLayout( val id: String, val name: String, val elements: List = emptyList(), val template: InputLayoutTemplate = InputLayoutTemplate.CUSTOM, val style: InputSurfaceStyle = InputSurfaceStyle(), val screen: InputLayoutScreen = InputLayoutScreen.BOTH, + val aspect: Float = 0f, + val origin: String? = null, + val scale: Float = 1f, ) { val editable get() = template == InputLayoutTemplate.CUSTOM + val quickButtons get() = QUICK_BUTTONS.filter { button -> elements.any { it.quickButton == button } } + fun withQuickButton(button: InputCommand, shown: Boolean) = + copy(style = style.copy(hidden = if (shown) style.hidden - button else style.hidden + button)) + fun derived(id: String, name: String) = copy(id = id, name = name, origin = origin ?: this.id) val adaptiveSticks get() = elements.filter { it.kind == InputElementKind.STICK }.takeIf { editable && it.isNotEmpty() }?.all { it.floating && it.hideWhenIdle } fun supports(role: InputRole) = screen.supports(role) && when (template) { InputLayoutTemplate.OVERLAY -> role == InputRole.PRIMARY @@ -132,7 +142,7 @@ data class InputLibrary( if (shared) return withProfile(target.copy(parts = target.parts + (role to source))) val sourceLayout = layout(sourceId, role) val name = (target.name + " / " + role.name.lowercase()).take(80) - val copiedLayout = sourceLayout?.takeIf { it.editable }?.copy(id = newInputId(), name = name) + val copiedLayout = sourceLayout?.takeIf { it.editable }?.derived(newInputId(), name) val copiedBindings = preset(source.bindings).copy(id = newInputId(), name = name, layoutId = copiedLayout?.id ?: preset(source.bindings).layoutId) return (if (copiedLayout == null) this else withLayout(copiedLayout)).withPreset(copiedBindings) @@ -143,7 +153,7 @@ data class InputLibrary( val source = layouts.first { it.id == layoutId } require(source.supports(role)) val target = profile(profileId) - val layout = if (shared || !source.editable) source else source.copy(id = newInputId(), name = (target.name + " / " + source.name).take(80)) + val layout = if (shared || !source.editable) source else source.derived(newInputId(), (target.name + " / " + source.name).take(80)) val previous = preset(target.binding(role)) val actions = if (previous.compatible(source)) previous else bindings.firstOrNull { it.compatible(source) } ?: SavedInputBindings(newInputId(), target.name, false, controls = if (source.editable) emptyMap() else InputProfile.defaults(target.base).bindings[role].orEmpty(), @@ -181,7 +191,7 @@ data class InputLibrary( fun copyLayout(id: String, name: String, newId: String = newInputId()): InputLibrary { val source = layouts.first { it.id == id } require(source.editable) - return withLayout(source.copy(id = newId, name = inputName(name))) + return withLayout(source.derived(newId, inputName(name))) } fun removeProfile(id: String): InputLibrary { @@ -227,7 +237,9 @@ data class InputLibrary( require(selected == InputProfiles.AUTO || profiles.any { it.id == selected }) require(profiles.any { it.id == steam } && profiles.any { it.id == desktop }) for (layout in layouts) { - require(layout.style.opacity in 1..100) + require(layout.style.opacity in 1..100 && layout.style.hidden.all { it in QUICK_BUTTONS }) + require(layout.aspect == 0f || layout.aspect.isFinite() && layout.aspect in .05f..20f) + require(layout.scale.isFinite() && layout.scale in .5f..2f) inputName(layout.name); require(layout.elements.size <= 64); unique(layout.elements.map { it.id }) for (e in layout.elements) { inputName(e.name) diff --git a/app/src/main/java/com/droiddeck/launcher/input/InputRouter.kt b/app/src/main/java/com/droiddeck/launcher/input/InputRouter.kt index 5d6741314..6907af4d4 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/InputRouter.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/InputRouter.kt @@ -234,7 +234,7 @@ class InputRouter(private val output: InputOutput) { when (a) { is InputAction.Keys -> if (down) a.codes.forEach { key(it, true) } else a.codes.asReversed().forEach { key(it, false) } is InputAction.MouseButton -> button(a.code, down) - is InputAction.Command -> if (down) output.command(a.command, role) + is InputAction.Command -> if (!down) output.command(a.command, role) else -> Unit } } diff --git a/app/src/main/java/com/droiddeck/launcher/input/InputStockLayouts.kt b/app/src/main/java/com/droiddeck/launcher/input/InputStockLayouts.kt index abf938864..059fbb1dd 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/InputStockLayouts.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/InputStockLayouts.kt @@ -1,32 +1,61 @@ package com.droiddeck.launcher.input +import androidx.annotation.StringRes +import com.droiddeck.launcher.R + +enum class DeckPanel(val id: String, val layout: String, val bindings: String, @StringRes val label: Int) { + GAMEPAD("gamepad", "builtin.deck.gamepad", "preset.deck.gamepad", R.string.deck_tab_gamepad), + GRIPS("grips", "builtin.deck.grips", "preset.deck.grips", R.string.deck_tab_grips), + LEFT("left", "builtin.deck.left", "preset.deck.left", R.string.deck_tab_left), + RIGHT("right", "builtin.deck.right", "preset.deck.right", R.string.deck_tab_right), + BOTH("both", InputLibraries.DECK, InputStockLayouts.DECK, R.string.deck_tab_both); + + companion object { + fun from(id: String?) = entries.firstOrNull { it.id == id } ?: BOTH + } +} + internal object InputStockLayouts { private val NEUTRAL = InputSurfaceStyle(tint = null) const val DECK = "preset.deck" const val TRACKPAD = "preset.trackpad" - private fun commands(indices: List): List { - val names = listOf("DroidDeck menu", "Keyboard", "Steam", "Quick access") - val icons = listOf(InputButtonIcons.MENU, InputButtonIcons.KEYBOARD, InputButtonIcons.STEAM, InputButtonIcons.QUICK_ACCESS) - val step = .98f / indices.size - return indices.mapIndexed { position, i -> - InputSurfaceElement(InputControl.A, .02f + position * step, .01f, step - .02f, .11f, - "command:$i", name = names[i], icon = icons[i]) - } - } - private fun commandActions(): Map = listOf(InputCommand.MENU, InputCommand.KEYBOARD, InputCommand.STEAM, InputCommand.QUICK_ACCESS) - .mapIndexed { i, command -> "command:$i" to InputAction.Command(command) }.toMap() private fun grips() = listOf(InputControl.L4, InputControl.L5, InputControl.R5, InputControl.R4).mapIndexed { i, c -> InputSurfaceElement(c, .02f + i * .245f, .78f, .225f, .20f) } - private fun pads() = listOf(InputSurfaceElement(InputControl.LEFT_PAD, .02f, .14f, .47f, .62f), - InputSurfaceElement(InputControl.RIGHT_PAD, .51f, .14f, .47f, .62f)) + private fun pads() = listOf(InputSurfaceElement(InputControl.LEFT_PAD, .02f, .02f, .47f, .74f), + InputSurfaceElement(InputControl.RIGHT_PAD, .51f, .02f, .47f, .74f)) + private fun gripGrid() = listOf(InputControl.L4 to (.02f to .02f), InputControl.L5 to (.02f to .51f), + InputControl.R4 to (.51f to .02f), InputControl.R5 to (.51f to .51f)).map { (c, at) -> + InputSurfaceElement(c, at.first, at.second, .47f, .47f) + } + private fun pad(right: Boolean) = listOf( + InputSurfaceElement(if (right) InputControl.RIGHT_PAD else InputControl.LEFT_PAD, .02f, .02f, .96f, .74f), + InputSurfaceElement(if (right) InputControl.RIGHT_CLICK else InputControl.LEFT_CLICK, .02f, .78f, .96f, .20f), + ) + private fun gamepad(): List { + fun round(c: InputControl, x: Float, y: Float, size: Float) = InputSurfaceElement(c, x, y, size, size, buttonShape = InputButtonShape.CIRCLE) + fun pill(c: InputControl, x: Float) = InputSurfaceElement(c, x, .02f, .2f, .12f, buttonShape = InputButtonShape.PILL) + return listOf(pill(InputControl.LT, .02f), pill(InputControl.LB, .24f), pill(InputControl.RB, .56f), pill(InputControl.RT, .78f), + InputSurfaceElement(InputControl.LS, .02f, .17f, .4f, .4f), + InputSurfaceElement(InputControl.RIGHT_PAD, .58f, .59f, .4f, .39f), + round(InputControl.UP, .14f, .6f, .12f), round(InputControl.LEFT, .03f, .73f, .12f), + round(InputControl.RIGHT, .25f, .73f, .12f), round(InputControl.DOWN, .14f, .86f, .12f), + round(InputControl.Y, .72f, .17f, .12f), round(InputControl.X, .61f, .3f, .12f), + round(InputControl.B, .83f, .3f, .12f), round(InputControl.A, .72f, .43f, .12f), + InputSurfaceElement(InputControl.SELECT, .42f, .3f, .07f, .1f, buttonShape = InputButtonShape.PILL), + InputSurfaceElement(InputControl.START, .51f, .3f, .07f, .1f, buttonShape = InputButtonShape.PILL)) + } fun layouts() = listOf( - SavedInputLayout(InputLibraries.DECK, "Deck grips + trackpads", commands(listOf(2, 3, 0)) + pads() + grips(), style = NEUTRAL, screen = InputLayoutScreen.SECONDARY), - SavedInputLayout(InputLibraries.TRACKPAD, "Trackpad + keyboard", commands(listOf(0, 1)) + - InputSurfaceElement(InputControl.RIGHT_PAD, .02f, .14f, .96f, .84f, mouseGestures = true), style = NEUTRAL, screen = InputLayoutScreen.SECONDARY), + SavedInputLayout(InputLibraries.DECK, "Deck grips + trackpads", pads() + grips(), style = NEUTRAL, screen = InputLayoutScreen.SECONDARY), + SavedInputLayout(InputLibraries.TRACKPAD, "Trackpad + keyboard", listOf( + InputSurfaceElement(InputControl.RIGHT_PAD, .02f, .02f, .96f, .96f, mouseGestures = true)), style = NEUTRAL, screen = InputLayoutScreen.SECONDARY), + SavedInputLayout(DeckPanel.GAMEPAD.layout, "Deck gamepad", gamepad(), style = NEUTRAL, screen = InputLayoutScreen.SECONDARY), + SavedInputLayout(DeckPanel.GRIPS.layout, "Deck grips", gripGrid(), style = NEUTRAL, screen = InputLayoutScreen.SECONDARY), + SavedInputLayout(DeckPanel.LEFT.layout, "Deck left trackpad", pad(right = false), style = NEUTRAL, screen = InputLayoutScreen.SECONDARY), + SavedInputLayout(DeckPanel.RIGHT.layout, "Deck right trackpad", pad(right = true), style = NEUTRAL, screen = InputLayoutScreen.SECONDARY), ) - fun actions(layout: SavedInputLayout): Map = (commandActions() + if (layout.id == InputLibraries.TRACKPAD) mapOf( + fun actions(layout: SavedInputLayout): Map = (if (layout.id == InputLibraries.TRACKPAD) mapOf( "legacy:right-pad" to InputAction.Pointer(), "legacy:a" to InputAction.MouseButton(272), "legacy:b" to InputAction.MouseButton(273), ) else emptyMap()).filterKeys { id -> layout.elements.any { it.id == id } } @@ -35,7 +64,8 @@ internal object InputStockLayouts { fun presets(layouts: List) = layouts.map { layout -> val desktop = layout.id == InputLibraries.TRACKPAD - SavedInputBindings(if (desktop) TRACKPAD else DECK, layout.name, false, layout.id, + val id = if (desktop) TRACKPAD else DeckPanel.entries.first { it.layout == layout.id }.bindings + SavedInputBindings(id, layout.name, false, layout.id, controls = InputProfile.defaults(if (desktop) InputProfile.DESKTOP else InputProfile.GAME).bindings[InputRole.SECONDARY].orEmpty(), elements = actions(layout), clicks = clicks(layout)) } diff --git a/app/src/main/java/com/droiddeck/launcher/input/InputSurfaceLayout.kt b/app/src/main/java/com/droiddeck/launcher/input/InputSurfaceLayout.kt index b20acf374..de16d485f 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/InputSurfaceLayout.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/InputSurfaceLayout.kt @@ -38,7 +38,33 @@ data class InputSurfaceElement( val hasClick get() = kind == InputElementKind.STICK || kind == InputElementKind.TRACKPAD fun bounded() = copy(x = x.coerceIn(0f, 0.97f), y = y.coerceIn(0f, 0.97f), - width = width.coerceIn(0.03f, 1f - x.coerceIn(0f, 0.97f)), height = height.coerceIn(0.03f, 1f - y.coerceIn(0f, 0.97f))) + width = width.coerceIn(0.03f, (1f - x.coerceIn(0f, 0.97f)).coerceAtLeast(0.03f)), height = height.coerceIn(0.03f, (1f - y.coerceIn(0f, 0.97f)).coerceAtLeast(0.03f))) +} + +internal val QUICK_BUTTONS = listOf(InputCommand.STEAM, InputCommand.QUICK_ACCESS, InputCommand.KEYBOARD) + +val InputSurfaceElement.quickButton: InputCommand? get() = when { + id == "legacy:${InputControl.GUIDE.id}" -> InputCommand.STEAM + id == "legacy:${InputControl.QAM.id}" -> InputCommand.QUICK_ACCESS + id == "builtin:keyboard" -> InputCommand.KEYBOARD + else -> null +} + +internal fun List.scaled(factor: Float): List = map { e -> + fun anchor(centre: Float) = when { centre < 1f / 3f -> 0f; centre > 2f / 3f -> 1f; else -> .5f } + val w = (e.width * factor).coerceIn(.03f, 1f) + val h = (e.height * factor).coerceIn(.03f, 1f) + val cx = e.x + e.width / 2 + val cy = e.y + e.height / 2 + val ax = anchor(cx) + val ay = anchor(cy) + e.copy(x = (ax + (cx - ax) * factor - w / 2).coerceIn(0f, 1f - w), y = (ay + (cy - ay) * factor - h / 2).coerceIn(0f, 1f - h), + width = w, height = h).bounded() +} + +internal fun List.withPositionsOf(defaults: List): List { + val byId = defaults.associateBy { it.id } + return map { e -> byId[e.id]?.let { d -> e.copy(x = d.x, y = d.y, width = d.width, height = d.height).bounded() } ?: e } } object InputSurfaceLayout { diff --git a/app/src/main/java/com/droiddeck/launcher/input/InputSurfaceSource.kt b/app/src/main/java/com/droiddeck/launcher/input/InputSurfaceSource.kt index 80c869a64..555f99b3e 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/InputSurfaceSource.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/InputSurfaceSource.kt @@ -71,6 +71,12 @@ class InputSurfaceSource(private val pad: PadBridge, private val role: InputRole InputTiming.end(timing) } + fun cancel() { + if (closed) return + route?.takeIf { it.action is InputAction.Command }?.release() + update(0f) + } + fun click(down: Boolean) { if (closed) return val timing = InputTiming.begin() diff --git a/app/src/main/java/com/droiddeck/launcher/input/LegacyGamepadLayout.kt b/app/src/main/java/com/droiddeck/launcher/input/LegacyGamepadLayout.kt index 11092cfbe..f2857900a 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/LegacyGamepadLayout.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/LegacyGamepadLayout.kt @@ -4,10 +4,10 @@ import android.content.Context import android.graphics.Rect import kotlin.math.* -internal class LegacyGamepadLayout(private val context: Context, private val width: Int, private val height: Int) { +internal class LegacyGamepadLayout(private val context: Context, private val width: Int, private val height: Int, private val safe: Rect = Rect(), + private val scale: Float = 1f) { private val resources get() = context.resources private val settings = ControllerPrefs.read(context) - private val safe = Rect() private var fit = 1f private class Control(val id: String, val group: String, val stick: Int, val radiusDp: Float, val offsetXDp: Float, val offsetYDp: Float) { var radius = 0f @@ -41,7 +41,7 @@ internal class LegacyGamepadLayout(private val context: Context, private val wid private fun dp(value: Float) = value * resources.displayMetrics.density - private fun scaled(value: Float) = dp(value) * settings.size / 100f * fit + private fun scaled(value: Float) = dp(value) * settings.size / 100f * scale * fit private fun fitScale(): Float { val metrics = resources.displayMetrics @@ -195,6 +195,76 @@ internal class LegacyGamepadLayout(private val context: Context, private val wid return result } + fun regenerate(stored: List): List = withGeometry(stored, elements()) + + fun rescale(stored: List, from: LegacyGamepadLayout): List { + val before = from.elements().associateBy { it.id } + val after = elements().associateBy { it.id } + return stored.map { e -> + val b = before[e.id] ?: return@map e + val a = after[e.id] ?: return@map e + val w = (a.width * e.width / b.width).coerceIn(.03f, 1f) + val h = (a.height * e.height / b.height).coerceIn(.03f, 1f) + val cx = a.x + a.width / 2 + (e.x + e.width / 2 - b.x - b.width / 2) + val cy = a.y + a.height / 2 + (e.y + e.height / 2 - b.y - b.height / 2) + e.copy(x = (cx - w / 2).coerceIn(0f, 1f - w), y = (cy - h / 2).coerceIn(0f, 1f - h), width = w, height = h).bounded() + } + } + + fun buttonsOnly(stored: List): List { + val inset = dp(BUTTONS_ONLY_INSET_DP) + val bottom = height - safe.bottom - inset + val radius = dp(BUTTONS_ONLY_RADIUS_DP) + fun pick(control: InputControl, shown: Boolean) = stored.firstOrNull { it.id == "legacy:${control.id}" } + ?: stored.firstOrNull { it.control == control && it.kind == InputElementKind.BUTTON } + ?: InputSurfaceElement(control, 0f, 0f, 0f, 0f, buttonShape = InputButtonShape.CIRCLE).takeIf { shown } + return listOfNotNull( + pick(InputControl.GUIDE, settings.steamButton)?.let { circle(it, safe.left + inset, bottom, radius) }, + pick(InputControl.QAM, settings.qamButton)?.let { circle(it, width - safe.right - inset, bottom, radius) }, + stored.firstOrNull { it.id == KEYBOARD_ID }?.let { circle(it, width / 2f, bottom, dp(KEYBOARD_RADIUS_DP)) }, + ) + } + + fun dock(stored: List, portrait: Boolean, grips: Boolean): List { + val boxes = GamepadGeometry.layout(width.toFloat(), height.toFloat(), resources.displayMetrics.density, portrait) + val placed = mutableListOf() + fun box(control: InputControl, slot: GamepadGeometry.Slot, shape: InputButtonShape) { + val b = boxes[slot] ?: return + placed += InputSurfaceElement(control, b.left / width, b.top / height, b.width / width, b.height / height, + floating = false, buttonShape = shape).bounded() + } + fun cross(controls: List, slot: GamepadGeometry.Slot) { + val b = boxes[slot] ?: return + val side = min(b.width, b.height) + val spread = side * CROSS_SPREAD + val radius = side * CROSS_RADIUS + controls.zip(listOf(0f to -1f, 1f to 0f, 0f to 1f, -1f to 0f)).forEach { (control, direction) -> + placed += circle(InputSurfaceElement(control, 0f, 0f, 0f, 0f, buttonShape = InputButtonShape.CIRCLE), + b.cx + direction.first * spread, b.cy + direction.second * spread, radius) + } + } + box(InputControl.LS, GamepadGeometry.Slot.LEFT_MAIN, InputButtonShape.CIRCLE) + cross(DPAD_ORDER, GamepadGeometry.Slot.LEFT_SECOND) + box(InputControl.RS, GamepadGeometry.Slot.RIGHT_MAIN, InputButtonShape.CIRCLE) + cross(FACE_ORDER, GamepadGeometry.Slot.RIGHT_SECOND) + box(InputControl.LT, GamepadGeometry.Slot.LEFT_OUTER_SHOULDER, InputButtonShape.PILL) + box(InputControl.LB, GamepadGeometry.Slot.LEFT_INNER_SHOULDER, InputButtonShape.PILL) + box(InputControl.RB, GamepadGeometry.Slot.RIGHT_INNER_SHOULDER, InputButtonShape.PILL) + box(InputControl.RT, GamepadGeometry.Slot.RIGHT_OUTER_SHOULDER, InputButtonShape.PILL) + box(InputControl.SELECT, GamepadGeometry.Slot.VIEW, InputButtonShape.PILL) + box(InputControl.START, GamepadGeometry.Slot.MENU, InputButtonShape.PILL) + if (grips) GRIPS.forEach { (control, slot) -> box(control, slot, InputButtonShape.ROUNDED) } + return withGeometry(stored, placed) + placed.filter { p -> GRIPS.any { it.first == p.control } && stored.none { it.id == p.id } } + } + + private fun circle(e: InputSurfaceElement, cx: Float, cy: Float, radius: Float) = + e.copy(x = (cx - radius) / width, y = (cy - radius) / height, width = 2 * radius / width, height = 2 * radius / height).bounded() + + private fun withGeometry(stored: List, placed: List): List { + val byId = placed.associateBy { it.id } + return stored.mapNotNull { e -> byId[e.id]?.let { e.copy(x = it.x, y = it.y, width = it.width, height = it.height) } } + } + private fun keyboard(): InputSurfaceElement { val radius = dp(KEYBOARD_RADIUS_DP) val cx = safe.left + dp(KEYBOARD_X_DP) @@ -268,5 +338,13 @@ internal class LegacyGamepadLayout(private val context: Context, private val wid const val KEYBOARD_RADIUS_DP = 18f const val KEYBOARD_X_DP = 90f const val KEYBOARD_Y_DP = 34f + const val BUTTONS_ONLY_RADIUS_DP = 30f + const val BUTTONS_ONLY_INSET_DP = 44f + const val CROSS_SPREAD = .30f + const val CROSS_RADIUS = .16f + val DPAD_ORDER = listOf(InputControl.UP, InputControl.RIGHT, InputControl.DOWN, InputControl.LEFT) + val FACE_ORDER = listOf(InputControl.Y, InputControl.B, InputControl.A, InputControl.X) + val GRIPS = listOf(InputControl.L4 to GamepadGeometry.Slot.L4, InputControl.L5 to GamepadGeometry.Slot.L5, + InputControl.R5 to GamepadGeometry.Slot.R5, InputControl.R4 to GamepadGeometry.Slot.R4) } } diff --git a/app/src/main/java/com/droiddeck/launcher/input/LegacyScreenKeyMigration.kt b/app/src/main/java/com/droiddeck/launcher/input/LegacyScreenKeyMigration.kt index fed7b35fe..58329fc88 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/LegacyScreenKeyMigration.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/LegacyScreenKeyMigration.kt @@ -8,6 +8,8 @@ import org.json.JSONObject internal object LegacyScreenKeyMigration { const val BACKUP = "legacy-screen-keys-backup" + const val MERGED = "legacy-screen-keys-merged" + private const val SHOWN = "migrated.screenkeys.0" fun snapshot(context: Context): String? { val values = ControllerPrefs.prefs(context).all.filterKeys { it == "screenKeys" || it == "screenKeysShown" || it.startsWith("screenLayout.") } @@ -38,7 +40,8 @@ internal object LegacyScreenKeyMigration { if (result.profiles.any { it.id == id }) continue val converted = convert(context, set.second, metrics.widthPixels, metrics.heightPixels, metrics.density) if (converted.isEmpty()) continue - val layout = SavedInputLayout("$id.layout", set.first, converted.map { it.first }, style = style, screen = InputLayoutScreen.MAIN) + val layout = SavedInputLayout("$id.layout", set.first, converted.map { it.first }, style = style, screen = InputLayoutScreen.MAIN, + aspect = (metrics.widthPixels.coerceAtLeast(1).toFloat() / metrics.heightPixels.coerceAtLeast(1)).coerceIn(.05f, 20f)) val bindings = SavedInputBindings("$id.bindings", set.first, false, layout.id, elements = converted.associate { it.first.id to it.second }) val base = library.profile(library.steam) val profile = SavedInputProfile(id, set.first, base.base, base.parts + (InputRole.PRIMARY to InputPart(bindings.id, layout.id))) @@ -47,30 +50,58 @@ internal object LegacyScreenKeyMigration { return result.validate() } + fun merge(library: InputLibrary, raw: String): InputLibrary { + val old = JSONObject(raw) + if (!old.optBoolean("screenKeysShown", true) || old.optString("screenKeys").isBlank()) return library + val keys = library.layouts.firstOrNull { it.id == "$SHOWN.layout" } ?: return library + val actions = library.bindings.firstOrNull { it.id == "$SHOWN.bindings" }?.elements.orEmpty() + var result = library + for (part in listOf(false, true).map { library.activePart(it, InputRole.PRIMARY) }.distinct()) { + val layout = result.layouts.firstOrNull { it.id == part.layout } ?: continue + val preset = result.preset(part.bindings) + if (!layout.editable || preset.layoutId != layout.id || layout.elements.any { e -> keys.elements.any { it.id == e.id } } || + layout.elements.size + keys.elements.size > 64) continue + val added = keys.elements.mapNotNull { e -> actions[e.id]?.let { e.id to it } }.toMap() + result = result.withLayout(layout.copy(elements = layout.elements + keys.elements)) + .withPreset(preset.copy(elements = added + preset.elements)) + } + return result.validate() + } + internal fun convert(context: Context, raw: String, width: Int, height: Int, density: Float): List> = - raw.split(';').filter { it.isNotBlank() }.mapIndexed { index, entry -> - val parts = entry.split(':') - require(parts.size >= 2) { "Invalid legacy screen key" } - val codes = parts[0].split('+').map { it.toInt() } + convert(context, parse(raw), width, height, density) + + internal fun parse(raw: String): List = raw.split(';').filter { it.isNotBlank() }.map { entry -> + val parts = entry.split(':') + require(parts.size >= 2) { "Invalid legacy screen key" } + val codes = parts[0].split('+').map { it.toInt() } + val position = parts[1].split(',').map { it.toFloat() } + require(position.size == 3) + fun text(at: Int) = parts.getOrNull(at).orEmpty().replace("%2C", ",").replace("%3A", ":").replace("%3B", ";").replace("%25", "%") + LegacyScreenKey(codes, position[0], position[1], position[2], text(2), text(3), text(4), + parts.getOrNull(5)?.toFloatOrNull() ?: 1f, parts.getOrNull(6)?.toIntOrNull() ?: 0, parts.getOrNull(7) == "1", parts.getOrNull(8) != "0") + } + + internal fun convert(context: Context, keys: List, width: Int, height: Int, density: Float): List> = + keys.mapIndexed { index, key -> + val codes = key.codes require(codes.all { it in 0..255 }) - val position = parts[1].split(',').map { it.toFloat() } - require(position.size == 3 && position.all { it.isFinite() }) - fun text(at: Int) = parts.getOrNull(at).orEmpty().replace("%2C", ",").replace("%3A", ":").replace("%3B", ";").replace("%25", "%") - val shape = parts.getOrNull(6)?.toIntOrNull() ?: 0 + require(listOf(key.x, key.y, key.size).all { it.isFinite() }) + val shape = key.shape val kind = when (shape) { 5 -> InputElementKind.DPAD; 6 -> InputElementKind.STICK; 4 -> InputElementKind.TRIGGER; else -> InputElementKind.BUTTON } - val label = text(3).ifEmpty { text(2) }.ifEmpty { codes.filter { it != 0 }.joinToString("+") { pcKeyLabel(context, it) } }.ifBlank { "Button" }.take(80) - val image = text(4) - val icon = codes.singleOrNull()?.takeIf { InputButtonIcons.has(it) && text(3).isEmpty() && text(2).isEmpty() } ?: 0 - val heightPx = position[2].coerceIn(24f, 140f) * density + val label = key.text.ifEmpty { key.glyph }.ifEmpty { codes.filter { it != 0 }.joinToString("+") { pcKeyLabel(context, it) } }.ifBlank { "Button" }.take(80) + val image = key.image + val icon = codes.singleOrNull()?.takeIf { InputButtonIcons.has(it) && key.text.isEmpty() && key.glyph.isEmpty() } ?: 0 + val heightPx = key.size.coerceIn(24f, 140f) * density val ratio = when { shape == 2 -> 1f; shape == 4 -> 1.7f; shape == 5 || shape == 6 || image.isNotEmpty() || icon != 0 -> 1.15f; else -> (.55f * label.length + 1.05f).coerceIn(1.7f, 4.6f) } val w = (heightPx * ratio / width.coerceAtLeast(1)).coerceIn(.03f, 1f) val h = (heightPx / height.coerceAtLeast(1)).coerceIn(.03f, 1f) - val element = InputSurfaceElement(kind.control, (position[0] - w / 2).coerceIn(0f, 1f - w), (position[1] - h / 2).coerceIn(0f, 1f - h), w, h, + val element = InputSurfaceElement(kind.control, (key.x - w / 2).coerceIn(0f, 1f - w), (key.y - h / 2).coerceIn(0f, 1f - h), w, h, id = "imported:$index", kind = kind, name = label, floating = false, - toggle = parts.getOrNull(7) == "1" && kind != InputElementKind.DPAD && kind != InputElementKind.STICK, - icon = icon, image = image, opacity = (parts.getOrNull(5)?.toFloatOrNull() ?: 1f).coerceIn(.2f, 1f), + toggle = key.toggle && kind != InputElementKind.DPAD && kind != InputElementKind.STICK, + icon = icon, image = image, opacity = key.opacity.coerceIn(.2f, 1f), buttonShape = when (shape) { 0 -> InputButtonShape.PILL; 2 -> InputButtonShape.CIRCLE; 3 -> InputButtonShape.RECTANGLE; else -> InputButtonShape.ROUNDED }, - showLabel = parts.getOrNull(8) != "0").bounded() + showLabel = key.labels).bounded() val action = when (kind) { InputElementKind.DPAD -> InputAction.Directions(codes.getOrElse(0) { 0 }, codes.getOrElse(1) { 0 }, codes.getOrElse(2) { 0 }, codes.getOrElse(3) { 0 }) InputElementKind.STICK -> InputAction.Directions(codes.getOrElse(0) { 0 }, codes.getOrElse(2) { 0 }, codes.getOrElse(4) { 0 }, codes.getOrElse(6) { 0 }, listOf(1, 3, 5, 7).map { codes.getOrElse(it) { 0 } }) @@ -80,3 +111,9 @@ internal object LegacyScreenKeyMigration { element to action } } + +internal data class LegacyScreenKey( + val codes: List, val x: Float, val y: Float, val size: Float, + val glyph: String = "", val text: String = "", val image: String = "", + val opacity: Float = 1f, val shape: Int = 0, val toggle: Boolean = false, val labels: Boolean = true, +) diff --git a/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt b/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt index de05e0b02..e28533712 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/OnScreenControls.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.content.Context import android.graphics.Canvas import android.graphics.Paint +import android.graphics.Rect import android.graphics.Typeface import android.os.Build import android.view.Gravity @@ -14,9 +15,15 @@ import android.view.WindowInsets import android.widget.FrameLayout import com.droiddeck.launcher.ui.InputSurfaceView -class OnScreenControls(context: Context, pad: PadBridge?) : FrameLayout(context) { - private val surface = InputSurfaceView(context, pad, inputRole = InputRole.PRIMARY) +class OnScreenControls(context: Context, pad: PadBridge?, val dock: OnScreenLayout.Dock? = null) : FrameLayout(context) { + private val surface = InputSurfaceView(context, pad, inputRole = InputRole.PRIMARY, haptics = dock != null) private var layout: SavedInputLayout? = null + private val safe = Rect() + private var viewWidth = 0 + private var viewHeight = 0 + private val placements = object : LinkedHashMap>(4, .75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry>) = size > PLACEMENT_CACHE + } private val quickToggle = QuickToggle(context) var isButtonsOnly = false private set @@ -54,25 +61,41 @@ class OnScreenControls(context: Context, pad: PadBridge?) : FrameLayout(context) render() } + private data class Placement(val layout: SavedInputLayout?, val width: Int, val height: Int, val safe: Rect, val buttonsOnly: Boolean) + + private fun placed(): List { + val key = Placement(layout, viewWidth, viewHeight, Rect(safe), isButtonsOnly) + return placements.getOrPut(key) { + OnScreenLayout.elements(context, layout, viewWidth, viewHeight, safe, isButtonsOnly, dock) + } + } + private fun render() { - val elements = layout?.elements.orEmpty() - surface.updateCustomLayout(when { - isButtonsOnly -> elements.filter { it.control == InputControl.GUIDE || it.control == InputControl.QAM || it.id == "builtin:keyboard" } - isQuickHidden -> elements.filter { it.id == "builtin:keyboard" } - else -> elements - }) + val elements = placed() + surface.updateCustomLayout(if (isQuickHidden) elements.filter { it.id == "builtin:keyboard" } else elements) val style = layout?.style ?: InputSurfaceStyle() surface.updateStyle(style) quickToggle.tint = style.tint quickToggle.alpha = style.opacity / 100f - quickToggle.visibility = if (isButtonsOnly || layout == null) GONE else VISIBLE + quickToggle.visibility = if (isButtonsOnly || layout == null || dock != null) GONE else VISIBLE quickToggle.invalidate() } fun takesTouchAt(x: Float, y: Float) = surface.takesTouchAt(x, y) || quickToggle.visibility == VISIBLE && x >= quickToggle.left && x < quickToggle.right && y >= quickToggle.top && y < quickToggle.bottom fun releaseAll() = surface.releaseInputs() - fun reload() { releaseAll(); render() } + fun reload() { releaseAll(); placements.clear(); render() } + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val w = MeasureSpec.getSize(widthMeasureSpec) + val h = MeasureSpec.getSize(heightMeasureSpec) + if (w != viewWidth || h != viewHeight) { + viewWidth = w + viewHeight = h + render() + } + super.onMeasure(widthMeasureSpec, heightMeasureSpec) + } override fun onDetachedFromWindow() { releaseAll(); super.onDetachedFromWindow() } @@ -87,6 +110,11 @@ class OnScreenControls(context: Context, pad: PadBridge?) : FrameLayout(context) params.bottomMargin = bottom quickToggle.layoutParams = params } + val fresh = Rect(cutout?.safeInsetLeft ?: 0, if (dock == null) cutout?.safeInsetTop ?: 0 else 0, cutout?.safeInsetRight ?: 0, cutout?.safeInsetBottom ?: 0) + if (fresh != safe) { + safe.set(fresh) + render() + } return super.onApplyWindowInsets(insets) } @@ -136,5 +164,6 @@ class OnScreenControls(context: Context, pad: PadBridge?) : FrameLayout(context) const val QUICK_CENTER_DP = 34f const val QUICK_HIT_DP = 26f const val QUICK_RADIUS_DP = 18f + const val PLACEMENT_CACHE = 4 } } diff --git a/app/src/main/java/com/droiddeck/launcher/input/OnScreenLayout.kt b/app/src/main/java/com/droiddeck/launcher/input/OnScreenLayout.kt new file mode 100644 index 000000000..b7c1bafea --- /dev/null +++ b/app/src/main/java/com/droiddeck/launcher/input/OnScreenLayout.kt @@ -0,0 +1,84 @@ +package com.droiddeck.launcher.input + +import android.content.Context +import android.graphics.Rect +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +object OnScreenLayout { + data class Dock(val portrait: Boolean, val grips: Boolean) + + private const val TOLERANCE = .01f + private const val KEYBOARD_ID = "builtin:keyboard" + + fun elements(context: Context, layout: SavedInputLayout?, width: Int, height: Int, safe: Rect, + buttonsOnly: Boolean = false, dock: Dock? = null): List { + val stored = layout?.elements ?: return emptyList() + if (width <= 0 || height <= 0) return stored + val generator = LegacyGamepadLayout(context, width, height, safe, layout.scale) + if (buttonsOnly && dock == null) return generator.buttonsOnly(stored) + val (reference, generated) = reference(context, layout, width, height) + val gamepad = if (generated) stored.filter(::isGamepad) else emptyList() + val others = fit(stored.filterNot { generated && isGamepad(it) }, reference.first, reference.second, width, height, safe) + val placed = when { + gamepad.isEmpty() -> emptyList() + dock != null -> generator.dock(gamepad, dock.portrait, dock.grips) + else -> generator.regenerate(gamepad) + } + val byId = (placed + others).associateBy { it.id } + return stored.mapNotNull { byId[it.id] } + placed.filter { p -> stored.none { it.id == p.id } } + } + + fun fit(elements: List, referenceWidth: Float, referenceHeight: Float, width: Int, height: Int, safe: Rect): List { + val w = width.toFloat() + val h = height.toFloat() + val scale = min(w / referenceWidth, h / referenceHeight) + return elements.map { e -> + val ew = e.width * referenceWidth * scale + val eh = e.height * referenceHeight * scale + val left = anchor(e.x, e.width, referenceWidth, w, scale).coerceIn(safe.left.toFloat(), max(safe.left.toFloat(), w - safe.right - ew)) + val top = anchor(e.y, e.height, referenceHeight, h, scale).coerceIn(safe.top.toFloat(), max(safe.top.toFloat(), h - safe.bottom - eh)) + e.copy(x = left / w, y = top / h, width = ew / w, height = eh / h).bounded() + } + } + + private fun anchor(start: Float, size: Float, reference: Float, actual: Float, scale: Float): Float { + val centre = start + size / 2f + val scaled = size * reference * scale + return when { + centre < 1f / 3f -> start * reference * scale + centre > 2f / 3f -> actual - (1f - start - size) * reference * scale - scaled + else -> centre * actual - scaled / 2f + } + } + + private fun isGamepad(e: InputSurfaceElement) = e.id.startsWith("legacy:") || e.id == KEYBOARD_ID + + fun generatedSize(context: Context, layout: SavedInputLayout, width: Int, height: Int): Pair? = + reference(context, layout, width, height).takeIf { it.second }?.first?.let { it.first.toInt() to it.second.toInt() } + + private fun reference(context: Context, layout: SavedInputLayout, width: Int, height: Int): Pair, Boolean> { + val (display, metrics) = InputLibraries.primaryDisplay(context) + val long = max(metrics.widthPixels, metrics.heightPixels).coerceAtLeast(1) + val short = min(metrics.widthPixels, metrics.heightPixels).coerceAtLeast(1) + val gamepad = layout.elements.filter(::isGamepad) + if (gamepad.any { it.id.startsWith("legacy:") }) { + val named = layout.id.removePrefix("migrated.layout.").takeIf { it != layout.id }?.split('x')?.mapNotNull { it.toIntOrNull() } + ?.takeIf { it.size == 2 && it.all { size -> size > 0 } }?.let { it[0] to it[1] } + for ((w, h) in listOfNotNull(named, width to height, long to short, short to long).distinct()) { + if (matches(gamepad, LegacyGamepadLayout(display, w, h, scale = layout.scale).elements())) return (w.toFloat() to h.toFloat()) to true + } + } + val aspect = layout.aspect.takeIf { it > 0f } ?: (long.toFloat() / short) + return (aspect to 1f) to false + } + + private fun matches(stored: List, generated: List): Boolean { + val byId = generated.associateBy { it.id } + return stored.all { e -> + val g = byId[e.id] ?: return@all false + abs(e.x - g.x) <= TOLERANCE && abs(e.y - g.y) <= TOLERANCE && abs(e.width - g.width) <= TOLERANCE && abs(e.height - g.height) <= TOLERANCE + } + } +} diff --git a/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt b/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt index 90ed7efdb..316ac0824 100644 --- a/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt +++ b/app/src/main/java/com/droiddeck/launcher/input/SecondScreenMode.kt @@ -1,9 +1,11 @@ package com.droiddeck.launcher.input +import android.content.Context import android.hardware.display.DisplayManager import android.view.Display import androidx.annotation.StringRes import com.droiddeck.launcher.R +import com.droiddeck.launcher.session.SessionPrefs enum class SecondScreenMode(val id: String, @StringRes val label: Int, val bindings: String? = null) { NONE("none", R.string.second_mode_none), @@ -16,6 +18,8 @@ enum class SecondScreenMode(val id: String, @StringRes val label: Int, val bindi val controls: Boolean get() = this == CUSTOM_CONTROLS || bindings != null + fun panel(context: Context): String? = if (this == DECK_CONTROLS) SessionPrefs.deckPanel(context).bindings else bindings + companion object { fun opening(remembered: String?, deckPad: Boolean, dashboard: Boolean = false, bottomDesktop: Boolean = false, desktopSession: Boolean = false): SecondScreenMode { entries.firstOrNull { diff --git a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt index 03c64b50a..6c10b1833 100644 --- a/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt +++ b/app/src/main/java/com/droiddeck/launcher/session/SessionPrefs.kt @@ -1558,6 +1558,13 @@ object SessionPrefs { prefs(context).edit().putString(if (right) "gamepadRight" else "gamepadLeft", surface).apply() } + fun deckPanel(context: Context): com.droiddeck.launcher.input.DeckPanel = + com.droiddeck.launcher.input.DeckPanel.from(prefs(context).getString("deckPanel", null)) + + fun setDeckPanel(context: Context, panel: com.droiddeck.launcher.input.DeckPanel) { + prefs(context).edit().putString("deckPanel", panel.id).apply() + } + fun suspendPolicy(context: Context, mode: String): String = prefs(context).getString("suspendPolicy.${prefMode(mode)}", if (mode == SessionService.MODE_STEAM) SUSPEND_NATIVE else SUSPEND_MANUAL) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/BottomScreenBar.kt b/app/src/main/java/com/droiddeck/launcher/ui/BottomScreenBar.kt index 4930b6db8..e4715b6a1 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/BottomScreenBar.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/BottomScreenBar.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -47,6 +48,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.droiddeck.launcher.R +import com.droiddeck.launcher.input.DeckPanel import com.droiddeck.launcher.session.SessionService import com.droiddeck.launcher.session.SessionState @@ -59,6 +61,8 @@ internal class BarExtra( val onClick: () -> Unit, ) +internal class BarTabs(val labels: List, val selected: Int, val onSelect: (Int) -> Unit) + internal class BottomBarModel( val title: String?, val onClose: (() -> Unit)?, @@ -67,10 +71,22 @@ internal class BottomBarModel( val onQam: (() -> Unit)?, val extras: List = emptyList(), val menuActive: Boolean = false, + val tabs: BarTabs? = null, ) @Composable internal fun BottomScreenBar(model: BottomBarModel, modifier: Modifier = Modifier) { + val tabs = model.tabs + if (tabs == null) BarRow(model, modifier) else Column(modifier.fillMaxWidth().blockTouches()) { + BarRow(model) + Box(Modifier.fillMaxWidth().padding(start = BAR_PAD, end = BAR_PAD, bottom = 8.dp), contentAlignment = Alignment.Center) { + TabStrip(tabs.labels, tabs.selected, tabs.onSelect, Modifier.testTag("bottom-bar-tabs"), bumpers = false) + } + } +} + +@Composable +private fun BarRow(model: BottomBarModel, modifier: Modifier = Modifier) { val colors = MaterialTheme.colorScheme BoxWithConstraints(modifier.fillMaxWidth().height(BAR_HEIGHT).blockTouches()) { val buttons = listOfNotNull(model.onClose, model.onMenu, model.onQam).size + model.extras.size @@ -195,11 +211,15 @@ internal fun sessionBarModel( onSteam: () -> Unit, onQam: () -> Unit, extras: List = emptyList(), + tabs: BarTabs? = null, ): BottomBarModel { val steam = SessionState.mode == SessionService.MODE_STEAM - return BottomBarModel(title, onClose, onMenu, onSteam.takeIf { steam }, onQam.takeIf { steam }, extras) + return BottomBarModel(title, onClose, onMenu, onSteam.takeIf { steam }, onQam.takeIf { steam }, extras, tabs = tabs) } +internal fun deckPanelTabs(context: Context, selected: DeckPanel, onSelect: (DeckPanel) -> Unit) = + BarTabs(DeckPanel.entries.map { context.getString(it.label) }, selected.ordinal) { onSelect(DeckPanel.entries[it]) } + internal fun bottomScreenBarView(context: Context, model: () -> BottomBarModel): ComposeView = ComposeView(context).apply { setContent { DroidDeckTheme { BottomScreenBar(model()) } } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/InputElementProperties.kt b/app/src/main/java/com/droiddeck/launcher/ui/InputElementProperties.kt index 6236ac7af..33f5a68f8 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/InputElementProperties.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/InputElementProperties.kt @@ -6,20 +6,38 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.foundation.Canvas import androidx.compose.ui.graphics.nativeCanvas import com.droiddeck.launcher.input.* import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import com.droiddeck.launcher.R +import kotlin.math.roundToInt +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @Composable internal fun InputElementProperties(element: InputSurfaceElement, onChange: (InputSurfaceElement) -> Unit) { + val current by rememberUpdatedState(element) + val change by rememberUpdatedState(onChange) OutlinedTextField(element.name, { onChange(element.copy(name = it.take(80))) }, label = { Text(stringResource(R.string.input_name)) }, singleLine = true, modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp)) if (element.kind == InputElementKind.BUTTON || element.kind == InputElementKind.TRIGGER) { val host = rememberMenuHost() val context = LocalContext.current + val scope = rememberCoroutineScope() + val pickImage = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri -> + if (uri != null) scope.launch { + withContext(Dispatchers.IO) { InputButtonImages.import(context.applicationContext, uri) }?.let { change(current.copy(icon = 0, image = it)) } + } + } ToggleRow(host, "element-toggle", stringResource(R.string.input_toggle_button), stringResource(R.string.input_toggle_button_hint), element.toggle) { onChange(element.copy(toggle = it)) } ChoiceRow(host, "element-shape", stringResource(R.string.input_button_shape), null, InputButtonShape.entries.map { it to stringResource(when (it) { InputButtonShape.ROUNDED -> R.string.input_shape_rounded; InputButtonShape.PILL -> R.string.input_shape_pill; InputButtonShape.CIRCLE -> R.string.input_shape_circle; InputButtonShape.RECTANGLE -> R.string.input_shape_rectangle }) }, @@ -30,6 +48,7 @@ internal fun InputElementProperties(element: InputSurfaceElement, onChange: (Inp ValueChip(if (element.image.isNotEmpty()) stringResource(R.string.input_imported_image) else if (element.icon == 0) stringResource(R.string.input_button_text) else InputButtonIcons.label(context, element.icon), open) { host.open = if (open) null else "element-icon" } AnchoredMenu(open, onDismiss = { host.open = null }, title = stringResource(R.string.input_button_symbol)) { firstItemFocus -> MenuItem(stringResource(R.string.input_button_text), element.icon == 0 && element.image.isEmpty(), focusRequester = firstItemFocus) { onChange(element.copy(icon = 0, image = "")); host.open = null } + MenuItem(stringResource(R.string.screen_key_image), element.image.isNotEmpty()) { host.open = null; pickImage.launch("image/*") } for (code in InputButtonIcons.codes) MenuItem(InputButtonIcons.label(context, code), element.icon == code && element.image.isEmpty(), leading = { val color = MaterialTheme.colorScheme.onSurface val symbol = remember(context, code) { InputButtonIcons.drawable(context, code) } @@ -51,4 +70,10 @@ internal fun InputElementProperties(element: InputSurfaceElement, onChange: (Inp ToggleRow(host, "element-floating", stringResource(R.string.input_floating_stick), null, element.floating) { onChange(element.copy(floating = it)) } ToggleRow(host, "element-hide-idle", stringResource(R.string.input_hide_idle), null, element.hideWhenIdle) { onChange(element.copy(hideWhenIdle = it)) } } + val host = rememberMenuHost() + if ((element.kind == InputElementKind.BUTTON || element.kind == InputElementKind.TRIGGER) && element.icon == 0 && element.image.isEmpty() || + element.kind == InputElementKind.STICK && element.id.startsWith("legacy:")) + ToggleRow(host, "element-label", stringResource(R.string.input_show_label), null, element.showLabel) { onChange(element.copy(showLabel = it)) } + SliderRow(stringResource(R.string.ctrl_opacity), null, (element.opacity * 100).roundToInt().coerceIn(20, 100), 20..100, step = 5, + format = { "$it%" }, modifier = Modifier.testTag("setting-element-opacity")) { onChange(element.copy(opacity = it / 100f)) } } diff --git a/app/src/main/java/com/droiddeck/launcher/ui/InputSettingsPage.kt b/app/src/main/java/com/droiddeck/launcher/ui/InputSettingsPage.kt index 30f602fab..66d81ffa3 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/InputSettingsPage.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/InputSettingsPage.kt @@ -1,6 +1,12 @@ package com.droiddeck.launcher.ui +import android.content.Context +import android.net.Uri +import android.provider.OpenableColumns +import android.util.DisplayMetrics import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.focusGroup import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.ui.focus.focusRequester @@ -21,6 +27,9 @@ import androidx.compose.ui.window.DialogProperties import com.droiddeck.launcher.R import com.droiddeck.launcher.input.* import com.droiddeck.launcher.session.SessionPrefs +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext @Composable fun InputSettingsDialog(onBack: () -> Unit, onChanged: () -> Unit) { @@ -88,7 +97,9 @@ private fun InputSettingsContent(onBack: () -> Unit, onChanged: () -> Unit, embe LaunchedEffect(surface) { onSurfaceEditing(surface) } DisposableEffect(Unit) { onDispose { onSurfaceEditing(false) } } var managingLayout by remember { mutableStateOf(null) } + var exporting by remember { mutableStateOf(null) } var error by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() val controller = rememberControllerSettings() var oscMode by remember { mutableStateOf(SessionPrefs.oscMode(context)) } fun update(change: (InputLibrary) -> InputLibrary): Boolean = runCatching { InputLibraries.update(context, change) } @@ -107,6 +118,27 @@ private fun InputSettingsContent(onBack: () -> Unit, onChanged: () -> Unit, embe val profile = library.profile(profileId) val preset = library.preset(profile.binding(role)) val layout = library.layout(profileId, role) + val importer = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> + if (uri == null) return@rememberLauncherForActivityResult + val target = role + scope.launch { + val raw = withContext(Dispatchers.IO) { readLayoutFile(context, uri) } + val metrics = inputDisplayMetrics(context, target) + val imported = raw?.let { InputLayoutFile.import(context, it, layoutFileName(context, uri) ?: context.getString(R.string.ctrl_layout), + if (target == InputRole.PRIMARY) InputLayoutScreen.MAIN else InputLayoutScreen.SECONDARY, layout?.style ?: InputSurfaceStyle(), + metrics.widthPixels, metrics.heightPixels, metrics.density) } + if (imported == null) error = context.getString(R.string.input_layout_import_failed) + else if (update { it.withLayout(imported.first).withPreset(imported.second).chooseLayout(profileId, target, imported.first.id) }) go(InputPage.SOURCE) + } + } + val exporter = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/json")) { uri -> + val text = exporting + exporting = null + if (uri != null && text != null) scope.launch { + val saved = withContext(Dispatchers.IO) { runCatching { context.contentResolver.openOutputStream(uri, "wt")?.use { it.write(text.toByteArray()) } != null }.getOrDefault(false) } + if (!saved) error = context.getString(R.string.input_save_failed) + } + } fun rename(title: Int, name: String, save: (String) -> Unit) { naming = InputNameRequest(title, name) { value, _, _ -> save(value) } } @@ -249,6 +281,8 @@ private fun InputSettingsContent(onBack: () -> Unit, onChanged: () -> Unit, embe InputColorRow(host, layout.style.tint) { color -> update { it.withLayout(layout.copy(style = layout.style.copy(tint = color))) } } SliderRow(stringResource(R.string.ctrl_opacity), null, layout.style.opacity, 1..100, step = 1, format = { "$it%" }) { opacity -> update { it.withLayout(layout.copy(style = layout.style.copy(opacity = opacity))) } } + for (button in layout.quickButtons) ToggleRow(host, "quick-${button.name.lowercase()}", stringResource(quickButtonLabel(button)), null, + button !in layout.style.hidden) { on -> update { it.withLayout(layout.withQuickButton(button, on)) } } } if (role == InputRole.SECONDARY) InputHint(stringResource(R.string.input_second_screen_enable)) } @@ -302,10 +336,14 @@ private fun InputSettingsContent(onBack: () -> Unit, onChanged: () -> Unit, embe if (other.isNotEmpty()) PadTextButton(onClick = { showOther = !showOther }, modifier = Modifier.padding(8.dp)) { Text(stringResource(if (showOther) R.string.input_hide_other_layouts else R.string.input_other_layouts, other.size)) } - PadTextButton(onClick = { naming = InputNameRequest(R.string.input_new_layout, "") { name, _, _ -> - val created = SavedInputLayout(newInputId(), name, screen = if (role == InputRole.PRIMARY) InputLayoutScreen.MAIN else InputLayoutScreen.SECONDARY) - if (update { it.withLayout(created).useLayout(profileId, role, created.id, shared = true) }) { go(InputPage.SOURCE); surface = true } - } }, modifier = Modifier.padding(8.dp)) { Text(stringResource(R.string.input_new_layout)) } + Row { + PadTextButton(onClick = { naming = InputNameRequest(R.string.input_new_layout, "") { name, _, _ -> + val created = SavedInputLayout(newInputId(), name, screen = if (role == InputRole.PRIMARY) InputLayoutScreen.MAIN else InputLayoutScreen.SECONDARY) + if (update { it.withLayout(created).useLayout(profileId, role, created.id, shared = true) }) { go(InputPage.SOURCE); surface = true } + } }, modifier = Modifier.padding(8.dp)) { Text(stringResource(R.string.input_new_layout)) } + PadTextButton(onClick = { importer.launch(arrayOf("application/json", "text/plain", "application/octet-stream", "*/*")) }, + modifier = Modifier.padding(8.dp).testTag("input-layout-import")) { Text(stringResource(R.string.boot_movie_import)) } + } } } @@ -329,6 +367,16 @@ private fun InputSettingsContent(onBack: () -> Unit, onChanged: () -> Unit, embe managingLayout = null rename(R.string.input_rename_layout, saved.name) { name -> update { it.withLayout(saved.copy(name = name)) } } } + val exported = remember(saved, library) { + val metrics = inputDisplayMetrics(context, if (saved.screen == InputLayoutScreen.SECONDARY) InputRole.SECONDARY else InputRole.PRIMARY) + InputLayoutFile.export(saved, library.bindings.firstOrNull { it.id == preset.id && it.layoutId == saved.id } + ?: library.bindings.firstOrNull { it.layoutId == saved.id }, metrics.widthPixels, metrics.heightPixels) + } + if (exported != null) SecondaryButton(stringResource(R.string.screen_key_layout_export)) { + managingLayout = null + exporting = exported + exporter.launch(InputLayoutFile.fileName(saved)) + } val used = library.usersOfLayout(saved.id).isNotEmpty() || library.bindings.any { it.layoutId == saved.id && library.selectedBindings(it.id) } if (!used && saved.editable) SecondaryButton(stringResource(R.string.input_delete)) { managingLayout = null @@ -413,6 +461,38 @@ private fun InputNavigationRow(title: String, detail: String?, modifier: Modifie } } +internal fun quickButtonLabel(button: InputCommand) = when (button) { + InputCommand.STEAM -> R.string.ctrl_steam_button + InputCommand.QUICK_ACCESS -> R.string.ctrl_qam_button + else -> R.string.ctrl_keyboard_button +} + +@Suppress("DEPRECATION") +internal fun inputDisplayMetrics(context: Context, role: InputRole): DisplayMetrics = DisplayMetrics().also { metrics -> + (context.findActivity() as? com.droiddeck.launcher.SessionActivity)?.inputEditorDisplay(role)?.getRealMetrics(metrics) + ?: metrics.setTo(context.resources.displayMetrics) +} + +private fun readLayoutFile(context: Context, uri: Uri): String? = runCatching { + context.contentResolver.openInputStream(uri)?.use { input -> + val out = java.io.ByteArrayOutputStream() + val buffer = ByteArray(8192) + while (true) { + val read = input.read(buffer) + if (read < 0) break + out.write(buffer, 0, read) + if (out.size() > 1_000_000) return@use null + } + out.toString(Charsets.UTF_8.name()) + } +}.getOrNull() + +private fun layoutFileName(context: Context, uri: Uri): String? = runCatching { + context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) cursor.getString(0)?.substringBeforeLast('.')?.trim()?.takeIf { it.isNotEmpty() } else null + } +}.getOrNull() + @Composable internal fun roleLabel(role: InputRole) = stringResource(when (role) { InputRole.PHYSICAL -> R.string.input_physical; InputRole.PRIMARY -> R.string.input_primary; InputRole.SECONDARY -> R.string.input_secondary diff --git a/app/src/main/java/com/droiddeck/launcher/ui/InputSurfaceDialog.kt b/app/src/main/java/com/droiddeck/launcher/ui/InputSurfaceDialog.kt index 9a9e50db8..2c414aba0 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/InputSurfaceDialog.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/InputSurfaceDialog.kt @@ -58,7 +58,18 @@ import com.droiddeck.launcher.SessionActivity import com.droiddeck.launcher.input.* import com.droiddeck.launcher.session.SessionState -private class SurfaceDraft(val layout: SavedInputLayout, val preset: SavedInputBindings) { +private data class SurfaceSnapshot(val elements: List, val actions: Map, + val clicks: Map, val scale: Float) + +private class GamepadScaler(private val context: android.content.Context, private val width: Int, private val height: Int) { + private fun generator(scale: Float) = LegacyGamepadLayout(context, width, height, scale = scale) + val ids = generator(1f).elements().map { it.id }.toSet() + fun rescale(stored: List, from: Float, to: Float) = generator(to).rescale(stored, generator(from)) + fun reset(stored: List) = generator(1f).regenerate(stored).associateBy { it.id }.let { placed -> stored.map { placed[it.id] ?: it } } +} + +private class SurfaceDraft(val layout: SavedInputLayout, val preset: SavedInputBindings, val defaults: List? = null, + private val gamepad: GamepadScaler? = null) { var elements by mutableStateOf(layout.elements) var actions by mutableStateOf(preset.elements) var clicks by mutableStateOf(preset.clicks) @@ -70,15 +81,41 @@ private class SurfaceDraft(val layout: SavedInputLayout, val preset: SavedInputB var adding by mutableStateOf(false) var properties by mutableStateOf(false) var clickProperties by mutableStateOf(false) - var history by mutableStateOf, Map, Map>>>(emptyList()) + var history by mutableStateOf>(emptyList()) + var layoutScale by mutableFloatStateOf(layout.scale) + private var scaleBase by mutableStateOf, List, Float>?>(null) + private var scaleValue by mutableIntStateOf(100) + private val rescales get() = gamepad != null && elements.any { it.id in gamepad.ids } + val scale get() = when { + scaleBase?.second == elements -> scaleValue + rescales -> (layoutScale * 100).roundToInt() + else -> 100 + } + val positioned get() = (if (rescales) gamepad!!.reset(elements) else defaults?.let { elements.withPositionsOf(it) }) + ?.takeIf { it != elements || rescales && layoutScale != 1f } val element get() = elements.firstOrNull { it.id == selected } - val dirty get() = elements != layout.elements || actions != preset.elements || clicks != preset.clicks - fun checkpoint() { history = (history + Triple(elements, actions, clicks)).takeLast(40) } - fun undo() { history.lastOrNull()?.let { elements = it.first; actions = it.second; clicks = it.third; history = history.dropLast(1) } } + val dirty get() = elements != layout.elements || actions != preset.elements || clicks != preset.clicks || layoutScale != layout.scale + fun checkpoint() { history = (history + SurfaceSnapshot(elements, actions, clicks, layoutScale)).takeLast(40) } + fun undo() { history.lastOrNull()?.let { elements = it.elements; actions = it.actions; clicks = it.clicks; layoutScale = it.scale; history = history.dropLast(1) } } + fun scaleAll(percent: Int) { + val base = scaleBase?.takeIf { it.second == elements } ?: Triple(elements, elements, if (rescales) layoutScale else 0f).also { checkpoint() } + val factor = percent / 100f + elements = if (base.third > 0f && gamepad != null) { + layoutScale = factor + val others = base.first.filterNot { it.id in gamepad.ids }.scaled(factor / base.third).associateBy { it.id } + gamepad.rescale(base.first, base.third, factor).map { others[it.id] ?: it } + } else base.first.scaled(factor) + scaleValue = percent; scaleBase = Triple(base.first, elements, base.third) + } + fun resetPositions() { positioned?.let { checkpoint(); elements = it; if (rescales) layoutScale = 1f } } fun action(e: InputSurfaceElement) = preset.copy(elements = actions).action(e) fun clickAction(e: InputSurfaceElement) = preset.copy(elements = actions, clicks = clicks).clickAction(e) } +@Suppress("DEPRECATION") +private fun displayAspect(display: Display): Float? = android.util.DisplayMetrics().also(display::getRealMetrics) + .takeIf { it.widthPixels > 0 && it.heightPixels > 0 }?.let { (it.widthPixels.toFloat() / it.heightPixels).coerceIn(.05f, 20f) } + @Composable fun InputSurfaceDialog(profile: String, onDismiss: () -> Unit, onChanged: () -> Unit, role: InputRole = InputRole.SECONDARY, layoutId: String? = null, bindingsId: String? = null) { @@ -89,7 +126,14 @@ fun InputSurfaceDialog(profile: String, onDismiss: () -> Unit, onChanged: () -> val savedBindings = remember { library.preset(bindingsId ?: library.profile(profile).binding(role)) } val layoutUsers = library.usersOfLayout(savedLayout.id).size val bindingUsers = library.usersOfBindings(savedBindings.id).size - val draft = remember { SurfaceDraft(savedLayout, savedBindings) } + val draft = remember { + val (display, metrics) = InputLibraries.primaryDisplay(context) + val gamepad = savedLayout.elements.takeIf { e -> e.any { it.id.startsWith("legacy:") } }?.let { + val (w, h) = OnScreenLayout.generatedSize(context, savedLayout, metrics.widthPixels, metrics.heightPixels) ?: (metrics.widthPixels to metrics.heightPixels) + GamepadScaler(display, w, h) + } + SurfaceDraft(savedLayout, savedBindings, InputLibraries.defaultPositions(context, library, savedLayout), gamepad) + } val manager = remember { context.getSystemService(DisplayManager::class.java) } var revision by remember { mutableIntStateOf(0) } var discard by remember { mutableStateOf(false) } @@ -109,7 +153,7 @@ fun InputSurfaceDialog(profile: String, onDismiss: () -> Unit, onChanged: () -> InputLibraries.update(context) { latest -> val l = latest.layouts.first { it.id == savedLayout.id } val p = latest.preset(savedBindings.id) - latest.withLayout(l.copy(elements = draft.elements)).withPreset(p.copy(elements = draft.actions.filterKeys { id -> draft.elements.any { it.id == id } }, + latest.withLayout(l.copy(elements = draft.elements, aspect = target?.let(::displayAspect) ?: l.aspect, scale = draft.layoutScale)).withPreset(p.copy(elements = draft.actions.filterKeys { id -> draft.elements.any { it.id == id } }, clicks = draft.clicks.filterKeys { id -> draft.elements.any { it.id == id && it.hasClick } })) } }.onSuccess { onChanged(); onDismiss() }.onFailure { failure = it.message ?: context.getString(R.string.input_save_failed) } @@ -204,7 +248,7 @@ private fun SurfaceCanvas(draft: SurfaceDraft, transparent: Boolean = false) { AndroidView(factory = { InputSurfaceEditorView(it) }, modifier = Modifier.fillMaxSize().testTag("input-layout-canvas"), update = { view -> view.setBackgroundColor(if (transparent) android.graphics.Color.TRANSPARENT else android.graphics.Color.rgb(10, 11, 13)) - view.style = draft.layout.style; view.elements = draft.elements; view.selected = draft.selected; view.snap = draft.snap + view.style = draft.layout.style.copy(hidden = emptySet()); view.elements = draft.elements; view.selected = draft.selected; view.snap = draft.snap view.isEnabled = !draft.properties && !draft.adding view.onSelect = { if (!draft.properties) draft.selected = it } view.onBeginEdit = draft::checkpoint @@ -269,12 +313,15 @@ private fun SurfaceToolbar(draft: SurfaceDraft, onSave: () -> Unit, onLeave: () SecondaryButton(stringResource(R.string.common_cancel), compact = true, onClick = onLeave) SecondaryButton(stringResource(R.string.input_editor_add), enabled = draft.elements.size < 64, compact = true) { draft.adding = true } SecondaryButton(stringResource(R.string.input_editor_undo), enabled = draft.history.isNotEmpty(), compact = true) { draft.undo() } + if (draft.defaults != null) SecondaryButton(stringResource(R.string.input_reset_positions), enabled = draft.positioned != null, compact = true) { draft.resetPositions() } Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(draft.snap, { draft.snap = it }) Text(stringResource(R.string.ctrl_editor_snap)) } PrimaryButton(stringResource(R.string.input_editor_done), compact = true, onClick = onSave) } + if (draft.elements.isNotEmpty()) SliderRow(stringResource(R.string.ctrl_size), null, draft.scale, 80..125, step = 5, + format = { "$it%" }, modifier = Modifier.testTag("input-editor-scale")) { draft.scaleAll(it) } } @OptIn(ExperimentalLayoutApi::class) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/InputSurfaceView.kt b/app/src/main/java/com/droiddeck/launcher/ui/InputSurfaceView.kt index a5ab70273..b1cab727e 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/InputSurfaceView.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/InputSurfaceView.kt @@ -23,7 +23,6 @@ import com.droiddeck.launcher.input.PointerGestures import android.view.View import android.view.ViewGroup import android.widget.FrameLayout -import android.widget.HorizontalScrollView import android.widget.LinearLayout import android.widget.TextView import com.droiddeck.launcher.input.InputRole @@ -35,7 +34,7 @@ import com.droiddeck.launcher.input.InputSurfaceSource import com.droiddeck.launcher.input.InputElementKind import com.droiddeck.launcher.input.InputButtonIcons import com.droiddeck.launcher.input.InputButtonShape -import java.io.File +import com.droiddeck.launcher.input.InputButtonImages import com.droiddeck.launcher.input.PadBridge import com.droiddeck.launcher.session.SessionPrefs import kotlin.math.atan2 @@ -49,40 +48,94 @@ class InputSurfaceView( private var customLayout: List = emptyList(), private val inputRole: InputRole = InputRole.SECONDARY, private var surfaceStyle: InputSurfaceStyle = InputSurfaceStyle(), + private val haptics: Boolean = inputRole != InputRole.PRIMARY, ) : FrameLayout(context) { private val content = FrameLayout(context) private val elementSources = ArrayList() private var customCanvas: ViewGroup? = null - init { addView(content, LayoutParams(-1, -1)); renderCustom(pad) } + private val owners = HashMap() + private var requestedLayout = customLayout + init { customLayout = surfaceStyle.shown(customLayout); addView(content, LayoutParams(-1, -1)); renderCustom(pad) } var onTouchMiss: ((MotionEvent) -> Unit)? = null private var forwardedPointers = 0 override fun dispatchTouchEvent(event: MotionEvent): Boolean { - if (event.actionMasked == MotionEvent.ACTION_DOWN) forwardedPointers = 0 - if (onTouchMiss == null) return super.dispatchTouchEvent(event) + if (event.actionMasked == MotionEvent.ACTION_DOWN) { forwardedPointers = 0; cancelOwners(event) } if (event.actionMasked == MotionEvent.ACTION_DOWN || event.actionMasked == MotionEvent.ACTION_POINTER_DOWN) { val index = event.actionIndex - if (!takesTouchAt(event.getX(index), event.getY(index))) forwardedPointers = forwardedPointers or (1 shl event.getPointerId(index)) - else if (SessionPrefs.lowLatency(context)) requestUnbufferedDispatch(event) + if (event.getToolType(index) == MotionEvent.TOOL_TYPE_MOUSE || !takesTouchAt(event.getX(index), event.getY(index))) + forwardedPointers = forwardedPointers or (1 shl event.getPointerId(index)) + else if (onTouchMiss != null && SessionPrefs.lowLatency(context)) requestUnbufferedDispatch(event) } - if (forwardedPointers == 0) return super.dispatchTouchEvent(event) + if (forwardedPointers == 0) return route(event) var all = 0 for (index in 0 until event.pointerCount) all = all or (1 shl event.getPointerId(index)) - event.pointerSubset(all and forwardedPointers.inv())?.let { split -> - try { super.dispatchTouchEvent(split) } finally { split.recycle() } - } + val routed = event.pointerSubset(all and forwardedPointers.inv())?.let { split -> + try { route(split) } finally { split.recycle() } + } ?: false event.pointerSubset(all and forwardedPointers)?.let { split -> try { onTouchMiss?.invoke(split) } finally { split.recycle() } } if (event.actionMasked == MotionEvent.ACTION_POINTER_UP) forwardedPointers = forwardedPointers and (1 shl event.getPointerId(event.actionIndex)).inv() if (event.actionMasked == MotionEvent.ACTION_UP || event.actionMasked == MotionEvent.ACTION_CANCEL) forwardedPointers = 0 - return true + return onTouchMiss != null || routed + } + + private fun route(event: MotionEvent): Boolean { + val action = event.actionMasked + if (action == MotionEvent.ACTION_CANCEL) { cancelOwners(event); return true } + val index = event.actionIndex + val pointer = event.getPointerId(index) + var taken = true + if (action == MotionEvent.ACTION_DOWN || action == MotionEvent.ACTION_POINTER_DOWN) { + val target = elementAt(event.getX(index), event.getY(index)) + taken = target != null + when { + target is HoldButton -> { owners[pointer] = target; target.press(pointer, true) } + target != null && !(target is StickView && target in owners.values) -> owners[pointer] = target + } + } + if (action == MotionEvent.ACTION_MOVE) for (i in 0 until event.pointerCount) slide(event.getPointerId(i), event.getX(i), event.getY(i)) + for (view in owners.values.distinct()) { + if (view is HoldButton) continue + var bits = 0 + for ((id, owner) in owners) if (owner === view) bits = bits or (1 shl id) + event.pointerSubset(bits)?.let { split -> + try { split.offsetLocation(-originX(view), -originY(view)); view.dispatchTouchEvent(split) } finally { split.recycle() } + } + } + if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_POINTER_UP) { + (owners.remove(pointer) as? HoldButton)?.press(pointer, false, event.flags and MotionEvent.FLAG_CANCELED != 0) + } + return taken + } + + private fun slide(pointer: Int, x: Float, y: Float) { + val owner = owners[pointer] + if (owner != null && owner !is HoldButton) return + val over = elementAt(x, y) as? HoldButton + if (over === owner) return + (owner as? HoldButton)?.press(pointer, false, cancelled = true) + if (over == null) owners.remove(pointer) else { owners[pointer] = over; over.press(pointer, true) } + } + + private fun cancelOwners(event: MotionEvent) { + if (owners.isEmpty()) return + val views = owners.values.distinct() + owners.clear() + for (view in views) { + if (view is HoldButton) { view.cancel(); continue } + val cancel = MotionEvent.obtain(event.downTime, event.eventTime, MotionEvent.ACTION_CANCEL, 0f, 0f, 0) + try { view.dispatchTouchEvent(cancel) } finally { cancel.recycle() } + } } fun updateStyle(style: InputSurfaceStyle) { if (surfaceStyle == style) return + val relayout = surfaceStyle.hidden != style.hidden surfaceStyle = style + if (relayout) updateCustomLayout(requestedLayout) customCanvas?.let { canvas -> for (i in 0 until canvas.childCount) styleElement(canvas.getChildAt(i)) } } @@ -92,23 +145,30 @@ class InputSurfaceView( view.invalidate() } - fun updateCustomLayout(layout: List) { + fun updateCustomLayout(requested: List) { + requestedLayout = requested + val layout = surfaceStyle.shown(requested) val sameElements = customLayout.map { it.copy(x = 0f, y = 0f, width = 1f, height = 1f) } == layout.map { it.copy(x = 0f, y = 0f, width = 1f, height = 1f) } customLayout = layout if (sameElements) customCanvas?.requestLayout() else { elementSources.forEach { it.close() }; elementSources.clear() - content.removeAllViews(); renderCustom(pad) + owners.clear(); content.removeAllViews(); renderCustom(pad) } } fun releaseInputs() { val now = android.os.SystemClock.uptimeMillis() val cancel = MotionEvent.obtain(now, now, MotionEvent.ACTION_CANCEL, 0f, 0f, 0) - try { super.dispatchTouchEvent(cancel) } finally { cancel.recycle() } + try { cancelOwners(cancel) } finally { cancel.recycle() } forwardedPointers = 0 elementSources.forEach { it.release() } } + override fun onVisibilityChanged(changedView: View, visibility: Int) { + super.onVisibilityChanged(changedView, visibility) + if (visibility != VISIBLE) releaseInputs() + } + private fun renderCustom(pad: PadBridge?) { val canvas = object : ViewGroup(context) { override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { @@ -129,7 +189,7 @@ class InputSurfaceView( } } } - canvas.clipChildren = customLayout.none { it.kind == InputElementKind.STICK && classic(it) } + canvas.clipChildren = customLayout.none { it.kind == InputElementKind.STICK && (classic(it) || adaptive(it)) } for (element in customLayout) { val source = pad?.let { InputSurfaceSource(it, inputRole, element).also { s -> elementSources += s } } canvas.addView(when (element.kind) { @@ -146,7 +206,7 @@ class InputSurfaceView( override fun onAttachedToWindow() { super.onAttachedToWindow() if (pad != null && elementSources.isEmpty()) { - content.removeAllViews() + owners.clear(); content.removeAllViews() renderCustom(pad) } } @@ -157,19 +217,52 @@ class InputSurfaceView( super.onDetachedFromWindow() } - fun takesTouchAt(x: Float, y: Float): Boolean = controlUnder(this, x, y) + fun takesTouchAt(x: Float, y: Float): Boolean = elementAt(x, y) != null - private fun controlUnder(view: View, x: Float, y: Float): Boolean { - if (view.visibility != VISIBLE || x < 0f || y < 0f || x >= view.width || y >= view.height) return false - if (view.isClickable || view is HorizontalScrollView || view is Surface || view is DirectionPad || - view is androidx.compose.ui.platform.ComposeView) return true - val group = view as? ViewGroup ?: return false - return (group.childCount - 1 downTo 0).any { i -> - val child = group.getChildAt(i) - controlUnder(child, x + group.scrollX - child.x, y + group.scrollY - child.y) - } + private fun elementAt(x: Float, y: Float): View? { + val canvas = customCanvas ?: return null + if (visibility != VISIBLE || x < 0f || y < 0f || x >= width || y >= height) return null + val views = (canvas.childCount - 1 downTo 0).map(canvas::getChildAt).filter { it.visibility == VISIBLE } + views.firstOrNull { !(it is StickView && it.adaptive) && hits(it, x, y, 0f) }?.let { return it } + views.filter { it is HoldButton && hits(it, x, y, slack(it)) }.minByOrNull { distance(it, x, y) }?.let { return it } + if (views.any { it is HoldButton && hits(it, x, y, slack(it) + ADAPTIVE_CLEARANCE_PX) }) return null + return views.filter { it is StickView && it.adaptive && it !in owners.values && (hits(it, x, y, 0f) || it.captures(x - originX(it), y - originY(it))) } + .minByOrNull { distance(it, x, y) } } + private fun hits(view: View, x: Float, y: Float, slack: Float): Boolean { + val left = originX(view) + val top = originY(view) + if ((view as? HoldButton)?.round == true && slack > 0f) { + val reach = min(view.width, view.height) / 2f + slack + return hypot(x - left - view.width / 2f, y - top - view.height / 2f) <= reach + } + return x >= left - slack && x < left + view.width + slack && y >= top - slack && y < top + view.height + slack + } + + private fun slack(view: View) = if ((view as? HoldButton)?.gamepad == true) min(view.width, view.height) / 2f * HIT_SLACK else 0f + + private fun distance(view: View, x: Float, y: Float): Float { + val cx = originX(view) + ((view as? StickView)?.centreX() ?: (view.width / 2f)) + return hypot(x - cx, y - originY(view) - view.height / 2f) + } + + private fun originX(view: View): Float { + var x = 0f + var at: View = view + while (at !== this) { x += at.left - ((at.parent as? View)?.scrollX ?: 0); at = at.parent as? View ?: break } + return x + } + + private fun originY(view: View): Float { + var y = 0f + var at: View = view + while (at !== this) { y += at.top - ((at.parent as? View)?.scrollY ?: 0); at = at.parent as? View ?: break } + return y + } + + private fun feedback(view: View, type: Int) { if (haptics) view.performHapticFeedback(type) } + private abstract class Surface(context: Context) : View(context) { var tint: Int? = null protected val density = resources.displayMetrics.density @@ -231,11 +324,13 @@ class InputSurfaceView( protected fun textColor(lit: Boolean) = if (lit) Color.WHITE else tint?.let { light(it, 210, 0.75f) } ?: TEXT } - private class HoldButton(context: Context, private val element: InputSurfaceElement, private val source: InputSurfaceSource?) : Surface(context) { + private inner class HoldButton(context: Context, private val element: InputSurfaceElement, private val source: InputSurfaceSource?) : Surface(context) { private var down = false + private val pointers = HashSet() + val round get() = element.buttonShape == InputButtonShape.CIRCLE + val gamepad get() = round || element.buttonShape == InputButtonShape.PILL private val symbol = InputButtonIcons.drawable(context, element.icon) - private val bitmap = element.image.takeIf { it.matches(Regex("[A-Za-z0-9_.-]{1,128}")) && it != "." && it != ".." }?.let { name -> - val file = File(File(context.filesDir, "screenkeys"), name) + private val bitmap = InputButtonImages.file(context, element.image)?.let { file -> val size = BitmapFactory.Options().apply { inJustDecodeBounds = true } BitmapFactory.decodeFile(file.path, size) val options = BitmapFactory.Options().apply { @@ -282,23 +377,20 @@ class InputSurfaceView( canvas.drawText(element.name, cx, height / 2f + labelPaint.textSize * .35f, labelPaint) } - @SuppressLint("ClickableViewAccessibility") - override fun onTouchEvent(event: MotionEvent): Boolean { - when (event.actionMasked) { - MotionEvent.ACTION_DOWN -> set(true) - MotionEvent.ACTION_UP -> set(false) - MotionEvent.ACTION_CANCEL -> { set(false); source?.release() } - } - return true + fun press(pointer: Int, pressed: Boolean, cancelled: Boolean = false) { + if (pressed) pointers.add(pointer) else pointers.remove(pointer) + set(pointers.isNotEmpty(), cancelled) } - override fun onDetachedFromWindow() { set(false); source?.release(); super.onDetachedFromWindow() } + fun cancel() { pointers.clear(); set(false, cancelled = true); source?.release() } - private fun set(pressed: Boolean) { + override fun onDetachedFromWindow() { cancel(); super.onDetachedFromWindow() } + + private fun set(pressed: Boolean, cancelled: Boolean = false) { if (down == pressed) return down = pressed - if (pressed) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) - source?.update(if (pressed) 1f else 0f) + if (pressed) feedback(this, HapticFeedbackConstants.VIRTUAL_KEY) + if (!pressed && cancelled) source?.cancel() else source?.update(if (pressed) 1f else 0f) invalidate() } } @@ -314,7 +406,7 @@ class InputSurfaceView( } override fun onButton(button: Int, pressed: Boolean, x: Float, y: Float) { source?.gestureButton(button, pressed) } override fun onWheel(steps: Int) { source?.gestureWheel(steps) } - override fun onLongPress() { performHapticFeedback(HapticFeedbackConstants.LONG_PRESS) } + override fun onLongPress() { feedback(this@TrackpadView, HapticFeedbackConstants.LONG_PRESS) } }).apply { bounds = RectF(-1e9f, -1e9f, 1e9f, 1e9f) } private var touching = false @@ -396,7 +488,7 @@ class InputSurfaceView( private fun click(down: Boolean) { if (clicked == down) return clicked = down - if (down) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + if (down) feedback(this, HapticFeedbackConstants.VIRTUAL_KEY) source?.click(down) invalidate() } @@ -418,9 +510,18 @@ class InputSurfaceView( private val ring = Paint(Paint.ANTI_ALIAS_FLAG).apply { style = Paint.Style.STROKE } private val knob = Paint(Paint.ANTI_ALIAS_FLAG) - private fun reach() = min(width, height) * 0.30f + val adaptive get() = adaptive(element) - private fun centreX() = when { + fun reach() = min(width, height) * if (classic(element)) CLASSIC_REACH else PANEL_REACH + + fun captures(x: Float, y: Float): Boolean { + val cx = centreX() + val dx = if (element.control == InputControl.LS) maxOf(0f, x - cx) else x - cx + val limit = reach() * ADAPTIVE_REACH + return dx * dx + (y - height / 2f) * (y - height / 2f) <= limit * limit + } + + fun centreX() = when { !classic(element) || width <= height -> width / 2f element.control == InputControl.LS -> width - height / 2f else -> height / 2f @@ -465,7 +566,7 @@ class InputSurfaceView( originX = if (floating) event.x else centreX() originY = if (floating) event.y else height / 2f touching = true - performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + feedback(this, HapticFeedbackConstants.VIRTUAL_KEY) if (lastUp != 0L && event.eventTime - lastUp < CLICK_MS) setClick(true) follow(event.x, event.y, r, force = true) } @@ -501,7 +602,7 @@ class InputSurfaceView( private fun setClick(down: Boolean) { if (clicking == down) return clicking = down - if (down) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + if (down) feedback(this, HapticFeedbackConstants.VIRTUAL_KEY) source?.click(down) } @@ -556,7 +657,7 @@ class InputSurfaceView( private fun set(bits: Int) { if (bits == held) return - if (bits and held.inv() != 0) performHapticFeedback(HapticFeedbackConstants.VIRTUAL_KEY) + if (bits and held.inv() != 0) feedback(this, HapticFeedbackConstants.VIRTUAL_KEY) held = bits source?.update((if (bits and 2 != 0) 1f else 0f) - (if (bits and 8 != 0) 1f else 0f), (if (bits and 4 != 0) 1f else 0f) - (if (bits and 1 != 0) 1f else 0f)) @@ -573,6 +674,11 @@ class InputSurfaceView( companion object { private const val CLICK_MS = 300L private const val STICK_TAP_MS = 200L + private const val CLASSIC_REACH = 0.5f + private const val PANEL_REACH = 0.30f + private const val HIT_SLACK = 0.25f + private const val ADAPTIVE_CLEARANCE_PX = 5f + private val ADAPTIVE_REACH = 1.4f * kotlin.math.sqrt(1.5f) private const val CORNER = 20f @@ -588,6 +694,8 @@ class InputSurfaceView( private val TEXT_DIM = Color.rgb(0x7A, 0x80, 0x8A) private val KNOB = Color.rgb(0x2A, 0x2D, 0x33) + private fun adaptive(element: InputSurfaceElement) = element.kind == InputElementKind.STICK && element.floating && element.hideWhenIdle + private fun classic(element: InputSurfaceElement) = (element.id == "legacy:${element.control.id}" || element.id == "builtin:keyboard") && (element.buttonShape == InputButtonShape.CIRCLE || element.buttonShape == InputButtonShape.PILL) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt b/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt index 718873661..000aa8afc 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SecondScreenPresentation.kt @@ -18,11 +18,13 @@ import androidx.activity.setViewTreeOnBackPressedDispatcherOwner import androidx.lifecycle.setViewTreeLifecycleOwner import androidx.lifecycle.setViewTreeViewModelStoreOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import com.droiddeck.launcher.input.DeckPanel import com.droiddeck.launcher.input.KeyboardHost import com.droiddeck.launcher.input.PadBridge import com.droiddeck.launcher.input.SecondScreenMode import com.droiddeck.launcher.SessionActivity import com.droiddeck.launcher.session.BottomDesktop +import com.droiddeck.launcher.session.SessionPrefs import com.droiddeck.launcher.session.SessionService import com.droiddeck.launcher.session.SessionState @@ -39,6 +41,7 @@ class SecondScreenPresentation( private val dashboard: (@androidx.compose.runtime.Composable () -> Unit)? = null, private val onSwap: (() -> Unit)? = null, private val onMenuTouched: () -> Unit = {}, + private val onDeckPanel: (DeckPanel) -> Unit = { SessionPrefs.setDeckPanel(sessionActivity, it) }, ) : Presentation(sessionActivity, display) { private val frame = FrameLayout(context) private var menuView: androidx.compose.ui.platform.ComposeView? = null @@ -55,6 +58,7 @@ class SecondScreenPresentation( private var currentMode = SecondScreenMode.NONE private var keyboardHost: KeyboardHost? = null private val keyboardShown = androidx.compose.runtime.mutableStateOf(false) + private val deckPanel = androidx.compose.runtime.mutableStateOf(DeckPanel.BOTH) private var keyboardPending = false private var imeSeen = false private var fieldKeyboard = false @@ -284,6 +288,8 @@ class SecondScreenPresentation( when (mode) { SecondScreenMode.TERMINAL -> renderTerminal() SecondScreenMode.CUSTOM_CONTROLS, SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.DECK_CONTROLS -> { + if (mode == SecondScreenMode.DECK_CONTROLS) deckPanel.value = SessionPrefs.deckPanel(context) + bar(mode) val layout = panelLayout(mode) val controls = InputSurfaceView(context, pad, layout?.elements.orEmpty(), surfaceStyle = layout?.style ?: com.droiddeck.launcher.input.InputSurfaceStyle()) @@ -301,19 +307,39 @@ class SecondScreenPresentation( } private fun panelLayout(mode: SecondScreenMode) = com.droiddeck.launcher.input.InputLibraries.read(context) - .panelLayout(SessionState.mode == SessionService.MODE_DESKTOP, mode.bindings) + .panelLayout(SessionState.mode == SessionService.MODE_DESKTOP, mode.panel(context)) - private fun bar(mode: SecondScreenMode, extras: () -> List = { emptyList() }) { - root.addView(bottomScreenBarView(context) { - sessionBarModel(context.getString(mode.label), onClose, onMenu, onSteamMenu, onQam, extras()) - }, LinearLayout.LayoutParams(-1, -2)) + private fun bar(mode: SecondScreenMode) { + val first = barModel(mode) ?: return + root.addView(bottomScreenBarView(context) { barModel(mode) ?: first }, LinearLayout.LayoutParams(-1, -2)) + } + + internal fun barModel(mode: SecondScreenMode): BottomBarModel? { + val swap = listOfNotNull(onSwap?.let { BarExtra(context.getString(R.string.swap_screens), icon = R.drawable.ic_bottom_swap, tag = "bottom-swap", onClick = it) }) + fun model(extras: List, tabs: BarTabs? = null) = + sessionBarModel(context.getString(mode.label), onClose, onMenu, onSteamMenu, onQam, extras, tabs) + return when (mode) { + SecondScreenMode.KEYBOARD_TRACKPAD -> model(listOf(BarExtra( + context.getString(if (keyboardShown.value) R.string.second_hide_keyboard else R.string.second_show_keyboard), + icon = R.drawable.ic_bottom_keyboard, active = keyboardShown.value, tag = "bottom-keyboard") { showKeyboard() })) + SecondScreenMode.DECK_CONTROLS -> model(swap, deckPanelTabs(context, deckPanel.value, ::selectDeckPanel)) + SecondScreenMode.DASHBOARD -> model(swap) + SecondScreenMode.TERMINAL -> model(listOf(BarExtra(context.getString(R.string.drawer_keyboard), icon = R.drawable.ic_bottom_keyboard, + tag = "bottom-keyboard") { terminalPanel?.showKeyboard() })) + else -> null + } + } + + private fun selectDeckPanel(panel: DeckPanel) { + if (deckPanel.value == panel) return + deckPanel.value = panel + onDeckPanel(panel) + refreshControls() } private fun renderDashboard() { val content = dashboard ?: return - bar(SecondScreenMode.DASHBOARD) { - listOfNotNull(onSwap?.let { BarExtra(context.getString(R.string.swap_screens), icon = R.drawable.ic_bottom_swap, tag = "bottom-swap", onClick = it) }) - } + bar(SecondScreenMode.DASHBOARD) val view = androidx.compose.ui.platform.ComposeView(context).apply { setContent { content() } } dashboardView = view root.addView(view, weightParams()) @@ -337,11 +363,7 @@ class SecondScreenPresentation( } private fun renderTerminal() { - bar(SecondScreenMode.TERMINAL) { - listOf(BarExtra(context.getString(R.string.drawer_keyboard), icon = R.drawable.ic_bottom_keyboard, tag = "bottom-keyboard") { - terminalPanel?.showKeyboard() - }) - } + bar(SecondScreenMode.TERMINAL) val panel = SessionTerminalPanel(context) terminalPanel = panel root.addView(panel, weightParams()) diff --git a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt index c1ce2cd1f..dc0e8f320 100644 --- a/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt +++ b/app/src/main/java/com/droiddeck/launcher/ui/SessionOverlay.kt @@ -316,6 +316,8 @@ class DrawerActions( val unfoldedControls: Boolean? = null, val adaptiveSticks: Boolean? = null, val onAdaptiveSticks: (Boolean) -> Unit = {}, + val quickButtons: Map = emptyMap(), + val onQuickButton: (com.droiddeck.launcher.input.InputCommand, Boolean) -> Unit = { _, _ -> }, val onUnfoldedControls: (Boolean) -> Unit = {}, val unfoldedControlsWithController: Boolean = false, val onUnfoldedControlsWithController: (Boolean) -> Unit = {}, @@ -642,6 +644,11 @@ internal fun SessionDrawer(open: Boolean, requestedPage: SessionDrawerPage, cont ToggleRow(host, "adaptive-sticks", stringResource(R.string.ctrl_adaptive), null, on, chipModifier = focus.track(page, "adaptive-sticks"), onChange = a.onAdaptiveSticks) } + for ((button, shown) in a.quickButtons) { + val key = "quick-${button.name.lowercase()}" + ToggleRow(host, key, stringResource(quickButtonLabel(button)), null, shown, + chipModifier = focus.track(page, key)) { a.onQuickButton(button, it) } + } a.onEditSteamTouch?.let { edit -> ActionRow(stringResource(R.string.osc_steam_layout), stringResource(R.string.osc_steam_layout_hint), stringResource(R.string.ctrl_edit), edit) } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 0b2a08496..b5e68c382 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -272,24 +272,8 @@ لمس Steam (تجريبي) تبدأ عناصر التحكم باللمس من Steam مع جلسة Steam التالية تخطيط لمس Steam - مفاتيح الشاشة - إضافة مفتاح - نص - الحجم - العتامة - مفتاح - زناد - تبديل تصدير - كبسولة - مستدير - دائرة - مستطيل - مفتاح صورة - مسح - التخطيط - حفظ انقل عناصر التحكم باللمس لهذه اللعبة أو غيّر حجمها أو أخفها؛ ويتم الحفظ في Steam وحدة التحكم كيف يرى Steam وحدة التحكم لديك. يُطبَّق في الجلسة التالية. @@ -776,7 +760,6 @@ تلقائي تشغيل إيقاف - المؤشر لوحة مفاتيح الكمبيوتر Android الشاشة الثانية @@ -1135,43 +1118,23 @@ 3 · ADB من كمبيوتر (كخيار أخير) فقط إذا كان تصحيح الأخطاء اللاسلكي غير متاح. 3 · ADB من كمبيوتر (كخيار أخير) - متى تظهر عناصر التحكم باللمس في الجلسة اللون - درجة لون الأزرار على الشاشة مخصّص العتامة %1$d%% حجم الأزرار - 100% هو الحجم القياسي - ضغط العصا - انقر مرتين مع الاستمرار على عصا للحصول على L3 أو R3 عصي تكيّفية اهتزاز اللعبة المنطقة الميتة لعصا التحكم زر Steam زر QAM زر لوحة المفاتيح - اضغط على Select لفتح قائمة Steam - بدلًا من View/Select. أجهزة Joy-Con كوحدات تحكم VR أثناء تشغيل SteamVR، يصبح كل Joy-Con يدًا. وجّهه إلى الأمام واضغط مطولًا على Home أو Capture لإعادة توسيطه. الرسوم المتحركة - تظهر العصي حيث تلمس وتُخفى عند الرفع التخطيط - تم حفظ مواضع مخصّصة - مهيّأ لهذه الشاشة تعديل إعادة ضبط - تعيين الأزرار - يرسل كل زر إدخاله الخاص - تمت إعادة تعيين %1$d من %2$d أزرار - ضبط - إعادة ضبط وحدة التحكم - يستعيد كل إعدادات وحدة التحكم والتخطيط - ما يرسله كل زر على الشاشة إلى اللعبة. يؤدي إخفاؤه إلى إزالة الزر. - أزرار الشاشة - الافتراضية - إعادة ضبط التعيين تطبيقات Android %1$d تطبيق لم يتم العثور على تطبيقات Android قابلة للتشغيل. @@ -1295,15 +1258,8 @@ عصا التحكّم لوحة اللمس التبديل بين عصا التحكّم ولوحة اللمس - المزيد من عناصر تحكّم Deck - اللوحات إغلاق عناصر التحكّم على الشاشة الثانية فتح قائمة Steam - يسار · علوي - يسار · سفلي - يمين · علوي - يمين · سفلي - نقر لوحة اللمس اليمنى لوحة اللمس اليسرى لوحة اللمس @@ -1903,9 +1859,7 @@ لوحة الاتجاهات لليسار لوحة الاتجاهات لليمين مخفي - اسحب للنقل محاذاة - حفظ لا شيء لوحة المفاتيح + لوحة اللمس الوحدة الطرفية @@ -2765,7 +2719,6 @@ الإنجازات غير متوفرة؛ تحقّق من سجلات الجلسة تم فتح الإنجاز لصق - لوحة الاتجاهات العصا اليسرى العصا اليمنى القائمة @@ -3858,4 +3811,7 @@ لم يتم العثور على إضافات تعذّر الوصول إلى %1$s لا يوجد ملف لإصدار اللعبة هذا + إعادة تعيين المواضع + إظهار التسمية + تعذّر استيراد هذا التخطيط diff --git a/app/src/main/res/values-b+es+419/strings.xml b/app/src/main/res/values-b+es+419/strings.xml index dff3e76b9..fe24a6b46 100644 --- a/app/src/main/res/values-b+es+419/strings.xml +++ b/app/src/main/res/values-b+es+419/strings.xml @@ -272,24 +272,8 @@ Táctil de Steam (beta) Los controles táctiles de Steam se activan en la próxima sesión de Steam Disposición táctil de Steam - Teclas en pantalla - Agregar tecla - Texto - Tamaño - Opacidad - Tecla - Gatillo - Alternar Exportar - Píldora - Redondeado - Círculo - Rectángulo - Tecla Imagen - Borrar - Disposición - Guardar Mueve, cambia de tamaño u oculta los controles táctiles de este juego; se guarda en Steam Control Cómo ve Steam tu control. Se aplica en la próxima sesión. @@ -794,7 +778,6 @@ Automático Activado Desactivado - Cursor Teclado de PC Android Segunda pantalla @@ -1144,23 +1127,17 @@ 3 · ADB desde una computadora (último recurso) Solo si la Depuración inalámbrica no está disponible. 3 · ADB desde una computadora (último recurso) - Cuándo aparecen los controles táctiles en una sesión Color - Tinte de los botones en pantalla Personalizado Opacidad %1$d%% Tamaño de botones - 100% es el tamaño estándar - Clic del stick - Toca dos veces y mantén un stick para L3 o R3 Sticks adaptables Vibración en juegos Zona muerta de la palanca Botón Steam Botón QAM Botón de teclado - Presiona Select para abrir el menú de Steam Joy-Con como controles de RV Mientras SteamVR está activo, cada Joy-Con es una mano. Apúntalo al frente y mantén Home o Captura para recentrarlo. Sin ningún mando conectado, la cámara del teléfono rastrea tus manos en realidad virtual: pellizca con el pulgar y el índice para hacer clic, cierra la mano para agarrar y gira la palma hacia ti y pellizca para abrir el menú. Con los mandos Joy-Con emparejados, la cámara rastrea su posición mientras los mandos Joy-Con proporcionan la rotación y las entradas de los botones. Las imágenes se procesan en este dispositivo y nunca se guardan ni se envían. @@ -1206,24 +1183,10 @@ La conexión con las XREAL se interrumpió o devolvió una respuesta no compatible. Reintentando… Compatibilidad experimental con las 1S. Usa el modo Follow y desactiva la estabilización en el menú de las gafas. Aún falta probar en el dispositivo las direcciones del seguimiento y los modos de pantalla. Seguimiento de cabeza experimental para Pro 2. Los ajustes de brillo y volumen y el cambio de modo de pantalla aún no son compatibles. - En lugar de View/Select. Animaciones - Los sticks aparecen donde tocas y se ocultan al soltar Disposición - Posiciones personalizadas guardadas - Ajustada a esta pantalla Editar Restablecer - Asignación de botones - Cada botón envía su propia entrada - %1$d de %2$d botones reasignados - Configurar - Restablecer control - Restaura todos los ajustes del control y la disposición - Lo que cada botón en pantalla envía al juego. Oculto quita el botón. - Botones en pantalla - Predeterminados - Restablecer asignación Apps de Android %1$d apps No se encontraron apps de Android que se puedan abrir. @@ -1344,15 +1307,8 @@ Stick Trackpad Cambiar entre stick y trackpad - Más controles de Deck - PANELES Cerrar controles de la segunda pantalla Abrir menú de Steam - Izquierdo · superior - Izquierdo · inferior - Derecho · superior - Derecho · inferior - Clic Trackpad derecho Trackpad izquierdo Trackpad @@ -1895,9 +1851,7 @@ Cruceta izquierda Cruceta derecha Oculto - Arrastra para mover Ajustar - Guardar Nada Teclado + trackpad Terminal @@ -2702,7 +2656,6 @@ Logros no disponibles; revisa los registros de la sesión Logro desbloqueado Pegar - Pad direccional Stick izquierdo Stick derecho Menú @@ -3714,4 +3667,7 @@ No se encontraron complementos No se pudo conectar con %1$s No hay archivo para esta versión del juego + Restablecer posiciones + Mostrar etiqueta + No se pudo importar esta disposición diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index 0a3e0baad..1d89d523e 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -272,24 +272,8 @@ Докосване в Steam (бета) Сензорните контроли на Steam ще стартират от следващата сесия на Steam Разположение на докосването в Steam - Клавиши на екрана - Добавяне на клавиш - Текст - Размер - Прозрачност - Клавиш - Спускът - Превключване Експорт - Капсула - Заоблено - Кръг - Правоъгълник - Клавиш Изображение - Изчистване - Разположение - Запиши Премествайте, преоразмерявайте или скривайте сензорните контроли на тази игра; записва се в Steam Контролер Как Steam разпознава контролера ви. Прилага се от следващата сесия. @@ -768,7 +752,6 @@ Автоматично Включено Изключено - Курсор Клавиатура за компютър Android Втори екран @@ -1111,43 +1094,23 @@ 3 · ADB от компютър (последна възможност) Само ако безжичното отстраняване на грешки не е достъпно. 3 · ADB от компютър (последна възможност) - Кога се появяват екранните контроли в сесия Цвят - Оттенък на екранните бутони Персонализиран Прозрачност %1$d%% Размер на бутоните - 100% е стандартният размер - Натискане на стика - Докоснете двукратно и задръжте стик за L3 или R3 Адаптивни стикове Вибрация от играта Мъртва зона на стика Бутон Steam Бутон QAM Бутон за клавиатура - Натиснете Select, за да отворите менюто на Steam - Вместо View/Select. Joy-Con като VR контролери Докато SteamVR работи, всеки Joy-Con става ръка. Насочете го напред и задръжте Home или Capture, за да го центрирате отново. Анимации - Стиковете се появяват там, където докоснете, и се скриват при отпускане Разположение - Запазени персонализирани позиции - Нагласено за този екран Редактиране Нулиране - Разпределение на бутоните - Всеки бутон изпраща собствения си сигнал - %1$d от %2$d бутона преназначени - Конфигуриране - Нулиране на контролера - Възстановява всички настройки на контролера и разположението - Какво изпраща всеки екранен бутон към играта. „Скрит“ премахва бутона. - Екранни бутони - Стандартни настройки - Нулиране на разпределението Приложения за Android %1$d приложения Не са намерени приложения за Android за стартиране. @@ -1267,15 +1230,8 @@ Стик Тракпад Превключване между стик и тракпад - Още контроли на Deck - ПАНЕЛИ Затваряне на контролите за втория екран Отваряне на менюто на Steam - Ляво · горе - Ляво · долу - Дясно · горе - Дясно · долу - Клик Десен тракпад Ляв тракпад Тракпад @@ -1807,9 +1763,7 @@ D-pad наляво D-pad надясно Скрит - Плъзни, за да преместиш Прилепване - Запазване Нищо Клавиатура + тракпад Терминал @@ -2657,7 +2611,6 @@ Постиженията не са достъпни; проверете логовете на сесията Постижение отключено Поставяне - Кръстат бутон Ляв стик Десен стик Меню @@ -3702,4 +3655,7 @@ Няма намерени добавки Няма връзка с %1$s Няма файл за тази версия на играта + Нулиране на позициите + Показване на етикета + Това разположение не можа да бъде импортирано diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index e1ddcfdb5..ba8dd05dd 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -272,24 +272,8 @@ Dotykové ovládání Steamu (beta) Dotykové ovládání Steamu se spustí od příští relace Steamu Rozvržení dotykového ovládání Steamu - Klávesy na obrazovce - Přidat klávesu - Text - Velikost - Průhlednost - Klávesa - Spoušť - Přepínač Exportovat - Kapsle - Zaoblené - Kruh - Obdélník - Klávesa Obrázek - Vymazat - Rozložení - Uložit Přesuňte, změňte velikost nebo skryjte dotykové ovládání této hry; uloží se do Steamu Ovladač Jak Steam vidí váš ovladač. Použije se od příští relace. @@ -772,7 +756,6 @@ Automaticky Zapnuto Vypnuto - Kurzor Klávesnice PC Android Druhá obrazovka @@ -1123,43 +1106,23 @@ 3 · ADB z počítače (poslední možnost) Jen pokud Bezdrátové ladění není k dispozici. 3 · ADB z počítače (poslední možnost) - Kdy se dotykové ovládání zobrazí v relaci Barva - Odstín tlačítek na obrazovce Vlastní Průhlednost %1$d%% Velikost tlačítek - 100 % je standardní velikost - Kliknutí páčkou - Dvojitým dotykem a podržením páčky aktivujete L3 nebo R3 Adaptivní páčky Vibrace ve hře Mrtvá zóna páčky Tlačítko Steam Tlačítko QAM Tlačítko klávesnice - Otevřít nabídku Steam stisknutím Select - Místo View/Select. Joy-Cony jako ovladače VR Když běží SteamVR, každý Joy-Con slouží jako ruka. Namiřte ho dopředu a podržením Home nebo Capture ho znovu vycentrujete. Animace - Páčky se zobrazí tam, kde se dotknete, a skryjí se po uvolnění Rozložení - Uloženy vlastní pozice - Přizpůsobeno této obrazovce Upravit Obnovit - Mapování tlačítek - Každé tlačítko odesílá svůj vlastní vstup - %1$d z %2$d tlačítek přemapováno - Konfigurovat - Obnovit ovladač - Obnoví všechna nastavení ovladače a rozložení - Co každé tlačítko na obrazovce odesílá hře. Skryté tlačítko odstraní. - Tlačítka na obrazovce - Výchozí - Obnovit mapování Aplikace Android %1$d aplikací Nebyly nalezeny žádné spustitelné aplikace Android. @@ -1281,15 +1244,8 @@ Páčka Trackpad Přepnout mezi páčkou a trackpadem - Další ovládání Deck - PANELY Zavřít ovládání druhé obrazovky Otevřít nabídku Steamu - Levý · horní - Levý · dolní - Pravý · horní - Pravý · dolní - Kliknutí Pravý trackpad Levý trackpad Trackpad @@ -1855,9 +1811,7 @@ D-pad vlevo D-pad vpravo Skryté - Přetažením přesunete Přichytit - Uložit Žádný Klávesnice + trackpad Terminál @@ -2711,7 +2665,6 @@ Achievementy nejsou k dispozici; zkontrolujte protokoly relace Achievement odemčen Vložit - Křížový ovladač Levá páčka Pravá páčka Nabídka @@ -3780,4 +3733,7 @@ Nenalezeny žádné doplňky Nelze se připojit k %1$s Pro tuto verzi hry není k dispozici soubor + Obnovit pozice + Zobrazit popisek + Toto rozložení nelze importovat diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index a79078ea9..572c522b0 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -272,24 +272,8 @@ Steam-berøring (beta) Steams berøringskontroller starter med næste Steam-session Steam-berøringslayout - Skærmtaster - Tilføj tast - Tekst - Størrelse - Gennemsigtighed - Tast - Udløser - Skift Eksportér - Kapsel - Afrundet - Cirkel - Rektangel - Tast Billede - Ryd - Layout - Gem Flyt, tilpas størrelsen på eller skjul dette spils berøringskontroller; gemmes til Steam Controller Hvordan Steam ser din controller. Gælder fra næste session. @@ -768,7 +752,6 @@ Automatisk Til Fra - Markør PC-tastatur Android Anden skærm @@ -1111,43 +1094,23 @@ 3 · Computer-ADB (sidste udvej) Kun hvis Trådløs fejlretning ikke er tilgængelig. 3 · Computer-ADB (sidste udvej) - Hvornår berøringskontrollerne vises i en session Farve - Farvetone for knapperne på skærmen Brugerdefineret Gennemsigtighed %1$d%% Knapstørrelse - 100% er standardstørrelsen - Stick-klik - Dobbelttryk og hold en stick nede for L3 eller R3 Adaptive styrepinde Spilvibration Dødzone for styrepind Steam-knap QAM-knap Tastaturknap - Tryk på Select for at åbne Steam-menuen - I stedet for View/Select. Joy-Cons som VR-controllere Mens SteamVR kører, bliver hver Joy-Con en hånd. Peg den fremad, og hold Home eller Capture nede for at centrere den igen. Animationer - Sticks vises, hvor du rører skærmen, og skjules, når du slipper Layout - Brugerdefinerede positioner gemt - Tilpasset denne skærm Rediger Nulstil - Knapmapning - Hver knap sender sit eget input - %1$d af %2$d knapper ommappet - Konfigurer - Nulstil controller - Gendanner alle controllerindstillinger og layoutet - Hvad hver knap på skærmen sender til spillet. Skjult fjerner knappen. - Knapper på skærmen - Standarder - Nulstil mapning Android-apps %1$d apps Ingen Android-apps, der kan startes, fundet. @@ -1267,15 +1230,8 @@ Stick Trackpad Skift mellem stick og trackpad - Flere Deck-kontroller - PANELER Luk kontrollerne til den anden skærm Åbn Steam-menuen - Venstre · øverst - Venstre · nederst - Højre · øverst - Højre · nederst - Klik Højre trackpad Venstre trackpad Trackpad @@ -1807,9 +1763,7 @@ D-pad venstre D-pad højre Skjult - Træk for at flytte Fastgør - Gem Ingen Tastatur + trackpad Terminal @@ -2657,7 +2611,6 @@ Bedrifter ikke tilgængelige; se sessionslogs Bedrift låst op Indsæt - Styrekryds Venstre stick Højre stick Menu @@ -3702,4 +3655,7 @@ Ingen tilføjelser fundet Kunne ikke nå %1$s Ingen fil til denne spilversion + Nulstil placeringer + Vis etiket + Dette layout kunne ikke importeres diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index a4ce52946..03d89f720 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -272,24 +272,8 @@ Steam-Touch (Beta) Steams Touch-Steuerung startet mit der nächsten Steam-Sitzung Steam-Touch-Layout - Bildschirmtasten - Taste hinzufügen - Text - Größe - Deckkraft - Taste - Trigger - Umschalten Exportieren - Kapsel - Rund - Kreis - Rechteck - Taste Bild - Entfernen - Layout - Speichern Touch-Steuerung dieses Spiels verschieben, skalieren oder ausblenden; wird in Steam gespeichert Controller Wie Steam deinen Controller erkennt. Gilt ab der nächsten Sitzung. @@ -768,7 +752,6 @@ Automatisch An Aus - Zeiger PC-Tastatur Android Zweiter Bildschirm @@ -1103,43 +1086,23 @@ 3 · ADB am Computer (letzter Ausweg) Nur wenn „Debugging über WLAN“ nicht verfügbar ist. 3 · ADB am Computer (letzter Ausweg) - Wann die Touch-Steuerung in einer Sitzung erscheint Farbe - Farbton der Bildschirmtasten Benutzerdefiniert Deckkraft %1$d %% Tastengröße - Standardgröße: 100 % - Stick-Klick - Doppeltippen und Stick halten für L3 oder R3 Adaptive Sticks Spielvibration Stick-Totzone Steam-Taste QAM-Taste Tastatur-Taste - Mit Select das Steam-Menü öffnen - Statt View/Select. Joy-Cons als VR-Controller Während SteamVR läuft, wird jeder Joy-Con zu einer Hand. Richte ihn nach vorn und halte HOME oder Capture gedrückt, um ihn neu auszurichten. Animationen - Sticks erscheinen dort, wo du tippst, und verschwinden beim Loslassen Layout - Eigene Positionen gespeichert - An diesen Bildschirm angepasst Bearbeiten Zurücksetzen - Tastenbelegung - Jede Taste sendet ihre eigene Eingabe - %1$d von %2$d Tasten neu belegt - Konfigurieren - Controller zurücksetzen - Stellt alle Controller-Einstellungen und das Layout wieder her - Was jede Bildschirmtaste an das Spiel sendet. „Ausgeblendet“ entfernt die Taste. - Bildschirmtasten - Standardwerte - Belegung zurücksetzen Android-Apps %1$d Apps Keine startbaren Android-Apps gefunden. @@ -1259,15 +1222,8 @@ Stick Trackpad Zwischen Stick und Trackpad wechseln - Weitere Deck-Steuerelemente - BEREICHE Steuerung auf dem Zweitbildschirm schließen Steam-Menü öffnen - Links · oben - Links · unten - Rechts · oben - Rechts · unten - Klick Rechtes Trackpad Linkes Trackpad Trackpad @@ -1799,9 +1755,7 @@ Steuerkreuz links Steuerkreuz rechts Ausgeblendet - Zum Verschieben ziehen Einrasten - Speichern Nichts Tastatur + Trackpad Terminal @@ -2646,7 +2600,6 @@ Erfolge nicht verfügbar; Sitzungsprotokolle prüfen Erfolg freigeschaltet Einfügen - Richtungstasten Linker Stick Rechter Stick Menü @@ -3702,4 +3655,7 @@ Keine Addons gefunden %1$s ist nicht erreichbar Keine Datei für diese Spielversion + Positionen zurücksetzen + Beschriftung anzeigen + Dieses Layout konnte nicht importiert werden diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 2b2702116..97b593f5e 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -272,24 +272,8 @@ Αφή Steam (beta) Τα χειριστήρια αφής του Steam ξεκινούν από την επόμενη συνεδρία Steam Διάταξη αφής Steam - Πλήκτρα οθόνης - Προσθήκη πλήκτρου - Κείμενο - Μέγεθος - Διαφάνεια - Πλήκτρο - Σκανδάλη - Εναλλαγή Εξαγωγή - Κάψουλα - Στρογγυλεμένο - Κύκλος - Ορθογώνιο - Πλήκτρο Εικόνα - Εκκαθάριση - Διάταξη - Αποθήκευση Μετακινήστε, αλλάξτε μέγεθος ή αποκρύψτε τα χειριστήρια αφής αυτού του παιχνιδιού· αποθηκεύεται στο Steam Χειριστήριο Πώς βλέπει το Steam το χειριστήριό σας. Εφαρμόζεται στην επόμενη συνεδρία. @@ -768,7 +752,6 @@ Αυτόματα Ενεργό Ανενεργό - Δείκτης Πληκτρολόγιο PC Android Δεύτερη οθόνη @@ -1111,43 +1094,23 @@ 3 · ADB υπολογιστή (τελευταία λύση) Μόνο αν ο Ασύρματος εντοπισμός σφαλμάτων δεν είναι διαθέσιμος. 3 · ADB υπολογιστή (τελευταία λύση) - Όταν εμφανίζονται τα χειριστήρια αφής σε μια συνεδρία Χρώμα - Απόχρωση των κουμπιών οθόνης Προσαρμοσμένο Διαφάνεια %1$d%% Μέγεθος κουμπιών - Το 100% είναι το κανονικό μέγεθος - Κλικ μοχλού - Πατήστε δύο φορές και κρατήστε έναν μοχλό για L3 ή R3 Προσαρμοστικοί μοχλοί Κραδασμός παιχνιδιού Νεκρή ζώνη μοχλού Κουμπί Steam Κουμπί QAM Κουμπί πληκτρολογίου - Πατήστε Select για να ανοίξετε το μενού Steam - Αντί για View/Select. Joy-Con ως χειριστήρια VR Όσο εκτελείται το SteamVR, κάθε Joy-Con γίνεται χέρι. Στρέψτε το μπροστά και κρατήστε πατημένο το Home ή το Capture για να το επανακεντράρετε. Κινήσεις - Οι μοχλοί εμφανίζονται όπου αγγίζετε και εξαφανίζονται όταν αφήνετε Διάταξη - Αποθηκεύτηκαν προσαρμοσμένες θέσεις - Προσαρμοσμένο σε αυτή την οθόνη Επεξεργασία Επαναφορά - Αντιστοίχιση κουμπιών - Κάθε κουμπί στέλνει τη δική του είσοδο - %1$d από %2$d κουμπιά επαναντιστοιχίστηκαν - Διαμόρφωση - Επαναφορά χειριστηρίου - Επαναφέρει όλες τις ρυθμίσεις χειριστηρίου και τη διάταξη - Τι στέλνει κάθε κουμπί οθόνης στο παιχνίδι. Το Κρυμμένο αφαιρεί το κουμπί. - Κουμπιά οθόνης - Προεπιλογές - Επαναφορά αντιστοίχισης Εφαρμογές Android %1$d εφαρμογές Δεν βρέθηκαν εφαρμογές Android για εκκίνηση. @@ -1267,15 +1230,8 @@ Μοχλός Πινακίδα αφής Εναλλαγή μεταξύ μοχλού και πινακίδας αφής - Περισσότερα χειριστήρια Deck - ΠΙΝΑΚΕΣ Κλείσιμο χειριστηρίων δεύτερης οθόνης Άνοιγμα μενού Steam - Αριστερά · πάνω - Αριστερά · κάτω - Δεξιά · πάνω - Δεξιά · κάτω - Κλικ Δεξιά πινακίδα αφής Αριστερή πινακίδα αφής Πινακίδα αφής @@ -1807,9 +1763,7 @@ D-pad αριστερά D-pad δεξιά Κρυφό - Σύρετε για μετακίνηση Προσκόλληση - Αποθήκευση Κανένα Πληκτρολόγιο + πινακίδα αφής Τερματικό @@ -2657,7 +2611,6 @@ Τα επιτεύγματα δεν είναι διαθέσιμα· ελέγξτε τα αρχεία καταγραφής της συνεδρίας Το επίτευγμα ξεκλειδώθηκε Επικόλληση - Σταυρός κατεύθυνσης Αριστερό στικ Δεξί στικ Μενού @@ -3702,4 +3655,7 @@ Δεν βρέθηκαν πρόσθετα Δεν ήταν δυνατή η σύνδεση με %1$s Δεν υπάρχει αρχείο για αυτή την έκδοση του παιχνιδιού + Επαναφορά θέσεων + Εμφάνιση ετικέτας + Δεν ήταν δυνατή η εισαγωγή αυτής της διάταξης diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 471e4fc01..e0368f571 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -272,24 +272,8 @@ Táctil de Steam (beta) Los controles táctiles de Steam se activan en la próxima sesión de Steam Disposición táctil de Steam - Teclas en pantalla - Añadir tecla - Texto - Tamaño - Opacidad - Tecla - Gatillo - Alternar Exportar - Píldora - Redondeado - Círculo - Rectángulo - Tecla Imagen - Borrar - Disposición - Guardar Mueve, cambia de tamaño u oculta los controles táctiles de este juego; se guarda en Steam Mando Cómo ve Steam tu mando. Se aplica en la próxima sesión. @@ -770,7 +754,6 @@ Automático Activado Desactivado - Cursor Teclado de PC Android Segunda pantalla @@ -1117,43 +1100,23 @@ 3 · ADB desde ordenador (último recurso) Solo si la Depuración inalámbrica no está disponible. 3 · ADB desde ordenador (último recurso) - Cuándo aparecen los controles táctiles en una sesión Color - Tinte de los botones en pantalla Personalizado Opacidad %1$d%% Tamaño de botones - 100% es el tamaño estándar - Clic del stick - Toca dos veces y mantén un stick para L3 o R3 Sticks adaptables Vibración en juegos Zona muerta de la palanca Botón Steam Botón QAM Botón de teclado - Pulsa Select para abrir el menú de Steam - En lugar de View/Select. Joy-Con como mandos de RV Mientras SteamVR está activo, cada Joy-Con es una mano. Apúntalo al frente y mantén Home o Captura para recentrarlo. Animaciones - Los sticks aparecen donde tocas y se ocultan al soltar Disposición - Posiciones personalizadas guardadas - Ajustada a esta pantalla Editar Restablecer - Asignación de botones - Cada botón envía su propia entrada - %1$d de %2$d botones reasignados - Configurar - Restablecer mando - Restaura todos los ajustes del mando y la disposición - Lo que cada botón en pantalla envía al juego. Oculto quita el botón. - Botones en pantalla - Predeterminados - Restablecer asignación Apps de Android %1$d apps No se encontraron apps de Android que se puedan abrir. @@ -1274,15 +1237,8 @@ Stick Trackpad Cambiar entre stick y trackpad - Más controles de Deck - PANELES Cerrar controles de la segunda pantalla Abrir menú de Steam - Izquierdo · superior - Izquierdo · inferior - Derecho · superior - Derecho · inferior - Clic Trackpad derecho Trackpad izquierdo Trackpad @@ -1831,9 +1787,7 @@ Cruceta izquierda Cruceta derecha Oculto - Arrastra para mover Ajustar - Guardar Nada Teclado + trackpad Terminal @@ -2681,7 +2635,6 @@ Logros no disponibles; revisa los registros de la sesión Logro desbloqueado Pegar - Pad direccional Stick izquierdo Stick derecho Menú @@ -3741,4 +3694,7 @@ No se han encontrado complementos No se pudo conectar con %1$s No hay ningún archivo para esta versión del juego + Restablecer posiciones + Mostrar etiqueta + No se ha podido importar esta disposición diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index c749fe252..34a7db291 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -272,24 +272,8 @@ Steam-kosketus (beta) Steamin kosketusohjaimet otetaan käyttöön seuraavassa Steam-istunnossa Steamin kosketusasettelu - Näytön näppäimet - Lisää näppäin - Teksti - Koko - Peittävyys - Näppäin - Liipaisin - Vaihda Vie - Kapseli - Pyöristetty - Ympyrä - Suorakaide - Näppäin Kuva - Tyhjennä - Asettelu - Tallenna Siirrä, muuta kokoa tai piilota tämän pelin kosketusohjaimet; tallennetaan Steamiin Ohjain Miten Steam näkee ohjaimesi. Tulee voimaan seuraavassa istunnossa. @@ -768,7 +752,6 @@ Automaattinen Päällä Pois - Osoitin PC-näppäimistö Android Toinen näyttö @@ -1111,43 +1094,23 @@ 3 · Tietokoneen ADB (viimeinen keino) Vain jos Langaton virheenkorjaus ei ole käytettävissä. 3 · Tietokoneen ADB (viimeinen keino) - Kun kosketusohjaimet näkyvät istunnossa Väri - Näytön painikkeiden sävy Mukautettu Peittävyys %1$d %% Painikkeiden koko - 100% on vakiokoko - Sauvan napsautus - Kaksoisnapauta ja pidä sauvaa pohjassa saadaksesi L3:n tai R3:n Mukautuvat sauvat Pelin tärinä Ohjaussauvan kuollut alue Steam-painike QAM-painike Näppäimistöpainike - Avaa Steam-valikko Select-painikkeella - View/Select-painikkeen sijaan. Joy-Conit VR-ohjaimina Kun SteamVR on käynnissä, jokainen Joy-Con toimii kätenä. Osoita sillä eteenpäin ja pidä Home- tai Capture-painiketta pohjassa keskittääksesi sen uudelleen. Animaatiot - Sauvat tulevat näkyviin siihen, mihin kosketat, ja piiloutuvat, kun vapautat Asettelu - Mukautetut sijainnit tallennettu - Sovitettu tähän näyttöön Muokkaa Palauta - Painikkeiden kartoitus - Jokainen painike lähettää oman syötteensä - %1$d/%2$d painiketta uudelleenkartoitettu - Määritä - Palauta ohjain - Palauttaa kaikki ohjaimen asetukset ja asettelun - Mitä mikä näytön painike lähettää pelille. Piilotettu poistaa painikkeen. - Näytön painikkeet - Oletukset - Palauta kartoitus Android-sovellukset %1$d sovellusta Käynnistettäviä Android-sovelluksia ei löytynyt. @@ -1267,15 +1230,8 @@ Tatti Kosketuslevy Vaihda tatin ja kosketuslevyn välillä - Lisää Deck-ohjaimia - PANEELIT Sulje toisen näytön ohjaimet Avaa Steam-valikko - Vasen · ylä - Vasen · ala - Oikea · ylä - Oikea · ala - Napsautus Oikea kosketuslevy Vasen kosketuslevy Kosketuslevy @@ -1807,9 +1763,7 @@ D-padi vasen D-padi oikea Piilotettu - Vedä siirtääksesi Kohdistus - Tallenna Ei mitään Näppäimistö + kosketuslevy Pääte @@ -2657,7 +2611,6 @@ Saavutukset ei käytettävissä; tarkista istunnon lokit Saavutus avattu Liitä - Ristiohjain Vasen tatti Oikea tatti Valikko @@ -3702,4 +3655,7 @@ Lisäosia ei löytynyt Yhteyttä palveluun %1$s ei saatu Tälle peliversiolle ei ole tiedostoa + Palauta sijainnit + Näytä nimi + Tätä asettelua ei voitu tuoda diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index f8728055a..7a178546b 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -272,24 +272,8 @@ Tactile Steam (bêta) Les commandes tactiles de Steam s\'activeront à la prochaine session Steam Disposition tactile Steam - Touches à l\'écran - Ajouter une touche - Texte - Taille - Opacité - Touche - Gâchette - Basculer Exporter - Pilule - Arrondi - Cercle - Rectangle - Touche Image - Effacer - Disposition - Enregistrer Déplacez, redimensionnez ou masquez les commandes tactiles de ce jeu ; enregistré dans Steam Manette Comment Steam reconnaît votre manette. Prendra effet à la prochaine session. @@ -770,7 +754,6 @@ Automatique Activé Désactivé - Curseur Clavier PC Android Deuxième écran @@ -1117,43 +1100,23 @@ 3 · ADB sur PC (en dernier recours) À utiliser uniquement si le débogage sans fil n\'est pas disponible. 3 · ADB sur PC (en dernier recours) - Lors de l\'affichage des commandes à l\'écran en session Couleur - Teinte des boutons à l\'écran Personnalisée Opacité %1$d%% Taille des boutons - 100 % correspond à la taille standard - Clic du joystick - Double-cliquez et maintenez le joystick pour déclencher L3 ou R3 Joystick adaptatif Vibration du jeu Zone morte du stick Bouton Steam Bouton QAM Bouton clavier - Appuyez sur Select pour ouvrir le menu Steam - À la place de View/Select. Joy-Con comme manettes VR Pendant que SteamVR tourne, chaque Joy-Con devient une main. Pointez-le devant vous et maintenez Home ou Capture pour le recentrer. Animations - Le joystick apparaîtra là où vous touchez et se masquera une fois relâché Disposition - Position personnalisée enregistrée - Adapté à cet écran Modifier Réinitialiser - Mappage des boutons - Chaque bouton envoie l\'entrée d\'origine correspondante - %1$d/%2$d boutons remappés - Configurer - Réinitialiser la manette - Restaurer tous les paramètres et dispositions de la manette - Définissez l\'entrée que chaque bouton à l\'écran envoie au jeu. « Masquer » supprimera ce bouton. - Boutons à l\'écran - Valeurs par défaut - Réinitialiser le mappage Applications Android %1$d applications Aucune application Android lançable trouvée. @@ -1274,15 +1237,8 @@ Joystick Pavé tactile Basculer entre joystick et pavé tactile - Plus de commandes Deck - PANNEAUX Fermer les commandes du deuxième écran Ouvrir le menu Steam - Gauche · Haut - Gauche · Bas - Droite · Haut - Droite · Bas - Cliquer Pavé tactile droit Pavé tactile gauche Pavé tactile @@ -1831,9 +1787,7 @@ Croix gauche Croix droite Masqué - Faites glisser pour déplacer Aimanter - Enregistrer Aucun Clavier + pavé tactile Terminal @@ -2681,7 +2635,6 @@ Succès indisponibles ; consultez les journaux de session Succès débloqué Coller - Pavé directionnel Stick gauche Stick droit Menu @@ -3741,4 +3694,7 @@ Aucun addon trouvé Impossible de joindre %1$s Aucun fichier pour cette version du jeu + Réinitialiser les positions + Afficher le libellé + Impossible d’importer cette disposition diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index e03cb36d2..987457fb5 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -272,24 +272,8 @@ Steam टच (बीटा) Steam के टच कंट्रोल अगले Steam सेशन से शुरू होंगे Steam टच लेआउट - स्क्रीन कुंजियाँ - कुंजी जोड़ें - टेक्स्ट - आकार - ओपैसिटी - कुंजी - ट्रिगर - टॉगल निर्यात - कैप्सूल - गोल - वृत्त - आयत - कुंजी छवि - हटाएं - लेआउट - सेव करें इस गेम के टच कंट्रोल को घुमाएं, आकार बदलें या छिपाएं; Steam में सेव होता है कंट्रोलर Steam आपके कंट्रोलर को किस तरह पहचानता है। अगले सेशन से लागू होता है। @@ -768,7 +752,6 @@ स्वचालित चालू बंद - कर्सर पीसी कीबोर्ड Android दूसरी स्क्रीन @@ -1111,43 +1094,23 @@ 3 · कंप्यूटर ADB (आख़िरी विकल्प) सिर्फ़ तब जब वायरलेस डीबगिंग उपलब्ध न हो। 3 · कंप्यूटर ADB (आख़िरी विकल्प) - सेशन में टच कंट्रोल कब दिखते हैं रंग - ऑन-स्क्रीन बटन का रंग कस्टम ओपैसिटी %1$d%% बटन का आकार - 100% मानक आकार है - स्टिक क्लिक - L3 या R3 के लिए किसी स्टिक को डबल-टैप करके दबाए रखें एडैप्टिव स्टिक गेम वाइब्रेशन स्टिक का डेड ज़ोन Steam बटन QAM बटन कीबोर्ड बटन - Steam मेनू खोलने के लिए Select दबाएं - View/Select के बदले। Joy-Con को VR कंट्रोलर की तरह SteamVR चलते समय हर Joy-Con एक हाथ बन जाता है। उसे सामने की ओर करें और फिर से केंद्रित करने के लिए Home या Capture दबाए रखें। एनिमेशन - आप जहां टच करें वहां स्टिक दिखते हैं और छोड़ने पर छिप जाते हैं लेआउट - कस्टम पोज़िशन सेव हैं - इस स्क्रीन के हिसाब से फ़िट किया गया संपादित करें रीसेट करें - बटन मैपिंग - हर बटन अपना इनपुट भेजता है - %2$d में से %1$d बटन रीमैप किए गए - कॉन्फ़िगर करें - कंट्रोलर रीसेट करें - सभी कंट्रोलर सेटिंग और लेआउट वापस लाता है - हर ऑन-स्क्रीन बटन गेम को क्या भेजता है। Hidden बटन हटा देता है। - ऑन-स्क्रीन बटन - डिफ़ॉल्ट - मैपिंग रीसेट करें Android ऐप %1$d ऐप लॉन्च करने लायक कोई Android ऐप नहीं मिला। @@ -1267,15 +1230,8 @@ स्टिक ट्रैकपैड स्टिक और ट्रैकपैड के बीच बदलें - और Deck कंट्रोल - पैनल दूसरी स्क्रीन के कंट्रोल बंद करें Steam मेनू खोलें - बायां · ऊपर - बायां · नीचे - दायां · ऊपर - दायां · नीचे - क्लिक दायां ट्रैकपैड बायां ट्रैकपैड ट्रैकपैड @@ -1807,9 +1763,7 @@ D-pad बायां D-pad दायां छिपा हुआ - ले जाने के लिए ड्रैग करें स्नैप - सेव करें कोई नहीं कीबोर्ड + ट्रैकपैड टर्मिनल @@ -2657,7 +2611,6 @@ उपलब्धियां उपलब्ध नहीं; सेशन लॉग देखें उपलब्धि अनलॉक हुई पेस्ट करें - डी-पैड बायां स्टिक दायां स्टिक मेन्यू @@ -3702,4 +3655,7 @@ कोई ऐडऑन नहीं मिला %1$s तक नहीं पहुँच सके गेम के इस संस्करण के लिए कोई फ़ाइल नहीं है + स्थितियाँ रीसेट करें + लेबल दिखाएँ + यह लेआउट इम्पोर्ट नहीं किया जा सका diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 6f43ff687..7130a4afe 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -272,24 +272,8 @@ Steam érintés (béta) A Steam érintésvezérlői a következő Steam-munkamenettől lépnek életbe Steam érintéskiosztás - Képernyőgombok - Gomb hozzáadása - Szöveg - Méret - Átlátszatlanság - Gomb - Ravasz - Váltás Exportálás - Kapszula - Lekerekített - Kör - Téglalap - Billentyű Kép - Törlés - Elrendezés - Mentés Mozgassa, méretezze vagy rejtse el a játék érintésvezérlőit; a Steamben mentve Kontroller Hogyan látja a Steam a kontrollerét. A következő munkamenettől érvényes. @@ -768,7 +752,6 @@ Automatikus Be Ki - Kurzor PC-billentyűzet Android Második képernyő @@ -1111,43 +1094,23 @@ 3 · Számítógépes ADB (végső megoldás) Csak ha a vezeték nélküli hibakeresés nem elérhető. 3 · Számítógépes ADB (végső megoldás) - Mikor jelennek meg az érintésvezérlők egy munkamenetben Szín - A képernyőn megjelenő gombok árnyalata Egyéni Átlátszatlanság %1$d%% Gombméret - A 100% az alapértelmezett méret - Kar kattintása - Koppintson duplán, és tartsa lenyomva a kart az L3 vagy R3 érdekében Adaptív karok Játékvibráció Kar holtzónája Steam gomb QAM gomb Billentyűzet gomb - Nyomja meg a Select gombot a Steam-menü megnyitásához - A View/Select helyett. Joy-Conok VR-kontrollerként Amíg a SteamVR fut, minden Joy-Con egy kéz lesz. Irányítsa előre, és tartsa lenyomva a Home vagy a Capture gombot az újraközpontosításhoz. Animációk - A karok ott jelennek meg, ahol megérinti őket, és elengedéskor elrejtőznek Elrendezés - Egyéni pozíciók mentve - Ehhez a képernyőhöz illesztve Szerkesztés Visszaállítás - Gombkiosztás - Minden gomb a saját bemenetét küldi - %1$d/%2$d gomb átkiosztva - Beállítás - Kontroller visszaállítása - Visszaállítja az összes kontrollerbeállítást és az elrendezést - Mit küld a játéknak az egyes képernyőgombok. A Rejtett eltávolítja a gombot. - Képernyőgombok - Alapértelmezések - Kiosztás visszaállítása Android-alkalmazások %1$d alkalmazás Nem található indítható Android-alkalmazás. @@ -1267,15 +1230,8 @@ Kar Érintőpad Váltás a kar és az érintőpad között - További Deck-vezérlők - PANELEK Második képernyős vezérlők bezárása Steam-menü megnyitása - Bal · felső - Bal · alsó - Jobb · felső - Jobb · alsó - Kattintás Jobb érintőpad Bal érintőpad Érintőpad @@ -1807,9 +1763,7 @@ D-pad balra D-pad jobbra Rejtett - Húzza a mozgatáshoz Rögzítés - Mentés Nincs Billentyűzet + érintőpad Terminál @@ -2657,7 +2611,6 @@ Eredmények nem elérhetők; nézd meg a munkamenet-naplókat Eredmény feloldva Beillesztés - Iránykereszt Bal kar Jobb kar Menü @@ -3702,4 +3655,7 @@ Nem található kiegészítő A(z) %1$s nem érhető el Nincs fájl ehhez a játékverzióhoz + Pozíciók visszaállítása + Címke megjelenítése + Ez az elrendezés nem importálható diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 18b6463c8..9806030f2 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -272,24 +272,8 @@ Sentuhan Steam (beta) Kontrol sentuh Steam dimulai pada sesi Steam berikutnya Tata letak sentuh Steam - Tombol layar - Tambah tombol - Teks - Ukuran - Opasitas - Tombol - Pemicu - Alih Ekspor - Kapsul - Membulat - Lingkaran - Persegi panjang - Tombol Gambar - Hapus - Tata letak - Simpan Pindahkan, ubah ukuran, atau sembunyikan kontrol sentuh gim ini; disimpan ke Steam Pengontrol Cara Steam mendeteksi pengontrol Anda. Berlaku pada sesi berikutnya. @@ -766,7 +750,6 @@ Otomatis Aktif Nonaktif - Kursor Keyboard PC Android Layar kedua @@ -1105,43 +1088,23 @@ 3 · ADB komputer (upaya terakhir) Hanya jika Debugging nirkabel tidak tersedia. 3 · ADB komputer (upaya terakhir) - Saat kontrol sentuh muncul dalam sesi Warna - Warna tombol di layar Khusus Opasitas %1$d%% Ukuran tombol - 100% adalah ukuran standar - Klik stik - Ketuk dua kali dan tahan sebuah stik untuk L3 atau R3 Stik adaptif Getaran gim Zona mati stik Tombol Steam Tombol QAM Tombol keyboard - Tekan Select untuk membuka menu Steam - Bukan View/Select. Joy-Con sebagai pengontrol VR Saat SteamVR berjalan, setiap Joy-Con menjadi tangan. Arahkan ke depan lalu tahan Home atau Capture untuk memusatkannya kembali. Animasi - Stik muncul di tempat Anda menyentuh dan tersembunyi saat dilepas Tata letak - Posisi khusus disimpan - Disesuaikan dengan layar ini Edit Reset - Pemetaan tombol - Setiap tombol mengirim input-nya sendiri - %1$d dari %2$d tombol dipetakan ulang - Konfigurasi - Reset pengontrol - Mengembalikan semua setelan pengontrol dan tata letak - Apa yang dikirim setiap tombol di layar ke gim. Tersembunyi menghapus tombol tersebut. - Tombol di layar - Default - Reset pemetaan Aplikasi Android %1$d aplikasi Tidak ada aplikasi Android yang dapat dijalankan. @@ -1260,15 +1223,8 @@ Stik Trackpad Beralih antara stik dan trackpad - Kontrol Deck lainnya - PANEL Tutup kontrol layar kedua Buka menu Steam - Kiri · atas - Kiri · bawah - Kanan · atas - Kanan · bawah - Klik Trackpad kanan Trackpad kiri Trackpad @@ -1783,9 +1739,7 @@ D-pad kiri D-pad kanan Tersembunyi - Seret untuk memindahkan Ratakan - Simpan Tidak ada Keyboard + trackpad Terminal @@ -2630,7 +2584,6 @@ Pencapaian tidak tersedia; periksa log sesi Pencapaian terbuka Tempel - Tombol arah Stik kiri Stik kanan Menu @@ -3672,4 +3625,7 @@ Tidak ada addon ditemukan Tidak dapat menjangkau %1$s Tidak ada file untuk versi game ini + Atur ulang posisi + Tampilkan label + Tata letak ini tidak dapat diimpor diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index fbb220a61..4495768c1 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -272,24 +272,8 @@ Touch di Steam (beta) I controlli touch di Steam partono dalla prossima sessione Steam Layout touch di Steam - Tasti a schermo - Aggiungi tasto - Testo - Dimensione - Opacità - Tasto - Grilletto - Attiva Esporta - Capsula - Arrotondato - Cerchio - Rettangolo - Tasto Immagine - Cancella - Layout - Salva Sposta, ridimensiona o nascondi i controlli touch di questo gioco; vengono salvati su Steam Controller Come Steam vede il tuo controller. Si applica dalla prossima sessione. @@ -770,7 +754,6 @@ Automatico Attivo Disattivato - Cursore Tastiera PC Android Secondo schermo @@ -1109,43 +1092,23 @@ 3 · ADB da computer (ultima risorsa) Solo se il Debug wireless non è disponibile. 3 · ADB da computer (ultima risorsa) - Quando i controlli touch appaiono in una sessione Colore - Tonalità dei tasti sullo schermo Personalizzato Opacità %1$d%% Dimensione dei tasti - 100% è la dimensione standard - Clic levetta - Tocca due volte e tieni premuta una levetta per L3 o R3 Levette adattive Vibrazione di gioco Zona morta dello stick Tasto Steam Tasto QAM Tasto tastiera - Premi Select per aprire il menu Steam - Invece di View/Select. Joy-Con come controller VR Mentre SteamVR è in esecuzione, ogni Joy-Con diventa una mano. Puntalo in avanti e tieni premuto Home o Capture per ricentrarlo. Animazioni - Le levette appaiono dove tocchi e si nascondono al rilascio Layout - Posizioni personalizzate salvate - Adattato a questo schermo Modifica Ripristina - Mappatura dei tasti - Ogni tasto invia il proprio input - %1$d di %2$d tasti rimappati - Configura - Ripristina controller - Ripristina tutte le impostazioni del controller e il layout - Cosa invia al gioco ciascun tasto sullo schermo. Nascosto rimuove il tasto. - Tasti sullo schermo - Predefiniti - Ripristina mappatura App Android %1$d app Nessuna app Android avviabile trovata. @@ -1266,15 +1229,8 @@ Levetta Trackpad Alterna tra levetta e trackpad - Altri controlli Deck - PANNELLI Chiudi i controlli sul secondo schermo Apri il menu Steam - Sinistra · in alto - Sinistra · in basso - Destra · in alto - Destra · in basso - Clic Trackpad destro Trackpad sinistro Trackpad @@ -1823,9 +1779,7 @@ Croce sinistra Croce destra Nascosto - Trascina per spostare Aggancia - Salva Nessuno Tastiera + trackpad Terminale @@ -2673,7 +2627,6 @@ Obiettivi non disponibili; controlla i log della sessione Obiettivo sbloccato Incolla - Croce direzionale Stick sinistro Stick destro Menu @@ -3741,4 +3694,7 @@ Nessun addon trovato Impossibile raggiungere %1$s Nessun file per questa versione del gioco + Ripristina posizioni + Mostra etichetta + Impossibile importare questo layout diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 0b0d02459..af0a2c76d 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -272,24 +272,8 @@ Steamタッチ(ベータ版) Steamのタッチコントロールは次回のSteamセッションから有効になります Steamタッチのレイアウト - 画面のキー - キーを追加 - テキスト - サイズ - 不透明度 - キー - トリガー - トグル エクスポート - カプセル - 角丸 - 円 - 四角 - キー 画像 - クリア - レイアウト - 保存 このゲームのタッチコントロールを移動、サイズ変更、非表示にします。Steamに保存されます コントローラー Steamがコントローラーを認識する方法。次回のセッションで有効になります。 @@ -766,7 +750,6 @@ 自動 オン オフ - カーソル PCキーボード Android セカンドスクリーン @@ -1105,43 +1088,23 @@ 3 · PC ADB(最終手段) ワイヤレスデバッグが利用できない場合にのみ使用してください。 3 · PC ADB(最終手段) - セッション中に画面コントローラーを表示する場合 色 - 画面ボタンの色調 カスタム 不透明度 %1$d%% ボタンのサイズ - 100% が標準サイズです - スティッククリック - スティックをダブルタップして長押しすると、L3またはR3がトリガーされます アダプティブスティック ゲームの振動 スティックのデッドゾーン Steamボタン QAMボタン キーボードボタン - Selectを押してSteamメニューを開く - View/Selectの代わり。 Joy-ConをVRコントローラーとして使う SteamVRの実行中は各Joy-Conが手になります。前に向けてHOMEボタンかキャプチャーボタンを長押しすると、向きを合わせ直します。 アニメーション - スティックは触れた位置に表示され、離すと非表示になります レイアウト - カスタム位置を保存済み - この画面に最適化済み 編集 リセット - キーマッピング - 各ボタンは対応する元の入力を送信します - %1$d/%2$d 個のボタンを再マッピング済み - 設定 - コントローラーをリセット - すべてのコントローラー設定とレイアウトを復元 - 各画面ボタンがゲームに送信する入力を設定します。「非表示」にするとそのボタンが削除されます。 - 画面ボタン - デフォルト - マッピングをリセット Androidアプリ %1$d 個のアプリ 起動可能なAndroidアプリが見つかりませんでした。 @@ -1260,15 +1223,8 @@ スティック トラックパッド スティックとトラックパッドを切り替え - その他のDeckコントロール - パネル セカンドスクリーンコントロールを閉じる Steamメニューを開く - 左 · 上 - 左 · 下 - 右 · 上 - 右 · 下 - クリック 右トラックパッド 左トラックパッド トラックパッド @@ -1783,9 +1739,7 @@ 十字キー左 十字キー右 非表示 - ドラッグして移動 スナップ - 保存 なし キーボード + トラックパッド ターミナル @@ -2630,7 +2584,6 @@ 実績を利用できません。セッションログを確認してください 実績を解除しました 貼り付け - 十字キー 左スティック 右スティック メニュー @@ -3672,4 +3625,7 @@ アドオンが見つかりません %1$s に接続できませんでした このゲームバージョン用のファイルがありません + 位置をリセット + ラベルを表示 + このレイアウトをインポートできませんでした diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index ec87fdf08..d076c7d0e 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -272,24 +272,8 @@ Steam 터치(베타) Steam 터치 컨트롤은 다음 Steam 세션부터 적용됩니다 Steam 터치 레이아웃 - 화면 키 - 키 추가 - 텍스트 - 크기 - 불투명도 - 키 - 트리거 - 토글 내보내기 - 캡슐 - 둥근 모서리 - 원 - 사각형 - 키 이미지 - 지우기 - 레이아웃 - 저장 이 게임의 터치 컨트롤을 옮기거나 크기를 바꾸거나 숨깁니다. Steam에 저장됩니다 컨트롤러 Steam이 컨트롤러를 인식하는 방식입니다. 다음 세션에 적용됩니다. @@ -766,7 +750,6 @@ 자동 켜기 끄기 - 커서 PC 키보드 Android 보조 화면 @@ -1105,43 +1088,23 @@ 3 · PC ADB(최후의 수단) 무선 디버깅을 사용할 수 없을 때만 사용하세요. 3 · PC ADB(최후의 수단) - 세션에서 화면 컨트롤을 표시할 때 색상 - 화면 버튼의 색조 사용자 정의 불투명도 %1$d%% 버튼 크기 - 100%가 표준 크기입니다 - 스틱 클릭 - 스틱을 두 번 탭한 후 길게 누르면 L3 또는 R3이 입력됩니다 적응형 스틱 게임 진동 스틱 데드존 Steam 버튼 QAM 버튼 키보드 버튼 - Select를 눌러 Steam 메뉴 열기 - View/Select 대신. Joy-Con을 VR 컨트롤러로 사용 SteamVR이 실행되는 동안 각 Joy-Con이 손이 됩니다. 앞을 향하게 한 채 HOME 또는 캡처 버튼을 길게 누르면 다시 가운데로 맞춥니다. 애니메이션 - 스틱이 터치한 위치에 나타나며 손을 떼면 숨겨집니다 레이아웃 - 사용자 정의 위치 저장됨 - 이 화면에 맞게 조정됨 편집 초기화 - 키 매핑 - 각 버튼은 해당하는 원본 입력을 전송합니다 - %1$d/%2$d개 버튼 다시 매핑됨 - 구성 - 컨트롤러 초기화 - 모든 컨트롤러 설정 및 레이아웃 복원 - 각 화면 버튼이 게임에 전송할 입력을 설정합니다. \"숨기기\"를 선택하면 해당 버튼이 제거됩니다. - 화면 버튼 - 기본값 - 매핑 초기화 Android 앱 %1$d개 앱 실행 가능한 Android 앱을 찾을 수 없습니다. @@ -1260,15 +1223,8 @@ 스틱 터치패드 스틱과 터치패드 간 전환 - Deck 컨트롤 더 보기 - 패널 보조 화면 컨트롤 닫기 Steam 메뉴 열기 - 왼쪽 · 위 - 왼쪽 · 아래 - 오른쪽 · 위 - 오른쪽 · 아래 - 클릭 오른쪽 터치패드 왼쪽 터치패드 터치패드 @@ -1783,9 +1739,7 @@ 방향 패드 왼쪽 방향 패드 오른쪽 숨김 - 드래그하여 이동 맞춤 - 저장 없음 키보드 + 터치패드 터미널 @@ -2630,7 +2584,6 @@ 도전 과제를 사용할 수 없습니다. 세션 로그를 확인하세요 도전 과제 잠금 해제됨 붙여넣기 - 방향패드 왼쪽 스틱 오른쪽 스틱 메뉴 @@ -3672,4 +3625,7 @@ 애드온을 찾을 수 없음 %1$s에 연결할 수 없습니다 이 게임 버전용 파일이 없습니다 + 위치 초기화 + 라벨 표시 + 이 레이아웃을 가져올 수 없습니다 diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index c40b293a8..30f1be10b 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -272,24 +272,8 @@ Sentuhan Steam (beta) Kawalan sentuh Steam bermula pada sesi Steam seterusnya Susun atur sentuh Steam - Kekunci skrin - Tambah kekunci - Teks - Saiz - Kelegapan - Kekunci - Pencetus - Togol Eksport - Kapsul - Bulat - Bulatan - Segi empat - Kekunci Imej - Kosongkan - Susun atur - Simpan Gerakkan, ubah saiz atau sembunyikan kawalan sentuh permainan ini; disimpan ke Steam Pengawal Cara Steam mengesan pengawal anda. Berkuat kuasa pada sesi seterusnya. @@ -766,7 +750,6 @@ Automatik Hidup Mati - Kursor Papan kekunci PC Android Skrin kedua @@ -1105,43 +1088,23 @@ 3 · ADB komputer (pilihan akhir) Hanya jika Debugging Wayarles tidak tersedia. 3 · ADB komputer (pilihan akhir) - Bila kawalan sentuh muncul dalam sesi Warna - Rona butang pada skrin Tersuai Kelegapan %1$d%% Saiz butang - 100% ialah saiz standard - Klik stik - Ketik dua kali dan tahan stik untuk L3 atau R3 Stik adaptif Getaran permainan Zon mati kayu bedik Butang Steam Butang QAM Butang papan kekunci - Tekan Select untuk membuka menu Steam - Sebagai ganti View/Select. Joy-Con sebagai pengawal VR Semasa SteamVR berjalan, setiap Joy-Con menjadi tangan. Halakan ke hadapan dan tahan Home atau Capture untuk memusatkannya semula. Animasi - Stik muncul di tempat anda sentuh dan tersembunyi apabila dilepaskan Susun atur - Kedudukan tersuai disimpan - Disesuaikan dengan skrin ini Sunting Tetapkan semula - Pemetaan butang - Setiap butang menghantar inputnya sendiri - %1$d daripada %2$d butang dipetakan semula - Konfigurasikan - Tetapkan semula pengawal - Memulihkan semua tetapan pengawal dan susun atur - Apa yang setiap butang pada skrin hantar kepada permainan. Tersembunyi mengalih keluar butang itu. - Butang pada skrin - Lalai - Tetapkan semula pemetaan Apl Android %1$d apl Tiada apl Android boleh dilancarkan dijumpai. @@ -1260,15 +1223,8 @@ Stik Trackpad Tukar antara stik dan trackpad - Lagi kawalan Deck - PANEL Tutup kawalan skrin kedua Buka menu Steam - Kiri · atas - Kiri · bawah - Kanan · atas - Kanan · bawah - Klik Trackpad kanan Trackpad kiri Trackpad @@ -1783,9 +1739,7 @@ D-pad kiri D-pad kanan Tersembunyi - Seret untuk alih Lekat - Simpan Tiada Papan kekunci + trackpad Terminal @@ -2630,7 +2584,6 @@ Pencapaian tidak tersedia; semak log sesi Pencapaian dibuka kunci Tampal - Pad arah Kayu bedik kiri Kayu bedik kanan Menu @@ -3672,4 +3625,7 @@ Tiada addon ditemui Tidak dapat mencapai %1$s Tiada fail untuk versi permainan ini + Tetapkan semula kedudukan + Tunjukkan label + Susun atur ini tidak dapat diimport diff --git a/app/src/main/res/values-nb/strings.xml b/app/src/main/res/values-nb/strings.xml index bdb3934dc..52561aeea 100644 --- a/app/src/main/res/values-nb/strings.xml +++ b/app/src/main/res/values-nb/strings.xml @@ -272,24 +272,8 @@ Steam-berøring (beta) Steams berøringskontroller starter med neste Steam-sesjon Steam-berøringslayout - Skjermtaster - Legg til tast - Tekst - Størrelse - Opasitet - Tast - Utløser - Veksle Eksporter - Kapsel - Avrundet - Sirkel - Rektangel - Tast Bilde - Fjern - Oppsett - Lagre Flytt, endre størrelse på eller skjul dette spillets berøringskontroller; lagres til Steam Kontroller Hvordan Steam ser kontrolleren din. Gjelder fra neste sesjon. @@ -768,7 +752,6 @@ Automatisk På Av - Peker PC-tastatur Android Andre skjerm @@ -1111,43 +1094,23 @@ 3 · ADB fra datamaskin (siste utvei) Bare hvis trådløs feilsøking ikke er tilgjengelig. 3 · ADB fra datamaskin (siste utvei) - Når skjermkontrollene vises i en sesjon Farge - Fargetone på skjermknappene Egendefinert Opasitet %1$d%% Knappestørrelse - 100% er standardstørrelsen - Stikke-klikk - Dobbelttrykk og hold en stikke for L3 eller R3 Adaptive stikker Spillvibrasjon Dødsone for styrespak Steam-knapp QAM-knapp Tastatur-knapp - Trykk på Select for å åpne Steam-menyen - I stedet for View/Select. Joy-Con som VR-kontrollere Mens SteamVR kjører, blir hver Joy-Con en hånd. Pek den fremover og hold inne Home eller Capture for å sentrere den på nytt. Animasjoner - Stikker vises der du trykker og skjules når du slipper Oppsett - Egendefinerte posisjoner lagret - Tilpasset denne skjermen Rediger Nullstill - Knappetilordning - Hver knapp sender sin egen inndata - %1$d av %2$d knapper omtilordnet - Konfigurer - Nullstill kontroller - Gjenoppretter alle kontrollerinnstillinger og oppsettet - Hva hver skjermknapp sender til spillet. Skjult fjerner knappen. - Skjermknapper - Standardverdier - Nullstill tilordning Android-apper %1$d apper Fant ingen startbare Android-apper. @@ -1267,15 +1230,8 @@ Stikke Styreflate Bytt mellom stikke og styreflate - Flere Deck-kontroller - PANELER Lukk kontroller for andre skjerm Åpne Steam-menyen - Venstre · øvre - Venstre · nedre - Høyre · øvre - Høyre · nedre - Klikk Høyre styreflate Venstre styreflate Styreflate @@ -1807,9 +1763,7 @@ D-pad venstre D-pad høyre Skjult - Dra for å flytte Fest - Lagre Ingen Tastatur + styreflate Terminal @@ -2657,7 +2611,6 @@ Prestasjoner ikke tilgjengelig; sjekk sesjonsloggene Prestasjon låst opp Lim inn - Styrekors Venstre stikke Høyre stikke Meny @@ -3702,4 +3655,7 @@ Fant ingen tillegg Fikk ikke kontakt med %1$s Ingen fil for denne spillversjonen + Tilbakestill plasseringer + Vis etikett + Kunne ikke importere dette oppsettet diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index a0f76cc25..1d72169e6 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -272,24 +272,8 @@ Steam-aanraakbediening (bèta) De aanraakbediening van Steam start bij de volgende Steam-sessie Steam-aanraakindeling - Schermtoetsen - Toets toevoegen - Tekst - Grootte - Transparantie - Toets - Trigger - Schakelen Exporteren - Capsule - Afgerond - Cirkel - Rechthoek - Toets Afbeelding - Wissen - Indeling - Opslaan Verplaats, schaal of verberg de aanraakbediening van deze game; opgeslagen in Steam Controller Hoe Steam je controller ziet. Geldt vanaf de volgende sessie. @@ -768,7 +752,6 @@ Automatisch Aan Uit - Cursor Pc-toetsenbord Android Tweede scherm @@ -1111,43 +1094,23 @@ 3 · Computer-ADB (laatste redmiddel) Alleen als Draadloos debuggen niet beschikbaar is. 3 · Computer-ADB (laatste redmiddel) - Wanneer de aanraakbediening in een sessie verschijnt Kleur - Tint van de knoppen op het scherm Aangepast Transparantie %1$d%% Knopgrootte - 100% is de standaardgrootte - Stickklik - Dubbeltik en houd een stick ingedrukt voor L3 of R3 Adaptieve sticks Trillen in games Dode zone van stick Steam-knop QAM-knop Toetsenbordknop - Druk op Select om het Steam-menu te openen - In plaats van View/Select. Joy-Cons als VR-controllers Zolang SteamVR draait, wordt elke Joy-Con een hand. Richt hem naar voren en houd Home of Capture ingedrukt om hem opnieuw te centreren. Animaties - Sticks verschijnen waar je aanraakt en verdwijnen als je loslaat Indeling - Aangepaste posities opgeslagen - Aangepast aan dit scherm Bewerken Resetten - Knoppenindeling - Elke knop stuurt zijn eigen invoer - %1$d van %2$d knoppen opnieuw toegewezen - Configureren - Controller resetten - Herstelt alle controllerinstellingen en de indeling - Wat elke schermknop naar de game stuurt. Verborgen verwijdert de knop. - Schermknoppen - Standaardinstellingen - Indeling resetten Android-apps %1$d apps Geen startbare Android-apps gevonden. @@ -1267,15 +1230,8 @@ Stick Trackpad Wisselen tussen stick en trackpad - Meer Deck-bediening - PANELEN Bediening op tweede scherm sluiten Steam-menu openen - Links · boven - Links · onder - Rechts · boven - Rechts · onder - Klik Rechtertrackpad Linkertrackpad Trackpad @@ -1807,9 +1763,7 @@ D-pad links D-pad rechts Verborgen - Sleep om te verplaatsen Uitlijnen - Opslaan Geen Toetsenbord + trackpad Terminal @@ -2657,7 +2611,6 @@ Prestaties niet beschikbaar; controleer sessielogs Prestatie ontgrendeld Plakken - Richtingstoetsen Linkerstick Rechterstick Menu @@ -3702,4 +3655,7 @@ Geen add-ons gevonden Kan %1$s niet bereiken Geen bestand voor deze gameversie + Posities herstellen + Label tonen + Deze indeling kan niet worden geïmporteerd diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 569df0344..ef5e9a296 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -272,24 +272,8 @@ Dotyk Steam (beta) Sterowanie dotykowe Steam zacznie działać od następnej sesji Steam Układ dotykowy Steam - Klawisze ekranu - Dodaj klawisz - Tekst - Rozmiar - Przezroczystość - Klawisz - Spust - Przełącznik Eksportuj - Kapsułka - Zaokrąglony - Koło - Prostokąt - Klawisz Obraz - Wyczyść - Układ - Zapisz Przesuwaj, zmieniaj rozmiar lub ukrywaj sterowanie dotykowe tej gry; zapisywane w Steam Kontroler Jak Steam widzi Twój kontroler. Działa od następnej sesji. @@ -772,7 +756,6 @@ Automatycznie Włączone Wyłączone - Kursor Klawiatura PC Android Drugi ekran @@ -1123,43 +1106,23 @@ 3 · Komputer z ADB (ostatnia opcja) Tylko jeśli debugowanie bezprzewodowe jest niedostępne. 3 · Komputer z ADB (ostatnia opcja) - Gdy w sesji pojawiają się sterowanie dotykowe Kolor - Odcień przycisków na ekranie Własny Przezroczystość %1$d%% Rozmiar przycisków - 100% to rozmiar standardowy - Kliknięcie gałki - Dotknij dwukrotnie i przytrzymaj gałkę, aby uzyskać L3 lub R3 Adaptacyjne gałki Wibracje gry Martwa strefa drążka Przycisk Steam Przycisk QAM Przycisk klawiatury - Naciśnij Select, aby otworzyć menu Steam - Zamiast View/Select. Joy-Cony jako kontrolery VR Gdy działa SteamVR, każdy Joy-Con staje się dłonią. Skieruj go przed siebie i przytrzymaj Home lub Capture, aby go wyśrodkować. Animacje - Gałki pojawiają się tam, gdzie dotykasz, i chowają się po puszczeniu Układ - Zapisano własne pozycje - Dopasowany do tego ekranu Edytuj Resetuj - Mapowanie przycisków - Każdy przycisk wysyła własny sygnał - Zmapowano %1$d z %2$d przycisków - Skonfiguruj - Resetuj kontroler - Przywraca wszystkie ustawienia kontrolera i układ - Co wysyła do gry każdy przycisk na ekranie. Ukryty usuwa przycisk. - Przyciski na ekranie - Domyślne - Resetuj mapowanie Aplikacje Android %1$d aplikacji Nie znaleziono żadnych aplikacji Android do uruchomienia. @@ -1281,15 +1244,8 @@ Gałka Trackpad Przełącz między gałką a trackpadem - Więcej sterowania Deck - PANELE Zamknij sterowanie na drugim ekranie Otwórz menu Steam - Lewy · górny - Lewy · dolny - Prawy · górny - Prawy · dolny - Kliknięcie Prawy trackpad Lewy trackpad Trackpad @@ -1855,9 +1811,7 @@ Krzyżak w lewo Krzyżak w prawo Ukryty - Przeciągnij, aby przenieść Przyciąganie - Zapisz Brak Klawiatura + trackpad Terminal @@ -2711,7 +2665,6 @@ Osiągnięcia niedostępne; sprawdź dzienniki sesji Osiągnięcie odblokowane Wklej - Krzyżak Lewy drążek Prawy drążek Menu @@ -3780,4 +3733,7 @@ Nie znaleziono dodatków Nie można połączyć się z %1$s Brak pliku dla tej wersji gry + Resetuj położenie + Pokaż etykietę + Nie można zaimportować tego układu diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 898a0b065..7b1133cc2 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -272,24 +272,8 @@ Controlos táteis do Steam (beta) Os controlos táteis do Steam começam na próxima sessão do Steam Esquema tátil do Steam - Teclas no ecrã - Adicionar tecla - Texto - Tamanho - Opacidade - Tecla - Gatilho - Alternar Exportar - Cápsula - Arredondado - Círculo - Retângulo - Tecla Imagem - Limpar - Esquema - Guardar Mova, redimensione ou oculte os controlos táteis deste jogo; é guardado no Steam Comando Como o Steam vê o seu comando. Aplica-se na próxima sessão. @@ -770,7 +754,6 @@ Automático Ativado Desativado - Cursor Teclado de PC Android Segundo ecrã @@ -1109,43 +1092,23 @@ 3 · ADB num computador (último recurso) Apenas se a Depuração sem fios não estiver disponível. 3 · ADB num computador (último recurso) - Quando os controlos táteis aparecem numa sessão Cor - Tonalidade dos botões no ecrã Personalizada Opacidade %1$d%% Tamanho dos botões - O tamanho padrão é 100% - Clique do analógico - Toque duas vezes num analógico e mantenha para L3 ou R3 Analógicos adaptáveis Vibração do jogo Zona morta do manípulo Botão Steam Botão QAM Botão do teclado - Premir Select para abrir o menu do Steam - Em vez de View/Select. Joy-Con como comandos de RV Enquanto o SteamVR está a correr, cada Joy-Con torna-se uma mão. Aponte-o para a frente e mantenha Home ou Capture premido para o recentrar. Animações - Os analógicos aparecem onde tocar e ocultam-se ao soltar Esquema - Posições personalizadas guardadas - Ajustado a este ecrã Editar Repor - Mapeamento de botões - Cada botão envia a sua própria entrada - %1$d de %2$d botões remapeados - Configurar - Repor comando - Restaura todas as definições do comando e o esquema - O que cada botão no ecrã envia para o jogo. Oculto remove o botão. - Botões no ecrã - Valores padrão - Repor mapeamento Apps Android %1$d apps Não foram encontradas apps Android para iniciar. @@ -1266,15 +1229,8 @@ Joystick Trackpad Alternar entre joystick e trackpad - Mais controlos do Deck - PAINÉIS Fechar controlos do segundo ecrã Abrir menu Steam - Esquerda · superior - Esquerda · inferior - Direita · superior - Direita · inferior - Clique Trackpad direito Trackpad esquerdo Trackpad @@ -1823,9 +1779,7 @@ Direcional para a esquerda Direcional para a direita Oculto - Arraste para mover Encaixar - Guardar Nenhum Teclado + trackpad Terminal @@ -2673,7 +2627,6 @@ Conquistas indisponíveis; verifica os registos da sessão Conquista desbloqueada Colar - Direcional Stick esquerdo Stick direito Menu @@ -3741,4 +3694,7 @@ Nenhum addon encontrado Não foi possível contactar %1$s Não há ficheiro para esta versão do jogo + Repor posições + Mostrar etiqueta + Não foi possível importar este esquema diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index d4b6b29f8..85c48fa7c 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -272,24 +272,8 @@ Toque da Steam (beta) Os controles de toque da Steam começam na próxima sessão da Steam Layout de toque da Steam - Teclas na tela - Adicionar tecla - Texto - Tamanho - Opacidade - Tecla - Gatilho - Alternar Exportar - Cápsula - Arredondado - Círculo - Retângulo - Tecla Imagem - Limpar - Layout - Salvar Mova, redimensione ou oculte os controles de toque deste jogo; salvo na Steam Controle Como a Steam vê seu controle. Vale a partir da próxima sessão. @@ -770,7 +754,6 @@ Automático Ativado Desativado - Cursor Teclado de PC Android Segunda tela @@ -1109,43 +1092,23 @@ 3 · ADB pelo computador (último recurso) Só se a Depuração por Wi-Fi não estiver disponível. 3 · ADB pelo computador (último recurso) - Quando os controles de toque aparecem em uma sessão Cor - Tom dos botões na tela Personalizada Opacidade %1$d%% Tamanho dos botões - 100% é o tamanho padrão - Clique do analógico - Toque duas vezes e segure um analógico para L3 ou R3 Analógicos adaptáveis Vibração do jogo Zona morta do analógico Botão Steam Botão QAM Botão do teclado - Pressione Select para abrir o menu Steam - Em vez de View/Select. Joy-Cons como controles de RV Enquanto o SteamVR está rodando, cada Joy-Con vira uma mão. Aponte-o para a frente e segure Home ou Capture para recentralizá-lo. Animações - Os analógicos aparecem onde você toca e somem ao soltar Layout - Posições personalizadas salvas - Ajustado a esta tela Editar Redefinir - Mapeamento de botões - Cada botão envia o próprio comando - %1$d de %2$d botões remapeados - Configurar - Redefinir controle - Restaura todas as configurações do controle e o layout - O que cada botão na tela envia ao jogo. Oculto remove o botão. - Botões na tela - Padrões - Redefinir mapeamento Apps Android %1$d apps Nenhum app Android que possa ser aberto foi encontrado. @@ -1266,15 +1229,8 @@ Analógico Trackpad Alternar entre analógico e trackpad - Mais controles do Deck - PAINÉIS Fechar controles da segunda tela Abrir o menu Steam - Esquerdo · superior - Esquerdo · inferior - Direito · superior - Direito · inferior - Clique Trackpad direito Trackpad esquerdo Trackpad @@ -1823,9 +1779,7 @@ Direcional para a esquerda Direcional para a direita Oculto - Arraste para mover Encaixar - Salvar Nenhum Teclado + trackpad Terminal @@ -2673,7 +2627,6 @@ Conquistas indisponíveis; veja os registros da sessão Conquista desbloqueada Colar - Direcional Analógico esquerdo Analógico direito Menu @@ -3741,4 +3694,7 @@ Nenhum addon encontrado Não foi possível acessar %1$s Não há arquivo para esta versão do jogo + Redefinir posições + Mostrar rótulo + Não foi possível importar este layout diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index a27d19616..21d868573 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -272,24 +272,8 @@ Atingere Steam (beta) Comenzile tactile Steam pornesc odată cu următoarea sesiune Steam Aranjament tactil Steam - Taste pe ecran - Adaugă tastă - Text - Dimensiune - Opacitate - Tastă - Trăgaci - Comutare Exportă - Capsulă - Rotunjit - Cerc - Dreptunghi - Tastă Imagine - Șterge - Aranjament - Salvează Mută, redimensionează sau ascunde comenzile tactile ale acestui joc; salvate în Steam Controller Cum vede Steam controllerul tău. Se aplică la sesiunea următoare. @@ -770,7 +754,6 @@ Automat Pornit Oprit - Cursor Tastatură de PC Android Al doilea ecran @@ -1117,43 +1100,23 @@ 3 · ADB pe computer (ultima soluție) Doar dacă Depanarea wireless nu este disponibilă. 3 · ADB pe computer (ultima soluție) - Când apar comenzile tactile într-o sesiune Culoare - Tenta butoanelor de pe ecran Personalizat Opacitate %1$d%% Dimensiunea butoanelor - 100% este dimensiunea standard - Apăsarea manșei - Apasă de două ori și ține apăsată o manșă pentru L3 sau R3 Manșe adaptive Vibrații de joc Zonă moartă a manetei Butonul Steam Butonul QAM Butonul tastaturii - Apasă Select pentru a deschide meniul Steam - În loc de View/Select. Joy-Con ca controllere VR Cât timp rulează SteamVR, fiecare Joy-Con devine o mână. Îndreaptă-l înainte și ține apăsat Home sau Capture pentru a-l recentra. Animații - Manșele apar unde atingi ecranul și se ascund la eliberare Aranjament - Poziții personalizate salvate - Adaptat pentru acest ecran Editează Resetează - Maparea butoanelor - Fiecare buton trimite propria intrare - %1$d din %2$d butoane remapate - Configurează - Resetează controllerul - Restaurează toate setările controllerului și aranjamentul - Ce trimite jocului fiecare buton de pe ecran. Ascuns elimină butonul. - Butoane pe ecran - Valori implicite - Resetează maparea Aplicații Android %1$d aplicații Nu s-a găsit nicio aplicație Android care poate fi lansată. @@ -1274,15 +1237,8 @@ Manșă Trackpad Comută între manșă și trackpad - Mai multe comenzi Deck - PANOURI Închide comenzile celui de-al doilea ecran Deschide meniul Steam - Stânga · sus - Stânga · jos - Dreapta · sus - Dreapta · jos - Clic Trackpad-ul drept Trackpad-ul stâng Trackpad @@ -1831,9 +1787,7 @@ D-pad stânga D-pad dreapta Ascuns - Trage pentru a mișca Aliniere - Salvează Niciunul Tastatură + trackpad Terminal @@ -2684,7 +2638,6 @@ Realizări indisponibile; verifică jurnalele sesiunii Realizare deblocată Inserează - Cruce directională Stick stânga Stick dreapta Meniu @@ -3741,4 +3694,7 @@ Nu s-au găsit suplimente Nu s-a putut accesa %1$s Nu există fișier pentru această versiune a jocului + Resetează pozițiile + Afișează eticheta + Acest aranjament nu a putut fi importat diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 97683ba73..25414fa12 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -272,24 +272,8 @@ Сенсорное управление Steam (бета) Сенсорное управление Steam включится со следующей сессии Steam Сенсорная раскладка Steam - Экранные клавиши - Добавить клавишу - Текст - Размер - Непрозрачность - Клавиша - Триггер - Переключение Экспорт - Капсула - Скругление - Круг - Прямоугольник - Клавиша Изображение - Очистить - Раскладка - Сохранить Перемещайте, меняйте размер или скрывайте сенсорные кнопки этой игры; сохраняется в Steam Контроллер Как Steam распознает ваш контроллер. Вступит в силу в следующей сессии. @@ -791,7 +775,6 @@ Автоматически Включено Выключено - Курсор Клавиатура ПК Android Второй экран @@ -1160,43 +1143,23 @@ 3 · ADB на ПК (крайняя мера) Используйте только если отладка по Wi-Fi недоступна. 3 · ADB на ПК (крайняя мера) - Когда показывать экранные элементы управления в сессии Цвет - Оттенок экранных кнопок Пользовательский Непрозрачность %1$d%% Размер кнопок - 100% — стандартный размер - Нажатие стика - Дважды нажмите и удерживайте стик для L3 или R3 Адаптивные стики Вибрация в играх Мёртвая зона стика Кнопка Steam Кнопка QAM Кнопка клавиатуры - Нажмите Select, чтобы открыть меню Steam - Вместо View/Select. Joy-Con как VR-контроллеры Пока работает SteamVR, каждый Joy-Con становится рукой. Направьте его вперёд и удерживайте Home или Capture, чтобы снова его отцентрировать. Анимации - Стики появляются в месте касания и исчезают после отпускания Раскладка - Сохранено свое расположение - Адаптировано под этот экран Изменить Сбросить - Назначение кнопок - Каждая кнопка отправляет свой ввод - Переназначено кнопок: %1$d из %2$d - Настроить - Сбросить контроллер - Восстанавливает все настройки контроллера и раскладку - Что каждая экранная кнопка отправляет в игру. Значение «Скрыто» убирает кнопку. - Экранные кнопки - По умолчанию - Сбросить назначение Приложения Android Приложений: %1$d Не найдено приложений Android для запуска. @@ -1318,15 +1281,8 @@ Стик Тачпад Переключение между стиком и тачпадом - Другие элементы управления Deck - ПАНЕЛИ Закрыть управление вторым экраном Открыть меню Steam - Слева · сверху - Слева · снизу - Справа · сверху - Справа · снизу - Клик Правый тачпад Левый тачпад Тачпад @@ -1901,9 +1857,7 @@ Крестовина влево Крестовина вправо Скрыто - Перетащите для перемещения Привязка - Сохранить Ничего Клавиатура + тачпад Терминал @@ -2791,7 +2745,6 @@ Достижения недоступны; проверьте журналы сессии Достижение открыто Вставить - Крестовина Левый стик Правый стик Меню @@ -3863,4 +3816,7 @@ Аддоны не найдены Не удалось подключиться к %1$s Нет файла для этой версии игры + Сбросить положение + Показывать подпись + Не удалось импортировать эту раскладку diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 3c3e8b9dd..00631845b 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -272,24 +272,8 @@ Steam-pekstyrning (beta) Steams pekstyrning startar med nästa Steam-session Steams peklayout - Skärmtangenter - Lägg till tangent - Text - Storlek - Opacitet - Tangent - Avtryckare - Växla Exportera - Kapsel - Rundad - Cirkel - Rektangel - Tangent Bild - Rensa - Layout - Spara Flytta, ändra storlek på eller dölj spelets pekkontroller; sparas i Steam Handkontroll Hur Steam ser din handkontroll. Gäller från nästa session. @@ -768,7 +752,6 @@ Automatiskt På Av - Markör PC-tangentbord Android Andra skärmen @@ -1111,43 +1094,23 @@ 3 · Dator-ADB (sista utväg) Bara om Trådlös felsökning inte är tillgänglig. 3 · Dator-ADB (sista utväg) - När skärmkontrollerna visas i en session Färg - Nyans på skärmknapparna Anpassad Opacitet %1$d %% Knappstorlek - 100 % är standardstorleken - Spakklick - Dubbeltryck och håll en spak för L3 eller R3 Adaptiva spakar Spelvibration Styrspakens dödzon Steam-knappen QAM-knappen Tangentbordsknappen - Tryck på Select för att öppna Steam-menyn - I stället för View/Select. Joy-Con som VR-handkontroller Medan SteamVR körs blir varje Joy-Con en hand. Rikta den framåt och håll in Home eller Capture för att centrera den igen. Animationer - Spakarna visas där du trycker och döljs när du släpper Layout - Anpassade positioner sparade - Anpassad till den här skärmen Redigera Återställ - Knappmappning - Varje knapp skickar sin egen inmatning - %1$d av %2$d knappar ommappade - Konfigurera - Återställ handkontroll - Återställer alla handkontrollsinställningar och layouten - Vad varje skärmknapp skickar till spelet. Dold tar bort knappen. - Skärmknappar - Standardvärden - Återställ mappning Android-appar %1$d appar Inga startbara Android-appar hittades. @@ -1267,15 +1230,8 @@ Styrspak Styrplatta Växla mellan styrspak och styrplatta - Fler Deck-kontroller - PANELER Stäng kontrollerna på andra skärmen Öppna Steam-menyn - Vänster · övre - Vänster · nedre - Höger · övre - Höger · nedre - Klick Höger styrplatta Vänster styrplatta Styrplatta @@ -1807,9 +1763,7 @@ D-pad vänster D-pad höger Dold - Dra för att flytta Fäst - Spara Inget Tangentbord + styrplatta Terminal @@ -2657,7 +2611,6 @@ Prestationer inte tillgängliga; kontrollera sessionsloggarna Prestation upplåst Klistra in - Styrkors Vänster styrspak Höger styrspak Meny @@ -3702,4 +3655,7 @@ Inga tillägg hittades Kunde inte nå %1$s Ingen fil för den här spelversionen + Återställ positioner + Visa etikett + Det gick inte att importera layouten diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index af54a2d03..d5aa598d6 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -272,24 +272,8 @@ การสัมผัสของ Steam (ทดลอง) ปุ่มสัมผัสของ Steam จะเริ่มใช้งานในเซสชัน Steam ถัดไป รูปแบบปุ่มสัมผัสของ Steam - ปุ่มบนหน้าจอ - เพิ่มปุ่ม - ข้อความ - ขนาด - ความโปร่งใส - ปุ่ม - ไก - สลับ ส่งออก - แคปซูล - มน - วงกลม - สี่เหลี่ยม - ปุ่ม รูปภาพ - ล้าง - การจัดวาง - บันทึก ย้าย ปรับขนาด หรือซ่อนปุ่มสัมผัสของเกมนี้ บันทึกไปยัง Steam คอนโทรลเลอร์ วิธีที่ Steam มองเห็นคอนโทรลเลอร์ของคุณ จะมีผลในเซสชันถัดไป @@ -766,7 +750,6 @@ อัตโนมัติ เปิด ปิด - เคอร์เซอร์ แป้นพิมพ์พีซี Android หน้าจอที่สอง @@ -1105,43 +1088,23 @@ 3 · ADB จากคอมพิวเตอร์ (ทางเลือกสุดท้าย) ใช้เฉพาะเมื่อ Wireless debugging ใช้งานไม่ได้ 3 · ADB จากคอมพิวเตอร์ (ทางเลือกสุดท้าย) - เมื่อปุ่มสัมผัสจะปรากฏในเซสชัน สี - สีของปุ่มบนหน้าจอ กำหนดเอง ความโปร่งใส %1$d%% ขนาดปุ่ม - 100% คือขนาดมาตรฐาน - การกดสติ๊ก - แตะสองครั้งแล้วกดสติ๊กค้างไว้สำหรับ L3 หรือ R3 สติ๊กแบบปรับอัตโนมัติ การสั่นของเกม เดดโซนของแอนะล็อกสติ๊ก ปุ่ม Steam ปุ่ม QAM ปุ่มแป้นพิมพ์ - กด Select เพื่อเปิดเมนู Steam - แทน View/Select ใช้ Joy-Con เป็นคอนโทรลเลอร์ VR ขณะที่ SteamVR ทำงาน Joy-Con แต่ละตัวจะกลายเป็นมือ ชี้ไปข้างหน้าแล้วกด Home หรือ Capture ค้างไว้เพื่อจัดให้อยู่ตรงกลางอีกครั้ง ภาพเคลื่อนไหว - สติ๊กจะปรากฏที่ตำแหน่งที่คุณแตะและซ่อนเมื่อปล่อย การจัดวาง - บันทึกตำแหน่งกำหนดเองไว้แล้ว - ปรับให้พอดีกับหน้าจอนี้ แก้ไข รีเซ็ต - การกำหนดปุ่ม - ทุกปุ่มส่งอินพุตของตัวเอง - กำหนดใหม่ %1$d จาก %2$d ปุ่ม - ตั้งค่า - รีเซ็ตคอนโทรลเลอร์ - คืนค่าการตั้งค่าคอนโทรลเลอร์ทั้งหมดและการจัดวาง - สิ่งที่ปุ่มบนหน้าจอแต่ละปุ่มส่งไปยังเกม ซ่อนจะเอาปุ่มนั้นออก - ปุ่มบนหน้าจอ - ค่าเริ่มต้น - รีเซ็ตการกำหนดปุ่ม แอป Android %1$d แอป ไม่พบแอป Android ที่เปิดได้ @@ -1260,15 +1223,8 @@ สติ๊ก แทร็กแพด สลับระหว่างสติ๊กและแทร็กแพด - การควบคุม Deck เพิ่มเติม - แผง ปิดการควบคุมหน้าจอที่สอง เปิดเมนู Steam - ซ้าย · บน - ซ้าย · ล่าง - ขวา · บน - ขวา · ล่าง - คลิก แทร็กแพดขวา แทร็กแพดซ้าย แทร็กแพด @@ -1783,9 +1739,7 @@ D-pad ซ้าย D-pad ขวา ซ่อน - ลากเพื่อย้าย จัดให้พอดี - บันทึก ไม่มี แป้นพิมพ์ + แทร็กแพด เทอร์มินัล @@ -2630,7 +2584,6 @@ ไม่พบความสำเร็จ ตรวจสอบบันทึกเซสชัน ปลดล็อกความสำเร็จแล้ว วาง - ปุ่มกากบาท อนาล็อกซ้าย อนาล็อกขวา เมนู @@ -3672,4 +3625,7 @@ ไม่พบแอดออน ไม่สามารถเชื่อมต่อ %1$s ได้ ไม่มีไฟล์สำหรับเกมเวอร์ชันนี้ + รีเซ็ตตำแหน่ง + แสดงป้ายกำกับ + ไม่สามารถนำเข้าการจัดวางนี้ได้ diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 086a9f337..9f9d07eda 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -272,24 +272,8 @@ Steam dokunmatik (beta) Steam\'in dokunmatik kontrolleri sonraki Steam oturumunda başlar Steam dokunmatik düzeni - Ekran tuşları - Tuş ekle - Metin - Boyut - Opaklık - Tuş - Tetik - Aç/Kapa Dışa aktar - Kapsül - Yuvarlak - Daire - Dikdörtgen - Tuş Görsel - Temizle - Düzen - Kaydet Bu oyunun dokunmatik kontrollerini taşıyın, boyutlandırın veya gizleyin; Steam\'e kaydedilir Kumanda Steam\'in kumandanızı nasıl gördüğü. Sonraki oturumda uygulanır. @@ -768,7 +752,6 @@ Otomatik Açık Kapalı - İmleç PC klavyesi Android İkinci ekran @@ -1103,43 +1086,23 @@ 3 · Bilgisayardan ADB (son yöntem) Yalnızca Kablosuz hata ayıklama kullanılamıyorsa. 3 · Bilgisayardan ADB (son yöntem) - Dokunmatik kontrollerin bir oturumda ne zaman görüneceği Renk - Ekrandaki düğmelerin rengi Özel Opaklık %1$d%% Düğme boyutu - %100 standart boyuttur - Çubuk tıklaması - L3 veya R3 için bir çubuğa çift dokunup basılı tutun Uyarlanabilir çubuklar Oyun titreşimi Çubuk ölü bölgesi Steam düğmesi QAM düğmesi Klavye düğmesi - Steam menüsünü açmak için Select\'e basın - Görünüm/Select yerine. VR kumandası olarak Joy-Con\'lar SteamVR çalışırken her Joy-Con bir el olur. Öne doğrultun ve yeniden ortalamak için Home veya Capture düğmesini basılı tutun. Animasyonlar - Çubuklar dokunduğunuz yerde görünür, bıraktığınızda gizlenir Düzen - Özel konumlar kaydedildi - Bu ekrana uyarlandı Düzenle Sıfırla - Düğme eşlemesi - Her düğme kendi girdisini gönderir - %2$d düğmeden %1$d\'i yeniden eşlendi - Yapılandır - Kumandayı sıfırla - Tüm kumanda ayarlarını ve düzeni geri yükler - Ekrandaki her düğmenin oyuna gönderdiği girdi. Gizli, düğmeyi kaldırır. - Ekrandaki düğmeler - Varsayılanlar - Eşlemeyi sıfırla Android uygulamaları %1$d uygulama Başlatılabilir Android uygulaması bulunamadı. @@ -1259,15 +1222,8 @@ Çubuk Dokunmatik yüzey Çubuk ile dokunmatik yüzey arasında geçiş yap - Diğer Deck kontrolleri - PANELLER İkinci ekran kontrollerini kapat Steam menüsünü aç - Sol · üst - Sol · alt - Sağ · üst - Sağ · alt - Tıkla Sağ dokunmatik yüzey Sol dokunmatik yüzey Dokunmatik yüzey @@ -1799,9 +1755,7 @@ Yön tuşu sol Yön tuşu sağ Gizli - Taşımak için sürükleyin Hizala - Kaydet Hiçbiri Klavye + dokunmatik yüzey Terminal @@ -2646,7 +2600,6 @@ Başarımlar kullanılamıyor; oturum günlüklerini kontrol et Başarımın kilidi açıldı Yapıştır - Yön tuşu Sol analog çubuk Sağ analog çubuk Menü @@ -3702,4 +3655,7 @@ Eklenti bulunamadı %1$s adresine ulaşılamadı Bu oyun sürümü için dosya yok + Konumları sıfırla + Etiketi göster + Bu düzen içe aktarılamadı diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index b356c327f..1ba7514e1 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -272,24 +272,8 @@ Сенсорне керування Steam (бета) Сенсорне керування Steam увімкнеться з наступним сеансом Steam Розкладка сенсорного керування Steam - Екранні клавіші - Додати клавішу - Текст - Розмір - Непрозорість - Клавіша - Тригер - Перемикання Експорт - Капсула - Заокруглення - Коло - Прямокутник - Клавіша Зображення - Очистити - Розкладка - Зберегти Переміщуйте, змінюйте розмір або приховуйте сенсорні елементи керування цієї гри; зберігається в Steam Контролер Яким Steam бачить ваш контролер. Застосовується в наступному сеансі. @@ -772,7 +756,6 @@ Автоматично Увімкнено Вимкнено - Курсор Клавіатура ПК Android Другий екран @@ -1123,43 +1106,23 @@ 3 · ADB з комп’ютера (останній варіант) Лише якщо бездротове налагодження недоступне. 3 · ADB з комп’ютера (останній варіант) - Коли сенсорне керування з’являється в сеансі Колір - Відтінок екранних кнопок Власний Непрозорість %1$d%% Розмір кнопок - 100% — стандартний розмір - Натискання стика - Двічі торкніться і затримайте стик, щоб отримати L3 або R3 Адаптивні стики Вібрація гри Мертва зона стіка Кнопка Steam Кнопка QAM Кнопка клавіатури - Натисніть Select, щоб відкрити меню Steam - Замість View/Select. Joy-Con як VR-контролери Поки працює SteamVR, кожен Joy-Con стає рукою. Спрямуйте його вперед і утримуйте Home або Capture, щоб знову його відцентрувати. Анімації - Стики з’являються там, де ви торкаєтеся, і зникають після відпускання Розкладка - Збережено власне розташування - Підлаштовано під цей екран Змінити Скинути - Призначення кнопок - Кожна кнопка надсилає власне введення - Перепризначено %1$d з %2$d кнопок - Налаштувати - Скинути контролер - Відновлює всі налаштування контролера та розкладку - Що кожна екранна кнопка надсилає грі. «Приховано» прибирає кнопку. - Екранні кнопки - Стандартні - Скинути призначення Додатки Android %1$d додатків Не знайдено жодного додатка Android для запуску. @@ -1281,15 +1244,8 @@ Стік Трекпад Перемкнути між стіком і трекпадом - Більше елементів керування Deck - ПАНЕЛІ Закрити керування на другому екрані Відкрити меню Steam - Ліва · верхня - Ліва · нижня - Права · верхня - Права · нижня - Натискання Правий трекпад Лівий трекпад Трекпад @@ -1855,9 +1811,7 @@ Хрестовина вліво Хрестовина вправо Приховано - Перетягніть, щоб перемістити Прив’язка - Зберегти Нічого Клавіатура + трекпад Термінал @@ -2711,7 +2665,6 @@ Досягнення недоступні; перевірте журнали сесії Досягнення відкрито Вставити - Хрестовина Лівий стик Правий стик Меню @@ -3780,4 +3733,7 @@ Аддонів не знайдено Не вдалося під’єднатися до %1$s Немає файлу для цієї версії гри + Скинути розташування + Показувати підпис + Не вдалося імпортувати цю розкладку diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index e343bab91..99b00b333 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -272,24 +272,8 @@ Chạm Steam (beta) Các nút chạm của Steam bắt đầu từ phiên Steam kế tiếp Bố cục chạm Steam - Phím trên màn hình - Thêm phím - Văn bản - Kích thước - Độ mờ - Phím - Cò - Bật tắt Xuất - Viên nang - Bo tròn - Tròn - Chữ nhật - Phím Hình ảnh - Xóa - Bố cục - Lưu Di chuyển, đổi kích thước hoặc ẩn các nút chạm của game này; được lưu vào Steam Tay cầm Cách Steam nhận dạng tay cầm của bạn. Áp dụng từ phiên sau. @@ -766,7 +750,6 @@ Tự động Bật Tắt - Con trỏ Bàn phím PC Android Màn hình thứ hai @@ -1105,43 +1088,23 @@ 3 · ADB từ máy tính (phương án cuối) Chỉ khi Gỡ lỗi qua mạng không dây không khả dụng. 3 · ADB từ máy tính (phương án cuối) - Khi các nút điều khiển chạm xuất hiện trong một phiên Màu - Màu sắc của các nút trên màn hình Tùy chỉnh Độ mờ %1$d%% Kích thước nút - 100% là kích thước chuẩn - Nhấn cần - Chạm hai lần và giữ một cần để dùng L3 hoặc R3 Cần thích ứng Rung khi chơi game Vùng chết của cần điều khiển Nút Steam Nút QAM Nút bàn phím - Nhấn Select để mở menu Steam - Thay cho View/Select. Dùng Joy-Con làm tay cầm VR Khi SteamVR đang chạy, mỗi Joy-Con trở thành một bàn tay. Hướng nó về phía trước rồi giữ Home hoặc Capture để căn giữa lại. Hiệu ứng chuyển động - Cần xuất hiện ở nơi bạn chạm và ẩn khi thả ra Bố cục - Vị trí tùy chỉnh đã lưu - Vừa khớp với màn hình này Sửa Đặt lại - Gán nút - Mỗi nút gửi tín hiệu riêng của nó - %1$d trong %2$d nút đã được gán lại - Cấu hình - Đặt lại tay cầm - Khôi phục mọi thiết lập tay cầm và bố cục - Mỗi nút trên màn hình gửi gì đến game. Ẩn sẽ xóa nút đó. - Nút trên màn hình - Mặc định - Đặt lại gán nút Ứng dụng Android %1$d ứng dụng Không tìm thấy ứng dụng Android nào có thể khởi chạy. @@ -1260,15 +1223,8 @@ Cần Trackpad Chuyển giữa cần và trackpad - Thêm điều khiển Deck - BẢNG Đóng điều khiển màn hình phụ Mở menu Steam - Trái · trên - Trái · dưới - Phải · trên - Phải · dưới - Nhấn Trackpad phải Trackpad trái Trackpad @@ -1783,9 +1739,7 @@ D-pad trái D-pad phải Ẩn - Kéo để di chuyển Hít vào lưới (snap) - Lưu Không có Bàn phím + trackpad Terminal @@ -2630,7 +2584,6 @@ Không có thành tích; hãy kiểm tra nhật ký phiên Đã mở khóa thành tích Dán - Phím chữ thập Cần analog trái Cần analog phải Menu @@ -3672,4 +3625,7 @@ Không tìm thấy addon Không thể kết nối tới %1$s Không có tệp cho phiên bản trò chơi này + Đặt lại vị trí + Hiện nhãn + Không thể nhập bố cục này diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index c536c2730..3f9eb6d2c 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -272,24 +272,8 @@ Steam 触控(测试版) Steam 的触控按键将从下次 Steam 会话开始生效 Steam 触控布局 - 屏幕按键 - 添加按键 - 文本 - 大小 - 透明 - 单键 - 扳机 - 切换 导出 - 胶囊 - 圆角 - 圆形 - 矩形 - 绑定 图片 - 清除 - 布局 - 保存 移动、调整大小或隐藏此游戏的触控按键;保存到 Steam 手柄 决定 Steam 把你的手柄识别为哪种类型,将在下次会话生效。 @@ -766,7 +750,6 @@ 自动 开 关 - 光标 PC 键盘 Android 副屏 @@ -1189,43 +1172,23 @@ 3 · 电脑 ADB(最后手段) 仅在无线调试不可用时使用。 3 · 电脑 ADB(最后手段) - 会话中屏幕控制何时出现 颜色 - 屏幕按钮的色调 自定义 不透明度 %1$d%% 按钮大小 - 100% 为标准大小 - 摇杆按击 - 双击并按住摇杆可触发 L3 或 R3 自适应摇杆 游戏震动 摇杆死区 Steam 按钮 QAM 按钮 键盘按钮 - 按 Select 打开 Steam 菜单 - 代替 View/Select。 将 Joy-Con 用作 VR 手柄 SteamVR 运行时,每个 Joy-Con 都会成为一只手。将其指向前方并按住 HOME 键或截图键即可重新居中。 动画 - 摇杆出现在触摸处,松手后隐藏 布局 - 已保存自定义位置 - 已适配此屏幕 编辑 重置 - 按键映射 - 每个按钮发送各自的输入 - %2$d 个按钮中已重映射 %1$d 个 - 配置 - 重置手柄 - 恢复所有手柄设置和布局 - 每个屏幕按钮发送给游戏的内容。“隐藏”会移除该按钮。 - 屏幕按钮 - 默认值 - 重置映射 Android 应用 %1$d 个应用 未找到可启动的 Android 应用。 @@ -1344,15 +1307,8 @@ 摇杆 触控板 在摇杆与触控板之间切换 - 更多 Deck 控制 - 面板 关闭副屏控制 打开 Steam 菜单 - 左 · 上 - 左 · 下 - 右 · 上 - 右 · 下 - 点击 右触控板 左触控板 触控板 @@ -1861,9 +1817,7 @@ 方向键左 方向键右 隐藏 - 拖动以移动 对齐 - 保存 无 键盘 + 触控板 终端 @@ -2634,7 +2588,6 @@ 成就不可用;请查看会话日志 成就已解锁 粘贴 - 方向键 左摇杆 右摇杆 菜单 @@ -3676,4 +3629,7 @@ 未找到插件 无法连接到 %1$s 没有适用于此游戏版本的文件 + 重置位置 + 显示标签 + 无法导入此布局 diff --git a/app/src/main/res/values-zh-rHK/strings.xml b/app/src/main/res/values-zh-rHK/strings.xml index e4e34b6aa..b1b41d210 100644 --- a/app/src/main/res/values-zh-rHK/strings.xml +++ b/app/src/main/res/values-zh-rHK/strings.xml @@ -272,24 +272,8 @@ Steam 觸控(測試版) Steam 的觸控控制會在下次 Steam 會話啟用 Steam 觸控佈局 - 螢幕按鍵 - 新增按鍵 - 文本 - 大小 - 透明 - 單鍵 - 扳機 - 切換 匯出 - 膠囊 - 圓角 - 圓形 - 矩形 - 綁定 圖片 - 清除 - 佈局 - 儲存 移動、調整大小或隱藏此遊戲的觸控控制;會儲存至 Steam 控制器 Steam 如何識別你的控制器。下次會話生效。 @@ -766,7 +750,6 @@ 自動 開 關 - 游標 電腦鍵盤 Android 第二螢幕 @@ -1105,43 +1088,23 @@ 3 · 電腦 ADB(最後手段) 只在無法使用無線偵錯時使用。 3 · 電腦 ADB(最後手段) - 會話中何時顯示觸控控制 顏色 - 螢幕按鍵的色調 自訂 不透明度 %1$d%% 按鍵大小 - 100% 為標準大小 - 按下搖桿 - 在搖桿上輕按兩下並按住,即可觸發 L3 或 R3 自動調整搖桿 遊戲震動 搖桿死區 Steam 按鈕 QAM 按鈕 鍵盤按鈕 - 按 Select 開啟 Steam 選單 - 代替 View/Select。 將 Joy-Con 用作 VR 控制器 SteamVR 執行時,每個 Joy-Con 都會變成一隻手。將它指向前方並按住 HOME 鍵或擷取鍵,即可重新置中。 動畫 - 搖桿會在你觸控的位置出現,放開後隱藏 佈局 - 已儲存自訂位置 - 已配合此螢幕 編輯 重設 - 按鍵對應 - 每個按鍵都發送其原本的輸入 - 已重新對應 %1$d/%2$d 個按鍵 - 設定 - 重設控制器 - 還原所有控制器設定和佈局 - 每個螢幕按鍵向遊戲發送的輸入。「隱藏」會移除該按鍵。 - 螢幕按鍵 - 預設值 - 重設對應 Android 應用程式 %1$d 個應用程式 找不到可啟動的 Android 應用程式。 @@ -1260,15 +1223,8 @@ 搖桿 觸控板 在搖桿和觸控板之間切換 - 更多 Deck 控制 - 面板 關閉第二螢幕控制 開啟 Steam 選單 - 左側 · 上方 - 左側 · 下方 - 右側 · 上方 - 右側 · 下方 - 按下 右側觸控板 左側觸控板 觸控板 @@ -1783,9 +1739,7 @@ 十字鍵左 十字鍵右 隱藏 - 拖曳以移動 對齊 - 儲存 無 鍵盤 + 觸控板 終端機 @@ -2630,7 +2584,6 @@ 成就不可用;請查看工作階段日誌 成就已解鎖 貼上 - 方向鍵 左搖桿 右搖桿 選單 @@ -3675,4 +3628,7 @@ 找不到插件 無法連接 %1$s 沒有適用於此遊戲版本的檔案 + 重設位置 + 顯示標籤 + 無法匯入此佈局 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 6568d97ab..6c62968f9 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -272,24 +272,8 @@ Steam 觸控(測試版) Steam 觸控控制會在下次 Steam 工作階段啟用 Steam 觸控版面配置 - 螢幕按鍵 - 新增按鍵 - 文本 - 大小 - 透明 - 單鍵 - 扳機 - 切換 匯出 - 膠囊 - 圓角 - 圓形 - 矩形 - 綁定 圖片 - 清除 - 版面配置 - 儲存 移動、調整大小或隱藏這款遊戲的觸控控制;會儲存到 Steam 控制器 Steam 辨識你的控制器的方式。下次工作階段生效。 @@ -766,7 +750,6 @@ 自動 開 關 - 游標 電腦鍵盤 Android 第二螢幕 @@ -1105,43 +1088,23 @@ 3 · 電腦 ADB(最後手段) 僅在無線偵錯無法使用時使用。 3 · 電腦 ADB(最後手段) - 工作階段中顯示螢幕控制時 顏色 - 螢幕按鍵的色調 自訂 不透明度 %1$d%% 按鍵大小 - 100% 為標準大小 - 搖桿點擊 - 點兩下並按住搖桿可觸發 L3 或 R3 自適應搖桿 遊戲震動 搖桿死區 Steam 按鈕 QAM 按鈕 鍵盤按鈕 - 按 Select 開啟 Steam 功能表 - 代替 View/Select。 將 Joy-Con 當作 VR 控制器 SteamVR 執行時,每個 Joy-Con 都會變成一隻手。將它指向前方並按住 HOME 鍵或擷取鍵,即可重新置中。 動畫 - 搖桿會出現在你觸控的位置,並在放開後隱藏 版面配置 - 已儲存自訂位置 - 已適應此螢幕 編輯 重設 - 按鍵映射 - 每個按鍵傳送對應的原始輸入 - %1$d/%2$d 個按鍵已重新映射 - 設定 - 重設控制器 - 還原所有控制器設定和版面配置 - 設定每個螢幕按鍵向遊戲傳送的輸入。「隱藏」會移除該按鍵。 - 螢幕按鍵 - 預設值 - 重設映射 Android 應用程式 %1$d 個應用程式 沒有找到可啟動的 Android 應用程式。 @@ -1260,15 +1223,8 @@ 搖桿 觸控板 在搖桿與觸控板之間切換 - 更多 Deck 控制 - 面板 關閉第二螢幕控制 開啟 Steam 選單 - 左側 · 上方 - 左側 · 下方 - 右側 · 上方 - 右側 · 下方 - 點擊 右側觸控板 左側觸控板 觸控板 @@ -1783,9 +1739,7 @@ 方向鍵左 方向鍵右 隱藏 - 拖曳以移動 對齊 - 儲存 無 鍵盤 + 觸控板 終端機 @@ -2630,7 +2584,6 @@ 成就無法使用;請查看工作階段記錄 成就已解鎖 貼上 - 方向鍵 左搖桿 右搖桿 選單 @@ -3675,4 +3628,7 @@ 找不到插件 無法連線至 %1$s 沒有適用於此遊戲版本的檔案 + 重設位置 + 顯示標籤 + 無法匯入此版面配置 diff --git a/app/src/main/res/values/input_library.xml b/app/src/main/res/values/input_library.xml index d46347806..27d018b6e 100644 --- a/app/src/main/res/values/input_library.xml +++ b/app/src/main/res/values/input_library.xml @@ -1,14 +1,12 @@ Unreadable input settings were preserved in a recovery copy. You can continue editing the restored profiles. - This built-in layout keeps its existing controls. Create a custom layout to add, move or resize elements. Properties Click properties %1$s · Click Buttons Sticks, pads and triggers Hide tools - Show tools Controller Send controller input to Steam Input or the game. This element sends no controller, mouse or keyboard input. @@ -16,29 +14,12 @@ DroidDeck action Action Scroll speed: %1$d steps/s - Auto selection - Shortcuts and diagnostics - Layouts - Opening a profile for editing does not activate it. - Saved profiles + New profile + New layout - + New bindings - Saved elements, positions and sizes. Actions are stored in bindings. - Used by %1$d profiles %1$s · Used by %2$d profiles. Changes apply to every profile using these bindings. - Empty layout - Saved parts - Choose shared bindings and a layout for each touch screen. - Arrange elements directly on the target display. Properties open on the main display. - Use this profile Rename - Make a copy Delete Add elements to the layout before assigning their actions. - Edit on - Confirm change - Continue with this reset or deletion? Could not save input settings Name Start from @@ -51,21 +32,17 @@ Undo + Add Drag to move. Drag a corner to resize. Tap Properties to assign an action. - Tap to place the new element. Editing on your second screen The selected display is not available. Connect it to edit this layout at its actual size. Discard unsaved layout changes? This layout is used by %1$d profiles. Saved changes apply to all of them. - Unassigned Default profiles Desktop mode Game mode - The session is pinned to %1$s. Choose Auto in the session menu to use these defaults. Default for Desktop and Game Default for Game mode Default for Desktop mode Device settings - Vibration, stick dead zone, shortcuts and diagnostics Controls Buttons, sticks, triggers and extra keys Copy profile @@ -77,9 +54,6 @@ Edit on screen Saved layouts… Choose a layout or create your own - Built-in gamepad options - Appearance, buttons and positions - These options apply to the built-in main-screen gamepad in every profile. Custom layouts are edited directly on screen. Choose Profile controls in the second-screen session menu. The Profile and Second screen selectors in the Controller menu choose the controls to show. Button actions Shared with %1$s. Edits apply to those profiles too. @@ -99,7 +73,6 @@ Copy these controls and actions, or link them so future edits apply to both profiles. Keep shared Independent copy - Built-in gamepad · positions shared across profiles Start stick at touch position Hide stick when not in use Tools diff --git a/app/src/main/res/values/input_strings.xml b/app/src/main/res/values/input_strings.xml index 765191d9e..bc18c561a 100644 --- a/app/src/main/res/values/input_strings.xml +++ b/app/src/main/res/values/input_strings.xml @@ -1,17 +1,9 @@ Input profiles - Choose what each control sends. Native controls keep their gamepad or Steam Input behavior; local actions send keys, mouse input or DroidDeck commands. Profiles - Game - Desktop - Active profile - Auto uses Desktop in desktop sessions and Game in other sessions. Select Game manually when playing a game from the desktop. - Edit profile - Control source Physical controller Main screen Second screen - Bindings Native / Steam Input Native: %1$s Send another controller control @@ -29,16 +21,8 @@ Show keyboard Open Steam menu Open Steam quick access - Screen layout - Custom second-screen layout - Arrange controls here, then select Custom controls in the second-screen panel. Their actions come from this profile’s Second screen bindings. Native trackpads require a Steam Deck controller; Steam Mobile Touch layouts remain configured in Steam. - Horizontal position - Vertical position - Width - Height Remove control Add control - Custom controls Device-specific buttons Assign a physical button Release the button, then choose Edit, Retry, or Cancel. B or Back cancels after release. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5307166fa..e747e3d23 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -301,23 +301,7 @@ Steam touch (beta) Steam\'s touch controls start with the next Steam session Steam touch layout - Screen keys - Add key - Text - Size - Alpha - Key - Pill - Round - Circle - Rect - Trigger - Bind Image - Clear - Toggle - Layout - Save Export Move, resize or hide this game\'s touch controls; saved to Steam Controller @@ -840,7 +824,6 @@ Auto On Off - Cursor PC keyboard Android Second screen @@ -1220,43 +1203,23 @@ 3 · Computer ADB (last resort) Only if Wireless debugging is unavailable. 3 · Computer ADB (last resort) - When the touch controls appear in a session Color - Tint of the on-screen buttons Custom Opacity %1$d%% Button size - 100% is the standard size - Stick click - Double-tap and hold a stick for L3 or R3 Adaptive sticks Game vibration Stick dead zone Steam button QAM button Keyboard button - Press Select to open the Steam menu - Instead of View/Select. Joy-Cons as VR controllers While SteamVR runs, each Joy-Con becomes a hand. Point it ahead and hold Home or Capture to recenter it. Animations - Sticks appear where you touch and hide on release Layout - Custom positions saved - Fitted to this screen Edit Reset - Button mapping - Every button sends its own input - %1$d of %2$d buttons remapped - Configure - Reset controller - Restores all controller settings and the layout - What each on-screen button sends to the game. Hidden removes the button. - On-screen buttons - Defaults - Reset mapping Android apps %1$d apps No launchable Android apps found. @@ -1418,15 +1381,8 @@ Stick Trackpad Switch between stick and trackpad - More Deck controls - PANELS Close second-screen controls Open Steam menu - Left · upper - Left · lower - Right · upper - Right · lower - Click Right trackpad Left trackpad Trackpad @@ -1978,9 +1934,7 @@ D-pad left D-pad right Hidden - Drag to move Snap - Save None Keyboard + trackpad Terminal @@ -2915,7 +2869,6 @@ Mali bridge: on %1$s is not an Adreno. The experimental Vulkan bridge is on: the session draws through the phone\'s own driver. Expect bugs; send the session logs. Paste - D-pad Left stick Right stick Menu @@ -3863,4 +3816,7 @@ No file for this game version WoWInterface CurseForge + Reset positions + Show label + Could not import this layout diff --git a/app/src/test/java/com/droiddeck/launcher/input/InputLayoutFileTest.kt b/app/src/test/java/com/droiddeck/launcher/input/InputLayoutFileTest.kt new file mode 100644 index 000000000..725cea500 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/InputLayoutFileTest.kt @@ -0,0 +1,95 @@ +package com.droiddeck.launcher.input + +import android.content.Context +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], manifest = Config.NONE) +class InputLayoutFileTest { + private val context: Context get() = RuntimeEnvironment.getApplication() + @Before fun reset() { + for (name in listOf("input-profiles", "input-surfaces", "controller")) context.getSharedPreferences(name, Context.MODE_PRIVATE).edit().clear().commit() + } + + private fun import(raw: String) = InputLayoutFile.import(context, raw, "Fallback", InputLayoutScreen.MAIN, InputSurfaceStyle(), 1000, 500, 2f) + + @Test fun exportThenImportKeepsKeysShapesCentresSizesAndToggles() { + val keys = LegacyScreenKeyMigration.convert(context, + "28:0.25,0.6,50::::0.8:2:0:1;29+33:0.7,0.8,40::Find::0.6:3:1:1;103+0+106+0+108+0+105+0:0.5,0.5,80::::1:6:0:1;17+32+31+30:0.2,0.3,70::::1:5:0:1", + 1000, 500, 2f) + val layout = SavedInputLayout("source", "My layout", keys.map { it.first }) + val bindings = SavedInputBindings("source.bindings", "My layout", false, layout.id, elements = keys.associate { it.first.id to it.second }) + val raw = InputLayoutFile.export(layout, bindings, 1000, 500)!! + assertEquals("My layout", JSONObject(raw).getString("name")) + val (imported, actions) = import(raw)!! + assertEquals("My layout", imported.name) + assertEquals(InputLayoutScreen.MAIN, imported.screen) + assertEquals(layout.elements.map { it.kind }, imported.elements.map { it.kind }) + for ((before, after) in layout.elements.zip(imported.elements)) { + assertEquals(before.x + before.width / 2, after.x + after.width / 2, .002f) + assertEquals(before.y + before.height / 2, after.y + after.height / 2, .002f) + assertEquals(before.height, after.height, .005f) + assertEquals(before.toggle, after.toggle) + assertEquals(before.opacity, after.opacity, .001f) + } + assertEquals(InputButtonShape.CIRCLE, imported.elements[0].buttonShape) + assertEquals(InputButtonShape.RECTANGLE, imported.elements[1].buttonShape) + assertEquals("Find", imported.elements[1].name) + val codes = imported.elements.map { actions.action(it) } + assertEquals(InputAction.Keys(listOf(28)), codes[0]) + assertEquals(InputAction.Keys(listOf(29, 33)), codes[1]) + assertEquals(listOf(103, 106, 108, 105), (codes[2] as InputAction.Directions).let { listOf(it.up, it.right, it.down, it.left) }) + assertEquals(InputAction.Directions(17, 32, 31, 30), codes[3]) + InputLibraries.update(context) { it.withLayout(imported).withPreset(actions).chooseLayout(InputProfile.GAME, InputRole.PRIMARY, imported.id) } + assertEquals(imported.id, InputLibraries.read(context).layout(InputProfile.GAME, InputRole.PRIMARY)!!.id) + } + + @Test fun importsOlderDroidDeckAndWinlatorFiles() { + val raw = """{"id":0,"name":"DroidDeck","cursorSpeed":1.5,"elements":[ + {"type":"BUTTON","shape":"CIRCLE","bindings":["KEY_SPACE"],"scale":1.2,"x":0.8,"y":0.7,"toggleSwitch":false,"text":"Jump","opacity":0.5}, + {"type":"BUTTON","shape":"ROUND_RECT","bindings":["KEY_CTRL_L","KEY_C"],"scale":1.0,"x":0.3,"y":0.2,"toggleSwitch":true,"text":""}, + {"type":"STICK","shape":"ROUND_RECT","bindings":["KEY_W","","KEY_D","","KEY_S","","KEY_A",""],"scale":1.0,"x":0.2,"y":0.7}, + {"type":"STICK","bindings":["KEY_UP","KEY_RIGHT","KEY_DOWN","KEY_LEFT"],"scale":1.0,"x":0.6,"y":0.5}, + {"type":"D_PAD","bindings":["KEY_I","KEY_L","KEY_K","KEY_J"],"scale":1.0,"x":0.4,"y":0.5}, + {"type":"TRACKPAD","bindings":["MOUSE_MOVE_LEFT"]}, + {"type":"BUTTON","bindings":["GAMEPAD_BUTTON_A"],"x":0.1,"y":0.1} + ]}""" + val (layout, actions) = import(raw)!! + assertEquals("DroidDeck", layout.name) + assertEquals(listOf(InputElementKind.BUTTON, InputElementKind.BUTTON, InputElementKind.STICK, InputElementKind.STICK, InputElementKind.DPAD), layout.elements.map { it.kind }) + val jump = layout.elements[0] + assertEquals("Jump", jump.name) + assertEquals(.5f, jump.opacity, .001f) + assertEquals(InputButtonShape.CIRCLE, jump.buttonShape) + assertEquals(.8f, jump.x + jump.width / 2, .001f) + assertEquals(6f * 10f * 1.2f / 500f, jump.height, .002f) + assertTrue(layout.elements[1].toggle) + assertEquals(InputAction.Keys(listOf(29, 46)), actions.action(layout.elements[1])) + assertEquals(InputAction.Directions(17, 32, 31, 30, listOf(0, 0, 0, 0)), actions.action(layout.elements[2])) + assertEquals(InputAction.Directions(103, 106, 108, 105, listOf(0, 0, 0, 0)), actions.action(layout.elements[3])) + assertEquals(InputAction.Directions(23, 38, 37, 36), actions.action(layout.elements[4])) + } + + @Test fun rejectsFilesWithoutUsableControls() { + assertNull(import("not json")) + assertNull(import("""{"name":"x"}""")) + assertNull(import("""{"elements":[{"type":"BUTTON","bindings":["GAMEPAD_BUTTON_A"]}]}""")) + val (layout, _) = import("""{"elements":[{"type":"BUTTON","bindings":["KEY_E"],"x":"bad","scale":-1}]}""")!! + assertEquals("Fallback", layout.name) + assertEquals(.5f, layout.elements.single().x + layout.elements.single().width / 2, .001f) + } + + @Test fun layoutsWithoutKeyBindingsHaveNothingToExport() { + val library = InputLibraries.read(context) + val gamepad = library.layouts.first { it.id == InputLibraries.OVERLAY } + assertNull(InputLayoutFile.export(gamepad, library.bindings.firstOrNull { it.layoutId == gamepad.id }, 1000, 500)) + assertEquals("A_B_.icp", InputLayoutFile.fileName(gamepad.copy(name = "A/B?"))) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/input/InputLayoutToolsTest.kt b/app/src/test/java/com/droiddeck/launcher/input/InputLayoutToolsTest.kt new file mode 100644 index 000000000..44ad82b90 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/InputLayoutToolsTest.kt @@ -0,0 +1,194 @@ +package com.droiddeck.launcher.input + +import android.content.Context +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], manifest = Config.NONE) +class InputLayoutToolsTest { + private val context: Context get() = RuntimeEnvironment.getApplication() + @Before fun reset() { + for (name in listOf("input-profiles", "input-surfaces", "controller")) context.getSharedPreferences(name, Context.MODE_PRIVATE).edit().clear().commit() + } + + @Test fun scaleAllResizesEveryElementAroundItsCentreInsideTheScreen() { + val elements = listOf(InputSurfaceElement(InputControl.A, .4f, .4f, .2f, .2f, "a", name = "A"), + InputSurfaceElement(InputControl.A, 0f, .9f, .1f, .1f, "b", name = "B")) + val bigger = elements.scaled(1.25f) + assertEquals(.25f, bigger[0].width, .0001f) + assertEquals(.5f, bigger[0].x + bigger[0].width / 2, .0001f) + assertEquals(.5f, bigger[0].y + bigger[0].height / 2, .0001f) + assertEquals(0f, bigger[1].x, .0001f) + assertEquals(1f, bigger[1].y + bigger[1].height, .0001f) + assertTrue(bigger.all { it == it.bounded() }) + assertEquals(.16f, elements.scaled(.8f)[0].height, .0001f) + assertEquals(elements.map { it.name }, bigger.map { it.name }) + } + + @Test fun resetPositionsOnlyRestoresGeometryOfElementsTheDefaultsKnow() { + val defaults = listOf(InputSurfaceElement(InputControl.A, .1f, .1f, .2f, .2f, "legacy:a")) + val moved = listOf(defaults[0].copy(x = .5f, y = .6f, width = .3f, name = "Jump", opacity = .5f), + InputSurfaceElement(InputControl.A, .3f, .3f, .1f, .1f, "custom", name = "Custom")) + val reset = moved.withPositionsOf(defaults) + assertEquals(defaults[0].copy(name = "Jump", opacity = .5f), reset[0]) + assertEquals(moved[1], reset[1]) + } + + @Test fun copiesRememberTheirOriginSoStockPositionsCanBeRestored() { + val library = InputLibraries.read(context) + val copied = library.copyLayout(InputLibraries.DECK, "Mine", "mine") + val mine = copied.layouts.first { it.id == "mine" } + assertEquals(InputLibraries.DECK, mine.origin) + assertEquals(InputLibraries.DECK, copied.copyLayout("mine", "Again", "again").layouts.first { it.id == "again" }.origin) + val independent = library.useLayout(InputProfile.GAME, InputRole.SECONDARY, InputLibraries.DECK, shared = false) + assertEquals(InputLibraries.DECK, independent.layout(InputProfile.GAME, InputRole.SECONDARY)!!.origin) + val stock = InputStockLayouts.layouts().first { it.id == InputLibraries.DECK }.elements + val dragged = mine.copy(elements = mine.elements.map { it.copy(x = 0f, y = 0f) }) + assertEquals(stock, InputLibraries.defaultPositions(context, copied, dragged)) + assertEquals(mine.elements, dragged.elements.withPositionsOf(stock)) + assertEquals(copied, InputLibraries.decode(InputLibraries.encode(copied))) + } + + @Test fun builtInGamepadDefaultsComeFromTheGamepadGenerator() { + val library = InputLibraries.read(context) + val gamepad = library.layouts.first { it.id == InputLibraries.OVERLAY } + val defaults = InputLibraries.defaultPositions(context, library, gamepad)!! + assertEquals(gamepad.elements.map { it.id }.toSet(), defaults.map { it.id }.toSet()) + assertNull(InputLibraries.defaultPositions(context, library, SavedInputLayout("new", "New"))) + } + + @Test fun quickButtonsHideWithoutRemovingElementsAndPersist() { + val library = InputLibraries.read(context) + val gamepad = library.layouts.first { it.id == InputLibraries.OVERLAY } + assertTrue(InputCommand.KEYBOARD in gamepad.quickButtons) + assertTrue(InputLibraries.setQuickButton(context, gamepad.id, InputCommand.KEYBOARD, false)) + val hidden = InputLibraries.read(context).layouts.first { it.id == gamepad.id } + assertEquals(gamepad.elements, hidden.elements) + assertEquals(setOf(InputCommand.KEYBOARD), hidden.style.hidden) + val shown = hidden.style.shown(hidden.elements) + assertEquals(gamepad.elements.filterNot { it.id == "builtin:keyboard" }, shown) + assertTrue(InputLibraries.setQuickButton(context, gamepad.id, InputCommand.KEYBOARD, true)) + assertEquals(gamepad, InputLibraries.read(context).layouts.first { it.id == gamepad.id }) + } + + @Test fun onlySteamQuickAccessAndKeyboardCanBeHidden() { + val library = InputLibraries.read(context) + val gamepad = library.layouts.first { it.id == InputLibraries.OVERLAY } + assertThrows(IllegalArgumentException::class.java) { + library.withLayout(gamepad.copy(style = gamepad.style.copy(hidden = setOf(InputCommand.MENU)))).validate() + } + val custom = InputSurfaceElement(InputControl.A, .1f, .1f, .1f, .1f, "custom", name = "Steam") + assertFalse(InputSurfaceStyle(hidden = QUICK_BUTTONS.toSet()).hides(custom)) + } + + private fun overlapping(elements: List, width: Int, height: Int): List> { + fun round(e: InputSurfaceElement) = e.buttonShape == InputButtonShape.CIRCLE && e.kind != InputElementKind.STICK + val result = mutableListOf>() + for ((i, a) in elements.withIndex()) for (b in elements.drop(i + 1)) { + val ax = (a.x + a.width / 2) * width; val ay = (a.y + a.height / 2) * height + val bx = (b.x + b.width / 2) * width; val by = (b.y + b.height / 2) * height + val hit = if (round(a) && round(b)) { + val ra = minOf(a.width * width, a.height * height) / 2; val rb = minOf(b.width * width, b.height * height) / 2 + kotlin.math.hypot(ax - bx, ay - by) < ra + rb - .5f + } else kotlin.math.abs(ax - bx) < (a.width + b.width) * width / 2 - .5f && kotlin.math.abs(ay - by) < (a.height + b.height) * height / 2 - .5f + if (hit) result += a.id to b.id + } + return result + } + + @Test fun generatedGamepadSpacingFollowsButtonSize() { + for ((w, h) in listOf(1920 to 1080, 1280 to 960)) { + val normal = LegacyGamepadLayout(context, w, h).elements() + for (scale in listOf(.8f, 1.25f)) { + val elements = LegacyGamepadLayout(context, w, h, scale = scale).elements() + assertEquals("$w x $h at $scale", emptyList>(), overlapping(elements, w, h)) + val face = listOf("legacy:a", "legacy:y").map { id -> elements.first { it.id == id } } + val faceNormal = listOf("legacy:a", "legacy:y").map { id -> normal.first { it.id == id } } + val gap = (face[0].y - face[1].y) / (faceNormal[0].y - faceNormal[1].y) + if (scale > 1f) assertTrue(gap > 1f) else assertTrue(gap < 1f) + } + } + } + + @Test fun scalingACustomLayoutScalesTheGapsBetweenNeighbours() { + val left = listOf(InputSurfaceElement(InputControl.A, .05f, .5f, .08f, .1f, "one", name = "One"), + InputSurfaceElement(InputControl.A, .16f, .5f, .08f, .1f, "two", name = "Two")) + val right = listOf(InputSurfaceElement(InputControl.A, .7f, .2f, .1f, .1f, "three", name = "Three"), + InputSurfaceElement(InputControl.A, .85f, .2f, .1f, .1f, "four", name = "Four")) + for (pair in listOf(left, right)) { + val gap = pair[1].x - (pair[0].x + pair[0].width) + val bigger = pair.scaled(1.25f) + assertEquals(gap * 1.25f, bigger[1].x - (bigger[0].x + bigger[0].width), .0001f) + val smaller = pair.scaled(.8f) + assertEquals(gap * .8f, smaller[1].x - (smaller[0].x + smaller[0].width), .0001f) + assertTrue((bigger + smaller).all { it.x >= 0f && it.x + it.width <= 1.0001f && it.y >= 0f && it.y + it.height <= 1.0001f }) + } + } + + @Test fun aScaledGamepadStaysGeneratedAndRegeneratesOnRotation() { + val (display, metrics) = InputLibraries.primaryDisplay(context) + val long = maxOf(metrics.widthPixels, metrics.heightPixels) + val short = minOf(metrics.widthPixels, metrics.heightPixels) + val gamepad = InputLibraries.read(context).layouts.first { it.id == InputLibraries.OVERLAY } + val scaled = gamepad.copy(scale = 1.25f, elements = LegacyGamepadLayout(display, long, short, scale = 1.25f).regenerate(gamepad.elements)) + assertEquals(long to short, OnScreenLayout.generatedSize(context, scaled, long, short)) + val rotated = OnScreenLayout.elements(context, scaled, short, long, android.graphics.Rect()) + assertEquals(LegacyGamepadLayout(context, short, long, scale = 1.25f).regenerate(scaled.elements), rotated) + assertNull(OnScreenLayout.generatedSize(context, scaled.copy(scale = 1f), long, short)) + InputLibraries.update(context) { it.withLayout(scaled) } + assertEquals(1.25f, InputLibraries.read(context).layouts.first { it.id == gamepad.id }.scale) + } + + private fun assertCross(elements: List, width: Int, height: Int, ids: List) { + val (up, right, down, left) = ids.map { id -> elements.first { it.id == id }.let { (it.x + it.width / 2) * width to (it.y + it.height / 2) * height } } + val cx = (up.first + right.first + down.first + left.first) / 4 + val cy = (up.second + right.second + down.second + left.second) / 4 + assertEquals(up.first, down.first, 1f) + assertEquals(left.second, right.second, 1f) + assertTrue(up.second < down.second && left.first < right.first) + val reach = listOf(up, right, down, left).map { kotlin.math.hypot(it.first - cx, it.second - cy) } + reach.forEach { assertEquals(reach[0], it, 1f) } + } + + private fun assertGamepadShape(elements: List, width: Int, height: Int, label: String) { + assertCross(elements, width, height, listOf("legacy:y", "legacy:b", "legacy:a", "legacy:x")) + assertCross(elements, width, height, listOf("legacy:up", "legacy:right", "legacy:down", "legacy:left")) + assertEquals(label, emptyList>(), overlapping(elements, width, height)) + } + + @Test @Config(qualifiers = "w833dp-h468dp-land-369dpi") + fun buttonSizeKeepsTheGamepadShapeAtHandheldDensity() { + for ((w, h) in listOf(1920 to 1080, 1920 to 1200)) { + val normal = LegacyGamepadLayout(context, w, h).elements() + assertGamepadShape(normal, w, h, "$w x $h") + val right = setOf("legacy:a", "legacy:b", "legacy:x", "legacy:y", "legacy:rs", "legacy:rb", "legacy:rt", "legacy:start") + val left = setOf("legacy:up", "legacy:right", "legacy:down", "legacy:left", "legacy:ls", "legacy:lb", "legacy:lt", "legacy:select") + val moved = normal.map { e -> + when (e.id) { + in right -> e.copy(x = e.x + 21f / w, y = e.y + 18f / h) + in left -> e.copy(x = e.x - 20f / w, y = e.y + 15f / h) + else -> e + } + } + for (scale in listOf(.8f, 1.25f)) { + val generator = LegacyGamepadLayout(context, w, h, scale = scale) + assertGamepadShape(generator.elements(), w, h, "$w x $h generated at $scale") + val rescaled = generator.rescale(moved, LegacyGamepadLayout(context, w, h)) + assertGamepadShape(rescaled, w, h, "$w x $h moved at $scale") + val a = rescaled.first { it.id == "legacy:a" } + val generatedA = generator.elements().first { it.id == "legacy:a" } + assertEquals(generatedA.x + 21f / w, a.x, .001f) + assertEquals(generatedA.y + 18f / h, a.y, .001f) + assertEquals(moved, LegacyGamepadLayout(context, w, h).rescale(rescaled, generator).let { back -> + back.zip(moved).map { (b, m) -> if (kotlin.math.abs(b.x - m.x) < .001f && kotlin.math.abs(b.y - m.y) < .001f && kotlin.math.abs(b.width - m.width) < .001f) m else b } + }) + } + } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/input/InputLibraryTest.kt b/app/src/test/java/com/droiddeck/launcher/input/InputLibraryTest.kt index 305111668..4bfa52f51 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/InputLibraryTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/InputLibraryTest.kt @@ -19,11 +19,12 @@ class InputLibraryTest { for (name in listOf("input-profiles", "input-surfaces", "controller")) context.getSharedPreferences(name, Context.MODE_PRIVATE).edit().clear().commit() } - @Test fun freshLibraryHasOnlyTheMainGamepadAndTwoLowerScreenLayouts() { + @Test fun freshLibraryHasTheMainGamepadAndTheStockLowerScreenLayouts() { val library = InputLibraries.read(context) assertEquals(listOf("game", "desktop"), library.profiles.map { it.id }) - assertEquals(setOf(InputLibraries.OVERLAY, InputLibraries.DECK, InputLibraries.TRACKPAD), library.layouts.map { it.id }.toSet()) - assertEquals(6, library.bindings.size) + assertEquals(setOf(InputLibraries.OVERLAY, InputLibraries.TRACKPAD) + DeckPanel.entries.map { it.layout }, library.layouts.map { it.id }.toSet()) + assertEquals(10, library.bindings.size) + for (panel in DeckPanel.entries) assertEquals(panel.layout, library.panelLayout(false, panel.bindings)?.id) assertEquals(InputLibraries.DECK, library.activeLayout(false, InputRole.SECONDARY)?.id) assertEquals(InputLibraries.TRACKPAD, library.activeLayout(true, InputRole.SECONDARY)?.id) assertEquals(InputAction.Pointer(), library.compileActive(true).action(InputRole.PHYSICAL, InputControl.LS)) @@ -32,22 +33,18 @@ class InputLibraryTest { val desktop = library.activeLayout(true, InputRole.SECONDARY)!! val pad = desktop.elements.single { it.kind == InputElementKind.TRACKPAD } assertTrue(pad.mouseGestures) - assertEquals(3, desktop.elements.size) + assertEquals(1, desktop.elements.size) assertEquals(InputAction.Pointer(), library.compileActive(true).elementAction(InputRole.SECONDARY, pad.id, pad.control)) assertEquals(InputAction.MouseButton(272), library.compileActive(true).elementClickAction(InputRole.SECONDARY, pad)) val game = library.activeLayout(false, InputRole.SECONDARY)!! - assertEquals(9, game.elements.size) + assertEquals(6, game.elements.size) assertEquals(setOf(InputControl.LEFT_PAD, InputControl.RIGHT_PAD), game.elements.filter { it.kind == InputElementKind.TRACKPAD }.map { it.control }.toSet()) for (control in listOf(InputControl.L4, InputControl.L5, InputControl.R4, InputControl.R5)) { val element = game.elements.single { it.control == control } assertEquals(InputAction.Native(), library.compileActive(false).elementAction(InputRole.SECONDARY, element.id, element.control)) } - for ((isDesktop, commands) in listOf(true to listOf(InputCommand.MENU, InputCommand.KEYBOARD), - false to listOf(InputCommand.STEAM, InputCommand.QUICK_ACCESS, InputCommand.MENU))) { - val elements = library.activeLayout(isDesktop, InputRole.SECONDARY)!!.elements.filter { it.id.startsWith("command:") } - assertEquals(commands.map { InputAction.Command(it) }, elements.map { library.compileActive(isDesktop).elementAction(InputRole.SECONDARY, it.id, it.control) }) - assertTrue(elements.all { InputButtonIcons.has(it.icon) && it.icon > 255 }) - } + for (layout in InputStockLayouts.layouts()) assertTrue(layout.elements.none { it.id.startsWith("command:") }) + assertTrue(game.elements.filter { it.kind == InputElementKind.TRACKPAD }.all { it.y == .02f }) assertEquals(library, InputLibraries.decode(InputLibraries.encode(library))) } @@ -71,11 +68,11 @@ class InputLibraryTest { assertEquals(original, InputLibraries.read(context)) } - @Test fun resettingDefaultActionsKeepsTheMenuAndKeyboardCommands() { + @Test fun resettingDefaultActionsKeepsTheStockPanelActions() { val library = InputLibraries.read(context) for (id in listOf("game", "desktop")) { val reset = library.resetProfile(id).validate() - for (element in library.layout(id, InputRole.SECONDARY)!!.elements.filter { it.id.startsWith("command:") }) { + for (element in library.layout(id, InputRole.SECONDARY)!!.elements) { assertEquals(library.compile(id).elementAction(InputRole.SECONDARY, element.id, element.control), reset.compile(id).elementAction(InputRole.SECONDARY, element.id, element.control)) } @@ -460,7 +457,7 @@ class InputLibraryTest { assertTrue(upgraded.layout("game", InputRole.SECONDARY)!!.editable) val output = upgraded.compile("game") assertEquals(InputAction.Keys(listOf(57)), output.elementAction(InputRole.SECONDARY, "legacy:l4", InputControl.L4)) - assertEquals(InputAction.Command(InputCommand.MENU), output.elementAction(InputRole.SECONDARY, "command:0", InputControl.A)) + assertEquals(InputStockLayouts.layouts().single { it.id == InputLibraries.DECK }.elements, upgraded.layout("game", InputRole.SECONDARY)!!.elements) assertEquals(upgraded, InputLibraries.read(context)) } @@ -473,7 +470,9 @@ class InputLibraryTest { val copy = changed.layout("game", role)!! assertNotEquals(layout.id, copy.id) assertEquals(layout.elements, copy.elements) - assertEquals(InputAction.Command(InputCommand.MENU), changed.compile("game").elementAction(role, "command:0", InputControl.A)) + val stock = InputStockLayouts.presets(listOf(layout)).single() + for (element in layout.elements) assertEquals(stock.elements[element.id] ?: stock.controls[element.control] ?: InputAction.Native(), + changed.compile("game").elementAction(role, element.id, element.control)) } } @@ -533,4 +532,64 @@ class InputLibraryTest { assertEquals(ControllerPrefs.STEAM_BLUE, upgraded.layouts.single { it.id == InputLibraries.OVERLAY }.style.tint) assertEquals(upgraded, InputLibraries.read(context)) } + private fun versionElevenPanel(layout: SavedInputLayout, commands: List, padTop: Float = .14f) = layout.copy(elements = commands.mapIndexed { position, i -> + InputSurfaceElement(InputControl.A, .02f + position * .3f, .01f, .28f, .11f, "command:$i", name = "Command $i", icon = InputButtonIcons.MENU) + } + layout.elements.map { if (it.kind == InputElementKind.TRACKPAD) it.copy(y = padTop, height = it.y + it.height - padTop) else it }) + + private fun versionElevenLibrary(change: (InputLibrary) -> InputLibrary = { it }): InputLibrary { + val fresh = InputLibraries.read(context) + val stock = InputStockLayouts.layouts() + var old = fresh.copy(layouts = fresh.layouts.filter { it.id == InputLibraries.OVERLAY || it.id == InputLibraries.DECK || it.id == InputLibraries.TRACKPAD }, + bindings = fresh.bindings.filter { b -> DeckPanel.entries.none { it != DeckPanel.BOTH && it.bindings == b.id } }) + old = old.withLayout(versionElevenPanel(stock.single { it.id == InputLibraries.DECK }, listOf(2, 3, 0))) + .withLayout(versionElevenPanel(stock.single { it.id == InputLibraries.TRACKPAD }, listOf(0, 1))) + old = old.copy(bindings = old.bindings.map { b -> + when (b.layoutId) { + InputLibraries.DECK -> b.copy(elements = b.elements + mapOf("command:0" to InputAction.Command(InputCommand.MENU), "command:2" to InputAction.Command(InputCommand.STEAM))) + InputLibraries.TRACKPAD -> b.copy(elements = b.elements + ("command:1" to InputAction.Command(InputCommand.KEYBOARD))) + else -> b + } + }) + return change(old).validate() + } + + private fun storeVersionEleven(library: InputLibrary) { + prefs.edit().putString("library", JSONObject(InputLibraries.encode(library)).put("version", 11).toString()).commit() + } + + @Test fun versionTwelveMovesStockPanelCommandsToTheBarAndAddsTheDeckTabs() { + val mine = SavedInputLayout("mine", "Mine", listOf(InputSurfaceElement(InputControl.A, .1f, .1f, .2f, .2f, "command:0", name = "Menu")), + screen = InputLayoutScreen.SECONDARY) + val minePreset = SavedInputBindings("mine.bindings", "Mine", false, mine.id, elements = mapOf("command:0" to InputAction.Command(InputCommand.MENU))) + val old = versionElevenLibrary { it.withLayout(mine).withPreset(minePreset) } + assertTrue(old.layouts.single { it.id == InputLibraries.DECK }.elements.any { it.id.startsWith("command:") }) + storeVersionEleven(old) + val upgraded = InputLibraries.read(context) + val stock = InputStockLayouts.layouts() + for (id in listOf(InputLibraries.DECK, InputLibraries.TRACKPAD)) + assertEquals(stock.single { it.id == id }.elements, upgraded.layouts.single { it.id == id }.elements) + assertTrue(upgraded.bindings.filter { it.layoutId == InputLibraries.DECK || it.layoutId == InputLibraries.TRACKPAD } + .all { b -> b.elements.keys.none { it.startsWith("command:") } }) + assertEquals(InputAction.Pointer(), upgraded.preset(InputStockLayouts.TRACKPAD).elements["legacy:right-pad"]) + assertEquals(mine, upgraded.layouts.single { it.id == mine.id }) + assertEquals(minePreset, upgraded.preset(minePreset.id)) + for (panel in DeckPanel.entries) { + assertEquals(stock.single { it.id == panel.layout }, upgraded.layouts.single { it.id == panel.layout }) + assertEquals(panel.layout, upgraded.preset(panel.bindings).layoutId) + } + assertEquals(old.profiles, upgraded.profiles) + assertEquals(upgraded, InputLibraries.read(context)) + } + + @Test fun versionTwelveLeavesEditedStockPanelsAlone() { + val old = versionElevenLibrary { library -> + val trackpad = library.layouts.single { it.id == InputLibraries.TRACKPAD } + library.withLayout(trackpad.copy(elements = trackpad.elements.map { if (it.kind == InputElementKind.TRACKPAD) it.copy(x = .1f, width = .8f) else it })) + } + storeVersionEleven(old) + val upgraded = InputLibraries.read(context) + assertEquals(old.layouts.single { it.id == InputLibraries.TRACKPAD }, upgraded.layouts.single { it.id == InputLibraries.TRACKPAD }) + assertEquals(old.preset(InputStockLayouts.TRACKPAD), upgraded.preset(InputStockLayouts.TRACKPAD)) + assertEquals(InputStockLayouts.layouts().single { it.id == InputLibraries.DECK }.elements, upgraded.layouts.single { it.id == InputLibraries.DECK }.elements) + } } diff --git a/app/src/test/java/com/droiddeck/launcher/input/InputProfilesTest.kt b/app/src/test/java/com/droiddeck/launcher/input/InputProfilesTest.kt index f94a04569..e5fc41f09 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/InputProfilesTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/InputProfilesTest.kt @@ -70,8 +70,6 @@ class InputProfilesTest { assertEquals(InputProfile.defaults(InputProfile.GAME).bindings, profile.bindings) assertEquals(mapOf( InputRole.PRIMARY to mapOf("builtin:keyboard" to InputAction.Command(InputCommand.KEYBOARD)), - InputRole.SECONDARY to mapOf("command:0" to InputAction.Command(InputCommand.MENU), - "command:2" to InputAction.Command(InputCommand.STEAM), "command:3" to InputAction.Command(InputCommand.QUICK_ACCESS)), ), profile.elements) } diff --git a/app/src/test/java/com/droiddeck/launcher/input/InputRouterTest.kt b/app/src/test/java/com/droiddeck/launcher/input/InputRouterTest.kt index 331399577..1b0e86fbb 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/InputRouterTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/InputRouterTest.kt @@ -146,9 +146,28 @@ class InputRouterTest { val source = router.pad(InputRole.PHYSICAL) val raw = PadState() for (value in listOf(.6f, .5f, .4f, .6f, .3f, .6f)) { raw.leftTrigger = value; source.route(raw) } + assertEquals(listOf("MENU"), output.events) + raw.leftTrigger = 0f; source.route(raw) assertEquals(listOf("MENU", "MENU"), output.events) } + @Test fun commandsFireOnReleaseAndNotWhenCancelled() { + router.setProfile(bind(InputControl.A, InputAction.Command(InputCommand.KEYBOARD), InputRole.PRIMARY)) + val button = router.channel(InputRole.PRIMARY, InputControl.A) + button.update(1f) + assertTrue(output.events.isEmpty()) + button.update(0f) + assertEquals(listOf("KEYBOARD"), output.events) + button.update(1f) + button.release() + button.update(0f) + router.releaseAll() + button.update(1f) + router.releaseAll() + button.update(0f) + assertEquals(listOf("KEYBOARD"), output.events) + } + @Test fun pointerTicksOnlyWhileMovingAndOrdinaryKeysDoNotWaitForTick() { router.setProfile(InputProfile.defaults(InputProfile.DESKTOP)) val source = router.pad(InputRole.PHYSICAL) diff --git a/app/src/test/java/com/droiddeck/launcher/input/OnScreenControlsTest.kt b/app/src/test/java/com/droiddeck/launcher/input/OnScreenControlsTest.kt index 1e317f239..1aa7c9e20 100644 --- a/app/src/test/java/com/droiddeck/launcher/input/OnScreenControlsTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/input/OnScreenControlsTest.kt @@ -25,7 +25,7 @@ class OnScreenControlsTest { private var time = 1000L @Before fun setup() { view = OnScreenControls(context, bridge) - view.updateLayout(SavedInputLayout("test", "Test", listOf(left, button))) + view.updateLayout(SavedInputLayout("test", "Test", listOf(left, button), aspect = ASPECT)) measure(view) } @After fun close() { view.releaseAll(); bridge.stop() } @@ -102,7 +102,7 @@ class OnScreenControlsTest { @Test fun floatingAndFixedSticksUseTheirConfiguredOrigin() { for (floating in listOf(true, false)) { - view.updateLayout(SavedInputLayout("test", "Test", listOf(left.copy(floating = floating)))) + view.updateLayout(SavedInputLayout("test", "Test", listOf(left.copy(floating = floating)), aspect = ASPECT)) measure(view) touch(MotionEvent.ACTION_DOWN, 3 to (230f to 300f)) assertEquals(if (floating) 0f else .5f, state().leftX, .001f) @@ -110,16 +110,18 @@ class OnScreenControlsTest { } } - @Test fun systemOnlyVisibilityUsesTheSameLayoutAndReleasesOtherButtons() { + @Test fun systemOnlyVisibilityMovesSteamAndQamToTheBottomCornersAndReleasesOtherButtons() { val guide = InputSurfaceElement(InputControl.GUIDE, .4f, .7f, .1f, .2f) - view.updateLayout(SavedInputLayout("test", "Test", listOf(button, guide))) + view.updateLayout(SavedInputLayout("test", "Test", listOf(button, guide), aspect = ASPECT)) measure(view) touch(MotionEvent.ACTION_DOWN, 0 to (800f to 250f)) view.setButtonsOnly(true) measure(view) assertFalse(state().isDown(PadState.A)) assertFalse(view.takesTouchAt(800f, 250f)) - assertTrue(view.takesTouchAt(450f, 480f)) + assertFalse(view.takesTouchAt(450f, 480f)) + assertTrue(view.takesTouchAt(44f, 556f)) + assertTrue(view.takesTouchAt(956f, 556f)) } @Test fun aCancelledSecondFingerDoesNotArmAStickClick() { @@ -177,7 +179,7 @@ class OnScreenControlsTest { @Test fun theQuickToggleHidesEverythingButTheKeyboardAndShowsItAgain() { val keyboard = InputSurfaceElement(InputControl.A, .4f, .4f, .1f, .1f, "builtin:keyboard", name = "Keyboard") - view.updateLayout(SavedInputLayout("test", "Test", listOf(left, button, keyboard))) + view.updateLayout(SavedInputLayout("test", "Test", listOf(left, button, keyboard), aspect = ASPECT)) measure(view) val density = context.resources.displayMetrics.density val x = 34 * density @@ -199,9 +201,12 @@ class OnScreenControlsTest { assertTrue(view.takesTouchAt(800f, 250f)) view.setButtonsOnly(true) measure(view) - assertFalse(view.takesTouchAt(x, y)) + touch(MotionEvent.ACTION_DOWN, 6 to (x to y)); touch(MotionEvent.ACTION_UP, 6 to (x to y)) + assertFalse(view.isQuickHidden) } + private companion object { const val ASPECT = 1000f / 600f } + private fun touch(action: Int, vararg pointers: Pair>, flags: Int = 0): Boolean { val properties = pointers.map { (id, _) -> MotionEvent.PointerProperties().apply { this.id = id; toolType = MotionEvent.TOOL_TYPE_FINGER } }.toTypedArray() val coords = pointers.map { (_, position) -> MotionEvent.PointerCoords().apply { x = position.first; y = position.second; pressure = 1f; size = 1f } }.toTypedArray() diff --git a/app/src/test/java/com/droiddeck/launcher/input/OnScreenLayoutTest.kt b/app/src/test/java/com/droiddeck/launcher/input/OnScreenLayoutTest.kt new file mode 100644 index 000000000..8c137fbf2 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/input/OnScreenLayoutTest.kt @@ -0,0 +1,191 @@ +package com.droiddeck.launcher.input + +import android.content.Context +import android.graphics.Rect +import org.json.JSONObject +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], manifest = Config.NONE) +class OnScreenLayoutTest { + private val context: Context get() = RuntimeEnvironment.getApplication() + private val prefs get() = context.getSharedPreferences("input-profiles", Context.MODE_PRIVATE) + private val density get() = context.resources.displayMetrics.density + + @Before fun reset() { + for (name in listOf("input-profiles", "input-surfaces", "controller")) context.getSharedPreferences(name, Context.MODE_PRIVATE).edit().clear().commit() + } + + private fun gamepad() = InputLibraries.read(context).layouts.first { it.id == InputLibraries.OVERLAY } + + private fun px(e: InputSurfaceElement, w: Int, h: Int) = floatArrayOf(e.x * w, e.y * h, e.width * w, e.height * h) + + private fun assertSquare(e: InputSurfaceElement, w: Int, h: Int) = assertEquals(e.width * w, e.height * h, 1.5f) + + private fun assertInside(elements: List, w: Int, h: Int, safe: Rect = Rect()) { + for (e in elements) { + val (x, y, ew, eh) = px(e, w, h).toList() + assertTrue("${e.id} left", x >= safe.left - .5f) + assertTrue("${e.id} top", y >= safe.top - .5f) + assertTrue("${e.id} right", x + ew <= w - safe.right + .5f) + assertTrue("${e.id} bottom", y + eh <= h - safe.bottom + .5f) + } + } + + @Test fun theBuiltInGamepadIsRegeneratedForEverySizeWithoutStretching() { + val layout = gamepad() + for ((w, h) in listOf(1200 to 540, 540 to 1200, 900 to 850)) { + val placed = OnScreenLayout.elements(context, layout, w, h, Rect()) + assertEquals(layout.elements.map { it.id }, placed.map { it.id }) + assertEquals(LegacyGamepadLayout(context, w, h).regenerate(layout.elements), placed) + for (id in listOf("legacy:a", "legacy:up", "legacy:guide")) assertSquare(placed.first { it.id == id }, w, h) + assertInside(placed, w, h) + } + } + + @Test fun regenerationKeepsTheUsersButtonStylesAndRemovedButtons() { + val layout = gamepad().let { g -> g.copy(elements = g.elements.filterNot { it.id == "legacy:y" }.map { if (it.id == "legacy:a") it.copy(opacity = .4f, icon = 0, name = "Jump") else it }) } + val placed = OnScreenLayout.elements(context, layout, 2000, 1000, Rect()) + assertNull(placed.firstOrNull { it.id == "legacy:y" }) + val a = placed.first { it.id == "legacy:a" } + assertEquals(.4f, a.opacity) + assertEquals("Jump", a.name) + assertEquals(LegacyGamepadLayout(context, 2000, 1000).elements().first { it.id == "legacy:a" }.x, a.x, .0001f) + } + + @Test fun anEditedLayoutKeepsItsAspectAndCornersWhenTheScreenRotates() { + val corner = InputSurfaceElement(InputControl.A, .85f, .7f, .1f, .2f) + val left = InputSurfaceElement(InputControl.LS, .05f, .5f, .2f, .4f) + val middle = InputSurfaceElement(InputControl.GUIDE, .45f, .05f, .1f, .2f) + val layout = SavedInputLayout("edited", "Edited", listOf(left, corner, middle), aspect = 2f) + assertEquals(layout.elements, OnScreenLayout.elements(context, layout, 2000, 1000, Rect()).map { it.copy(x = round(it.x), y = round(it.y), width = round(it.width), height = round(it.height)) }) + val portrait = OnScreenLayout.elements(context, layout, 1000, 2000, Rect()) + val (lx, ly, lw, lh) = px(portrait[0], 1000, 2000).toList() + val (cx, cy, cw, ch) = px(portrait[1], 1000, 2000).toList() + assertEquals(lw, lh, 1f) + assertEquals(cw, ch, 1f) + assertEquals(200f, lw, 1f) + assertEquals(50f, lx, 1f) + assertEquals(2000f, ly + lh + 50f, 1f) + assertEquals(1000f - 50f, cx + cw, 1f) + assertEquals(2000f - 50f, cy + ch, 1f) + assertEquals(500f, px(portrait[2], 1000, 2000).let { it[0] + it[2] / 2f }, 1f) + assertInside(portrait, 1000, 2000) + } + + private fun round(v: Float) = Math.round(v * 10000f) / 10000f + + @Test fun controlsStayOutOfTheDisplayCutout() { + val safe = Rect(120, 0, 0, 40) + val generated = OnScreenLayout.elements(context, gamepad(), 2400, 1080, safe) + assertInside(generated, 2400, 1080, safe) + assertNotEquals(OnScreenLayout.elements(context, gamepad(), 2400, 1080, Rect()), generated) + val edge = SavedInputLayout("edited", "Edited", listOf(InputSurfaceElement(InputControl.B, 0f, .9f, .05f, .1f)), aspect = 2400f / 1080) + assertInside(OnScreenLayout.elements(context, edge, 2400, 1080, safe), 2400, 1080, safe) + } + + @Test fun steamAndQamOnlyModePutsLargeButtonsInTheBottomCornersAndTheKeyboardBetween() { + val safe = Rect(80, 0, 30, 20) + val placed = OnScreenLayout.elements(context, gamepad(), 1000, 500, safe, buttonsOnly = true) + assertEquals(setOf(InputControl.GUIDE, InputControl.QAM, InputControl.A), placed.map { it.control }.toSet()) + val radius = 30 * density + val inset = 44 * density + fun centre(e: InputSurfaceElement) = (e.x + e.width / 2f) * 1000 to (e.y + e.height / 2f) * 500 + val guide = placed.first { it.control == InputControl.GUIDE } + val qam = placed.first { it.control == InputControl.QAM } + val keyboard = placed.first { it.id == "builtin:keyboard" } + assertEquals(2 * radius, guide.width * 1000, .5f) + assertEquals(80 + inset, centre(guide).first, .5f) + assertEquals(500 - 20 - inset, centre(guide).second, .5f) + assertEquals(1000 - 30 - inset, centre(qam).first, .5f) + assertEquals(500f, centre(keyboard).first, .5f) + assertEquals(500 - 20 - inset, centre(keyboard).second, .5f) + val user = SavedInputLayout("edited", "Edited", listOf(InputSurfaceElement(InputControl.A, .5f, .5f, .1f, .1f))) + assertEquals(setOf(InputControl.GUIDE, InputControl.QAM), OnScreenLayout.elements(context, user, 2000, 1000, Rect(), buttonsOnly = true).map { it.control }.toSet()) + } + + @Test fun theUnfoldedStripUsesTheDockGeometryInLandscapeAndPortrait() { + for ((size, portrait) in listOf((2208 to 800) to false, (1080 to 1300) to true)) { + val (w, h) = size + val placed = OnScreenLayout.elements(context, gamepad(), w, h, Rect(), dock = OnScreenLayout.Dock(portrait, grips = true)) + val boxes = GamepadGeometry.layout(w.toFloat(), h.toFloat(), density, portrait) + val ls = placed.first { it.id == "legacy:ls" } + val box = boxes.getValue(GamepadGeometry.Slot.LEFT_MAIN) + assertEquals(box.left, ls.x * w, 1f) + assertEquals(box.width, ls.width * w, 1f) + assertEquals(box.height, ls.height * h, 1f) + assertSquare(placed.first { it.id == "legacy:a" }, w, h) + assertNull(placed.firstOrNull { it.control == InputControl.GUIDE || it.control == InputControl.QAM }) + assertEquals(portrait, placed.any { it.control == InputControl.L4 }) + assertInside(placed, w, h) + val solid = placed.filter { it.kind != InputElementKind.STICK } + for (a in solid) for (b in solid) if (a !== b) { + val overlap = if (a.buttonShape == InputButtonShape.CIRCLE && b.buttonShape == InputButtonShape.CIRCLE) + Math.hypot(((a.x + a.width / 2f) - (b.x + b.width / 2f)) * w.toDouble(), ((a.y + a.height / 2f) - (b.y + b.height / 2f)) * h.toDouble()) < + (a.width * w + b.width * w) / 2f - .5f + else a.x < b.x + b.width - .0001f && b.x < a.x + a.width - .0001f && a.y < b.y + b.height - .0001f && b.y < a.y + a.height - .0001f + assertFalse("${a.id} overlaps ${b.id}", overlap) + } + } + val user = SavedInputLayout("edited", "Edited", listOf(InputSurfaceElement(InputControl.A, .8f, .6f, .1f, .2f)), aspect = 2208f / 1768) + val fitted = OnScreenLayout.elements(context, user, 2208, 800, Rect(), dock = OnScreenLayout.Dock(false, false)).single() + assertEquals(.1f * 2208f / 1768 / .2f, fitted.width * 2208 / (fitted.height * 800), .01f) + } + + private val legacyKeys = "29+33:0.3,0.7,44::Find::0.6:1:1:1" + + private fun importedOn(library: InputLibrary) = library.activeLayout(false, InputRole.PRIMARY)!!.elements.filter { it.id.startsWith("imported:") } + + @Test fun shownScreenKeysAreDrawnWithTheGamepadAfterMigration() { + val before = gamepad() + prefs.edit().clear().commit() + ControllerPrefs.prefs(context).edit().putString("screenKeys", legacyKeys).commit() + val migrated = InputLibraries.read(context) + val layout = migrated.activeLayout(false, InputRole.PRIMARY)!! + assertEquals(InputLibraries.OVERLAY, layout.id) + assertEquals(before.elements, layout.elements.filterNot { it.id.startsWith("imported:") }) + assertEquals(1, importedOn(migrated).size) + assertEquals(InputAction.Keys(listOf(29, 33)), migrated.compileActive(false).elementAction(InputRole.PRIMARY, "imported:0", InputControl.A)) + assertEquals(InputAction.Native(), migrated.compileActive(false).elementAction(InputRole.PRIMARY, "legacy:a", InputControl.A)) + assertTrue(migrated.profiles.any { it.id == "migrated.screenkeys.0" }) + assertTrue(prefs.getBoolean(LegacyScreenKeyMigration.MERGED, false)) + assertEquals(migrated, InputLibraries.read(context)) + val placed = OnScreenLayout.elements(context, layout, 2400, 1080, Rect()) + assertEquals(layout.elements.map { it.id }, placed.map { it.id }) + } + + @Test fun librariesMigratedBeforeTheMergeGetTheirScreenKeysBackOnceWithoutLosingEdits() { + val fresh = InputLibraries.read(context) + val backup = JSONObject().put("screenKeys", legacyKeys).toString() + val old = LegacyScreenKeyMigration.migrate(context, fresh, backup) + val bindings = old.activePart(false, InputRole.PRIMARY).bindings + val edited = old.withPreset(old.preset(bindings).let { it.copy(controls = it.controls + (InputControl.A to InputAction.Keys(listOf(57)))) }) + prefs.edit().putString("library", InputLibraries.encode(edited)).putString(LegacyScreenKeyMigration.BACKUP, backup).commit() + val merged = InputLibraries.read(context) + assertEquals(1, importedOn(merged).size) + assertEquals(InputAction.Keys(listOf(57)), merged.compileActive(false).action(InputRole.PRIMARY, InputControl.A)) + assertEquals(edited.profiles, merged.profiles) + assertEquals(edited.layouts.map { it.id }.toSet(), merged.layouts.map { it.id }.toSet()) + assertEquals(edited.layouts.first { it.id == "migrated.screenkeys.0.layout" }, merged.layouts.first { it.id == "migrated.screenkeys.0.layout" }) + assertEquals(backup, prefs.getString(LegacyScreenKeyMigration.BACKUP, null)) + assertEquals(merged, InputLibraries.read(context)) + prefs.edit().remove(LegacyScreenKeyMigration.MERGED).commit() + assertEquals(1, importedOn(InputLibraries.read(context)).size) + } + + @Test fun hiddenScreenKeysOrAProfileThatAlreadyShowsThemAreLeftAlone() { + val fresh = InputLibraries.read(context) + val hidden = JSONObject().put("screenKeys", legacyKeys).put("screenKeysShown", false).toString() + val old = LegacyScreenKeyMigration.migrate(context, fresh, hidden) + assertEquals(old, LegacyScreenKeyMigration.merge(old, hidden)) + val shown = JSONObject().put("screenKeys", legacyKeys).toString() + val selected = old.selectProfile(false, "migrated.screenkeys.0").selectProfile(true, "migrated.screenkeys.0") + assertEquals(selected, LegacyScreenKeyMigration.merge(selected, shown)) + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/session/SettingsResetTest.kt b/app/src/test/java/com/droiddeck/launcher/session/SettingsResetTest.kt index f25207a69..422096107 100644 --- a/app/src/test/java/com/droiddeck/launcher/session/SettingsResetTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/session/SettingsResetTest.kt @@ -143,6 +143,21 @@ class SettingsResetTest { assertEquals(defaultSurface, InputSurfaceLayout.read(app, InputProfile.DESKTOP)) } + @Test fun inputLayoutsAndQuickButtonVisibilityReset() { + val original = com.droiddeck.launcher.input.InputLibraries.read(app) + val gamepad = original.layouts.first { it.id == com.droiddeck.launcher.input.InputLibraries.OVERLAY } + com.droiddeck.launcher.input.InputLibraries.update(app) { library -> + library.withLayout(gamepad.withQuickButton(com.droiddeck.launcher.input.InputCommand.STEAM, false) + .copy(elements = gamepad.elements.map { it.copy(x = 0f, y = 0f) })).copyLayout(gamepad.id, "Mine", "mine") + } + + assertTrue(SettingsReset.resetAll(app, gameProfiles = false)) + + val reset = com.droiddeck.launcher.input.InputLibraries.read(app) + assertEquals(original.layouts.map { it.id }.toSet(), reset.layouts.map { it.id }.toSet()) + assertEquals(gamepad, reset.layouts.first { it.id == gamepad.id }) + } + @Test fun gameTextureFilteringIsKeptUnlessGameProfilesAreReset() { session.edit().putString("gameTextureFiltering", """{"440":{"anisotropy":16}}""").commit() SettingsReset.resetAll(app, gameProfiles = false) diff --git a/app/src/test/java/com/droiddeck/launcher/ui/InputSettingsTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/InputSettingsTest.kt index 530f18c5a..679655d20 100644 --- a/app/src/test/java/com/droiddeck/launcher/ui/InputSettingsTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/ui/InputSettingsTest.kt @@ -5,6 +5,7 @@ import android.app.Presentation import android.hardware.display.DisplayManager import android.view.Display import androidx.compose.runtime.mutableStateOf +import androidx.compose.foundation.verticalScroll import androidx.compose.ui.test.* import com.droiddeck.launcher.input.* import com.droiddeck.launcher.R @@ -379,4 +380,147 @@ class InputSettingsTest { if (display.isValid) ShadowDisplayManager.removeDisplay(displayId) } } + + @Test fun elementPropertiesEditOpacityLabelsAndOfferImages() { + val element = mutableStateOf(InputSurfaceElement(InputControl.A, .1f, .1f, .2f, .2f, "custom", name = "Custom button")) + compose.setContent { DroidDeckTheme { + androidx.compose.foundation.layout.Column(androidx.compose.ui.Modifier.verticalScroll(androidx.compose.foundation.rememberScrollState())) { + InputElementProperties(element.value) { element.value = it } + } + } } + compose.onNodeWithTag("setting-element-opacity").performScrollTo().performSemanticsAction(androidx.compose.ui.semantics.SemanticsActions.SetProgress) { it(60f) } + compose.runOnIdle { assertEquals(.6f, element.value.opacity, .001f) } + compose.onNodeWithTag("setting-element-label").performScrollTo().performClick() + compose.runOnIdle { assertFalse(element.value.showLabel) } + compose.onNodeWithText(context.getString(R.string.input_button_text)).performScrollTo().performClick() + compose.onNodeWithText(context.getString(R.string.screen_key_image)).assertExists() + compose.runOnIdle { element.value = element.value.copy(icon = InputButtonIcons.MENU) } + compose.onNodeWithTag("setting-element-label").assertDoesNotExist() + compose.runOnIdle { element.value = InputSurfaceElement(InputControl.LEFT_PAD, .1f, .1f, .4f, .4f, "pad", InputElementKind.TRACKPAD, "Pad") } + compose.onNodeWithTag("setting-element-label").assertDoesNotExist() + compose.onNodeWithTag("setting-element-opacity").assertExists() + } + + @Test fun editorScalesEveryControlAndRestoresStockPositions() { + val library = InputLibraries.read(context) + val layout = library.layout("game", InputRole.SECONDARY)!! + assertEquals(InputLibraries.DECK, layout.id) + val round = mutableStateOf(0) + compose.setContent { DroidDeckTheme { androidx.compose.runtime.key(round.value) { InputSurfaceDialog("game", {}, {}, role = InputRole.PRIMARY, + layoutId = layout.id, bindingsId = library.profile("game").binding(InputRole.SECONDARY)) } } } + compose.onNodeWithTag("input-editor-tools").performClick() + compose.onNodeWithText(context.getString(R.string.input_reset_positions)).performScrollTo().assertIsNotEnabled() + compose.onNodeWithTag("input-editor-scale").performScrollTo().performSemanticsAction(androidx.compose.ui.semantics.SemanticsActions.SetProgress) { it(80f) } + compose.onNodeWithText(context.getString(R.string.input_editor_done)).performScrollTo().performClick() + val scaled = InputLibraries.read(context).layouts.first { it.id == layout.id } + assertEquals(layout.elements.scaled(.8f), scaled.elements) + compose.runOnIdle { round.value = 1 } + compose.onNodeWithTag("input-editor-tools").performClick() + compose.onNodeWithText(context.getString(R.string.input_reset_positions)).performScrollTo().performClick() + compose.onNodeWithText(context.getString(R.string.input_editor_done)).performScrollTo().performClick() + compose.runOnIdle { assertEquals(layout.elements, InputLibraries.read(context).layouts.first { it.id == layout.id }.elements) } + } + + @Test fun quickButtonTogglesHideBuiltInButtonsWithoutDeletingThem() { + compose.setContent { DroidDeckTheme { InputSettingsPage({}) } } + compose.onNodeWithTag("input-profile-game").performScrollTo().performClick() + compose.onNodeWithTag("input-part-primary").performScrollTo().performClick() + val before = InputLibraries.read(context).layout("game", InputRole.PRIMARY)!! + compose.onNodeWithTag("setting-quick-keyboard").performScrollTo().performClick() + compose.runOnIdle { + val after = InputLibraries.read(context).layout("game", InputRole.PRIMARY)!! + assertEquals(before.elements, after.elements) + assertEquals(setOf(InputCommand.KEYBOARD), after.style.hidden) + } + compose.onNodeWithTag("setting-quick-keyboard").performScrollTo().performClick() + compose.runOnIdle { assertTrue(InputLibraries.read(context).layout("game", InputRole.PRIMARY)!!.style.hidden.isEmpty()) } + } + + @Test fun layoutsCanBeImportedAndExported() { + val source = java.io.File(context.cacheDir, "jump.icp").apply { + writeText("""{"name":"Jump keys","elements":[{"type":"BUTTON","shape":"CIRCLE","bindings":["KEY_SPACE"],"scale":1.0,"x":0.8,"y":0.7,"text":"Jump"}]}""") + } + val target = java.io.File(context.cacheDir, "out.icp").apply { delete() } + val registry = object : androidx.activity.result.ActivityResultRegistry() { + override fun onLaunch(requestCode: Int, contract: androidx.activity.result.contract.ActivityResultContract, input: I, + options: androidx.core.app.ActivityOptionsCompat?) { + dispatchResult(requestCode, android.net.Uri.fromFile(if (input is String) target else source)) + } + } + val owner = object : androidx.activity.result.ActivityResultRegistryOwner { override val activityResultRegistry = registry } + compose.setContent { androidx.compose.runtime.CompositionLocalProvider(androidx.activity.compose.LocalActivityResultRegistryOwner provides owner) { + DroidDeckTheme { InputSettingsPage({}) } + } } + compose.onNodeWithTag("input-profile-game").performScrollTo().performClick() + compose.onNodeWithTag("input-part-primary").performScrollTo().performClick() + compose.onNodeWithText(context.getString(R.string.input_saved_layouts)).performScrollTo().performClick() + compose.onNodeWithTag("layout-manage-${InputLibraries.OVERLAY}").performScrollTo().performClick() + compose.onNodeWithText(context.getString(R.string.screen_key_layout_export)).assertDoesNotExist() + compose.onNodeWithText(context.getString(R.string.common_close)).performClick() + compose.onNodeWithTag("input-layout-import").performScrollTo().performClick() + compose.waitUntil(5_000) { InputLibraries.read(context).layout("game", InputRole.PRIMARY)!!.name == "Jump keys" } + val imported = InputLibraries.read(context).layout("game", InputRole.PRIMARY)!! + assertEquals(InputLayoutScreen.MAIN, imported.screen) + assertEquals(InputAction.Keys(listOf(57)), InputLibraries.read(context).compile("game").elementAction(InputRole.PRIMARY, imported.elements.single().id, InputControl.A)) + compose.onNodeWithText(context.getString(R.string.input_saved_layouts)).performScrollTo().performClick() + compose.onNodeWithTag("layout-manage-${imported.id}").performScrollTo().performClick() + compose.onNodeWithText(context.getString(R.string.screen_key_layout_export)).performClick() + compose.waitUntil(5_000) { target.isFile && target.length() > 0 } + assertEquals(source.readText().let { org.json.JSONObject(it).getString("name") }, org.json.JSONObject(target.readText()).getString("name")) + assertEquals("KEY_SPACE", org.json.JSONObject(target.readText()).getJSONArray("elements").getJSONObject(0).getJSONArray("bindings").getString(0)) + } + + @Test fun buttonSizeRegeneratesTheGamepadAndUndoesAsOneStep() { + val library = InputLibraries.read(context) + val layout = library.layout("game", InputRole.PRIMARY)!! + val (_, metrics) = InputLibraries.primaryDisplay(context) + assertNotNull(OnScreenLayout.generatedSize(context, layout, metrics.widthPixels, metrics.heightPixels)) + compose.setContent { DroidDeckTheme { InputSurfaceDialog("game", {}, {}, role = InputRole.PRIMARY, + layoutId = layout.id, bindingsId = library.profile("game").binding(InputRole.PRIMARY)) } } + compose.onNodeWithTag("input-editor-tools").performClick() + val slider = compose.onNodeWithTag("input-editor-scale").performScrollTo() + slider.assert(SemanticsMatcher.expectValue(androidx.compose.ui.semantics.SemanticsProperties.StateDescription, "100%")) + for (value in listOf(110f, 125f)) slider.performSemanticsAction(androidx.compose.ui.semantics.SemanticsActions.SetProgress) { it(value) } + compose.onNodeWithText(context.getString(R.string.input_editor_undo)).performScrollTo().performClick() + slider.assert(SemanticsMatcher.expectValue(androidx.compose.ui.semantics.SemanticsProperties.StateDescription, "100%")) + slider.performSemanticsAction(androidx.compose.ui.semantics.SemanticsActions.SetProgress) { it(125f) } + compose.onNodeWithText(context.getString(R.string.input_editor_done)).performScrollTo().performClick() + compose.runOnIdle { + val saved = InputLibraries.read(context).layouts.first { it.id == layout.id } + assertEquals(1.25f, saved.scale) + assertNotNull(OnScreenLayout.generatedSize(context, saved, metrics.widthPixels, metrics.heightPixels)) + assertNotEquals(layout.elements.first { it.id == "legacy:a" }.y, saved.elements.first { it.id == "legacy:a" }.y) + } + } + + @Test @Config(qualifiers = "w833dp-h468dp-land-369dpi") + fun buttonSizeKeepsMovedGamepadGroupsInShape() { + val face = setOf("legacy:a", "legacy:b", "legacy:x", "legacy:y") + val original = InputLibraries.read(context) + val gamepad = original.layout("game", InputRole.PRIMARY)!! + val moved = gamepad.copy(elements = gamepad.elements.map { if (it.id in face) it.copy(x = it.x + .012f, y = it.y + .017f) else it }) + InputLibraries.update(context) { it.withLayout(moved) } + val (_, metrics) = InputLibraries.primaryDisplay(context) + assertNull(OnScreenLayout.generatedSize(context, moved, metrics.widthPixels, metrics.heightPixels)) + val library = InputLibraries.read(context) + compose.setContent { DroidDeckTheme { InputSurfaceDialog("game", {}, {}, role = InputRole.PRIMARY, + layoutId = moved.id, bindingsId = library.profile("game").binding(InputRole.PRIMARY)) } } + compose.onNodeWithTag("input-editor-tools").performClick() + compose.onNodeWithTag("input-editor-scale").performScrollTo().performSemanticsAction(androidx.compose.ui.semantics.SemanticsActions.SetProgress) { it(125f) } + compose.onNodeWithText(context.getString(R.string.input_editor_done)).performScrollTo().performClick() + compose.runOnIdle { + val saved = InputLibraries.read(context).layouts.first { it.id == moved.id } + assertEquals(1.25f, saved.scale) + val w = metrics.widthPixels; val h = metrics.heightPixels + fun centre(id: String) = saved.elements.first { it.id == id }.let { (it.x + it.width / 2) * w to (it.y + it.height / 2) * h } + assertEquals(centre("legacy:y").first, centre("legacy:a").first, 1f) + assertEquals(centre("legacy:x").second, centre("legacy:b").second, 1f) + assertTrue(centre("legacy:y").second < centre("legacy:x").second && centre("legacy:x").second < centre("legacy:a").second) + assertEquals(centre("legacy:a").second - centre("legacy:y").second, centre("legacy:b").first - centre("legacy:x").first, 1f) + assertEquals(centre("legacy:up").first, centre("legacy:down").first, 1f) + assertEquals(centre("legacy:left").second, centre("legacy:right").second, 1f) + val before = moved.elements.first { it.id == "legacy:a" } + assertTrue(saved.elements.first { it.id == "legacy:a" }.width > before.width * 1.2f) + } + } } diff --git a/app/src/test/java/com/droiddeck/launcher/ui/InputSurfaceOwnershipTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/InputSurfaceOwnershipTest.kt index 566c93d77..d1275581f 100644 --- a/app/src/test/java/com/droiddeck/launcher/ui/InputSurfaceOwnershipTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/ui/InputSurfaceOwnershipTest.kt @@ -93,8 +93,7 @@ class InputSurfaceOwnershipTest { @Test fun removingAButtonReleasesOnlyItsOwnInput() { val buttons = child("HoldButton") - buttons.layout(0, 0, 300, 300) - touch(buttons, MotionEvent.ACTION_DOWN, 150f, 240f) + press(buttons) assertTrue(state().isDown(PadState.A)) val position = JoyConPadTest.RecordingWriter.deckPads.copyOf() (buttons.parent as ViewGroup).removeView(buttons) @@ -123,8 +122,7 @@ class InputSurfaceOwnershipTest { private fun holdFaceButton() { val buttons = child("HoldButton") - buttons.layout(0, 0, 300, 300) - touch(buttons, MotionEvent.ACTION_DOWN, 150f, 240f) + press(buttons) } private fun assertTrackpadStillHeld(position: ShortArray) { @@ -139,6 +137,11 @@ class InputSurfaceOwnershipTest { if (view is ViewGroup) for (i in 0 until view.childCount) yieldAll(descendants(view.getChildAt(i))) } + private fun press(button: View) { + button.javaClass.getDeclaredMethod("press", Int::class.java, Boolean::class.java, Boolean::class.java) + .apply { isAccessible = true }.invoke(button, 0, true, false) + } + private fun touch(view: View, action: Int, x: Float, y: Float, fingers: Int = 1) { val properties = Array(fingers) { i -> MotionEvent.PointerProperties().apply { id = i; toolType = MotionEvent.TOOL_TYPE_FINGER } } val coords = Array(fingers) { MotionEvent.PointerCoords().apply { this.x = x; this.y = y; pressure = 1f; size = 1f } } diff --git a/app/src/test/java/com/droiddeck/launcher/ui/InputSurfaceTouchTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/InputSurfaceTouchTest.kt new file mode 100644 index 000000000..27f066962 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/InputSurfaceTouchTest.kt @@ -0,0 +1,235 @@ +package com.droiddeck.launcher.ui + +import android.content.Context +import android.view.HapticFeedbackConstants +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import androidx.activity.ComponentActivity +import com.droiddeck.launcher.input.* +import java.io.File +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], manifest = Config.NONE, shadows = [JoyConPadTest.RecordingWriter::class]) +class InputSurfaceTouchTest { + private val context: Context get() = RuntimeEnvironment.getApplication() + private val bridge = PadBridge(File(context.cacheDir, "surface-touch")) + private val events = mutableListOf() + private val router = InputRouter(object : InputOutput { + override fun key(code: Int, down: Boolean) { events += "key $code $down" } + override fun button(code: Int, down: Boolean) {} + override fun pointer(dx: Float, dy: Float) {} + override fun scroll(steps: Int) {} + override fun command(command: InputCommand) { events += command.name } + override fun command(command: InputCommand, role: InputRole) { events += "${command.name}:$role" } + override fun requestMotion() {} + }) + private lateinit var view: OnScreenControls + private val a = InputSurfaceElement(InputControl.A, .7f, .3f, .2f, .2f) + private val b = InputSurfaceElement(InputControl.B, .7f, .6f, .2f, .2f) + private val stick = InputSurfaceElement(InputControl.LS, .1f, .3f, .2f, .4f, floating = false) + private val keyboard = InputSurfaceElement(InputControl.A, .4f, .8f, .1f, .1f, "builtin:keyboard", name = "Keyboard") + private var time = 1000L + + @Before fun setup() { + bridge.setInputRouter(router) + bridge.setInputProfile(InputProfile("touch", elements = mapOf(InputRole.PRIMARY to mapOf( + "builtin:keyboard" to InputAction.Command(InputCommand.KEYBOARD), "latch" to InputAction.Keys(listOf(42)))))) + view = OnScreenControls(context, bridge) + show(a, b, stick, keyboard) + } + + @After fun close() { view.releaseAll(); bridge.stop() } + + private fun show(vararg elements: InputSurfaceElement) { + view.updateLayout(SavedInputLayout("touch", "Touch", elements.toList(), aspect = 1000f / 600f)) + measure(view) + } + + private fun measure(target: View) { + target.measure(View.MeasureSpec.makeMeasureSpec(1000, View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec(600, View.MeasureSpec.EXACTLY)) + target.layout(0, 0, 1000, 600) + } + + private fun state() = JoyConPadTest.RecordingWriter.pads[0] ?: PadState() + + @Test fun aFingerSlidingBetweenButtonsMovesThePress() { + touch(MotionEvent.ACTION_DOWN, 1 to (800f to 240f)) + assertTrue(state().isDown(PadState.A)) + touch(MotionEvent.ACTION_MOVE, 1 to (800f to 420f)) + assertFalse(state().isDown(PadState.A)) + assertTrue(state().isDown(PadState.B)) + touch(MotionEvent.ACTION_MOVE, 1 to (600f to 100f)) + assertFalse(state().isDown(PadState.B)) + touch(MotionEvent.ACTION_MOVE, 1 to (800f to 240f)) + assertTrue(state().isDown(PadState.A)) + touch(MotionEvent.ACTION_UP, 1 to (800f to 240f)) + assertFalse(state().isDown(PadState.A)) + } + + @Test fun aStickKeepsItsFingerWhenItCrossesAButton() { + touch(MotionEvent.ACTION_DOWN, 1 to (200f to 300f)) + touch(MotionEvent.ACTION_MOVE, 1 to (800f to 240f)) + assertFalse(state().isDown(PadState.A)) + assertEquals(1f, state().leftX, .01f) + touch(MotionEvent.ACTION_UP, 1 to (800f to 240f)) + assertEquals(0f, state().leftX, 0f) + } + + @Test fun mouseClicksPassThroughToTheGame() { + val missed = mutableListOf() + view.onTouchMiss = { missed += it.actionMasked } + touch(MotionEvent.ACTION_DOWN, 1 to (800f to 240f), tool = MotionEvent.TOOL_TYPE_MOUSE) + touch(MotionEvent.ACTION_UP, 1 to (800f to 240f), tool = MotionEvent.TOOL_TYPE_MOUSE) + assertFalse(state().isDown(PadState.A)) + assertEquals(listOf(MotionEvent.ACTION_DOWN, MotionEvent.ACTION_UP), missed) + view.onTouchMiss = null + assertFalse(touch(MotionEvent.ACTION_DOWN, 1 to (800f to 240f), tool = MotionEvent.TOOL_TYPE_MOUSE)) + assertFalse(state().isDown(PadState.A)) + } + + @Test fun keyboardShortcutOpensOnRelease() { + touch(MotionEvent.ACTION_DOWN, 1 to (450f to 510f)) + assertTrue(events.isEmpty()) + touch(MotionEvent.ACTION_UP, 1 to (450f to 510f)) + assertEquals(listOf("KEYBOARD:PRIMARY"), events) + } + + @Test fun cancelledDraggedOrDisabledKeyboardShortcutDoesNotOpen() { + touch(MotionEvent.ACTION_DOWN, 1 to (450f to 510f)) + touch(MotionEvent.ACTION_CANCEL, 1 to (450f to 510f)) + touch(MotionEvent.ACTION_UP, 1 to (450f to 510f)) + touch(MotionEvent.ACTION_DOWN, 1 to (450f to 510f)) + touch(MotionEvent.ACTION_UP, 1 to (450f to 510f), flags = MotionEvent.FLAG_CANCELED) + touch(MotionEvent.ACTION_DOWN, 1 to (450f to 510f)) + touch(MotionEvent.ACTION_MOVE, 1 to (600f to 510f)) + touch(MotionEvent.ACTION_UP, 1 to (600f to 510f)) + assertTrue(events.isEmpty()) + show(a, b, stick) + view.setQuickHidden(true) + measure(view) + assertFalse(view.takesTouchAt(450f, 510f)) + assertFalse(touch(MotionEvent.ACTION_DOWN, 1 to (450f to 510f))) + assertTrue(events.isEmpty()) + } + + @Test fun keyboardShortcutWorksWithHiddenPadAndButtonsOnly() { + view.setQuickHidden(true) + measure(view) + assertFalse(view.takesTouchAt(800f, 240f)) + touch(MotionEvent.ACTION_DOWN, 1 to (450f to 510f)) + touch(MotionEvent.ACTION_UP, 1 to (450f to 510f)) + view.setButtonsOnly(true) + measure(view) + val (x, y) = centre(element(view, "builtin:keyboard")) + assertFalse(view.takesTouchAt(800f, 240f)) + touch(MotionEvent.ACTION_DOWN, 1 to (x to y)) + touch(MotionEvent.ACTION_UP, 1 to (x to y)) + assertEquals(listOf("KEYBOARD:PRIMARY", "KEYBOARD:PRIMARY"), events) + } + + @Test fun hidingTheViewReleasesLatchedKeys() { + val controller = Robolectric.buildActivity(ComponentActivity::class.java).setup() + try { + controller.get().setContentView(view) + show(InputSurfaceElement(InputControl.A, .7f, .3f, .2f, .2f, "latch", toggle = true)) + touch(MotionEvent.ACTION_DOWN, 1 to (800f to 240f)) + touch(MotionEvent.ACTION_UP, 1 to (800f to 240f)) + assertEquals(listOf("key 42 true"), events) + view.visibility = View.GONE + assertEquals(listOf("key 42 true", "key 42 false"), events) + } finally { controller.pause().stop().destroy() } + } + + @Test fun onlyPanelsVibrateOnPress() { + touch(MotionEvent.ACTION_DOWN, 1 to (800f to 240f)) + assertTrue(state().isDown(PadState.A)) + assertNotEquals(HapticFeedbackConstants.VIRTUAL_KEY, shadowOf(button(view)).lastHapticFeedbackPerformed()) + touch(MotionEvent.ACTION_UP, 1 to (800f to 240f)) + val panel = InputSurfaceView(context, bridge, listOf(a)) + measure(panel) + touch(MotionEvent.ACTION_DOWN, 1 to (800f to 240f), target = panel) + assertEquals(HapticFeedbackConstants.VIRTUAL_KEY, shadowOf(button(panel)).lastHapticFeedbackPerformed()) + touch(MotionEvent.ACTION_CANCEL, 1 to (800f to 240f), target = panel) + } + + @Test fun classicSticksTravelTheirFullRadius() { + show(stick.copy(buttonShape = InputButtonShape.CIRCLE)) + touch(MotionEvent.ACTION_DOWN, 1 to (200f to 300f)) + touch(MotionEvent.ACTION_MOVE, 1 to (250f to 300f)) + assertEquals(.5f, state().leftX, .01f) + touch(MotionEvent.ACTION_MOVE, 1 to (300f to 300f)) + assertEquals(1f, state().leftX, .01f) + touch(MotionEvent.ACTION_UP, 1 to (300f to 300f)) + } + + @Test fun buttonsAcceptTouchesJustOutsideTheirEdge() { + show(InputSurfaceElement(InputControl.A, .7f, .3f, .1f, 1f / 6f, buttonShape = InputButtonShape.CIRCLE), + InputSurfaceElement(InputControl.B, .3f, .3f, .2f, .1f, buttonShape = InputButtonShape.PILL), + InputSurfaceElement(InputControl.X, .3f, .6f, .2f, .1f)) + assertTrue(view.takesTouchAt(750f, 285f)) + assertTrue(view.takesTouchAt(805f, 230f)) + assertFalse(view.takesTouchAt(750f, 300f)) + assertFalse(view.takesTouchAt(805f, 285f)) + assertTrue(view.takesTouchAt(400f, 245f)) + assertFalse(view.takesTouchAt(400f, 255f)) + assertFalse(view.takesTouchAt(400f, 359f)) + assertTrue(view.takesTouchAt(400f, 361f)) + touch(MotionEvent.ACTION_DOWN, 1 to (750f to 285f)) + assertTrue(state().isDown(PadState.A)) + touch(MotionEvent.ACTION_UP, 1 to (750f to 285f)) + } + + @Test fun adaptiveSticksCaptureAroundThemAndTheLeftOneToTheEdge() { + val left = InputSurfaceElement(InputControl.LS, .3f, .3f, .2f, .4f, hideWhenIdle = true) + val right = InputSurfaceElement(InputControl.RS, .6f, .3f, .2f, .4f, hideWhenIdle = true) + show(left, right) + assertTrue(view.takesTouchAt(10f, 300f)) + assertTrue(view.takesTouchAt(10f, 390f)) + assertFalse(view.takesTouchAt(10f, 420f)) + assertTrue(view.takesTouchAt(795f, 300f)) + assertFalse(view.takesTouchAt(815f, 300f)) + val missed = mutableListOf() + view.onTouchMiss = { missed += it.getPointerId(0) } + touch(MotionEvent.ACTION_DOWN, 1 to (50f to 300f)) + touch(MotionEvent.ACTION_POINTER_DOWN or (1 shl MotionEvent.ACTION_POINTER_INDEX_SHIFT), 1 to (50f to 300f), 2 to (60f to 310f)) + assertEquals(listOf(2), missed) + touch(MotionEvent.ACTION_MOVE, 1 to (150f to 300f), 2 to (60f to 310f)) + assertTrue(state().leftX > .9f) + show(left.copy(hideWhenIdle = false), right.copy(hideWhenIdle = false)) + assertFalse(view.takesTouchAt(10f, 300f)) + assertFalse(view.takesTouchAt(815f, 300f)) + } + + private fun element(root: View, id: String): View = descendants(root).first { (it.tag as? InputSurfaceElement)?.id == id } + private fun centre(target: View): Pair { + var x = target.width / 2f + var y = target.height / 2f + var at: View = target + while (at !== view) { x += at.left; y += at.top; at = at.parent as View } + return x to y + } + private fun button(root: View): View = descendants(root).first { it.javaClass.simpleName == "HoldButton" } + private fun descendants(root: View): Sequence = sequence { + yield(root) + if (root is ViewGroup) for (i in 0 until root.childCount) yieldAll(descendants(root.getChildAt(i))) + } + + private fun touch(action: Int, vararg pointers: Pair>, flags: Int = 0, + tool: Int = MotionEvent.TOOL_TYPE_FINGER, target: View = view): Boolean { + val properties = pointers.map { (id, _) -> MotionEvent.PointerProperties().apply { this.id = id; toolType = tool } }.toTypedArray() + val coords = pointers.map { (_, position) -> MotionEvent.PointerCoords().apply { x = position.first; y = position.second; pressure = 1f; size = 1f } }.toTypedArray() + val event = MotionEvent.obtain(1000L, time++, action, pointers.size, properties, coords, 0, 0, 1f, 1f, 0, 0, 0, flags) + return try { target.dispatchTouchEvent(event) } finally { event.recycle() } + } +} diff --git a/app/src/test/java/com/droiddeck/launcher/ui/InputUiLayoutTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/InputUiLayoutTest.kt index 2715af711..9f28c9ccc 100644 --- a/app/src/test/java/com/droiddeck/launcher/ui/InputUiLayoutTest.kt +++ b/app/src/test/java/com/droiddeck/launcher/ui/InputUiLayoutTest.kt @@ -110,7 +110,6 @@ class InputUiLayoutTest { compose.onNodeWithText(context.getString(R.string.setup_tab_controller)).performClick() compose.onNodeWithTag("input-profile-game").assertIsDisplayed() compose.onNodeWithText(context.getString(R.string.ctrl_layout)).assertDoesNotExist() - compose.onNodeWithText(context.getString(R.string.ctrl_configure)).assertDoesNotExist() screenshot("controller-tab") compose.onNodeWithTag("input-profile-game").performClick() compose.onNodeWithTag("input-part-physical").performScrollTo().performClick() diff --git a/app/src/test/java/com/droiddeck/launcher/ui/SecondScreenBarTest.kt b/app/src/test/java/com/droiddeck/launcher/ui/SecondScreenBarTest.kt new file mode 100644 index 000000000..8a16a55e0 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/ui/SecondScreenBarTest.kt @@ -0,0 +1,229 @@ +package com.droiddeck.launcher.ui + +import android.hardware.display.DisplayManager +import android.view.Display +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.currentComposer +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.droiddeck.launcher.R +import com.droiddeck.launcher.SessionActivity +import com.droiddeck.launcher.input.DeckPanel +import com.droiddeck.launcher.input.InputLibraries +import com.droiddeck.launcher.input.InputSurfaceElement +import com.droiddeck.launcher.input.SecondScreenMode +import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.session.SessionService +import com.droiddeck.launcher.session.SessionState +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode +import org.robolectric.util.ReflectionHelpers + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [28], qualifiers = "w800dp-h600dp") +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class SecondScreenBarTest { + @get:Rule val compose = createRobolectricComposeRule() + private val context get() = RuntimeEnvironment.getApplication() + private var previousMode = "" + private var previousDeckPad = false + + @Before fun setUp() { + previousMode = SessionState.mode + previousDeckPad = SessionState.deckPad + context.getSharedPreferences("input-profiles", android.content.Context.MODE_PRIVATE).edit().clear().commit() + SessionPrefs.setDeckPanel(context, DeckPanel.BOTH) + } + + @After fun tearDown() { + SessionState.mode = previousMode + SessionState.deckPad = previousDeckPad + } + + private fun presentation(swap: (() -> Unit)? = {}): SecondScreenPresentation { + val activity = Robolectric.buildActivity(SessionActivity::class.java).get() + val display = activity.getSystemService(DisplayManager::class.java).getDisplay(Display.DEFAULT_DISPLAY) + return SecondScreenPresentation(activity, display, {}, {}, {}, onMenu = {}, onSwap = swap) + } + + private fun descendants(view: View): Sequence = sequence { + yield(view) + if (view is ViewGroup) for (index in 0 until view.childCount) yieldAll(descendants(view.getChildAt(index))) + } + + private fun rootChildren(panel: SecondScreenPresentation): List { + val root: ViewGroup = ReflectionHelpers.getField(panel, "root") + return (0 until root.childCount).map { root.getChildAt(it) } + } + + @Test fun trackpadPanelHasTheBarWithTheKeyboardToggleState() { + SessionState.mode = SessionService.MODE_STEAM + val panel = presentation() + try { + panel.show() + panel.showMode(SecondScreenMode.KEYBOARD_TRACKPAD) + val children = rootChildren(panel) + assertTrue(children[0] is ComposeView) + assertTrue(children[1] is InputSurfaceView) + val bar = panel.barModel(SecondScreenMode.KEYBOARD_TRACKPAD)!! + assertEquals(context.getString(R.string.second_mode_keyboard_trackpad), bar.title) + assertNotNull(bar.onClose); assertNotNull(bar.onMenu); assertNotNull(bar.onSteam); assertNotNull(bar.onQam) + assertNull(bar.tabs) + val keyboard = bar.extras.single() + assertEquals(context.getString(R.string.second_show_keyboard), keyboard.description) + assertFalse(keyboard.active) + ReflectionHelpers.getField>(panel, "keyboardShown").value = true + val shown = panel.barModel(SecondScreenMode.KEYBOARD_TRACKPAD)!!.extras.single() + assertEquals(context.getString(R.string.second_hide_keyboard), shown.description) + assertTrue(shown.active) + } finally { panel.closeControls() } + } + + @Test fun deckPanelHasTheBarWithSwapAndTabsAndProfileControlsStayFullScreen() { + SessionState.mode = SessionService.MODE_STEAM + SessionState.deckPad = true + var swaps = 0 + val panel = presentation { swaps++ } + try { + panel.show() + panel.showMode(SecondScreenMode.DECK_CONTROLS) + assertTrue(rootChildren(panel)[0] is ComposeView) + val bar = panel.barModel(SecondScreenMode.DECK_CONTROLS)!! + assertEquals(context.getString(R.string.second_mode_deck_controls), bar.title) + assertEquals(listOf(context.getString(R.string.swap_screens)), bar.extras.map { it.description }) + bar.extras.single().onClick() + assertEquals(1, swaps) + assertEquals(DeckPanel.entries.map { context.getString(it.label) }, bar.tabs!!.labels) + assertEquals(DeckPanel.BOTH.ordinal, bar.tabs!!.selected) + panel.showMode(SecondScreenMode.CUSTOM_CONTROLS) + assertNull(panel.barModel(SecondScreenMode.CUSTOM_CONTROLS)) + assertTrue(rootChildren(panel)[0] is InputSurfaceView) + } finally { panel.closeControls() } + val noSwap = presentation(swap = null) + try { + noSwap.show() + noSwap.showMode(SecondScreenMode.DECK_CONTROLS) + assertTrue(noSwap.barModel(SecondScreenMode.DECK_CONTROLS)!!.extras.isEmpty()) + } finally { noSwap.closeControls() } + } + + @Test fun nonSteamSessionsHideSteamAndQuickAccess() { + for (mode in listOf(SessionService.MODE_DESKTOP, SessionService.MODE_RUN)) { + SessionState.mode = mode + SessionState.deckPad = true + val panel = presentation() + try { + panel.show() + for (panelMode in listOf(SecondScreenMode.KEYBOARD_TRACKPAD, SecondScreenMode.DECK_CONTROLS)) { + panel.showMode(panelMode) + val bar = panel.barModel(panelMode)!! + assertNull(bar.onSteam); assertNull(bar.onQam) + assertNotNull(bar.onClose); assertNotNull(bar.onMenu) + } + } finally { panel.closeControls() } + } + val stock = InputLibraries.read(context).layouts.filter { it.id == InputLibraries.DECK || it.id == InputLibraries.TRACKPAD } + assertTrue(stock.all { layout -> layout.elements.none { it.id.startsWith("command:") } }) + } + + @Test fun deckTabsSwitchTheLayoutAndAreRemembered() { + SessionState.mode = SessionService.MODE_STEAM + SessionState.deckPad = true + val library = InputLibraries.read(context) + fun shown(panel: SecondScreenPresentation): List { + val controls = descendants(panel.window!!.decorView).filterIsInstance().single() + return descendants(controls).mapNotNull { it.tag as? InputSurfaceElement }.toList() + } + val first = presentation() + try { + first.show() + first.showMode(SecondScreenMode.DECK_CONTROLS) + assertEquals(library.layouts.single { it.id == DeckPanel.BOTH.layout }.elements, shown(first)) + val controls = descendants(first.window!!.decorView).filterIsInstance().single() + for (panel in listOf(DeckPanel.GRIPS, DeckPanel.LEFT, DeckPanel.RIGHT, DeckPanel.GAMEPAD)) { + first.barModel(SecondScreenMode.DECK_CONTROLS)!!.tabs!!.onSelect(panel.ordinal) + assertEquals(panel, SessionPrefs.deckPanel(context)) + assertEquals(panel.ordinal, first.barModel(SecondScreenMode.DECK_CONTROLS)!!.tabs!!.selected) + assertSame(controls, descendants(first.window!!.decorView).filterIsInstance().single()) + assertEquals(library.layouts.single { it.id == panel.layout }.elements, shown(first)) + } + first.barModel(SecondScreenMode.DECK_CONTROLS)!!.tabs!!.onSelect(DeckPanel.GRIPS.ordinal) + } finally { first.closeControls() } + val second = presentation() + try { + second.show() + second.showMode(SecondScreenMode.DECK_CONTROLS) + assertEquals(DeckPanel.GRIPS.ordinal, second.barModel(SecondScreenMode.DECK_CONTROLS)!!.tabs!!.selected) + assertEquals(library.layouts.single { it.id == DeckPanel.GRIPS.layout }.elements, shown(second)) + second.showMode(SecondScreenMode.KEYBOARD_TRACKPAD) + assertEquals(library.layouts.single { it.id == InputLibraries.TRACKPAD }.elements, shown(second)) + } finally { second.closeControls() } + assertEquals(DeckPanel.GRIPS.bindings, SecondScreenMode.DECK_CONTROLS.panel(context)) + assertEquals(SecondScreenMode.KEYBOARD_TRACKPAD.bindings, SecondScreenMode.KEYBOARD_TRACKPAD.panel(context)) + } + + @Test fun barTabRowSelectsTabs() { + var picked = -1 + compose.setContent { + DroidDeckTheme { + BottomScreenBar(BottomBarModel("Deck", {}, {}, null, null, tabs = deckPanelTabs(context, DeckPanel.BOTH) { picked = it.ordinal })) + } + } + for (panel in DeckPanel.entries) compose.onNodeWithText(context.getString(panel.label)).assertIsDisplayed() + compose.onNodeWithTag("tab-${DeckPanel.LEFT.ordinal}").performClick() + compose.runOnIdle { assertEquals(DeckPanel.LEFT.ordinal, picked) } + } + + @Test fun swappedTopScreenShowsTheTerminalWithItsBar() { + SessionState.mode = SessionService.MODE_STEAM + val activity = Robolectric.buildActivity(SessionActivity::class.java).get() + ReflectionHelpers.getField>(activity, "secondScreenMode\$delegate").value = SecondScreenMode.TERMINAL + val method = SessionActivity::class.java.getDeclaredMethod("SwappedTopPanel", androidx.compose.runtime.Composer::class.java, Int::class.javaPrimitiveType) + method.isAccessible = true + compose.setContent { DroidDeckTheme { method.invoke(activity, currentComposer, 0) } } + compose.onNodeWithText(context.getString(R.string.second_mode_terminal)).assertIsDisplayed() + compose.onNodeWithContentDescription(context.getString(R.string.drawer_keyboard)).assertIsDisplayed() + compose.onNodeWithContentDescription(context.getString(R.string.swap_screens)).assertIsDisplayed() + compose.onNodeWithTag("bottom-bar-steam").assertIsDisplayed() + } + + @Test fun swappedTopScreenDeckPanelHasTabs() { + SessionState.mode = SessionService.MODE_DESKTOP + SessionState.deckPad = true + val activity = Robolectric.buildActivity(SessionActivity::class.java).get() + ReflectionHelpers.getField>(activity, "secondScreenMode\$delegate").value = SecondScreenMode.DECK_CONTROLS + val method = SessionActivity::class.java.getDeclaredMethod("SwappedTopPanel", androidx.compose.runtime.Composer::class.java, Int::class.javaPrimitiveType) + method.isAccessible = true + compose.setContent { DroidDeckTheme { method.invoke(activity, currentComposer, 0) } } + compose.onNodeWithText(context.getString(R.string.second_mode_deck_controls)).assertIsDisplayed() + compose.onNodeWithTag("bottom-bar-tabs").assertIsDisplayed() + compose.onNodeWithTag("bottom-bar-steam").assertDoesNotExist() + compose.onNodeWithTag("bottom-bar-close").assertIsDisplayed() + } + + @Test fun unfoldedControlsGetTheSessionBar() { + SessionState.mode = SessionService.MODE_STEAM + val activity = Robolectric.buildActivity(SessionActivity::class.java).get() + assertTrue(ReflectionHelpers.callInstanceMethod(activity, "unfoldedBar") is ComposeView) + } +}